pico
created pr with
pico/141.1
added pico/141.2
1: 2845ffb = 1: 2845ffb refactor: invite system for pgs and prose
-: ------- > 2: ee606d0 chore(prose): require plus or prose feature flag in order to upload
added pico/141.3
1: 2845ffb = 1: 2845ffb refactor: invite system for pgs and prose
2: ee606d0 = 2: ee606d0 chore(prose): require plus or prose feature flag in order to upload
-: ------- > 3: e14a99e refactor: remove unnecessary tests
added pico/141.4
1: 2845ffb = 1: 2845ffb refactor: invite system for pgs and prose
2: ee606d0 < -: ------- chore(prose): require plus or prose feature flag in order to upload
3: e14a99e ! 2: 7fb4f8f chore(prose): require plus or prose feature flag in order to upload
added pico/141.5
1: 2845ffb = 1: 2845ffb refactor: invite system for pgs and prose
2: 7fb4f8f ! 2: e469694 chore(prose): require plus or prose feature flag in order to upload
added pico/141.6
1: 2845ffb ! 1: 1e9cddc style(prose): minimal design ethos
2: e469694 ! 2: 0967882 chore(prose): remove with_styles and layout front-matter fields
cmds
checkout latest patchset:
ssh pr.pico.sh pull pico:141 | git am -3checkout specific patchset revision:
ssh pr.pico.sh pull pico:141 [rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pico:141add comment to patch request:
ssh pr.pico.sh comment pico:141 "lgtm!"
Patchset
pico/141.4
refactor: invite system for pgs and prose
Eric Bower
2026-09-26T00:37:31ZThis 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 are allowed to invite people. We might open this up in the future but this was the easiest way to stop the flood of spam posts from ai bots.
Semantic diff summary
9 added,
9 modified,
1 signature changed,
0 removed
across 7 analyzed files
(2 files skipped: unsupported file type)
+2
-1
Makefile
#
| ... | ... | @@ -159,10 +159,11 @@ migrate: | |
| 159 | 159 | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260503_add_analytics_summary_tables.sql | |
| 160 | 160 | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260504_add_analytics_summary_indexes.sql | |
| 161 | 161 | $(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 | |
| 162 | 163 | .PHONY: migrate | |
| 163 | 164 | ||
| 164 | 165 | 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 | |
| 166 | 167 | .PHONY: latest | |
| 167 | 168 | ||
| 168 | 169 | psql: |
+2
-0
pkg/db/db.go
#
| ... | ... | @@ -643,5 +643,7 @@ type DB interface { | |
| 643 | 643 | InsertPipeMonitorHistory(monitorID string, windowDur time.Duration, windowEnd, lastPing *time.Time) error | |
| 644 | 644 | FindPipeMonitorHistory(monitorID string, from, to time.Time) ([]*PipeMonitorHistory, error) | |
| 645 | 645 | ||
| 646 | + | InviteUser(fromUserID string, toUserID string) error | |
| 647 | + | ||
| 646 | 648 | Close() error | |
| 647 | 649 | } |
+18
-0
pkg/db/postgres/storage.go
#
| ... | ... | @@ -2126,3 +2126,21 @@ func (me *PsqlDB) FindPipeMonitorHistory(monitorID string, from, to time.Time) ( | |
| 2126 | 2126 | } | |
| 2127 | 2127 | return history, nil | |
| 2128 | 2128 | } | |
| 2129 | + | ||
| 2130 | + | func (me *PsqlDB) InviteUser(fromUserID string, toUserID string) error { | |
| 2131 | + | _, err := me.Db.Exec( | |
| 2132 | + | `INSERT INTO invites (from_user_id, to_user_id) VALUES ($1, $2)`, | |
| 2133 | + | fromUserID, toUserID, | |
| 2134 | + | ) | |
| 2135 | + | if err != nil { | |
| 2136 | + | return err | |
| 2137 | + | } | |
| 2138 | + | ||
| 2139 | + | expiresAt := time.Now().Add(5 * 365 * 24 * time.Hour) | |
| 2140 | + | _, err = me.InsertFeature(toUserID, "prose", expiresAt) | |
| 2141 | + | if err != nil { | |
| 2142 | + | return err | |
| 2143 | + | } | |
| 2144 | + | _, err = me.InsertFeature(toUserID, "pgs", expiresAt) | |
| 2145 | + | return err | |
| 2146 | + | } |
+4
-0
pkg/db/stub/stub.go
#
| ... | ... | @@ -275,3 +275,7 @@ func (me *StubDB) InsertPipeMonitorHistory(monitorID string, windowDur time.Dura | |
| 275 | 275 | func (me *StubDB) FindPipeMonitorHistory(monitorID string, from, to time.Time) ([]*db.PipeMonitorHistory, error) { | |
| 276 | 276 | return nil, errNotImpl | |
| 277 | 277 | } | |
| 278 | + | ||
| 279 | + | func (me *StubDB) InviteUser(fromUserID string, toUserID string) error { | |
| 280 | + | return errNotImpl | |
| 281 | + | } |
+13
-7
pkg/tui/info.go
#
| ... | ... | @@ -153,12 +153,13 @@ func (m *FeaturesList) getFeaturesKv() []Kv { | |
| 153 | 153 | } | |
| 154 | 154 | ||
| 155 | 155 | type ServicesList struct { | |
| 156 | - | plusFf *db.FeatureFlag | |
| 157 | - | pgsFf *db.FeatureFlag | |
| 156 | + | plusFf *db.FeatureFlag | |
| 157 | + | pgsFf *db.FeatureFlag | |
| 158 | + | proseFf *db.FeatureFlag | |
| 158 | 159 | } | |
| 159 | 160 | ||
| 160 | - | func NewServicesList(plusFf *db.FeatureFlag, pgsFf *db.FeatureFlag) *ServicesList { | |
| 161 | - | return &ServicesList{plusFf: plusFf, pgsFf: pgsFf} | |
| 161 | + | func NewServicesList(plusFf *db.FeatureFlag, pgsFf *db.FeatureFlag, proseFf *db.FeatureFlag) *ServicesList { | |
| 162 | + | return &ServicesList{plusFf: plusFf, pgsFf: pgsFf, proseFf: proseFf} | |
| 162 | 163 | } | |
| 163 | 164 | ||
| 164 | 165 | func (m *ServicesList) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) { |
| ... | ... | @@ -183,15 +184,20 @@ func (m *ServicesList) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) { | |
| 183 | 184 | func (m *ServicesList) getServiceKv() []Kv { | |
| 184 | 185 | hasPlus := m.plusFf != nil && m.plusFf.IsValid() | |
| 185 | 186 | hasPgs := m.pgsFf != nil && m.pgsFf.IsValid() | |
| 186 | - | pagesStatus := "pico+" | |
| 187 | + | hasProse := m.proseFf != nil && m.proseFf.IsValid() | |
| 188 | + | pagesStatus := "invite/pico+" | |
| 187 | 189 | if hasPlus || hasPgs { | |
| 188 | 190 | pagesStatus = "active" | |
| 189 | 191 | } | |
| 192 | + | proseStatus := "invite/pico+" | |
| 193 | + | if hasPlus || hasProse { | |
| 194 | + | proseStatus = "active" | |
| 195 | + | } | |
| 190 | 196 | data := [][]string{ | |
| 191 | 197 | {"name", "status"}, | |
| 192 | - | {"prose", "active"}, | |
| 193 | 198 | {"pipe", "active"}, | |
| 194 | 199 | {"pastes", "active"}, | |
| 200 | + | {"prose", proseStatus}, | |
| 195 | 201 | {"pages", pagesStatus}, | |
| 196 | 202 | } | |
| 197 | 203 |
+131
-0
pkg/tui/invite.go
#
| ... | ... | @@ -0,0 +1,131 @@ | |
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 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/richtext" | |
| 10 | + | "git.sr.ht/~rockorager/vaxis/vxfw/text" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type AddInvitePage struct { | |
| 14 | + | shared *SharedModel | |
| 15 | + | ||
| 16 | + | err error | |
| 17 | + | focus string | |
| 18 | + | input *TextInput | |
| 19 | + | btn *button.Button | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func NewAddInvitePage(shrd *SharedModel) *AddInvitePage { | |
| 23 | + | btn := button.New("SEND", func() (vxfw.Command, error) { return nil, nil }) | |
| 24 | + | btn.Style = button.StyleSet{ | |
| 25 | + | Default: vaxis.Style{Background: grey}, | |
| 26 | + | Focus: vaxis.Style{Background: oj, Foreground: black}, | |
| 27 | + | } | |
| 28 | + | return &AddInvitePage{ | |
| 29 | + | shared: shrd, | |
| 30 | + | ||
| 31 | + | input: NewTextInput("pico user"), | |
| 32 | + | btn: btn, | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (m *AddInvitePage) Footer() []Shortcut { | |
| 37 | + | return []Shortcut{ | |
| 38 | + | {Shortcut: "tab", Text: "focus"}, | |
| 39 | + | {Shortcut: "enter", Text: "invite user"}, | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (m *AddInvitePage) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) { | |
| 44 | + | switch msg := ev.(type) { | |
| 45 | + | case vaxis.Key: | |
| 46 | + | if msg.Matches(vaxis.KeyEnter) { | |
| 47 | + | err := m.addInvite(m.input.GetValue()) | |
| 48 | + | m.err = err | |
| 49 | + | if err == nil { | |
| 50 | + | m.input.Reset() | |
| 51 | + | m.shared.App.PostEvent(Navigate{To: HOME}) | |
| 52 | + | return nil, nil | |
| 53 | + | } | |
| 54 | + | return vxfw.RedrawCmd{}, nil | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | return nil, nil | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func (m *AddInvitePage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) { | |
| 61 | + | switch msg := ev.(type) { | |
| 62 | + | case PageIn: | |
| 63 | + | m.err = nil | |
| 64 | + | m.focus = "input" | |
| 65 | + | m.input.Reset() | |
| 66 | + | return m.input.FocusIn() | |
| 67 | + | case vaxis.Key: | |
| 68 | + | if msg.Matches(vaxis.KeyTab) { | |
| 69 | + | if m.focus == "input" { | |
| 70 | + | m.focus = "button" | |
| 71 | + | cmd, _ := m.input.FocusOut() | |
| 72 | + | return vxfw.BatchCmd([]vxfw.Command{ | |
| 73 | + | vxfw.FocusWidgetCmd(m.btn), | |
| 74 | + | cmd, | |
| 75 | + | }), nil | |
| 76 | + | } | |
| 77 | + | m.focus = "input" | |
| 78 | + | return m.input.FocusIn() | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | return nil, nil | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func (m *AddInvitePage) addInvite(username string) error { | |
| 86 | + | if m.shared.PlusFeatureFlag == nil { | |
| 87 | + | return fmt.Errorf("must be pico+ to invite users") | |
| 88 | + | } | |
| 89 | + | db := m.shared.Dbpool | |
| 90 | + | user, err := db.FindUserByName(username) | |
| 91 | + | if err != nil { | |
| 92 | + | return err | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | return db.InviteUser(m.shared.User.ID, user.ID) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (m *AddInvitePage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) { | |
| 99 | + | w := ctx.Max.Width | |
| 100 | + | h := ctx.Max.Height | |
| 101 | + | root := vxfw.NewSurface(w, h, m) | |
| 102 | + | ah := 0 | |
| 103 | + | ||
| 104 | + | header := text.New("Invite a user to pico! They must already have a pico account. This grants them `pgs` and `prose` access on their respective free tiers. Only pico+ members can invite users.") | |
| 105 | + | headerSurf, _ := header.Draw(ctx) | |
| 106 | + | root.AddChild(0, ah, headerSurf) | |
| 107 | + | ah += int(headerSurf.Size.Height) + 1 | |
| 108 | + | ||
| 109 | + | inputSurf, _ := m.input.Draw(ctx) | |
| 110 | + | root.AddChild(0, ah, inputSurf) | |
| 111 | + | ah += int(headerSurf.Size.Height) + 1 | |
| 112 | + | ||
| 113 | + | btnSurf, _ := m.btn.Draw(vxfw.DrawContext{ | |
| 114 | + | Characters: ctx.Characters, | |
| 115 | + | Max: vxfw.Size{Width: 6, Height: 1}, | |
| 116 | + | }) | |
| 117 | + | root.AddChild(0, ah, btnSurf) | |
| 118 | + | ||
| 119 | + | if m.err != nil { | |
| 120 | + | e := richtext.New([]vaxis.Segment{ | |
| 121 | + | { | |
| 122 | + | Text: m.err.Error(), | |
| 123 | + | Style: vaxis.Style{Foreground: red}, | |
| 124 | + | }, | |
| 125 | + | }) | |
| 126 | + | errSurf, _ := e.Draw(createDrawCtx(ctx, 1)) | |
| 127 | + | root.AddChild(0, ah, errSurf) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | return root, nil | |
| 131 | + | } |
+5
-0
pkg/tui/ui.go
#
| ... | ... | @@ -351,6 +355,7 @@ func NewTui(opts vaxis.Options, shrd *SharedModel) error { | |
| 351 | 355 | HOME: NewMenuPage(shrd), | |
| 352 | 356 | "pubkeys": NewPubkeysPage(shrd), | |
| 353 | 357 | "add-pubkey": NewAddPubkeyPage(shrd), | |
| 358 | + | "invite": NewAddInvitePage(shrd), | |
| 354 | 359 | "tokens": NewTokensPage(shrd), | |
| 355 | 360 | "add-token": NewAddTokenPage(shrd), | |
| 356 | 361 | "signup": NewSignupPage(shrd), |
+16
-0
sql/migrations/20260925_add_invites.sql
#
| ... | ... | @@ -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 | + | ); |