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.5
chore(prose): require plus or prose feature flag in order to upload
Eric Bower
2026-09-26T01:38:45ZThis now blocks users without feature flag access from uploading files to prose.
Semantic diff summary
15 added,
5 modified,
0 signature changed,
0 removed
across 6 analyzed files
pkg/apps/prose/uploader.go
-
method_declarationValidateadded -
type_declarationctxFeatureFlagKeyadded -
function_declarationsetFeatureFlagadded -
function_declarationsetFeatureLimitsadded -
function_declarationfindFeatureFlagadded -
type_declarationUploadHandleradded -
function_declarationNewUploadHandleradded -
function_declarationgetFeatureFlagadded -
method_declarationGetLoggeradded
+1
-1
pkg/apps/prose/ssh.go
#
| ... | ... | @@ -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
pkg/apps/prose/uploader.go
#
| ... | ... | @@ -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") | |
| 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") | |
| 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 the prose feature flag or pico+") | |
| 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
pkg/apps/prose/uploader_test.go
#
| ... | ... | @@ -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 | + | } |
+7
-0
pkg/db/db.go
#
| ... | ... | @@ -439,6 +439,13 @@ 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 | + | } | |
| 448 | + | ||
| 442 | 449 | func ComputeUptime(history []*PipeMonitorHistory, from, to time.Time) UptimeResult { | |
| 443 | 450 | totalDuration := to.Sub(from) | |
| 444 | 451 | if totalDuration <= 0 { |
+11
-0
pkg/db/postgres/storage.go
#
| ... | ... | @@ -2128,6 +2128,17 @@ func (me *PsqlDB) FindPipeMonitorHistory(monitorID string, from, to time.Time) ( | |
| 2128 | 2128 | } | |
| 2129 | 2129 | ||
| 2130 | 2130 | func (me *PsqlDB) InviteUser(fromUserID string, toUserID string) error { | |
| 2131 | + | ff, _ := me.FindFeature(fromUserID, "plus") | |
| 2132 | + | hasPlus := ff != nil && ff.IsValid() | |
| 2133 | + | ||
| 2134 | + | var invite *db.Invite | |
| 2135 | + | _ = me.Db.Select(&invite, "SELECT * from invites WHERE to_user_id=?", fromUserID) | |
| 2136 | + | hasBeenInvited := invite != nil | |
| 2137 | + | ||
| 2138 | + | if !hasBeenInvited && !hasPlus { | |
| 2139 | + | return fmt.Errorf("must have valid pico+ membership or have been invited yourself by someone") | |
| 2140 | + | } | |
| 2141 | + | ||
| 2131 | 2142 | _, err := me.Db.Exec( | |
| 2132 | 2143 | `INSERT INTO invites (from_user_id, to_user_id) VALUES ($1, $2)`, | |
| 2133 | 2144 | fromUserID, toUserID, |
+1
-6
pkg/tui/invite.go
#
| ... | ... | @@ -83,9 +81,6 @@ func (m *AddInvitePage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw | |
| 83 | 81 | } | |
| 84 | 82 | ||
| 85 | 83 | 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 | 84 | db := m.shared.Dbpool | |
| 90 | 85 | user, err := db.FindUserByName(username) | |
| 91 | 86 | if err != nil { |
| ... | ... | @@ -101,7 +96,7 @@ func (m *AddInvitePage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) { | |
| 101 | 96 | root := vxfw.NewSurface(w, h, m) | |
| 102 | 97 | ah := 0 | |
| 103 | 98 | ||
| 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.") | |
| 99 | + | 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 or users that received an invite can invite users.") | |
| 105 | 100 | headerSurf, _ := header.Draw(ctx) | |
| 106 | 101 | root.AddChild(0, ah, headerSurf) | |
| 107 | 102 | ah += int(headerSurf.Size.Height) + 1 |