Commit 949a5c0
Antonio Mika
·
2026-10-10 15:28:43 -0400 EDT
parent c1156d3
feat(invites): gate pastes and let invited users invite
11 files changed,
+350,
-22
M
Makefile
+2,
-1
| ... | ... | @@ -164,10 +164,11 @@ migrate: | |
| 164 | 164 | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260504_add_analytics_summary_indexes.sql | |
| 165 | 165 | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260716_block_signups.sql | |
| 166 | 166 | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260925_add_invites.sql | |
| 167 | + | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20261010_add_pastes_invite_feature.sql | |
| 167 | 168 | .PHONY: migrate | |
| 168 | 169 | ||
| 169 | 170 | latest: | |
| 170 | - | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260925_add_invites.sql | |
| 171 | + | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20261010_add_pastes_invite_feature.sql | |
| 171 | 172 | .PHONY: latest | |
| 172 | 173 | ||
| 173 | 174 | psql: |
+1,
-1
| ... | ... | @@ -43,7 +43,7 @@ func StartSshServer() { | |
| 43 | 43 | fileMap := map[string]filehandlers.ReadWriteHandler{ | |
| 44 | 44 | "fallback": filehandlers.NewScpPostHandler(dbh, cfg, hooks), | |
| 45 | 45 | } | |
| 46 | - | handler := filehandlers.NewFileHandlerRouter(cfg, dbh, fileMap) | |
| 46 | + | handler := NewUploadHandler(cfg, dbh, fileMap) | |
| 47 | 47 | sshAuth := shared.NewSshAuthHandler(dbh, logger, "pastes") | |
| 48 | 48 | ||
| 49 | 49 | // Create a new SSH server |
+77,
-0
| ... | ... | @@ -0,0 +1,77 @@ | |
| 1 | + | package pastes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "log/slog" | |
| 6 | + | ||
| 7 | + | "github.com/picosh/pico/pkg/db" | |
| 8 | + | "github.com/picosh/pico/pkg/filehandlers" | |
| 9 | + | "github.com/picosh/pico/pkg/pssh" | |
| 10 | + | sendutils "github.com/picosh/pico/pkg/send/utils" | |
| 11 | + | "github.com/picosh/pico/pkg/shared" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func checkAccess(dbpool db.DB, userID string) error { | |
| 15 | + | ffPlus, plusErr := dbpool.FindFeature(userID, "plus") | |
| 16 | + | if plusErr == nil && ffPlus.IsValid() { | |
| 17 | + | return nil | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | ffPastes, pastesErr := dbpool.FindFeature(userID, "pastes") | |
| 21 | + | if pastesErr == nil { | |
| 22 | + | if ffPastes.IsValid() { | |
| 23 | + | return nil | |
| 24 | + | } | |
| 25 | + | return fmt.Errorf("ERROR: your pastes access has expired: https://blog.pico.sh/ann-038-pico-invite-system") | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | if plusErr == nil { | |
| 29 | + | return fmt.Errorf("ERROR: your pico+ has expired: https://blog.pico.sh/ann-038-pico-invite-system") | |
| 30 | + | } | |
| 31 | + | return fmt.Errorf("ERROR: uploading to pastes requires an invitation or pico+: https://blog.pico.sh/ann-038-pico-invite-system") | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | type UploadHandler struct { | |
| 35 | + | *filehandlers.FileHandlerRouter | |
| 36 | + | Cfg *shared.ConfigSite | |
| 37 | + | DB db.DB | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | var _ sendutils.CopyFromClientHandler = (*UploadHandler)(nil) | |
| 41 | + | ||
| 42 | + | func NewUploadHandler(cfg *shared.ConfigSite, dbpool db.DB, fileMap map[string]filehandlers.ReadWriteHandler) *UploadHandler { | |
| 43 | + | router := filehandlers.NewFileHandlerRouter(cfg, dbpool, fileMap) | |
| 44 | + | return &UploadHandler{ | |
| 45 | + | FileHandlerRouter: router, | |
| 46 | + | Cfg: cfg, | |
| 47 | + | DB: dbpool, | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func (h *UploadHandler) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger { | |
| 52 | + | logger := pssh.GetLogger(s) | |
| 53 | + | if logger == nil { | |
| 54 | + | if h.Cfg != nil && h.Cfg.Logger != nil { | |
| 55 | + | return h.Cfg.Logger | |
| 56 | + | } | |
| 57 | + | return slog.Default() | |
| 58 | + | } | |
| 59 | + | return logger | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func (h *UploadHandler) Validate(s *pssh.SSHServerConnSession) error { | |
| 63 | + | logger := h.GetLogger(s) | |
| 64 | + | user := pssh.GetUser(s) | |
| 65 | + | ||
| 66 | + | if user == nil { | |
| 67 | + | err := fmt.Errorf("could not get user from ctx") | |
| 68 | + | logger.Error("error getting user from ctx", "err", err) | |
| 69 | + | return err | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | if err := checkAccess(h.DB, user.ID); err != nil { | |
| 73 | + | return err | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | return h.FileHandlerRouter.Validate(s) | |
| 77 | + | } |
+130,
-0
| ... | ... | @@ -0,0 +1,130 @@ | |
| 1 | + | package pastes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "log/slog" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/picosh/pico/pkg/db" | |
| 12 | + | "github.com/picosh/pico/pkg/db/stub" | |
| 13 | + | "github.com/picosh/pico/pkg/filehandlers" | |
| 14 | + | "github.com/picosh/pico/pkg/pssh" | |
| 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 | + | if ff, ok := m.features[userID+":"+name]; ok { | |
| 32 | + | return ff, nil | |
| 33 | + | } | |
| 34 | + | return nil, fmt.Errorf("feature flag %s not found for user %s", name, userID) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (m *mockFeatureDB) setFeature(userID, name string, expiresAt time.Time) { | |
| 38 | + | m.features[userID+":"+name] = &db.FeatureFlag{UserID: userID, Name: name, ExpiresAt: &expiresAt} | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestUploadHandlerValidate(t *testing.T) { | |
| 42 | + | cfg := NewConfigSite("pastes-test") | |
| 43 | + | userID := "user-1" | |
| 44 | + | future := time.Now().Add(24 * time.Hour) | |
| 45 | + | past := time.Now().Add(-24 * time.Hour) | |
| 46 | + | ||
| 47 | + | createSession := func(user *db.User) *pssh.SSHServerConnSession { | |
| 48 | + | conn := &pssh.SSHServerConn{ | |
| 49 | + | Conn: &ssh.ServerConn{ | |
| 50 | + | Permissions: &ssh.Permissions{ | |
| 51 | + | Extensions: map[string]string{}, | |
| 52 | + | }, | |
| 53 | + | }, | |
| 54 | + | Logger: slog.Default(), | |
| 55 | + | } | |
| 56 | + | sesh := &pssh.SSHServerConnSession{ | |
| 57 | + | SSHServerConn: conn, | |
| 58 | + | Ctx: context.Background(), | |
| 59 | + | } | |
| 60 | + | if user != nil { | |
| 61 | + | pssh.SetUser(sesh, user) | |
| 62 | + | } | |
| 63 | + | return sesh | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | tests := []struct { | |
| 67 | + | name string | |
| 68 | + | setup func(m *mockFeatureDB) | |
| 69 | + | user *db.User | |
| 70 | + | wantErr string | |
| 71 | + | }{ | |
| 72 | + | { | |
| 73 | + | name: "nil user", | |
| 74 | + | setup: func(m *mockFeatureDB) {}, | |
| 75 | + | wantErr: "could not get user", | |
| 76 | + | }, | |
| 77 | + | { | |
| 78 | + | name: "no invite or pico+", | |
| 79 | + | setup: func(m *mockFeatureDB) {}, | |
| 80 | + | user: &db.User{ID: userID, Name: "tester"}, | |
| 81 | + | wantErr: "requires an invitation or pico+", | |
| 82 | + | }, | |
| 83 | + | { | |
| 84 | + | name: "pastes feature", | |
| 85 | + | setup: func(m *mockFeatureDB) { m.setFeature(userID, "pastes", future) }, | |
| 86 | + | user: &db.User{ID: userID, Name: "tester"}, | |
| 87 | + | }, | |
| 88 | + | { | |
| 89 | + | name: "pico+ feature", | |
| 90 | + | setup: func(m *mockFeatureDB) { m.setFeature(userID, "plus", future) }, | |
| 91 | + | user: &db.User{ID: userID, Name: "tester"}, | |
| 92 | + | }, | |
| 93 | + | { | |
| 94 | + | name: "expired pico+ with active pastes", | |
| 95 | + | setup: func(m *mockFeatureDB) { m.setFeature(userID, "plus", past); m.setFeature(userID, "pastes", future) }, | |
| 96 | + | user: &db.User{ID: userID, Name: "tester"}, | |
| 97 | + | }, | |
| 98 | + | { | |
| 99 | + | name: "expired pastes", | |
| 100 | + | setup: func(m *mockFeatureDB) { m.setFeature(userID, "pastes", past) }, | |
| 101 | + | user: &db.User{ID: userID, Name: "tester"}, | |
| 102 | + | wantErr: "pastes access has expired", | |
| 103 | + | }, | |
| 104 | + | { | |
| 105 | + | name: "expired pico+", | |
| 106 | + | setup: func(m *mockFeatureDB) { m.setFeature(userID, "plus", past) }, | |
| 107 | + | user: &db.User{ID: userID, Name: "tester"}, | |
| 108 | + | wantErr: "pico+ has expired", | |
| 109 | + | }, | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | for _, tt := range tests { | |
| 113 | + | t.Run(tt.name, func(t *testing.T) { | |
| 114 | + | mockDB := newMockFeatureDB() | |
| 115 | + | tt.setup(mockDB) | |
| 116 | + | handler := NewUploadHandler(cfg, mockDB, map[string]filehandlers.ReadWriteHandler{}) | |
| 117 | + | ||
| 118 | + | err := handler.Validate(createSession(tt.user)) | |
| 119 | + | if tt.wantErr == "" { | |
| 120 | + | if err != nil { | |
| 121 | + | t.Fatalf("unexpected error: %v", err) | |
| 122 | + | } | |
| 123 | + | return | |
| 124 | + | } | |
| 125 | + | if err == nil || !strings.Contains(err.Error(), tt.wantErr) { | |
| 126 | + | t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) | |
| 127 | + | } | |
| 128 | + | }) | |
| 129 | + | } | |
| 130 | + | } |
+32,
-13
| ... | ... | @@ -2262,16 +2262,35 @@ func (me *PsqlDB) FindInvitesByUser(userID string) ([]*db.Invite, error) { | |
| 2262 | 2262 | return invites, nil | |
| 2263 | 2263 | } | |
| 2264 | 2264 | ||
| 2265 | - | func (me *PsqlDB) InviteUser(fromUserID string, toUserID string) error { | |
| 2266 | - | ff, _ := me.FindFeature(fromUserID, "plus") | |
| 2267 | - | hasPlus := ff != nil && ff.IsValid() | |
| 2265 | + | // inviteFeatures are the services an invite grants access to. | |
| 2266 | + | var inviteFeatures = []string{"prose", "pgs", "pastes"} | |
| 2267 | + | ||
| 2268 | + | func (me *PsqlDB) hasActiveInvite(userID string) (bool, error) { | |
| 2269 | + | var invited bool | |
| 2270 | + | err := me.Db.Get(&invited, `SELECT EXISTS (SELECT 1 FROM invites WHERE to_user_id = $1)`, userID) | |
| 2271 | + | if err != nil || !invited { | |
| 2272 | + | return false, err | |
| 2273 | + | } | |
| 2274 | + | for _, name := range inviteFeatures { | |
| 2275 | + | if me.HasFeatureByUser(userID, name) { | |
| 2276 | + | return true, nil | |
| 2277 | + | } | |
| 2278 | + | } | |
| 2279 | + | return false, nil | |
| 2280 | + | } | |
| 2268 | 2281 | ||
| 2269 | - | var invite *db.Invite | |
| 2270 | - | _ = me.Db.Select(&invite, "SELECT * from invites WHERE to_user_id=?", fromUserID) | |
| 2271 | - | hasBeenInvited := invite != nil | |
| 2282 | + | func (me *PsqlDB) InviteUser(fromUserID string, toUserID string) error { | |
| 2283 | + | canInvite := me.HasFeatureByUser(fromUserID, "plus") | |
| 2284 | + | if !canInvite { | |
| 2285 | + | invited, err := me.hasActiveInvite(fromUserID) | |
| 2286 | + | if err != nil { | |
| 2287 | + | return err | |
| 2288 | + | } | |
| 2289 | + | canInvite = invited | |
| 2290 | + | } | |
| 2272 | 2291 | ||
| 2273 | - | if !hasBeenInvited && !hasPlus { | |
| 2274 | - | return fmt.Errorf("must have valid pico+ membership or have already been invited by someone") | |
| 2292 | + | if !canInvite { | |
| 2293 | + | return fmt.Errorf("must have valid pico+ membership or an active invite from someone") | |
| 2275 | 2294 | } | |
| 2276 | 2295 | ||
| 2277 | 2296 | _, err := me.Db.Exec( |
| ... | ... | @@ -2283,10 +2302,10 @@ func (me *PsqlDB) InviteUser(fromUserID string, toUserID string) error { | |
| 2283 | 2302 | } | |
| 2284 | 2303 | ||
| 2285 | 2304 | expiresAt := time.Now().Add(5 * 365 * 24 * time.Hour) | |
| 2286 | - | _, err = me.InsertFeature(toUserID, "prose", expiresAt) | |
| 2287 | - | if err != nil { | |
| 2288 | - | return err | |
| 2305 | + | for _, name := range inviteFeatures { | |
| 2306 | + | if _, err := me.InsertFeature(toUserID, name, expiresAt); err != nil { | |
| 2307 | + | return err | |
| 2308 | + | } | |
| 2289 | 2309 | } | |
| 2290 | - | _, err = me.InsertFeature(toUserID, "pgs", expiresAt) | |
| 2291 | - | return err | |
| 2310 | + | return nil | |
| 2292 | 2311 | } |
+80,
-0
| ... | ... | @@ -1716,3 +1716,83 @@ func TestFindPipeMonitorsByUser_Empty(t *testing.T) { | |
| 1716 | 1716 | t.Errorf("expected 0 monitors for user with none, got %d", len(monitors)) | |
| 1717 | 1717 | } | |
| 1718 | 1718 | } | |
| 1719 | + | ||
| 1720 | + | func TestInviteUser_PlusMember(t *testing.T) { | |
| 1721 | + | cleanupTestData(t) | |
| 1722 | + | ||
| 1723 | + | from, _ := testDB.RegisterUser("inviteplus", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI inviteplus", "comment", "") | |
| 1724 | + | to, _ := testDB.RegisterUser("inviteplusto", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI inviteplusto", "comment", "") | |
| 1725 | + | _, _ = testDB.InsertFeature(from.ID, "plus", time.Now().Add(365*24*time.Hour)) | |
| 1726 | + | ||
| 1727 | + | if err := testDB.InviteUser(from.ID, to.ID); err != nil { | |
| 1728 | + | t.Fatalf("InviteUser failed: %v", err) | |
| 1729 | + | } | |
| 1730 | + | ||
| 1731 | + | for _, name := range []string{"prose", "pgs", "pastes"} { | |
| 1732 | + | if !testDB.HasFeatureByUser(to.ID, name) { | |
| 1733 | + | t.Errorf("expected invitee to have %s", name) | |
| 1734 | + | } | |
| 1735 | + | } | |
| 1736 | + | ||
| 1737 | + | inviter, err := testDB.FindWhoInvitedUser(to.ID) | |
| 1738 | + | if err != nil { | |
| 1739 | + | t.Fatalf("FindWhoInvitedUser failed: %v", err) | |
| 1740 | + | } | |
| 1741 | + | if inviter != "inviteplus" { | |
| 1742 | + | t.Errorf("expected inviter inviteplus, got %s", inviter) | |
| 1743 | + | } | |
| 1744 | + | } | |
| 1745 | + | ||
| 1746 | + | func TestInviteUser_InvitedUserCanInvite(t *testing.T) { | |
| 1747 | + | cleanupTestData(t) | |
| 1748 | + | ||
| 1749 | + | plus, _ := testDB.RegisterUser("invitechaina", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI invitechaina", "comment", "") | |
| 1750 | + | mid, _ := testDB.RegisterUser("invitechainb", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI invitechainb", "comment", "") | |
| 1751 | + | last, _ := testDB.RegisterUser("invitechainc", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI invitechainc", "comment", "") | |
| 1752 | + | _, _ = testDB.InsertFeature(plus.ID, "plus", time.Now().Add(365*24*time.Hour)) | |
| 1753 | + | ||
| 1754 | + | if err := testDB.InviteUser(plus.ID, mid.ID); err != nil { | |
| 1755 | + | t.Fatalf("InviteUser from plus failed: %v", err) | |
| 1756 | + | } | |
| 1757 | + | if err := testDB.InviteUser(mid.ID, last.ID); err != nil { | |
| 1758 | + | t.Fatalf("InviteUser from invited user failed: %v", err) | |
| 1759 | + | } | |
| 1760 | + | if !testDB.HasFeatureByUser(last.ID, "pastes") { | |
| 1761 | + | t.Error("expected second invitee to have pastes") | |
| 1762 | + | } | |
| 1763 | + | } | |
| 1764 | + | ||
| 1765 | + | func TestInviteUser_Unauthorized(t *testing.T) { | |
| 1766 | + | cleanupTestData(t) | |
| 1767 | + | ||
| 1768 | + | from, _ := testDB.RegisterUser("invitenone", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI invitenone", "comment", "") | |
| 1769 | + | to, _ := testDB.RegisterUser("invitenoneto", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI invitenoneto", "comment", "") | |
| 1770 | + | ||
| 1771 | + | if err := testDB.InviteUser(from.ID, to.ID); err == nil { | |
| 1772 | + | t.Fatal("expected error for user without pico+ or invite") | |
| 1773 | + | } | |
| 1774 | + | if testDB.HasFeatureByUser(to.ID, "prose") { | |
| 1775 | + | t.Error("expected invitee not to have prose") | |
| 1776 | + | } | |
| 1777 | + | } | |
| 1778 | + | ||
| 1779 | + | func TestInviteUser_ExpiredInvite(t *testing.T) { | |
| 1780 | + | cleanupTestData(t) | |
| 1781 | + | ||
| 1782 | + | plus, _ := testDB.RegisterUser("inviteexpa", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI inviteexpa", "comment", "") | |
| 1783 | + | expired, _ := testDB.RegisterUser("inviteexpb", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI inviteexpb", "comment", "") | |
| 1784 | + | to, _ := testDB.RegisterUser("inviteexpc", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI inviteexpc", "comment", "") | |
| 1785 | + | ||
| 1786 | + | _, err := testDB.Db.Exec(`INSERT INTO invites (from_user_id, to_user_id) VALUES ($1, $2)`, plus.ID, expired.ID) | |
| 1787 | + | if err != nil { | |
| 1788 | + | t.Fatalf("insert invite failed: %v", err) | |
| 1789 | + | } | |
| 1790 | + | past := time.Now().Add(-24 * time.Hour) | |
| 1791 | + | for _, name := range []string{"prose", "pgs", "pastes"} { | |
| 1792 | + | _, _ = testDB.InsertFeature(expired.ID, name, past) | |
| 1793 | + | } | |
| 1794 | + | ||
| 1795 | + | if err := testDB.InviteUser(expired.ID, to.ID); err == nil { | |
| 1796 | + | t.Fatal("expected error for user whose invite access expired") | |
| 1797 | + | } | |
| 1798 | + | } |
+12,
-6
| ... | ... | @@ -153,13 +153,14 @@ func (m *FeaturesList) getFeaturesKv() []Kv { | |
| 153 | 153 | } | |
| 154 | 154 | ||
| 155 | 155 | type ServicesList struct { | |
| 156 | - | plusFf *db.FeatureFlag | |
| 157 | - | pgsFf *db.FeatureFlag | |
| 158 | - | proseFf *db.FeatureFlag | |
| 156 | + | plusFf *db.FeatureFlag | |
| 157 | + | pgsFf *db.FeatureFlag | |
| 158 | + | proseFf *db.FeatureFlag | |
| 159 | + | pastesFf *db.FeatureFlag | |
| 159 | 160 | } | |
| 160 | 161 | ||
| 161 | - | func NewServicesList(plusFf *db.FeatureFlag, pgsFf *db.FeatureFlag, proseFf *db.FeatureFlag) *ServicesList { | |
| 162 | - | return &ServicesList{plusFf: plusFf, pgsFf: pgsFf, proseFf: proseFf} | |
| 162 | + | func NewServicesList(plusFf *db.FeatureFlag, pgsFf *db.FeatureFlag, proseFf *db.FeatureFlag, pastesFf *db.FeatureFlag) *ServicesList { | |
| 163 | + | return &ServicesList{plusFf: plusFf, pgsFf: pgsFf, proseFf: proseFf, pastesFf: pastesFf} | |
| 163 | 164 | } | |
| 164 | 165 | ||
| 165 | 166 | func (m *ServicesList) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) { |
| ... | ... | @@ -181,6 +182,7 @@ func (m *ServicesList) getServiceKv() []Kv { | |
| 181 | 182 | hasPlus := m.plusFf != nil && m.plusFf.IsValid() | |
| 182 | 183 | hasPgs := m.pgsFf != nil && m.pgsFf.IsValid() | |
| 183 | 184 | hasProse := m.proseFf != nil && m.proseFf.IsValid() | |
| 185 | + | hasPastes := m.pastesFf != nil && m.pastesFf.IsValid() | |
| 184 | 186 | pagesStatus := "invite/pico+" | |
| 185 | 187 | if hasPlus || hasPgs { | |
| 186 | 188 | pagesStatus = "active" |
| ... | ... | @@ -189,10 +191,14 @@ func (m *ServicesList) getServiceKv() []Kv { | |
| 189 | 191 | if hasPlus || hasProse { | |
| 190 | 192 | proseStatus = "active" | |
| 191 | 193 | } | |
| 194 | + | pastesStatus := "invite/pico+" | |
| 195 | + | if hasPlus || hasPastes { | |
| 196 | + | pastesStatus = "active" | |
| 197 | + | } | |
| 192 | 198 | data := [][]string{ | |
| 193 | 199 | {"name", "status"}, | |
| 194 | 200 | {"pipe", "active"}, | |
| 195 | - | {"pastes", "active"}, | |
| 201 | + | {"pastes", pastesStatus}, | |
| 196 | 202 | {"prose", proseStatus}, | |
| 197 | 203 | {"pages", pagesStatus}, | |
| 198 | 204 | } |
+1,
-1
| ... | ... | @@ -143,7 +143,7 @@ func (m *AddInvitePage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) { | |
| 143 | 143 | root := vxfw.NewSurface(w, h, m) | |
| 144 | 144 | ah := 0 | |
| 145 | 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.") | |
| 146 | + | header := text.New("Invite a user to pico! Invitee must already have a pico account. This grants them `pgs`, `prose` and `pastes` access on their respective free tiers. Only pico+ members or users with an active invite can invite other users.") | |
| 147 | 147 | headerSurf, _ := header.Draw(ctx) | |
| 148 | 148 | root.AddChild(0, ah, headerSurf) | |
| 149 | 149 | ah += int(headerSurf.Size.Height) + 1 |
| ... | ... | @@ -109,6 +109,7 @@ func (m *MenuPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) { | |
| 109 | 109 | m.shared.PlusFeatureFlag, | |
| 110 | 110 | m.shared.PgsFeatureFlag, | |
| 111 | 111 | m.shared.ProseFeatureFlag, | |
| 112 | + | m.shared.PastesFeatureFlag, | |
| 112 | 113 | ).Draw(ctx) | |
| 113 | 114 | features, _ := m.features.Draw(ctx) | |
| 114 | 115 |
+4,
-0
| ... | ... | @@ -33,6 +33,7 @@ type SharedModel struct { | |
| 33 | 33 | BouncerFeatureFlag *db.FeatureFlag | |
| 34 | 34 | PgsFeatureFlag *db.FeatureFlag | |
| 35 | 35 | ProseFeatureFlag *db.FeatureFlag | |
| 36 | + | PastesFeatureFlag *db.FeatureFlag | |
| 36 | 37 | Impersonator string | |
| 37 | 38 | App *vxfw.App | |
| 38 | 39 | } |
| ... | ... | @@ -282,6 +283,9 @@ func initData(shrd *SharedModel) error { | |
| 282 | 283 | prosef, _ := FindFeatureFlag(shrd, "prose") | |
| 283 | 284 | shrd.ProseFeatureFlag = prosef | |
| 284 | 285 | ||
| 286 | + | pastesf, _ := FindFeatureFlag(shrd, "pastes") | |
| 287 | + | shrd.PastesFeatureFlag = pastesf | |
| 288 | + | ||
| 285 | 289 | if shrd.User != nil { | |
| 286 | 290 | invitedBy, _ := shrd.Dbpool.FindWhoInvitedUser(user.ID) | |
| 287 | 291 | shrd.InvitedBy = invitedBy |
| ... | ... | @@ -0,0 +1,10 @@ | |
| 1 | + | INSERT INTO feature_flags (user_id, name, expires_at) | |
| 2 | + | SELECT DISTINCT ON (ff.user_id) ff.user_id, 'pastes', ff.expires_at | |
| 3 | + | FROM feature_flags ff | |
| 4 | + | JOIN invites ON invites.to_user_id = ff.user_id | |
| 5 | + | WHERE ff.name = 'prose' | |
| 6 | + | AND NOT EXISTS ( | |
| 7 | + | SELECT 1 FROM feature_flags existing | |
| 8 | + | WHERE existing.user_id = ff.user_id AND existing.name = 'pastes' | |
| 9 | + | ) | |
| 10 | + | ORDER BY ff.user_id, ff.expires_at DESC; |