Commit 5efb0d3

Eric Bower  ·  2026-09-25 20:37:31 -0400 EDT
parent 9e23580
refactor: invite system for pgs and prose

This creates an invite system in order to be granted access to prose and pgs
services.

The goal here is to prevent spam.

Only pico+ users or users invited by someone else are allowed to invite people.
14 files changed,  +544, -16
+2, -1
......@@ -159,10 +159,11 @@ migrate:
159159 $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260503_add_analytics_summary_tables.sql
160160 $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260504_add_analytics_summary_indexes.sql
161161 $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260716_block_signups.sql
162+ $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260925_add_invites.sql
162163 .PHONY: migrate
163164
164165 latest:
165- $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260716_block_signups.sql
166+ $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260925_add_invites.sql
166167 .PHONY: latest
167168
168169 psql:
+3, -3
......@@ -21,7 +21,7 @@ func findFeatureFlag(dbpool pgsdb.PgsDB, cfg *PgsConfig, userID string) (*db.Fea
2121 setFeatureLimits(ff, cfg)
2222 return ff, nil
2323 }
24- err = fmt.Errorf("ERROR: your pico+ has expired, https://blog.pico.sh/ann-037-pgs-pico-plus-only")
24+ err = fmt.Errorf("ERROR: your pico+ has expired: https://blog.pico.sh/ann-038-pico-invite-system")
2525 }
2626
2727 ffPgs, pgsErr := dbpool.FindFeature(userID, "pgs")
......@@ -30,7 +30,7 @@ func findFeatureFlag(dbpool pgsdb.PgsDB, cfg *PgsConfig, userID string) (*db.Fea
3030 setFeatureLimits(ffPgs, cfg)
3131 return ffPgs, nil
3232 }
33- pgsErr = fmt.Errorf("ERROR: your pgs access has expired, https://blog.pico.sh/ann-037-pgs-pico-plus-only")
33+ pgsErr = fmt.Errorf("ERROR: your pgs access has expired: https://blog.pico.sh/ann-038-pico-invite-system")
3434 }
3535
3636 if err != nil && strings.Contains(err.Error(), "expired") {
......@@ -39,5 +39,5 @@ func findFeatureFlag(dbpool pgsdb.PgsDB, cfg *PgsConfig, userID string) (*db.Fea
3939 if pgsErr != nil && strings.Contains(pgsErr.Error(), "expired") {
4040 return nil, pgsErr
4141 }
42- return nil, fmt.Errorf("ERROR: uploading to pgs requires pico+, https://blog.pico.sh/ann-037-pgs-pico-plus-only")
42+ return nil, fmt.Errorf("ERROR: uploading to pgs requires an invitation or pico+: https://blog.pico.sh/ann-038-pico-invite-system")
4343 }
+2, -2
......@@ -646,7 +646,7 @@ func TestFindFeatureFlag(t *testing.T) {
646646 if err == nil {
647647 t.Fatal("expected error, got nil")
648648 }
649- if !strings.Contains(err.Error(), "https://blog.pico.sh/ann-037-pgs-pico-plus-only") {
649+ if !strings.Contains(err.Error(), "https://blog.pico.sh/ann-038-pico-invite-system") {
650650 t.Errorf("expected blog link in error, got %v", err)
651651 }
652652 })
......@@ -657,7 +657,7 @@ func TestFindFeatureFlag(t *testing.T) {
657657 if err == nil {
658658 t.Fatal("expected error, got nil")
659659 }
660- if !strings.Contains(err.Error(), "pico+") || !strings.Contains(err.Error(), "https://blog.pico.sh/ann-037-pgs-pico-plus-only") {
660+ if !strings.Contains(err.Error(), "pico+") || !strings.Contains(err.Error(), "https://blog.pico.sh/ann-038-pico-invite-system") {
661661 t.Errorf("expected pico+ requirement and blog link in error, got %v", err)
662662 }
663663 })
+1, -1
......@@ -57,7 +57,7 @@ func StartSshServer() {
5757 ".lxt": filehandlers.NewScpPostHandler(dbh, cfg, hooks),
5858 "fallback": uploadimgs.NewUploadImgHandler(dbh, cfg, st),
5959 }
60- handler := filehandlers.NewFileHandlerRouter(cfg, dbh, fileMap)
60+ handler := NewUploadHandler(cfg, dbh, fileMap)
6161
6262 sshAuth := shared.NewSshAuthHandler(dbh, logger, "prose")
6363
+110, -0
......@@ -0,0 +1,110 @@
1+package prose
2+
3+import (
4+ "fmt"
5+ "log/slog"
6+ "strings"
7+
8+ "github.com/picosh/pico/pkg/db"
9+ "github.com/picosh/pico/pkg/filehandlers"
10+ "github.com/picosh/pico/pkg/pssh"
11+ sendutils "github.com/picosh/pico/pkg/send/utils"
12+ "github.com/picosh/pico/pkg/shared"
13+)
14+
15+type ctxFeatureFlagKey struct{}
16+
17+func getFeatureFlag(s *pssh.SSHServerConnSession) *db.FeatureFlag {
18+ v := s.Context().Value(ctxFeatureFlagKey{})
19+ if v == nil {
20+ return nil
21+ }
22+ ff := s.Context().Value(ctxFeatureFlagKey{}).(*db.FeatureFlag)
23+ return ff
24+}
25+
26+func setFeatureFlag(s *pssh.SSHServerConnSession, ff *db.FeatureFlag) {
27+ s.SetValue(ctxFeatureFlagKey{}, ff)
28+}
29+
30+func setFeatureLimits(ff *db.FeatureFlag, cfg *shared.ConfigSite) {
31+ ff.Data.StorageMax = ff.FindStorageMax(cfg.MaxSize)
32+ ff.Data.FileMax = ff.FindFileMax(cfg.MaxAssetSize)
33+ ff.Data.SpecialFileMax = ff.FindSpecialFileMax(cfg.MaxSpecialFileSize)
34+}
35+
36+func findFeatureFlag(dbpool db.DB, cfg *shared.ConfigSite, userID string) (*db.FeatureFlag, error) {
37+ ff, err := dbpool.FindFeature(userID, "plus")
38+ if err == nil {
39+ if ff.IsValid() {
40+ setFeatureLimits(ff, cfg)
41+ return ff, nil
42+ }
43+ err = fmt.Errorf("ERROR: your pico+ has expired: https://blog.pico.sh/ann-038-pico-invite-system")
44+ }
45+
46+ ffProse, proseErr := dbpool.FindFeature(userID, "prose")
47+ if proseErr == nil {
48+ if ffProse.IsValid() {
49+ setFeatureLimits(ffProse, cfg)
50+ return ffProse, nil
51+ }
52+ proseErr = fmt.Errorf("ERROR: your prose access has expired: https://blog.pico.sh/ann-038-pico-invite-system")
53+ }
54+
55+ if err != nil && strings.Contains(err.Error(), "expired") {
56+ return nil, err
57+ }
58+ if proseErr != nil && strings.Contains(proseErr.Error(), "expired") {
59+ return nil, proseErr
60+ }
61+ return nil, fmt.Errorf("ERROR: uploading to prose requires an invitation or pico+: https://blog.pico.sh/ann-038-pico-invite-system")
62+}
63+
64+type UploadHandler struct {
65+ *filehandlers.FileHandlerRouter
66+ Cfg *shared.ConfigSite
67+ DB db.DB
68+}
69+
70+var _ sendutils.CopyFromClientHandler = &UploadHandler{}
71+var _ sendutils.CopyFromClientHandler = (*UploadHandler)(nil)
72+
73+func NewUploadHandler(cfg *shared.ConfigSite, dbpool db.DB, fileMap map[string]filehandlers.ReadWriteHandler) *UploadHandler {
74+ router := filehandlers.NewFileHandlerRouter(cfg, dbpool, fileMap)
75+ return &UploadHandler{
76+ FileHandlerRouter: router,
77+ Cfg: cfg,
78+ DB: dbpool,
79+ }
80+}
81+
82+func (h *UploadHandler) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger {
83+ logger := pssh.GetLogger(s)
84+ if logger == nil {
85+ if h.Cfg != nil && h.Cfg.Logger != nil {
86+ return h.Cfg.Logger
87+ }
88+ return slog.Default()
89+ }
90+ return logger
91+}
92+
93+func (h *UploadHandler) Validate(s *pssh.SSHServerConnSession) error {
94+ logger := h.GetLogger(s)
95+ user := pssh.GetUser(s)
96+
97+ if user == nil {
98+ err := fmt.Errorf("could not get user from ctx")
99+ logger.Error("error getting user from ctx", "err", err)
100+ return err
101+ }
102+
103+ ff, err := findFeatureFlag(h.DB, h.Cfg, user.ID)
104+ if err != nil {
105+ return err
106+ }
107+ setFeatureFlag(s, ff)
108+
109+ return h.FileHandlerRouter.Validate(s)
110+}
+110, -0
......@@ -0,0 +1,110 @@
1+package prose
2+
3+import (
4+ "context"
5+ "fmt"
6+ "log/slog"
7+ "testing"
8+ "time"
9+
10+ "github.com/picosh/pico/pkg/db"
11+ "github.com/picosh/pico/pkg/db/stub"
12+ "github.com/picosh/pico/pkg/filehandlers"
13+ "github.com/picosh/pico/pkg/pssh"
14+ "github.com/picosh/pico/pkg/shared"
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+ key := userID + ":" + name
32+ if ff, ok := m.features[key]; ok {
33+ return ff, nil
34+ }
35+ return nil, fmt.Errorf("feature flag %s not found for user %s", name, userID)
36+}
37+
38+func (m *mockFeatureDB) setFeature(userID string, ff *db.FeatureFlag) {
39+ key := userID + ":" + ff.Name
40+ m.features[key] = ff
41+}
42+
43+func TestUploadHandlerValidate(t *testing.T) {
44+ cfg := NewConfigSite("prose-test")
45+ validExpires := time.Now().Add(24 * time.Hour)
46+ userID := "user-1"
47+
48+ createSession := func(user *db.User) *pssh.SSHServerConnSession {
49+ conn := &pssh.SSHServerConn{
50+ Conn: &ssh.ServerConn{
51+ Permissions: &ssh.Permissions{
52+ Extensions: map[string]string{},
53+ },
54+ },
55+ Logger: slog.Default(),
56+ }
57+ sesh := &pssh.SSHServerConnSession{
58+ SSHServerConn: conn,
59+ Ctx: context.Background(),
60+ }
61+ if user != nil {
62+ pssh.SetUser(sesh, user)
63+ }
64+ return sesh
65+ }
66+
67+ t.Run("nil user in session fails validation", func(t *testing.T) {
68+ mockDB := newMockFeatureDB()
69+ handler := NewUploadHandler(cfg, mockDB, map[string]filehandlers.ReadWriteHandler{})
70+
71+ sesh := createSession(nil)
72+ err := handler.Validate(sesh)
73+ if err == nil {
74+ t.Fatalf("expected error for nil user, got nil")
75+ }
76+ })
77+
78+ t.Run("user without feature flag fails validation", func(t *testing.T) {
79+ mockDB := newMockFeatureDB()
80+ handler := NewUploadHandler(cfg, mockDB, map[string]filehandlers.ReadWriteHandler{})
81+
82+ user := &db.User{ID: userID, Name: "tester"}
83+ sesh := createSession(user)
84+ err := handler.Validate(sesh)
85+ if err == nil {
86+ t.Fatalf("expected error for user without feature flag, got nil")
87+ }
88+ })
89+
90+ t.Run("user with prose feature flag passes validation and sets session flag", func(t *testing.T) {
91+ mockDB := newMockFeatureDB()
92+ proseFF := db.NewFeatureFlag(userID, "prose", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB))
93+ proseFF.ExpiresAt = &validExpires
94+ mockDB.setFeature(userID, proseFF)
95+
96+ handler := NewUploadHandler(cfg, mockDB, map[string]filehandlers.ReadWriteHandler{})
97+
98+ user := &db.User{ID: userID, Name: "tester"}
99+ sesh := createSession(user)
100+ err := handler.Validate(sesh)
101+ if err != nil {
102+ t.Fatalf("unexpected error: %v", err)
103+ }
104+
105+ ff := getFeatureFlag(sesh)
106+ if ff == nil || ff.Name != "prose" {
107+ t.Errorf("expected session feature flag 'prose', got %v", ff)
108+ }
109+ })
110+}
+13, -0
......@@ -439,6 +439,15 @@ type UptimeResult struct {
439439 UptimePercent float64
440440 }
441441
442+type Invite struct {
443+ ID string `db:"id"`
444+ FromUserID string `db:"from_user_id"`
445+ ToUserID string `db:"to_user_id"`
446+ CreatedAt *time.Time `db:"created_at"`
447+ FromUserName string `db:"from_user_name"`
448+ ToUserName string `db:"to_user_name"`
449+}
450+
442451 func ComputeUptime(history []*PipeMonitorHistory, from, to time.Time) UptimeResult {
443452 totalDuration := to.Sub(from)
444453 if totalDuration <= 0 {
......@@ -643,5 +652,9 @@ type DB interface {
643652 InsertPipeMonitorHistory(monitorID string, windowDur time.Duration, windowEnd, lastPing *time.Time) error
644653 FindPipeMonitorHistory(monitorID string, from, to time.Time) ([]*PipeMonitorHistory, error)
645654
655+ FindWhoInvitedUser(toUserID string) (string, error)
656+ FindInvitesByUser(userID string) ([]*Invite, error)
657+ InviteUser(fromUserID string, toUserID string) error
658+
646659 Close() error
647660 }
+60, -0
......@@ -2126,3 +2126,63 @@ func (me *PsqlDB) FindPipeMonitorHistory(monitorID string, from, to time.Time) (
21262126 }
21272127 return history, nil
21282128 }
2129+
2130+func (me *PsqlDB) FindWhoInvitedUser(toUserID string) (string, error) {
2131+ var inviterName string
2132+ query := `
2133+ SELECT app_users.name
2134+ FROM invites
2135+ LEFT JOIN app_users ON app_users.id = invites.from_user_id
2136+ WHERE to_user_id=$1
2137+ `
2138+ err := me.Db.Get(&inviterName, query, toUserID)
2139+ if err != nil {
2140+ return "", err
2141+ }
2142+
2143+ return inviterName, nil
2144+}
2145+
2146+func (me *PsqlDB) FindInvitesByUser(userID string) ([]*db.Invite, error) {
2147+ var invites []*db.Invite
2148+ query := `SELECT
2149+ invites.id, invites.from_user_id, invites.to_user_id, invites.created_at,
2150+ app_users.name as to_user_name
2151+ FROM invites
2152+ LEFT JOIN app_users ON app_users.id = invites.to_user_id
2153+ WHERE from_user_id=$1`
2154+ err := me.Db.Select(&invites, query, userID)
2155+ if err != nil {
2156+ return nil, err
2157+ }
2158+ return invites, nil
2159+}
2160+
2161+func (me *PsqlDB) InviteUser(fromUserID string, toUserID string) error {
2162+ ff, _ := me.FindFeature(fromUserID, "plus")
2163+ hasPlus := ff != nil && ff.IsValid()
2164+
2165+ var invite *db.Invite
2166+ _ = me.Db.Select(&invite, "SELECT * from invites WHERE to_user_id=?", fromUserID)
2167+ hasBeenInvited := invite != nil
2168+
2169+ if !hasBeenInvited && !hasPlus {
2170+ return fmt.Errorf("must have valid pico+ membership or have already been invited by someone")
2171+ }
2172+
2173+ _, err := me.Db.Exec(
2174+ `INSERT INTO invites (from_user_id, to_user_id) VALUES ($1, $2)`,
2175+ fromUserID, toUserID,
2176+ )
2177+ if err != nil {
2178+ return err
2179+ }
2180+
2181+ expiresAt := time.Now().Add(5 * 365 * 24 * time.Hour)
2182+ _, err = me.InsertFeature(toUserID, "prose", expiresAt)
2183+ if err != nil {
2184+ return err
2185+ }
2186+ _, err = me.InsertFeature(toUserID, "pgs", expiresAt)
2187+ return err
2188+}
+12, -0
......@@ -275,3 +275,15 @@ func (me *StubDB) InsertPipeMonitorHistory(monitorID string, windowDur time.Dura
275275 func (me *StubDB) FindPipeMonitorHistory(monitorID string, from, to time.Time) ([]*db.PipeMonitorHistory, error) {
276276 return nil, errNotImpl
277277 }
278+
279+func (me *StubDB) FindWhoInvitedUser(toUserID string) (string, error) {
280+ return "", errNotImpl
281+}
282+
283+func (me *StubDB) FindInvitesByUser(userID string) ([]*db.Invite, error) {
284+ return nil, errNotImpl
285+}
286+
287+func (me *StubDB) InviteUser(fromUserID string, toUserID string) error {
288+ return errNotImpl
289+}
+22, -8
......@@ -31,7 +31,7 @@ func (m *UsageInfo) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
3131 Characters: ctx.Characters,
3232 Max: vxfw.Size{
3333 Width: 30,
34- Height: 3 + 3,
34+ Height: 4 + 3,
3535 },
3636 })
3737 }
......@@ -70,6 +70,10 @@ func (m *UserInfo) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
7070 if m.shared.PlusFeatureFlag != nil {
7171 h += 1
7272 }
73+ if m.shared.InvitedBy != "" {
74+ h += 1
75+ }
76+
7377 return brd.Draw(vxfw.DrawContext{
7478 Characters: ctx.Characters,
7579 Max: vxfw.Size{
......@@ -90,6 +94,10 @@ func (m *UserInfo) getKv() []Kv {
9094 kv = append(kv, Kv{Key: "pico+ expires", Value: expiresAt})
9195 }
9296
97+ if m.shared.InvitedBy != "" {
98+ kv = append(kv, Kv{Key: "invited by", Value: m.shared.InvitedBy})
99+ }
100+
93101 return kv
94102 }
95103
......@@ -153,12 +161,13 @@ func (m *FeaturesList) getFeaturesKv() []Kv {
153161 }
154162
155163 type ServicesList struct {
156- plusFf *db.FeatureFlag
157- pgsFf *db.FeatureFlag
164+ plusFf *db.FeatureFlag
165+ pgsFf *db.FeatureFlag
166+ proseFf *db.FeatureFlag
158167 }
159168
160-func NewServicesList(plusFf *db.FeatureFlag, pgsFf *db.FeatureFlag) *ServicesList {
161- return &ServicesList{plusFf: plusFf, pgsFf: pgsFf}
169+func NewServicesList(plusFf *db.FeatureFlag, pgsFf *db.FeatureFlag, proseFf *db.FeatureFlag) *ServicesList {
170+ return &ServicesList{plusFf: plusFf, pgsFf: pgsFf, proseFf: proseFf}
162171 }
163172
164173 func (m *ServicesList) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
......@@ -174,7 +183,7 @@ func (m *ServicesList) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
174183 return brd.Draw(vxfw.DrawContext{
175184 Characters: ctx.Characters,
176185 Max: vxfw.Size{
177- Width: 30,
186+ Width: 32,
178187 Height: uint16(servicesHeight) + 3,
179188 },
180189 })
......@@ -183,15 +192,20 @@ func (m *ServicesList) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
183192 func (m *ServicesList) getServiceKv() []Kv {
184193 hasPlus := m.plusFf != nil && m.plusFf.IsValid()
185194 hasPgs := m.pgsFf != nil && m.pgsFf.IsValid()
186- pagesStatus := "pico+"
195+ hasProse := m.proseFf != nil && m.proseFf.IsValid()
196+ pagesStatus := "invite/pico+"
187197 if hasPlus || hasPgs {
188198 pagesStatus = "active"
189199 }
200+ proseStatus := "invite/pico+"
201+ if hasPlus || hasProse {
202+ proseStatus = "active"
203+ }
190204 data := [][]string{
191205 {"name", "status"},
192- {"prose", "active"},
193206 {"pipe", "active"},
194207 {"pastes", "active"},
208+ {"prose", proseStatus},
195209 {"pages", pagesStatus},
196210 }
197211
+178, -0
......@@ -0,0 +1,178 @@
1+package tui
2+
3+import (
4+ "time"
5+
6+ "git.sr.ht/~rockorager/vaxis"
7+ "git.sr.ht/~rockorager/vaxis/vxfw"
8+ "git.sr.ht/~rockorager/vaxis/vxfw/button"
9+ "git.sr.ht/~rockorager/vaxis/vxfw/list"
10+ "git.sr.ht/~rockorager/vaxis/vxfw/richtext"
11+ "git.sr.ht/~rockorager/vaxis/vxfw/text"
12+ "github.com/picosh/pico/pkg/db"
13+)
14+
15+type AddInvitePage struct {
16+ shared *SharedModel
17+ list list.Dynamic
18+
19+ err error
20+ focus string
21+ input *TextInput
22+ btn *button.Button
23+ invites []*db.Invite
24+}
25+
26+func NewAddInvitePage(shrd *SharedModel) *AddInvitePage {
27+ btn := button.New("SEND", func() (vxfw.Command, error) { return nil, nil })
28+ btn.Style = button.StyleSet{
29+ Default: vaxis.Style{Background: grey},
30+ Focus: vaxis.Style{Background: oj, Foreground: black},
31+ }
32+ m := &AddInvitePage{
33+ shared: shrd,
34+
35+ input: NewTextInput("pico user"),
36+ btn: btn,
37+ }
38+ m.list = list.Dynamic{DrawCursor: true, Builder: m.getWidget, Gap: 1}
39+ return m
40+}
41+
42+func (m *AddInvitePage) Footer() []Shortcut {
43+ return []Shortcut{
44+ {Shortcut: "tab", Text: "focus"},
45+ {Shortcut: "enter", Text: "invite user"},
46+ }
47+}
48+
49+func (m *AddInvitePage) getWidget(i uint, cursor uint) vxfw.Widget {
50+ if int(i) >= len(m.invites) {
51+ return nil
52+ }
53+
54+ style := vaxis.Style{Foreground: grey}
55+ isSelected := i == cursor
56+ if isSelected {
57+ style = vaxis.Style{Foreground: fuschia}
58+ }
59+
60+ invite := m.invites[i]
61+
62+ txt := richtext.New([]vaxis.Segment{
63+ {Text: "User: ", Style: style},
64+ {Text: invite.ToUserName + "\n"},
65+
66+ {Text: "Invited: ", Style: style},
67+ {Text: invite.CreatedAt.Format(time.DateOnly)},
68+ })
69+
70+ return txt
71+}
72+
73+func (m *AddInvitePage) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) {
74+ switch msg := ev.(type) {
75+ case vaxis.Key:
76+ if msg.Matches(vaxis.KeyEnter) {
77+ err := m.addInvite(m.input.GetValue())
78+ m.err = err
79+ if err == nil {
80+ m.input.Reset()
81+ m.shared.App.PostEvent(Navigate{To: HOME})
82+ return nil, nil
83+ }
84+ return vxfw.RedrawCmd{}, nil
85+ }
86+ }
87+ return nil, nil
88+}
89+
90+func (m *AddInvitePage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
91+ switch msg := ev.(type) {
92+ case PageIn:
93+ m.err = m.fetchInvites()
94+ m.focus = "input"
95+ m.input.Reset()
96+ return m.input.FocusIn()
97+ case vaxis.Key:
98+ if msg.Matches(vaxis.KeyTab) {
99+ if m.focus == "input" {
100+ m.focus = "button"
101+ cmd, _ := m.input.FocusOut()
102+ return vxfw.BatchCmd([]vxfw.Command{
103+ vxfw.FocusWidgetCmd(m.btn),
104+ cmd,
105+ }), nil
106+ }
107+ m.focus = "input"
108+ return m.input.FocusIn()
109+ }
110+ }
111+
112+ return nil, nil
113+}
114+
115+func (m *AddInvitePage) fetchInvites() error {
116+ invites, err := m.shared.Dbpool.FindInvitesByUser(m.shared.User.ID)
117+ if err != nil {
118+ return err
119+
120+ }
121+ m.invites = invites
122+ return nil
123+}
124+
125+func (m *AddInvitePage) addInvite(username string) error {
126+ db := m.shared.Dbpool
127+ user, err := db.FindUserByName(username)
128+ if err != nil {
129+ return err
130+ }
131+
132+ err = db.InviteUser(m.shared.User.ID, user.ID)
133+ if err != nil {
134+ return err
135+ }
136+
137+ return m.fetchInvites()
138+}
139+
140+func (m *AddInvitePage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
141+ w := ctx.Max.Width
142+ h := ctx.Max.Height
143+ root := vxfw.NewSurface(w, h, m)
144+ ah := 0
145+
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.")
147+ headerSurf, _ := header.Draw(ctx)
148+ root.AddChild(0, ah, headerSurf)
149+ ah += int(headerSurf.Size.Height) + 1
150+
151+ inputSurf, _ := m.input.Draw(ctx)
152+ root.AddChild(0, ah, inputSurf)
153+ ah += int(headerSurf.Size.Height) + 1
154+
155+ btnSurf, _ := m.btn.Draw(vxfw.DrawContext{
156+ Characters: ctx.Characters,
157+ Max: vxfw.Size{Width: 6, Height: 1},
158+ })
159+ root.AddChild(0, ah, btnSurf)
160+ ah += int(btnSurf.Size.Height) + 1
161+
162+ if m.err != nil {
163+ e := richtext.New([]vaxis.Segment{
164+ {
165+ Text: m.err.Error(),
166+ Style: vaxis.Style{Foreground: red},
167+ },
168+ })
169+ errSurf, _ := e.Draw(createDrawCtx(ctx, 1))
170+ root.AddChild(0, ah, errSurf)
171+ ah += int(errSurf.Size.Height) + 1
172+ }
173+
174+ listSurf, _ := m.list.Draw(createDrawCtx(ctx, ctx.Max.Height-uint16(ah)))
175+ root.AddChild(0, ah, listSurf)
176+
177+ return root, nil
178+}
+6, -1
......@@ -10,6 +10,7 @@ import (
1010
1111 var menuChoices = []string{
1212 "pubkeys",
13+ "invite",
1314 "tokens",
1415 "logs",
1516 "access_logs",
......@@ -104,7 +105,11 @@ func (m *MenuPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
104105 },
105106 })
106107
107- services, _ := NewServicesList(m.shared.PlusFeatureFlag, m.shared.PgsFeatureFlag).Draw(ctx)
108+ services, _ := NewServicesList(
109+ m.shared.PlusFeatureFlag,
110+ m.shared.PgsFeatureFlag,
111+ m.shared.ProseFeatureFlag,
112+ ).Draw(ctx)
108113 features, _ := m.features.Draw(ctx)
109114
110115 leftPane := NewGroupStack([]vxfw.Surface{
+9, -0
......@@ -28,9 +28,11 @@ type SharedModel struct {
2828 Dbpool db.DB
2929 PgsDB pgsdb.PgsDB
3030 User *db.User
31+ InvitedBy string
3132 PlusFeatureFlag *db.FeatureFlag
3233 BouncerFeatureFlag *db.FeatureFlag
3334 PgsFeatureFlag *db.FeatureFlag
35+ ProseFeatureFlag *db.FeatureFlag
3436 Impersonator string
3537 App *vxfw.App
3638 }
......@@ -280,6 +282,12 @@ func initData(shrd *SharedModel) error {
280282
281283 pff, _ := FindFeatureFlag(shrd, "pgs")
282284 shrd.PgsFeatureFlag = pff
285+
286+ prosef, _ := FindFeatureFlag(shrd, "prose")
287+ shrd.ProseFeatureFlag = prosef
288+
289+ invitedBy, _ := shrd.Dbpool.FindWhoInvitedUser(user.ID)
290+ shrd.InvitedBy = invitedBy
283291 return nil
284292 }
285293
......@@ -351,6 +359,7 @@ func NewTui(opts vaxis.Options, shrd *SharedModel) error {
351359 HOME: NewMenuPage(shrd),
352360 "pubkeys": NewPubkeysPage(shrd),
353361 "add-pubkey": NewAddPubkeyPage(shrd),
362+ "invite": NewAddInvitePage(shrd),
354363 "tokens": NewTokensPage(shrd),
355364 "add-token": NewAddTokenPage(shrd),
356365 "signup": NewSignupPage(shrd),
+16, -0
......@@ -0,0 +1,16 @@
1+CREATE TABLE IF NOT EXISTS invites (
2+ id SERIAL PRIMARY KEY,
3+ from_user_id uuid NOT NULL,
4+ to_user_id uuid NOT NULL,
5+ created_at timestamp without time zone NOT NULL DEFAULT NOW(),
6+ CONSTRAINT fk_invites_from_app_users
7+ FOREIGN KEY(from_user_id)
8+ REFERENCES app_users(id)
9+ ON DELETE CASCADE
10+ ON UPDATE CASCADE,
11+ CONSTRAINT fk_invites_to_app_users
12+ FOREIGN KEY(to_user_id)
13+ REFERENCES app_users(id)
14+ ON DELETE CASCADE
15+ ON UPDATE CASCADE
16+);