Commit 7b5a5aa

Eric Bower  ·  2026-08-17 09:35:36 -0400 EDT
parent c58eb5b
chore(pgs): check for pgs feature flag
11 files changed,  +290, -23
+37, -0
......@@ -0,0 +1,37 @@
1+package main
2+
3+import (
4+ "log/slog"
5+ "os"
6+
7+ "github.com/picosh/pico/pkg/db/postgres"
8+)
9+
10+func main() {
11+ logger := slog.Default()
12+ DbURL := os.Getenv("DATABASE_URL")
13+ dbpool := postgres.NewDB(DbURL, logger)
14+
15+ args := os.Args
16+ if len(args) < 3 {
17+ logger.Error("usage: go run ./cmd/scripts/feature-flag <username> <feature>")
18+ os.Exit(1)
19+ }
20+
21+ username := args[1]
22+ feature := args[2]
23+
24+ logger.Info(
25+ "Adding feature flag to user",
26+ "username", username,
27+ "feature", feature,
28+ )
29+
30+ err := dbpool.AddFeatureUser(username, feature)
31+ if err != nil {
32+ logger.Error("Failed to add feature flag to user", "err", err)
33+ os.Exit(1)
34+ } else {
35+ logger.Info("Successfully added feature flag to user")
36+ }
37+}
+3, -5
......@@ -230,13 +230,11 @@ This means only you can access the site through a web tunnel or by downloading t
230230 }
231231
232232 func (c *Cmd) stats(cfgMaxSize uint64) error {
233- ff, err := c.Dbpool.FindFeature(c.User.ID, "plus")
233+ ff, err := findFeatureFlag(c.Dbpool, c.Cfg, c.User.ID)
234234 if err != nil {
235- ff = db.NewFeatureFlag(c.User.ID, "plus", cfgMaxSize, 0, 0)
235+ ff = db.NewFeatureFlag(c.User.ID, "pgs", cfgMaxSize, 0, 0)
236236 }
237- // this is jank
238- ff.Data.StorageMax = ff.FindStorageMax(cfgMaxSize)
239- storageMax := ff.Data.StorageMax
237+ storageMax := ff.FindStorageMax(cfgMaxSize)
240238
241239 bucketName := shared.GetAssetBucketName(c.User.ID)
242240 bucket, err := c.Store.GetBucket(bucketName)
+1, -1
......@@ -62,7 +62,7 @@ func (c *PgsConfig) StaticPath(fname string) string {
6262 return filepath.Join("pkg", "apps", "pgs", fname)
6363 }
6464
65-var maxSize = uint64(25 * shared.MB)
65+var maxSize = uint64(50 * shared.MB)
6666 var maxAssetSize = int64(10 * shared.MB)
6767
6868 // Needs to be small for caching files like _headers and _redirects.
+10, -1
......@@ -16,6 +16,7 @@ type MemoryDB struct {
1616 Projects []*db.Project
1717 Pubkeys []*db.PublicKey
1818 Feature *db.FeatureFlag
19+ Features []*db.FeatureFlag
1920 FormEntries []*db.FormEntry
2021 }
2122
......@@ -82,7 +83,15 @@ func (me *MemoryDB) FindUserByName(name string) (*db.User, error) {
8283 }
8384
8485 func (me *MemoryDB) FindFeature(userID, name string) (*db.FeatureFlag, error) {
85- return me.Feature, nil
86+ for _, ff := range me.Features {
87+ if ff.UserID == userID && ff.Name == name {
88+ return ff, nil
89+ }
90+ }
91+ if me.Feature != nil && (me.Feature.Name == name || me.Feature.Name == "") {
92+ return me.Feature, nil
93+ }
94+ return nil, fmt.Errorf("feature flag not found")
8695 }
8796
8897 func (me *MemoryDB) Close() error {
+43, -0
......@@ -0,0 +1,43 @@
1+package pgs
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
8+ "github.com/picosh/pico/pkg/db"
9+)
10+
11+func setFeatureLimits(ff *db.FeatureFlag, cfg *PgsConfig) {
12+ ff.Data.StorageMax = ff.FindStorageMax(cfg.MaxSize)
13+ ff.Data.FileMax = ff.FindFileMax(cfg.MaxAssetSize)
14+ ff.Data.SpecialFileMax = ff.FindSpecialFileMax(cfg.MaxSpecialFileSize)
15+}
16+
17+func findFeatureFlag(dbpool pgsdb.PgsDB, cfg *PgsConfig, userID string) (*db.FeatureFlag, error) {
18+ ff, err := dbpool.FindFeature(userID, "plus")
19+ if err == nil {
20+ if ff.IsValid() {
21+ setFeatureLimits(ff, cfg)
22+ return ff, nil
23+ }
24+ err = fmt.Errorf("your pico+ has expired")
25+ }
26+
27+ ffPgs, pgsErr := dbpool.FindFeature(userID, "pgs")
28+ if pgsErr == nil {
29+ if ffPgs.IsValid() {
30+ setFeatureLimits(ffPgs, cfg)
31+ return ffPgs, nil
32+ }
33+ pgsErr = fmt.Errorf("your pgs access has expired")
34+ }
35+
36+ if err != nil && strings.Contains(err.Error(), "expired") {
37+ return nil, err
38+ }
39+ if pgsErr != nil {
40+ return nil, pgsErr
41+ }
42+ return nil, err
43+}
+1, -16
......@@ -15,7 +15,6 @@ import (
1515 "sync"
1616 "time"
1717
18- pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
1918 "github.com/picosh/pico/pkg/db"
2019 "github.com/picosh/pico/pkg/pssh"
2120 sendutils "github.com/picosh/pico/pkg/send/utils"
......@@ -219,7 +218,7 @@ func (h *UploadAssetHandler) Validate(s *pssh.SSHServerConnSession) error {
219218 return err
220219 }
221220
222- ff, err := findPlusFF(h.Cfg.DB, h.Cfg, user.ID)
221+ ff, err := findFeatureFlag(h.Cfg.DB, h.Cfg, user.ID)
223222 if err != nil {
224223 return err
225224 }
......@@ -273,20 +272,6 @@ func (h *UploadAssetHandler) findDenylist(bucket storage.Bucket, project *db.Pro
273272 return str, nil
274273 }
275274
276-func findPlusFF(dbpool pgsdb.PgsDB, cfg *PgsConfig, userID string) (*db.FeatureFlag, error) {
277- ff, err := dbpool.FindFeature(userID, "plus")
278- if err != nil {
279- return nil, err
280- }
281- if !ff.IsValid() {
282- return nil, fmt.Errorf("your pico+ has expired")
283- }
284- ff.Data.StorageMax = ff.FindStorageMax(cfg.MaxSize)
285- ff.Data.FileMax = ff.FindFileMax(cfg.MaxAssetSize)
286- ff.Data.SpecialFileMax = ff.FindSpecialFileMax(cfg.MaxSpecialFileSize)
287- return ff, nil
288-}
289-
290275 func mtimeToTime(entry *sendutils.FileEntry) time.Time {
291276 var mtime time.Time
292277 if entry.Mtime > 0 {
+157, -0
......@@ -557,3 +557,160 @@ func TestNoPlusFlagBlocksUpload(t *testing.T) {
557557 t.Errorf("expected 0 projects for non-plus user, got %d (empty project leak)", len(projects))
558558 }
559559 }
560+
561+func TestFindFeatureFlag(t *testing.T) {
562+ logger := slog.Default()
563+ cfg := &PgsConfig{
564+ MaxSize: uint64(100 * shared.MB),
565+ MaxAssetSize: int64(10 * shared.MB),
566+ MaxSpecialFileSize: int64(5 * shared.KB),
567+ }
568+
569+ validExpires := time.Now().Add(24 * time.Hour)
570+ expiredExpires := time.Now().Add(-24 * time.Hour)
571+ userID := "user-1"
572+
573+ t.Run("plus valid returns plus", func(t *testing.T) {
574+ dbpool := pgsdb.NewDBMemory(logger)
575+ plusFF := db.NewFeatureFlag(userID, "plus", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB))
576+ plusFF.ExpiresAt = &validExpires
577+ dbpool.Features = []*db.FeatureFlag{plusFF}
578+
579+ ff, err := findFeatureFlag(dbpool, cfg, userID)
580+ if err != nil {
581+ t.Fatalf("unexpected error: %v", err)
582+ }
583+ if ff.Name != "plus" {
584+ t.Errorf("expected plus, got %s", ff.Name)
585+ }
586+ })
587+
588+ t.Run("pgs valid returns pgs when no plus", func(t *testing.T) {
589+ dbpool := pgsdb.NewDBMemory(logger)
590+ pgsFF := db.NewFeatureFlag(userID, "pgs", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB))
591+ pgsFF.ExpiresAt = &validExpires
592+ dbpool.Features = []*db.FeatureFlag{pgsFF}
593+
594+ ff, err := findFeatureFlag(dbpool, cfg, userID)
595+ if err != nil {
596+ t.Fatalf("unexpected error: %v", err)
597+ }
598+ if ff.Name != "pgs" {
599+ t.Errorf("expected pgs, got %s", ff.Name)
600+ }
601+ })
602+
603+ t.Run("plus preferred over pgs when both valid", func(t *testing.T) {
604+ dbpool := pgsdb.NewDBMemory(logger)
605+ plusFF := db.NewFeatureFlag(userID, "plus", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB))
606+ plusFF.ExpiresAt = &validExpires
607+ pgsFF := db.NewFeatureFlag(userID, "pgs", uint64(30*shared.MB), int64(3*shared.MB), int64(1*shared.KB))
608+ pgsFF.ExpiresAt = &validExpires
609+ dbpool.Features = []*db.FeatureFlag{plusFF, pgsFF}
610+
611+ ff, err := findFeatureFlag(dbpool, cfg, userID)
612+ if err != nil {
613+ t.Fatalf("unexpected error: %v", err)
614+ }
615+ if ff.Name != "plus" {
616+ t.Errorf("expected plus to be picked first, got %s", ff.Name)
617+ }
618+ })
619+
620+ t.Run("pgs picked when plus is expired", func(t *testing.T) {
621+ dbpool := pgsdb.NewDBMemory(logger)
622+ plusFF := db.NewFeatureFlag(userID, "plus", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB))
623+ plusFF.ExpiresAt = &expiredExpires
624+ pgsFF := db.NewFeatureFlag(userID, "pgs", uint64(30*shared.MB), int64(3*shared.MB), int64(1*shared.KB))
625+ pgsFF.ExpiresAt = &validExpires
626+ dbpool.Features = []*db.FeatureFlag{plusFF, pgsFF}
627+
628+ ff, err := findFeatureFlag(dbpool, cfg, userID)
629+ if err != nil {
630+ t.Fatalf("unexpected error: %v", err)
631+ }
632+ if ff.Name != "pgs" {
633+ t.Errorf("expected pgs when plus is expired, got %s", ff.Name)
634+ }
635+ })
636+
637+ t.Run("error when both plus and pgs expired", func(t *testing.T) {
638+ dbpool := pgsdb.NewDBMemory(logger)
639+ plusFF := db.NewFeatureFlag(userID, "plus", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB))
640+ plusFF.ExpiresAt = &expiredExpires
641+ pgsFF := db.NewFeatureFlag(userID, "pgs", uint64(30*shared.MB), int64(3*shared.MB), int64(1*shared.KB))
642+ pgsFF.ExpiresAt = &expiredExpires
643+ dbpool.Features = []*db.FeatureFlag{plusFF, pgsFF}
644+
645+ _, err := findFeatureFlag(dbpool, cfg, userID)
646+ if err == nil {
647+ t.Fatal("expected error, got nil")
648+ }
649+ })
650+
651+ t.Run("error when neither plus nor pgs exists", func(t *testing.T) {
652+ dbpool := pgsdb.NewDBMemory(logger)
653+ _, err := findFeatureFlag(dbpool, cfg, userID)
654+ if err == nil {
655+ t.Fatal("expected error, got nil")
656+ }
657+ })
658+}
659+
660+func TestPgsFlagAllowsUpload(t *testing.T) {
661+ dbpool := pgsdb.NewDBMemory(slog.Default())
662+ dbpool.SetupTestData()
663+ // Replace default plus flag with a valid pgs flag
664+ dbpool.Feature = nil
665+ valid := time.Now().Add(24 * time.Hour)
666+ pgsFF := db.NewFeatureFlag(
667+ dbpool.Users[0].ID,
668+ "pgs",
669+ uint64(25*shared.MB),
670+ int64(10*shared.MB),
671+ int64(5*shared.KB),
672+ )
673+ pgsFF.ExpiresAt = &valid
674+ dbpool.Features = []*db.FeatureFlag{pgsFF}
675+
676+ addr, teardown := setupPgsTestServer(t, dbpool)
677+ defer teardown()
678+
679+ user := GenerateUser()
680+ dbpool.Pubkeys = append(dbpool.Pubkeys, &db.PublicKey{
681+ ID: "pgs-user-pubkey",
682+ UserID: dbpool.Users[0].ID,
683+ Key: shared.KeyForKeyText(user.signer.PublicKey()),
684+ })
685+
686+ conn, err := user.NewClientAddr(addr)
687+ if err != nil {
688+ t.Fatalf("ssh dial failed: %v", err)
689+ }
690+ defer func() { _ = conn.Close() }()
691+
692+ client, err := sftp.NewClient(conn)
693+ if err != nil {
694+ t.Fatalf("sftp client failed: %v", err)
695+ }
696+ defer func() { _ = client.Close() }()
697+
698+ // Upload should succeed for a pgs user.
699+ f, err := client.Create("myproject/index.html")
700+ if err != nil {
701+ t.Fatalf("pgs user upload was blocked unexpectedly: %v", err)
702+ }
703+ if _, err := f.Write([]byte("<html>hello from pgs</html>")); err != nil {
704+ t.Fatalf("write failed: %v", err)
705+ }
706+ _ = f.Close()
707+
708+ // Confirm the file is accessible via SFTP stat.
709+ fi, err := client.Lstat("myproject/index.html")
710+ if err != nil {
711+ t.Fatalf("file not found after upload: %v", err)
712+ }
713+ if fi.Size() == 0 {
714+ t.Errorf("uploaded file has zero size")
715+ }
716+}
+1, -0
......@@ -609,6 +609,7 @@ type DB interface {
609609 VisitUrlNotFound(opts *SummaryOpts) ([]*VisitUrl, error)
610610
611611 AddPicoPlusUser(username, email, paymentType, txId string) error
612+ AddFeatureUser(username, name string) error
612613 FindFeature(userID string, feature string) (*FeatureFlag, error)
613614 FindFeaturesByUser(userID string) ([]*FeatureFlag, error)
614615 HasFeatureByUser(userID string, feature string) bool
+11, -0
......@@ -1902,6 +1902,17 @@ func (me *PsqlDB) AddPicoPlusUser(username, email, paymentType, txId string) err
19021902 return tx.Commit()
19031903 }
19041904
1905+func (me *PsqlDB) AddFeatureUser(username, name string) error {
1906+ user, err := me.FindUserByName(username)
1907+ if err != nil {
1908+ return err
1909+ }
1910+
1911+ expiresAt := me.createFeatureExpiresAt(user.ID, name)
1912+ _, err = me.InsertFeature(user.ID, name, expiresAt)
1913+ return err
1914+}
1915+
19051916 func (me *PsqlDB) UpsertProject(userID, projectName, projectDir string) (*db.Project, error) {
19061917 project, err := me.FindProjectByName(userID, projectName)
19071918 if err == nil {
+22, -0
......@@ -1081,6 +1081,28 @@ func TestAddPicoPlusUser(t *testing.T) {
10811081 }
10821082 }
10831083
1084+func TestAddFeatureUser(t *testing.T) {
1085+ cleanupTestData(t)
1086+
1087+ user, _ := testDB.RegisterUser("featureuserowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI featureuserowner", "comment", "")
1088+
1089+ err := testDB.AddFeatureUser("featureuserowner", "pgs")
1090+ if err != nil {
1091+ t.Fatalf("AddFeatureUser failed: %v", err)
1092+ }
1093+
1094+ ff, err := testDB.FindFeature(user.ID, "pgs")
1095+ if err != nil {
1096+ t.Fatalf("FindFeature failed: %v", err)
1097+ }
1098+ if ff.Name != "pgs" {
1099+ t.Errorf("expected feature name pgs, got %s", ff.Name)
1100+ }
1101+ if !ff.IsValid() {
1102+ t.Errorf("expected feature to be valid")
1103+ }
1104+}
1105+
10841106 // ============ Feed Items Tests ============
10851107
10861108 func TestInsertFeedItems(t *testing.T) {
+4, -0
......@@ -204,6 +204,10 @@ func (me *StubDB) AddPicoPlusUser(username, email, paymentType, txId string) err
204204 return errNotImpl
205205 }
206206
207+func (me *StubDB) AddFeatureUser(username, name string) error {
208+ return errNotImpl
209+}
210+
207211 func (me *StubDB) FindUserStats(userID string) (*db.UserStats, error) {
208212 return nil, errNotImpl
209213 }