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.2
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,
1 modified,
0 signature changed,
0 removed
across 3 analyzed files
pkg/apps/prose/uploader.go
-
type_declarationctxFeatureFlagKeyadded -
function_declarationgetFeatureFlagadded -
function_declarationsetFeatureLimitsadded -
function_declarationNewUploadHandleradded -
method_declarationGetLoggeradded -
function_declarationsetFeatureFlagadded -
function_declarationfindFeatureFlagadded -
type_declarationUploadHandleradded -
method_declarationValidateadded
+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 | + | } |
+227
-0
pkg/apps/prose/uploader_test.go
#
| ... | ... | @@ -0,0 +1,227 @@ | |
| 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 TestFindFeatureFlag(t *testing.T) { | |
| 44 | + | cfg := NewConfigSite("prose-test") | |
| 45 | + | validExpires := time.Now().Add(24 * time.Hour) | |
| 46 | + | expiredExpires := time.Now().Add(-24 * time.Hour) | |
| 47 | + | userID := "user-1" | |
| 48 | + | ||
| 49 | + | t.Run("plus valid returns plus", func(t *testing.T) { | |
| 50 | + | mockDB := newMockFeatureDB() | |
| 51 | + | plusFF := db.NewFeatureFlag(userID, "plus", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB)) | |
| 52 | + | plusFF.ExpiresAt = &validExpires | |
| 53 | + | mockDB.setFeature(userID, plusFF) | |
| 54 | + | ||
| 55 | + | ff, err := findFeatureFlag(mockDB, cfg, userID) | |
| 56 | + | if err != nil { | |
| 57 | + | t.Fatalf("unexpected error: %v", err) | |
| 58 | + | } | |
| 59 | + | if ff.Name != "plus" { | |
| 60 | + | t.Errorf("expected plus, got %s", ff.Name) | |
| 61 | + | } | |
| 62 | + | }) | |
| 63 | + | ||
| 64 | + | t.Run("prose valid returns prose when no plus", func(t *testing.T) { | |
| 65 | + | mockDB := newMockFeatureDB() | |
| 66 | + | proseFF := db.NewFeatureFlag(userID, "prose", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB)) | |
| 67 | + | proseFF.ExpiresAt = &validExpires | |
| 68 | + | mockDB.setFeature(userID, proseFF) | |
| 69 | + | ||
| 70 | + | ff, err := findFeatureFlag(mockDB, cfg, userID) | |
| 71 | + | if err != nil { | |
| 72 | + | t.Fatalf("unexpected error: %v", err) | |
| 73 | + | } | |
| 74 | + | if ff.Name != "prose" { | |
| 75 | + | t.Errorf("expected prose, got %s", ff.Name) | |
| 76 | + | } | |
| 77 | + | }) | |
| 78 | + | ||
| 79 | + | t.Run("plus valid takes precedence when both plus and prose exist", func(t *testing.T) { | |
| 80 | + | mockDB := newMockFeatureDB() | |
| 81 | + | plusFF := db.NewFeatureFlag(userID, "plus", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB)) | |
| 82 | + | plusFF.ExpiresAt = &validExpires | |
| 83 | + | mockDB.setFeature(userID, plusFF) | |
| 84 | + | ||
| 85 | + | proseFF := db.NewFeatureFlag(userID, "prose", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB)) | |
| 86 | + | proseFF.ExpiresAt = &validExpires | |
| 87 | + | mockDB.setFeature(userID, proseFF) | |
| 88 | + | ||
| 89 | + | ff, err := findFeatureFlag(mockDB, cfg, userID) | |
| 90 | + | if err != nil { | |
| 91 | + | t.Fatalf("unexpected error: %v", err) | |
| 92 | + | } | |
| 93 | + | if ff.Name != "plus" { | |
| 94 | + | t.Errorf("expected plus, got %s", ff.Name) | |
| 95 | + | } | |
| 96 | + | }) | |
| 97 | + | ||
| 98 | + | t.Run("plus expired falls back to prose if prose is valid", func(t *testing.T) { | |
| 99 | + | mockDB := newMockFeatureDB() | |
| 100 | + | plusFF := db.NewFeatureFlag(userID, "plus", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB)) | |
| 101 | + | plusFF.ExpiresAt = &expiredExpires | |
| 102 | + | mockDB.setFeature(userID, plusFF) | |
| 103 | + | ||
| 104 | + | proseFF := db.NewFeatureFlag(userID, "prose", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB)) | |
| 105 | + | proseFF.ExpiresAt = &validExpires | |
| 106 | + | mockDB.setFeature(userID, proseFF) | |
| 107 | + | ||
| 108 | + | ff, err := findFeatureFlag(mockDB, cfg, userID) | |
| 109 | + | if err != nil { | |
| 110 | + | t.Fatalf("unexpected error: %v", err) | |
| 111 | + | } | |
| 112 | + | if ff.Name != "prose" { | |
| 113 | + | t.Errorf("expected prose, got %s", ff.Name) | |
| 114 | + | } | |
| 115 | + | }) | |
| 116 | + | ||
| 117 | + | t.Run("plus expired and no prose returns expired error", func(t *testing.T) { | |
| 118 | + | mockDB := newMockFeatureDB() | |
| 119 | + | plusFF := db.NewFeatureFlag(userID, "plus", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB)) | |
| 120 | + | plusFF.ExpiresAt = &expiredExpires | |
| 121 | + | mockDB.setFeature(userID, plusFF) | |
| 122 | + | ||
| 123 | + | _, err := findFeatureFlag(mockDB, cfg, userID) | |
| 124 | + | if err == nil { | |
| 125 | + | t.Fatalf("expected error, got nil") | |
| 126 | + | } | |
| 127 | + | if err.Error() != "ERROR: your pico+ has expired" { | |
| 128 | + | t.Errorf("unexpected error message: %v", err) | |
| 129 | + | } | |
| 130 | + | }) | |
| 131 | + | ||
| 132 | + | t.Run("prose expired and no plus returns expired error", func(t *testing.T) { | |
| 133 | + | mockDB := newMockFeatureDB() | |
| 134 | + | proseFF := db.NewFeatureFlag(userID, "prose", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB)) | |
| 135 | + | proseFF.ExpiresAt = &expiredExpires | |
| 136 | + | mockDB.setFeature(userID, proseFF) | |
| 137 | + | ||
| 138 | + | _, err := findFeatureFlag(mockDB, cfg, userID) | |
| 139 | + | if err == nil { | |
| 140 | + | t.Fatalf("expected error, got nil") | |
| 141 | + | } | |
| 142 | + | if err.Error() != "ERROR: your prose access has expired" { | |
| 143 | + | t.Errorf("unexpected error message: %v", err) | |
| 144 | + | } | |
| 145 | + | }) | |
| 146 | + | ||
| 147 | + | t.Run("no flags returns required message", func(t *testing.T) { | |
| 148 | + | mockDB := newMockFeatureDB() | |
| 149 | + | ||
| 150 | + | _, err := findFeatureFlag(mockDB, cfg, userID) | |
| 151 | + | if err == nil { | |
| 152 | + | t.Fatalf("expected error, got nil") | |
| 153 | + | } | |
| 154 | + | if err.Error() != "ERROR: uploading to prose requires the prose feature flag or pico+" { | |
| 155 | + | t.Errorf("unexpected error message: %v", err) | |
| 156 | + | } | |
| 157 | + | }) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func TestUploadHandlerValidate(t *testing.T) { | |
| 161 | + | cfg := NewConfigSite("prose-test") | |
| 162 | + | validExpires := time.Now().Add(24 * time.Hour) | |
| 163 | + | userID := "user-1" | |
| 164 | + | ||
| 165 | + | createSession := func(user *db.User) *pssh.SSHServerConnSession { | |
| 166 | + | conn := &pssh.SSHServerConn{ | |
| 167 | + | Conn: &ssh.ServerConn{ | |
| 168 | + | Permissions: &ssh.Permissions{ | |
| 169 | + | Extensions: map[string]string{}, | |
| 170 | + | }, | |
| 171 | + | }, | |
| 172 | + | Logger: slog.Default(), | |
| 173 | + | } | |
| 174 | + | sesh := &pssh.SSHServerConnSession{ | |
| 175 | + | SSHServerConn: conn, | |
| 176 | + | Ctx: context.Background(), | |
| 177 | + | } | |
| 178 | + | if user != nil { | |
| 179 | + | pssh.SetUser(sesh, user) | |
| 180 | + | } | |
| 181 | + | return sesh | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | t.Run("nil user in session fails validation", func(t *testing.T) { | |
| 185 | + | mockDB := newMockFeatureDB() | |
| 186 | + | handler := NewUploadHandler(cfg, mockDB, map[string]filehandlers.ReadWriteHandler{}) | |
| 187 | + | ||
| 188 | + | sesh := createSession(nil) | |
| 189 | + | err := handler.Validate(sesh) | |
| 190 | + | if err == nil { | |
| 191 | + | t.Fatalf("expected error for nil user, got nil") | |
| 192 | + | } | |
| 193 | + | }) | |
| 194 | + | ||
| 195 | + | t.Run("user without feature flag fails validation", func(t *testing.T) { | |
| 196 | + | mockDB := newMockFeatureDB() | |
| 197 | + | handler := NewUploadHandler(cfg, mockDB, map[string]filehandlers.ReadWriteHandler{}) | |
| 198 | + | ||
| 199 | + | user := &db.User{ID: userID, Name: "tester"} | |
| 200 | + | sesh := createSession(user) | |
| 201 | + | err := handler.Validate(sesh) | |
| 202 | + | if err == nil { | |
| 203 | + | t.Fatalf("expected error for user without feature flag, got nil") | |
| 204 | + | } | |
| 205 | + | }) | |
| 206 | + | ||
| 207 | + | t.Run("user with prose feature flag passes validation and sets session flag", func(t *testing.T) { | |
| 208 | + | mockDB := newMockFeatureDB() | |
| 209 | + | proseFF := db.NewFeatureFlag(userID, "prose", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB)) | |
| 210 | + | proseFF.ExpiresAt = &validExpires | |
| 211 | + | mockDB.setFeature(userID, proseFF) | |
| 212 | + | ||
| 213 | + | handler := NewUploadHandler(cfg, mockDB, map[string]filehandlers.ReadWriteHandler{}) | |
| 214 | + | ||
| 215 | + | user := &db.User{ID: userID, Name: "tester"} | |
| 216 | + | sesh := createSession(user) | |
| 217 | + | err := handler.Validate(sesh) | |
| 218 | + | if err != nil { | |
| 219 | + | t.Fatalf("unexpected error: %v", err) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | ff := getFeatureFlag(sesh) | |
| 223 | + | if ff == nil || ff.Name != "prose" { | |
| 224 | + | t.Errorf("expected session feature flag 'prose', got %v", ff) | |
| 225 | + | } | |
| 226 | + | }) | |
| 227 | + | } |