Commit 949a5c0

Antonio Mika  ·  2026-10-10 15:28:43 -0400 EDT
parent c1156d3
feat(invites): gate pastes and let invited users invite
11 files changed,  +350, -22
+2, -1
......@@ -164,10 +164,11 @@ migrate:
164164 $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260504_add_analytics_summary_indexes.sql
165165 $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260716_block_signups.sql
166166 $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260925_add_invites.sql
167+ $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20261010_add_pastes_invite_feature.sql
167168 .PHONY: migrate
168169
169170 latest:
170- $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260925_add_invites.sql
171+ $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20261010_add_pastes_invite_feature.sql
171172 .PHONY: latest
172173
173174 psql:
+1, -1
......@@ -43,7 +43,7 @@ func StartSshServer() {
4343 fileMap := map[string]filehandlers.ReadWriteHandler{
4444 "fallback": filehandlers.NewScpPostHandler(dbh, cfg, hooks),
4545 }
46- handler := filehandlers.NewFileHandlerRouter(cfg, dbh, fileMap)
46+ handler := NewUploadHandler(cfg, dbh, fileMap)
4747 sshAuth := shared.NewSshAuthHandler(dbh, logger, "pastes")
4848
4949 // Create a new SSH server
+77, -0
......@@ -0,0 +1,77 @@
1+package pastes
2+
3+import (
4+ "fmt"
5+ "log/slog"
6+
7+ "github.com/picosh/pico/pkg/db"
8+ "github.com/picosh/pico/pkg/filehandlers"
9+ "github.com/picosh/pico/pkg/pssh"
10+ sendutils "github.com/picosh/pico/pkg/send/utils"
11+ "github.com/picosh/pico/pkg/shared"
12+)
13+
14+func checkAccess(dbpool db.DB, userID string) error {
15+ ffPlus, plusErr := dbpool.FindFeature(userID, "plus")
16+ if plusErr == nil && ffPlus.IsValid() {
17+ return nil
18+ }
19+
20+ ffPastes, pastesErr := dbpool.FindFeature(userID, "pastes")
21+ if pastesErr == nil {
22+ if ffPastes.IsValid() {
23+ return nil
24+ }
25+ return fmt.Errorf("ERROR: your pastes access has expired: https://blog.pico.sh/ann-038-pico-invite-system")
26+ }
27+
28+ if plusErr == nil {
29+ return fmt.Errorf("ERROR: your pico+ has expired: https://blog.pico.sh/ann-038-pico-invite-system")
30+ }
31+ return fmt.Errorf("ERROR: uploading to pastes requires an invitation or pico+: https://blog.pico.sh/ann-038-pico-invite-system")
32+}
33+
34+type UploadHandler struct {
35+ *filehandlers.FileHandlerRouter
36+ Cfg *shared.ConfigSite
37+ DB db.DB
38+}
39+
40+var _ sendutils.CopyFromClientHandler = (*UploadHandler)(nil)
41+
42+func NewUploadHandler(cfg *shared.ConfigSite, dbpool db.DB, fileMap map[string]filehandlers.ReadWriteHandler) *UploadHandler {
43+ router := filehandlers.NewFileHandlerRouter(cfg, dbpool, fileMap)
44+ return &UploadHandler{
45+ FileHandlerRouter: router,
46+ Cfg: cfg,
47+ DB: dbpool,
48+ }
49+}
50+
51+func (h *UploadHandler) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger {
52+ logger := pssh.GetLogger(s)
53+ if logger == nil {
54+ if h.Cfg != nil && h.Cfg.Logger != nil {
55+ return h.Cfg.Logger
56+ }
57+ return slog.Default()
58+ }
59+ return logger
60+}
61+
62+func (h *UploadHandler) Validate(s *pssh.SSHServerConnSession) error {
63+ logger := h.GetLogger(s)
64+ user := pssh.GetUser(s)
65+
66+ if user == nil {
67+ err := fmt.Errorf("could not get user from ctx")
68+ logger.Error("error getting user from ctx", "err", err)
69+ return err
70+ }
71+
72+ if err := checkAccess(h.DB, user.ID); err != nil {
73+ return err
74+ }
75+
76+ return h.FileHandlerRouter.Validate(s)
77+}
+130, -0
......@@ -0,0 +1,130 @@
1+package pastes
2+
3+import (
4+ "context"
5+ "fmt"
6+ "log/slog"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/picosh/pico/pkg/db"
12+ "github.com/picosh/pico/pkg/db/stub"
13+ "github.com/picosh/pico/pkg/filehandlers"
14+ "github.com/picosh/pico/pkg/pssh"
15+ "golang.org/x/crypto/ssh"
16+)
17+
18+type mockFeatureDB struct {
19+ *stub.StubDB
20+ features map[string]*db.FeatureFlag
21+}
22+
23+func newMockFeatureDB() *mockFeatureDB {
24+ return &mockFeatureDB{
25+ StubDB: stub.NewStubDB(slog.Default()),
26+ features: make(map[string]*db.FeatureFlag),
27+ }
28+}
29+
30+func (m *mockFeatureDB) FindFeature(userID, name string) (*db.FeatureFlag, error) {
31+ if ff, ok := m.features[userID+":"+name]; ok {
32+ return ff, nil
33+ }
34+ return nil, fmt.Errorf("feature flag %s not found for user %s", name, userID)
35+}
36+
37+func (m *mockFeatureDB) setFeature(userID, name string, expiresAt time.Time) {
38+ m.features[userID+":"+name] = &db.FeatureFlag{UserID: userID, Name: name, ExpiresAt: &expiresAt}
39+}
40+
41+func TestUploadHandlerValidate(t *testing.T) {
42+ cfg := NewConfigSite("pastes-test")
43+ userID := "user-1"
44+ future := time.Now().Add(24 * time.Hour)
45+ past := time.Now().Add(-24 * time.Hour)
46+
47+ createSession := func(user *db.User) *pssh.SSHServerConnSession {
48+ conn := &pssh.SSHServerConn{
49+ Conn: &ssh.ServerConn{
50+ Permissions: &ssh.Permissions{
51+ Extensions: map[string]string{},
52+ },
53+ },
54+ Logger: slog.Default(),
55+ }
56+ sesh := &pssh.SSHServerConnSession{
57+ SSHServerConn: conn,
58+ Ctx: context.Background(),
59+ }
60+ if user != nil {
61+ pssh.SetUser(sesh, user)
62+ }
63+ return sesh
64+ }
65+
66+ tests := []struct {
67+ name string
68+ setup func(m *mockFeatureDB)
69+ user *db.User
70+ wantErr string
71+ }{
72+ {
73+ name: "nil user",
74+ setup: func(m *mockFeatureDB) {},
75+ wantErr: "could not get user",
76+ },
77+ {
78+ name: "no invite or pico+",
79+ setup: func(m *mockFeatureDB) {},
80+ user: &db.User{ID: userID, Name: "tester"},
81+ wantErr: "requires an invitation or pico+",
82+ },
83+ {
84+ name: "pastes feature",
85+ setup: func(m *mockFeatureDB) { m.setFeature(userID, "pastes", future) },
86+ user: &db.User{ID: userID, Name: "tester"},
87+ },
88+ {
89+ name: "pico+ feature",
90+ setup: func(m *mockFeatureDB) { m.setFeature(userID, "plus", future) },
91+ user: &db.User{ID: userID, Name: "tester"},
92+ },
93+ {
94+ name: "expired pico+ with active pastes",
95+ setup: func(m *mockFeatureDB) { m.setFeature(userID, "plus", past); m.setFeature(userID, "pastes", future) },
96+ user: &db.User{ID: userID, Name: "tester"},
97+ },
98+ {
99+ name: "expired pastes",
100+ setup: func(m *mockFeatureDB) { m.setFeature(userID, "pastes", past) },
101+ user: &db.User{ID: userID, Name: "tester"},
102+ wantErr: "pastes access has expired",
103+ },
104+ {
105+ name: "expired pico+",
106+ setup: func(m *mockFeatureDB) { m.setFeature(userID, "plus", past) },
107+ user: &db.User{ID: userID, Name: "tester"},
108+ wantErr: "pico+ has expired",
109+ },
110+ }
111+
112+ for _, tt := range tests {
113+ t.Run(tt.name, func(t *testing.T) {
114+ mockDB := newMockFeatureDB()
115+ tt.setup(mockDB)
116+ handler := NewUploadHandler(cfg, mockDB, map[string]filehandlers.ReadWriteHandler{})
117+
118+ err := handler.Validate(createSession(tt.user))
119+ if tt.wantErr == "" {
120+ if err != nil {
121+ t.Fatalf("unexpected error: %v", err)
122+ }
123+ return
124+ }
125+ if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
126+ t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
127+ }
128+ })
129+ }
130+}
+32, -13
......@@ -2262,16 +2262,35 @@ func (me *PsqlDB) FindInvitesByUser(userID string) ([]*db.Invite, error) {
22622262 return invites, nil
22632263 }
22642264
2265-func (me *PsqlDB) InviteUser(fromUserID string, toUserID string) error {
2266- ff, _ := me.FindFeature(fromUserID, "plus")
2267- hasPlus := ff != nil && ff.IsValid()
2265+// inviteFeatures are the services an invite grants access to.
2266+var inviteFeatures = []string{"prose", "pgs", "pastes"}
2267+
2268+func (me *PsqlDB) hasActiveInvite(userID string) (bool, error) {
2269+ var invited bool
2270+ err := me.Db.Get(&invited, `SELECT EXISTS (SELECT 1 FROM invites WHERE to_user_id = $1)`, userID)
2271+ if err != nil || !invited {
2272+ return false, err
2273+ }
2274+ for _, name := range inviteFeatures {
2275+ if me.HasFeatureByUser(userID, name) {
2276+ return true, nil
2277+ }
2278+ }
2279+ return false, nil
2280+}
22682281
2269- var invite *db.Invite
2270- _ = me.Db.Select(&invite, "SELECT * from invites WHERE to_user_id=?", fromUserID)
2271- hasBeenInvited := invite != nil
2282+func (me *PsqlDB) InviteUser(fromUserID string, toUserID string) error {
2283+ canInvite := me.HasFeatureByUser(fromUserID, "plus")
2284+ if !canInvite {
2285+ invited, err := me.hasActiveInvite(fromUserID)
2286+ if err != nil {
2287+ return err
2288+ }
2289+ canInvite = invited
2290+ }
22722291
2273- if !hasBeenInvited && !hasPlus {
2274- return fmt.Errorf("must have valid pico+ membership or have already been invited by someone")
2292+ if !canInvite {
2293+ return fmt.Errorf("must have valid pico+ membership or an active invite from someone")
22752294 }
22762295
22772296 _, err := me.Db.Exec(
......@@ -2283,10 +2302,10 @@ func (me *PsqlDB) InviteUser(fromUserID string, toUserID string) error {
22832302 }
22842303
22852304 expiresAt := time.Now().Add(5 * 365 * 24 * time.Hour)
2286- _, err = me.InsertFeature(toUserID, "prose", expiresAt)
2287- if err != nil {
2288- return err
2305+ for _, name := range inviteFeatures {
2306+ if _, err := me.InsertFeature(toUserID, name, expiresAt); err != nil {
2307+ return err
2308+ }
22892309 }
2290- _, err = me.InsertFeature(toUserID, "pgs", expiresAt)
2291- return err
2310+ return nil
22922311 }
+80, -0
......@@ -1716,3 +1716,83 @@ func TestFindPipeMonitorsByUser_Empty(t *testing.T) {
17161716 t.Errorf("expected 0 monitors for user with none, got %d", len(monitors))
17171717 }
17181718 }
1719+
1720+func TestInviteUser_PlusMember(t *testing.T) {
1721+ cleanupTestData(t)
1722+
1723+ from, _ := testDB.RegisterUser("inviteplus", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI inviteplus", "comment", "")
1724+ to, _ := testDB.RegisterUser("inviteplusto", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI inviteplusto", "comment", "")
1725+ _, _ = testDB.InsertFeature(from.ID, "plus", time.Now().Add(365*24*time.Hour))
1726+
1727+ if err := testDB.InviteUser(from.ID, to.ID); err != nil {
1728+ t.Fatalf("InviteUser failed: %v", err)
1729+ }
1730+
1731+ for _, name := range []string{"prose", "pgs", "pastes"} {
1732+ if !testDB.HasFeatureByUser(to.ID, name) {
1733+ t.Errorf("expected invitee to have %s", name)
1734+ }
1735+ }
1736+
1737+ inviter, err := testDB.FindWhoInvitedUser(to.ID)
1738+ if err != nil {
1739+ t.Fatalf("FindWhoInvitedUser failed: %v", err)
1740+ }
1741+ if inviter != "inviteplus" {
1742+ t.Errorf("expected inviter inviteplus, got %s", inviter)
1743+ }
1744+}
1745+
1746+func TestInviteUser_InvitedUserCanInvite(t *testing.T) {
1747+ cleanupTestData(t)
1748+
1749+ plus, _ := testDB.RegisterUser("invitechaina", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI invitechaina", "comment", "")
1750+ mid, _ := testDB.RegisterUser("invitechainb", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI invitechainb", "comment", "")
1751+ last, _ := testDB.RegisterUser("invitechainc", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI invitechainc", "comment", "")
1752+ _, _ = testDB.InsertFeature(plus.ID, "plus", time.Now().Add(365*24*time.Hour))
1753+
1754+ if err := testDB.InviteUser(plus.ID, mid.ID); err != nil {
1755+ t.Fatalf("InviteUser from plus failed: %v", err)
1756+ }
1757+ if err := testDB.InviteUser(mid.ID, last.ID); err != nil {
1758+ t.Fatalf("InviteUser from invited user failed: %v", err)
1759+ }
1760+ if !testDB.HasFeatureByUser(last.ID, "pastes") {
1761+ t.Error("expected second invitee to have pastes")
1762+ }
1763+}
1764+
1765+func TestInviteUser_Unauthorized(t *testing.T) {
1766+ cleanupTestData(t)
1767+
1768+ from, _ := testDB.RegisterUser("invitenone", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI invitenone", "comment", "")
1769+ to, _ := testDB.RegisterUser("invitenoneto", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI invitenoneto", "comment", "")
1770+
1771+ if err := testDB.InviteUser(from.ID, to.ID); err == nil {
1772+ t.Fatal("expected error for user without pico+ or invite")
1773+ }
1774+ if testDB.HasFeatureByUser(to.ID, "prose") {
1775+ t.Error("expected invitee not to have prose")
1776+ }
1777+}
1778+
1779+func TestInviteUser_ExpiredInvite(t *testing.T) {
1780+ cleanupTestData(t)
1781+
1782+ plus, _ := testDB.RegisterUser("inviteexpa", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI inviteexpa", "comment", "")
1783+ expired, _ := testDB.RegisterUser("inviteexpb", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI inviteexpb", "comment", "")
1784+ to, _ := testDB.RegisterUser("inviteexpc", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI inviteexpc", "comment", "")
1785+
1786+ _, err := testDB.Db.Exec(`INSERT INTO invites (from_user_id, to_user_id) VALUES ($1, $2)`, plus.ID, expired.ID)
1787+ if err != nil {
1788+ t.Fatalf("insert invite failed: %v", err)
1789+ }
1790+ past := time.Now().Add(-24 * time.Hour)
1791+ for _, name := range []string{"prose", "pgs", "pastes"} {
1792+ _, _ = testDB.InsertFeature(expired.ID, name, past)
1793+ }
1794+
1795+ if err := testDB.InviteUser(expired.ID, to.ID); err == nil {
1796+ t.Fatal("expected error for user whose invite access expired")
1797+ }
1798+}
+12, -6
......@@ -153,13 +153,14 @@ func (m *FeaturesList) getFeaturesKv() []Kv {
153153 }
154154
155155 type ServicesList struct {
156- plusFf *db.FeatureFlag
157- pgsFf *db.FeatureFlag
158- proseFf *db.FeatureFlag
156+ plusFf *db.FeatureFlag
157+ pgsFf *db.FeatureFlag
158+ proseFf *db.FeatureFlag
159+ pastesFf *db.FeatureFlag
159160 }
160161
161-func NewServicesList(plusFf *db.FeatureFlag, pgsFf *db.FeatureFlag, proseFf *db.FeatureFlag) *ServicesList {
162- return &ServicesList{plusFf: plusFf, pgsFf: pgsFf, proseFf: proseFf}
162+func NewServicesList(plusFf *db.FeatureFlag, pgsFf *db.FeatureFlag, proseFf *db.FeatureFlag, pastesFf *db.FeatureFlag) *ServicesList {
163+ return &ServicesList{plusFf: plusFf, pgsFf: pgsFf, proseFf: proseFf, pastesFf: pastesFf}
163164 }
164165
165166 func (m *ServicesList) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
......@@ -181,6 +182,7 @@ func (m *ServicesList) getServiceKv() []Kv {
181182 hasPlus := m.plusFf != nil && m.plusFf.IsValid()
182183 hasPgs := m.pgsFf != nil && m.pgsFf.IsValid()
183184 hasProse := m.proseFf != nil && m.proseFf.IsValid()
185+ hasPastes := m.pastesFf != nil && m.pastesFf.IsValid()
184186 pagesStatus := "invite/pico+"
185187 if hasPlus || hasPgs {
186188 pagesStatus = "active"
......@@ -189,10 +191,14 @@ func (m *ServicesList) getServiceKv() []Kv {
189191 if hasPlus || hasProse {
190192 proseStatus = "active"
191193 }
194+ pastesStatus := "invite/pico+"
195+ if hasPlus || hasPastes {
196+ pastesStatus = "active"
197+ }
192198 data := [][]string{
193199 {"name", "status"},
194200 {"pipe", "active"},
195- {"pastes", "active"},
201+ {"pastes", pastesStatus},
196202 {"prose", proseStatus},
197203 {"pages", pagesStatus},
198204 }
+1, -1
......@@ -143,7 +143,7 @@ func (m *AddInvitePage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
143143 root := vxfw.NewSurface(w, h, m)
144144 ah := 0
145145
146- header := text.New("Invite a user to pico! Invitee must already have a pico account. This grants them `pgs` and `prose` access on their respective free tiers. Only pico+ members or users that received an invite can invite other users.")
146+ header := text.New("Invite a user to pico! Invitee must already have a pico account. This grants them `pgs`, `prose` and `pastes` access on their respective free tiers. Only pico+ members or users with an active invite can invite other users.")
147147 headerSurf, _ := header.Draw(ctx)
148148 root.AddChild(0, ah, headerSurf)
149149 ah += int(headerSurf.Size.Height) + 1
+1, -0
......@@ -109,6 +109,7 @@ func (m *MenuPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
109109 m.shared.PlusFeatureFlag,
110110 m.shared.PgsFeatureFlag,
111111 m.shared.ProseFeatureFlag,
112+ m.shared.PastesFeatureFlag,
112113 ).Draw(ctx)
113114 features, _ := m.features.Draw(ctx)
114115
+4, -0
......@@ -33,6 +33,7 @@ type SharedModel struct {
3333 BouncerFeatureFlag *db.FeatureFlag
3434 PgsFeatureFlag *db.FeatureFlag
3535 ProseFeatureFlag *db.FeatureFlag
36+ PastesFeatureFlag *db.FeatureFlag
3637 Impersonator string
3738 App *vxfw.App
3839 }
......@@ -282,6 +283,9 @@ func initData(shrd *SharedModel) error {
282283 prosef, _ := FindFeatureFlag(shrd, "prose")
283284 shrd.ProseFeatureFlag = prosef
284285
286+ pastesf, _ := FindFeatureFlag(shrd, "pastes")
287+ shrd.PastesFeatureFlag = pastesf
288+
285289 if shrd.User != nil {
286290 invitedBy, _ := shrd.Dbpool.FindWhoInvitedUser(user.ID)
287291 shrd.InvitedBy = invitedBy
+10, -0
......@@ -0,0 +1,10 @@
1+INSERT INTO feature_flags (user_id, name, expires_at)
2+SELECT DISTINCT ON (ff.user_id) ff.user_id, 'pastes', ff.expires_at
3+FROM feature_flags ff
4+JOIN invites ON invites.to_user_id = ff.user_id
5+WHERE ff.name = 'prose'
6+ AND NOT EXISTS (
7+ SELECT 1 FROM feature_flags existing
8+ WHERE existing.user_id = ff.user_id AND existing.name = 'pastes'
9+ )
10+ORDER BY ff.user_id, ff.expires_at DESC;