repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

commit
0b5edcf
parent
c5a0909
author
Eric Bower
date
2023-08-03 20:52:36 +0000 UTC
feat: pgs.sh (#28)

A zero-dependency static site hosting service for hackers.

Features:
- Terminal workflow
- No client-side installation required to fully manage static sites
- Distinct static sites as "projects"
- Unlimited projects created on-the-fly (no need to create a project
  first)
- Deploy via `rsync -a . erock@pgs.sh:/myproject`
- Symbolic linking from one project to another (to support
  promotions/rollbacks)
- HTTPS for all projects `https://erock-myproject.pgs.sh`
- Custom domains for projects (managed simply by `TXT` records)
67 files changed,  +2970, -310
M go.mod
M go.sum
M .env.example
+21, -0
 1@@ -113,3 +113,24 @@ FEEDS_CUSTOMDOMAINS=1
 2 FEEDS_PROTOCOL=http
 3 FEEDS_ALLOW_REGISTER=1
 4 FEEDS_DEBUG=1
 5+
 6+PGS_V4=
 7+PGS_V6=
 8+PGS_HTTP_V4=$PGS_V4:80
 9+PGS_HTTP_V6=[$PGS_V6]:80
10+PGS_HTTPS_V4=$PGS_V4:443
11+PGS_HTTPS_V6=[$PGS_V6]:443
12+PGS_SSH_V4=$PGS_V4:22
13+PGS_SSH_V6=[$PGS_V6]:22
14+PGS_HOST=
15+PGS_SSH_PORT=2222
16+PGS_WEB_PORT=3000
17+PGS_PROM_PORT=9222
18+PGS_DOMAIN=pgs.dev.pico.sh:3005
19+PGS_EMAIL=hello@pico.sh
20+PGS_SUBDOMAINS=1
21+PGS_CUSTOMDOMAINS=1
22+PGS_PROTOCOL=http
23+PGS_ALLOW_REGISTER=1
24+PGS_STORAGE_DIR=.storage
25+PGS_DEBUG=1
M .github/workflows/build.yml
+6, -0
 1@@ -84,6 +84,12 @@ jobs:
 2         app: imgs
 3         platforms: ${{ env.PLATFORMS }}
 4         registry: ${{ env.REGISTRY }}
 5+    - name: Run docker build for pgs
 6+      uses: ./.github/actions/build
 7+      with:
 8+        app: pgs
 9+        platforms: ${{ env.PLATFORMS }}
10+        registry: ${{ env.REGISTRY }}
11     - name: Run docker build for feeds
12       uses: ./.github/actions/build
13       with:
M .gitignore
+1, -0
1@@ -12,3 +12,4 @@ ssh_data
2 .storage
3 __debug_bin
4 .bin
5+tmp/
M Makefile
+17, -3
 1@@ -14,6 +14,7 @@ css:
 2 	cp ./smol.css pastes/public/main.css
 3 	cp ./smol.css imgs/public/main.css
 4 	cp ./smol.css feeds/public/main.css
 5+	cp ./smol.css pgs/public/main.css
 6 
 7 	cp ./syntax.css pastes/public/syntax.css
 8 	cp ./syntax.css prose/public/syntax.css
 9@@ -37,7 +38,7 @@ bp-%: bp-setup
10 	$(DOCKER_BUILDX_BUILD) --build-arg "APP=$*" -t "ghcr.io/picosh/pico/$*-web:$(DOCKER_TAG)" --target release-web .
11 .PHONY: bp-%
12 
13-bp-all: bp-prose bp-lists bp-pastes bp-imgs bp-feeds
14+bp-all: bp-prose bp-lists bp-pastes bp-imgs bp-feeds bp-pgs
15 .PHONY: bp-all
16 
17 build-%:
18@@ -45,9 +46,20 @@ build-%:
19 	go build -o "build/$*-ssh" "./cmd/$*/ssh"
20 .PHONY: build-%
21 
22-build: build-prose build-lists build-pastes build-imgs build-feeds
23+build: build-prose build-lists build-pastes build-imgs build-feeds build-pgs
24 .PHONY: build
25 
26+pgs-static:
27+	go build -o "build/pgs-static" "./cmd/pgs/static"
28+.PHONY: pgs-static
29+
30+pgs-site:
31+	rm -rf tmp
32+	mkdir tmp
33+	./build/pgs-static -out ./tmp
34+	cp ./pgs/public/* ./tmp
35+.PHONY: pgs-site
36+
37 format:
38 	go fmt ./...
39 .PHONY: format
40@@ -77,10 +89,12 @@ migrate:
41 	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20221112_add_feeds_space.sql
42 	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20230310_add_aliases_table.sql
43 	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20230326_add_feed_items.sql
44+	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20230707_add_projects_table.sql
45 .PHONY: migrate
46 
47 latest:
48-	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20230326_add_feed_items.sql
49+	$(DOCKER_CMD) cp ./sql/migrations/20230707_add_projects_table.sql $(DB_CONTAINER):/tmp
50+	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) -f /tmp/20230707_add_projects_table.sql
51 .PHONY: latest
52 
53 psql:
M cmd/feeds/ssh/main.go
+1, -1
 1@@ -16,8 +16,8 @@ import (
 2 	"github.com/picosh/pico/db/postgres"
 3 	"github.com/picosh/pico/feeds"
 4 	"github.com/picosh/pico/filehandlers"
 5-	"github.com/picosh/pico/imgs/storage"
 6 	"github.com/picosh/pico/shared"
 7+	"github.com/picosh/pico/shared/storage"
 8 	"github.com/picosh/pico/wish/cms"
 9 	"github.com/picosh/pico/wish/list"
10 	"github.com/picosh/pico/wish/pipe"
M cmd/imgs/ssh/main.go
+2, -2
 1@@ -16,8 +16,8 @@ import (
 2 	"github.com/picosh/pico/db/postgres"
 3 	uploadimgs "github.com/picosh/pico/filehandlers/imgs"
 4 	"github.com/picosh/pico/imgs"
 5-	"github.com/picosh/pico/imgs/storage"
 6 	"github.com/picosh/pico/shared"
 7+	"github.com/picosh/pico/shared/storage"
 8 	"github.com/picosh/pico/wish/cms"
 9 	"github.com/picosh/pico/wish/list"
10 	"github.com/picosh/pico/wish/pipe"
11@@ -95,7 +95,7 @@ func main() {
12 		withProxy(
13 			cfg,
14 			handler,
15-			promwish.Middleware(fmt.Sprintf("%s:%s", host, promPort), "pastes-ssh"),
16+			promwish.Middleware(fmt.Sprintf("%s:%s", host, promPort), "imgs-ssh"),
17 		),
18 	)
19 	if err != nil {
M cmd/lists/ssh/main.go
+1, -1
 1@@ -15,9 +15,9 @@ import (
 2 	"github.com/gliderlabs/ssh"
 3 	"github.com/picosh/pico/db/postgres"
 4 	"github.com/picosh/pico/filehandlers"
 5-	"github.com/picosh/pico/imgs/storage"
 6 	"github.com/picosh/pico/lists"
 7 	"github.com/picosh/pico/shared"
 8+	"github.com/picosh/pico/shared/storage"
 9 	"github.com/picosh/pico/wish/cms"
10 	"github.com/picosh/pico/wish/list"
11 	"github.com/picosh/pico/wish/pipe"
M cmd/pastes/ssh/main.go
+1, -1
 1@@ -15,9 +15,9 @@ import (
 2 	"github.com/gliderlabs/ssh"
 3 	"github.com/picosh/pico/db/postgres"
 4 	"github.com/picosh/pico/filehandlers"
 5-	"github.com/picosh/pico/imgs/storage"
 6 	"github.com/picosh/pico/pastes"
 7 	"github.com/picosh/pico/shared"
 8+	"github.com/picosh/pico/shared/storage"
 9 	"github.com/picosh/pico/wish/cms"
10 	"github.com/picosh/pico/wish/list"
11 	"github.com/picosh/pico/wish/pipe"
A cmd/pgs/ssh/main.go
+120, -0
  1@@ -0,0 +1,120 @@
  2+package main
  3+
  4+import (
  5+	"context"
  6+	"fmt"
  7+	"os"
  8+	"os/signal"
  9+	"syscall"
 10+	"time"
 11+
 12+	"github.com/charmbracelet/promwish"
 13+	"github.com/charmbracelet/wish"
 14+	bm "github.com/charmbracelet/wish/bubbletea"
 15+	lm "github.com/charmbracelet/wish/logging"
 16+	"github.com/gliderlabs/ssh"
 17+	"github.com/picosh/pico/db/postgres"
 18+	uploadassets "github.com/picosh/pico/filehandlers/assets"
 19+	"github.com/picosh/pico/pgs"
 20+	"github.com/picosh/pico/shared"
 21+	"github.com/picosh/pico/shared/storage"
 22+	"github.com/picosh/pico/wish/list"
 23+	"github.com/picosh/pico/wish/pipe"
 24+	"github.com/picosh/pico/wish/proxy"
 25+	"github.com/picosh/pico/wish/send/auth"
 26+	wishrsync "github.com/picosh/pico/wish/send/rsync"
 27+	"github.com/picosh/pico/wish/send/scp"
 28+	"github.com/picosh/pico/wish/send/sftp"
 29+)
 30+
 31+type SSHServer struct{}
 32+
 33+func (me *SSHServer) authHandler(ctx ssh.Context, key ssh.PublicKey) bool {
 34+	return true
 35+}
 36+
 37+func createRouter(cfg *shared.ConfigSite, handler *uploadassets.UploadAssetHandler) proxy.Router {
 38+	return func(sh ssh.Handler, s ssh.Session) []wish.Middleware {
 39+		return []wish.Middleware{
 40+			pipe.Middleware(handler, ""),
 41+			list.Middleware(handler),
 42+			pgs.WishMiddleware(handler.DBPool, handler.Storage, handler.Cfg.Logger),
 43+			scp.Middleware(handler),
 44+			wishrsync.Middleware(handler),
 45+			auth.Middleware(handler),
 46+			bm.Middleware(pgs.CmsMiddleware(&cfg.ConfigCms, cfg)),
 47+			lm.Middleware(),
 48+		}
 49+	}
 50+}
 51+
 52+func withProxy(cfg *shared.ConfigSite, handler *uploadassets.UploadAssetHandler, otherMiddleware ...wish.Middleware) ssh.Option {
 53+	return func(server *ssh.Server) error {
 54+		err := sftp.SSHOption(handler)(server)
 55+		if err != nil {
 56+			return err
 57+		}
 58+
 59+		return proxy.WithProxy(createRouter(cfg, handler), otherMiddleware...)(server)
 60+	}
 61+}
 62+
 63+func main() {
 64+	host := shared.GetEnv("PGS_HOST", "0.0.0.0")
 65+	port := shared.GetEnv("PGS_SSH_PORT", "2222")
 66+	promPort := shared.GetEnv("PGS_PROM_PORT", "9222")
 67+	cfg := pgs.NewConfigSite()
 68+	logger := cfg.Logger
 69+	dbh := postgres.NewDB(&cfg.ConfigCms)
 70+	defer dbh.Close()
 71+
 72+	var st storage.ObjectStorage
 73+	var err error
 74+	if cfg.MinioURL == "" {
 75+		st, err = storage.NewStorageFS(cfg.StorageDir)
 76+	} else {
 77+		st, err = storage.NewStorageMinio(cfg.MinioURL, cfg.MinioUser, cfg.MinioPass)
 78+	}
 79+
 80+	if err != nil {
 81+		logger.Fatal(err)
 82+	}
 83+
 84+	handler := uploadassets.NewUploadAssetHandler(
 85+		dbh,
 86+		cfg,
 87+		st,
 88+	)
 89+
 90+	sshServer := &SSHServer{}
 91+	s, err := wish.NewServer(
 92+		wish.WithAddress(fmt.Sprintf("%s:%s", host, port)),
 93+		wish.WithHostKeyPath("ssh_data/term_info_ed25519"),
 94+		wish.WithPublicKeyAuth(sshServer.authHandler),
 95+		withProxy(
 96+			cfg,
 97+			handler,
 98+			promwish.Middleware(fmt.Sprintf("%s:%s", host, promPort), "pgs-ssh"),
 99+		),
100+	)
101+	if err != nil {
102+		logger.Fatal(err)
103+	}
104+
105+	done := make(chan os.Signal, 1)
106+	signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
107+	logger.Infof("Starting SSH server on %s:%s", host, port)
108+	go func() {
109+		if err = s.ListenAndServe(); err != nil {
110+			logger.Fatal(err)
111+		}
112+	}()
113+
114+	<-done
115+	logger.Info("Stopping SSH server")
116+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
117+	defer func() { cancel() }()
118+	if err := s.Shutdown(ctx); err != nil {
119+		logger.Fatal(err)
120+	}
121+}
A cmd/pgs/static/main.go
+17, -0
 1@@ -0,0 +1,17 @@
 2+package main
 3+
 4+import (
 5+	"flag"
 6+
 7+	"github.com/picosh/pico/pgs"
 8+)
 9+
10+func main() {
11+	out := flag.String("out", "./public", "output folder for static assets")
12+	flag.Parse()
13+	cfg := pgs.NewConfigSite()
14+	err := pgs.GenStaticSite(*out, cfg)
15+	if err != nil {
16+		panic(err)
17+	}
18+}
A cmd/pgs/web/main.go
+7, -0
1@@ -0,0 +1,7 @@
2+package main
3+
4+import "github.com/picosh/pico/pgs"
5+
6+func main() {
7+	pgs.StartApiServer()
8+}
M cmd/prose/ssh/main.go
+1, -1
 1@@ -15,9 +15,9 @@ import (
 2 	"github.com/gliderlabs/ssh"
 3 	"github.com/picosh/pico/db/postgres"
 4 	"github.com/picosh/pico/filehandlers"
 5-	"github.com/picosh/pico/imgs/storage"
 6 	"github.com/picosh/pico/prose"
 7 	"github.com/picosh/pico/shared"
 8+	"github.com/picosh/pico/shared/storage"
 9 	"github.com/picosh/pico/wish/cms"
10 	"github.com/picosh/pico/wish/list"
11 	"github.com/picosh/pico/wish/pipe"
M cmd/scripts/webp/webp.go
+1, -1
 1@@ -7,8 +7,8 @@ import (
 2 	"github.com/picosh/pico/db"
 3 	"github.com/picosh/pico/db/postgres"
 4 	"github.com/picosh/pico/imgs"
 5-	"github.com/picosh/pico/imgs/storage"
 6 	"github.com/picosh/pico/shared"
 7+	"github.com/picosh/pico/shared/storage"
 8 )
 9 
10 func main() {
M db/db.go
+13, -0
 1@@ -31,6 +31,13 @@ type PostData struct {
 2 	LastDigest *time.Time `json:"last_digest"`
 3 }
 4 
 5+type Project struct {
 6+	ID         string `json:"id"`
 7+	UserID     string `json:"user_id"`
 8+	Name       string `json:"name"`
 9+	ProjectDir string `json:"project_dir"`
10+}
11+
12 // Make the Attrs struct implement the driver.Valuer interface. This method
13 // simply returns the JSON-encoded representation of the struct.
14 func (p PostData) Value() (driver.Value, error) {
15@@ -199,5 +206,11 @@ type DB interface {
16 	InsertFeedItems(postID string, items []*FeedItem) error
17 	FindFeedItemsByPostID(postID string) ([]*FeedItem, error)
18 
19+	InsertProject(userID, name, projectDir string) (string, error)
20+	UpdateProject(projectID, projectDir string) error
21+	RemoveProject(projectID string) error
22+	FindProjectByName(userID, name string) (*Project, error)
23+	FindProjectsByUser(userID string) ([]*Project, error)
24+
25 	Close() error
26 }
M db/postgres/storage.go
+73, -0
 1@@ -242,6 +242,12 @@ const (
 2 	sqlRemovePosts         = `DELETE FROM posts WHERE id = ANY($1::uuid[])`
 3 	sqlRemoveKeys          = `DELETE FROM public_keys WHERE id = ANY($1::uuid[])`
 4 	sqlRemoveUsers         = `DELETE FROM app_users WHERE id = ANY($1::uuid[])`
 5+
 6+	sqlInsertProject      = `INSERT INTO projects (user_id, name, project_dir) VALUES ($1, $2, $3) RETURNING id;`
 7+	sqlFindProjectByName  = `SELECT id, user_id, name, project_dir FROM projects WHERE user_id = $1 AND name = $2;`
 8+	sqlFindProjectsByUser = `SELECT id, user_id, name, project_dir FROM projects WHERE user_id = $1;`
 9+	sqlUpdateProject      = `UPDATE projects SET project_dir = $1, updated_at = $2 WHERE id = $3;`
10+	sqlRemoveProject      = `DELETE FROM projects WHERE id = $1;`
11 )
12 
13 type PsqlDB struct {
14@@ -1173,3 +1179,70 @@ func (me *PsqlDB) FindFeedItemsByPostID(postID string) ([]*db.FeedItem, error) {
15 
16 	return items, nil
17 }
18+
19+func (me *PsqlDB) InsertProject(userID, name, projectDir string) (string, error) {
20+	var id string
21+	err := me.Db.QueryRow(sqlInsertProject, userID, name, projectDir).Scan(&id)
22+	if err != nil {
23+		return "", err
24+	}
25+	return id, nil
26+}
27+
28+func (me *PsqlDB) UpdateProject(projectID, projectDir string) error {
29+	_, err := me.Db.Exec(
30+		sqlUpdateProject,
31+		projectDir,
32+		time.Now(),
33+		projectID,
34+	)
35+	return err
36+}
37+func (me *PsqlDB) RemoveProject(projectID string) error {
38+	_, err := me.Db.Exec(sqlRemoveProject, projectID)
39+	return err
40+}
41+
42+func (me *PsqlDB) FindProjectByName(userID, name string) (*db.Project, error) {
43+	project := &db.Project{}
44+	r := me.Db.QueryRow(sqlFindProjectByName, userID, name)
45+	err := r.Scan(
46+		&project.ID,
47+		&project.UserID,
48+		&project.Name,
49+		&project.ProjectDir,
50+	)
51+	if err != nil {
52+		return nil, err
53+	}
54+
55+	return project, nil
56+}
57+
58+func (me *PsqlDB) FindProjectsByUser(userID string) ([]*db.Project, error) {
59+	var projects []*db.Project
60+	rs, err := me.Db.Query(sqlFindProjectsByUser, userID)
61+	if err != nil {
62+		return nil, err
63+	}
64+	for rs.Next() {
65+		project := &db.Project{}
66+		err := rs.Scan(
67+			&project.ID,
68+			&project.UserID,
69+			&project.Name,
70+			&project.ProjectDir,
71+		)
72+		if err != nil {
73+			return nil, err
74+		}
75+
76+		projects = append(projects, project)
77+	}
78+
79+	if rs.Err() != nil {
80+		return nil, rs.Err()
81+	}
82+
83+	return projects, nil
84+}
M docker-compose.override.yml
+23, -0
 1@@ -94,6 +94,29 @@ services:
 2       - ./data/imgs-ssh/data:/app/ssh_data
 3     ports:
 4       - "2223:2222"
 5+  pgs-web:
 6+    build:
 7+      args:
 8+        APP: pgs
 9+      target: release-web
10+    env_file:
11+      - .env.example
12+    volumes:
13+      - ./data/pgs-storage/data:/app/.storage
14+    ports:
15+      - "3003:3000"
16+  pgs-ssh:
17+    build:
18+      args:
19+        APP: pgs
20+      target: release-ssh
21+    env_file:
22+      - .env.example
23+    volumes:
24+      - ./data/pgs-storage/data:/app/.storage
25+      - ./data/pgs-ssh/data:/app/ssh_data
26+    ports:
27+      - "2223:2222"
28   feeds-web:
29     build:
30       args:
M docker-compose.prod.yml
+51, -0
 1@@ -204,6 +204,51 @@ services:
 2     ports:
 3       - "${IMGS_SSH_V4:-22}:2222"
 4       - "${IMGS_SSH_V6:-[::1]:22}:2222"
 5+  pgs-caddy:
 6+    image: ghcr.io/picosh/pico/caddy:latest
 7+    restart: always
 8+    networks:
 9+      - pgs
10+    env_file:
11+      - .env.prod
12+    environment:
13+      APP_DOMAIN: ${PGS_DOMAIN:-pgs.sh}
14+      APP_EMAIL: ${PGS_EMAIL:-hello@pico.sh}
15+    volumes:
16+      - ./caddy/Caddyfile:/etc/caddy/Caddyfile
17+      - ./data/pgs-caddy/data:/data
18+      - ./data/pgs-caddy/config:/config
19+    ports:
20+      - "${PGS_HTTPS_V4:-443}:443"
21+      - "${PGS_HTTP_V4:-80}:80"
22+      - "${PGS_HTTPS_V6:-[::1]:443}:443"
23+      - "${PGS_HTTP_V6:-[::1]:80}:80"
24+    profiles:
25+      - pgs
26+      - caddy
27+      - all
28+  pgs-web:
29+    networks:
30+      pgs:
31+        aliases:
32+          - web
33+    env_file:
34+      - .env.prod
35+    volumes:
36+      - ./data/pgs-storage/data:/app/.storage
37+  pgs-ssh:
38+    networks:
39+      pgs:
40+        aliases:
41+          - ssh
42+    env_file:
43+      - .env.prod
44+    volumes:
45+      - ./data/pgs-storage/data:/app/.storage
46+      - ./data/pgs-ssh/data:/app/ssh_data
47+    ports:
48+      - "${PGS_SSH_V4:-22}:2222"
49+      - "${PGS_SSH_V6:-[::1]:22}:2222"
50   feeds-caddy:
51     image: ghcr.io/picosh/pico/caddy:latest
52     restart: always
53@@ -283,3 +328,9 @@ networks:
54     ipam:
55       config:
56         - subnet: 172.22.0.0/16
57+  pgs:
58+    driver_opts:
59+      com.docker.network.bridge.name: pgs
60+    ipam:
61+      config:
62+        - subnet: 172.23.0.0/16
M docker-compose.yml
+14, -0
 1@@ -69,6 +69,20 @@ services:
 2       - imgs
 3       - services
 4       - all
 5+  pgs-web:
 6+    image: ghcr.io/picosh/pico/pgs-web:latest
 7+    restart: always
 8+    profiles:
 9+      - pgs
10+      - services
11+      - all
12+  pgs-ssh:
13+    image: ghcr.io/picosh/pico/pgs-ssh:latest
14+    restart: always
15+    profiles:
16+      - pgs
17+      - services
18+      - all
19   feeds-web:
20     image: ghcr.io/picosh/pico/feeds-web:latest
21     restart: always
M feeds/api.go
+1, -1
 1@@ -7,8 +7,8 @@ import (
 2 
 3 	gocache "github.com/patrickmn/go-cache"
 4 	"github.com/picosh/pico/db/postgres"
 5-	"github.com/picosh/pico/imgs/storage"
 6 	"github.com/picosh/pico/shared"
 7+	"github.com/picosh/pico/shared/storage"
 8 )
 9 
10 func createStaticRoutes() []shared.Route {
A filehandlers/assets/asset.go
+77, -0
 1@@ -0,0 +1,77 @@
 2+package uploadassets
 3+
 4+import (
 5+	"bytes"
 6+	"fmt"
 7+	"path/filepath"
 8+	"strings"
 9+
10+	"github.com/picosh/pico/shared"
11+	"github.com/picosh/pico/shared/storage"
12+)
13+
14+func (h *UploadAssetHandler) validateAsset(data *FileData) (bool, error) {
15+	assetBucket := shared.GetAssetBucketName(data.User.ID)
16+	bucket, err := h.Storage.UpsertBucket(assetBucket)
17+	if err != nil {
18+		return false, err
19+	}
20+	totalFileSize, err := h.Storage.GetBucketQuota(bucket)
21+	if err != nil {
22+		return false, err
23+	}
24+
25+	fname := filepath.Base(data.Filepath)
26+	if int(data.Size) > maxAssetSize {
27+		return false, fmt.Errorf("ERROR: file (%s) has exceeded maximum file size (%d bytes)", fname, maxAssetSize)
28+	}
29+
30+	if totalFileSize+uint64(data.Size) > uint64(maxSize) {
31+		return false, fmt.Errorf("ERROR: user (%s) has exceeded (%d bytes) max (%d bytes)", data.User.Name, totalFileSize, maxSize)
32+	}
33+
34+	if !shared.IsExtAllowed(fname, h.Cfg.AllowedExt) {
35+		extStr := strings.Join(h.Cfg.AllowedExt, ",")
36+		err := fmt.Errorf(
37+			"ERROR: (%s) invalid file, format must be (%s), skipping",
38+			fname,
39+			extStr,
40+		)
41+		return false, err
42+	}
43+
44+	return true, nil
45+}
46+
47+func (h *UploadAssetHandler) writeAsset(data *FileData) error {
48+	valid, err := h.validateAsset(data)
49+	if !valid {
50+		return err
51+	}
52+
53+	assetBucket := shared.GetAssetBucketName(data.User.ID)
54+	assetFilename := shared.GetAssetFileName(data.FileEntry)
55+	bucket, err := h.Storage.UpsertBucket(assetBucket)
56+	if err != nil {
57+		return err
58+	}
59+
60+	if data.Size == 0 {
61+		err = h.Storage.DeleteFile(bucket, assetFilename)
62+		if err != nil {
63+			return err
64+		}
65+	} else {
66+		reader := bytes.NewReader(data.Text)
67+		_, err := h.Storage.PutFile(
68+			bucket,
69+			assetFilename,
70+			storage.NopReaderAtCloser(reader),
71+		)
72+		if err != nil {
73+			return err
74+		}
75+	}
76+
77+	return nil
78+}
A filehandlers/assets/handler.go
+206, -0
  1@@ -0,0 +1,206 @@
  2+package uploadassets
  3+
  4+import (
  5+	"encoding/binary"
  6+	"fmt"
  7+	"io"
  8+	"os"
  9+	"path/filepath"
 10+	"time"
 11+
 12+	"github.com/gliderlabs/ssh"
 13+	"github.com/picosh/pico/db"
 14+	"github.com/picosh/pico/shared"
 15+	"github.com/picosh/pico/shared/storage"
 16+	"github.com/picosh/pico/wish/cms/util"
 17+	"github.com/picosh/pico/wish/send/utils"
 18+)
 19+
 20+var KB = 1024
 21+var MB = KB * 1024
 22+var GB = MB * 1024
 23+var maxSize = 1 * GB
 24+var maxAssetSize = 50 * MB
 25+
 26+func bytesToGB(size int) float32 {
 27+	return (((float32(size) / 1024) / 1024) / 1024)
 28+}
 29+
 30+type ctxUserKey struct{}
 31+
 32+func getUser(s ssh.Session) (*db.User, error) {
 33+	user := s.Context().Value(ctxUserKey{}).(*db.User)
 34+	if user == nil {
 35+		return user, fmt.Errorf("user not set on `ssh.Context()` for connection")
 36+	}
 37+	return user, nil
 38+}
 39+
 40+type FileData struct {
 41+	*utils.FileEntry
 42+	Text []byte
 43+	User *db.User
 44+}
 45+
 46+type UploadAssetHandler struct {
 47+	DBPool  db.DB
 48+	Cfg     *shared.ConfigSite
 49+	Storage storage.ObjectStorage
 50+}
 51+
 52+func NewUploadAssetHandler(dbpool db.DB, cfg *shared.ConfigSite, storage storage.ObjectStorage) *UploadAssetHandler {
 53+	return &UploadAssetHandler{
 54+		DBPool:  dbpool,
 55+		Cfg:     cfg,
 56+		Storage: storage,
 57+	}
 58+}
 59+
 60+func (h *UploadAssetHandler) Read(s ssh.Session, entry *utils.FileEntry) (os.FileInfo, io.ReaderAt, error) {
 61+	user, err := getUser(s)
 62+	if err != nil {
 63+		return nil, nil, err
 64+	}
 65+
 66+	fileInfo := &utils.VirtualFile{
 67+		FName:    filepath.Base(entry.Filepath),
 68+		FIsDir:   false,
 69+		FSize:    int64(entry.Size),
 70+		FModTime: time.Unix(entry.Mtime, 0),
 71+	}
 72+
 73+	bucket, err := h.Storage.GetBucket(shared.GetAssetBucketName(user.ID))
 74+	if err != nil {
 75+		return nil, nil, err
 76+	}
 77+
 78+	fname := shared.GetAssetFileName(entry)
 79+	contents, err := h.Storage.GetFile(bucket, fname)
 80+	if err != nil {
 81+		return nil, nil, err
 82+	}
 83+
 84+	return fileInfo, contents, nil
 85+}
 86+
 87+func (h *UploadAssetHandler) List(s ssh.Session, fpath string) ([]os.FileInfo, error) {
 88+	var fileList []os.FileInfo
 89+	user, err := getUser(s)
 90+	if err != nil {
 91+		return fileList, err
 92+	}
 93+	cleanFilename := filepath.Base(fpath)
 94+	bucketName := shared.GetAssetBucketName(user.ID)
 95+	bucket, err := h.Storage.GetBucket(bucketName)
 96+	if err != nil {
 97+		return fileList, err
 98+	}
 99+
100+	if cleanFilename == "" || cleanFilename == "." {
101+		name := cleanFilename
102+		if name == "" {
103+			name = "/"
104+		}
105+
106+		info := &utils.VirtualFile{
107+			FName:  name,
108+			FIsDir: true,
109+		}
110+		fileList = append(fileList, info)
111+	} else {
112+		fileList, err = h.Storage.ListFiles(bucket, fpath)
113+		if err != nil {
114+			return fileList, err
115+		}
116+	}
117+
118+	return fileList, nil
119+}
120+
121+func (h *UploadAssetHandler) Validate(s ssh.Session) error {
122+	var err error
123+	key, err := util.KeyText(s)
124+	if err != nil {
125+		return fmt.Errorf("key not found")
126+	}
127+
128+	user, err := h.DBPool.FindUserForKey(s.User(), key)
129+	if err != nil {
130+		return err
131+	}
132+
133+	if user.Name == "" {
134+		return fmt.Errorf("must have username set")
135+	}
136+
137+	if !h.DBPool.HasFeatureForUser(user.ID, "pgs") {
138+		return fmt.Errorf("you do not have access to this service")
139+	}
140+
141+	s.Context().SetValue(ctxUserKey{}, user)
142+	h.Cfg.Logger.Infof("(%s) attempting to upload files to (%s)", user.Name, h.Cfg.Space)
143+	return nil
144+}
145+
146+func (h *UploadAssetHandler) Write(s ssh.Session, entry *utils.FileEntry) (string, error) {
147+	user, err := getUser(s)
148+	if err != nil {
149+		return "", err
150+	}
151+
152+	var origText []byte
153+	if b, err := io.ReadAll(entry.Reader); err == nil {
154+		origText = b
155+	}
156+	fileSize := binary.Size(origText)
157+	// TODO: hack for now until I figure out how to get correct
158+	// filesize from sftp,scp,rsync
159+	entry.Size = int64(fileSize)
160+
161+	data := &FileData{
162+		FileEntry: entry,
163+		User:      user,
164+		Text:      origText,
165+	}
166+	err = h.writeAsset(data)
167+	if err != nil {
168+		return "", err
169+	}
170+
171+	projectName := shared.GetProjectName(entry)
172+
173+	// find and create project
174+	_, err = h.DBPool.FindProjectByName(user.ID, projectName)
175+	if err != nil {
176+		_, err = h.DBPool.InsertProject(user.ID, projectName, projectName)
177+		if err != nil {
178+			return "", err
179+		}
180+	}
181+
182+	bucketName := shared.GetAssetBucketName(user.ID)
183+	bucket, err := h.Storage.UpsertBucket(bucketName)
184+	if err != nil {
185+		return "", err
186+	}
187+
188+	totalFileSize, err := h.Storage.GetBucketQuota(bucket)
189+	if err != nil {
190+		return "", err
191+	}
192+
193+	curl := shared.NewCreateURL(h.Cfg)
194+	url := h.Cfg.FullPostURL(
195+		curl,
196+		fmt.Sprintf("%s-%s", user.Name, projectName),
197+		filepath.Base(data.Filepath),
198+	)
199+	str := fmt.Sprintf(
200+		"%s (space: %.2f/%.2fGB, %.2f%%)",
201+		url,
202+		bytesToGB(int(totalFileSize)),
203+		bytesToGB(maxSize),
204+		(float32(totalFileSize)/float32(maxSize))*100,
205+	)
206+	return str, nil
207+}
M filehandlers/imgs/handler.go
+8, -9
 1@@ -6,15 +6,14 @@ import (
 2 	"io"
 3 	"net/http"
 4 	"os"
 5-	"path"
 6-	"strings"
 7+	"path/filepath"
 8 	"time"
 9 
10 	"github.com/gliderlabs/ssh"
11 	exifremove "github.com/neurosnap/go-exif-remove"
12 	"github.com/picosh/pico/db"
13-	"github.com/picosh/pico/imgs/storage"
14 	"github.com/picosh/pico/shared"
15+	"github.com/picosh/pico/shared/storage"
16 	"github.com/picosh/pico/wish/cms/util"
17 	"github.com/picosh/pico/wish/send/utils"
18 	"golang.org/x/exp/slices"
19@@ -80,13 +79,13 @@ func (h *UploadImgHandler) removePost(data *PostMetaData) error {
20 	return nil
21 }
22 
23-func (h *UploadImgHandler) Read(s ssh.Session, filename string) (os.FileInfo, io.ReaderAt, error) {
24+func (h *UploadImgHandler) Read(s ssh.Session, entry *utils.FileEntry) (os.FileInfo, io.ReaderAt, error) {
25 	user, err := getUser(s)
26 	if err != nil {
27 		return nil, nil, err
28 	}
29 
30-	cleanFilename := strings.ReplaceAll(filename, "/", "")
31+	cleanFilename := filepath.Base(entry.Filepath)
32 
33 	if cleanFilename == "" || cleanFilename == "." {
34 		return nil, nil, os.ErrNotExist
35@@ -117,13 +116,13 @@ func (h *UploadImgHandler) Read(s ssh.Session, filename string) (os.FileInfo, io
36 	return fileInfo, contents, nil
37 }
38 
39-func (h *UploadImgHandler) List(s ssh.Session, filename string) ([]os.FileInfo, error) {
40+func (h *UploadImgHandler) List(s ssh.Session, fpath string) ([]os.FileInfo, error) {
41 	var fileList []os.FileInfo
42 	user, err := getUser(s)
43 	if err != nil {
44 		return fileList, err
45 	}
46-	cleanFilename := strings.ReplaceAll(filename, "/", "")
47+	cleanFilename := filepath.Base(fpath)
48 
49 	var post *db.Post
50 	var posts []*db.Post
51@@ -189,7 +188,7 @@ func (h *UploadImgHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
52 		return "", err
53 	}
54 
55-	filename := entry.Name
56+	filename := filepath.Base(entry.Filepath)
57 
58 	var text []byte
59 	if b, err := io.ReadAll(entry.Reader); err == nil {
60@@ -226,7 +225,7 @@ func (h *UploadImgHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
61 		Shasum:    shasum,
62 	}
63 
64-	ext := path.Ext(filename)
65+	ext := filepath.Ext(filename)
66 	// DetectContentType does not detect markdown
67 	if ext == ".md" {
68 		nextPost.MimeType = "text/markdown; charset=UTF-8"
M filehandlers/imgs/img.go
+1, -1
 1@@ -8,8 +8,8 @@ import (
 2 
 3 	"github.com/gliderlabs/ssh"
 4 	"github.com/picosh/pico/db"
 5-	"github.com/picosh/pico/imgs/storage"
 6 	"github.com/picosh/pico/shared"
 7+	"github.com/picosh/pico/shared/storage"
 8 )
 9 
10 func (h *UploadImgHandler) validateImg(data *PostMetaData) (bool, error) {
M filehandlers/post_handler.go
+8, -8
 1@@ -6,15 +6,15 @@ import (
 2 	"io"
 3 	"net/http"
 4 	"os"
 5-	"path"
 6+	"path/filepath"
 7 	"strings"
 8 	"time"
 9 
10 	"github.com/gliderlabs/ssh"
11 	"github.com/picosh/pico/db"
12 	"github.com/picosh/pico/imgs"
13-	"github.com/picosh/pico/imgs/storage"
14 	"github.com/picosh/pico/shared"
15+	"github.com/picosh/pico/shared/storage"
16 	"github.com/picosh/pico/wish/cms/util"
17 	"github.com/picosh/pico/wish/send/utils"
18 )
19@@ -61,12 +61,12 @@ func NewScpPostHandler(dbpool db.DB, cfg *shared.ConfigSite, hooks ScpFileHooks,
20 	}
21 }
22 
23-func (h *ScpUploadHandler) Read(s ssh.Session, filename string) (os.FileInfo, io.ReaderAt, error) {
24+func (h *ScpUploadHandler) Read(s ssh.Session, entry *utils.FileEntry) (os.FileInfo, io.ReaderAt, error) {
25 	user, err := getUser(s)
26 	if err != nil {
27 		return nil, nil, err
28 	}
29-	cleanFilename := strings.ReplaceAll(filename, "/", "")
30+	cleanFilename := filepath.Base(entry.Filepath)
31 
32 	if cleanFilename == "" || cleanFilename == "." {
33 		return nil, nil, os.ErrNotExist
34@@ -87,14 +87,14 @@ func (h *ScpUploadHandler) Read(s ssh.Session, filename string) (os.FileInfo, io
35 	return fileInfo, strings.NewReader(post.Text), nil
36 }
37 
38-func (h *ScpUploadHandler) List(s ssh.Session, filename string) ([]os.FileInfo, error) {
39+func (h *ScpUploadHandler) List(s ssh.Session, fpath string) ([]os.FileInfo, error) {
40 	var fileList []os.FileInfo
41 	user, err := getUser(s)
42 	if err != nil {
43 		return fileList, err
44 	}
45 
46-	cleanFilename := strings.ReplaceAll(filename, "/", "")
47+	cleanFilename := filepath.Base(fpath)
48 
49 	var post *db.Post
50 	var posts []*db.Post
51@@ -162,7 +162,7 @@ func (h *ScpUploadHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
52 	}
53 
54 	userID := user.ID
55-	filename := entry.Name
56+	filename := filepath.Base(entry.Filepath)
57 
58 	if shared.IsExtAllowed(filename, h.ImgClient.Cfg.AllowedExt) {
59 		return h.ImgClient.Upload(s, entry)
60@@ -174,7 +174,7 @@ func (h *ScpUploadHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
61 	}
62 
63 	mimeType := http.DetectContentType(origText)
64-	ext := path.Ext(filename)
65+	ext := filepath.Ext(filename)
66 	// DetectContentType does not detect markdown
67 	if ext == ".md" {
68 		mimeType = "text/markdown; charset=UTF-8"
M go.mod
+22, -10
 1@@ -18,7 +18,8 @@ require (
 2 	github.com/lib/pq v1.10.7
 3 	github.com/matryer/is v1.4.0
 4 	github.com/microcosm-cc/bluemonday v1.0.21
 5-	github.com/minio/minio-go/v7 v7.0.45
 6+	github.com/minio/madmin-go/v3 v3.0.5
 7+	github.com/minio/minio-go/v7 v7.0.49
 8 	github.com/mmcdole/gofeed v1.1.3
 9 	github.com/muesli/reflow v0.3.0
10 	github.com/neurosnap/go-exif-remove v0.0.0-20221010134343-50d1e3c35577
11@@ -29,7 +30,7 @@ require (
12 	github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594
13 	github.com/yuin/goldmark-meta v1.1.0
14 	go.uber.org/zap v1.24.0
15-	golang.org/x/crypto v0.4.0
16+	golang.org/x/crypto v0.6.0
17 	golang.org/x/exp v0.0.0-20221211140036-ad323defaf05
18 )
19 
20@@ -54,8 +55,9 @@ require (
21 	github.com/dsoprea/go-photoshop-info-format v0.0.0-20200610045659-121dd752914d // indirect
22 	github.com/dsoprea/go-png-image-structure v0.0.0-20210512210324-29b889a6093d // indirect
23 	github.com/dsoprea/go-utility v0.0.0-20221003172846-a3e1774ef349 // indirect
24-	github.com/dustin/go-humanize v1.0.0 // indirect
25+	github.com/dustin/go-humanize v1.0.1 // indirect
26 	github.com/go-errors/errors v1.4.2 // indirect
27+	github.com/go-ole/go-ole v1.2.6 // indirect
28 	github.com/go-xmlfmt/xmlfmt v1.1.2 // indirect
29 	github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
30 	github.com/golang/protobuf v1.5.2 // indirect
31@@ -63,11 +65,12 @@ require (
32 	github.com/google/uuid v1.3.0 // indirect
33 	github.com/gorilla/css v1.0.0 // indirect
34 	github.com/json-iterator/go v1.1.12 // indirect
35-	github.com/klauspost/compress v1.15.13 // indirect
36-	github.com/klauspost/cpuid/v2 v2.2.2 // indirect
37+	github.com/klauspost/compress v1.15.15 // indirect
38+	github.com/klauspost/cpuid/v2 v2.2.4 // indirect
39 	github.com/kr/fs v0.1.0 // indirect
40 	github.com/kr/pretty v0.3.0 // indirect
41 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
42+	github.com/lufia/plan9stats v0.0.0-20230110061619-bbe2e5e100de // indirect
43 	github.com/mattn/go-isatty v0.0.16 // indirect
44 	github.com/mattn/go-localereader v0.0.1 // indirect
45 	github.com/mattn/go-runewidth v0.0.14 // indirect
46@@ -83,21 +86,30 @@ require (
47 	github.com/muesli/cancelreader v0.2.2 // indirect
48 	github.com/muesli/termenv v0.13.0 // indirect
49 	github.com/neurosnap/go-jpeg-image-structure v0.0.0-20221010133817-70b1c1ff679e // indirect
50+	github.com/philhofer/fwd v1.1.2 // indirect
51+	github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b // indirect
52 	github.com/prometheus/client_golang v1.14.0 // indirect
53 	github.com/prometheus/client_model v0.3.0 // indirect
54 	github.com/prometheus/common v0.38.0 // indirect
55-	github.com/prometheus/procfs v0.8.0 // indirect
56+	github.com/prometheus/procfs v0.9.0 // indirect
57 	github.com/rivo/uniseg v0.4.3 // indirect
58 	github.com/rs/xid v1.4.0 // indirect
59+	github.com/secure-io/sio-go v0.3.1 // indirect
60 	github.com/sendgrid/rest v2.6.9+incompatible // indirect
61+	github.com/shirou/gopsutil/v3 v3.23.1 // indirect
62 	github.com/sirupsen/logrus v1.9.0 // indirect
63+	github.com/tinylib/msgp v1.1.8 // indirect
64+	github.com/tklauser/go-sysconf v0.3.11 // indirect
65+	github.com/tklauser/numcpus v0.6.0 // indirect
66+	github.com/yusufpapurcu/wmi v1.2.2 // indirect
67 	go.uber.org/atomic v1.10.0 // indirect
68 	go.uber.org/multierr v1.8.0 // indirect
69 	golang.org/x/image v0.2.0 // indirect
70-	golang.org/x/net v0.4.0 // indirect
71-	golang.org/x/sys v0.3.0 // indirect
72-	golang.org/x/term v0.3.0 // indirect
73-	golang.org/x/text v0.5.0 // indirect
74+	golang.org/x/net v0.7.0 // indirect
75+	golang.org/x/sync v0.1.0 // indirect
76+	golang.org/x/sys v0.5.0 // indirect
77+	golang.org/x/term v0.5.0 // indirect
78+	golang.org/x/text v0.7.0 // indirect
79 	google.golang.org/protobuf v1.28.1 // indirect
80 	gopkg.in/ini.v1 v1.67.0 // indirect
81 	gopkg.in/yaml.v2 v2.4.0 // indirect
M go.sum
+68, -20
  1@@ -78,8 +78,8 @@ github.com/dsoprea/go-utility v0.0.0-20200711062821-fab8125e9bdf/go.mod h1:95+K3
  2 github.com/dsoprea/go-utility v0.0.0-20221003172846-a3e1774ef349 h1:/py11NlxDaOxkT9OKN+gXgT+QOH5xj1ZRoyusfRIlo4=
  3 github.com/dsoprea/go-utility v0.0.0-20221003172846-a3e1774ef349/go.mod h1:KVK+/Hul09ujXAGq+42UBgCTnXkiJZRnLYdURGjQUwo=
  4 github.com/dsoprea/go-utility/v2 v2.0.0-20200717064901-2fccff4aa15e/go.mod h1:uAzdkPTub5Y9yQwXe8W4m2XuP0tK4a9Q/dantD0+uaU=
  5-github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
  6-github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
  7+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
  8+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
  9 github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
 10 github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
 11 github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
 12@@ -87,6 +87,8 @@ github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWE
 13 github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
 14 github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
 15 github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
 16+github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
 17+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
 18 github.com/go-xmlfmt/xmlfmt v0.0.0-20191208150333-d5b6f63a941b/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
 19 github.com/go-xmlfmt/xmlfmt v1.1.2 h1:Nea7b4icn8s57fTx1M5AI4qQT5HEM3rVUO8MuE6g80U=
 20 github.com/go-xmlfmt/xmlfmt v1.1.2/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
 21@@ -100,7 +102,9 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
 22 github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
 23 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 24 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 25-github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
 26+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 27+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 28+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 29 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 30 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
 31 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
 32@@ -114,12 +118,12 @@ github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJS
 33 github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
 34 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 35 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 36-github.com/klauspost/compress v1.15.13 h1:NFn1Wr8cfnenSJSA46lLq4wHCcBzKTSjnBIexDMMOV0=
 37-github.com/klauspost/compress v1.15.13/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM=
 38+github.com/klauspost/compress v1.15.15 h1:EF27CXIuDsYJ6mmvtBRlEuB2UVOqHG1tAXgZ7yIO+lw=
 39+github.com/klauspost/compress v1.15.15/go.mod h1:ZcK2JAFqKOpnBlxcLsJzYfrS9X1akm9fHZNnD9+Vo/4=
 40 github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 41 github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 42-github.com/klauspost/cpuid/v2 v2.2.2 h1:xPMwiykqNK9VK0NYC3+jTMYv9I6Vl3YdjZgPZKG3zO0=
 43-github.com/klauspost/cpuid/v2 v2.2.2/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
 44+github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
 45+github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
 46 github.com/kolesa-team/go-webp v1.0.2 h1:XCrWqxI7tNOI3dr0YufD9TUb+54vBDogg9KsHH7q5Lc=
 47 github.com/kolesa-team/go-webp v1.0.2/go.mod h1:oMvdivD6K+Q5qIIkVC2w4k2ZUnI1H+MyP7inwgWq9aA=
 48 github.com/kolesa-team/go-webp v1.0.4 h1:wQvU4PLG/X7RS0vAeyhiivhLRoxfLVRlDq4I3frdxIQ=
 49@@ -138,6 +142,9 @@ github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
 50 github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 51 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 52 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 53+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
 54+github.com/lufia/plan9stats v0.0.0-20230110061619-bbe2e5e100de h1:V53FWzU6KAZVi1tPp5UIsMoUWJ2/PNwYIDXnu7QuBCE=
 55+github.com/lufia/plan9stats v0.0.0-20230110061619-bbe2e5e100de/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE=
 56 github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
 57 github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
 58 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
 59@@ -154,10 +161,12 @@ github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zk
 60 github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
 61 github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg=
 62 github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
 63+github.com/minio/madmin-go/v3 v3.0.5 h1:ynWTsnszHnQVJWRL2OE4ysCvCNG0uHgdTvJpdLazf9c=
 64+github.com/minio/madmin-go/v3 v3.0.5/go.mod h1:lPrMoc1aeiIWmmrxBthkDqzMPQwC/Lu9ByuyM2wenJk=
 65 github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
 66 github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
 67-github.com/minio/minio-go/v7 v7.0.45 h1:g4IeM9M9pW/Lo8AGGNOjBZYlvmtlE1N5TQEYWXRWzIs=
 68-github.com/minio/minio-go/v7 v7.0.45/go.mod h1:nCrRzjoSUQh8hgKKtu3Y708OLvRLtuASMg2/nvmbarw=
 69+github.com/minio/minio-go/v7 v7.0.49 h1:dE5DfOtnXMXCjr/HWI6zN9vCrY6Sv666qhhiwUMvGV4=
 70+github.com/minio/minio-go/v7 v7.0.49/go.mod h1:UI34MvQEiob3Cf/gGExGMmzugkM/tNgbFypNDy5LMVc=
 71 github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
 72 github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
 73 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
 74@@ -193,19 +202,24 @@ github.com/neurosnap/go-jpeg-image-structure v0.0.0-20221010133817-70b1c1ff679e
 75 github.com/neurosnap/go-jpeg-image-structure v0.0.0-20221010133817-70b1c1ff679e/go.mod h1:nZBDA7+RD63GDJwjZmxhxac65MJqiCIHUUUvdYOsFkk=
 76 github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
 77 github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
 78+github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
 79+github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
 80 github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
 81 github.com/pkg/sftp v1.13.5 h1:a3RLUqkyjYRtBTZJZ1VRrKbN3zhuPLlUc3sphVz81go=
 82 github.com/pkg/sftp v1.13.5/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg=
 83-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 84 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 85+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
 86+github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
 87+github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b h1:0LFwY6Q3gMACTjAbMZBjXAqTOzOwFaj2Ld6cjeQ7Rig=
 88+github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
 89 github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
 90 github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
 91 github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
 92 github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
 93 github.com/prometheus/common v0.38.0 h1:VTQitp6mXTdUoCmDMugDVOJ1opi6ADftKfp/yeqTR/E=
 94 github.com/prometheus/common v0.38.0/go.mod h1:MBXfmBQZrK5XpbCkjofnXs96LD2QQ7fEq4C0xjC/yec=
 95-github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
 96-github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
 97+github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
 98+github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
 99 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
100 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
101 github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
102@@ -217,17 +231,32 @@ github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
103 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
104 github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
105 github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
106+github.com/secure-io/sio-go v0.3.1 h1:dNvY9awjabXTYGsTF1PiCySl9Ltofk9GA3VdWlo7rRc=
107+github.com/secure-io/sio-go v0.3.1/go.mod h1:+xbkjDzPjwh4Axd07pRKSNriS9SCiYksWnZqdnfpQxs=
108 github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0=
109 github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE=
110 github.com/sendgrid/sendgrid-go v3.12.0+incompatible h1:/N2vx18Fg1KmQOh6zESc5FJB8pYwt5QFBDflYPh1KVg=
111 github.com/sendgrid/sendgrid-go v3.12.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
112+github.com/shirou/gopsutil/v3 v3.23.1 h1:a9KKO+kGLKEvcPIs4W62v0nu3sciVDOOOPUD0Hz7z/4=
113+github.com/shirou/gopsutil/v3 v3.23.1/go.mod h1:NN6mnm5/0k8jw4cBfCnJtr5L7ErOTg18tMNpgFkn0hA=
114 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
115 github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
116 github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
117 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
118+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
119+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
120 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
121 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
122-github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
123+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
124+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
125+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
126+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
127+github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
128+github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
129+github.com/tklauser/go-sysconf v0.3.11 h1:89WgdJhk5SNwJfu+GKyYveZ4IaJ7xAkecBo+KdJV0CM=
130+github.com/tklauser/go-sysconf v0.3.11/go.mod h1:GqXfhXY3kiPa0nAXPDIQIWzJbMCB7AmcWpGR8lSZfqI=
131+github.com/tklauser/numcpus v0.6.0 h1:kebhY2Qt+3U6RNK7UqpYNA+tJ23IBEGKkB7JQBfDYms=
132+github.com/tklauser/numcpus v0.6.0/go.mod h1:FEZLMke0lhOUG6w2JadTzp0a+Nl8PF/GFkQ5UVIcaL4=
133 github.com/urfave/cli v1.22.3/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
134 github.com/yuin/goldmark v1.4.5/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
135 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
136@@ -237,6 +266,8 @@ github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 h1:yHfZ
137 github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594/go.mod h1:U9ihbh+1ZN7fR5Se3daSPoz1CGF9IYtSvWwVQtnzGHU=
138 github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc=
139 github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0=
140+github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg=
141+github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
142 go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
143 go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
144 go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
145@@ -246,11 +277,12 @@ go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95a
146 go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
147 go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
148 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
149+golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
150 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
151 golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
152 golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
153-golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8=
154-golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
155+golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
156+golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
157 golang.org/x/exp v0.0.0-20221211140036-ad323defaf05 h1:T8EldfGCcveFMewH5xAYxxoX3PSQMrsechlUGVFlQBU=
158 golang.org/x/exp v0.0.0-20221211140036-ad323defaf05/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
159 golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
160@@ -258,7 +290,9 @@ golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeap
161 golang.org/x/image v0.2.0 h1:/DcQ0w3VHKCC5p0/P2B0JpAZ9Z++V2KOo2fyU89CXBQ=
162 golang.org/x/image v0.2.0/go.mod h1:la7oBXb9w3YFjBqaAwtynVioc1ZvOnNteUNrifGNmAI=
163 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
164+golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
165 golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
166+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
167 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
168 golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
169 golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
170@@ -271,14 +305,21 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
171 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
172 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
173 golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
174-golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
175-golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
176+golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
177+golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
178+golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
179 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
180 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
181 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
182+golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
183+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
184 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
185+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
186+golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
187+golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
188 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
189 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
190+golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
191 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
192 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
193 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
194@@ -293,23 +334,29 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
195 golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
196 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
197 golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
198-golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
199+golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
200 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
201+golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
202+golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
203+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
204 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
205 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
206 golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
207-golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
208 golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
209+golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
210+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
211 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
212 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
213 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
214 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
215 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
216-golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
217 golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
218+golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
219+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
220 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
221 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
222 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
223+golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
224 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
225 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
226 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
227@@ -330,3 +377,4 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
228 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
229 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
230 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
231+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
M imgs/api.go
+1, -1
 1@@ -15,8 +15,8 @@ import (
 2 	gocache "github.com/patrickmn/go-cache"
 3 	"github.com/picosh/pico/db"
 4 	"github.com/picosh/pico/db/postgres"
 5-	"github.com/picosh/pico/imgs/storage"
 6 	"github.com/picosh/pico/shared"
 7+	"github.com/picosh/pico/shared/storage"
 8 	"go.uber.org/zap"
 9 	"golang.org/x/exp/slices"
10 )
M imgs/client.go
+1, -1
 1@@ -4,8 +4,8 @@ import (
 2 	"github.com/gliderlabs/ssh"
 3 	"github.com/picosh/pico/db"
 4 	uploadimgs "github.com/picosh/pico/filehandlers/imgs"
 5-	"github.com/picosh/pico/imgs/storage"
 6 	"github.com/picosh/pico/shared"
 7+	"github.com/picosh/pico/shared/storage"
 8 	"github.com/picosh/pico/wish/send/utils"
 9 )
10 
D imgs/storage/minio.go
+0, -97
 1@@ -1,97 +0,0 @@
 2-package storage
 3-
 4-import (
 5-	"context"
 6-	"errors"
 7-	"fmt"
 8-	"net/url"
 9-
10-	"github.com/minio/minio-go/v7"
11-	"github.com/minio/minio-go/v7/pkg/credentials"
12-)
13-
14-type StorageMinio struct {
15-	Client *minio.Client
16-}
17-
18-func NewStorageMinio(address, user, pass string) (*StorageMinio, error) {
19-	endpoint, err := url.Parse(address)
20-	if err != nil {
21-		return nil, err
22-	}
23-
24-	mClient, err := minio.New(endpoint.Host, &minio.Options{
25-		Creds:  credentials.NewStaticV4(user, pass, ""),
26-		Secure: endpoint.Scheme == "https",
27-	})
28-
29-	if err != nil {
30-		return nil, err
31-	}
32-
33-	return &StorageMinio{
34-		Client: mClient,
35-	}, err
36-}
37-
38-func (s *StorageMinio) GetBucket(name string) (Bucket, error) {
39-	bucket := Bucket{
40-		Name: name,
41-	}
42-
43-	exists, err := s.Client.BucketExists(context.TODO(), bucket.Name)
44-	if err != nil || !exists {
45-		if err == nil {
46-			err = errors.New("bucket does not exist")
47-		}
48-		return bucket, err
49-	}
50-
51-	return bucket, nil
52-}
53-
54-func (s *StorageMinio) UpsertBucket(name string) (Bucket, error) {
55-	bucket, err := s.GetBucket(name)
56-	if err == nil {
57-		return bucket, nil
58-	}
59-
60-	err = s.Client.MakeBucket(context.TODO(), name, minio.MakeBucketOptions{})
61-	if err != nil {
62-		return bucket, err
63-	}
64-
65-	return bucket, nil
66-}
67-
68-func (s *StorageMinio) GetFile(bucket Bucket, fname string) (ReaderAtCloser, error) {
69-	obj, err := s.Client.GetObject(context.TODO(), bucket.Name, fname, minio.GetObjectOptions{})
70-	if err != nil {
71-		return nil, err
72-	}
73-
74-	_, err = obj.Stat()
75-	if err != nil {
76-		return nil, err
77-	}
78-
79-	return obj, nil
80-}
81-
82-func (s *StorageMinio) PutFile(bucket Bucket, fname string, contents ReaderAtCloser) (string, error) {
83-	info, err := s.Client.PutObject(context.TODO(), bucket.Name, fname, contents, -1, minio.PutObjectOptions{})
84-	if err != nil {
85-		return "", err
86-	}
87-
88-	return fmt.Sprintf("%s/%s", info.Bucket, info.Key), nil
89-}
90-
91-func (s *StorageMinio) DeleteFile(bucket Bucket, fname string) error {
92-	err := s.Client.RemoveObject(context.TODO(), bucket.Name, fname, minio.RemoveObjectOptions{})
93-	if err != nil {
94-		return err
95-	}
96-
97-	return nil
98-}
D imgs/storage/storage.go
+0, -124
  1@@ -1,124 +0,0 @@
  2-package storage
  3-
  4-import (
  5-	"fmt"
  6-	"io"
  7-	"os"
  8-	"path"
  9-)
 10-
 11-type Bucket struct {
 12-	Name string
 13-	Path string
 14-}
 15-
 16-type ReadAndReaderAt interface {
 17-	io.ReaderAt
 18-	io.Reader
 19-}
 20-
 21-type ReaderAtCloser interface {
 22-	io.ReaderAt
 23-	io.ReadCloser
 24-}
 25-
 26-func NopReaderAtCloser(r ReadAndReaderAt) ReaderAtCloser {
 27-	return nopReaderAtCloser{r}
 28-}
 29-
 30-type nopReaderAtCloser struct {
 31-	ReadAndReaderAt
 32-}
 33-
 34-func (nopReaderAtCloser) Close() error { return nil }
 35-
 36-type ObjectStorage interface {
 37-	GetBucket(name string) (Bucket, error)
 38-	UpsertBucket(name string) (Bucket, error)
 39-
 40-	GetFile(bucket Bucket, fname string) (ReaderAtCloser, error)
 41-	PutFile(bucket Bucket, fname string, contents ReaderAtCloser) (string, error)
 42-	DeleteFile(bucket Bucket, fname string) error
 43-}
 44-
 45-type StorageFS struct {
 46-	Dir string
 47-}
 48-
 49-func NewStorageFS(dir string) (*StorageFS, error) {
 50-	return &StorageFS{Dir: dir}, nil
 51-}
 52-
 53-// GetBucket - A bucket for the filesystem is just a directory.
 54-func (s *StorageFS) GetBucket(name string) (Bucket, error) {
 55-	dirPath := path.Join(s.Dir, name)
 56-	bucket := Bucket{
 57-		Name: name,
 58-		Path: dirPath,
 59-	}
 60-
 61-	info, err := os.Stat(dirPath)
 62-	if os.IsNotExist(err) {
 63-		return bucket, fmt.Errorf("directory does not exist: %v %w", dirPath, err)
 64-	}
 65-
 66-	if err != nil {
 67-		return bucket, fmt.Errorf("directory error: %v %w", dirPath, err)
 68-
 69-	}
 70-
 71-	if !info.IsDir() {
 72-		return bucket, fmt.Errorf("directory is a file, not a directory: %#v", dirPath)
 73-	}
 74-
 75-	return bucket, nil
 76-}
 77-
 78-func (s *StorageFS) UpsertBucket(name string) (Bucket, error) {
 79-	bucket, err := s.GetBucket(name)
 80-	if err == nil {
 81-		return bucket, nil
 82-	}
 83-
 84-	err = os.MkdirAll(bucket.Path, os.ModePerm)
 85-	if err != nil {
 86-		return bucket, err
 87-	}
 88-
 89-	return bucket, nil
 90-}
 91-
 92-func (s *StorageFS) GetFile(bucket Bucket, fname string) (ReaderAtCloser, error) {
 93-	dat, err := os.Open(path.Join(bucket.Path, fname))
 94-	if err != nil {
 95-		return nil, err
 96-	}
 97-
 98-	return dat, nil
 99-}
100-
101-func (s *StorageFS) PutFile(bucket Bucket, fname string, contents ReaderAtCloser) (string, error) {
102-	loc := path.Join(bucket.Path, fname)
103-	f, err := os.OpenFile(loc, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
104-	if err != nil {
105-		return "", err
106-	}
107-	defer f.Close()
108-
109-	_, err = io.Copy(f, contents)
110-	if err != nil {
111-		return "", err
112-	}
113-
114-	return loc, nil
115-}
116-
117-func (s *StorageFS) DeleteFile(bucket Bucket, fname string) error {
118-	loc := path.Join(bucket.Path, fname)
119-	err := os.Remove(loc)
120-	if err != nil {
121-		return err
122-	}
123-
124-	return nil
125-}
M lists/api.go
+1, -1
 1@@ -15,8 +15,8 @@ import (
 2 	"github.com/picosh/pico/db"
 3 	"github.com/picosh/pico/db/postgres"
 4 	"github.com/picosh/pico/imgs"
 5-	"github.com/picosh/pico/imgs/storage"
 6 	"github.com/picosh/pico/shared"
 7+	"github.com/picosh/pico/shared/storage"
 8 	"golang.org/x/exp/slices"
 9 )
10 
M pastes/api.go
+1, -1
 1@@ -11,8 +11,8 @@ import (
 2 	gocache "github.com/patrickmn/go-cache"
 3 	"github.com/picosh/pico/db"
 4 	"github.com/picosh/pico/db/postgres"
 5-	"github.com/picosh/pico/imgs/storage"
 6 	"github.com/picosh/pico/shared"
 7+	"github.com/picosh/pico/shared/storage"
 8 )
 9 
10 type PageData struct {
A pgs/api.go
+186, -0
  1@@ -0,0 +1,186 @@
  2+package pgs
  3+
  4+import (
  5+	"fmt"
  6+	"io"
  7+	"net/http"
  8+	"net/url"
  9+	"path/filepath"
 10+	"strings"
 11+	"time"
 12+
 13+	_ "net/http/pprof"
 14+
 15+	gocache "github.com/patrickmn/go-cache"
 16+	"github.com/picosh/pico/db"
 17+	"github.com/picosh/pico/db/postgres"
 18+	"github.com/picosh/pico/shared"
 19+	"github.com/picosh/pico/shared/storage"
 20+	"github.com/picosh/pico/wish/send/utils"
 21+	"go.uber.org/zap"
 22+)
 23+
 24+type AssetHandler struct {
 25+	Username  string
 26+	Subdomain string
 27+	Filepath  string
 28+	Cfg       *shared.ConfigSite
 29+	Dbpool    db.DB
 30+	Storage   storage.ObjectStorage
 31+	Logger    *zap.SugaredLogger
 32+	Cache     *gocache.Cache
 33+	UserID    string
 34+}
 35+
 36+func assetHandler(w http.ResponseWriter, h *AssetHandler) {
 37+	bucket, err := h.Storage.GetBucket(shared.GetAssetBucketName(h.UserID))
 38+	if err != nil {
 39+		h.Logger.Infof("bucket not found for %s", h.Username)
 40+		http.Error(w, "bucket not found", http.StatusNotFound)
 41+		return
 42+	}
 43+
 44+	fname := shared.GetAssetFileName(&utils.FileEntry{Filepath: h.Filepath})
 45+
 46+	contents, err := h.Storage.GetFile(bucket, fname)
 47+	if err != nil {
 48+		h.Logger.Infof(
 49+			"asset not found in bucket: bucket:[%s], file:[%s]",
 50+			bucket.Name,
 51+			fname,
 52+		)
 53+		http.Error(w, err.Error(), http.StatusInternalServerError)
 54+		return
 55+	}
 56+	defer contents.Close()
 57+
 58+	contentType := shared.GetMimeType(fname)
 59+
 60+	w.Header().Add("Content-Type", contentType)
 61+	_, err = io.Copy(w, contents)
 62+
 63+	if err != nil {
 64+		h.Logger.Error(err)
 65+	}
 66+}
 67+
 68+type SubdomainProps struct {
 69+	ProjectName string
 70+	Username    string
 71+}
 72+
 73+func getProjectFromSubdomain(subdomain string) (*SubdomainProps, error) {
 74+	props := &SubdomainProps{}
 75+	strs := strings.SplitN(subdomain, "-", 2)
 76+	if len(strs) < 2 {
 77+		return nil, fmt.Errorf("subdomain incorrect format, must have period: %s", subdomain)
 78+	}
 79+	props.Username = strs[0]
 80+	props.ProjectName = strs[1]
 81+	return props, nil
 82+}
 83+
 84+func serveAsset(subdomain string, w http.ResponseWriter, r *http.Request) {
 85+	cfg := shared.GetCfg(r)
 86+	dbpool := shared.GetDB(r)
 87+	st := shared.GetStorage(r)
 88+	logger := shared.GetLogger(r)
 89+	cache := shared.GetCache(r)
 90+
 91+	floc, _ := url.PathUnescape(shared.GetField(r, 0))
 92+	props, err := getProjectFromSubdomain(subdomain)
 93+	if err != nil {
 94+		logger.Info(err)
 95+		http.Error(w, err.Error(), http.StatusNotFound)
 96+		return
 97+	}
 98+	projectDir := props.ProjectName
 99+
100+	user, err := dbpool.FindUserForName(props.Username)
101+	if err != nil {
102+		logger.Infof("user not found: %s", props.Username)
103+		http.Error(w, "user not found", http.StatusNotFound)
104+		return
105+	}
106+	project, err := dbpool.FindProjectByName(user.ID, props.ProjectName)
107+	if err == nil {
108+		projectDir = project.ProjectDir
109+	}
110+
111+	fname := filepath.Base(floc)
112+	fdir := filepath.Dir(floc)
113+	// hack: we need to accommodate routes that are just directories
114+	// and point the user to the index.html of each root dir.
115+	if fname == "." || filepath.Ext(floc) == "" {
116+		fname = "index.html"
117+		fdir = floc
118+	}
119+	fpath := filepath.Join(projectDir, fdir, fname)
120+
121+	assetHandler(w, &AssetHandler{
122+		Username:  props.Username,
123+		UserID:    user.ID,
124+		Subdomain: subdomain,
125+		Filepath:  fpath,
126+		Cfg:       cfg,
127+		Dbpool:    dbpool,
128+		Storage:   st,
129+		Logger:    logger,
130+		Cache:     cache,
131+	})
132+}
133+
134+func marketingRequest(w http.ResponseWriter, r *http.Request) {
135+	subdomain := "hey-pgs-prod"
136+	serveAsset(subdomain, w, r)
137+}
138+
139+func assetRequest(w http.ResponseWriter, r *http.Request) {
140+	subdomain := shared.GetSubdomain(r)
141+	serveAsset(subdomain, w, r)
142+}
143+
144+func StartApiServer() {
145+	cfg := NewConfigSite()
146+	logger := cfg.Logger
147+
148+	db := postgres.NewDB(&cfg.ConfigCms)
149+	defer db.Close()
150+
151+	var st storage.ObjectStorage
152+	var err error
153+	if cfg.MinioURL == "" {
154+		st, err = storage.NewStorageFS(cfg.StorageDir)
155+	} else {
156+		st, err = storage.NewStorageMinio(cfg.MinioURL, cfg.MinioUser, cfg.MinioPass)
157+	}
158+
159+	// cache resizing images since they are CPU-bound
160+	// we want to clear the cache since we are storing images
161+	// as []byte in-memory
162+	cache := gocache.New(2*time.Minute, 5*time.Minute)
163+
164+	if err != nil {
165+		logger.Fatal(err)
166+	}
167+
168+	mainRoutes := []shared.Route{
169+		shared.NewRoute("GET", "/", marketingRequest),
170+		shared.NewRoute("GET", "/(.+)", marketingRequest),
171+	}
172+	subdomainRoutes := []shared.Route{
173+		shared.NewRoute("GET", "/", assetRequest),
174+		shared.NewRoute("GET", "/(.+)", assetRequest),
175+	}
176+
177+	handler := shared.CreateServe(mainRoutes, subdomainRoutes, cfg, db, st, logger, cache)
178+	router := http.HandlerFunc(handler)
179+
180+	portStr := fmt.Sprintf(":%s", cfg.Port)
181+	logger.Infof("Starting server on port %s", cfg.Port)
182+	logger.Infof("Subdomains enabled: %t", cfg.SubdomainsEnabled)
183+	logger.Infof("Domain: %s", cfg.Domain)
184+	logger.Infof("Email: %s", cfg.Email)
185+
186+	logger.Fatal(http.ListenAndServe(portStr, router))
187+}
A pgs/cms.go
+371, -0
  1@@ -0,0 +1,371 @@
  2+package pgs
  3+
  4+import (
  5+	"errors"
  6+	"fmt"
  7+
  8+	"github.com/charmbracelet/bubbles/spinner"
  9+	tea "github.com/charmbracelet/bubbletea"
 10+	"github.com/charmbracelet/lipgloss"
 11+	bm "github.com/charmbracelet/wish/bubbletea"
 12+	"github.com/gliderlabs/ssh"
 13+	"github.com/muesli/reflow/indent"
 14+	"github.com/muesli/reflow/wordwrap"
 15+	"github.com/muesli/reflow/wrap"
 16+	"github.com/picosh/pico/db"
 17+	"github.com/picosh/pico/db/postgres"
 18+	"github.com/picosh/pico/shared/storage"
 19+	"github.com/picosh/pico/wish/cms/config"
 20+	"github.com/picosh/pico/wish/cms/ui/account"
 21+	"github.com/picosh/pico/wish/cms/ui/common"
 22+	"github.com/picosh/pico/wish/cms/ui/info"
 23+	"github.com/picosh/pico/wish/cms/ui/keys"
 24+	"github.com/picosh/pico/wish/cms/ui/username"
 25+	"github.com/picosh/pico/wish/cms/util"
 26+)
 27+
 28+type status int
 29+
 30+const (
 31+	statusInit status = iota
 32+	statusReady
 33+	statusNoAccount
 34+	statusBrowsingKeys
 35+	statusSettingUsername
 36+	statusQuitting
 37+)
 38+
 39+func (s status) String() string {
 40+	return [...]string{
 41+		"initializing",
 42+		"ready",
 43+		"setting username",
 44+		"browsing keys",
 45+		"quitting",
 46+		"error",
 47+	}[s]
 48+}
 49+
 50+// menuChoice represents a chosen menu item.
 51+type menuChoice int
 52+
 53+// menu choices.
 54+const (
 55+	setUserChoice menuChoice = iota
 56+	keysChoice
 57+	exitChoice
 58+	unsetChoice // set when no choice has been made
 59+)
 60+
 61+// menu text corresponding to menu choices. these are presented to the user.
 62+var menuChoices = map[menuChoice]string{
 63+	setUserChoice: "Set username",
 64+	keysChoice:    "Manage keys",
 65+	exitChoice:    "Exit",
 66+}
 67+
 68+var (
 69+	spinnerStyle = lipgloss.NewStyle().
 70+		Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"})
 71+)
 72+
 73+func NewSpinner() spinner.Model {
 74+	s := spinner.NewModel()
 75+	s.Spinner = spinner.Dot
 76+	s.Style = spinnerStyle
 77+	return s
 78+}
 79+
 80+type GotDBMsg db.DB
 81+
 82+func CmsMiddleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
 83+	return func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
 84+		logger := cfg.Logger
 85+
 86+		_, _, active := s.Pty()
 87+		if !active {
 88+			logger.Info("no active terminal, skipping")
 89+			return nil, nil
 90+		}
 91+		key, err := util.KeyText(s)
 92+		if err != nil {
 93+			logger.Error(err)
 94+		}
 95+
 96+		sshUser := s.User()
 97+
 98+		dbpool := postgres.NewDB(cfg)
 99+
100+		var st storage.ObjectStorage
101+		if cfg.MinioURL == "" {
102+			st, err = storage.NewStorageFS(cfg.StorageDir)
103+		} else {
104+			st, err = storage.NewStorageMinio(cfg.MinioURL, cfg.MinioUser, cfg.MinioPass)
105+		}
106+
107+		if err != nil {
108+			logger.Fatal(err)
109+		}
110+
111+		m := model{
112+			cfg:        cfg,
113+			urls:       urls,
114+			publicKey:  key,
115+			dbpool:     dbpool,
116+			st:         st,
117+			sshUser:    sshUser,
118+			status:     statusInit,
119+			menuChoice: unsetChoice,
120+			styles:     common.DefaultStyles(),
121+			spinner:    common.NewSpinner(),
122+		}
123+
124+		user, err := m.findUser()
125+		if err != nil {
126+			_, _ = fmt.Fprintln(s.Stderr(), err)
127+			return nil, nil
128+		}
129+		m.user = user
130+
131+		return m, []tea.ProgramOption{tea.WithAltScreen()}
132+	}
133+}
134+
135+// Just a generic tea.Model to demo terminal information of ssh.
136+type model struct {
137+	cfg           *config.ConfigCms
138+	urls          config.ConfigURL
139+	publicKey     string
140+	dbpool        db.DB
141+	st            storage.ObjectStorage
142+	user          *db.User
143+	err           error
144+	sshUser       string
145+	status        status
146+	menuIndex     int
147+	menuChoice    menuChoice
148+	terminalWidth int
149+	styles        common.Styles
150+	info          info.Model
151+	spinner       spinner.Model
152+	username      username.Model
153+	keys          keys.Model
154+	createAccount account.CreateModel
155+}
156+
157+func (m model) Init() tea.Cmd {
158+	return spinner.Tick
159+}
160+
161+func (m model) findUser() (*db.User, error) {
162+	logger := m.cfg.Logger
163+	var user *db.User
164+
165+	if m.sshUser == "new" {
166+		logger.Infof("User requesting to register account")
167+		return nil, nil
168+	}
169+
170+	user, err := m.dbpool.FindUserForKey(m.sshUser, m.publicKey)
171+
172+	if err != nil {
173+		logger.Error(err)
174+		// we only want to throw an error for specific cases
175+		if errors.Is(err, &db.ErrMultiplePublicKeys{}) {
176+			return nil, err
177+		}
178+		return nil, nil
179+	}
180+
181+	return user, nil
182+}
183+
184+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
185+	var (
186+		cmds []tea.Cmd
187+		cmd  tea.Cmd
188+	)
189+
190+	switch msg := msg.(type) {
191+	case tea.WindowSizeMsg:
192+		m.terminalWidth = msg.Width
193+	case tea.KeyMsg:
194+		switch msg.Type {
195+		case tea.KeyCtrlC:
196+			m.dbpool.Close()
197+			return m, tea.Quit
198+		}
199+
200+		if m.status == statusReady { // Process keys for the menu
201+			switch msg.String() {
202+			// Quit
203+			case "q", "esc":
204+				m.status = statusQuitting
205+				m.dbpool.Close()
206+				return m, tea.Quit
207+
208+			// Prev menu item
209+			case "up", "k":
210+				m.menuIndex--
211+				if m.menuIndex < 0 {
212+					m.menuIndex = len(menuChoices) - 1
213+				}
214+
215+			// Select menu item
216+			case "enter":
217+				m.menuChoice = menuChoice(m.menuIndex)
218+
219+			// Next menu item
220+			case "down", "j":
221+				m.menuIndex++
222+				if m.menuIndex >= len(menuChoices) {
223+					m.menuIndex = 0
224+				}
225+			}
226+		}
227+	case username.NameSetMsg:
228+		m.status = statusReady
229+		m.info.User.Name = string(msg)
230+		m.user = m.info.User
231+		m.username = username.NewModel(m.dbpool, m.user, m.sshUser) // reset the state
232+	case account.CreateAccountMsg:
233+		m.status = statusReady
234+		m.info.User = msg
235+		m.user = msg
236+		m.username = username.NewModel(m.dbpool, m.user, m.sshUser)
237+		m.info = info.NewModel(m.cfg, m.urls, m.user)
238+		m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
239+		m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey)
240+	}
241+
242+	switch m.status {
243+	case statusInit:
244+		m.username = username.NewModel(m.dbpool, m.user, m.sshUser)
245+		m.info = info.NewModel(m.cfg, m.urls, m.user)
246+		m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
247+		m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey)
248+		if m.user == nil {
249+			m.status = statusNoAccount
250+		} else {
251+			m.status = statusReady
252+		}
253+	}
254+
255+	m, cmd = updateChildren(msg, m)
256+	if cmd != nil {
257+		cmds = append(cmds, cmd)
258+	}
259+
260+	return m, tea.Batch(cmds...)
261+}
262+
263+func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
264+	var cmd tea.Cmd
265+
266+	switch m.status {
267+	case statusBrowsingKeys:
268+		newModel, newCmd := m.keys.Update(msg)
269+		keysModel, ok := newModel.(keys.Model)
270+		if !ok {
271+			panic("could not perform assertion on keys model")
272+		}
273+		m.keys = keysModel
274+		cmd = newCmd
275+
276+		if m.keys.Exit {
277+			m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
278+			m.status = statusReady
279+		} else if m.keys.Quit {
280+			m.status = statusQuitting
281+			return m, tea.Quit
282+		}
283+	case statusSettingUsername:
284+		m.username, cmd = username.Update(msg, m.username)
285+		if m.username.Done {
286+			m.username = username.NewModel(m.dbpool, m.user, m.sshUser) // reset the state
287+			m.status = statusReady
288+		} else if m.username.Quit {
289+			m.status = statusQuitting
290+			return m, tea.Quit
291+		}
292+	case statusNoAccount:
293+		m.createAccount, cmd = account.Update(msg, m.createAccount)
294+		if m.createAccount.Done {
295+			m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey) // reset the state
296+			m.status = statusReady
297+		} else if m.createAccount.Quit {
298+			m.status = statusQuitting
299+			return m, tea.Quit
300+		}
301+	}
302+
303+	// Handle the menu
304+	switch m.menuChoice {
305+	case setUserChoice:
306+		m.status = statusSettingUsername
307+		m.menuChoice = unsetChoice
308+		cmd = username.InitialCmd()
309+	case keysChoice:
310+		m.status = statusBrowsingKeys
311+		m.menuChoice = unsetChoice
312+		cmd = keys.LoadKeys(m.keys)
313+	case exitChoice:
314+		m.status = statusQuitting
315+		m.dbpool.Close()
316+		cmd = tea.Quit
317+	}
318+
319+	return m, cmd
320+}
321+
322+func (m model) menuView() string {
323+	var s string
324+	for i := 0; i < len(menuChoices); i++ {
325+		e := "  "
326+		menuItem := menuChoices[menuChoice(i)]
327+		if i == m.menuIndex {
328+			e = m.styles.SelectionMarker.String() +
329+				m.styles.SelectedMenuItem.Render(menuItem)
330+		} else {
331+			e += menuItem
332+		}
333+		if i < len(menuChoices)-1 {
334+			e += "\n"
335+		}
336+		s += e
337+	}
338+
339+	return s
340+}
341+
342+func footerView(m model) string {
343+	if m.err != nil {
344+		return m.errorView(m.err)
345+	}
346+	return "\n\n" + common.HelpView("j/k, ↑/↓: choose", "enter: select")
347+}
348+
349+func (m model) errorView(err error) string {
350+	head := m.styles.Error.Render("Error: ")
351+	body := m.styles.Subtle.Render(err.Error())
352+	msg := m.styles.Wrap.Render(head + body)
353+	return "\n\n" + indent.String(msg, 2)
354+}
355+
356+func (m model) View() string {
357+	w := m.terminalWidth - m.styles.App.GetHorizontalFrameSize()
358+	s := m.styles.Logo.SetString(m.cfg.Domain).String() + "\n\n"
359+	switch m.status {
360+	case statusNoAccount:
361+		s += account.View(m.createAccount)
362+	case statusReady:
363+		s += m.info.View()
364+		s += "\n\n" + m.menuView()
365+		s += footerView(m)
366+	case statusSettingUsername:
367+		s += username.View(m.username)
368+	case statusBrowsingKeys:
369+		s += m.keys.View()
370+	}
371+	return m.styles.App.Render(wrap.String(wordwrap.String(s, w), w))
372+}
A pgs/config.go
+93, -0
 1@@ -0,0 +1,93 @@
 2+package pgs
 3+
 4+import (
 5+	"fmt"
 6+
 7+	"github.com/picosh/pico/shared"
 8+	"github.com/picosh/pico/wish/cms/config"
 9+)
10+
11+type ImgsLinkify struct {
12+	Cfg          *shared.ConfigSite
13+	Username     string
14+	OnSubdomain  bool
15+	WithUsername bool
16+}
17+
18+func NewImgsLinkify(username string) *ImgsLinkify {
19+	cfg := NewConfigSite()
20+	return &ImgsLinkify{
21+		Cfg:      cfg,
22+		Username: username,
23+	}
24+}
25+
26+func (i *ImgsLinkify) Create(fname string) string {
27+	return i.Cfg.ImgFullURL(i.Username, fname)
28+}
29+
30+func NewConfigSite() *shared.ConfigSite {
31+	debug := shared.GetEnv("PGS_DEBUG", "0")
32+	domain := shared.GetEnv("PGS_DOMAIN", "pgs.sh")
33+	email := shared.GetEnv("PGS_EMAIL", "hello@prose.sh")
34+	subdomains := shared.GetEnv("PGS_SUBDOMAINS", "0")
35+	customdomains := shared.GetEnv("PGS_CUSTOMDOMAINS", "0")
36+	port := shared.GetEnv("PGS_WEB_PORT", "3000")
37+	protocol := shared.GetEnv("PGS_PROTOCOL", "https")
38+	allowRegister := shared.GetEnv("PGS_ALLOW_REGISTER", "1")
39+	storageDir := shared.GetEnv("PGS_STORAGE_DIR", ".storage")
40+	minioURL := shared.GetEnv("MINIO_URL", "")
41+	minioUser := shared.GetEnv("MINIO_ROOT_USER", "")
42+	minioPass := shared.GetEnv("MINIO_ROOT_PASSWORD", "")
43+	dbURL := shared.GetEnv("DATABASE_URL", "")
44+
45+	intro := "To get started, enter a username.\n"
46+	intro += "Then create a folder locally (e.g. ~/sites).\n"
47+	intro += "Finally, send your files to us:\n\n"
48+	intro += fmt.Sprintf("rsync ~/sites/* %s:/<project_name>", domain)
49+
50+	cfg := shared.ConfigSite{
51+		Debug:                debug == "1",
52+		SubdomainsEnabled:    subdomains == "1",
53+		CustomdomainsEnabled: customdomains == "1",
54+		ConfigCms: config.ConfigCms{
55+			Domain:      domain,
56+			Email:       email,
57+			Port:        port,
58+			Protocol:    protocol,
59+			DbURL:       dbURL,
60+			StorageDir:  storageDir,
61+			MinioURL:    minioURL,
62+			MinioUser:   minioUser,
63+			MinioPass:   minioPass,
64+			Description: "a zero-dependency static site hosting platform",
65+			IntroText:   intro,
66+			Space:       "pgs",
67+			AllowedExt: []string{
68+				".jpg",
69+				".jpeg",
70+				".png",
71+				".gif",
72+				".webp",
73+				".svg",
74+				".ico",
75+				".html",
76+				".htm",
77+				".css",
78+				".js",
79+				".pdf",
80+				".txt",
81+				".otf",
82+				".ttf",
83+				".woff",
84+				".woff2",
85+				".json",
86+				".md",
87+			},
88+			Logger:        shared.CreateLogger(),
89+			AllowRegister: allowRegister == "1",
90+		},
91+	}
92+
93+	return &cfg
94+}
A pgs/html/base.layout.tmpl
+19, -0
 1@@ -0,0 +1,19 @@
 2+{{define "base"}}
 3+<!doctype html>
 4+<html lang="en">
 5+  <head>
 6+    <meta charset='utf-8'>
 7+    <meta name="viewport" content="width=device-width, initial-scale=1" />
 8+    <title>{{template "title" .}}</title>
 9+
10+    <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
11+
12+    <meta name="keywords" content="static, site, hosting" />
13+
14+    <link rel="stylesheet" href="/main.css" />
15+
16+    {{template "meta" .}}
17+  </head>
18+  <body {{template "attrs" .}}>{{template "body" .}}</body>
19+</html>
20+{{end}}
A pgs/html/footer.partial.tmpl
+3, -0
1@@ -0,0 +1,3 @@
2+{{define "footer"}}
3+<hr />
4+{{end}}
A pgs/html/help.page.tmpl
+114, -0
  1@@ -0,0 +1,114 @@
  2+{{template "base" .}}
  3+
  4+{{define "title"}}help -- {{.Site.Domain}}{{end}}
  5+
  6+{{define "meta"}}
  7+<meta name="description" content="questions and answers" />
  8+{{end}}
  9+
 10+{{define "attrs"}}{{end}}
 11+
 12+{{define "body"}}
 13+<header>
 14+  <h1 class="text-2xl">Need help?</h1>
 15+  <p>Here are some common questions on using this platform that we would like to answer.</p>
 16+</header>
 17+<main>
 18+  <section id="permission-denied">
 19+    <h2 class="text-xl">
 20+      <a href="#permission-denied" rel="nofollow noopener">#</a>
 21+      I get a permission denied when trying to SSH
 22+    </h2>
 23+    <p>
 24+      Unfortunately SHA-2 RSA keys are <strong>not</strong> currently supported.
 25+    </p>
 26+    <p>
 27+      Unfortunately, due to a shortcoming in Go’s x/crypto/ssh package, we
 28+      not currently support access via new SSH RSA keys: only the old SHA-1 ones will work.
 29+      Until we sort this out you’ll either need an SHA-1 RSA key or a key with another
 30+      algorithm, e.g. Ed25519. Not sure what type of keys you have? You can check with the
 31+      following:
 32+    </p>
 33+    <pre>$ find ~/.ssh/id_*.pub -exec ssh-keygen -l -f {} \;</pre>
 34+    <p>If you’re curious about the inner workings of this problem have a look at:</p>
 35+    <ul>
 36+      <li><a href="https://github.com/golang/go/issues/37278">golang/go#37278</a></li>
 37+      <li><a href="https://go-review.googlesource.com/c/crypto/+/220037">go-review</a></li>
 38+      <li><a href="https://github.com/golang/crypto/pull/197">golang/crypto#197</a></li>
 39+    </ul>
 40+  </section>
 41+
 42+  <section id="blog-ssh-key">
 43+    <h2 class="text-xl">
 44+      <a href="#blog-ssh-key" rel="nofollow noopener">#</a>
 45+      Generating a new SSH key
 46+    </h2>
 47+    <p>
 48+      <a href="https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent">Github reference</a>
 49+    </p>
 50+    <pre>ssh-keygen -t ed25519 -C "your_email@example.com"</pre>
 51+    <ol>
 52+      <li>When you're prompted to "Enter a file in which to save the key," press Enter. This accepts the default file location.</li>
 53+      <li>At the prompt, type a secure passphrase.</li>
 54+    </ol>
 55+  </section>
 56+
 57+  <section id="cli-commands">
 58+    <h2 class="text-xl">
 59+      <a href="#cli-commands" rel="nofollow noopener">#</a>
 60+      CLI Commands
 61+    </h2>
 62+    <pre>ssh {{.Site.Domain}} help</pre>
 63+  </section>
 64+
 65+  <section id="file-types">
 66+    <h2 class="text-xl">
 67+      <a href="#file-types" rel="nofollow noopener">#</a>
 68+      What file types are supported?
 69+    </h2>
 70+    <ul>
 71+      <li>html</li>
 72+      <li>htm</li>
 73+      <li>css</li>
 74+      <li>js</li>
 75+      <li>jpg</li>
 76+      <li>png</li>
 77+      <li>gif</li>
 78+      <li>webp</li>
 79+      <li>svg</li>
 80+      <li>ico</li>
 81+      <li>pdf</li>
 82+      <li>json</li>
 83+      <li>txt</li>
 84+      <li>otf</li>
 85+      <li>ttf</li>
 86+      <li>woff</li>
 87+      <li>woff2</li>
 88+      <li>md</li>
 89+    </ul>
 90+  </section>
 91+
 92+  <section id="custom-domain">
 93+    <h2 class="text-xl">
 94+      <a href="#custom-domain" rel="nofollow noopener">#</a>
 95+      Setup a custom domain
 96+    </h2>
 97+    <p>
 98+      A blog can be accessed from a custom domain.
 99+      HTTPS will be automatically enabled and a certificate will be retrieved
100+      from <a href="https://letsencrypt.org/">Let's Encrypt</a>. In order for this to work,
101+      2 DNS records need to be created:
102+    </p>
103+
104+    <p>CNAME for the domain to pgs (subdomains or DNS hosting with CNAME flattening) or A record</p>
105+    <pre>CNAME subdomain.yourcustomdomain.com -> {{.Site.Domain}}</pre>
106+    <p>Resulting in:</p>
107+    <pre>subdomain.yourcustomdomain.com.         300     IN      CNAME   {{.Site.Domain}}.</pre>
108+    <p>And a TXT record to tell Prose what blog is hosted on that domain at the subdomain entry _pgs</p>
109+    <pre>TXT _pgs.subdomain.yourcustomdomain.com -> yourusername</pre>
110+    <p>Resulting in:</p>
111+    <pre>_pgs.subdomain.yourcustomdomain.com.         300     IN      TXT     "hey"</pre>
112+  </section>
113+</main>
114+{{template "marketing-footer" .}}
115+{{end}}
A pgs/html/marketing-footer.partial.tmpl
+12, -0
 1@@ -0,0 +1,12 @@
 2+{{define "marketing-footer"}}
 3+<footer>
 4+  <hr />
 5+  <p class="font-italic">Built and maintained by <a href="https://pico.sh">pico.sh</a>.</p>
 6+  <div>
 7+    <a href="/">home</a> |
 8+    <a href="/ops.html">ops</a> |
 9+    <a href="/help.html">help</a> |
10+    <a href="https://github.com/picosh/pico">source</a>
11+  </div>
12+</footer>
13+{{end}}
A pgs/html/marketing.page.tmpl
+139, -0
  1@@ -0,0 +1,139 @@
  2+{{template "base" .}}
  3+
  4+{{define "title"}}{{.Site.Domain}} -- a static website hosting service for hackers.{{end}}
  5+
  6+{{define "meta"}}
  7+<meta name="description" content="A zero-dependency static site hosting service for hackers." />
  8+
  9+<meta property="og:type" content="website">
 10+<meta property="og:site_name" content="{{.Site.Domain}}">
 11+<meta property="og:url" content="https://{{.Site.Domain}}">
 12+<meta property="og:title" content="{{.Site.Domain}}">
 13+<meta property="og:description" content="A zero-dependency static site hosting service for hackers.">
 14+
 15+<meta name="twitter:card" content="summary" />
 16+<meta property="twitter:url" content="https://{{.Site.Domain}}">
 17+<meta property="twitter:title" content="{{.Site.Domain}}">
 18+<meta property="twitter:description" content="A zero-dependency static site hosting service for hackers.">
 19+<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
 20+<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
 21+
 22+<meta property="og:image:width" content="300" />
 23+<meta property="og:image:height" content="300" />
 24+<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
 25+<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
 26+{{end}}
 27+
 28+{{define "attrs"}}{{end}}
 29+
 30+{{define "body"}}
 31+<header class="text-center">
 32+  <h1 class="text-2xl font-bold">{{.Site.Domain}}</h1>
 33+  <p class="text-lg">A zero-dependency static site hosting service for hackers.</p>
 34+  <hr />
 35+</header>
 36+
 37+<main>
 38+  <section>
 39+    <h2 class="text-lg font-bold">Examples</h2>
 40+    <ul>
 41+      <li>The site you are reading right now</li>
 42+      <li><a href="https://git.erock.sh">git web viewer</a></li>
 43+    </ul>
 44+  </section>
 45+
 46+  <section>
 47+    <h2 class="text-lg font-bold">Features</h2>
 48+    <ul>
 49+      <li>Terminal workflow</li>
 50+      <li>No client-side installation required to fully manage static sites</li>
 51+      <li>Distinct static sites as "projects"</li>
 52+      <li>Unlimited projects created on-the-fly (no need to create a project first)</li>
 53+      <li>Deploy via <code>rsync -a . erock@{{.Site.Domain}}:/myproject</code></li>
 54+      <li>Symbolic linking from one project to another (to support promotions/rollbacks)</li>
 55+      <li>Managed HTTPS for all projects (e.g. [https://erock-myproject.{{.Site.Domain}}](https://erock-myproject.{{.Site.Domain}}))</li>
 56+      <li>Custom domains for projects (managed simply by `TXT` records)</li>
 57+      <li>1GB max storage</li>
 58+      <li>50MB max file size</li>
 59+      <li>All assets are public-only</li>
 60+      <li>Only web assets are supported</li>
 61+    </ul>
 62+  </section>
 63+
 64+  <section>
 65+    <h2 class="text-lg font-bold">Create your account with Public-Key Cryptography</h2>
 66+    <p>We don't want your email address.</p>
 67+    <p>To get started, simply ssh into our content management system:</p>
 68+    <pre>ssh new@{{.Site.Domain}}</pre>
 69+    <div class="text-sm font-italic note">
 70+      note: <code>new</code> is a special username that will always send you to account
 71+      creation, even with multiple accounts associated with your key-pair.
 72+    </div>
 73+    <div class="text-sm font-italic note">
 74+      note: getting permission denied? <a href="/help#permission-denied">read this</a>
 75+    </div>
 76+    <p>
 77+      After that, just set a username and you're ready to start writing! When you SSH
 78+      again, use your username that you set in the CMS.
 79+    </p>
 80+  </section>
 81+
 82+  <section>
 83+    <h2 class="text-lg font-bold">Publish your site with one command</h2>
 84+    <p>
 85+        When your site is ready to be published, copy the files to our server with a familiar
 86+        command:
 87+    </p>
 88+    <pre>rsync -a . {{.Site.Domain}}:/myproject</pre>
 89+    <p>
 90+      That's it!  There's no need to formally create a project, we
 91+      create them on-the-fly.  Further, we provide TLS for every project
 92+      automatically.  In this case the url for the project above would
 93+      look something like <code>https://{username}-myproject.{{.Site.Domain}}</code>.
 94+    </p>
 95+  </section>
 96+
 97+  <section>
 98+    <h2 class="text-lg font-bold">Manage your projects with a remote CLI</h2>
 99+    <p>
100+      Our management system is done via ssh commands.  Type the following command to learn more:
101+    </p>
102+    <pre>ssh {{.Site.Domain}} help</pre>
103+  </section>
104+
105+  <section>
106+    <h2 class="text-lg font-bold">Project promotion and rollbacks</h2>
107+    <p>
108+      Additionally you can setup a pipeline for promotion and rollbacks.
109+    </p>
110+    <pre>ssh {{.Site.Domain}} link project-prod project-d0131d4</pre>
111+    <p>
112+      A common way to perform promotions within {{.Site.Domain}} is to setup CI/CD so every
113+      push to <code>main</code> would trigger a build and create a new project
114+      based on the git commit hash (e.g. <code>project-d0131d4</code>).
115+    </p>
116+    <p>
117+      This command will create a symbolic link from <code>project-prod</code>
118+      to <code>project-d0131d4</code>. Want to rollback a release?
119+      Just change the link for <code>project-prod</code> to a previous project.
120+    </p>
121+  </section>
122+
123+  <section>
124+    <h2 class="text-lg font-bold">Philosophy</h2>
125+    <p>
126+      Creating a static website should be as simple as copying files from a local folder to a server.
127+    </p>
128+    <p>Read more about team pico's philosophy <a href="https://pico.sh">here</a>.</p>
129+  </section>
130+
131+  <section>
132+    <h2 class="text-lg font-bold">Roadmap</h2>
133+    <ol>
134+      <li>Not sure</li>
135+    </ol>
136+  </section>
137+</main>
138+
139+{{template "marketing-footer" .}}
140+{{end}}
A pgs/html/ops.page.tmpl
+146, -0
  1@@ -0,0 +1,146 @@
  2+{{template "base" .}}
  3+
  4+{{define "title"}}operations -- {{.Site.Domain}}{{end}}
  5+
  6+{{define "meta"}}
  7+<meta name="description" content="{{.Site.Domain}} operations" />
  8+{{end}}
  9+
 10+{{define "attrs"}}{{end}}
 11+
 12+{{define "body"}}
 13+<header>
 14+    <h1 class="text-2xl">Operations</h1>
 15+    <ul>
 16+        <li><a href="/privacy.html">privacy</a></li>
 17+    </ul>
 18+</header>
 19+<main>
 20+  <section>
 21+    <h2 class="text-xl">Purpose</h2>
 22+    <p>
 23+      {{.Site.Domain}} exists to allow people to create and share their thoughts
 24+      without the need to set up their own server or be part of a platform
 25+      that shows ads or tracks its users.
 26+    </p>
 27+  </section>
 28+  <section>
 29+    <h2 class="text-xl">Ethics</h2>
 30+    <p>We are committed to:</p>
 31+    <ul>
 32+      <li>No browser-based tracking of visitor behavior.</li>
 33+      <li>No attempt to identify users.</li>
 34+      <li>Never sell any user or visitor data.</li>
 35+      <li>No ads — ever.</li>
 36+    </ul>
 37+  </section>
 38+  <section>
 39+    <h2 class="text-xl">Code of Content Publication</h2>
 40+    <p>
 41+      Content in {{.Site.Domain}} blogs is unfiltered and unmonitored. Users are free to publish any
 42+      combination of words and pixels except for: content of animosity or disparagement of an
 43+      individual or a group on account of a group characteristic such as race, color, national
 44+      origin, sex, disability, religion, or sexual orientation, which will be taken down
 45+      immediately.
 46+    </p>
 47+    <p>
 48+      If one notices something along those lines in a blog please let us know at
 49+      <a href="mailto:{{.Site.Email}}">{{.Site.Email}}</a>.
 50+    </p>
 51+  </section>
 52+  <section>
 53+    <h2 class="text-xl">Liability</h2>
 54+    <p>
 55+      The user expressly understands and agrees that Eric Bower and Antonio Mika, the operator of this website
 56+      shall not be liable, in law or in equity, to them or to any third party for any direct,
 57+      indirect, incidental, lost profits, special, consequential, punitive or exemplary damages.
 58+    </p>
 59+  </section>
 60+  <section>
 61+    <h2 class="text-xl">Analytics</h2>
 62+    <p>
 63+      We are committed to zero browser-based tracking or trying to identify visitors.  This
 64+      means we do not try to understand the user based on cookies or IP address.  We do not
 65+      store personally identifiable information.
 66+    </p>
 67+    <p>
 68+      However, in order to provide a better service, we do have some analytics on posts.
 69+      List of metrics we track for posts:
 70+    </p>
 71+    <ul>
 72+      <li>anonymous view counts</li>
 73+    </ul>
 74+    <p>
 75+      We might also inspect the headers of HTTP requests to determine some tertiary information
 76+      about the request.  For example we might inspect the <code>User-Agent</code> or
 77+      <code>Referer</code> to filter out requests from bots.
 78+    </p>
 79+  </section>
 80+  <section>
 81+    <h2 class="text-xl">Account Terms</h2>
 82+    <p>
 83+      <ul>
 84+        <li>
 85+          The user is responsible for all content posted and all actions performed with
 86+          their account.
 87+        </li>
 88+        <li>
 89+          We reserve the right to disable or delete a user's account for any reason at
 90+          any time. We have this clause because, statistically speaking, there will be
 91+          people trying to do something nefarious.
 92+        </li>
 93+      </ul>
 94+    </p>
 95+  </section>
 96+  <section>
 97+    <h2 class="text-xl">Service Availability</h2>
 98+    <p>
 99+     We provide the {{.Site.Domain}} service on an "as is" and "as available" basis. We do not offer
100+     service-level agreements but do take uptime seriously.
101+    </p>
102+  </section>
103+  <section>
104+    <h2 class="text-xl">Contact and Support</h2>
105+    <p>
106+      Email us at <a href="mailto:{{.Site.Email}}">{{.Site.Email}}</a>
107+      with any questions.
108+    </p>
109+  </section>
110+  <section>
111+    <h2 class="text-xl">Acknowledgments</h2>
112+    <p>
113+      {{.Site.Domain}} was inspired by <a href="https://mataroa.blog">Mataroa Blog</a>
114+      and <a href="https://bearblog.dev/">Bear Blog</a>.
115+    </p>
116+    <p>
117+      {{.Site.Domain}} is built with many open source technologies.
118+    </p>
119+    <p>
120+      In particular we would like to thank:
121+    </p>
122+    <ul>
123+      <li>
124+        <span>The </span>
125+        <a href="https://charm.sh">charm.sh</a>
126+        <span> community</span>
127+      </li>
128+      <li>
129+        <span>The </span>
130+        <a href="https://go.dev">golang</a>
131+        <span> community</span>
132+      </li>
133+      <li>
134+        <span>The </span>
135+        <a href="https://www.postgresql.org/">postgresql</a>
136+        <span> community</span>
137+      </li>
138+      <li>
139+        <span>The </span>
140+        <a href="https://github.com/caddyserver/caddy">caddy</a>
141+        <span> community</span>
142+      </li>
143+    </ul>
144+  </section>
145+</main>
146+{{template "marketing-footer" .}}
147+{{end}}
A pgs/html/privacy.page.tmpl
+54, -0
 1@@ -0,0 +1,54 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}privacy -- {{.Site.Domain}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="{{.Site.Domain}} privacy policy" />
 8+{{end}}
 9+
10+{{define "attrs"}}{{end}}
11+
12+{{define "body"}}
13+<header>
14+  <h1 class="text-2xl">Privacy</h1>
15+  <p>Details on our privacy and security approach.</p>
16+</header>
17+<main>
18+  <section>
19+    <h2 class="text-xl">Account Data</h2>
20+    <p>
21+      In order to have a functional account at {{.Site.Domain}}, we need to store
22+      your public key.  That is the only piece of information we record for a user.
23+    </p>
24+    <p>
25+      Because we use public-key cryptography, our security posture is a battle-tested
26+      and proven technique for authentication.
27+    </p>
28+  </section>
29+
30+  <section>
31+    <h2 class="text-xl">Third parties</h2>
32+    <p>
33+      We have a strong commitment to never share any user data with any third-parties.
34+    </p>
35+  </section>
36+
37+  <section>
38+    <h2 class="text-xl">Service Providers</h2>
39+    <ul>
40+      <li>
41+        <span>We host our server on </span>
42+        <a href="https://www.oracle.com/cloud/">oracle cloud</a>
43+      </li>
44+    </ul>
45+  </section>
46+
47+  <section>
48+    <h2 class="text-xl">Cookies</h2>
49+    <p>
50+      We do not use any cookies, not even account authentication.
51+    </p>
52+  </section>
53+</main>
54+{{template "marketing-footer" .}}
55+{{end}}
A pgs/public/apple-touch-icon.png
+0, -0
A pgs/public/card.png
+0, -0
A pgs/public/favicon-16x16.png
+0, -0
A pgs/public/favicon.ico
+0, -0
A pgs/public/main.css
+376, -0
  1@@ -0,0 +1,376 @@
  2+*,
  3+::before,
  4+::after {
  5+  box-sizing: border-box;
  6+}
  7+
  8+::-moz-focus-inner {
  9+  border-style: none;
 10+  padding: 0;
 11+}
 12+:-moz-focusring {
 13+  outline: 1px dotted ButtonText;
 14+}
 15+:-moz-ui-invalid {
 16+  box-shadow: none;
 17+}
 18+
 19+@media (prefers-color-scheme: light) {
 20+  :root {
 21+    --white: #6a737d;
 22+    --code: #fff8d3;
 23+    --code-border: #f0d547;
 24+    --pre: #f6f8fa;
 25+    --bg-color: #fff;
 26+    --text-color: #24292f;
 27+    --link-color: #005cc5;
 28+    --visited: #6f42c1;
 29+    --blockquote: #005cc5;
 30+    --blockquote-bg: #fff;
 31+    --hover: #d73a49;
 32+    --grey: #ccc;
 33+  }
 34+}
 35+
 36+@media (prefers-color-scheme: dark) {
 37+  :root {
 38+    --white: #f2f2f2;
 39+    --code: #414558;
 40+    --code-border: #252525;
 41+    --pre: #252525;
 42+    --bg-color: #282a36;
 43+    --text-color: #f2f2f2;
 44+    --link-color: #8be9fd;
 45+    --visited: #bd93f9;
 46+    --blockquote: #bd93f9;
 47+    --blockquote-bg: #414558;
 48+    --hover: #ff80bf;
 49+    --grey: #414558;
 50+  }
 51+}
 52+
 53+html {
 54+  background-color: var(--bg-color);
 55+  color: var(--text-color);
 56+  line-height: 1.5;
 57+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
 58+    Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Arial,
 59+    sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
 60+  -webkit-text-size-adjust: 100%;
 61+  -moz-tab-size: 4;
 62+  tab-size: 4;
 63+}
 64+
 65+body {
 66+  margin: 0 auto;
 67+  max-width: 720px;
 68+}
 69+
 70+img {
 71+  max-width: 100%;
 72+  height: auto;
 73+}
 74+
 75+b,
 76+strong {
 77+  font-weight: bold;
 78+}
 79+
 80+code,
 81+kbd,
 82+samp,
 83+pre {
 84+  font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo,
 85+    monospace;
 86+  font-size: 0.8rem;
 87+}
 88+
 89+code,
 90+kbd,
 91+samp {
 92+  background-color: var(--code);
 93+  border: 1px solid var(--code-border);
 94+}
 95+
 96+pre > code {
 97+  background-color: inherit;
 98+  padding: 0;
 99+  border: none;
100+}
101+
102+code {
103+  border-radius: 0.3rem;
104+  padding: 0.15rem 0.2rem 0.05rem;
105+}
106+
107+pre {
108+  border-radius: 5px;
109+  padding: 1rem;
110+  margin: 1rem 0;
111+  overflow-x: auto;
112+  background-color: var(--pre) !important;
113+}
114+
115+small {
116+  font-size: 0.8rem;
117+}
118+
119+summary {
120+  display: list-item;
121+}
122+
123+h1,
124+h2,
125+h3 {
126+  margin: 0;
127+  padding: 0.6rem 0 0 0;
128+  border: 0;
129+  font-style: normal;
130+  font-weight: inherit;
131+  font-size: inherit;
132+}
133+
134+hr {
135+  color: inherit;
136+  border: 0;
137+  margin: 0;
138+  height: 1px;
139+  background: var(--grey);
140+  margin: 2rem auto;
141+  text-align: center;
142+}
143+
144+a {
145+  text-decoration: underline;
146+  color: var(--link-color);
147+}
148+
149+a:hover,
150+a:visited:hover {
151+  color: var(--hover);
152+}
153+
154+a:visited {
155+  color: var(--visited);
156+}
157+
158+a.link-grey {
159+  text-decoration: underline;
160+  color: var(--white);
161+}
162+
163+a.link-grey:visited {
164+  color: var(--white);
165+}
166+
167+section {
168+  margin-bottom: 1.4rem;
169+}
170+
171+section:last-child {
172+  margin-bottom: 0;
173+}
174+
175+header {
176+  margin: 1rem auto;
177+}
178+
179+p {
180+  margin: 0.8rem 0;
181+}
182+
183+article {
184+  overflow-wrap: break-word;
185+}
186+
187+blockquote {
188+  border-left: 5px solid var(--blockquote);
189+  background-color: var(--blockquote-bg);
190+  padding: 0.8rem;
191+  margin: 1rem 0;
192+}
193+
194+blockquote > p {
195+  margin: 0;
196+}
197+
198+ul,
199+ol {
200+  padding: 0 0 0 2rem;
201+  list-style-position: outside;
202+}
203+
204+ul[style*="list-style-type: none;"] {
205+  padding: 0;
206+}
207+
208+li {
209+  margin: 0.5rem 0;
210+}
211+
212+li > pre {
213+  padding: 0;
214+}
215+
216+footer {
217+  text-align: center;
218+  margin-bottom: 4rem;
219+}
220+
221+dt {
222+  font-weight: bold;
223+}
224+
225+dd {
226+  margin-left: 0;
227+}
228+
229+dd:not(:last-child) {
230+  margin-bottom: 0.5rem;
231+}
232+
233+figure {
234+  margin: 0;
235+}
236+
237+.post-date {
238+  width: 130px;
239+}
240+
241+.text-grey {
242+  color: var(--grey);
243+}
244+
245+.text-2xl {
246+  font-size: 1.85rem;
247+  line-height: 1.15;
248+}
249+
250+.text-xl {
251+  font-size: 1.55rem;
252+  line-height: 1.15;
253+}
254+
255+.text-lg {
256+  font-size: 1.35rem;
257+  line-height: 1.15;
258+}
259+
260+.text-md {
261+  font-size: 1.15rem;
262+  line-height: 1.15;
263+}
264+
265+.text-sm {
266+  font-size: 0.875rem;
267+}
268+
269+.text-center {
270+  text-align: center;
271+}
272+
273+.font-bold {
274+  font-weight: bold;
275+}
276+
277+.font-italic {
278+  font-style: italic;
279+}
280+
281+.inline {
282+  display: inline;
283+}
284+
285+.flex {
286+  display: flex;
287+}
288+
289+.items-center {
290+  align-items: center;
291+}
292+
293+.m-0 {
294+  margin: 0;
295+}
296+
297+.mt {
298+  margin-top: 0.5rem;
299+}
300+
301+.mb {
302+  margin-bottom: 0.5rem;
303+}
304+
305+.mr {
306+  margin-right: 0.5rem;
307+}
308+
309+.ml {
310+  margin-left: 0.5rem;
311+}
312+
313+.my {
314+  margin-top: 0.5rem;
315+  margin-bottom: 0.5rem;
316+}
317+
318+.my-2 {
319+  margin-top: 1rem;
320+  margin-bottom: 1rem;
321+}
322+
323+.mx {
324+  margin-left: 0.5rem;
325+  margin-right: 0.5rem;
326+}
327+
328+.mx-2 {
329+  margin-left: 1rem;
330+  margin-right: 1rem;
331+}
332+
333+.justify-between {
334+  justify-content: space-between;
335+}
336+
337+.flex-1 {
338+  flex: 1;
339+}
340+
341+.layout-aside {
342+  max-width: 50rem;
343+}
344+
345+.layout-aside aside {
346+  width: 200px;
347+}
348+
349+.layout-aside img {
350+  border-radius: 5px;
351+}
352+
353+#readme {
354+  display: none;
355+}
356+
357+@media only screen and (max-width: 600px) {
358+  body {
359+    padding: 1rem;
360+  }
361+
362+  header {
363+    margin: 0;
364+  }
365+
366+  .layout-aside main {
367+    flex-direction: column;
368+  }
369+
370+  aside {
371+    display: none;
372+  }
373+
374+  #readme {
375+    display: block;
376+  }
377+}
A pgs/public/robots.txt
+2, -0
1@@ -0,0 +1,2 @@
2+User-agent: *
3+Allow: /
A pgs/static.go
+66, -0
 1@@ -0,0 +1,66 @@
 2+package pgs
 3+
 4+import (
 5+	"bytes"
 6+	"os"
 7+	"path/filepath"
 8+
 9+	"github.com/picosh/pico/shared"
10+)
11+
12+type PageData struct {
13+	Site shared.SitePageData
14+}
15+
16+type Page struct {
17+	src  string
18+	dest string
19+	cfg  *shared.ConfigSite
20+}
21+
22+func genPage(page *Page) error {
23+	ts, err := shared.RenderTemplate(page.cfg, []string{page.cfg.StaticPath(page.src)})
24+
25+	if err != nil {
26+		page.cfg.Logger.Error(err)
27+		return err
28+	}
29+
30+	data := PageData{
31+		Site: *page.cfg.GetSiteData(),
32+	}
33+	buf := new(bytes.Buffer)
34+	err = ts.Execute(buf, data)
35+	if err != nil {
36+		page.cfg.Logger.Error(err)
37+		return err
38+	}
39+
40+	err = os.WriteFile(page.dest, buf.Bytes(), 0644)
41+	if err != nil {
42+		page.cfg.Logger.Fatal(err)
43+	}
44+
45+	return nil
46+}
47+
48+func GenStaticSite(dir string, cfg *shared.ConfigSite) error {
49+	pages := [][2]string{
50+		{"html/marketing.page.tmpl", "index.html"},
51+		{"html/ops.page.tmpl", "ops.html"},
52+		{"html/privacy.page.tmpl", "privacy.html"},
53+		{"html/help.page.tmpl", "help.html"},
54+	}
55+	for _, page := range pages {
56+		err := genPage(&Page{
57+			src:  page[0],
58+			dest: filepath.Join(dir, page[1]),
59+			cfg:  cfg,
60+		})
61+		if err != nil {
62+			return err
63+		}
64+	}
65+
66+	return nil
67+}
A pgs/wish.go
+187, -0
  1@@ -0,0 +1,187 @@
  2+package pgs
  3+
  4+import (
  5+	"fmt"
  6+	"strings"
  7+
  8+	"github.com/charmbracelet/wish"
  9+	"github.com/gliderlabs/ssh"
 10+	"github.com/picosh/pico/db"
 11+	"github.com/picosh/pico/shared"
 12+	"github.com/picosh/pico/shared/storage"
 13+	"github.com/picosh/pico/wish/cms/util"
 14+	"github.com/picosh/pico/wish/send/utils"
 15+	"go.uber.org/zap"
 16+)
 17+
 18+func getUser(s ssh.Session, dbpool db.DB) (*db.User, error) {
 19+	var err error
 20+	key, err := util.KeyText(s)
 21+	if err != nil {
 22+		return nil, fmt.Errorf("key not found")
 23+	}
 24+
 25+	user, err := dbpool.FindUserForKey(s.User(), key)
 26+	if err != nil {
 27+		return nil, err
 28+	}
 29+
 30+	if user.Name == "" {
 31+		return nil, fmt.Errorf("must have username set")
 32+	}
 33+
 34+	return user, nil
 35+}
 36+
 37+func getHelpText(userName, projectName string) string {
 38+	helpStr := "commands: [rm, list, link, unlink]\n\n"
 39+	sshCmdStr := fmt.Sprintf("ssh %s@pgs.sh", userName)
 40+	helpStr += fmt.Sprintf("`%s help`: prints this screen\n", sshCmdStr)
 41+	helpStr += fmt.Sprintf("`%s list`: lists projects\n", sshCmdStr)
 42+	helpStr += fmt.Sprintf("`%s %s rm`: deletes `%s`\n", sshCmdStr, projectName, projectName)
 43+	helpStr += fmt.Sprintf("`%s %s link projectB`: symbolic link from `%s` to `projectB`\n", sshCmdStr, projectName, projectName)
 44+	helpStr += fmt.Sprintf("`%s %s unlink`: removes symbolic link for `%s`\n", sshCmdStr, projectName, projectName)
 45+	return helpStr
 46+}
 47+
 48+func WishMiddleware(dbpool db.DB, store storage.ObjectStorage, log *zap.SugaredLogger) wish.Middleware {
 49+	return func(sshHandler ssh.Handler) ssh.Handler {
 50+		return func(session ssh.Session) {
 51+			_, _, activePty := session.Pty()
 52+			if activePty {
 53+				_ = session.Exit(0)
 54+				_ = session.Close()
 55+				return
 56+			}
 57+
 58+			user, err := getUser(session, dbpool)
 59+			if err != nil {
 60+				utils.ErrorHandler(session, err)
 61+				return
 62+			}
 63+
 64+			args := session.Command()
 65+			if len(args) == 1 {
 66+				cmd := strings.TrimSpace(args[0])
 67+				if cmd == "help" {
 68+					_, _ = session.Write([]byte(getHelpText(user.Name, "projectA")))
 69+				} else if cmd == "list" {
 70+					projects, err := dbpool.FindProjectsByUser(user.ID)
 71+					if err != nil {
 72+						log.Error(err)
 73+						utils.ErrorHandler(session, err)
 74+						return
 75+					}
 76+
 77+					if len(projects) == 0 {
 78+						out := "no linked projects found\n"
 79+						_, _ = session.Write([]byte(out))
 80+					}
 81+
 82+					for _, project := range projects {
 83+						out := fmt.Sprintf("%s (links to: %s)\n", project.Name, project.ProjectDir)
 84+						_, _ = session.Write([]byte(out))
 85+					}
 86+				}
 87+				return
 88+			} else if len(args) < 2 {
 89+				utils.ErrorHandler(session, fmt.Errorf("must supply project name and then a command"))
 90+				return
 91+			}
 92+
 93+			projectName := strings.TrimSpace(args[0])
 94+			cmd := strings.TrimSpace(args[1])
 95+			log.Infof("pgs middleware detected command: %s", args)
 96+
 97+			if cmd == "help" {
 98+				log.Infof("user (%s) running `help` command", user.Name)
 99+				_, _ = session.Write([]byte(getHelpText(user.Name, projectName)))
100+				return
101+			} else if cmd == "unlink" {
102+				log.Infof("user (%s) running `unlink` command with (%s)", user.Name, projectName)
103+				project, err := dbpool.FindProjectByName(user.ID, projectName)
104+				if err != nil {
105+					log.Error(err)
106+					utils.ErrorHandler(session, fmt.Errorf("project (%s) does not exit", projectName))
107+					return
108+				}
109+				err = dbpool.RemoveProject(project.ID)
110+				if err != nil {
111+					log.Error(err)
112+					utils.ErrorHandler(session, err)
113+					return
114+				}
115+
116+				return
117+			} else if cmd == "link" {
118+				if len(args) < 3 {
119+					utils.ErrorHandler(session, fmt.Errorf("must supply link command like: `projectA link projectB`"))
120+					return
121+				}
122+				linkTo := strings.TrimSpace(args[2])
123+				log.Infof("user (%s) running `link` command with (%s) (%s)", user.Name, projectName, linkTo)
124+
125+				projectDir := linkTo
126+				project, err := dbpool.FindProjectByName(user.ID, projectName)
127+				if err == nil {
128+					log.Infof("user (%s) already has project (%s), updating ...", user.Name, projectName)
129+					err = dbpool.UpdateProject(project.ID, projectDir)
130+					if err != nil {
131+						log.Error(err)
132+						utils.ErrorHandler(session, err)
133+						return
134+					}
135+				} else {
136+					log.Infof("user (%s) has no project record (%s), creating ...", user.Name, projectName)
137+					_, err := dbpool.InsertProject(user.ID, projectName, projectDir)
138+					if err != nil {
139+						log.Error(err)
140+						utils.ErrorHandler(session, err)
141+						return
142+					}
143+				}
144+				out := fmt.Sprintf("(%s) now points to (%s)\n", projectName, linkTo)
145+				_, _ = session.Write([]byte(out))
146+				return
147+			} else if cmd == "rm" {
148+				log.Infof("user (%s) running `rm` command for (%s)", user.Name, projectName)
149+				project, err := dbpool.FindProjectByName(user.ID, projectName)
150+				if err == nil {
151+					log.Infof("found project (%s) (%s), removing ...", projectName, project.ID)
152+					err = dbpool.RemoveProject(project.ID)
153+					if err != nil {
154+						log.Error(err)
155+						utils.ErrorHandler(session, err)
156+					}
157+				}
158+
159+				bucketName := shared.GetAssetBucketName(user.ID)
160+				bucket, err := store.GetBucket(bucketName)
161+				if err != nil {
162+					log.Error(err)
163+					utils.ErrorHandler(session, err)
164+					return
165+				}
166+
167+				fileList, err := store.ListFiles(bucket, projectName)
168+				if err != nil {
169+					log.Error(err)
170+					return
171+				}
172+
173+				for _, file := range fileList {
174+					err = store.DeleteFile(bucket, file.Name())
175+					if err == nil {
176+						_, _ = session.Write([]byte(fmt.Sprintf("deleted (%s)\n", file.Name())))
177+					} else {
178+						log.Error(err)
179+						utils.ErrorHandler(session, err)
180+					}
181+				}
182+				return
183+			}
184+
185+			sshHandler(session)
186+		}
187+	}
188+}
M prose/api.go
+1, -1
 1@@ -15,8 +15,8 @@ import (
 2 	"github.com/picosh/pico/db"
 3 	"github.com/picosh/pico/db/postgres"
 4 	"github.com/picosh/pico/imgs"
 5-	"github.com/picosh/pico/imgs/storage"
 6 	"github.com/picosh/pico/shared"
 7+	"github.com/picosh/pico/shared/storage"
 8 	"golang.org/x/exp/slices"
 9 )
10 
A shared/bucket.go
+24, -0
 1@@ -0,0 +1,24 @@
 2+package shared
 3+
 4+import (
 5+	"fmt"
 6+	"os"
 7+	"path/filepath"
 8+	"strings"
 9+
10+	"github.com/picosh/pico/wish/send/utils"
11+)
12+
13+func GetAssetBucketName(userID string) string {
14+	return fmt.Sprintf("static-%s", userID)
15+}
16+
17+func GetProjectName(entry *utils.FileEntry) string {
18+	dir := filepath.Dir(entry.Filepath)
19+	list := strings.Split(dir, string(os.PathSeparator))
20+	return list[1]
21+}
22+
23+func GetAssetFileName(entry *utils.FileEntry) string {
24+	return entry.Filepath
25+}
M shared/router.go
+1, -1
1@@ -11,7 +11,7 @@ import (
2 
3 	"github.com/patrickmn/go-cache"
4 	"github.com/picosh/pico/db"
5-	"github.com/picosh/pico/imgs/storage"
6+	"github.com/picosh/pico/shared/storage"
7 	"go.uber.org/zap"
8 )
9 
A shared/storage/fs.go
+148, -0
  1@@ -0,0 +1,148 @@
  2+package storage
  3+
  4+import (
  5+	"fmt"
  6+	"io"
  7+	"io/fs"
  8+	"os"
  9+	"path/filepath"
 10+	"strings"
 11+
 12+	"github.com/picosh/pico/wish/send/utils"
 13+)
 14+
 15+// https://stackoverflow.com/a/32482941
 16+func dirSize(path string) (int64, error) {
 17+	var size int64
 18+	err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
 19+		if err != nil {
 20+			return err
 21+		}
 22+		if !info.IsDir() {
 23+			size += info.Size()
 24+		}
 25+		return err
 26+	})
 27+
 28+	return size, err
 29+}
 30+
 31+type StorageFS struct {
 32+	Dir string
 33+}
 34+
 35+func NewStorageFS(dir string) (*StorageFS, error) {
 36+	return &StorageFS{Dir: dir}, nil
 37+}
 38+
 39+func (s *StorageFS) GetBucket(name string) (Bucket, error) {
 40+	dirPath := filepath.Join(s.Dir, name)
 41+	bucket := Bucket{
 42+		Name: name,
 43+		Path: dirPath,
 44+	}
 45+
 46+	info, err := os.Stat(dirPath)
 47+	if os.IsNotExist(err) {
 48+		return bucket, fmt.Errorf("directory does not exist: %v %w", dirPath, err)
 49+	}
 50+
 51+	if err != nil {
 52+		return bucket, fmt.Errorf("directory error: %v %w", dirPath, err)
 53+
 54+	}
 55+
 56+	if !info.IsDir() {
 57+		return bucket, fmt.Errorf("directory is a file, not a directory: %#v", dirPath)
 58+	}
 59+
 60+	return bucket, nil
 61+}
 62+
 63+func (s *StorageFS) UpsertBucket(name string) (Bucket, error) {
 64+	bucket, err := s.GetBucket(name)
 65+	if err == nil {
 66+		return bucket, nil
 67+	}
 68+
 69+	err = os.MkdirAll(bucket.Path, os.ModePerm)
 70+	if err != nil {
 71+		return bucket, err
 72+	}
 73+
 74+	return bucket, nil
 75+}
 76+
 77+func (s *StorageFS) GetBucketQuota(bucket Bucket) (uint64, error) {
 78+	dsize, err := dirSize(bucket.Path)
 79+	return uint64(dsize), err
 80+}
 81+
 82+func (s *StorageFS) DeleteBucket(bucket Bucket) error {
 83+	return os.RemoveAll(bucket.Path)
 84+}
 85+
 86+func (s *StorageFS) GetFile(bucket Bucket, fpath string) (ReaderAtCloser, error) {
 87+	dat, err := os.Open(filepath.Join(bucket.Path, fpath))
 88+	if err != nil {
 89+		return nil, err
 90+	}
 91+
 92+	return dat, nil
 93+}
 94+
 95+func (s *StorageFS) PutFile(bucket Bucket, fpath string, contents ReaderAtCloser) (string, error) {
 96+	loc := filepath.Join(bucket.Path, fpath)
 97+	err := os.MkdirAll(filepath.Dir(loc), os.ModePerm)
 98+	if err != nil {
 99+		return "", err
100+	}
101+	f, err := os.OpenFile(loc, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
102+	if err != nil {
103+		return "", err
104+	}
105+	defer f.Close()
106+
107+	_, err = io.Copy(f, contents)
108+	if err != nil {
109+		return "", err
110+	}
111+
112+	return loc, nil
113+}
114+
115+func (s *StorageFS) DeleteFile(bucket Bucket, fpath string) error {
116+	loc := filepath.Join(bucket.Path, fpath)
117+	err := os.Remove(loc)
118+	if err != nil {
119+		return err
120+	}
121+
122+	return nil
123+}
124+
125+func (s *StorageFS) ListFiles(bucket Bucket, dir string) ([]os.FileInfo, error) {
126+	var fileList []os.FileInfo
127+	fpath := filepath.Join(bucket.Path, dir)
128+	err := filepath.WalkDir(fpath, func(s string, d fs.DirEntry, err error) error {
129+		if err != nil {
130+			return err
131+		}
132+		if !d.IsDir() {
133+			fileInfo, err := os.Stat(s)
134+			if err != nil {
135+				return err
136+			}
137+			info := &utils.VirtualFile{
138+				FName:    strings.Replace(s, bucket.Path, "", 1),
139+				FIsDir:   d.IsDir(),
140+				FSize:    fileInfo.Size(),
141+				FModTime: fileInfo.ModTime(),
142+			}
143+			fileList = append(fileList, info)
144+		}
145+		return nil
146+	})
147+
148+	return fileList, err
149+}
A shared/storage/minio.go
+143, -0
  1@@ -0,0 +1,143 @@
  2+package storage
  3+
  4+import (
  5+	"context"
  6+	"errors"
  7+	"fmt"
  8+	"net/url"
  9+	"os"
 10+	"path"
 11+
 12+	"github.com/minio/madmin-go/v3"
 13+	"github.com/minio/minio-go/v7"
 14+	"github.com/minio/minio-go/v7/pkg/credentials"
 15+	"github.com/picosh/pico/wish/send/utils"
 16+)
 17+
 18+type StorageMinio struct {
 19+	Client *minio.Client
 20+	Admin  *madmin.AdminClient
 21+}
 22+
 23+func NewStorageMinio(address, user, pass string) (*StorageMinio, error) {
 24+	endpoint, err := url.Parse(address)
 25+	if err != nil {
 26+		return nil, err
 27+	}
 28+	ssl := endpoint.Scheme == "https"
 29+
 30+	mClient, err := minio.New(endpoint.Host, &minio.Options{
 31+		Creds:  credentials.NewStaticV4(user, pass, ""),
 32+		Secure: ssl,
 33+	})
 34+	if err != nil {
 35+		return nil, err
 36+	}
 37+
 38+	aClient, err := madmin.New(
 39+		endpoint.Host,
 40+		user,
 41+		pass,
 42+		ssl,
 43+	)
 44+	if err != nil {
 45+		return nil, err
 46+	}
 47+
 48+	mini := &StorageMinio{
 49+		Client: mClient,
 50+		Admin:  aClient,
 51+	}
 52+	return mini, err
 53+}
 54+
 55+func (s *StorageMinio) GetBucket(name string) (Bucket, error) {
 56+	bucket := Bucket{
 57+		Name: name,
 58+	}
 59+
 60+	exists, err := s.Client.BucketExists(context.TODO(), bucket.Name)
 61+	if err != nil || !exists {
 62+		if err == nil {
 63+			err = errors.New("bucket does not exist")
 64+		}
 65+		return bucket, err
 66+	}
 67+
 68+	return bucket, nil
 69+}
 70+
 71+func (s *StorageMinio) UpsertBucket(name string) (Bucket, error) {
 72+	bucket, err := s.GetBucket(name)
 73+	if err == nil {
 74+		return bucket, nil
 75+	}
 76+
 77+	err = s.Client.MakeBucket(context.TODO(), name, minio.MakeBucketOptions{})
 78+	if err != nil {
 79+		return bucket, err
 80+	}
 81+
 82+	return bucket, nil
 83+}
 84+
 85+func (s *StorageMinio) GetBucketQuota(bucket Bucket) (uint64, error) {
 86+	info, err := s.Admin.AccountInfo(context.TODO(), madmin.AccountOpts{})
 87+	if err != nil {
 88+		return 0, nil
 89+	}
 90+	for _, b := range info.Buckets {
 91+		if b.Name == bucket.Name {
 92+			return b.Size, nil
 93+		}
 94+	}
 95+
 96+	return 0, fmt.Errorf("%s bucket not found in account info", bucket.Name)
 97+}
 98+
 99+func (s *StorageMinio) ListFiles(bucket Bucket, dir string) ([]os.FileInfo, error) {
100+	var fileList []os.FileInfo
101+	objs := s.Client.ListObjects(context.TODO(), bucket.Name, minio.ListObjectsOptions{Prefix: dir})
102+	for obj := range objs {
103+		info := &utils.VirtualFile{
104+			FName:    path.Join(dir, obj.Key),
105+			FIsDir:   false,
106+			FSize:    obj.Size,
107+			FModTime: obj.LastModified,
108+		}
109+		fileList = append(fileList, info)
110+	}
111+
112+	return fileList, nil
113+}
114+
115+func (s *StorageMinio) DeleteBucket(bucket Bucket) error {
116+	return s.Client.RemoveBucket(context.TODO(), bucket.Name)
117+}
118+
119+func (s *StorageMinio) GetFile(bucket Bucket, fpath string) (ReaderAtCloser, error) {
120+	obj, err := s.Client.GetObject(context.TODO(), bucket.Name, fpath, minio.GetObjectOptions{})
121+	if err != nil {
122+		return nil, err
123+	}
124+
125+	return obj, nil
126+}
127+
128+func (s *StorageMinio) PutFile(bucket Bucket, fpath string, contents ReaderAtCloser) (string, error) {
129+	info, err := s.Client.PutObject(context.TODO(), bucket.Name, fpath, contents, -1, minio.PutObjectOptions{})
130+	if err != nil {
131+		return "", err
132+	}
133+
134+	return fmt.Sprintf("%s/%s", info.Bucket, info.Key), nil
135+}
136+
137+func (s *StorageMinio) DeleteFile(bucket Bucket, fpath string) error {
138+	err := s.Client.RemoveObject(context.TODO(), bucket.Name, fpath, minio.RemoveObjectOptions{})
139+	if err != nil {
140+		return err
141+	}
142+
143+	return nil
144+}
A shared/storage/storage.go
+43, -0
 1@@ -0,0 +1,43 @@
 2+package storage
 3+
 4+import (
 5+	"io"
 6+	"os"
 7+)
 8+
 9+type Bucket struct {
10+	Name string
11+	Path string
12+}
13+
14+type ReadAndReaderAt interface {
15+	io.ReaderAt
16+	io.Reader
17+}
18+
19+type ReaderAtCloser interface {
20+	io.ReaderAt
21+	io.ReadCloser
22+}
23+
24+func NopReaderAtCloser(r ReadAndReaderAt) ReaderAtCloser {
25+	return nopReaderAtCloser{r}
26+}
27+
28+type nopReaderAtCloser struct {
29+	ReadAndReaderAt
30+}
31+
32+func (nopReaderAtCloser) Close() error { return nil }
33+
34+type ObjectStorage interface {
35+	GetBucket(name string) (Bucket, error)
36+	UpsertBucket(name string) (Bucket, error)
37+
38+	DeleteBucket(bucket Bucket) error
39+	GetBucketQuota(bucket Bucket) (uint64, error)
40+	GetFile(bucket Bucket, fpath string) (ReaderAtCloser, error)
41+	PutFile(bucket Bucket, fpath string, contents ReaderAtCloser) (string, error)
42+	DeleteFile(bucket Bucket, fpath string) error
43+	ListFiles(bucket Bucket, dir string) ([]os.FileInfo, error)
44+}
M shared/util.go
+37, -0
 1@@ -125,3 +125,40 @@ func Shasum(data []byte) string {
 2 	bs := h.Sum(nil)
 3 	return hex.EncodeToString(bs)
 4 }
 5+
 6+func GetMimeType(fpath string) string {
 7+	ext := filepath.Ext(fpath)
 8+	if ext == ".svg" {
 9+		return "image/svg+xml"
10+	} else if ext == ".css" {
11+		return "text/css"
12+	} else if ext == ".js" {
13+		return "text/javascript"
14+	} else if ext == ".ico" {
15+		return "image/x-icon"
16+	} else if ext == ".pdf" {
17+		return "application/pdf"
18+	} else if ext == ".html" || ext == ".htm" {
19+		return "text/html"
20+	} else if ext == ".jpg" || ext == ".jpeg" {
21+		return "image/jpeg"
22+	} else if ext == ".png" {
23+		return "image/png"
24+	} else if ext == ".gif" {
25+		return "image/gif"
26+	} else if ext == ".webp" {
27+		return "image/webp"
28+	} else if ext == ".otf" {
29+		return "font/otf"
30+	} else if ext == ".woff" {
31+		return "font/woff"
32+	} else if ext == ".woff2" {
33+		return "font/woff2"
34+	} else if ext == ".ttf" {
35+		return "font/ttf"
36+	} else if ext == ".md" {
37+		return "text/markdown; charset=UTF-8"
38+	}
39+
40+	return "text/plain"
41+}
A sql/migrations/20230707_add_projects_table.sql
+15, -0
 1@@ -0,0 +1,15 @@
 2+CREATE TABLE IF NOT EXISTS projects (
 3+  id uuid NOT NULL DEFAULT uuid_generate_v4(),
 4+  user_id uuid NOT NULL,
 5+  name character varying(255) NOT NULL,
 6+  project_dir text NOT NULL DEFAULT '',
 7+  created_at timestamp without time zone NOT NULL DEFAULT NOW(),
 8+  updated_at timestamp without time zone NOT NULL DEFAULT NOW(),
 9+  CONSTRAINT projects_pkey PRIMARY KEY (id),
10+  CONSTRAINT unique_name_for_user UNIQUE (user_id, name),
11+  CONSTRAINT fk_projects_app_users
12+    FOREIGN KEY(user_id)
13+  REFERENCES app_users(id)
14+  ON DELETE CASCADE
15+  ON UPDATE CASCADE
16+);
M wish/cmd/server/main.go
+2, -2
 1@@ -29,7 +29,7 @@ func (h *handler) Validate(session ssh.Session) error {
 2 	return nil
 3 }
 4 
 5-func (h *handler) Read(session ssh.Session, filename string) (os.FileInfo, io.ReaderAt, error) {
 6+func (h *handler) Read(session ssh.Session, entry *utils.FileEntry) (os.FileInfo, io.ReaderAt, error) {
 7 	log.Printf("Received validate from session: %+v", session)
 8 
 9 	data := strings.NewReader("lorem ipsum dolor")
10@@ -42,7 +42,7 @@ func (h *handler) Read(session ssh.Session, filename string) (os.FileInfo, io.Re
11 	}, data, nil
12 }
13 
14-func (h *handler) List(session ssh.Session, filename string) ([]os.FileInfo, error) {
15+func (h *handler) List(session ssh.Session, fpath string) ([]os.FileInfo, error) {
16 	return nil, nil
17 }
18 
M wish/cms/cms.go
+1, -1
1@@ -14,7 +14,7 @@ import (
2 	"github.com/muesli/reflow/wrap"
3 	"github.com/picosh/pico/db"
4 	"github.com/picosh/pico/db/postgres"
5-	"github.com/picosh/pico/imgs/storage"
6+	"github.com/picosh/pico/shared/storage"
7 	"github.com/picosh/pico/wish/cms/config"
8 	"github.com/picosh/pico/wish/cms/ui/account"
9 	"github.com/picosh/pico/wish/cms/ui/common"
M wish/cms/ui/info/info.go
+1, -1
1@@ -78,7 +78,7 @@ func (m Model) bioView() string {
2 
3 	return common.KeyValueView(
4 		"Username", username,
5-		"Blog URL", m.urls.BlogURL(username),
6+		"URL", m.urls.BlogURL(username),
7 		"Public key", m.User.PublicKey.Key,
8 		"Joined", m.User.CreatedAt.Format("02 Jan 2006"),
9 	)
M wish/cms/ui/posts/posts.go
+1, -1
1@@ -8,7 +8,7 @@ import (
2 	tea "github.com/charmbracelet/bubbletea"
3 
4 	"github.com/picosh/pico/db"
5-	"github.com/picosh/pico/imgs/storage"
6+	"github.com/picosh/pico/shared/storage"
7 	"github.com/picosh/pico/wish/cms/config"
8 	"github.com/picosh/pico/wish/cms/ui/common"
9 
M wish/pipe/pipe.go
+0, -1
1@@ -30,7 +30,6 @@ func Middleware(writeHandler utils.CopyFromClientHandler, ext string) wish.Middl
2 			}
3 
4 			result, err := writeHandler.Write(session, &utils.FileEntry{
5-				Name:     name,
6 				Filepath: name,
7 				Mode:     fs.FileMode(0777),
8 				Size:     0,
M wish/send/rsync/rsync.go
+6, -7
 1@@ -6,7 +6,7 @@ import (
 2 	"io/fs"
 3 	"log"
 4 	"os"
 5-	"path"
 6+	"path/filepath"
 7 
 8 	"github.com/antoniomika/go-rsync-receiver/rsyncreceiver"
 9 	"github.com/antoniomika/go-rsync-receiver/rsyncsender"
10@@ -25,7 +25,7 @@ func (h *handler) Skip(file *rsyncutils.ReceiverFile) bool {
11 	return false
12 }
13 
14-func (h *handler) List(path string) ([]os.FileInfo, error) {
15+func (h *handler) List(path string) ([]fs.FileInfo, error) {
16 	list, err := h.writeHandler.List(h.session, path)
17 	if err != nil {
18 		return nil, err
19@@ -40,20 +40,19 @@ func (h *handler) List(path string) ([]os.FileInfo, error) {
20 }
21 
22 func (h *handler) Read(path string) (os.FileInfo, io.ReaderAt, error) {
23-	return h.writeHandler.Read(h.session, path)
24+	return h.writeHandler.Read(h.session, &utils.FileEntry{Filepath: path})
25 }
26 
27 func (h *handler) Put(fileName string, content io.Reader, fileSize int64, mTime int64, aTime int64) (int64, error) {
28-	cleanName := path.Base(fileName)
29+	cleanName := filepath.Base(fileName)
30+	fpath := "/"
31 	fileEntry := &utils.FileEntry{
32-		Name:     cleanName,
33-		Filepath: fmt.Sprintf("/%s", cleanName),
34+		Filepath: filepath.Join(fpath, cleanName),
35 		Mode:     fs.FileMode(0600),
36 		Size:     fileSize,
37 		Mtime:    mTime,
38 		Atime:    aTime,
39 	}
40-
41 	fileEntry.Reader = content
42 
43 	msg, err := h.writeHandler.Write(h.session, fileEntry)
M wish/send/scp/copy_from_client.go
+0, -1
1@@ -86,7 +86,6 @@ func copyFromClient(session ssh.Session, info Info, handler utils.CopyFromClient
2 			_, _ = session.Write(utils.NULL)
3 
4 			result, err := handler.Write(session, &utils.FileEntry{
5-				Name:     name,
6 				Filepath: filepath.Join(path, name),
7 				Mode:     fs.FileMode(mode),
8 				Size:     size,
M wish/send/sftp/handler.go
+10, -7
 1@@ -4,7 +4,6 @@ import (
 2 	"errors"
 3 	"io"
 4 	"os"
 5-	"path"
 6 
 7 	"github.com/gliderlabs/ssh"
 8 	"github.com/picosh/pico/wish/send/utils"
 9@@ -54,20 +53,23 @@ func (f *handler) Filelist(r *sftp.Request) (sftp.ListerAt, error) {
10 	return nil, errors.New("unsupported")
11 }
12 
13-func (f *handler) Filewrite(r *sftp.Request) (io.WriterAt, error) {
14-	fileEntry := &utils.FileEntry{
15-		Name:     path.Base(r.Filepath),
16+func toFileEntry(r *sftp.Request) *utils.FileEntry {
17+	entry := &utils.FileEntry{
18 		Filepath: r.Filepath,
19 		Mode:     r.Attributes().FileMode(),
20 		Size:     int64(r.Attributes().Size),
21 		Mtime:    int64(r.Attributes().Mtime),
22 		Atime:    int64(r.Attributes().Atime),
23 	}
24+	return entry
25+}
26 
27+func (f *handler) Filewrite(r *sftp.Request) (io.WriterAt, error) {
28+	entry := toFileEntry(r)
29 	buf := &buffer{}
30-	fileEntry.Reader = buf
31+	entry.Reader = buf
32 
33-	return fakeWrite{fileEntry: fileEntry, buf: buf, handler: f}, nil
34+	return fakeWrite{fileEntry: entry, buf: buf, handler: f}, nil
35 }
36 
37 func (f *handler) Fileread(r *sftp.Request) (io.ReaderAt, error) {
38@@ -75,7 +77,8 @@ func (f *handler) Fileread(r *sftp.Request) (io.ReaderAt, error) {
39 		return nil, os.ErrInvalid
40 	}
41 
42-	_, reader, err := f.writeHandler.Read(f.session, r.Filepath)
43+	fileEntry := toFileEntry(r)
44+	_, reader, err := f.writeHandler.Read(f.session, fileEntry)
45 
46 	return reader, err
47 }
M wish/send/utils/utils.go
+4, -3
 1@@ -6,6 +6,7 @@ import (
 2 	"io"
 3 	"io/fs"
 4 	"os"
 5+	"path/filepath"
 6 	"strconv"
 7 
 8 	"github.com/gliderlabs/ssh"
 9@@ -17,7 +18,6 @@ var NULL = []byte{'\x00'}
10 // FileEntry is an Entry that reads from a Reader, defining a file and
11 // its contents.
12 type FileEntry struct {
13-	Name     string
14 	Filepath string
15 	Mode     fs.FileMode
16 	Size     int64
17@@ -33,7 +33,8 @@ func (e *FileEntry) Write(w io.Writer) error {
18 			return fmt.Errorf("failed to write file: %q: %w", e.Filepath, err)
19 		}
20 	}
21-	if _, err := fmt.Fprintf(w, "C%s %d %s\n", octalPerms(e.Mode), e.Size, e.Name); err != nil {
22+	fname := filepath.Base(e.Filepath)
23+	if _, err := fmt.Fprintf(w, "C%s %d %s\n", octalPerms(e.Mode), e.Size, fname); err != nil {
24 		return fmt.Errorf("failed to write file: %q: %w", e.Filepath, err)
25 	}
26 
27@@ -56,7 +57,7 @@ func octalPerms(info fs.FileMode) string {
28 type CopyFromClientHandler interface {
29 	// Write should write the given file.
30 	Write(ssh.Session, *FileEntry) (string, error)
31-	Read(ssh.Session, string) (os.FileInfo, io.ReaderAt, error)
32+	Read(ssh.Session, *FileEntry) (os.FileInfo, io.ReaderAt, error)
33 	List(ssh.Session, string) ([]os.FileInfo, error)
34 	Validate(ssh.Session) error
35 }