- 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)
+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
+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:
+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:
+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"
+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 {
+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"
+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"
+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+}
+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+}
+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+}
+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"
+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 }
+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+}
+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:
+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
+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
+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 {
+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+}
+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+}
+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"
+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) {
+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=
+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 )
+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
+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-}
+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-}
+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
+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 {
+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+}
+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+}
+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+}
+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}}
1@@ -0,0 +1,3 @@
2+{{define "footer"}}
3+<hr />
4+{{end}}
+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}}
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}}
+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}}
+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}}
+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}}
+0,
-0
+0,
-0
+0,
-0
+0,
-0
+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+}
+2,
-0
1@@ -0,0 +1,2 @@
2+User-agent: *
3+Allow: /
+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+}
+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+}
+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
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+}
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
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+}
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+}
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+}
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+}
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+);
+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
+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"
+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 )
+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
+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,
+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)
+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,
+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 }
+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 }