repos / pico

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

commit
43ef9bb
parent
0df20ce
author
Eric Bower
date
2024-02-24 16:55:44 +0000 UTC
feat(pgs): private projects with web tunnels (#80)

We can support private projects via local forward ssh tunnels.

Eric sets ACL for project `hey-docs-dev`:

```
ssh pgs.sh acl hey-docs-dev pico antonio
```

Antonio creates SSH tunnel to project:

```
ssh -L 5000:localhost:80 -N hey-docs-dev@pgs.sh
```

Antonio can access site via http://localhost:5000

Other options we support:

- `acl public` -> default, open to everyone
- `acl pico` -> private to any pico user
- `acl pico xxx,yyy` -> private to specific pico users
- `acl public_keys` -> private to anyone with a pubkey
- `acl public_keys sha256:xxx,sha256:yyy` -> private to specific pubkeys

Further, there is a special API endpoint for any sites accessed via a
tunnel: GET http://localhost:5000/pico

This will return information about the user accessing the site, like
pico username, public key used, etc.
22 files changed,  +427, -134
M go.mod
M go.sum
M Makefile
+2, -1
 1@@ -110,10 +110,11 @@ migrate:
 2 	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20230707_add_projects_table.sql
 3 	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20230921_add_tokens_table.sql
 4 	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20240120_add_payment_history.sql
 5+	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20240221_add_project_acl.sql
 6 .PHONY: migrate
 7 
 8 latest:
 9-	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20240120_add_payment_history.sql
10+	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20240221_add_project_acl.sql
11 .PHONY: latest
12 
13 psql:
M db/db.go
+26, -2
 1@@ -32,25 +32,48 @@ type PostData struct {
 2 	LastDigest *time.Time `json:"last_digest"`
 3 }
 4 
 5+// Make the Attrs struct implement the driver.Valuer interface. This method
 6+// simply returns the JSON-encoded representation of the struct.
 7+func (p PostData) Value() (driver.Value, error) {
 8+	return json.Marshal(p)
 9+}
10+
11+// Make the Attrs struct implement the sql.Scanner interface. This method
12+// simply decodes a JSON-encoded value into the struct fields.
13+func (p *PostData) Scan(value interface{}) error {
14+	b, ok := value.([]byte)
15+	if !ok {
16+		return errors.New("type assertion to []byte failed")
17+	}
18+
19+	return json.Unmarshal(b, &p)
20+}
21+
22 type Project struct {
23 	ID         string     `json:"id"`
24 	UserID     string     `json:"user_id"`
25 	Name       string     `json:"name"`
26 	ProjectDir string     `json:"project_dir"`
27 	Username   string     `json:"username"`
28+	Acl        ProjectAcl `json:"acl"`
29 	CreatedAt  *time.Time `json:"created_at"`
30 	UpdatedAt  *time.Time `json:"updated_at"`
31 }
32 
33+type ProjectAcl struct {
34+	Type string   `json:"type"`
35+	Data []string `json:"data"`
36+}
37+
38 // Make the Attrs struct implement the driver.Valuer interface. This method
39 // simply returns the JSON-encoded representation of the struct.
40-func (p PostData) Value() (driver.Value, error) {
41+func (p ProjectAcl) Value() (driver.Value, error) {
42 	return json.Marshal(p)
43 }
44 
45 // Make the Attrs struct implement the sql.Scanner interface. This method
46 // simply decodes a JSON-encoded value into the struct fields.
47-func (p *PostData) Scan(value interface{}) error {
48+func (p *ProjectAcl) Scan(value interface{}) error {
49 	b, ok := value.([]byte)
50 	if !ok {
51 		return errors.New("type assertion to []byte failed")
52@@ -288,6 +311,7 @@ type DB interface {
53 
54 	InsertProject(userID, name, projectDir string) (string, error)
55 	UpdateProject(userID, name string) error
56+	UpdateProjectAcl(userID, name string, acl ProjectAcl) error
57 	LinkToProject(userID, projectID, projectDir string, commit bool) error
58 	RemoveProject(projectID string) error
59 	FindProjectByName(userID, name string) (*Project, error)
M db/postgres/storage.go
+16, -5
 1@@ -255,17 +255,18 @@ const (
 2 
 3 	sqlInsertProject      = `INSERT INTO projects (user_id, name, project_dir) VALUES ($1, $2, $3) RETURNING id;`
 4 	sqlUpdateProject      = `UPDATE projects SET updated_at = $3 WHERE user_id = $1 AND name = $2;`
 5-	sqlFindProjectByName  = `SELECT id, user_id, name, project_dir, created_at, updated_at FROM projects WHERE user_id = $1 AND name = $2;`
 6+	sqlUpdateProjectAcl   = `UPDATE projects SET acl = $3, updated_at = $4 WHERE user_id = $1 AND name = $2;`
 7+	sqlFindProjectByName  = `SELECT id, user_id, name, project_dir, acl, created_at, updated_at FROM projects WHERE user_id = $1 AND name = $2;`
 8 	sqlSelectProjectCount = `SELECT count(id) FROM projects`
 9 	sqlFindAllProjects    = `
10-	SELECT projects.id, user_id, app_users.name as username, projects.name, project_dir, projects.created_at, projects.updated_at
11+	SELECT projects.id, user_id, app_users.name as username, projects.name, project_dir, projects.acl, projects.created_at, projects.updated_at
12 	FROM projects
13 	LEFT JOIN app_users ON app_users.id = projects.user_id
14 	ORDER BY created_at ASC
15 	LIMIT $1 OFFSET $2`
16-	sqlFindProjectsByUser   = `SELECT id, user_id, name, project_dir, created_at, updated_at FROM projects WHERE user_id = $1 ORDER BY updated_at DESC;`
17-	sqlFindProjectsByPrefix = `SELECT id, user_id, name, project_dir, created_at, updated_at FROM projects WHERE user_id = $1 AND name = project_dir AND name ILIKE $2 ORDER BY updated_at ASC, name ASC;`
18-	sqlFindProjectLinks     = `SELECT id, user_id, name, project_dir, created_at, updated_at FROM projects WHERE user_id = $1 AND name != project_dir AND project_dir = $2 ORDER BY name ASC;`
19+	sqlFindProjectsByUser   = `SELECT id, user_id, name, project_dir, acl, created_at, updated_at FROM projects WHERE user_id = $1 ORDER BY updated_at DESC;`
20+	sqlFindProjectsByPrefix = `SELECT id, user_id, name, project_dir, acl, created_at, updated_at FROM projects WHERE user_id = $1 AND name = project_dir AND name ILIKE $2 ORDER BY updated_at ASC, name ASC;`
21+	sqlFindProjectLinks     = `SELECT id, user_id, name, project_dir, acl, created_at, updated_at FROM projects WHERE user_id = $1 AND name != project_dir AND project_dir = $2 ORDER BY name ASC;`
22 	sqlLinkToProject        = `UPDATE projects SET project_dir = $1, updated_at = $2 WHERE id = $3;`
23 	sqlRemoveProject        = `DELETE FROM projects WHERE id = $1;`
24 )
25@@ -1257,6 +1258,11 @@ func (me *PsqlDB) UpdateProject(userID, name string) error {
26 	return err
27 }
28 
29+func (me *PsqlDB) UpdateProjectAcl(userID, name string, acl db.ProjectAcl) error {
30+	_, err := me.Db.Exec(sqlUpdateProjectAcl, userID, name, acl, time.Now())
31+	return err
32+}
33+
34 func (me *PsqlDB) LinkToProject(userID, projectID, projectDir string, commit bool) error {
35 	linkToProject, err := me.FindProjectByName(userID, projectDir)
36 	if err != nil {
37@@ -1314,6 +1320,7 @@ func (me *PsqlDB) FindProjectByName(userID, name string) (*db.Project, error) {
38 		&project.UserID,
39 		&project.Name,
40 		&project.ProjectDir,
41+		&project.Acl,
42 		&project.CreatedAt,
43 		&project.UpdatedAt,
44 	)
45@@ -1337,6 +1344,7 @@ func (me *PsqlDB) FindProjectLinks(userID, name string) ([]*db.Project, error) {
46 			&project.UserID,
47 			&project.Name,
48 			&project.ProjectDir,
49+			&project.Acl,
50 			&project.CreatedAt,
51 			&project.UpdatedAt,
52 		)
53@@ -1367,6 +1375,7 @@ func (me *PsqlDB) FindProjectsByPrefix(userID, prefix string) ([]*db.Project, er
54 			&project.UserID,
55 			&project.Name,
56 			&project.ProjectDir,
57+			&project.Acl,
58 			&project.CreatedAt,
59 			&project.UpdatedAt,
60 		)
61@@ -1397,6 +1406,7 @@ func (me *PsqlDB) FindProjectsByUser(userID string) ([]*db.Project, error) {
62 			&project.UserID,
63 			&project.Name,
64 			&project.ProjectDir,
65+			&project.Acl,
66 			&project.CreatedAt,
67 			&project.UpdatedAt,
68 		)
69@@ -1428,6 +1438,7 @@ func (me *PsqlDB) FindAllProjects(page *db.Pager) (*db.Paginate[*db.Project], er
70 			&project.Username,
71 			&project.Name,
72 			&project.ProjectDir,
73+			&project.Acl,
74 			&project.CreatedAt,
75 			&project.UpdatedAt,
76 		)
M feeds/ssh.go
+1, -1
1@@ -41,7 +41,7 @@ func createRouter(handler *filehandlers.FileHandlerRouter) proxy.Router {
2 			scp.Middleware(handler),
3 			wishrsync.Middleware(handler),
4 			auth.Middleware(handler),
5-			bm.Middleware(cms.Middleware(&handler.Cfg.ConfigCms, handler.Cfg)),
6+			wsh.PtyMdw(bm.Middleware(cms.Middleware(&handler.Cfg.ConfigCms, handler.Cfg))),
7 			wsh.LogMiddleware(handler.GetLogger()),
8 		}
9 	}
M filehandlers/assets/handler.go
+1, -2
 1@@ -16,7 +16,6 @@ import (
 2 	futil "github.com/picosh/pico/filehandlers/util"
 3 	"github.com/picosh/pico/shared"
 4 	"github.com/picosh/pico/shared/storage"
 5-	"github.com/picosh/pico/wish/cms/util"
 6 	"github.com/picosh/pobj"
 7 	sst "github.com/picosh/pobj/storage"
 8 	"github.com/picosh/send/send/utils"
 9@@ -165,7 +164,7 @@ func (h *UploadAssetHandler) List(s ssh.Session, fpath string, isDir bool, recur
10 
11 func (h *UploadAssetHandler) Validate(s ssh.Session) error {
12 	var err error
13-	key, err := util.KeyText(s)
14+	key, err := shared.KeyText(s)
15 	if err != nil {
16 		return fmt.Errorf("key not found")
17 	}
M go.mod
+14, -9
 1@@ -6,11 +6,11 @@ require (
 2 	github.com/alecthomas/chroma v0.10.0
 3 	github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
 4 	github.com/charmbracelet/bubbles v0.16.1
 5-	github.com/charmbracelet/bubbletea v0.24.2
 6+	github.com/charmbracelet/bubbletea v0.25.0
 7 	github.com/charmbracelet/lipgloss v0.9.1
 8 	github.com/charmbracelet/promwish v0.7.0
 9-	github.com/charmbracelet/ssh v0.0.0-20230822194956-1a051f898e09
10-	github.com/charmbracelet/wish v1.2.0
11+	github.com/charmbracelet/ssh v0.0.0-20240130183930-33d2a30e8568
12+	github.com/charmbracelet/wish v1.3.0
13 	github.com/google/go-cmp v0.6.0
14 	github.com/gorilla/feeds v1.1.2
15 	github.com/lib/pq v1.10.9
16@@ -21,13 +21,14 @@ require (
17 	github.com/neurosnap/go-exif-remove v0.0.0-20221010134343-50d1e3c35577
18 	github.com/patrickmn/go-cache v2.1.0+incompatible
19 	github.com/picosh/pobj v0.0.0-20240218150308-1dc70e819bbf
20+	github.com/picosh/ptun v0.0.0-20240220214714-1c94994f91cc
21 	github.com/picosh/send v0.0.0-20240217194807-77b972121e63
22 	github.com/sendgrid/sendgrid-go v3.13.0+incompatible
23 	github.com/yuin/goldmark v1.6.0
24 	github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594
25 	github.com/yuin/goldmark-meta v1.1.0
26 	go.abhg.dev/goldmark/anchor v0.1.1
27-	golang.org/x/crypto v0.17.0
28+	golang.org/x/crypto v0.18.0
29 	gopkg.in/yaml.v2 v2.4.0
30 )
31 
32@@ -43,8 +44,11 @@ require (
33 	github.com/beorn7/perks v1.0.1 // indirect
34 	github.com/cespare/xxhash/v2 v2.2.0 // indirect
35 	github.com/charmbracelet/keygen v0.5.0 // indirect
36-	github.com/charmbracelet/log v0.3.0 // indirect
37+	github.com/charmbracelet/log v0.3.1 // indirect
38+	github.com/charmbracelet/x/errors v0.0.0-20240117030013-d31dba354651 // indirect
39+	github.com/charmbracelet/x/exp/term v0.0.0-20240130180102-bafe6fbaee60 // indirect
40 	github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
41+	github.com/creack/pty v1.1.21 // indirect
42 	github.com/dlclark/regexp2 v1.10.0 // indirect
43 	github.com/dsoprea/go-exif v0.0.0-20230826092837-6579e82b732d // indirect
44 	github.com/dsoprea/go-exif/v2 v2.0.0-20230826092837-6579e82b732d // indirect
45@@ -105,12 +109,13 @@ require (
46 	github.com/tinylib/msgp v1.1.9 // indirect
47 	github.com/tklauser/go-sysconf v0.3.12 // indirect
48 	github.com/tklauser/numcpus v0.6.1 // indirect
49+	github.com/u-root/u-root v0.11.0 // indirect
50 	github.com/yusufpapurcu/wmi v1.2.3 // indirect
51 	golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect
52-	golang.org/x/net v0.18.0 // indirect
53-	golang.org/x/sync v0.5.0 // indirect
54-	golang.org/x/sys v0.15.0 // indirect
55-	golang.org/x/term v0.15.0 // indirect
56+	golang.org/x/net v0.19.0 // indirect
57+	golang.org/x/sync v0.6.0 // indirect
58+	golang.org/x/sys v0.16.0 // indirect
59+	golang.org/x/term v0.16.0 // indirect
60 	golang.org/x/text v0.14.0 // indirect
61 	google.golang.org/protobuf v1.31.0 // indirect
62 	gopkg.in/ini.v1 v1.67.0 // indirect
M go.sum
+30, -20
  1@@ -25,22 +25,28 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj
  2 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
  3 github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY=
  4 github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc=
  5-github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY=
  6-github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg=
  7+github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
  8+github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
  9 github.com/charmbracelet/keygen v0.5.0 h1:XY0fsoYiCSM9axkrU+2ziE6u6YjJulo/b9Dghnw6MZc=
 10 github.com/charmbracelet/keygen v0.5.0/go.mod h1:DfvCgLHxZ9rJxdK0DGw3C/LkV4SgdGbnliHcObV3L+8=
 11 github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
 12 github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
 13-github.com/charmbracelet/log v0.3.0 h1:u5aB2KJDgNZo4WOfOC8C+KvGIkJ2rCFNlPWDu6xhnqI=
 14-github.com/charmbracelet/log v0.3.0/go.mod h1:OR4E1hutLsax3ZKpXbgUqPtTjQfrh1pG3zwHGWuuq8g=
 15+github.com/charmbracelet/log v0.3.1 h1:TjuY4OBNbxmHWSwO3tosgqs5I3biyY8sQPny/eCMTYw=
 16+github.com/charmbracelet/log v0.3.1/go.mod h1:OR4E1hutLsax3ZKpXbgUqPtTjQfrh1pG3zwHGWuuq8g=
 17 github.com/charmbracelet/promwish v0.7.0 h1:oaMH+ey6W4DDIv1xucS8jL1ik/Q46qxjNXlh6XxEm+s=
 18 github.com/charmbracelet/promwish v0.7.0/go.mod h1:WbRJN9irg8LmsBU8G2rFF8md9O3rSg63qrnqquP/+cs=
 19-github.com/charmbracelet/ssh v0.0.0-20230822194956-1a051f898e09 h1:ZDIQmTtohv0S/AAYE//w8mYTxCzqphhF1+4ACPDMiLU=
 20-github.com/charmbracelet/ssh v0.0.0-20230822194956-1a051f898e09/go.mod h1:F1vgddWsb/Yr/OZilFeRZEh5sE/qU0Dt1mKkmke6Zvg=
 21-github.com/charmbracelet/wish v1.2.0 h1:h5Wj9pr97IQz/l4gM5Xep2lXcY/YM+6O2RC2o3x0JIQ=
 22-github.com/charmbracelet/wish v1.2.0/go.mod h1:JX3fC+178xadJYAhPu6qWtVDpJTwpnFvpdjz9RKJlUE=
 23+github.com/charmbracelet/ssh v0.0.0-20240130183930-33d2a30e8568 h1:augSeqKvp+CMPYCHEZ2/OcZ0em1w8A3pGSmUyrixPNs=
 24+github.com/charmbracelet/ssh v0.0.0-20240130183930-33d2a30e8568/go.mod h1:IHy7o73i1MrQ5lmyJjjJ0g7y4+V+g69cm+Y7JCiZWPo=
 25+github.com/charmbracelet/wish v1.3.0 h1:SYV5TIlzDb6WaxjkkYXxv2WZsTu/QZGwfGVc0UB5M48=
 26+github.com/charmbracelet/wish v1.3.0/go.mod h1:1U/bI7zX+IE26ThD5gxtLgeRzctVhSrTpjucPqw4Pos=
 27+github.com/charmbracelet/x/errors v0.0.0-20240117030013-d31dba354651 h1:3RXpZWGWTOeVXCTv0Dnzxdv/MhNUkBfEcbaTY0zrTQI=
 28+github.com/charmbracelet/x/errors v0.0.0-20240117030013-d31dba354651/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
 29+github.com/charmbracelet/x/exp/term v0.0.0-20240130180102-bafe6fbaee60 h1:IV19YKUZVf6ATrhiPSCirZ4Bs7EsenYwOWcUHngV+q0=
 30+github.com/charmbracelet/x/exp/term v0.0.0-20240130180102-bafe6fbaee60/go.mod h1:kOOxxyxgAFQVcR5yQJWTuLjzt5dR2pcgwy3WaLEudjE=
 31 github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
 32 github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
 33+github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
 34+github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
 35 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 36 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 37 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 38@@ -184,6 +190,8 @@ github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
 39 github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
 40 github.com/picosh/pobj v0.0.0-20240218150308-1dc70e819bbf h1:xtr5DoSgoOqbPFnm8p5YjTqrRKVW8KYI0bDwwRiG8yI=
 41 github.com/picosh/pobj v0.0.0-20240218150308-1dc70e819bbf/go.mod h1:ILtZ0GOqkozrrGCvyJqCSUIwal2XQqzSfbKCNdS+HyU=
 42+github.com/picosh/ptun v0.0.0-20240220214714-1c94994f91cc h1:DYPOyRg5z5BLGwbQMGnnTP4iBCEmb3k7OxQNJguD7iA=
 43+github.com/picosh/ptun v0.0.0-20240220214714-1c94994f91cc/go.mod h1:uQfDebjN3JJPsI3PAx8T5rmJwdpfmjvdRe7fXY33Kbw=
 44 github.com/picosh/send v0.0.0-20240217194807-77b972121e63 h1:VSSbAejFzj2KBThfVnMcNXQwzHmwjPUridgi29LxihU=
 45 github.com/picosh/send v0.0.0-20240217194807-77b972121e63/go.mod h1:1JCq0NVOdTDenQ0/Kd8e4rP80lu06UHJJ+6dQxhcpew=
 46 github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
 47@@ -244,6 +252,10 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA
 48 github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
 49 github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
 50 github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
 51+github.com/u-root/gobusybox/src v0.0.0-20221229083637-46b2883a7f90 h1:zTk5683I9K62wtZ6eUa6vu6IWwVHXPnoKK5n2unAwv0=
 52+github.com/u-root/gobusybox/src v0.0.0-20221229083637-46b2883a7f90/go.mod h1:lYt+LVfZBBwDZ3+PHk4k/c/TnKOkjJXiJO73E32Mmpc=
 53+github.com/u-root/u-root v0.11.0 h1:6gCZLOeRyevw7gbTwMj3fKxnr9+yHFlgF3N7udUVNO8=
 54+github.com/u-root/u-root v0.11.0/go.mod h1:DBkDtiZyONk9hzVEdB/PWI9B4TxDkElWlVTHseglrZY=
 55 github.com/yuin/goldmark v1.4.5/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
 56 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 57 github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68=
 58@@ -259,10 +271,9 @@ go.abhg.dev/goldmark/anchor v0.1.1/go.mod h1:zYKiaHXTdugwVJRZqInVdmNGQRM3ZRJ6AGB
 59 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 60 golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 61 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 62-golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 63 golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
 64-golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
 65-golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
 66+golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
 67+golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
 68 golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=
 69 golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
 70 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 71@@ -276,20 +287,19 @@ golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/
 72 golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 73 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 74 golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 75-golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 76 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 77 golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
 78 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 79 golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 80 golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
 81-golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
 82-golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
 83+golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
 84+golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
 85 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 86 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 87 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 88 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 89-golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
 90-golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 91+golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
 92+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 93 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 94 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 95 golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 96@@ -309,15 +319,15 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 97 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 98 golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 99 golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
100-golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
101-golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
102+golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
103+golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
104 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
105 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
106 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
107 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
108 golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
109-golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
110-golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
111+golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
112+golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
113 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
114 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
115 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
M pastes/ssh.go
+1, -1
1@@ -41,7 +41,7 @@ func createRouter(handler *filehandlers.FileHandlerRouter) proxy.Router {
2 			scp.Middleware(handler),
3 			wishrsync.Middleware(handler),
4 			auth.Middleware(handler),
5-			bm.Middleware(cms.Middleware(&handler.Cfg.ConfigCms, handler.Cfg)),
6+			wsh.PtyMdw(bm.Middleware(cms.Middleware(&handler.Cfg.ConfigCms, handler.Cfg))),
7 			wsh.LogMiddleware(handler.GetLogger()),
8 		}
9 	}
A pgs/access.go
+44, -0
 1@@ -0,0 +1,44 @@
 2+package pgs
 3+
 4+import (
 5+	"slices"
 6+
 7+	"github.com/picosh/pico/db"
 8+	"golang.org/x/crypto/ssh"
 9+)
10+
11+func HasProjectAccess(project *db.Project, owner *db.User, requester *db.User, pubkey ssh.PublicKey) bool {
12+	aclType := project.Acl.Type
13+	data := project.Acl.Data
14+
15+	if aclType == "public" {
16+		return true
17+	}
18+
19+	if requester != nil {
20+		if owner.ID == requester.ID {
21+			return true
22+		}
23+	}
24+
25+	if aclType == "pico" {
26+		if requester == nil {
27+			return false
28+		}
29+
30+		if len(data) == 0 {
31+			return true
32+		}
33+		return slices.Contains(data, requester.Name)
34+	}
35+
36+	if aclType == "public_keys" {
37+		key := ssh.FingerprintSHA256(pubkey)
38+		if len(data) == 0 {
39+			return true
40+		}
41+		return slices.Contains(data, key)
42+	}
43+
44+	return true
45+}
M pgs/api.go
+24, -19
 1@@ -394,6 +394,30 @@ func AssetRequest(w http.ResponseWriter, r *http.Request) {
 2 	ServeAsset(fname, nil, false, w, r)
 3 }
 4 
 5+func PrivateAssetRequest(w http.ResponseWriter, r *http.Request) {
 6+	fname, _ := url.PathUnescape(shared.GetField(r, 0))
 7+	ServeAsset(fname, nil, false, w, r)
 8+}
 9+
10+var mainRoutes = []shared.Route{
11+	shared.NewRoute("GET", "/main.css", shared.ServeFile("main.css", "text/css")),
12+	shared.NewRoute("GET", "/card.png", shared.ServeFile("card.png", "image/png")),
13+	shared.NewRoute("GET", "/favicon-16x16.png", shared.ServeFile("favicon-16x16.png", "image/png")),
14+	shared.NewRoute("GET", "/apple-touch-icon.png", shared.ServeFile("apple-touch-icon.png", "image/png")),
15+	shared.NewRoute("GET", "/favicon.ico", shared.ServeFile("favicon.ico", "image/x-icon")),
16+	shared.NewRoute("GET", "/robots.txt", shared.ServeFile("robots.txt", "text/plain")),
17+
18+	shared.NewRoute("GET", "/", shared.CreatePageHandler("html/marketing.page.tmpl")),
19+	shared.NewRoute("GET", "/check", checkHandler),
20+	shared.NewRoute("GET", "/rss", rssHandler),
21+	shared.NewRoute("GET", "/(.+)", shared.CreatePageHandler("html/marketing.page.tmpl")),
22+}
23+var subdomainRoutes = []shared.Route{
24+	shared.NewRoute("GET", "/", AssetRequest),
25+	shared.NewRoute("GET", "(/.+.(?:jpg|jpeg|png|gif|webp|svg))(/.+)", ImgAssetRequest),
26+	shared.NewRoute("GET", "/(.+)", AssetRequest),
27+}
28+
29 func StartApiServer() {
30 	cfg := NewConfigSite()
31 	logger := cfg.Logger
32@@ -419,25 +443,6 @@ func StartApiServer() {
33 		return
34 	}
35 
36-	mainRoutes := []shared.Route{
37-		shared.NewRoute("GET", "/main.css", shared.ServeFile("main.css", "text/css")),
38-		shared.NewRoute("GET", "/card.png", shared.ServeFile("card.png", "image/png")),
39-		shared.NewRoute("GET", "/favicon-16x16.png", shared.ServeFile("favicon-16x16.png", "image/png")),
40-		shared.NewRoute("GET", "/apple-touch-icon.png", shared.ServeFile("apple-touch-icon.png", "image/png")),
41-		shared.NewRoute("GET", "/favicon.ico", shared.ServeFile("favicon.ico", "image/x-icon")),
42-		shared.NewRoute("GET", "/robots.txt", shared.ServeFile("robots.txt", "text/plain")),
43-
44-		shared.NewRoute("GET", "/", shared.CreatePageHandler("html/marketing.page.tmpl")),
45-		shared.NewRoute("GET", "/check", checkHandler),
46-		shared.NewRoute("GET", "/rss", rssHandler),
47-		shared.NewRoute("GET", "/(.+)", shared.CreatePageHandler("html/marketing.page.tmpl")),
48-	}
49-	subdomainRoutes := []shared.Route{
50-		shared.NewRoute("GET", "/", AssetRequest),
51-		shared.NewRoute("GET", "(/.+.(?:jpg|jpeg|png|gif|webp|svg))(/.+)", ImgAssetRequest),
52-		shared.NewRoute("GET", "/(.+)", AssetRequest),
53-	}
54-
55 	handler := shared.CreateServe(mainRoutes, subdomainRoutes, cfg, db, st, logger, cache)
56 	router := http.HandlerFunc(handler)
57 
M pgs/cli.go
+21, -0
 1@@ -7,6 +7,7 @@ import (
 2 	"log/slog"
 3 	"os"
 4 	"path/filepath"
 5+	"strings"
 6 
 7 	"github.com/picosh/pico/db"
 8 	"github.com/picosh/pico/shared"
 9@@ -30,6 +31,7 @@ func getHelpText(userName, projectName string) string {
10 	helpStr += fmt.Sprintf("`%s prune %s`: removes all projects that match prefix `%s` and is not linked to another project\n", sshCmdStr, projectName, projectName)
11 	helpStr += fmt.Sprintf("`%s retain %s`: alias for `prune` but retains the (3) most recently updated projects\n", sshCmdStr, projectName)
12 	helpStr += fmt.Sprintf("`%s depends %s`: lists all projects linked to `%s`\n", sshCmdStr, projectName, projectName)
13+	helpStr += fmt.Sprintf("`%s acl %s [public, public_keys, pico] [comma delimited shasum keys or pico usernames]`: control access to project `%s`\n", sshCmdStr, projectName, projectName)
14 	return helpStr
15 }
16 
17@@ -407,3 +409,22 @@ func (c *Cmd) rm(projectName string) error {
18 	err = c.RmProjectAssets(project.Name)
19 	return err
20 }
21+
22+func (c *Cmd) acl(projectName, aclType string, acls []string) error {
23+	c.Log.Info(
24+		"user running `acl` command",
25+		"user", c.User.Name,
26+		"project", projectName,
27+		"actType", aclType,
28+		"acls", acls,
29+	)
30+	c.output(fmt.Sprintf("setting acl for %s to %s (%s)", projectName, aclType, strings.Join(acls, ",")))
31+	acl := db.ProjectAcl{
32+		Type: aclType,
33+		Data: acls,
34+	}
35+	if c.Write {
36+		return c.Dbpool.UpdateProjectAcl(c.User.ID, projectName, acl)
37+	}
38+	return nil
39+}
M pgs/cms.go
+3, -2
 1@@ -14,6 +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/shared"
 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@@ -22,7 +23,6 @@ import (
10 	"github.com/picosh/pico/wish/cms/ui/keys"
11 	"github.com/picosh/pico/wish/cms/ui/tokens"
12 	"github.com/picosh/pico/wish/cms/ui/username"
13-	"github.com/picosh/pico/wish/cms/util"
14 )
15 
16 type status int
17@@ -80,6 +80,7 @@ type GotDBMsg db.DB
18 
19 func CmsMiddleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
20 	return func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
21+		fmt.Println("MADE IT HERE")
22 		logger := cfg.Logger
23 
24 		_, _, active := s.Pty()
25@@ -87,7 +88,7 @@ func CmsMiddleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
26 			logger.Info("no active terminal, skipping")
27 			return nil, nil
28 		}
29-		key, err := util.KeyText(s)
30+		key, err := shared.KeyText(s)
31 		if err != nil {
32 			logger.Error(err.Error())
33 		}
M pgs/ssh.go
+122, -2
  1@@ -2,7 +2,9 @@ package pgs
  2 
  3 import (
  4 	"context"
  5+	"encoding/json"
  6 	"fmt"
  7+	"net/http"
  8 	"os"
  9 	"os/signal"
 10 	"syscall"
 11@@ -12,11 +14,13 @@ import (
 12 	"github.com/charmbracelet/ssh"
 13 	"github.com/charmbracelet/wish"
 14 	bm "github.com/charmbracelet/wish/bubbletea"
 15+	gocache "github.com/patrickmn/go-cache"
 16 	"github.com/picosh/pico/db/postgres"
 17 	uploadassets "github.com/picosh/pico/filehandlers/assets"
 18 	"github.com/picosh/pico/shared"
 19 	"github.com/picosh/pico/shared/storage"
 20 	wsh "github.com/picosh/pico/wish"
 21+	"github.com/picosh/ptun"
 22 	"github.com/picosh/send/list"
 23 	"github.com/picosh/send/pipe"
 24 	"github.com/picosh/send/proxy"
 25@@ -28,7 +32,21 @@ import (
 26 
 27 type SSHServer struct{}
 28 
 29+type ctxPublicKey struct{}
 30+
 31+func getPublicKeyCtx(ctx ssh.Context) (ssh.PublicKey, error) {
 32+	pk, ok := ctx.Value(ctxPublicKey{}).(ssh.PublicKey)
 33+	if !ok {
 34+		return nil, fmt.Errorf("public key not set on `ssh.Context()` for connection")
 35+	}
 36+	return pk, nil
 37+}
 38+func setPublicKeyCtx(ctx ssh.Context, pk ssh.PublicKey) {
 39+	ctx.SetValue(ctxPublicKey{}, pk)
 40+}
 41+
 42 func (me *SSHServer) authHandler(ctx ssh.Context, key ssh.PublicKey) bool {
 43+	setPublicKeyCtx(ctx, key)
 44 	return true
 45 }
 46 
 47@@ -37,11 +55,11 @@ func createRouter(cfg *shared.ConfigSite, handler *uploadassets.UploadAssetHandl
 48 		return []wish.Middleware{
 49 			pipe.Middleware(handler, ""),
 50 			list.Middleware(handler),
 51-			WishMiddleware(handler),
 52 			scp.Middleware(handler),
 53 			wishrsync.Middleware(handler),
 54 			auth.Middleware(handler),
 55-			bm.Middleware(CmsMiddleware(&cfg.ConfigCms, cfg)),
 56+			wsh.PtyMdw(bm.Middleware(CmsMiddleware(&cfg.ConfigCms, cfg))),
 57+			WishMiddleware(handler),
 58 			wsh.LogMiddleware(handler.GetLogger()),
 59 		}
 60 	}
 61@@ -58,6 +76,16 @@ func withProxy(cfg *shared.ConfigSite, handler *uploadassets.UploadAssetHandler,
 62 	}
 63 }
 64 
 65+func unauthorizedHandler(w http.ResponseWriter, r *http.Request) {
 66+	http.Error(w, "You do not have access to this site", http.StatusUnauthorized)
 67+}
 68+
 69+type PicoApi struct {
 70+	UserID    string `json:"user_id"`
 71+	UserName  string `json:"username"`
 72+	PublicKey string `json:"public_key"`
 73+}
 74+
 75 func StartSshServer() {
 76 	host := shared.GetEnv("PGS_HOST", "0.0.0.0")
 77 	port := shared.GetEnv("PGS_SSH_PORT", "2222")
 78@@ -85,12 +113,104 @@ func StartSshServer() {
 79 		cfg,
 80 		st,
 81 	)
 82+	cache := gocache.New(2*time.Minute, 5*time.Minute)
 83+
 84+	webTunnel := &ptun.WebTunnelHandler{
 85+		HttpHandler: func(ctx ssh.Context) http.Handler {
 86+			subdomain := ctx.User()
 87+			log := logger.With(
 88+				"subdomain", subdomain,
 89+			)
 90+
 91+			pubkey, err := getPublicKeyCtx(ctx)
 92+			if err != nil {
 93+				log.Error(err.Error(), "subdomain", subdomain)
 94+				return http.HandlerFunc(unauthorizedHandler)
 95+			}
 96+			pubkeyStr, err := shared.KeyForKeyText(pubkey)
 97+			if err != nil {
 98+				log.Error(err.Error())
 99+				return http.HandlerFunc(unauthorizedHandler)
100+			}
101+			log = log.With(
102+				"pubkey", pubkeyStr,
103+			)
104+
105+			props, err := getProjectFromSubdomain(subdomain)
106+			if err != nil {
107+				log.Error(err.Error())
108+				return http.HandlerFunc(unauthorizedHandler)
109+			}
110+
111+			owner, err := dbh.FindUserForName(props.Username)
112+			if err != nil {
113+				log.Error(err.Error())
114+				return http.HandlerFunc(unauthorizedHandler)
115+			}
116+			log = log.With(
117+				"owner", owner.Name,
118+			)
119+
120+			project, err := dbh.FindProjectByName(owner.ID, props.ProjectName)
121+			if err != nil {
122+				log.Error(err.Error())
123+				return http.HandlerFunc(unauthorizedHandler)
124+			}
125+
126+			requester, _ := dbh.FindUserForKey("", pubkeyStr)
127+			if requester != nil {
128+				log = logger.With(
129+					"requester", requester.Name,
130+				)
131+			}
132+
133+			if !HasProjectAccess(project, owner, requester, pubkey) {
134+				log.Error("no access")
135+				return http.HandlerFunc(unauthorizedHandler)
136+			}
137+
138+			log.Info("user has access to site")
139+
140+			routes := []shared.Route{
141+				// special API endpoint for tunnel users accessing site
142+				shared.NewRoute("GET", "/pico", func(w http.ResponseWriter, r *http.Request) {
143+					w.Header().Set("Content-Type", "application/json")
144+					pico := &PicoApi{
145+						UserID:    "",
146+						UserName:  "",
147+						PublicKey: pubkeyStr,
148+					}
149+					if requester != nil {
150+						pico.UserID = requester.ID
151+						pico.UserName = requester.Name
152+					}
153+					err := json.NewEncoder(w).Encode(pico)
154+					if err != nil {
155+						log.Error(err.Error())
156+					}
157+				}),
158+			}
159+			routes = append(routes, subdomainRoutes...)
160+			httpHandler := shared.CreateServeBasic(
161+				routes,
162+				subdomain,
163+				cfg,
164+				dbh,
165+				st,
166+				logger,
167+				cache,
168+			)
169+			httpRouter := http.HandlerFunc(httpHandler)
170+			return httpRouter
171+		},
172+	}
173 
174 	sshServer := &SSHServer{}
175 	s, err := wish.NewServer(
176 		wish.WithAddress(fmt.Sprintf("%s:%s", host, port)),
177 		wish.WithHostKeyPath("ssh_data/term_info_ed25519"),
178 		wish.WithPublicKeyAuth(sshServer.authHandler),
179+		ptun.WithWebTunnel(webTunnel),
180 		withProxy(
181 			cfg,
182 			handler,
M pgs/wish.go
+48, -17
  1@@ -2,19 +2,20 @@ package pgs
  2 
  3 import (
  4 	"fmt"
  5+	"slices"
  6 	"strings"
  7 
  8 	"github.com/charmbracelet/ssh"
  9 	"github.com/charmbracelet/wish"
 10 	"github.com/picosh/pico/db"
 11 	uploadassets "github.com/picosh/pico/filehandlers/assets"
 12-	"github.com/picosh/pico/wish/cms/util"
 13+	"github.com/picosh/pico/shared"
 14 	"github.com/picosh/send/send/utils"
 15 )
 16 
 17 func getUser(s ssh.Session, dbpool db.DB) (*db.User, error) {
 18 	var err error
 19-	key, err := util.KeyText(s)
 20+	key, err := shared.KeyText(s)
 21 	if err != nil {
 22 		return nil, fmt.Errorf("key not found")
 23 	}
 24@@ -37,25 +38,24 @@ func WishMiddleware(handler *uploadassets.UploadAssetHandler) wish.Middleware {
 25 	cfg := handler.Cfg
 26 	store := handler.Storage
 27 
 28-	return func(sshHandler ssh.Handler) ssh.Handler {
 29-		return func(session ssh.Session) {
 30-			_, _, activePty := session.Pty()
 31+	return func(next ssh.Handler) ssh.Handler {
 32+		return func(sesh ssh.Session) {
 33+			_, _, activePty := sesh.Pty()
 34 			if activePty {
 35-				_ = session.Exit(0)
 36-				_ = session.Close()
 37+				next(sesh)
 38 				return
 39 			}
 40 
 41-			user, err := getUser(session, dbpool)
 42+			user, err := getUser(sesh, dbpool)
 43 			if err != nil {
 44-				utils.ErrorHandler(session, err)
 45+				utils.ErrorHandler(sesh, err)
 46 				return
 47 			}
 48 
 49-			args := session.Command()
 50+			args := sesh.Command()
 51 
 52 			opts := Cmd{
 53-				Session: session,
 54+				Session: sesh,
 55 				User:    user,
 56 				Store:   store,
 57 				Log:     log,
 58@@ -77,8 +77,7 @@ func WishMiddleware(handler *uploadassets.UploadAssetHandler) wish.Middleware {
 59 					opts.bail(err)
 60 					return
 61 				} else {
 62-					e := fmt.Errorf("%s not a valid command", args)
 63-					opts.bail(e)
 64+					next(sesh)
 65 					return
 66 				}
 67 			}
 68@@ -87,13 +86,13 @@ func WishMiddleware(handler *uploadassets.UploadAssetHandler) wish.Middleware {
 69 			projectName := strings.TrimSpace(args[1])
 70 
 71 			if projectName == "--write" {
 72-				utils.ErrorHandler(session, fmt.Errorf("`--write` should be placed at end of command"))
 73+				utils.ErrorHandler(sesh, fmt.Errorf("`--write` should be placed at end of command"))
 74 				return
 75 			}
 76 
 77 			if cmd == "link" {
 78 				if len(args) < 3 {
 79-					utils.ErrorHandler(session, fmt.Errorf("must supply link command like: `projectA link projectB`"))
 80+					utils.ErrorHandler(sesh, fmt.Errorf("must supply link command like: `projectA link projectB`"))
 81 					return
 82 				}
 83 				linkTo := strings.TrimSpace(args[2])
 84@@ -137,9 +136,41 @@ func WishMiddleware(handler *uploadassets.UploadAssetHandler) wish.Middleware {
 85 				opts.notice()
 86 				opts.bail(err)
 87 				return
 88+			} else if cmd == "acl" {
 89+				aclType := strings.TrimSpace(args[2])
 90+				if !slices.Contains([]string{"public", "public_keys", "pico"}, aclType) {
 91+					err := fmt.Errorf("acl type must be one of the following: [public, public_keys, pico], found %s", aclType)
 92+					opts.bail(err)
 93+					return
 94+				}
 95+
 96+				aclsRaw := ""
 97+				if len(args) == 4 {
 98+					if strings.TrimSpace(args[3]) == "--write" {
 99+						opts.Write = true
100+					} else {
101+						aclsRaw = strings.TrimSpace(args[3])
102+					}
103+				}
104+				if len(args) == 5 {
105+					aclsRaw = strings.TrimSpace(args[3])
106+					if strings.TrimSpace(args[4]) == "--write" {
107+						opts.Write = true
108+					}
109+				}
110+				acls := []string{}
111+				for _, key := range strings.Split(aclsRaw, ",") {
112+					st := strings.TrimSpace(key)
113+					if st == "" {
114+						continue
115+					}
116+					acls = append(acls, st)
117+				}
118+				err := opts.acl(projectName, aclType, acls)
119+				opts.notice()
120+				opts.bail(err)
121 			} else {
122-				e := fmt.Errorf("%s not a valid command", args)
123-				opts.bail(e)
124+				next(sesh)
125 				return
126 			}
127 		}
M prose/ssh.go
+1, -1
1@@ -42,7 +42,7 @@ func createRouter(handler *filehandlers.FileHandlerRouter) proxy.Router {
2 			scp.Middleware(handler),
3 			wishrsync.Middleware(handler),
4 			auth.Middleware(handler),
5-			bm.Middleware(cms.Middleware(&handler.Cfg.ConfigCms, handler.Cfg)),
6+			wsh.PtyMdw(bm.Middleware(cms.Middleware(&handler.Cfg.ConfigCms, handler.Cfg))),
7 			wsh.LogMiddleware(handler.GetLogger()),
8 		}
9 	}
M shared/config.go
+1, -1
1@@ -256,6 +256,6 @@ func CreateLogger(debug bool) *slog.Logger {
2 		AddSource: true,
3 	}
4 	return slog.New(
5-		slog.NewJSONHandler(os.Stdout, opts),
6+		slog.NewTextHandler(os.Stdout, opts),
7 	)
8 }
M shared/router.go
+42, -30
 1@@ -46,44 +46,23 @@ func CreatePProfRoutes(routes []Route) []Route {
 2 
 3 type ServeFn func(http.ResponseWriter, *http.Request)
 4 
 5-func CreateServe(routes []Route, subdomainRoutes []Route, cfg *ConfigSite, dbpool db.DB, st storage.StorageServe, logger *slog.Logger, cache *cache.Cache) ServeFn {
 6+func CreateServeBasic(routes []Route, subdomain string, cfg *ConfigSite, dbpool db.DB, st storage.StorageServe, logger *slog.Logger, cache *cache.Cache) ServeFn {
 7 	return func(w http.ResponseWriter, r *http.Request) {
 8 		var allow []string
 9-		var subdomain string
10-		curRoutes := routes
11-
12-		if cfg.IsCustomdomains() || cfg.IsSubdomains() {
13-			hostDomain := strings.ToLower(strings.Split(r.Host, ":")[0])
14-			appDomain := strings.ToLower(strings.Split(cfg.ConfigCms.Domain, ":")[0])
15-
16-			if hostDomain != appDomain {
17-				if strings.Contains(hostDomain, appDomain) {
18-					subdomain = strings.TrimSuffix(hostDomain, fmt.Sprintf(".%s", appDomain))
19-					if subdomain != "" {
20-						curRoutes = subdomainRoutes
21-					}
22-				} else {
23-					subdomain = GetCustomDomain(hostDomain, cfg.Space)
24-					if subdomain != "" {
25-						curRoutes = subdomainRoutes
26-					}
27-				}
28-			}
29-		}
30-
31-		for _, route := range curRoutes {
32+		loggerCtx := context.WithValue(r.Context(), ctxLoggerKey{}, logger)
33+		subdomainCtx := context.WithValue(loggerCtx, ctxSubdomainKey{}, subdomain)
34+		dbCtx := context.WithValue(subdomainCtx, ctxDBKey{}, dbpool)
35+		storageCtx := context.WithValue(dbCtx, ctxStorageKey{}, st)
36+		cfgCtx := context.WithValue(storageCtx, ctxCfg{}, cfg)
37+		cacheCtx := context.WithValue(cfgCtx, ctxCacheKey{}, cache)
38+
39+		for _, route := range routes {
40 			matches := route.Regex.FindStringSubmatch(r.URL.Path)
41 			if len(matches) > 0 {
42 				if r.Method != route.Method {
43 					allow = append(allow, route.Method)
44 					continue
45 				}
46-				loggerCtx := context.WithValue(r.Context(), ctxLoggerKey{}, logger)
47-				subdomainCtx := context.WithValue(loggerCtx, ctxSubdomainKey{}, subdomain)
48-				dbCtx := context.WithValue(subdomainCtx, ctxDBKey{}, dbpool)
49-				storageCtx := context.WithValue(dbCtx, ctxStorageKey{}, st)
50-				cfgCtx := context.WithValue(storageCtx, ctxCfg{}, cfg)
51-				cacheCtx := context.WithValue(cfgCtx, ctxCacheKey{}, cache)
52 				ctx := context.WithValue(cacheCtx, ctxKey{}, matches[1:])
53 				route.Handler(w, r.WithContext(ctx))
54 				return
55@@ -98,6 +77,39 @@ func CreateServe(routes []Route, subdomainRoutes []Route, cfg *ConfigSite, dbpoo
56 	}
57 }
58 
59+func findRouteConfig(r *http.Request, routes []Route, subdomainRoutes []Route, cfg *ConfigSite) ([]Route, string) {
60+	var subdomain string
61+	curRoutes := routes
62+
63+	if cfg.IsCustomdomains() || cfg.IsSubdomains() {
64+		hostDomain := strings.ToLower(strings.Split(r.Host, ":")[0])
65+		appDomain := strings.ToLower(strings.Split(cfg.ConfigCms.Domain, ":")[0])
66+
67+		if hostDomain != appDomain {
68+			if strings.Contains(hostDomain, appDomain) {
69+				subdomain = strings.TrimSuffix(hostDomain, fmt.Sprintf(".%s", appDomain))
70+				if subdomain != "" {
71+					curRoutes = subdomainRoutes
72+				}
73+			} else {
74+				subdomain = GetCustomDomain(hostDomain, cfg.Space)
75+				if subdomain != "" {
76+					curRoutes = subdomainRoutes
77+				}
78+			}
79+		}
80+	}
81+
82+	return curRoutes, subdomain
83+}
84+
85+func CreateServe(routes []Route, subdomainRoutes []Route, cfg *ConfigSite, dbpool db.DB, st storage.StorageServe, logger *slog.Logger, cache *cache.Cache) ServeFn {
86+	return func(w http.ResponseWriter, r *http.Request) {
87+		curRoutes, subdomain := findRouteConfig(r, routes, subdomainRoutes, cfg)
88+		CreateServeBasic(curRoutes, subdomain, cfg, dbpool, st, logger, cache)
89+	}
90+}
91+
92 type ctxDBKey struct{}
93 type ctxStorageKey struct{}
94 type ctxKey struct{}
M shared/util.go
+7, -3
 1@@ -56,10 +56,14 @@ func SanitizeFileExt(fname string) string {
 2 
 3 func KeyText(s ssh.Session) (string, error) {
 4 	if s.PublicKey() == nil {
 5-		return "", fmt.Errorf("session doesn't have public key")
 6+		return "", fmt.Errorf("Session doesn't have public key")
 7 	}
 8-	kb := base64.StdEncoding.EncodeToString(s.PublicKey().Marshal())
 9-	return fmt.Sprintf("%s %s", s.PublicKey().Type(), kb), nil
10+	return KeyForKeyText(s.PublicKey())
11+}
12+
13+func KeyForKeyText(pk ssh.PublicKey) (string, error) {
14+	kb := base64.StdEncoding.EncodeToString(pk.Marshal())
15+	return fmt.Sprintf("%s %s", pk.Type(), kb), nil
16 }
17 
18 func GetEnv(key string, defaultVal string) string {
A sql/migrations/20240221_add_project_acl.sql
+2, -0
1@@ -0,0 +1,2 @@
2+-- type could be: "public", "public_keys", "pico"
3+ALTER TABLE projects ADD COLUMN acl jsonb NOT NULL DEFAULT '{"type":"public","data":[]}'::jsonb;
M wish/cms/cms.go
+2, -2
 1@@ -15,6 +15,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/shared"
 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@@ -24,7 +25,6 @@ import (
10 	"github.com/picosh/pico/wish/cms/ui/posts"
11 	"github.com/picosh/pico/wish/cms/ui/tokens"
12 	"github.com/picosh/pico/wish/cms/ui/username"
13-	"github.com/picosh/pico/wish/cms/util"
14 )
15 
16 type status int
17@@ -93,7 +93,7 @@ func Middleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
18 			logger.Info("no active terminal, skipping")
19 			return nil, nil
20 		}
21-		key, err := util.KeyText(s)
22+		key, err := shared.KeyText(s)
23 		if err != nil {
24 			logger.Error(err.Error())
25 		}
D wish/cms/util/util.go
+0, -16
 1@@ -1,16 +0,0 @@
 2-package util
 3-
 4-import (
 5-	"encoding/base64"
 6-	"fmt"
 7-
 8-	"github.com/charmbracelet/ssh"
 9-)
10-
11-func KeyText(s ssh.Session) (string, error) {
12-	if s.PublicKey() == nil {
13-		return "", fmt.Errorf("Session doesn't have public key")
14-	}
15-	kb := base64.StdEncoding.EncodeToString(s.PublicKey().Marshal())
16-	return fmt.Sprintf("%s %s", s.PublicKey().Type(), kb), nil
17-}
A wish/mdw.go
+19, -0
 1@@ -0,0 +1,19 @@
 2+package wish
 3+
 4+import (
 5+	"github.com/charmbracelet/ssh"
 6+	"github.com/charmbracelet/wish"
 7+)
 8+
 9+func PtyMdw(mdw wish.Middleware) wish.Middleware {
10+	return func(next ssh.Handler) ssh.Handler {
11+		return func(sesh ssh.Session) {
12+			_, _, ok := sesh.Pty()
13+			if !ok {
14+				next(sesh)
15+				return
16+			}
17+			mdw(next)(sesh)
18+		}
19+	}
20+}