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
M
Makefile
M
Makefile
+2,
-1
| ... | ... | @@ -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: |
+3,
-3
| ... | ... | @@ -21,7 +21,7 @@ func findFeatureFlag(dbpool pgsdb.PgsDB, cfg *PgsConfig, userID string) (*db.Fea | |
| 21 | 21 | setFeatureLimits(ff, cfg) | |
| 22 | 22 | return ff, nil | |
| 23 | 23 | } | |
| 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") | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 27 | ffPgs, pgsErr := dbpool.FindFeature(userID, "pgs") |
| ... | ... | @@ -30,7 +30,7 @@ func findFeatureFlag(dbpool pgsdb.PgsDB, cfg *PgsConfig, userID string) (*db.Fea | |
| 30 | 30 | setFeatureLimits(ffPgs, cfg) | |
| 31 | 31 | return ffPgs, nil | |
| 32 | 32 | } | |
| 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") | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 36 | if err != nil && strings.Contains(err.Error(), "expired") { |
| ... | ... | @@ -39,5 +39,5 @@ func findFeatureFlag(dbpool pgsdb.PgsDB, cfg *PgsConfig, userID string) (*db.Fea | |
| 39 | 39 | if pgsErr != nil && strings.Contains(pgsErr.Error(), "expired") { | |
| 40 | 40 | return nil, pgsErr | |
| 41 | 41 | } | |
| 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") | |
| 43 | 43 | } |
+2,
-2
| ... | ... | @@ -646,7 +646,7 @@ func TestFindFeatureFlag(t *testing.T) { | |
| 646 | 646 | if err == nil { | |
| 647 | 647 | t.Fatal("expected error, got nil") | |
| 648 | 648 | } | |
| 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") { | |
| 650 | 650 | t.Errorf("expected blog link in error, got %v", err) | |
| 651 | 651 | } | |
| 652 | 652 | }) |
| ... | ... | @@ -657,7 +657,7 @@ func TestFindFeatureFlag(t *testing.T) { | |
| 657 | 657 | if err == nil { | |
| 658 | 658 | t.Fatal("expected error, got nil") | |
| 659 | 659 | } | |
| 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") { | |
| 661 | 661 | t.Errorf("expected pico+ requirement and blog link in error, got %v", err) | |
| 662 | 662 | } | |
| 663 | 663 | }) |
+1,
-1
| ... | ... | @@ -57,7 +57,7 @@ func StartSshServer() { | |
| 57 | 57 | ".lxt": filehandlers.NewScpPostHandler(dbh, cfg, hooks), | |
| 58 | 58 | "fallback": uploadimgs.NewUploadImgHandler(dbh, cfg, st), | |
| 59 | 59 | } | |
| 60 | - | handler := filehandlers.NewFileHandlerRouter(cfg, dbh, fileMap) | |
| 60 | + | handler := NewUploadHandler(cfg, dbh, fileMap) | |
| 61 | 61 | ||
| 62 | 62 | sshAuth := shared.NewSshAuthHandler(dbh, logger, "prose") | |
| 63 | 63 |
+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 { | |
| 439 | 439 | UptimePercent float64 | |
| 440 | 440 | } | |
| 441 | 441 | ||
| 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 | + | ||
| 442 | 451 | func ComputeUptime(history []*PipeMonitorHistory, from, to time.Time) UptimeResult { | |
| 443 | 452 | totalDuration := to.Sub(from) | |
| 444 | 453 | if totalDuration <= 0 { |
| ... | ... | @@ -643,5 +652,9 @@ type DB interface { | |
| 643 | 652 | InsertPipeMonitorHistory(monitorID string, windowDur time.Duration, windowEnd, lastPing *time.Time) error | |
| 644 | 653 | FindPipeMonitorHistory(monitorID string, from, to time.Time) ([]*PipeMonitorHistory, error) | |
| 645 | 654 | ||
| 655 | + | FindWhoInvitedUser(toUserID string) (string, error) | |
| 656 | + | FindInvitesByUser(userID string) ([]*Invite, error) | |
| 657 | + | InviteUser(fromUserID string, toUserID string) error | |
| 658 | + | ||
| 646 | 659 | Close() error | |
| 647 | 660 | } |
+60,
-0
| ... | ... | @@ -2126,3 +2126,63 @@ func (me *PsqlDB) FindPipeMonitorHistory(monitorID string, from, to time.Time) ( | |
| 2126 | 2126 | } | |
| 2127 | 2127 | return history, nil | |
| 2128 | 2128 | } | |
| 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 | |
| 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) 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) { | |
| 31 | 31 | Characters: ctx.Characters, | |
| 32 | 32 | Max: vxfw.Size{ | |
| 33 | 33 | Width: 30, | |
| 34 | - | Height: 3 + 3, | |
| 34 | + | Height: 4 + 3, | |
| 35 | 35 | }, | |
| 36 | 36 | }) | |
| 37 | 37 | } |
| ... | ... | @@ -70,6 +70,10 @@ func (m *UserInfo) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) { | |
| 70 | 70 | if m.shared.PlusFeatureFlag != nil { | |
| 71 | 71 | h += 1 | |
| 72 | 72 | } | |
| 73 | + | if m.shared.InvitedBy != "" { | |
| 74 | + | h += 1 | |
| 75 | + | } | |
| 76 | + | ||
| 73 | 77 | return brd.Draw(vxfw.DrawContext{ | |
| 74 | 78 | Characters: ctx.Characters, | |
| 75 | 79 | Max: vxfw.Size{ |
| ... | ... | @@ -90,6 +94,10 @@ func (m *UserInfo) getKv() []Kv { | |
| 90 | 94 | kv = append(kv, Kv{Key: "pico+ expires", Value: expiresAt}) | |
| 91 | 95 | } | |
| 92 | 96 | ||
| 97 | + | if m.shared.InvitedBy != "" { | |
| 98 | + | kv = append(kv, Kv{Key: "invited by", Value: m.shared.InvitedBy}) | |
| 99 | + | } | |
| 100 | + | ||
| 93 | 101 | return kv | |
| 94 | 102 | } | |
| 95 | 103 |
| ... | ... | @@ -153,12 +161,13 @@ func (m *FeaturesList) getFeaturesKv() []Kv { | |
| 153 | 161 | } | |
| 154 | 162 | ||
| 155 | 163 | type ServicesList struct { | |
| 156 | - | plusFf *db.FeatureFlag | |
| 157 | - | pgsFf *db.FeatureFlag | |
| 164 | + | plusFf *db.FeatureFlag | |
| 165 | + | pgsFf *db.FeatureFlag | |
| 166 | + | proseFf *db.FeatureFlag | |
| 158 | 167 | } | |
| 159 | 168 | ||
| 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} | |
| 162 | 171 | } | |
| 163 | 172 | ||
| 164 | 173 | 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) { | |
| 174 | 183 | return brd.Draw(vxfw.DrawContext{ | |
| 175 | 184 | Characters: ctx.Characters, | |
| 176 | 185 | Max: vxfw.Size{ | |
| 177 | - | Width: 30, | |
| 186 | + | Width: 32, | |
| 178 | 187 | Height: uint16(servicesHeight) + 3, | |
| 179 | 188 | }, | |
| 180 | 189 | }) |
| ... | ... | @@ -183,15 +192,20 @@ func (m *ServicesList) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) { | |
| 183 | 192 | func (m *ServicesList) getServiceKv() []Kv { | |
| 184 | 193 | hasPlus := m.plusFf != nil && m.plusFf.IsValid() | |
| 185 | 194 | hasPgs := m.pgsFf != nil && m.pgsFf.IsValid() | |
| 186 | - | pagesStatus := "pico+" | |
| 195 | + | hasProse := m.proseFf != nil && m.proseFf.IsValid() | |
| 196 | + | pagesStatus := "invite/pico+" | |
| 187 | 197 | if hasPlus || hasPgs { | |
| 188 | 198 | pagesStatus = "active" | |
| 189 | 199 | } | |
| 200 | + | proseStatus := "invite/pico+" | |
| 201 | + | if hasPlus || hasProse { | |
| 202 | + | proseStatus = "active" | |
| 203 | + | } | |
| 190 | 204 | data := [][]string{ | |
| 191 | 205 | {"name", "status"}, | |
| 192 | - | {"prose", "active"}, | |
| 193 | 206 | {"pipe", "active"}, | |
| 194 | 207 | {"pastes", "active"}, | |
| 208 | + | {"prose", proseStatus}, | |
| 195 | 209 | {"pages", pagesStatus}, | |
| 196 | 210 | } | |
| 197 | 211 |
+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 | + | } |
| ... | ... | @@ -10,6 +10,7 @@ import ( | |
| 10 | 10 | ||
| 11 | 11 | var menuChoices = []string{ | |
| 12 | 12 | "pubkeys", | |
| 13 | + | "invite", | |
| 13 | 14 | "tokens", | |
| 14 | 15 | "logs", | |
| 15 | 16 | "access_logs", |
| ... | ... | @@ -104,7 +105,11 @@ func (m *MenuPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) { | |
| 104 | 105 | }, | |
| 105 | 106 | }) | |
| 106 | 107 | ||
| 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) | |
| 108 | 113 | features, _ := m.features.Draw(ctx) | |
| 109 | 114 | ||
| 110 | 115 | leftPane := NewGroupStack([]vxfw.Surface{ |
+9,
-0
| ... | ... | @@ -28,9 +28,11 @@ type SharedModel struct { | |
| 28 | 28 | Dbpool db.DB | |
| 29 | 29 | PgsDB pgsdb.PgsDB | |
| 30 | 30 | User *db.User | |
| 31 | + | InvitedBy string | |
| 31 | 32 | PlusFeatureFlag *db.FeatureFlag | |
| 32 | 33 | BouncerFeatureFlag *db.FeatureFlag | |
| 33 | 34 | PgsFeatureFlag *db.FeatureFlag | |
| 35 | + | ProseFeatureFlag *db.FeatureFlag | |
| 34 | 36 | Impersonator string | |
| 35 | 37 | App *vxfw.App | |
| 36 | 38 | } |
| ... | ... | @@ -280,6 +282,12 @@ func initData(shrd *SharedModel) error { | |
| 280 | 282 | ||
| 281 | 283 | pff, _ := FindFeatureFlag(shrd, "pgs") | |
| 282 | 284 | 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 | |
| 283 | 291 | return nil | |
| 284 | 292 | } | |
| 285 | 293 |
| ... | ... | @@ -351,6 +359,7 @@ func NewTui(opts vaxis.Options, shrd *SharedModel) error { | |
| 351 | 359 | HOME: NewMenuPage(shrd), | |
| 352 | 360 | "pubkeys": NewPubkeysPage(shrd), | |
| 353 | 361 | "add-pubkey": NewAddPubkeyPage(shrd), | |
| 362 | + | "invite": NewAddInvitePage(shrd), | |
| 354 | 363 | "tokens": NewTokensPage(shrd), | |
| 355 | 364 | "add-token": NewAddTokenPage(shrd), | |
| 356 | 365 | "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 | + | ); |