Commit 2fc9766

Antonio Mika  ·  2026-10-10 21:29:07 -0400 EDT
parent bd15825
refactor(apps): give each app its own uploader and db queries
55 files changed,  +4445, -3953
+1, -1
......@@ -10,7 +10,7 @@ import (
1010
1111 func main() {
1212 cfg := feeds.NewConfigSite("feeds-fetch")
13- dbh := postgres.NewDB(cfg.DbURL, cfg.Logger)
13+ dbh := feeds.NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
1414 defer func() {
1515 _ = dbh.Close()
1616 }()
+3, -3
......@@ -13,7 +13,7 @@ import (
1313 )
1414
1515 func keepAliveHandler(w http.ResponseWriter, r *http.Request) {
16- dbpool := router.GetDB(r)
16+ dbpool := getDB(r)
1717 logger := router.GetLogger(r)
1818 postID, _ := url.PathUnescape(router.GetField(r, 0))
1919
......@@ -61,7 +61,7 @@ func keepAliveHandler(w http.ResponseWriter, r *http.Request) {
6161 }
6262
6363 func unsubHandler(w http.ResponseWriter, r *http.Request) {
64- dbpool := router.GetDB(r)
64+ dbpool := getDB(r)
6565 logger := router.GetLogger(r)
6666 postID, _ := url.PathUnescape(router.GetField(r, 0))
6767
......@@ -127,7 +127,7 @@ func createStaticRoutes() []router.Route {
127127
128128 func StartApiServer() {
129129 cfg := NewConfigSite("feeds-web")
130- db := postgres.NewDB(cfg.DbURL, cfg.Logger)
130+ db := NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
131131 defer func() {
132132 _ = db.Close()
133133 }()
+1, -1
......@@ -11,7 +11,7 @@ import (
1111 "github.com/picosh/pico/pkg/shared"
1212 )
1313
14-func Middleware(dbpool db.DB, cfg *shared.ConfigSite) pssh.SSHServerMiddleware {
14+func Middleware(dbpool DB, cfg *shared.ConfigSite) pssh.SSHServerMiddleware {
1515 return func(next pssh.SSHServerHandler) pssh.SSHServerHandler {
1616 return func(sesh *pssh.SSHServerConnSession) error {
1717 args := sesh.Command()
+2, -2
......@@ -129,13 +129,13 @@ func isValidItem(logger *slog.Logger, item *gofeed.Item, feedItems []*db.FeedIte
129129
130130 type Fetcher struct {
131131 cfg *shared.ConfigSite
132- db db.DB
132+ db DB
133133 auth sasl.Client
134134 gron *gronx.Gronx
135135 host string
136136 }
137137
138-func NewFetcher(dbpool db.DB, cfg *shared.ConfigSite) *Fetcher {
138+func NewFetcher(dbpool DB, cfg *shared.ConfigSite) *Fetcher {
139139 host := os.Getenv("PICO_SMTP_HOST")
140140 smtPass := os.Getenv("PICO_SMTP_PASS")
141141 emailLogin := os.Getenv("PICO_SMTP_USER")
+111, -0
......@@ -0,0 +1,111 @@
1+package feeds
2+
3+import (
4+ "database/sql"
5+ "fmt"
6+ "net/http"
7+
8+ "github.com/picosh/pico/pkg/db"
9+ "github.com/picosh/pico/pkg/db/postgres"
10+ "github.com/picosh/pico/pkg/shared/router"
11+)
12+
13+// DB is the storage feeds uses.
14+type DB interface {
15+ db.PostDB
16+ FindUser(userID string) (*db.User, error)
17+ FindPost(postID string) (*db.Post, error)
18+ FindUsersWithPost(space string) ([]*db.User, error)
19+ HasFeatureByUser(userID string, feature string) bool
20+ InsertFeedItems(postID string, items []*db.FeedItem) error
21+ FindFeedItemsByPostID(postID string) ([]*db.FeedItem, error)
22+}
23+
24+// PsqlDB is feeds's postgres storage: the queries apps share, and the ones
25+// only feeds makes.
26+type PsqlDB struct {
27+ *postgres.PsqlDB
28+}
29+
30+var _ DB = (*PsqlDB)(nil)
31+
32+func NewDB(base *postgres.PsqlDB) *PsqlDB {
33+ return &PsqlDB{PsqlDB: base}
34+}
35+
36+func (me *PsqlDB) FindUsersWithPost(space string) ([]*db.User, error) {
37+ var users []*db.User
38+ rs, err := me.Db.Queryx(
39+ `SELECT u.id, u.name, u.created_at
40+ FROM app_users u
41+ INNER JOIN posts ON u.id=posts.user_id
42+ WHERE cur_space='feeds'
43+ GROUP BY u.id, u.name, u.created_at
44+ ORDER BY name ASC`,
45+ )
46+ if err != nil {
47+ return users, err
48+ }
49+ defer func() { _ = rs.Close() }()
50+ for rs.Next() {
51+ var name sql.NullString
52+ user := &db.User{}
53+ err := rs.Scan(
54+ &user.ID,
55+ &name,
56+ &user.CreatedAt,
57+ )
58+ if err != nil {
59+ return users, err
60+ }
61+ user.Name = name.String
62+
63+ users = append(users, user)
64+ }
65+ if rs.Err() != nil {
66+ return users, rs.Err()
67+ }
68+ return users, nil
69+}
70+
71+func (me *PsqlDB) InsertFeedItems(postID string, items []*db.FeedItem) error {
72+ tx, err := me.Db.Beginx()
73+ if err != nil {
74+ return err
75+ }
76+ defer func() {
77+ _ = tx.Rollback()
78+ }()
79+
80+ for _, item := range items {
81+ _, err := tx.Exec(
82+ `INSERT INTO feed_items (post_id, guid, data) VALUES ($1, $2, $3) RETURNING id;`,
83+ item.PostID,
84+ item.GUID,
85+ item.Data,
86+ )
87+ if err != nil {
88+ return fmt.Errorf(
89+ "post id:%s, link:%s, guid:%s, err:%w",
90+ item.PostID, item.Data.Link, item.GUID, err,
91+ )
92+ }
93+ }
94+
95+ err = tx.Commit()
96+ return err
97+}
98+
99+func (me *PsqlDB) FindFeedItemsByPostID(postID string) ([]*db.FeedItem, error) {
100+ var items []*db.FeedItem
101+ err := me.Db.Select(&items, `SELECT * FROM feed_items WHERE post_id=$1`, postID)
102+ if err != nil {
103+ return nil, err
104+ }
105+ return items, nil
106+}
107+
108+// getDB is the storage StartApiServer gave the router, which is a PsqlDB.
109+func getDB(r *http.Request) DB {
110+ return router.GetDB(r).(DB)
111+}
+119, -0
......@@ -0,0 +1,119 @@
1+package feeds
2+
3+import (
4+ "log/slog"
5+ "testing"
6+ "time"
7+
8+ "github.com/picosh/pico/pkg/db"
9+ "github.com/picosh/pico/pkg/db/postgres"
10+ "github.com/picosh/pico/pkg/db/postgres/pgtest"
11+)
12+
13+var testDB *PsqlDB
14+
15+func TestMain(m *testing.M) {
16+ pgtest.Main(m)
17+}
18+
19+func cleanupTestData(t *testing.T) {
20+ t.Helper()
21+ if testDB == nil {
22+ testDB = NewDB(postgres.NewDB(pgtest.URL(t), slog.New(slog.DiscardHandler)))
23+ }
24+ pgtest.Clean(t, testDB.Db)
25+}
26+
27+func mustInsertPost(t *testing.T, post *db.Post) *db.Post {
28+ t.Helper()
29+ return pgtest.InsertPost(t, testDB, post)
30+}
31+
32+func TestFindUsersWithPost(t *testing.T) {
33+ cleanupTestData(t)
34+
35+ user, _ := testDB.RegisterUser("feedspostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedspostowner", "comment", "")
36+
37+ _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feed.txt", Slug: "feed", Title: "Feed", Space: "feeds"})
38+
39+ users, err := testDB.FindUsersWithPost("feeds")
40+ if err != nil {
41+ t.Fatalf("FindUsersWithPost failed: %v", err)
42+ }
43+ if len(users) != 1 {
44+ t.Errorf("expected 1 user, got %d", len(users))
45+ }
46+}
47+
48+func TestInsertFeedItems(t *testing.T) {
49+ cleanupTestData(t)
50+
51+ user, _ := testDB.RegisterUser("feeditemsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feeditemsowner", "comment", "")
52+ post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feed.txt", Slug: "feed", Title: "Feed", Space: "feeds"})
53+
54+ items := []*db.FeedItem{
55+ {PostID: post.ID, GUID: "guid-1", Data: db.FeedItemData{Title: "Item 1", Link: "http://example.com/1"}},
56+ {PostID: post.ID, GUID: "guid-2", Data: db.FeedItemData{Title: "Item 2", Link: "http://example.com/2"}},
57+ }
58+
59+ err := testDB.InsertFeedItems(post.ID, items)
60+ if err != nil {
61+ t.Fatalf("InsertFeedItems failed: %v", err)
62+ }
63+}
64+
65+func TestFindFeedItemsByPostID(t *testing.T) {
66+ cleanupTestData(t)
67+
68+ user, _ := testDB.RegisterUser("findfeeditemsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findfeeditemsowner", "comment", "")
69+ post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "findfeed.txt", Slug: "findfeed", Title: "Find Feed", Space: "feeds"})
70+
71+ items := []*db.FeedItem{
72+ {PostID: post.ID, GUID: "find-guid-1", Data: db.FeedItemData{Title: "Find Item 1"}},
73+ }
74+ _ = testDB.InsertFeedItems(post.ID, items)
75+
76+ found, err := testDB.FindFeedItemsByPostID(post.ID)
77+ if err != nil {
78+ t.Fatalf("FindFeedItemsByPostID failed: %v", err)
79+ }
80+ if len(found) != 1 {
81+ t.Errorf("expected 1 feed item, got %d", len(found))
82+ }
83+}
84+
85+// ============ Projects Tests ============
86+
87+func TestFeedItemData_JSONBRoundtrip(t *testing.T) {
88+ cleanupTestData(t)
89+
90+ user, _ := testDB.RegisterUser("feedjsonbowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedjsonbowner", "comment", "")
91+ post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedjsonb.txt", Slug: "feedjsonb", Title: "Feed JSONB", Space: "feeds"})
92+
93+ now := time.Now().Truncate(time.Second)
94+ items := []*db.FeedItem{
95+ {
96+ PostID: post.ID,
97+ GUID: "jsonb-guid",
98+ Data: db.FeedItemData{
99+ Title: "JSONB Item",
100+ Description: "Description",
101+ Content: "Content",
102+ Link: "http://example.com",
103+ PublishedAt: &now,
104+ },
105+ },
106+ }
107+ _ = testDB.InsertFeedItems(post.ID, items)
108+
109+ found, err := testDB.FindFeedItemsByPostID(post.ID)
110+ if err != nil {
111+ t.Fatalf("FindFeedItemsByPostID failed: %v", err)
112+ }
113+ if len(found) != 1 {
114+ t.Fatalf("expected 1 item, got %d", len(found))
115+ }
116+ if found[0].Data.Title != "JSONB Item" {
117+ t.Errorf("expected title 'JSONB Item', got '%s'", found[0].Data.Title)
118+ }
119+}
+0, -85
......@@ -1,85 +0,0 @@
1-package feeds
2-
3-import (
4- "errors"
5- "fmt"
6- "net/url"
7- "strings"
8-
9- "github.com/adhocore/gronx"
10- "github.com/picosh/pico/pkg/db"
11- "github.com/picosh/pico/pkg/filehandlers"
12- "github.com/picosh/pico/pkg/pssh"
13- "github.com/picosh/pico/pkg/shared"
14-)
15-
16-type FeedHooks struct {
17- Cfg *shared.ConfigSite
18- Db db.DB
19-}
20-
21-func (p *FeedHooks) FileValidate(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) (bool, error) {
22- if !shared.IsTextFile(string(data.Text)) {
23- err := fmt.Errorf(
24- "WARNING: (%s) invalid file must be plain text (utf-8), skipping",
25- data.Filename,
26- )
27- return false, err
28- }
29-
30- if !shared.IsExtAllowed(data.Filename, p.Cfg.AllowedExt) {
31- extStr := strings.Join(p.Cfg.AllowedExt, ",")
32- err := fmt.Errorf(
33- "WARNING: (%s) invalid file, format must be (%s), skipping",
34- data.Filename,
35- extStr,
36- )
37- return false, err
38- }
39-
40- // SFTP validates a file when it is opened, before its contents
41- // arrive, so there is nothing to parse yet.
42- if data.Text == "" {
43- return true, nil
44- }
45-
46- parsed := shared.ListParseText(string(data.Text))
47- if parsed.Email == "" {
48- return false, fmt.Errorf("ERROR: no email variable detected for %s, check the format of your file, skipping", data.Filename)
49- }
50-
51- if parsed.DigestInterval != "" {
52- return false, fmt.Errorf("ERROR: `digest_interval` is deprecated; use `cron`: https://pico.sh/feeds#cron")
53- }
54-
55- if parsed.Cron != "" {
56- if !gronx.IsValid(parsed.Cron) {
57- return false, fmt.Errorf("ERROR: `cron` is invalid, reference: https://github.com/adhocore/gronx?tab=readme-ov-file#cron-expression")
58- }
59- }
60-
61- var allErr error
62- for _, txt := range parsed.Items {
63- u := ""
64- if txt.IsText {
65- u = txt.Value
66- } else if txt.IsURL {
67- u = string(txt.URL)
68- }
69-
70- _, err := url.Parse(u)
71- if err != nil {
72- allErr = errors.Join(allErr, fmt.Errorf("%s: %w", u, err))
73- continue
74- }
75- }
76- if allErr != nil {
77- return false, fmt.Errorf("ERROR: some urls provided were invalid check the format of your file, skipping: %w", allErr)
78- }
79-
80- return true, nil
81-}
82-
83-func (p *FeedHooks) FileMeta(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) error {
84- return nil
85-}
+2, -11
......@@ -7,7 +7,6 @@ import (
77 "syscall"
88
99 "github.com/picosh/pico/pkg/db/postgres"
10- "github.com/picosh/pico/pkg/filehandlers"
1110 "github.com/picosh/pico/pkg/pssh"
1211 "github.com/picosh/pico/pkg/send/auth"
1312 "github.com/picosh/pico/pkg/send/list"
......@@ -30,20 +29,12 @@ func StartSshServer() {
3029 ctx, cancel := context.WithCancel(context.Background())
3130 defer cancel()
3231
33- dbh := postgres.NewDB(cfg.DbURL, cfg.Logger)
32+ dbh := NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
3433 defer func() {
3534 _ = dbh.Close()
3635 }()
3736
38- hooks := &FeedHooks{
39- Cfg: cfg,
40- Db: dbh,
41- }
42-
43- fileMap := map[string]filehandlers.ReadWriteHandler{
44- "fallback": filehandlers.NewScpPostHandler(dbh, cfg, hooks),
45- }
46- handler := filehandlers.NewFileHandlerRouter(cfg, dbh, fileMap)
37+ handler := NewUploadHandler(cfg, dbh)
4738
4839 sshAuth := shared.NewSshAuthHandler(dbh, logger, "feeds")
4940
+171, -0
......@@ -0,0 +1,171 @@
1+package feeds
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "log/slog"
7+ "net/url"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+
12+ "github.com/adhocore/gronx"
13+ "github.com/picosh/pico/pkg/db"
14+ "github.com/picosh/pico/pkg/filehandlers"
15+ "github.com/picosh/pico/pkg/pssh"
16+ sendutils "github.com/picosh/pico/pkg/send/utils"
17+ "github.com/picosh/pico/pkg/shared"
18+)
19+
20+// UploadHandler stores every file as a feed.
21+type UploadHandler struct {
22+ Cfg *shared.ConfigSite
23+ DB DB
24+}
25+
26+var (
27+ _ sendutils.CopyFromClientHandler = (*UploadHandler)(nil)
28+ _ sendutils.WriteValidator = (*UploadHandler)(nil)
29+ _ sendutils.FileRenamer = (*UploadHandler)(nil)
30+)
31+
32+func NewUploadHandler(cfg *shared.ConfigSite, dbpool DB) *UploadHandler {
33+ return &UploadHandler{Cfg: cfg, DB: dbpool}
34+}
35+
36+func (h *UploadHandler) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger {
37+ return pssh.GetLogger(s)
38+}
39+
40+func (h *UploadHandler) Validate(s *pssh.SSHServerConnSession) error {
41+ user, err := filehandlers.SessionUser(s)
42+ if err != nil {
43+ return err
44+ }
45+ h.GetLogger(s).Info("attempting to upload files", "user", user.Name, "space", h.Cfg.Space)
46+ return nil
47+}
48+
49+func (h *UploadHandler) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
50+ return filehandlers.BaseList(s, fpath, isDir, recursive, []string{h.Cfg.Space}, h.DB)
51+}
52+
53+func (h *UploadHandler) Read(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (os.FileInfo, sendutils.ReadAndReaderAtCloser, error) {
54+ return filehandlers.ReadPost(s, h.DB, h.Cfg.Space, entry)
55+}
56+
57+func (h *UploadHandler) Delete(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
58+ return filehandlers.DeletePost(s, h.DB, h.Cfg.Space, entry)
59+}
60+
61+// ValidateWrite checks a feed's name before its contents arrive.
62+func (h *UploadHandler) ValidateWrite(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
63+ if _, err := filehandlers.SessionUser(s); err != nil {
64+ return err
65+ }
66+ if entry.Mode.IsDir() {
67+ return fmt.Errorf("file entry is directory, but only files are supported: %s", entry.Filepath)
68+ }
69+ return h.validate(filehandlers.NewPost(filepath.Base(entry.Filepath), nil))
70+}
71+
72+func (h *UploadHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (string, error) {
73+ user, err := filehandlers.SessionUser(s)
74+ if err != nil {
75+ return "", err
76+ }
77+ logger := h.GetLogger(s).With("filename", filepath.Base(entry.Filepath))
78+
79+ post, err := filehandlers.PostFromEntry(entry, user, h.Cfg.Space)
80+ if err != nil {
81+ return "", err
82+ }
83+ if err := h.validate(post); err != nil {
84+ logger.Error("file failed validation", "err", err.Error())
85+ return "", err
86+ }
87+
88+ cur, err := filehandlers.LookupPost(h.DB, user.ID, h.Cfg.Space, post.Filename)
89+ if err != nil {
90+ return "", err
91+ }
92+ // The feed's digest state lives in its data.
93+ if cur != nil {
94+ post.Data, post.PublishAt = cur.Data, cur.PublishAt
95+ }
96+
97+ _, changed, err := filehandlers.SavePost(h.DB, cur, post)
98+ if err != nil {
99+ logger.Error("feed could not be saved", "err", err.Error())
100+ return "", err
101+ }
102+ logger.Info("saved feed", "new", cur == nil, "changed", changed)
103+ return h.Cfg.FullPostURL(shared.NewCreateURL(h.Cfg), user.Name, post.Slug), nil
104+}
105+
106+// Rename moves a feed to a new filename in place, keeping its digest state.
107+func (h *UploadHandler) Rename(s *pssh.SSHServerConnSession, from, to *sendutils.FileEntry) error {
108+ return filehandlers.RenamePost(s, h.DB, h.Cfg.Space, from, to, func(cur, next *db.Post) ([]string, error) {
109+ return nil, h.validate(next)
110+ })
111+}
112+
113+func (h *UploadHandler) validate(post *db.Post) error {
114+ if !shared.IsTextFile(post.Text) {
115+ return fmt.Errorf(
116+ "WARNING: (%s) invalid file must be plain text (utf-8), skipping",
117+ post.Filename,
118+ )
119+ }
120+
121+ if !shared.IsExtAllowed(post.Filename, h.Cfg.AllowedExt) {
122+ extStr := strings.Join(h.Cfg.AllowedExt, ",")
123+ return fmt.Errorf(
124+ "WARNING: (%s) invalid file, format must be (%s), skipping",
125+ post.Filename,
126+ extStr,
127+ )
128+ }
129+
130+ // SFTP validates a file when it is opened, before its contents
131+ // arrive, so there is nothing to parse yet.
132+ if post.Text == "" {
133+ return nil
134+ }
135+
136+ parsed := shared.ListParseText(post.Text)
137+ if parsed.Email == "" {
138+ return fmt.Errorf("ERROR: no email variable detected for %s, check the format of your file, skipping", post.Filename)
139+ }
140+
141+ if parsed.DigestInterval != "" {
142+ return fmt.Errorf("ERROR: `digest_interval` is deprecated; use `cron`: https://pico.sh/feeds#cron")
143+ }
144+
145+ if parsed.Cron != "" {
146+ if !gronx.IsValid(parsed.Cron) {
147+ return fmt.Errorf("ERROR: `cron` is invalid, reference: https://github.com/adhocore/gronx?tab=readme-ov-file#cron-expression")
148+ }
149+ }
150+
151+ var allErr error
152+ for _, txt := range parsed.Items {
153+ u := ""
154+ if txt.IsText {
155+ u = txt.Value
156+ } else if txt.IsURL {
157+ u = string(txt.URL)
158+ }
159+
160+ _, err := url.Parse(u)
161+ if err != nil {
162+ allErr = errors.Join(allErr, fmt.Errorf("%s: %w", u, err))
163+ continue
164+ }
165+ }
166+ if allErr != nil {
167+ return fmt.Errorf("ERROR: some urls provided were invalid check the format of your file, skipping: %w", allErr)
168+ }
169+
170+ return nil
171+}
+63, -0
......@@ -0,0 +1,63 @@
1+package feeds
2+
3+import (
4+ "context"
5+ "log/slog"
6+ "strings"
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/pssh"
13+ sendutils "github.com/picosh/pico/pkg/send/utils"
14+ "golang.org/x/crypto/ssh"
15+)
16+
17+func TestWriteKeepsDigestState(t *testing.T) {
18+ logger := slog.New(slog.DiscardHandler)
19+ session := &pssh.SSHServerConnSession{
20+ SSHServerConn: &pssh.SSHServerConn{
21+ Conn: &ssh.ServerConn{Permissions: &ssh.Permissions{Extensions: map[string]string{}}},
22+ Logger: logger,
23+ },
24+ Ctx: context.Background(),
25+ }
26+ user := &db.User{ID: "user-1", Name: "tester"}
27+ pssh.SetUser(session, user)
28+ posts := stub.NewPostsDB(logger, user)
29+ cfg := NewConfigSite("feeds-test")
30+ h := NewUploadHandler(cfg, posts)
31+ write := func(text string, mtime int64) {
32+ t.Helper()
33+ _, err := h.Write(session, &sendutils.FileEntry{Filepath: "/news.txt", Reader: strings.NewReader(text), Mtime: mtime})
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ }
38+
39+ write("=: email me@pico.test\n=> https://pico.sh/rss.xml\n", 100)
40+ post, err := posts.FindPostWithFilename("news.txt", user.ID, cfg.Space)
41+ if err != nil {
42+ t.Fatal(err)
43+ }
44+ digest := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
45+ post.Data = db.PostData{LastDigest: &digest, Attempts: 2}
46+ if _, err := posts.UpdatePost(post); err != nil {
47+ t.Fatal(err)
48+ }
49+
50+ write("=: email me@pico.test\n=> https://blog.pico.sh/rss.xml\n", 200)
51+ got, err := posts.FindPostWithFilename("news.txt", user.ID, cfg.Space)
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ if !strings.Contains(got.Text, "blog.pico.sh") || got.Data.LastDigest == nil || !got.Data.LastDigest.Equal(digest) || got.Data.Attempts != 2 {
56+ t.Errorf("feed = %q with data %+v, want the new text and the old digest state", got.Text, got.Data)
57+ }
58+
59+ _, err = h.Write(session, &sendutils.FileEntry{Filepath: "/news.txt", Reader: strings.NewReader("=> https://pico.sh/rss.xml\n")})
60+ if err == nil || !strings.Contains(err.Error(), "no email") {
61+ t.Errorf("feed without an email: %v", err)
62+ }
63+}
+4, -4
......@@ -74,7 +74,7 @@ type HeaderTxt struct {
7474
7575 func blogHandler(w http.ResponseWriter, r *http.Request) {
7676 username := router.GetUsernameFromRequest(r)
77- dbpool := router.GetDB(r)
77+ dbpool := getDB(r)
7878 blogger := router.GetLogger(r)
7979 logger := blogger.With("user", username)
8080 cfg := router.GetCfg(r)
......@@ -167,7 +167,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
167167 slug, _ = url.PathUnescape(router.GetField(r, 0))
168168 }
169169
170- dbpool := router.GetDB(r)
170+ dbpool := getDB(r)
171171 blogger := router.GetLogger(r)
172172 logger := blogger.With("slug", slug, "user", username)
173173
......@@ -271,7 +271,7 @@ func postHandlerRaw(w http.ResponseWriter, r *http.Request) {
271271 slug, _ = url.PathUnescape(router.GetField(r, 0))
272272 }
273273
274- dbpool := router.GetDB(r)
274+ dbpool := getDB(r)
275275 blogger := router.GetLogger(r)
276276 logger := blogger.With("user", username, "slug", slug)
277277
......@@ -378,7 +378,7 @@ func createSubdomainRoutes(staticRoutes []router.Route) []router.Route {
378378
379379 func StartApiServer() {
380380 cfg := NewConfigSite("pastes-web")
381- db := postgres.NewDB(cfg.DbURL, cfg.Logger)
381+ db := NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
382382 defer func() {
383383 _ = db.Close()
384384 }()
+2, -3
......@@ -3,11 +3,10 @@ package pastes
33 import (
44 "time"
55
6- "github.com/picosh/pico/pkg/db"
76 "github.com/picosh/pico/pkg/shared"
87 )
98
10-func deleteExpiredPosts(cfg *shared.ConfigSite, dbpool db.DB) error {
9+func deleteExpiredPosts(cfg *shared.ConfigSite, dbpool DB) error {
1110 cfg.Logger.Info("checking for expired posts")
1211 posts, err := dbpool.FindExpiredPosts(cfg.Space)
1312 if err != nil {
......@@ -28,7 +27,7 @@ func deleteExpiredPosts(cfg *shared.ConfigSite, dbpool db.DB) error {
2827 return nil
2928 }
3029
31-func CronDeleteExpiredPosts(cfg *shared.ConfigSite, dbpool db.DB) {
30+func CronDeleteExpiredPosts(cfg *shared.ConfigSite, dbpool DB) {
3231 for {
3332 err := deleteExpiredPosts(cfg, dbpool)
3433 if err != nil {
+52, -0
......@@ -0,0 +1,52 @@
1+package pastes
2+
3+import (
4+ "fmt"
5+ "net/http"
6+
7+ "github.com/picosh/pico/pkg/db"
8+ "github.com/picosh/pico/pkg/db/postgres"
9+ "github.com/picosh/pico/pkg/shared/router"
10+)
11+
12+// DB is the storage pastes uses.
13+type DB interface {
14+ db.PostDB
15+ FindUserByName(name string) (*db.User, error)
16+ FindExpiredPosts(space string) ([]*db.Post, error)
17+ FindFeature(userID string, feature string) (*db.FeatureFlag, error)
18+}
19+
20+// PsqlDB is pastes's postgres storage: the queries apps share, and the ones
21+// only pastes makes.
22+type PsqlDB struct {
23+ *postgres.PsqlDB
24+}
25+
26+var _ DB = (*PsqlDB)(nil)
27+
28+func NewDB(base *postgres.PsqlDB) *PsqlDB {
29+ return &PsqlDB{PsqlDB: base}
30+}
31+
32+func (me *PsqlDB) FindExpiredPosts(space string) ([]*db.Post, error) {
33+ var posts []*db.Post
34+ query := fmt.Sprintf(`
35+ SELECT %s
36+ FROM posts
37+ LEFT JOIN app_users ON app_users.id = posts.user_id
38+ WHERE
39+ cur_space = $1 AND
40+ expires_at <= now();
41+ `, postgres.SelectPost)
42+ err := me.Db.Select(&posts, query, space)
43+ if err != nil {
44+ return nil, err
45+ }
46+ return posts, nil
47+}
48+
49+// getDB is the storage StartApiServer gave the router, which is a PsqlDB.
50+func getDB(r *http.Request) DB {
51+ return router.GetDB(r).(DB)
52+}
+54, -0
......@@ -0,0 +1,54 @@
1+package pastes
2+
3+import (
4+ "log/slog"
5+ "testing"
6+ "time"
7+
8+ "github.com/picosh/pico/pkg/db"
9+ "github.com/picosh/pico/pkg/db/postgres"
10+ "github.com/picosh/pico/pkg/db/postgres/pgtest"
11+)
12+
13+var testDB *PsqlDB
14+
15+func TestMain(m *testing.M) {
16+ pgtest.Main(m)
17+}
18+
19+func cleanupTestData(t *testing.T) {
20+ t.Helper()
21+ if testDB == nil {
22+ testDB = NewDB(postgres.NewDB(pgtest.URL(t), slog.New(slog.DiscardHandler)))
23+ }
24+ pgtest.Clean(t, testDB.Db)
25+}
26+
27+func mustInsertPost(t *testing.T, post *db.Post) *db.Post {
28+ t.Helper()
29+ return pgtest.InsertPost(t, testDB, post)
30+}
31+
32+func TestFindExpiredPosts(t *testing.T) {
33+ cleanupTestData(t)
34+
35+ user, _ := testDB.RegisterUser("expiredpostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI expiredpostowner", "comment", "")
36+
37+ expired := time.Now().Add(-24 * time.Hour)
38+ _ = mustInsertPost(t, &db.Post{
39+ UserID: user.ID,
40+ Filename: "expired.txt",
41+ Slug: "expired",
42+ Title: "Expired",
43+ Space: "pastes",
44+ ExpiresAt: &expired,
45+ })
46+
47+ posts, err := testDB.FindExpiredPosts("pastes")
48+ if err != nil {
49+ t.Fatalf("FindExpiredPosts failed: %v", err)
50+ }
51+ if len(posts) != 1 {
52+ t.Errorf("expected 1 expired post, got %d", len(posts))
53+ }
54+}
+0, -116
......@@ -1,116 +0,0 @@
1-package pastes
2-
3-import (
4- "fmt"
5- "strconv"
6- "strings"
7- "time"
8-
9- "github.com/araddon/dateparse"
10- "github.com/picosh/pico/pkg/db"
11- "github.com/picosh/pico/pkg/filehandlers"
12- "github.com/picosh/pico/pkg/pssh"
13- "github.com/picosh/pico/pkg/shared"
14-)
15-
16-var DEFAULT_EXPIRES_AT = 90
17-
18-type FileHooks struct {
19- Cfg *shared.ConfigSite
20- Db db.DB
21-}
22-
23-func (p *FileHooks) FileValidate(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) (bool, error) {
24- if !shared.IsTextFile(string(data.Text)) {
25- err := fmt.Errorf(
26- "ERROR: (%s) invalid file must be plain text (utf-8), skipping",
27- data.Filename,
28- )
29- return false, err
30- }
31-
32- maxFileSize := int(p.Cfg.MaxAssetSize)
33- if data.FileSize > maxFileSize {
34- return false, fmt.Errorf(
35- "ERROR: file (%s) has exceeded maximum file size (%d bytes)",
36- data.Filename,
37- maxFileSize,
38- )
39- }
40-
41- return true, nil
42-}
43-
44-func (p *FileHooks) FileMeta(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) error {
45- data.Title = shared.ToUpper(data.Slug)
46- // we want the slug to be the filename for pastes
47- data.Slug = data.Filename
48-
49- // mark posts for deletion a X days after creation
50- expiresAt := time.Now().AddDate(0, 0, DEFAULT_EXPIRES_AT)
51- data.ExpiresAt = &expiresAt
52- var hidden bool
53- // A paste written without arguments, as over sftp, keeps its settings.
54- if cur := data.Cur; cur != nil {
55- hidden = cur.Hidden
56- if cur.ExpiresAt == nil || cur.ExpiresAt.After(time.Now()) {
57- data.ExpiresAt = cur.ExpiresAt
58- }
59- }
60-
61- var expiresFound bool
62- var expires *time.Time
63-
64- cmd := s.Command()
65-
66- for _, arg := range cmd {
67- if strings.Contains(arg, "=") {
68- splitArg := strings.Split(arg, "=")
69- if len(splitArg) != 2 {
70- continue
71- }
72-
73- switch splitArg[0] {
74- case "hidden":
75- val, err := strconv.ParseBool(splitArg[1])
76- if err != nil {
77- continue
78- }
79-
80- hidden = val
81- case "expires":
82- val, err := strconv.ParseBool(splitArg[1])
83- if err == nil {
84- expiresFound = true
85- expires = nil
86- if val {
87- expires = &expiresAt
88- }
89- continue
90- }
91-
92- duration, err := time.ParseDuration(splitArg[1])
93- if err == nil {
94- expiresFound = true
95- expireTime := time.Now().Add(duration)
96- expires = &expireTime
97- continue
98- }
99-
100- expireTime, err := dateparse.ParseStrict(splitArg[1])
101- if err == nil {
102- expiresFound = true
103- expires = &expireTime
104- }
105- }
106- }
107- }
108-
109- data.Hidden = hidden
110-
111- if expiresFound {
112- data.ExpiresAt = expires
113- }
114-
115- return nil
116-}
+0, -54
......@@ -1,54 +0,0 @@
1-package pastes
2-
3-import (
4- "context"
5- "testing"
6- "time"
7-
8- "github.com/picosh/pico/pkg/db"
9- "github.com/picosh/pico/pkg/filehandlers"
10- "github.com/picosh/pico/pkg/pssh"
11-)
12-
13-func TestFileMetaKeepsSettings(t *testing.T) {
14- soon := time.Now().Add(time.Hour)
15- past := time.Now().Add(-time.Hour)
16- // Pastes expire after calendar days, which a daylight saving change
17- // makes an hour longer or shorter.
18- defaultExpiry := time.Until(time.Now().AddDate(0, 0, DEFAULT_EXPIRES_AT)).Round(time.Minute)
19- tests := []struct {
20- name string
21- cmd []string
22- cur *db.Post
23- hidden bool
24- // expires is how long until the paste expires, or 0 for never.
25- expires time.Duration
26- }{
27- {name: "new", expires: defaultExpiry},
28- {name: "new with arguments", cmd: []string{"hidden=true", "expires=2h"}, hidden: true, expires: 2 * time.Hour},
29- {name: "existing", cur: &db.Post{Hidden: true, ExpiresAt: &soon}, hidden: true, expires: time.Hour},
30- {name: "existing without expiry", cur: &db.Post{Hidden: true}, hidden: true},
31- {name: "existing past its expiry", cur: &db.Post{Hidden: true, ExpiresAt: &past}, hidden: true, expires: defaultExpiry},
32- {name: "existing with arguments", cmd: []string{"hidden=false", "expires=true"}, cur: &db.Post{Hidden: true}, expires: defaultExpiry},
33- {name: "existing made permanent", cmd: []string{"expires=false"}, cur: &db.Post{ExpiresAt: &soon}},
34- }
35- for _, tt := range tests {
36- t.Run(tt.name, func(t *testing.T) {
37- s := &pssh.SSHServerConnSession{Ctx: context.Background()}
38- s.SetValue("command", tt.cmd)
39- data := &filehandlers.PostMetaData{Post: &db.Post{Filename: "a.txt", Slug: "a"}, Cur: tt.cur}
40- if err := (&FileHooks{}).FileMeta(s, data); err != nil {
41- t.Fatal(err)
42- }
43- if data.Hidden != tt.hidden {
44- t.Errorf("hidden = %v, want %v", data.Hidden, tt.hidden)
45- }
46- switch {
47- case tt.expires == 0 && data.ExpiresAt != nil:
48- t.Errorf("expires at %v, want never", data.ExpiresAt)
49- case tt.expires != 0 && (data.ExpiresAt == nil || time.Until(*data.ExpiresAt).Round(time.Minute) != tt.expires):
50- t.Errorf("expires at %v, want in %v", data.ExpiresAt, tt.expires)
51- }
52- })
53- }
54-}
+2, -2
......@@ -30,11 +30,11 @@ func StartSshServer() {
3030 ctx, cancel := context.WithCancel(context.Background())
3131 defer cancel()
3232
33- dbh := postgres.NewDB(cfg.DbURL, cfg.Logger)
33+ dbh := NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
3434 defer func() {
3535 _ = dbh.Close()
3636 }()
37- handler := NewFileHandler(cfg, dbh)
37+ handler := NewUploadHandler(cfg, dbh)
3838 sshAuth := shared.NewSshAuthHandler(dbh, logger, "pastes")
3939
4040 // Create a new SSH server
+183, -27
......@@ -3,7 +3,13 @@ package pastes
33 import (
44 "fmt"
55 "log/slog"
6+ "os"
7+ "path/filepath"
8+ "strconv"
9+ "strings"
10+ "time"
611
12+ "github.com/araddon/dateparse"
713 "github.com/picosh/pico/pkg/db"
814 "github.com/picosh/pico/pkg/filehandlers"
915 "github.com/picosh/pico/pkg/pssh"
......@@ -11,7 +17,9 @@ import (
1117 "github.com/picosh/pico/pkg/shared"
1218 )
1319
14-func checkAccess(dbpool db.DB, userID string) error {
20+var DEFAULT_EXPIRES_AT = 90
21+
22+func checkAccess(dbpool DB, userID string) error {
1523 ffPlus, plusErr := dbpool.FindFeature(userID, "plus")
1624 if plusErr == nil && ffPlus.IsValid() {
1725 return nil
......@@ -31,29 +39,20 @@ func checkAccess(dbpool db.DB, userID string) error {
3139 return fmt.Errorf("ERROR: uploading to pastes requires an invitation or pico+: https://blog.pico.sh/ann-038-pico-invite-system")
3240 }
3341
42+// UploadHandler stores every file as a paste.
3443 type UploadHandler struct {
35- *filehandlers.FileHandlerRouter
3644 Cfg *shared.ConfigSite
37- DB db.DB
45+ DB DB
3846 }
3947
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-}
48+var (
49+ _ sendutils.CopyFromClientHandler = (*UploadHandler)(nil)
50+ _ sendutils.WriteValidator = (*UploadHandler)(nil)
51+ _ sendutils.FileRenamer = (*UploadHandler)(nil)
52+)
5053
51-// NewFileHandler stores every file as a paste.
52-func NewFileHandler(cfg *shared.ConfigSite, dbpool db.DB) *UploadHandler {
53- hooks := &FileHooks{Cfg: cfg, Db: dbpool}
54- return NewUploadHandler(cfg, dbpool, map[string]filehandlers.ReadWriteHandler{
55- "fallback": filehandlers.NewScpPostHandler(dbpool, cfg, hooks),
56- })
54+func NewUploadHandler(cfg *shared.ConfigSite, dbpool DB) *UploadHandler {
55+ return &UploadHandler{Cfg: cfg, DB: dbpool}
5756 }
5857
5958 func (h *UploadHandler) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger {
......@@ -68,18 +67,175 @@ func (h *UploadHandler) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger {
6867 }
6968
7069 func (h *UploadHandler) Validate(s *pssh.SSHServerConnSession) error {
71- logger := h.GetLogger(s)
72- user := pssh.GetUser(s)
73-
74- if user == nil {
75- err := fmt.Errorf("could not get user from ctx")
76- logger.Error("error getting user from ctx", "err", err)
70+ user, err := filehandlers.SessionUser(s)
71+ if err != nil {
7772 return err
7873 }
79-
8074 if err := checkAccess(h.DB, user.ID); err != nil {
8175 return err
8276 }
77+ h.GetLogger(s).Info("attempting to upload files", "user", user.Name, "space", h.Cfg.Space)
78+ return nil
79+}
80+
81+func (h *UploadHandler) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
82+ return filehandlers.BaseList(s, fpath, isDir, recursive, []string{h.Cfg.Space}, h.DB)
83+}
84+
85+func (h *UploadHandler) Read(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (os.FileInfo, sendutils.ReadAndReaderAtCloser, error) {
86+ return filehandlers.ReadPost(s, h.DB, h.Cfg.Space, entry)
87+}
88+
89+func (h *UploadHandler) Delete(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
90+ return filehandlers.DeletePost(s, h.DB, h.Cfg.Space, entry)
91+}
92+
93+// ValidateWrite checks a paste's name before its contents arrive.
94+func (h *UploadHandler) ValidateWrite(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
95+ if _, err := filehandlers.SessionUser(s); err != nil {
96+ return err
97+ }
98+ if entry.Mode.IsDir() {
99+ return fmt.Errorf("file entry is directory, but only files are supported: %s", entry.Filepath)
100+ }
101+ return h.validate(filehandlers.NewPost(filepath.Base(entry.Filepath), nil))
102+}
103+
104+func (h *UploadHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (string, error) {
105+ user, err := filehandlers.SessionUser(s)
106+ if err != nil {
107+ return "", err
108+ }
109+ logger := h.GetLogger(s).With("filename", filepath.Base(entry.Filepath))
110+
111+ post, err := filehandlers.PostFromEntry(entry, user, h.Cfg.Space)
112+ if err != nil {
113+ return "", err
114+ }
115+ if err := h.validate(post); err != nil {
116+ logger.Error("file failed validation", "err", err.Error())
117+ return "", err
118+ }
119+
120+ cur, err := filehandlers.LookupPost(h.DB, user.ID, h.Cfg.Space, post.Filename)
121+ if err != nil {
122+ return "", err
123+ }
124+ if cur != nil {
125+ post.Data, post.PublishAt = cur.Data, cur.PublishAt
126+ }
127+ setMeta(s, cur, post)
83128
84- return h.FileHandlerRouter.Validate(s)
129+ _, changed, err := filehandlers.SavePost(h.DB, cur, post)
130+ if err != nil {
131+ logger.Error("paste could not be saved", "err", err.Error())
132+ return "", err
133+ }
134+ logger.Info("saved paste", "new", cur == nil, "changed", changed)
135+ return h.Cfg.FullPostURL(shared.NewCreateURL(h.Cfg), user.Name, post.Slug), nil
136+}
137+
138+// Rename moves a paste to a new filename in place, keeping its settings.
139+func (h *UploadHandler) Rename(s *pssh.SSHServerConnSession, from, to *sendutils.FileEntry) error {
140+ return filehandlers.RenamePost(s, h.DB, h.Cfg.Space, from, to, func(cur, next *db.Post) ([]string, error) {
141+ if err := h.validate(next); err != nil {
142+ return nil, err
143+ }
144+ setMeta(s, cur, next)
145+ return nil, nil
146+ })
147+}
148+
149+func (h *UploadHandler) validate(post *db.Post) error {
150+ if !shared.IsTextFile(post.Text) {
151+ return fmt.Errorf(
152+ "ERROR: (%s) invalid file must be plain text (utf-8), skipping",
153+ post.Filename,
154+ )
155+ }
156+
157+ maxFileSize := int(h.Cfg.MaxAssetSize)
158+ if post.FileSize > maxFileSize {
159+ return fmt.Errorf(
160+ "ERROR: file (%s) has exceeded maximum file size (%d bytes)",
161+ post.Filename,
162+ maxFileSize,
163+ )
164+ }
165+
166+ return nil
167+}
168+
169+// setMeta applies the paste's settings: those of cur, the paste it
170+// replaces, unless the upload command sets them.
171+func setMeta(s *pssh.SSHServerConnSession, cur, post *db.Post) {
172+ post.Title = shared.ToUpper(post.Slug)
173+ // we want the slug to be the filename for pastes
174+ post.Slug = post.Filename
175+
176+ // mark posts for deletion a X days after creation
177+ expiresAt := time.Now().AddDate(0, 0, DEFAULT_EXPIRES_AT)
178+ post.ExpiresAt = &expiresAt
179+ var hidden bool
180+ // A paste written without arguments, as over sftp, keeps its settings.
181+ if cur != nil {
182+ hidden = cur.Hidden
183+ if cur.ExpiresAt == nil || cur.ExpiresAt.After(time.Now()) {
184+ post.ExpiresAt = cur.ExpiresAt
185+ }
186+ }
187+
188+ var expiresFound bool
189+ var expires *time.Time
190+
191+ cmd := s.Command()
192+
193+ for _, arg := range cmd {
194+ if strings.Contains(arg, "=") {
195+ splitArg := strings.Split(arg, "=")
196+ if len(splitArg) != 2 {
197+ continue
198+ }
199+
200+ switch splitArg[0] {
201+ case "hidden":
202+ val, err := strconv.ParseBool(splitArg[1])
203+ if err != nil {
204+ continue
205+ }
206+
207+ hidden = val
208+ case "expires":
209+ val, err := strconv.ParseBool(splitArg[1])
210+ if err == nil {
211+ expiresFound = true
212+ expires = nil
213+ if val {
214+ expires = &expiresAt
215+ }
216+ continue
217+ }
218+
219+ duration, err := time.ParseDuration(splitArg[1])
220+ if err == nil {
221+ expiresFound = true
222+ expireTime := time.Now().Add(duration)
223+ expires = &expireTime
224+ continue
225+ }
226+
227+ expireTime, err := dateparse.ParseStrict(splitArg[1])
228+ if err == nil {
229+ expiresFound = true
230+ expires = &expireTime
231+ }
232+ }
233+ }
234+ }
235+
236+ post.Hidden = hidden
237+
238+ if expiresFound {
239+ post.ExpiresAt = expires
240+ }
85241 }
+88, -2
......@@ -2,16 +2,20 @@ package pastes
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
7+ "io"
68 "log/slog"
79 "strings"
810 "testing"
11+ "testing/iotest"
912 "time"
1013
1114 "github.com/picosh/pico/pkg/db"
1215 "github.com/picosh/pico/pkg/db/stub"
13- "github.com/picosh/pico/pkg/filehandlers"
1416 "github.com/picosh/pico/pkg/pssh"
17+ sendutils "github.com/picosh/pico/pkg/send/utils"
18+ "github.com/picosh/pico/pkg/shared"
1519 "golang.org/x/crypto/ssh"
1620 )
1721
......@@ -113,7 +117,7 @@ func TestUploadHandlerValidate(t *testing.T) {
113117 t.Run(tt.name, func(t *testing.T) {
114118 mockDB := newMockFeatureDB()
115119 tt.setup(mockDB)
116- handler := NewUploadHandler(cfg, mockDB, map[string]filehandlers.ReadWriteHandler{})
120+ handler := NewUploadHandler(cfg, mockDB)
117121
118122 err := handler.Validate(createSession(tt.user))
119123 if tt.wantErr == "" {
......@@ -128,3 +132,85 @@ func TestUploadHandlerValidate(t *testing.T) {
128132 })
129133 }
130134 }
135+
136+func TestSetMetaKeepsSettings(t *testing.T) {
137+ soon := time.Now().Add(time.Hour)
138+ past := time.Now().Add(-time.Hour)
139+ // Pastes expire after calendar days, which a daylight saving change
140+ // makes an hour longer or shorter.
141+ defaultExpiry := time.Until(time.Now().AddDate(0, 0, DEFAULT_EXPIRES_AT)).Round(time.Minute)
142+ tests := []struct {
143+ name string
144+ cmd []string
145+ cur *db.Post
146+ hidden bool
147+ // expires is how long until the paste expires, or 0 for never.
148+ expires time.Duration
149+ }{
150+ {name: "new", expires: defaultExpiry},
151+ {name: "new with arguments", cmd: []string{"hidden=true", "expires=2h"}, hidden: true, expires: 2 * time.Hour},
152+ {name: "existing", cur: &db.Post{Hidden: true, ExpiresAt: &soon}, hidden: true, expires: time.Hour},
153+ {name: "existing without expiry", cur: &db.Post{Hidden: true}, hidden: true},
154+ {name: "existing past its expiry", cur: &db.Post{Hidden: true, ExpiresAt: &past}, hidden: true, expires: defaultExpiry},
155+ {name: "existing with arguments", cmd: []string{"hidden=false", "expires=true"}, cur: &db.Post{Hidden: true}, expires: defaultExpiry},
156+ {name: "existing made permanent", cmd: []string{"expires=false"}, cur: &db.Post{ExpiresAt: &soon}},
157+ }
158+ for _, tt := range tests {
159+ t.Run(tt.name, func(t *testing.T) {
160+ s := &pssh.SSHServerConnSession{Ctx: context.Background()}
161+ s.SetValue("command", tt.cmd)
162+ data := &db.Post{Filename: "a.txt", Slug: "a"}
163+ setMeta(s, tt.cur, data)
164+ if data.Hidden != tt.hidden {
165+ t.Errorf("hidden = %v, want %v", data.Hidden, tt.hidden)
166+ }
167+ switch {
168+ case tt.expires == 0 && data.ExpiresAt != nil:
169+ t.Errorf("expires at %v, want never", data.ExpiresAt)
170+ case tt.expires != 0 && (data.ExpiresAt == nil || time.Until(*data.ExpiresAt).Round(time.Minute) != tt.expires):
171+ t.Errorf("expires at %v, want in %v", data.ExpiresAt, tt.expires)
172+ }
173+ })
174+ }
175+}
176+
177+func TestWriteReadError(t *testing.T) {
178+ checksumErr := errors.New("rsync: file failed verification")
179+ tests := []struct {
180+ name string
181+ text string
182+ err error
183+ }{
184+ {name: "before contents", err: io.ErrClosedPipe},
185+ {name: "partial contents", text: "partial upload", err: io.ErrUnexpectedEOF},
186+ {name: "failed verification", text: "complete upload", err: checksumErr},
187+ }
188+ for _, tt := range tests {
189+ t.Run(tt.name, func(t *testing.T) {
190+ logger := slog.New(slog.DiscardHandler)
191+ session := &pssh.SSHServerConnSession{
192+ SSHServerConn: &pssh.SSHServerConn{
193+ Conn: &ssh.ServerConn{Permissions: &ssh.Permissions{Extensions: map[string]string{}}},
194+ Logger: logger,
195+ },
196+ Ctx: context.Background(),
197+ }
198+ user := &db.User{ID: "user-1", Name: "tester"}
199+ pssh.SetUser(session, user)
200+ posts := stub.NewPostsDB(logger, user)
201+ handler := NewUploadHandler(&shared.ConfigSite{Space: "pastes", MaxAssetSize: 1024}, posts)
202+ reader := io.MultiReader(strings.NewReader(tt.text), iotest.ErrReader(tt.err))
203+
204+ message, err := handler.Write(session, &sendutils.FileEntry{Filepath: "/test.txt", Reader: reader})
205+ if !errors.Is(err, tt.err) {
206+ t.Fatalf("error = %v, want %v", err, tt.err)
207+ }
208+ if message != "" {
209+ t.Fatalf("failed upload returned a success message: %q", message)
210+ }
211+ if all, _ := posts.FindAllPostsByUser(user.ID, "pastes"); len(all) != 0 {
212+ t.Fatalf("failed upload stored %d posts", len(all))
213+ }
214+ })
215+ }
216+}
+3, -4
......@@ -17,7 +17,6 @@ import (
1717
1818 "github.com/google/uuid"
1919 "github.com/gorilla/websocket"
20- "github.com/picosh/pico/pkg/db"
2120 "github.com/picosh/pico/pkg/db/postgres"
2221 "github.com/picosh/pico/pkg/shared"
2322 "github.com/picosh/pico/pkg/shared/router"
......@@ -434,7 +433,7 @@ func handlePipe() http.HandlerFunc {
434433 }
435434 }
436435
437-func rssHandler(cfg *shared.ConfigSite, dbpool db.DB) http.HandlerFunc {
436+func rssHandler(cfg *shared.ConfigSite, dbpool DB) http.HandlerFunc {
438437 return func(w http.ResponseWriter, r *http.Request) {
439438 apiToken, _ := url.PathUnescape(router.GetField(r, 0))
440439 user, err := dbpool.FindUserByToken(apiToken)
......@@ -471,7 +470,7 @@ func rssHandler(cfg *shared.ConfigSite, dbpool db.DB) http.HandlerFunc {
471470 }
472471 }
473472
474-func createMainRoutes(staticRoutes []router.Route, cfg *shared.ConfigSite, dbpool db.DB) []router.Route {
473+func createMainRoutes(staticRoutes []router.Route, cfg *shared.ConfigSite, dbpool DB) []router.Route {
475474 routes := []router.Route{
476475 router.NewRoute("GET", "/", router.CreatePageHandler("html/marketing.page.tmpl")),
477476 router.NewRoute("GET", "/check", router.CheckHandler),
......@@ -502,7 +501,7 @@ func createMainRoutes(staticRoutes []router.Route, cfg *shared.ConfigSite, dbpoo
502501
503502 func StartApiServer() {
504503 cfg := NewConfigSite("pipe-web")
505- db := postgres.NewDB(cfg.DbURL, cfg.Logger)
504+ db := NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
506505 defer func() {
507506 _ = db.Close()
508507 }()
+2, -2
......@@ -185,7 +185,7 @@ func Middleware(handler *CliHandler) pssh.SSHServerMiddleware {
185185 }
186186
187187 type CliHandler struct {
188- DBPool db.DB
188+ DBPool DB
189189 Logger *slog.Logger
190190 PubSub psub.PubSub
191191 Cfg *shared.ConfigSite
......@@ -548,7 +548,7 @@ func parseDuration(s string) (time.Duration, error) {
548548 return time.ParseDuration(s)
549549 }
550550
551-func MonitorRss(dbpool db.DB, user *db.User, domain string) (string, error) {
551+func MonitorRss(dbpool DB, user *db.User, domain string) (string, error) {
552552 if user == nil {
553553 return "", fmt.Errorf("access denied")
554554 }
+107, -0
......@@ -0,0 +1,107 @@
1+package pipe
2+
3+import (
4+ "fmt"
5+ "time"
6+
7+ "github.com/picosh/pico/pkg/db"
8+ "github.com/picosh/pico/pkg/db/postgres"
9+)
10+
11+// DB is the storage pipe uses.
12+type DB interface {
13+ FindUserByToken(token string) (*db.User, error)
14+ HasFeatureByUser(userID string, feature string) bool
15+ UpsertPipeMonitor(userID, topic string, dur time.Duration, winEnd *time.Time) error
16+ UpdatePipeMonitorLastPing(userID, topic string, lastPing *time.Time) error
17+ RemovePipeMonitor(userID, topic string) error
18+ FindPipeMonitorByTopic(userID, topic string) (*db.PipeMonitor, error)
19+ FindPipeMonitorsByUser(userID string) ([]*db.PipeMonitor, error)
20+ InsertPipeMonitorHistory(monitorID string, windowDur time.Duration, windowEnd, lastPing *time.Time) error
21+ FindPipeMonitorHistory(monitorID string, from, to time.Time) ([]*db.PipeMonitorHistory, error)
22+}
23+
24+// PsqlDB is pipe's postgres storage: the queries apps share, and the ones
25+// only pipe makes.
26+type PsqlDB struct {
27+ *postgres.PsqlDB
28+}
29+
30+var _ DB = (*PsqlDB)(nil)
31+
32+func NewDB(base *postgres.PsqlDB) *PsqlDB {
33+ return &PsqlDB{PsqlDB: base}
34+}
35+
36+func (me *PsqlDB) UpsertPipeMonitor(userID, topic string, dur time.Duration, winEnd *time.Time) error {
37+ durStr := fmt.Sprintf("%d seconds", int64(dur.Seconds()))
38+ _, err := me.Db.Exec(
39+ `INSERT INTO pipe_monitors (user_id, topic, window_dur, window_end)
40+ VALUES ($1, $2, $3::interval, $4)
41+ ON CONFLICT (user_id, topic) DO UPDATE SET window_dur = $3::interval, window_end = $4, updated_at = NOW();`,
42+ userID,
43+ topic,
44+ durStr,
45+ winEnd,
46+ )
47+ return err
48+}
49+
50+func (me *PsqlDB) UpdatePipeMonitorLastPing(userID, topic string, lastPing *time.Time) error {
51+ _, err := me.Db.Exec(
52+ `UPDATE pipe_monitors SET last_ping = $3, updated_at = NOW() WHERE user_id = $1 AND topic = $2;`,
53+ userID,
54+ topic,
55+ lastPing,
56+ )
57+ return err
58+}
59+
60+func (me *PsqlDB) RemovePipeMonitor(userID, topic string) error {
61+ _, err := me.Db.Exec(
62+ `DELETE FROM pipe_monitors WHERE user_id = $1 AND topic = $2;`,
63+ userID,
64+ topic,
65+ )
66+ return err
67+}
68+
69+func (me *PsqlDB) FindPipeMonitorByTopic(userID, topic string) (*db.PipeMonitor, error) {
70+ monitor := &db.PipeMonitor{}
71+ err := me.Db.Get(monitor, `SELECT id, user_id, topic, (EXTRACT(EPOCH FROM window_dur) * 1000000000)::bigint as window_dur, window_end, last_ping, created_at, updated_at FROM pipe_monitors WHERE user_id = $1 AND topic = $2;`, userID, topic)
72+ if err != nil {
73+ return nil, err
74+ }
75+ return monitor, nil
76+}
77+
78+func (me *PsqlDB) FindPipeMonitorsByUser(userID string) ([]*db.PipeMonitor, error) {
79+ var monitors []*db.PipeMonitor
80+ err := me.Db.Select(&monitors, `SELECT id, user_id, topic, (EXTRACT(EPOCH FROM window_dur) * 1000000000)::bigint as window_dur, window_end, last_ping, created_at, updated_at FROM pipe_monitors WHERE user_id = $1 ORDER BY topic;`, userID)
81+ if err != nil {
82+ return nil, err
83+ }
84+ return monitors, nil
85+}
86+
87+func (me *PsqlDB) InsertPipeMonitorHistory(monitorID string, windowDur time.Duration, windowEnd, lastPing *time.Time) error {
88+ durStr := fmt.Sprintf("%d seconds", int64(windowDur.Seconds()))
89+ _, err := me.Db.Exec(
90+ `INSERT INTO pipe_monitors_history (monitor_id, window_dur, window_end, last_ping) VALUES ($1, $2::interval, $3, $4)`,
91+ monitorID, durStr, windowEnd, lastPing,
92+ )
93+ return err
94+}
95+
96+func (me *PsqlDB) FindPipeMonitorHistory(monitorID string, from, to time.Time) ([]*db.PipeMonitorHistory, error) {
97+ var history []*db.PipeMonitorHistory
98+ err := me.Db.Select(
99+ &history,
100+ `SELECT id, monitor_id, (EXTRACT(EPOCH FROM window_dur) * 1000000000)::bigint as window_dur, window_end, last_ping, created_at, updated_at FROM pipe_monitors_history WHERE monitor_id = $1 AND last_ping <= $2 AND window_end >= $3 ORDER BY last_ping ASC`,
101+ monitorID, to, from,
102+ )
103+ if err != nil {
104+ return nil, err
105+ }
106+ return history, nil
107+}
+178, -0
......@@ -0,0 +1,178 @@
1+package pipe
2+
3+import (
4+ "log/slog"
5+ "testing"
6+ "time"
7+
8+ "github.com/picosh/pico/pkg/db/postgres"
9+ "github.com/picosh/pico/pkg/db/postgres/pgtest"
10+)
11+
12+var testDB *PsqlDB
13+
14+func TestMain(m *testing.M) {
15+ pgtest.Main(m)
16+}
17+
18+func cleanupTestData(t *testing.T) {
19+ t.Helper()
20+ if testDB == nil {
21+ testDB = NewDB(postgres.NewDB(pgtest.URL(t), slog.New(slog.DiscardHandler)))
22+ }
23+ pgtest.Clean(t, testDB.Db)
24+}
25+
26+func TestUpsertPipeMonitor(t *testing.T) {
27+ cleanupTestData(t)
28+
29+ user, _ := testDB.RegisterUser("pipemonitorowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipemonitorowner", "comment", "")
30+
31+ winEnd := time.Now().Add(time.Hour)
32+ err := testDB.UpsertPipeMonitor(user.ID, "test-topic", 5*time.Minute, &winEnd)
33+ if err != nil {
34+ t.Fatalf("UpsertPipeMonitor failed: %v", err)
35+ }
36+
37+ monitor, err := testDB.FindPipeMonitorByTopic(user.ID, "test-topic")
38+ if err != nil {
39+ t.Fatalf("FindPipeMonitorByTopic failed: %v", err)
40+ }
41+ if monitor.Topic != "test-topic" {
42+ t.Errorf("expected topic 'test-topic', got '%s'", monitor.Topic)
43+ }
44+ if monitor.WindowDur != 5*time.Minute {
45+ t.Errorf("expected window_dur 5m, got %v", monitor.WindowDur)
46+ }
47+}
48+
49+func TestUpsertPipeMonitor_Update(t *testing.T) {
50+ cleanupTestData(t)
51+
52+ user, _ := testDB.RegisterUser("pipeupdateowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipeupdateowner", "comment", "")
53+
54+ winEnd1 := time.Now().Add(time.Hour)
55+ err := testDB.UpsertPipeMonitor(user.ID, "update-topic", 5*time.Minute, &winEnd1)
56+ if err != nil {
57+ t.Fatalf("first UpsertPipeMonitor failed: %v", err)
58+ }
59+
60+ winEnd2 := time.Now().Add(2 * time.Hour)
61+ err = testDB.UpsertPipeMonitor(user.ID, "update-topic", 10*time.Minute, &winEnd2)
62+ if err != nil {
63+ t.Fatalf("second UpsertPipeMonitor failed: %v", err)
64+ }
65+
66+ monitor, err := testDB.FindPipeMonitorByTopic(user.ID, "update-topic")
67+ if err != nil {
68+ t.Fatalf("FindPipeMonitorByTopic failed: %v", err)
69+ }
70+ if monitor.WindowDur != 10*time.Minute {
71+ t.Errorf("expected window_dur 10m after update, got %v", monitor.WindowDur)
72+ }
73+}
74+
75+func TestUpdatePipeMonitorLastPing(t *testing.T) {
76+ cleanupTestData(t)
77+
78+ user, _ := testDB.RegisterUser("pipepingowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipepingowner", "comment", "")
79+
80+ winEnd := time.Now().Add(time.Hour)
81+ err := testDB.UpsertPipeMonitor(user.ID, "ping-topic", 5*time.Minute, &winEnd)
82+ if err != nil {
83+ t.Fatalf("UpsertPipeMonitor failed: %v", err)
84+ }
85+
86+ lastPing := time.Now()
87+ err = testDB.UpdatePipeMonitorLastPing(user.ID, "ping-topic", &lastPing)
88+ if err != nil {
89+ t.Fatalf("UpdatePipeMonitorLastPing failed: %v", err)
90+ }
91+
92+ monitor, err := testDB.FindPipeMonitorByTopic(user.ID, "ping-topic")
93+ if err != nil {
94+ t.Fatalf("FindPipeMonitorByTopic failed: %v", err)
95+ }
96+ if monitor.LastPing == nil {
97+ t.Error("expected last_ping to be set, got nil")
98+ }
99+}
100+
101+func TestRemovePipeMonitor(t *testing.T) {
102+ cleanupTestData(t)
103+
104+ user, _ := testDB.RegisterUser("piperemoveowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI piperemoveowner", "comment", "")
105+
106+ winEnd := time.Now().Add(time.Hour)
107+ err := testDB.UpsertPipeMonitor(user.ID, "remove-topic", 5*time.Minute, &winEnd)
108+ if err != nil {
109+ t.Fatalf("UpsertPipeMonitor failed: %v", err)
110+ }
111+
112+ err = testDB.RemovePipeMonitor(user.ID, "remove-topic")
113+ if err != nil {
114+ t.Fatalf("RemovePipeMonitor failed: %v", err)
115+ }
116+
117+ _, err = testDB.FindPipeMonitorByTopic(user.ID, "remove-topic")
118+ if err == nil {
119+ t.Error("expected error finding removed monitor, got nil")
120+ }
121+}
122+
123+func TestFindPipeMonitorByTopic_NotFound(t *testing.T) {
124+ cleanupTestData(t)
125+
126+ user, _ := testDB.RegisterUser("pipenotfoundowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipenotfoundowner", "comment", "")
127+
128+ _, err := testDB.FindPipeMonitorByTopic(user.ID, "nonexistent-topic")
129+ if err == nil {
130+ t.Error("expected error for nonexistent monitor, got nil")
131+ }
132+}
133+
134+func TestFindPipeMonitorsByUser(t *testing.T) {
135+ cleanupTestData(t)
136+
137+ user, _ := testDB.RegisterUser("pipemonlistowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipemonlistowner", "comment", "")
138+
139+ winEnd := time.Now().Add(time.Hour)
140+ _ = testDB.UpsertPipeMonitor(user.ID, "service-a", 5*time.Minute, &winEnd)
141+ _ = testDB.UpsertPipeMonitor(user.ID, "service-b", 10*time.Minute, &winEnd)
142+ _ = testDB.UpsertPipeMonitor(user.ID, "service-c", 1*time.Hour, &winEnd)
143+
144+ monitors, err := testDB.FindPipeMonitorsByUser(user.ID)
145+ if err != nil {
146+ t.Fatalf("FindPipeMonitorsByUser failed: %v", err)
147+ }
148+
149+ if len(monitors) != 3 {
150+ t.Errorf("expected 3 monitors, got %d", len(monitors))
151+ }
152+
153+ // Should be ordered by topic
154+ if monitors[0].Topic != "service-a" {
155+ t.Errorf("expected first topic 'service-a', got %s", monitors[0].Topic)
156+ }
157+ if monitors[1].Topic != "service-b" {
158+ t.Errorf("expected second topic 'service-b', got %s", monitors[1].Topic)
159+ }
160+ if monitors[2].Topic != "service-c" {
161+ t.Errorf("expected third topic 'service-c', got %s", monitors[2].Topic)
162+ }
163+}
164+
165+func TestFindPipeMonitorsByUser_Empty(t *testing.T) {
166+ cleanupTestData(t)
167+
168+ user, _ := testDB.RegisterUser("pipenomonitors", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipenomonitors", "comment", "")
169+
170+ monitors, err := testDB.FindPipeMonitorsByUser(user.ID)
171+ if err != nil {
172+ t.Fatalf("FindPipeMonitorsByUser failed: %v", err)
173+ }
174+
175+ if len(monitors) != 0 {
176+ t.Errorf("expected 0 monitors for user with none, got %d", len(monitors))
177+ }
178+}
+1, -1
......@@ -27,7 +27,7 @@ func StartSshServer() {
2727 ctx, cancel := context.WithCancel(context.Background())
2828 defer cancel()
2929
30- dbh := postgres.NewDB(cfg.DbURL, cfg.Logger)
30+ dbh := NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
3131 defer func() {
3232 _ = dbh.Close()
3333 }()
+10, -10
......@@ -120,7 +120,7 @@ func GetBlogName(username string) string {
120120
121121 func blogStyleHandler(w http.ResponseWriter, r *http.Request) {
122122 username := router.GetUsernameFromRequest(r)
123- dbpool := router.GetDB(r)
123+ dbpool := getDB(r)
124124 logger := router.GetLogger(r)
125125 cfg := router.GetCfg(r)
126126
......@@ -150,7 +150,7 @@ func blogStyleHandler(w http.ResponseWriter, r *http.Request) {
150150
151151 func blogHandler(w http.ResponseWriter, r *http.Request) {
152152 username := router.GetUsernameFromRequest(r)
153- dbpool := router.GetDB(r)
153+ dbpool := getDB(r)
154154 logger := router.GetLogger(r)
155155 cfg := router.GetCfg(r)
156156
......@@ -290,7 +290,7 @@ func postRawHandler(w http.ResponseWriter, r *http.Request) {
290290 }
291291 slug = strings.TrimSuffix(slug, "/")
292292
293- dbpool := router.GetDB(r)
293+ dbpool := getDB(r)
294294 logger := router.GetLogger(r)
295295 logger = logger.With("slug", slug)
296296
......@@ -321,7 +321,7 @@ func postRawHandler(w http.ResponseWriter, r *http.Request) {
321321 func robotsHandler(w http.ResponseWriter, r *http.Request) {
322322 username := router.GetUsernameFromRequest(r)
323323 cfg := router.GetCfg(r)
324- dbpool := router.GetDB(r)
324+ dbpool := getDB(r)
325325 logger := router.GetLogger(r)
326326 user, err := dbpool.FindUserByName(username)
327327 if err != nil {
......@@ -357,7 +357,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
357357 }
358358 slug = strings.TrimSuffix(slug, "/")
359359
360- dbpool := router.GetDB(r)
360+ dbpool := getDB(r)
361361 logger := router.GetLogger(r)
362362
363363 user, err := dbpool.FindUserByName(username)
......@@ -548,7 +548,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
548548 }
549549
550550 func readHandler(w http.ResponseWriter, r *http.Request) {
551- dbpool := router.GetDB(r)
551+ dbpool := getDB(r)
552552 logger := router.GetLogger(r)
553553 cfg := router.GetCfg(r)
554554
......@@ -597,7 +597,7 @@ func readHandler(w http.ResponseWriter, r *http.Request) {
597597
598598 func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
599599 username := router.GetUsernameFromRequest(r)
600- dbpool := router.GetDB(r)
600+ dbpool := getDB(r)
601601 logger := router.GetLogger(r)
602602 cfg := router.GetCfg(r)
603603
......@@ -757,7 +757,7 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
757757 }
758758
759759 func rssHandler(w http.ResponseWriter, r *http.Request) {
760- dbpool := router.GetDB(r)
760+ dbpool := getDB(r)
761761 logger := router.GetLogger(r)
762762 cfg := router.GetCfg(r)
763763
......@@ -912,7 +912,7 @@ func createMainRoutes(staticRoutes []router.Route) []router.Route {
912912 func imgRequest(w http.ResponseWriter, r *http.Request) {
913913 logger := router.GetLogger(r)
914914 st := router.GetStorage(r)
915- dbpool := router.GetDB(r)
915+ dbpool := getDB(r)
916916 username := router.GetUsernameFromRequest(r)
917917 user, err := dbpool.FindUserByName(username)
918918 if err != nil {
......@@ -980,7 +980,7 @@ func createSubdomainRoutes(staticRoutes []router.Route) []router.Route {
980980
981981 func StartApiServer() {
982982 cfg := NewConfigSite("prose-web")
983- dbpool := postgres.NewDB(cfg.DbURL, cfg.Logger)
983+ dbpool := NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
984984 defer func() {
985985 _ = dbpool.Close()
986986 }()
+342, -0
......@@ -0,0 +1,342 @@
1+package prose
2+
3+import (
4+ "fmt"
5+ "math"
6+ "net/http"
7+ "sync"
8+ "time"
9+
10+ "github.com/hashicorp/golang-lru/v2/expirable"
11+ "github.com/jmoiron/sqlx"
12+ "github.com/picosh/pico/pkg/db"
13+ "github.com/picosh/pico/pkg/db/postgres"
14+ "github.com/picosh/pico/pkg/shared/router"
15+)
16+
17+// DB is the storage prose uses.
18+type DB interface {
19+ db.PostDB
20+ ReplaceTagsByPost(tags []string, postID string) error
21+ ReplaceAliasesByPost(aliases []string, postID string) error
22+ FindUserPostsByTag(pager *db.Pager, tag, userID, space string) (*db.Paginate[*db.Post], error)
23+ FindPopularPosts() ([]*db.Post, error)
24+ FindUserByName(name string) (*db.User, error)
25+ FindFeature(userID string, feature string) (*db.FeatureFlag, error)
26+}
27+
28+// PsqlDB is prose's postgres storage: the queries apps share, and the ones
29+// only prose makes.
30+type PsqlDB struct {
31+ *postgres.PsqlDB
32+
33+ popularPostsCache *expirable.LRU[string, []*db.Post]
34+ popularPostsMu sync.Mutex
35+}
36+
37+var _ DB = (*PsqlDB)(nil)
38+
39+func NewDB(base *postgres.PsqlDB) *PsqlDB {
40+ return &PsqlDB{
41+ PsqlDB: base,
42+ popularPostsCache: expirable.NewLRU[string, []*db.Post](16, nil, 15*time.Minute),
43+ }
44+}
45+
46+func (me *PsqlDB) ClearPopularPostsCache() {
47+ if me.popularPostsCache != nil {
48+ me.popularPostsCache.Purge()
49+ }
50+}
51+
52+func (me *PsqlDB) FindPopularPosts() ([]*db.Post, error) {
53+ if me.popularPostsCache != nil {
54+ if posts, ok := me.popularPostsCache.Get("popular_posts"); ok {
55+ return posts, nil
56+ }
57+ }
58+
59+ me.popularPostsMu.Lock()
60+ defer me.popularPostsMu.Unlock()
61+
62+ if me.popularPostsCache != nil {
63+ if posts, ok := me.popularPostsCache.Get("popular_posts"); ok {
64+ return posts, nil
65+ }
66+ }
67+
68+ query := `
69+ WITH candidate_posts AS (
70+ SELECT
71+ posts.id,
72+ posts.user_id,
73+ posts.filename,
74+ posts.slug,
75+ posts.title,
76+ posts.text,
77+ posts.description,
78+ posts.publish_at,
79+ app_users.name AS username,
80+ posts.updated_at,
81+ posts.mime_type
82+ FROM posts
83+ LEFT JOIN app_users ON app_users.id = posts.user_id
84+ WHERE
85+ hidden = FALSE
86+ AND publish_at::date <= CURRENT_DATE
87+ AND publish_at >= NOW() - INTERVAL '30 days'
88+ AND cur_space = 'prose'
89+ AND EXISTS (
90+ SELECT 1 FROM feature_flags
91+ WHERE feature_flags.user_id = posts.user_id
92+ AND (name = 'plus' OR name = 'prose')
93+ AND expires_at > NOW()
94+ )
95+ ),
96+ post_visitors AS (
97+ SELECT post_id, COUNT(DISTINCT ip_address) AS unique_visitors
98+ FROM analytics_visits
99+ WHERE post_id IN (SELECT id FROM candidate_posts)
100+ AND status = 200
101+ AND created_at >= NOW() - INTERVAL '30 days'
102+ GROUP BY post_id
103+ ),
104+ scored_posts AS (
105+ SELECT
106+ cp.*,
107+ COALESCE(pv.unique_visitors, 0) AS score,
108+ (
109+ LOG(GREATEST(COALESCE(pv.unique_visitors, 0), 1)) +
110+ (EXTRACT(EPOCH FROM cp.publish_at) - 1700000000) / 345600.0
111+ ) AS rank_score,
112+ ROW_NUMBER() OVER (
113+ PARTITION BY cp.user_id
114+ ORDER BY (
115+ LOG(GREATEST(COALESCE(pv.unique_visitors, 0), 1)) +
116+ (EXTRACT(EPOCH FROM cp.publish_at) - 1700000000) / 345600.0
117+ ) DESC, cp.publish_at DESC
118+ ) AS user_rank
119+ FROM candidate_posts cp
120+ LEFT JOIN post_visitors pv ON pv.post_id = cp.id
121+ )
122+ SELECT
123+ id,
124+ user_id,
125+ filename,
126+ slug,
127+ title,
128+ text,
129+ description,
130+ publish_at,
131+ username,
132+ updated_at,
133+ mime_type,
134+ score
135+ FROM scored_posts
136+ WHERE user_rank <= 3
137+ ORDER BY rank_score DESC, publish_at DESC
138+ LIMIT 30`
139+ rs, err := me.Db.Queryx(query)
140+ if err != nil {
141+ return nil, err
142+ }
143+ defer func() { _ = rs.Close() }()
144+
145+ var posts []*db.Post
146+ for rs.Next() {
147+ post := &db.Post{}
148+ err := rs.Scan(
149+ &post.ID,
150+ &post.UserID,
151+ &post.Filename,
152+ &post.Slug,
153+ &post.Title,
154+ &post.Text,
155+ &post.Description,
156+ &post.PublishAt,
157+ &post.Username,
158+ &post.UpdatedAt,
159+ &post.MimeType,
160+ &post.Score,
161+ )
162+ if err != nil {
163+ return nil, err
164+ }
165+
166+ posts = append(posts, post)
167+ }
168+ if rs.Err() != nil {
169+ return nil, rs.Err()
170+ }
171+
172+ if me.popularPostsCache != nil {
173+ me.popularPostsCache.Add("popular_posts", posts)
174+ }
175+
176+ return posts, nil
177+}
178+
179+func (me *PsqlDB) FindUserPostsByTag(page *db.Pager, tag, userID, space string) (*db.Paginate[*db.Post], error) {
180+ var posts []*db.Post
181+ query := fmt.Sprintf(`
182+ SELECT %s
183+ FROM posts
184+ LEFT JOIN app_users ON app_users.id = posts.user_id
185+ LEFT JOIN post_tags ON post_tags.post_id = posts.id
186+ WHERE
187+ hidden = FALSE AND
188+ user_id = $1 AND
189+ (post_tags.name = $2 OR hidden = true) AND
190+ publish_at::date <= CURRENT_DATE AND
191+ cur_space = $3
192+ ORDER BY publish_at DESC
193+ LIMIT $4 OFFSET $5`, postgres.SelectPost)
194+ err := me.Db.Select(
195+ &posts,
196+ query,
197+ userID,
198+ tag,
199+ space,
200+ page.Num,
201+ page.Num*page.Page,
202+ )
203+ if err != nil {
204+ return nil, err
205+ }
206+
207+ var count int
208+ err = me.Db.QueryRow(`SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`, space).Scan(&count)
209+ if err != nil {
210+ return nil, err
211+ }
212+
213+ pager := &db.Paginate[*db.Post]{
214+ Data: posts,
215+ Total: int(math.Ceil(float64(count) / float64(page.Num))),
216+ }
217+ return pager, nil
218+}
219+
220+func (me *PsqlDB) FindPostsByTag(pager *db.Pager, tag, space string) (*db.Paginate[*db.Post], error) {
221+ query := `
222+ SELECT
223+ posts.id,
224+ user_id,
225+ filename,
226+ slug,
227+ title,
228+ text,
229+ description,
230+ publish_at,
231+ app_users.name as username,
232+ posts.updated_at,
233+ posts.mime_type
234+ FROM posts
235+ LEFT JOIN app_users ON app_users.id = posts.user_id
236+ LEFT JOIN post_tags ON post_tags.post_id = posts.id
237+ WHERE
238+ post_tags.name = $3 AND
239+ publish_at::date <= CURRENT_DATE AND
240+ cur_space = $4
241+ ORDER BY publish_at DESC
242+ LIMIT $1 OFFSET $2`
243+ rs, err := me.Db.Queryx(
244+ query,
245+ pager.Num,
246+ pager.Num*pager.Page,
247+ tag,
248+ space,
249+ )
250+ if err != nil {
251+ return nil, err
252+ }
253+ defer func() { _ = rs.Close() }()
254+
255+ return me.postPager(rs, pager.Num, space, tag)
256+}
257+
258+func (me *PsqlDB) postPager(rs *sqlx.Rows, pageNum int, space string, tag string) (*db.Paginate[*db.Post], error) {
259+ var posts []*db.Post
260+ for rs.Next() {
261+ post := &db.Post{}
262+ err := rs.Scan(
263+ &post.ID,
264+ &post.UserID,
265+ &post.Filename,
266+ &post.Slug,
267+ &post.Title,
268+ &post.Text,
269+ &post.Description,
270+ &post.PublishAt,
271+ &post.Username,
272+ &post.UpdatedAt,
273+ &post.MimeType,
274+ )
275+ if err != nil {
276+ return nil, err
277+ }
278+
279+ posts = append(posts, post)
280+ }
281+ if rs.Err() != nil {
282+ return nil, rs.Err()
283+ }
284+
285+ var count int
286+ var err error
287+ if tag == "" {
288+ err = me.Db.QueryRow(`SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`, space).Scan(&count)
289+ } else {
290+ err = me.Db.QueryRow(`
291+ SELECT count(posts.id)
292+ FROM posts
293+ LEFT JOIN post_tags ON post_tags.post_id = posts.id
294+ WHERE hidden = FALSE AND cur_space=$1 and post_tags.name = $2`, space, tag).Scan(&count)
295+ }
296+ if err != nil {
297+ return nil, err
298+ }
299+
300+ pager := &db.Paginate[*db.Post]{
301+ Data: posts,
302+ Total: int(math.Ceil(float64(count) / float64(pageNum))),
303+ }
304+
305+ return pager, nil
306+}
307+
308+func (me *PsqlDB) FindPopularTags(space string) ([]string, error) {
309+ tags := make([]string, 0)
310+ query := `
311+ SELECT name, count(post_id) as "tally"
312+ FROM post_tags
313+ LEFT JOIN posts ON posts.id = post_id
314+ WHERE posts.cur_space = $1
315+ GROUP BY name
316+ ORDER BY tally DESC
317+ LIMIT 5`
318+ rs, err := me.Db.Queryx(query, space)
319+ if err != nil {
320+ return tags, err
321+ }
322+ defer func() { _ = rs.Close() }()
323+ for rs.Next() {
324+ name := ""
325+ tally := 0
326+ err := rs.Scan(&name, &tally)
327+ if err != nil {
328+ return tags, err
329+ }
330+
331+ tags = append(tags, name)
332+ }
333+ if rs.Err() != nil {
334+ return tags, rs.Err()
335+ }
336+ return tags, nil
337+}
338+
339+// getDB is the storage StartApiServer gave the router, which is a PsqlDB.
340+func getDB(r *http.Request) DB {
341+ return router.GetDB(r).(DB)
342+}
+182, -0
......@@ -0,0 +1,182 @@
1+package prose
2+
3+import (
4+ "log/slog"
5+ "testing"
6+ "time"
7+
8+ "github.com/picosh/pico/pkg/db"
9+ "github.com/picosh/pico/pkg/db/postgres"
10+ "github.com/picosh/pico/pkg/db/postgres/pgtest"
11+)
12+
13+var testDB *PsqlDB
14+
15+func TestMain(m *testing.M) {
16+ pgtest.Main(m)
17+}
18+
19+func cleanupTestData(t *testing.T) {
20+ t.Helper()
21+ if testDB == nil {
22+ testDB = NewDB(postgres.NewDB(pgtest.URL(t), slog.New(slog.DiscardHandler)))
23+ }
24+ pgtest.Clean(t, testDB.Db)
25+ testDB.ClearPopularPostsCache()
26+}
27+
28+func mustInsertPost(t *testing.T, post *db.Post) *db.Post {
29+ t.Helper()
30+ return pgtest.InsertPost(t, testDB, post)
31+}
32+
33+func TestFindPopularPosts(t *testing.T) {
34+ cleanupTestData(t)
35+
36+ user, _ := testDB.RegisterUser("feedowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedowner", "comment", "")
37+
38+ now := time.Now()
39+ post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost.md", Slug: "feedpost", Title: "Feed Post", Space: "prose", PublishAt: &now})
40+
41+ // Before having feature flag, should not appear
42+ posts, err := testDB.FindPopularPosts()
43+ if err != nil {
44+ t.Fatalf("FindPopularPosts failed: %v", err)
45+ }
46+ if len(posts) != 0 {
47+ t.Errorf("expected 0 posts without feature flag, got %d", len(posts))
48+ }
49+
50+ // Add prose feature flag
51+ _, err = testDB.InsertFeature(user.ID, "prose", time.Now().Add(time.Hour*24))
52+ if err != nil {
53+ t.Fatalf("InsertFeature failed: %v", err)
54+ }
55+ testDB.ClearPopularPostsCache()
56+
57+ posts, err = testDB.FindPopularPosts()
58+ if err != nil {
59+ t.Fatalf("FindPopularPosts failed: %v", err)
60+ }
61+ if len(posts) != 1 {
62+ t.Fatalf("expected 1 post in popular posts, got %d", len(posts))
63+ }
64+ if posts[0].Score != 0 {
65+ t.Errorf("expected score 0, got %d", posts[0].Score)
66+ }
67+
68+ // Add visits
69+ _ = testDB.InsertVisit(&db.AnalyticsVisits{UserID: user.ID, PostID: post.ID, IpAddress: "192.168.1.1", Status: 200})
70+ _ = testDB.InsertVisit(&db.AnalyticsVisits{UserID: user.ID, PostID: post.ID, IpAddress: "192.168.1.2", Status: 200})
71+ _ = testDB.InsertVisit(&db.AnalyticsVisits{UserID: user.ID, PostID: post.ID, IpAddress: "192.168.1.1", Status: 200}) // duplicate IP
72+
73+ // Before clearing cache, should return cached result (score 0)
74+ cachedPosts, err := testDB.FindPopularPosts()
75+ if err != nil {
76+ t.Fatalf("FindPopularPosts failed: %v", err)
77+ }
78+ if cachedPosts[0].Score != 0 {
79+ t.Errorf("expected cached score 0, got %d", cachedPosts[0].Score)
80+ }
81+
82+ // After clearing cache, should re-query and return updated score 2
83+ testDB.ClearPopularPostsCache()
84+ posts, err = testDB.FindPopularPosts()
85+ if err != nil {
86+ t.Fatalf("FindPopularPosts failed: %v", err)
87+ }
88+ if len(posts) != 1 {
89+ t.Fatalf("expected 1 post, got %d", len(posts))
90+ }
91+ if posts[0].Score != 2 {
92+ t.Errorf("expected score 2 (unique visitors), got %d", posts[0].Score)
93+ }
94+
95+ // Verify max 3 posts per user
96+ testDB.ClearPopularPostsCache()
97+ post2Time := now.Add(-1 * time.Hour)
98+ _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost2.md", Slug: "feedpost2", Title: "Feed Post 2", Space: "prose", PublishAt: &post2Time})
99+ post3Time := now.Add(-2 * time.Hour)
100+ _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost3.md", Slug: "feedpost3", Title: "Feed Post 3", Space: "prose", PublishAt: &post3Time})
101+ post4Time := now.Add(-3 * time.Hour)
102+ _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost4.md", Slug: "feedpost4", Title: "Feed Post 4", Space: "prose", PublishAt: &post4Time})
103+
104+ posts, err = testDB.FindPopularPosts()
105+ if err != nil {
106+ t.Fatalf("FindPopularPosts failed: %v", err)
107+ }
108+ if len(posts) != 3 {
109+ t.Errorf("expected max 3 posts for single user, got %d", len(posts))
110+ }
111+
112+ // Post older than 30 days should be excluded
113+ testDB.ClearPopularPostsCache()
114+ oldTime := now.Add(-31 * 24 * time.Hour)
115+ _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "oldpost.md", Slug: "oldpost", Title: "Old Post", Space: "prose", PublishAt: &oldTime})
116+
117+ posts, err = testDB.FindPopularPosts()
118+ if err != nil {
119+ t.Fatalf("FindPopularPosts failed: %v", err)
120+ }
121+ if len(posts) != 3 {
122+ t.Errorf("expected still 3 posts (old post excluded), got %d", len(posts))
123+ }
124+}
125+
126+// ============ Tags Tests ============
127+
128+func TestFindUserPostsByTag(t *testing.T) {
129+ cleanupTestData(t)
130+
131+ user, _ := testDB.RegisterUser("usertagowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI usertagowner", "comment", "")
132+ now := time.Now()
133+ post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "usertag.md", Slug: "usertag", Title: "User Tag", Space: "prose", PublishAt: &now})
134+ _ = testDB.ReplaceTagsByPost([]string{"mytag"}, post.ID)
135+
136+ pager := &db.Pager{Num: 10, Page: 0}
137+ result, err := testDB.FindUserPostsByTag(pager, "mytag", user.ID, "prose")
138+ if err != nil {
139+ t.Fatalf("FindUserPostsByTag failed: %v", err)
140+ }
141+ if len(result.Data) != 1 {
142+ t.Errorf("expected 1 post with tag, got %d", len(result.Data))
143+ }
144+}
145+
146+func TestFindPostsByTag(t *testing.T) {
147+ cleanupTestData(t)
148+
149+ user, _ := testDB.RegisterUser("tagsearchowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tagsearchowner", "comment", "")
150+ now := time.Now()
151+ post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "tagsearch.md", Slug: "tagsearch", Title: "Tag Search", Space: "prose", PublishAt: &now})
152+ _ = testDB.ReplaceTagsByPost([]string{"searchtag"}, post.ID)
153+
154+ pager := &db.Pager{Num: 10, Page: 0}
155+ result, err := testDB.FindPostsByTag(pager, "searchtag", "prose")
156+ if err != nil {
157+ t.Fatalf("FindPostsByTag failed: %v", err)
158+ }
159+ if len(result.Data) != 1 {
160+ t.Errorf("expected 1 post with tag, got %d", len(result.Data))
161+ }
162+}
163+
164+func TestFindPopularTags(t *testing.T) {
165+ cleanupTestData(t)
166+
167+ user, _ := testDB.RegisterUser("populartagowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI populartagowner", "comment", "")
168+ post1 := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "pop1.md", Slug: "pop1", Title: "Pop 1", Space: "prose"})
169+ post2 := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "pop2.md", Slug: "pop2", Title: "Pop 2", Space: "prose"})
170+ _ = testDB.ReplaceTagsByPost([]string{"popular"}, post1.ID)
171+ _ = testDB.ReplaceTagsByPost([]string{"popular"}, post2.ID)
172+
173+ tags, err := testDB.FindPopularTags("prose")
174+ if err != nil {
175+ t.Fatalf("FindPopularTags failed: %v", err)
176+ }
177+ if len(tags) < 1 {
178+ t.Errorf("expected at least 1 popular tag, got %d", len(tags))
179+ }
180+}
181+
182+// ============ Aliases Tests ============
+195, -0
......@@ -0,0 +1,195 @@
1+package prose
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "slices"
8+ "strings"
9+
10+ "github.com/picosh/pico/pkg/db"
11+ "github.com/picosh/pico/pkg/filehandlers"
12+ "github.com/picosh/pico/pkg/pssh"
13+ sendutils "github.com/picosh/pico/pkg/send/utils"
14+ "github.com/picosh/pico/pkg/shared"
15+)
16+
17+// postUploader stores prose's text files as posts.
18+type postUploader struct {
19+ Cfg *shared.ConfigSite
20+ DB DB
21+}
22+
23+var (
24+ _ filehandlers.ReadWriteHandler = (*postUploader)(nil)
25+ _ sendutils.WriteValidator = (*postUploader)(nil)
26+ _ sendutils.FileRenamer = (*postUploader)(nil)
27+)
28+
29+func (h *postUploader) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
30+ return filehandlers.BaseList(s, fpath, isDir, recursive, []string{h.Cfg.Space}, h.DB)
31+}
32+
33+func (h *postUploader) Read(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (os.FileInfo, sendutils.ReadAndReaderAtCloser, error) {
34+ return filehandlers.ReadPost(s, h.DB, h.Cfg.Space, entry)
35+}
36+
37+func (h *postUploader) Delete(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
38+ return filehandlers.DeletePost(s, h.DB, h.Cfg.Space, entry)
39+}
40+
41+// ValidateWrite checks a post's name before its contents arrive.
42+func (h *postUploader) ValidateWrite(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
43+ if _, err := filehandlers.SessionUser(s); err != nil {
44+ return err
45+ }
46+ if entry.Mode.IsDir() {
47+ return fmt.Errorf("file entry is directory, but only files are supported: %s", entry.Filepath)
48+ }
49+ return h.validate(filehandlers.NewPost(filepath.Base(entry.Filepath), nil))
50+}
51+
52+func (h *postUploader) Write(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (string, error) {
53+ user, err := filehandlers.SessionUser(s)
54+ if err != nil {
55+ return "", err
56+ }
57+ logger := pssh.GetLogger(s).With("filename", filepath.Base(entry.Filepath))
58+
59+ post, err := filehandlers.PostFromEntry(entry, user, h.Cfg.Space)
60+ if err != nil {
61+ return "", err
62+ }
63+ if err := h.validate(post); err != nil {
64+ logger.Error("file failed validation", "err", err.Error())
65+ return "", err
66+ }
67+
68+ cur, err := filehandlers.LookupPost(h.DB, user.ID, h.Cfg.Space, post.Filename)
69+ if err != nil {
70+ return "", err
71+ }
72+ if cur != nil {
73+ post.Data, post.PublishAt = cur.Data, cur.PublishAt
74+ }
75+ aliases, err := h.setMeta(post)
76+ if err != nil {
77+ logger.Error("file could not load meta", "err", err.Error())
78+ return "", err
79+ }
80+
81+ saved, changed, err := filehandlers.SavePost(h.DB, cur, post)
82+ if err != nil {
83+ logger.Error("post could not be saved", "err", err.Error())
84+ return "", err
85+ }
86+ logger.Info("saved post", "new", cur == nil, "changed", changed)
87+ // A new post has no tags or aliases to clear.
88+ if changed && (cur != nil || len(post.Tags) > 0) {
89+ if err := h.DB.ReplaceTagsByPost(post.Tags, saved.ID); err != nil {
90+ logger.Error("post could not replace tags", "err", err.Error())
91+ return "", fmt.Errorf("error for %s: %v", post.Filename, err)
92+ }
93+ }
94+ if changed && (cur != nil || len(aliases) > 0) {
95+ if err := h.DB.ReplaceAliasesByPost(aliases, saved.ID); err != nil {
96+ logger.Error("post could not replace aliases", "err", err.Error())
97+ return "", fmt.Errorf("error for %s: %v", post.Filename, err)
98+ }
99+ }
100+
101+ return h.Cfg.FullPostURL(shared.NewCreateURL(h.Cfg), user.Name, post.Slug), nil
102+}
103+
104+// Rename moves a post to a new filename in place, so it keeps its ID,
105+// publish date and settings. A post at the new filename is replaced.
106+func (h *postUploader) Rename(s *pssh.SSHServerConnSession, from, to *sendutils.FileEntry) error {
107+ return filehandlers.RenamePost(s, h.DB, h.Cfg.Space, from, to, func(cur, next *db.Post) ([]string, error) {
108+ if err := h.validate(next); err != nil {
109+ return nil, err
110+ }
111+ return h.setMeta(next)
112+ })
113+}
114+
115+func (h *postUploader) validate(post *db.Post) error {
116+ if !shared.IsTextFile(post.Text) {
117+ return fmt.Errorf(
118+ "ERROR: (%s) invalid file must be plain text (utf-8), skipping",
119+ post.Filename,
120+ )
121+ }
122+
123+ fp := strings.Replace(post.Filename, "/", "", 1)
124+ // special styles css file we want to permit but no other css file.
125+ // sometimes the directory is provided in the filename, so we want to
126+ // remove that before we perform this check.
127+ if fp == "_styles.css" {
128+ return nil
129+ }
130+ // allow users to upload robots file
131+ if fp == "robots.txt" {
132+ return nil
133+ }
134+
135+ if !shared.IsExtAllowed(post.Filename, h.Cfg.AllowedExt) {
136+ extStr := strings.Join(h.Cfg.AllowedExt, ",")
137+ return fmt.Errorf(
138+ "ERROR: (%s) invalid file, format must be (%s), skipping",
139+ post.Filename,
140+ extStr,
141+ )
142+ }
143+
144+ if post.FileSize > MAX_FILE_SIZE {
145+ return fmt.Errorf(
146+ "ERROR: file (%s) has exceeded maximum file size (%d bytes)",
147+ post.Filename,
148+ MAX_FILE_SIZE,
149+ )
150+ }
151+
152+ return nil
153+}
154+
155+// setMeta fills in a post's title, description, tags, publish date and
156+// hidden flag from its text, and returns its aliases.
157+func (h *postUploader) setMeta(post *db.Post) ([]string, error) {
158+ var aliases []string
159+ switch filepath.Ext(post.Filename) {
160+ case ".lxt":
161+ parsedText := shared.ListParseText(post.Text)
162+ if parsedText.Title == "" {
163+ post.Title = shared.ToUpper(post.Slug)
164+ } else {
165+ post.Title = parsedText.Title
166+ }
167+ aliases = parsedText.Aliases
168+ post.Tags = parsedText.Tags
169+ post.Description = parsedText.Description
170+ if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
171+ post.PublishAt = parsedText.PublishAt
172+ }
173+ post.Hidden = parsedText.Hidden
174+ case ".md":
175+ parsedText, err := shared.ParseText(post.Text)
176+ if err != nil {
177+ return nil, fmt.Errorf("%s: %w", post.Filename, err)
178+ }
179+ if parsedText.Title == "" {
180+ post.Title = shared.ToUpper(post.Slug)
181+ } else {
182+ post.Title = parsedText.Title
183+ }
184+ aliases = parsedText.Aliases
185+ post.Tags = parsedText.Tags
186+ post.Description = parsedText.Description
187+ if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
188+ post.PublishAt = parsedText.PublishAt
189+ }
190+ post.Hidden = parsedText.Hidden
191+ }
192+
193+ post.Hidden = post.Hidden || slices.Contains(h.Cfg.HiddenPosts, post.Filename)
194+ return aliases, nil
195+}
+0, -129
......@@ -1,129 +0,0 @@
1-package prose
2-
3-import (
4- "fmt"
5- "path/filepath"
6- "strings"
7-
8- "slices"
9-
10- "github.com/picosh/pico/pkg/db"
11- "github.com/picosh/pico/pkg/filehandlers"
12- "github.com/picosh/pico/pkg/pssh"
13- "github.com/picosh/pico/pkg/shared"
14- pipeUtil "github.com/picosh/utils/pipe"
15-)
16-
17-type MarkdownHooks struct {
18- Cfg *shared.ConfigSite
19- Db db.DB
20- Pipe *pipeUtil.ReconnectReadWriteCloser
21-}
22-
23-func (p *MarkdownHooks) FileValidate(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) (bool, error) {
24- if !shared.IsTextFile(data.Text) {
25- err := fmt.Errorf(
26- "ERROR: (%s) invalid file must be plain text (utf-8), skipping",
27- data.Filename,
28- )
29- return false, err
30- }
31-
32- fp := strings.Replace(data.Filename, "/", "", 1)
33- // special styles css file we want to permit but no other css file.
34- // sometimes the directory is provided in the filename, so we want to
35- // remove that before we perform this check.
36- if fp == "_styles.css" {
37- return true, nil
38- }
39- // allow users to upload robots file
40- if fp == "robots.txt" {
41- return true, nil
42- }
43-
44- if !shared.IsExtAllowed(data.Filename, p.Cfg.AllowedExt) {
45- extStr := strings.Join(p.Cfg.AllowedExt, ",")
46- err := fmt.Errorf(
47- "ERROR: (%s) invalid file, format must be (%s), skipping",
48- data.Filename,
49- extStr,
50- )
51- return false, err
52- }
53-
54- if data.FileSize > MAX_FILE_SIZE {
55- return false, fmt.Errorf(
56- "ERROR: file (%s) has exceeded maximum file size (%d bytes)",
57- data.Filename,
58- MAX_FILE_SIZE,
59- )
60- }
61-
62- return true, nil
63-}
64-
65-func (p *MarkdownHooks) metaLxt(data *filehandlers.PostMetaData) error {
66- parsedText := shared.ListParseText(data.Text)
67-
68- if parsedText.Title == "" {
69- data.Title = shared.ToUpper(data.Slug)
70- } else {
71- data.Title = parsedText.Title
72- }
73-
74- data.Aliases = parsedText.Aliases
75- data.Tags = parsedText.Tags
76- data.Description = parsedText.Description
77-
78- if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
79- data.PublishAt = parsedText.PublishAt
80- }
81- data.Hidden = parsedText.Hidden
82-
83- return nil
84-}
85-
86-func (p *MarkdownHooks) metaMd(data *filehandlers.PostMetaData) error {
87- parsedText, err := shared.ParseText(data.Text)
88- if err != nil {
89- return fmt.Errorf("%s: %w", data.Filename, err)
90- }
91-
92- if parsedText.Title == "" {
93- data.Title = shared.ToUpper(data.Slug)
94- } else {
95- data.Title = parsedText.Title
96- }
97-
98- data.Aliases = parsedText.Aliases
99- data.Tags = parsedText.Tags
100- data.Description = parsedText.Description
101-
102- if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
103- data.PublishAt = parsedText.PublishAt
104- }
105- data.Hidden = parsedText.Hidden
106-
107- return nil
108-}
109-
110-func (p *MarkdownHooks) FileMeta(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) error {
111- ext := filepath.Ext(data.Filename)
112- switch ext {
113- case ".lxt":
114- err := p.metaLxt(data)
115- if err != nil {
116- return fmt.Errorf("%s: %w", data.Filename, err)
117- }
118- case ".md":
119- err := p.metaMd(data)
120- if err != nil {
121- return fmt.Errorf("%s: %w", data.Filename, err)
122- }
123- }
124-
125- isHiddenFilename := slices.Contains(p.Cfg.HiddenPosts, data.Filename)
126- data.Hidden = data.Hidden || isHiddenFilename
127-
128- return nil
129-}
+1, -1
......@@ -31,7 +31,7 @@ func StartSshServer() {
3131 ctx, cancel := context.WithCancel(context.Background())
3232 defer cancel()
3333
34- dbh := postgres.NewDB(cfg.DbURL, cfg.Logger)
34+ dbh := NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
3535 defer func() {
3636 _ = dbh.Close()
3737 }()
+6, -7
......@@ -35,7 +35,7 @@ func setFeatureLimits(ff *db.FeatureFlag, cfg *shared.ConfigSite) {
3535 ff.Data.SpecialFileMax = ff.FindSpecialFileMax(cfg.MaxSpecialFileSize)
3636 }
3737
38-func findFeatureFlag(dbpool db.DB, cfg *shared.ConfigSite, userID string) (*db.FeatureFlag, error) {
38+func findFeatureFlag(dbpool DB, cfg *shared.ConfigSite, userID string) (*db.FeatureFlag, error) {
3939 ff, err := dbpool.FindFeature(userID, "plus")
4040 if err == nil {
4141 if ff.IsValid() {
......@@ -66,14 +66,14 @@ func findFeatureFlag(dbpool db.DB, cfg *shared.ConfigSite, userID string) (*db.F
6666 type UploadHandler struct {
6767 *filehandlers.FileHandlerRouter
6868 Cfg *shared.ConfigSite
69- DB db.DB
69+ DB DB
7070 }
7171
7272 var _ sendutils.CopyFromClientHandler = &UploadHandler{}
7373 var _ sendutils.CopyFromClientHandler = (*UploadHandler)(nil)
7474
75-func NewUploadHandler(cfg *shared.ConfigSite, dbpool db.DB, fileMap map[string]filehandlers.ReadWriteHandler) *UploadHandler {
76- router := filehandlers.NewFileHandlerRouter(cfg, dbpool, fileMap)
75+func NewUploadHandler(cfg *shared.ConfigSite, dbpool DB, fileMap map[string]filehandlers.ReadWriteHandler) *UploadHandler {
76+ router := filehandlers.NewFileHandlerRouter(cfg, fileMap)
7777 return &UploadHandler{
7878 FileHandlerRouter: router,
7979 Cfg: cfg,
......@@ -82,11 +82,10 @@ func NewUploadHandler(cfg *shared.ConfigSite, dbpool db.DB, fileMap map[string]f
8282 }
8383
8484 // NewFileHandler routes prose's text files to posts and the rest to images.
85-func NewFileHandler(cfg *shared.ConfigSite, dbpool db.DB, st storage.StorageServe) *UploadHandler {
86- hooks := &MarkdownHooks{Cfg: cfg, Db: dbpool}
85+func NewFileHandler(cfg *shared.ConfigSite, dbpool DB, st storage.StorageServe) *UploadHandler {
8786 // One post handler serves every text extension, so a post can be
8887 // renamed in place from one to another.
89- posts := filehandlers.NewScpPostHandler(dbpool, cfg, hooks)
88+ posts := &postUploader{Cfg: cfg, DB: dbpool}
9089 return NewUploadHandler(cfg, dbpool, map[string]filehandlers.ReadWriteHandler{
9190 ".md": posts,
9291 ".txt": posts,
+41, -0
......@@ -4,6 +4,8 @@ import (
44 "context"
55 "fmt"
66 "log/slog"
7+ "slices"
8+ "strings"
79 "testing"
810 "time"
911
......@@ -11,6 +13,7 @@ import (
1113 "github.com/picosh/pico/pkg/db/stub"
1214 "github.com/picosh/pico/pkg/filehandlers"
1315 "github.com/picosh/pico/pkg/pssh"
16+ sendutils "github.com/picosh/pico/pkg/send/utils"
1417 "github.com/picosh/pico/pkg/shared"
1518 "golang.org/x/crypto/ssh"
1619 )
......@@ -108,3 +111,41 @@ func TestUploadHandlerValidate(t *testing.T) {
108111 }
109112 })
110113 }
114+
115+func TestPostWriteTagsAndAliases(t *testing.T) {
116+ logger := slog.New(slog.DiscardHandler)
117+ session := &pssh.SSHServerConnSession{
118+ SSHServerConn: &pssh.SSHServerConn{
119+ Conn: &ssh.ServerConn{Permissions: &ssh.Permissions{Extensions: map[string]string{}}},
120+ Logger: logger,
121+ },
122+ Ctx: context.Background(),
123+ }
124+ user := &db.User{ID: "user-1", Name: "tester"}
125+ pssh.SetUser(session, user)
126+ posts := stub.NewPostsDB(logger, user)
127+ cfg := NewConfigSite("prose-test")
128+ h := &postUploader{Cfg: cfg, DB: posts}
129+ write := func(text string, mtime int64) *db.Post {
130+ t.Helper()
131+ _, err := h.Write(session, &sendutils.FileEntry{Filepath: "/hello.md", Reader: strings.NewReader(text), Mtime: mtime})
132+ if err != nil {
133+ t.Fatal(err)
134+ }
135+ post, err := posts.FindPostWithFilename("hello.md", user.ID, cfg.Space)
136+ if err != nil {
137+ t.Fatal(err)
138+ }
139+ return post
140+ }
141+
142+ post := write("---\ntitle: Hello\ntags: [a, b]\naliases: [hi]\n---\n# hello\n", 100)
143+ if post.Title != "Hello" || !slices.Equal(post.Tags, []string{"a", "b"}) || !slices.Equal(posts.Aliases(post.ID), []string{"hi"}) {
144+ t.Errorf("new post = %q tags %q aliases %q", post.Title, post.Tags, posts.Aliases(post.ID))
145+ }
146+
147+ post = write("# hello again\n", 200)
148+ if len(post.Tags) != 0 || len(posts.Aliases(post.ID)) != 0 {
149+ t.Errorf("updated post kept tags %q and aliases %q", post.Tags, posts.Aliases(post.ID))
150+ }
151+}
+51, -31
......@@ -576,31 +576,36 @@ var DenyList = []string{
576576 "nue",
577577 }
578578
579-type DB interface {
579+type UserDB interface {
580580 RegisterUser(name, pubkey, comment, ip string) (*User, error)
581- UpdatePublicKey(pubkeyID, name string) (*PublicKey, error)
582- InsertPublicKey(userID, pubkey, name string) error
583- FindKeysByUser(user *User) ([]*PublicKey, error)
584- RemoveKeys(pubkeyIDs []string) error
585-
586581 FindUsers() ([]*User, error)
587582 FindUserByName(name string) (*User, error)
588583 FindUserByKey(name string, pubkey string) (*User, error)
589584 FindUserByPubkey(pubkey string) (*User, error)
590585 FindUser(userID string) (*User, error)
586+ FindUserStats(userID string) (*UserStats, error)
587+}
591588
589+type KeyDB interface {
590+ UpdatePublicKey(pubkeyID, name string) (*PublicKey, error)
591+ InsertPublicKey(userID, pubkey, name string) error
592+ FindKeysByUser(user *User) ([]*PublicKey, error)
593+ RemoveKeys(pubkeyIDs []string) error
594+}
595+
596+type TokenDB interface {
592597 FindUserByToken(token string) (*User, error)
593598 FindTokensByUser(userID string) ([]*Token, error)
594599 InsertToken(userID, name string) (string, error)
595600 UpsertToken(userID, name string) (string, error)
596601 RemoveToken(tokenID string) error
602+}
597603
598- FindPosts() ([]*Post, error)
599- FindPost(postID string) (*Post, error)
604+// PostDB is the posts storage that prose, pastes and feeds share. Each
605+// keeps its posts in its own space.
606+type PostDB interface {
600607 FindPostsByUser(pager *Pager, userID string, space string) (*Paginate[*Post], error)
601608 FindAllPostsByUser(userID string, space string) ([]*Post, error)
602- FindUsersWithPost(space string) ([]*User, error)
603- FindExpiredPosts(space string) ([]*Post, error)
604609 FindPostWithFilename(filename string, userID string, space string) (*Post, error)
605610 FindPostWithSlug(slug string, userID string, space string) (*Post, error)
606611 InsertPost(post *Post) (*Post, error)
......@@ -612,62 +617,77 @@ type DB interface {
612617 // longer matches what the caller read.
613618 RenamePost(cur, next, replaced *Post, tags, aliases []string) (*Post, error)
614619 RemovePosts(postIDs []string) error
620+}
615621
616- FindPopularPosts() ([]*Post, error)
617-
622+type TagDB interface {
618623 ReplaceTagsByPost(tags []string, postID string) error
619- FindUserPostsByTag(pager *Pager, tag, userID, space string) (*Paginate[*Post], error)
620- FindPostsByTag(pager *Pager, tag, space string) (*Paginate[*Post], error)
621- FindPopularTags(space string) ([]string, error)
622624 ReplaceAliasesByPost(aliases []string, postID string) error
625+}
623626
627+type AnalyticsDB interface {
624628 InsertVisit(view *AnalyticsVisits) error
625629 VisitSummary(opts *SummaryOpts) (*SummaryVisits, error)
626630 FindVisitSiteList(opts *SummaryOpts) ([]*VisitUrl, error)
627631 VisitUrlNotFound(opts *SummaryOpts) ([]*VisitUrl, error)
632+}
628633
634+type FeatureDB interface {
629635 AddPicoPlusUser(username, email, paymentType, txId string) error
630636 AddFeatureUser(username, name string) error
631637 FindFeature(userID string, feature string) (*FeatureFlag, error)
632638 FindFeaturesByUser(userID string) ([]*FeatureFlag, error)
633639 HasFeatureByUser(userID string, feature string) bool
634-
635640 InsertFeature(userID, name string, expiresAt time.Time) (*FeatureFlag, error)
636641 RemoveFeature(userID, names string) error
642+}
637643
638- InsertFeedItems(postID string, items []*FeedItem) error
639- FindFeedItemsByPostID(postID string) ([]*FeedItem, error)
640-
644+type ProjectDB interface {
641645 UpsertProject(userID, name, projectDir string) (*Project, error)
642646 FindProjectByName(userID, name string) (*Project, error)
647+}
643648
644- FindUserStats(userID string) (*UserStats, error)
645-
649+type TunsDB interface {
646650 InsertTunsEventLog(log *TunsEventLog) error
647651 FindTunsEventLogs(userID string) ([]*TunsEventLog, error)
648652 FindTunsEventLogsByAddr(userID, addr string) ([]*TunsEventLog, error)
653+}
649654
655+type AccessLogDB interface {
650656 InsertAccessLog(log *AccessLog) error
651657 FindAccessLogs(userID string, fromDate *time.Time) ([]*AccessLog, error)
652658 FindPubkeysInAccessLogs(userID string) ([]string, error)
653659 FindAccessLogsByPubkey(pubkey string, fromDate *time.Time) ([]*AccessLog, error)
660+}
654661
655- UpsertPipeMonitor(userID, topic string, dur time.Duration, winEnd *time.Time) error
656- UpdatePipeMonitorLastPing(userID, topic string, lastPing *time.Time) error
657- RemovePipeMonitor(userID, topic string) error
658- FindPipeMonitorByTopic(userID, topic string) (*PipeMonitor, error)
659- FindPipeMonitorsByUser(userID string) ([]*PipeMonitor, error)
660-
661- InsertPipeMonitorHistory(monitorID string, windowDur time.Duration, windowEnd, lastPing *time.Time) error
662- FindPipeMonitorHistory(monitorID string, from, to time.Time) ([]*PipeMonitorHistory, error)
663-
662+type InviteDB interface {
664663 FindWhoInvitedUser(toUserID string) (string, error)
665664 FindInvitesByUser(userID string) ([]*Invite, error)
666665 InviteUser(fromUserID string, toUserID string) error
666+}
667+
668+// DB is the storage apps share. Each app declares the subset it uses, and
669+// keeps the queries only it makes in its own package.
670+type DB interface {
671+ UserDB
672+ KeyDB
673+ TokenDB
674+ PostDB
675+ TagDB
676+ AnalyticsDB
677+ FeatureDB
678+ ProjectDB
679+ TunsDB
680+ AccessLogDB
681+ InviteDB
682+
683+ FindPosts() ([]*Post, error)
684+ FindPost(postID string) (*Post, error)
667685
668686 Close() error
669687 }
670688
671-func HasPlusOrSpace(dbpool DB, userID, space string) bool {
689+func HasPlusOrSpace(dbpool interface {
690+ HasFeatureByUser(userID string, feature string) bool
691+}, userID, space string) bool {
672692 return dbpool.HasFeatureByUser(userID, "plus") || dbpool.HasFeatureByUser(userID, space)
673693 }
+45, -0
......@@ -0,0 +1,45 @@
1+package postgres
2+
3+import (
4+ "time"
5+
6+ "github.com/picosh/pico/pkg/db"
7+)
8+
9+func (me *PsqlDB) FindAccessLogs(userID string, fromDate *time.Time) ([]*db.AccessLog, error) {
10+ var logs []*db.AccessLog
11+ err := me.Db.Select(&logs, `SELECT * FROM access_logs WHERE user_id=$1 AND created_at >= $2 ORDER BY created_at ASC`, userID, fromDate)
12+ if err != nil {
13+ return nil, err
14+ }
15+ return logs, nil
16+}
17+
18+func (me *PsqlDB) FindAccessLogsByPubkey(pubkey string, fromDate *time.Time) ([]*db.AccessLog, error) {
19+ var logs []*db.AccessLog
20+ err := me.Db.Select(&logs, `SELECT * FROM access_logs WHERE pubkey=$1 AND created_at >= $2 ORDER BY created_at ASC`, pubkey, fromDate)
21+ if err != nil {
22+ return nil, err
23+ }
24+ return logs, nil
25+}
26+
27+func (me *PsqlDB) FindPubkeysInAccessLogs(userID string) ([]string, error) {
28+ var pubkeys []string
29+ err := me.Db.Select(&pubkeys, `SELECT DISTINCT(pubkey) FROM access_logs WHERE user_id=$1`, userID)
30+ if err != nil {
31+ return nil, err
32+ }
33+ return pubkeys, nil
34+}
35+
36+func (me *PsqlDB) InsertAccessLog(log *db.AccessLog) error {
37+ _, err := me.Db.Exec(
38+ `INSERT INTO access_logs (user_id, service, pubkey, identity) VALUES ($1, $2, $3, $4);`,
39+ log.UserID,
40+ log.Service,
41+ log.Pubkey,
42+ log.Identity,
43+ )
44+ return err
45+}
+760, -0
......@@ -0,0 +1,760 @@
1+package postgres
2+
3+import (
4+ "fmt"
5+ "sort"
6+ "sync"
7+ "time"
8+
9+ "github.com/picosh/pico/pkg/db"
10+)
11+
12+// mobileUserAgentExpr is a SQL expression to detect mobile user agents.
13+const mobileUserAgentExpr = `user_agent ILIKE '%mobile%' OR user_agent ILIKE '%android%' OR user_agent ILIKE '%iphone%' OR user_agent ILIKE '%ipad%' OR user_agent ILIKE '%ipod%' OR user_agent ILIKE '%blackberry%' OR user_agent ILIKE '%windows phone%'`
14+
15+func (me *PsqlDB) InsertVisit(visit *db.AnalyticsVisits) error {
16+ _, err := me.Db.Exec(
17+ `INSERT INTO analytics_visits (user_id, project_id, post_id, namespace, host, path, ip_address, user_agent, referer, status, content_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);`,
18+ visit.UserID,
19+ newNullString(visit.ProjectID),
20+ newNullString(visit.PostID),
21+ newNullString(visit.Namespace),
22+ visit.Host,
23+ visit.Path,
24+ visit.IpAddress,
25+ visit.UserAgent,
26+ visit.Referer,
27+ visit.Status,
28+ visit.ContentType,
29+ )
30+ return err
31+}
32+
33+func visitFilterBy(opts *db.SummaryOpts) (string, string) {
34+ where := ""
35+ val := ""
36+ if opts.Host != "" {
37+ where = "host"
38+ val = opts.Host
39+ } else if opts.Path != "" {
40+ where = "path"
41+ val = opts.Path
42+ }
43+
44+ return where, val
45+}
46+
47+func (me *PsqlDB) visitUnique(opts *db.SummaryOpts) ([]*db.VisitInterval, error) {
48+ var intervals, currentIntervals []*db.VisitInterval
49+ var sumErr, rawErr error
50+
51+ var wg sync.WaitGroup
52+ wg.Add(2)
53+
54+ go func() {
55+ defer wg.Done()
56+ intervals, sumErr = me.visitUniqueFromSummary(opts)
57+ }()
58+ go func() {
59+ defer wg.Done()
60+ currentIntervals, rawErr = me.visitUniqueFromRaw(opts)
61+ }()
62+
63+ wg.Wait()
64+
65+ if sumErr != nil {
66+ return nil, fmt.Errorf("query summary visits: %w", sumErr)
67+ }
68+ if rawErr != nil {
69+ return nil, fmt.Errorf("query raw visits: %w", rawErr)
70+ }
71+
72+ // Merge: current month data may overlap with summary data, combine counts
73+ return mergeVisitIntervals(intervals, currentIntervals), nil
74+}
75+
76+// visitUniqueFromSummary reads unique visitor counts from analytics_monthly_visits for historical data.
77+func (me *PsqlDB) visitUniqueFromSummary(opts *db.SummaryOpts) ([]*db.VisitInterval, error) {
78+ now := time.Now()
79+ currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
80+ previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
81+
82+ // If origin is in the previous month or later, raw data covers it — no summary to fetch.
83+ if !opts.Origin.Before(previousMonthStart) {
84+ return nil, nil
85+ }
86+
87+ where := ""
88+ args := []interface{}{opts.UserID, opts.Origin, currentMonthStart}
89+ argIdx := 4
90+ if opts.Host != "" {
91+ where = "AND host = $" + fmt.Sprintf("%d", argIdx)
92+ args = append(args, opts.Host)
93+ }
94+
95+ query := fmt.Sprintf(`
96+ SELECT
97+ date_trunc('%s', visit_date)::timestamptz as interval_start,
98+ sum(unique_visits) as unique_visitors,
99+ sum(mobile_visits) as mobile_visits,
100+ sum(desktop_visits) as desktop_visits
101+ FROM analytics_monthly_visits
102+ WHERE user_id = $1 AND visit_date >= $2 AND visit_date < $3 %s
103+ GROUP BY interval_start
104+ ORDER BY interval_start`, opts.Interval, where)
105+
106+ rows, err := me.Db.Queryx(query, args...)
107+ if err != nil {
108+ return nil, err
109+ }
110+ defer func() { _ = rows.Close() }()
111+
112+ var intervals []*db.VisitInterval
113+ for rows.Next() {
114+ interval := &db.VisitInterval{}
115+ if err := rows.Scan(&interval.Interval, &interval.Visitors, &interval.MobileVisitors, &interval.DesktopVisitors); err != nil {
116+ return nil, err
117+ }
118+ intervals = append(intervals, interval)
119+ }
120+ return intervals, rows.Err()
121+}
122+
123+// visitUniqueFromRaw reads unique visitor counts from analytics_visits for the previous and current months.
124+// This covers the gap between the last aggregated month and the current month.
125+func (me *PsqlDB) visitUniqueFromRaw(opts *db.SummaryOpts) ([]*db.VisitInterval, error) {
126+ now := time.Now()
127+ currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
128+ previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
129+
130+ where, with := visitFilterBy(opts)
131+
132+ // Determine the effective start: max(origin, previousMonthStart)
133+ effectiveStart := previousMonthStart
134+ if opts.Origin.After(previousMonthStart) {
135+ effectiveStart = opts.Origin
136+ }
137+
138+ uniqueVisitors := fmt.Sprintf(`
139+ SELECT
140+ date_trunc('%s', created_at)::timestamptz as interval_start,
141+ count(DISTINCT CASE WHEN %s THEN ip_address END) as mobile_visitors,
142+ count(DISTINCT CASE WHEN NOT %s THEN ip_address END) as desktop_visitors,
143+ count(DISTINCT ip_address) as unique_visitors
144+ FROM analytics_visits
145+ WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND status <> 404
146+ GROUP BY interval_start
147+ ORDER BY interval_start`,
148+ opts.Interval, mobileUserAgentExpr, mobileUserAgentExpr, where)
149+
150+ rows, err := me.Db.Queryx(uniqueVisitors, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
151+ if err != nil {
152+ return nil, err
153+ }
154+ defer func() { _ = rows.Close() }()
155+
156+ var intervals []*db.VisitInterval
157+ for rows.Next() {
158+ interval := &db.VisitInterval{}
159+ if err := rows.Scan(&interval.Interval, &interval.MobileVisitors, &interval.DesktopVisitors, &interval.Visitors); err != nil {
160+ return nil, err
161+ }
162+ intervals = append(intervals, interval)
163+ }
164+ return intervals, rows.Err()
165+}
166+
167+// mergeVisitIntervals combines historical (summary table) and current (raw) intervals.
168+// Summary data is preferred when both sources have the same interval to avoid double-counting.
169+func mergeVisitIntervals(historical, current []*db.VisitInterval) []*db.VisitInterval {
170+ if len(historical) == 0 {
171+ return current
172+ }
173+ if len(current) == 0 {
174+ return historical
175+ }
176+
177+ // Build a map by interval timestamp for merging.
178+ // Summary data takes precedence over raw data for the same interval
179+ // (e.g. when a month is aggregated but raw data still exists in a local dump).
180+ intervalMap := make(map[int64]*db.VisitInterval)
181+ for _, ci := range current {
182+ ts := ci.Interval.Unix()
183+ intervalMap[ts] = ci
184+ }
185+ for _, hi := range historical {
186+ ts := hi.Interval.Unix()
187+ intervalMap[ts] = hi // summary overwrites raw if both exist
188+ }
189+
190+ // Sort by interval
191+ result := make([]*db.VisitInterval, 0, len(intervalMap))
192+ for _, iv := range intervalMap {
193+ result = append(result, iv)
194+ }
195+ sort.Slice(result, func(i, j int) bool {
196+ return result[i].Interval.Before(*result[j].Interval)
197+ })
198+ return result
199+}
200+
201+// mergeTopUrls combines historical and current top URLs, summing counts for overlapping URLs,
202+// then returns the top 10 by total count.
203+func mergeTopUrls(historical, current []*db.VisitUrl) []*db.VisitUrl {
204+ if len(historical) == 0 {
205+ return current
206+ }
207+ if len(current) == 0 {
208+ return historical
209+ }
210+
211+ // Build a map by URL for merging
212+ urlMap := make(map[string]*db.VisitUrl)
213+ for _, hu := range historical {
214+ urlMap[hu.Url] = hu
215+ }
216+
217+ for _, cu := range current {
218+ if existing, ok := urlMap[cu.Url]; ok {
219+ existing.Count += cu.Count
220+ } else {
221+ urlMap[cu.Url] = cu
222+ }
223+ }
224+
225+ // Sort by count descending and take top 10
226+ result := make([]*db.VisitUrl, 0, len(urlMap))
227+ for _, u := range urlMap {
228+ result = append(result, u)
229+ }
230+ sort.Slice(result, func(i, j int) bool {
231+ return result[i].Count > result[j].Count
232+ })
233+ if len(result) > 10 {
234+ result = result[:10]
235+ }
236+ return result
237+}
238+
239+// mergeTopReferers combines historical and current top referers, summing counts for overlapping referers,
240+// then returns the top 10 by total count.
241+func mergeTopReferers(historical, current []*db.VisitUrl) []*db.VisitUrl {
242+ return mergeTopUrls(historical, current) // Same logic as mergeTopUrls
243+}
244+
245+// mergeHosts combines historical and current hosts, summing counts for overlapping hosts,
246+// then returns sorted by total count descending.
247+func mergeHosts(historical, current []*db.VisitUrl) []*db.VisitUrl {
248+ if len(historical) == 0 {
249+ return current
250+ }
251+ if len(current) == 0 {
252+ return historical
253+ }
254+
255+ // Build a map by host for merging
256+ hostMap := make(map[string]*db.VisitUrl)
257+ for _, hu := range historical {
258+ hostMap[hu.Url] = hu
259+ }
260+
261+ for _, cu := range current {
262+ if existing, ok := hostMap[cu.Url]; ok {
263+ existing.Count += cu.Count
264+ } else {
265+ hostMap[cu.Url] = cu
266+ }
267+ }
268+
269+ // Sort by count descending
270+ result := make([]*db.VisitUrl, 0, len(hostMap))
271+ for _, h := range hostMap {
272+ result = append(result, h)
273+ }
274+ sort.Slice(result, func(i, j int) bool {
275+ return result[i].Count > result[j].Count
276+ })
277+ return result
278+}
279+
280+func (me *PsqlDB) visitReferer(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
281+ var historical, current []*db.VisitUrl
282+ var histErr, rawErr error
283+
284+ var wg sync.WaitGroup
285+ wg.Add(2)
286+
287+ go func() {
288+ defer wg.Done()
289+ historical, histErr = me.visitRefererFromSummary(opts)
290+ }()
291+ go func() {
292+ defer wg.Done()
293+ current, rawErr = me.visitRefererFromRaw(opts)
294+ }()
295+
296+ wg.Wait()
297+
298+ if histErr != nil {
299+ return nil, fmt.Errorf("query summary referers: %w", histErr)
300+ }
301+ if rawErr != nil {
302+ return nil, fmt.Errorf("query raw referers: %w", rawErr)
303+ }
304+
305+ return mergeTopReferers(historical, current), nil
306+}
307+
308+// visitRefererFromSummary reads top referers from analytics_monthly_top_referers for historical data.
309+func (me *PsqlDB) visitRefererFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
310+ now := time.Now()
311+ currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
312+ previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
313+
314+ // If origin is in the previous month or later, raw data covers it — no summary to fetch.
315+ if !opts.Origin.Before(previousMonthStart) {
316+ return nil, nil
317+ }
318+
319+ // Clamp origin to month boundary for summary table lookup
320+ originMonthStart := time.Date(opts.Origin.Year(), opts.Origin.Month(), 1, 0, 0, 0, 0, time.UTC)
321+
322+ where := ""
323+ args := []interface{}{opts.UserID, originMonthStart, currentMonthStart}
324+ if opts.Host != "" {
325+ where = "AND host = $4"
326+ args = append(args, opts.Host)
327+ }
328+
329+ query := fmt.Sprintf(`
330+ SELECT referer, sum(unique_visits) as total_visits
331+ FROM analytics_monthly_top_referers
332+ WHERE user_id = $1 AND month >= $2 AND month < $3 %s
333+ GROUP BY referer
334+ ORDER BY total_visits DESC
335+ LIMIT 10`, where)
336+
337+ rows, err := me.Db.Queryx(query, args...)
338+ if err != nil {
339+ return nil, err
340+ }
341+ defer func() { _ = rows.Close() }()
342+
343+ var results []*db.VisitUrl
344+ for rows.Next() {
345+ result := &db.VisitUrl{}
346+ if err := rows.Scan(&result.Url, &result.Count); err != nil {
347+ return nil, err
348+ }
349+ results = append(results, result)
350+ }
351+ return results, rows.Err()
352+}
353+
354+// visitRefererFromRaw reads top referers from analytics_visits for the previous and current months.
355+func (me *PsqlDB) visitRefererFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
356+ now := time.Now()
357+ currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
358+ previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
359+
360+ where, with := visitFilterBy(opts)
361+
362+ // Determine the effective start: max(origin, previousMonthStart)
363+ effectiveStart := previousMonthStart
364+ if opts.Origin.After(previousMonthStart) {
365+ effectiveStart = opts.Origin
366+ }
367+
368+ topUrls := fmt.Sprintf(`
369+ SELECT
370+ referer,
371+ count(DISTINCT ip_address) as referer_count
372+ FROM analytics_visits
373+ WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND referer <> '' AND status <> 404
374+ GROUP BY referer
375+ ORDER BY referer_count DESC
376+ LIMIT 10`, where)
377+
378+ rows, err := me.Db.Queryx(topUrls, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
379+ if err != nil {
380+ return nil, err
381+ }
382+ defer func() { _ = rows.Close() }()
383+
384+ var results []*db.VisitUrl
385+ for rows.Next() {
386+ result := &db.VisitUrl{}
387+ if err := rows.Scan(&result.Url, &result.Count); err != nil {
388+ return nil, err
389+ }
390+ results = append(results, result)
391+ }
392+ return results, rows.Err()
393+}
394+
395+func (me *PsqlDB) visitUrl(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
396+ var historical, current []*db.VisitUrl
397+ var histErr, rawErr error
398+
399+ var wg sync.WaitGroup
400+ wg.Add(2)
401+
402+ go func() {
403+ defer wg.Done()
404+ historical, histErr = me.visitUrlFromSummary(opts)
405+ }()
406+ go func() {
407+ defer wg.Done()
408+ current, rawErr = me.visitUrlFromRaw(opts)
409+ }()
410+
411+ wg.Wait()
412+
413+ if histErr != nil {
414+ return nil, fmt.Errorf("query summary urls: %w", histErr)
415+ }
416+ if rawErr != nil {
417+ return nil, fmt.Errorf("query raw urls: %w", rawErr)
418+ }
419+
420+ return mergeTopUrls(historical, current), nil
421+}
422+
423+// visitUrlFromSummary reads top URLs from analytics_monthly_top_urls for historical data.
424+func (me *PsqlDB) visitUrlFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
425+ now := time.Now()
426+ currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
427+ previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
428+
429+ // If origin is in the previous month or later, raw data covers it — no summary to fetch.
430+ if !opts.Origin.Before(previousMonthStart) {
431+ return nil, nil
432+ }
433+
434+ // Clamp origin to month boundary for summary table lookup
435+ originMonthStart := time.Date(opts.Origin.Year(), opts.Origin.Month(), 1, 0, 0, 0, 0, time.UTC)
436+
437+ where := ""
438+ args := []interface{}{opts.UserID, originMonthStart, currentMonthStart}
439+ if opts.Host != "" {
440+ where = "AND host = $4"
441+ args = append(args, opts.Host)
442+ }
443+
444+ query := fmt.Sprintf(`
445+ SELECT path, sum(unique_visits) as total_visits
446+ FROM analytics_monthly_top_urls
447+ WHERE user_id = $1 AND month >= $2 AND month < $3 AND status_code <> 404 %s
448+ GROUP BY path
449+ ORDER BY total_visits DESC
450+ LIMIT 10`, where)
451+
452+ rows, err := me.Db.Queryx(query, args...)
453+ if err != nil {
454+ return nil, err
455+ }
456+ defer func() { _ = rows.Close() }()
457+
458+ var results []*db.VisitUrl
459+ for rows.Next() {
460+ result := &db.VisitUrl{}
461+ if err := rows.Scan(&result.Url, &result.Count); err != nil {
462+ return nil, err
463+ }
464+ results = append(results, result)
465+ }
466+ return results, rows.Err()
467+}
468+
469+// visitUrlFromRaw reads top URLs from analytics_visits for the previous and current months.
470+func (me *PsqlDB) visitUrlFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
471+ now := time.Now()
472+ currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
473+ previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
474+
475+ where, with := visitFilterBy(opts)
476+
477+ // Determine the effective start: max(origin, previousMonthStart)
478+ effectiveStart := previousMonthStart
479+ if opts.Origin.After(previousMonthStart) {
480+ effectiveStart = opts.Origin
481+ }
482+
483+ topUrls := fmt.Sprintf(`
484+ SELECT
485+ path,
486+ count(DISTINCT ip_address) as path_count
487+ FROM analytics_visits
488+ WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND path <> '' AND status <> 404
489+ GROUP BY path
490+ ORDER BY path_count DESC
491+ LIMIT 10`, where)
492+
493+ rows, err := me.Db.Queryx(topUrls, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
494+ if err != nil {
495+ return nil, err
496+ }
497+ defer func() { _ = rows.Close() }()
498+
499+ var results []*db.VisitUrl
500+ for rows.Next() {
501+ result := &db.VisitUrl{}
502+ if err := rows.Scan(&result.Url, &result.Count); err != nil {
503+ return nil, err
504+ }
505+ results = append(results, result)
506+ }
507+ return results, rows.Err()
508+}
509+
510+func (me *PsqlDB) VisitUrlNotFound(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
511+ limit := opts.Limit
512+ if limit == 0 {
513+ limit = 10
514+ }
515+
516+ var historical, current []*db.VisitUrl
517+ var histErr, rawErr error
518+
519+ var wg sync.WaitGroup
520+ wg.Add(2)
521+
522+ go func() {
523+ defer wg.Done()
524+ historical, histErr = me.visitUrlNotFoundFromSummary(opts, limit)
525+ }()
526+ go func() {
527+ defer wg.Done()
528+ current, rawErr = me.visitUrlNotFoundFromRaw(opts, limit)
529+ }()
530+
531+ wg.Wait()
532+
533+ if histErr != nil {
534+ return nil, fmt.Errorf("query summary 404 urls: %w", histErr)
535+ }
536+ if rawErr != nil {
537+ return nil, fmt.Errorf("query raw 404 urls: %w", rawErr)
538+ }
539+
540+ return mergeTopUrls(historical, current), nil
541+}
542+
543+// visitUrlNotFoundFromSummary reads top 404 URLs from analytics_monthly_top_urls for historical data.
544+func (me *PsqlDB) visitUrlNotFoundFromSummary(opts *db.SummaryOpts, limit int) ([]*db.VisitUrl, error) {
545+ now := time.Now()
546+ currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
547+ previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
548+
549+ // If origin is in the previous month or later, raw data covers it — no summary to fetch.
550+ if !opts.Origin.Before(previousMonthStart) {
551+ return nil, nil
552+ }
553+
554+ // Clamp origin to month boundary for summary table lookup
555+ originMonthStart := time.Date(opts.Origin.Year(), opts.Origin.Month(), 1, 0, 0, 0, 0, time.UTC)
556+
557+ where := ""
558+ args := []interface{}{opts.UserID, originMonthStart, currentMonthStart}
559+ argIdx := 4
560+ if opts.Host != "" {
561+ where = "AND host = $" + fmt.Sprintf("%d", argIdx)
562+ args = append(args, opts.Host)
563+ }
564+
565+ query := fmt.Sprintf(`
566+ SELECT path, sum(unique_visits) as total_visits
567+ FROM analytics_monthly_top_urls
568+ WHERE user_id = $1 AND month >= $2 AND month < $3 AND status_code = 404 %s
569+ GROUP BY path
570+ ORDER BY total_visits DESC
571+ LIMIT %d`, where, limit)
572+
573+ rows, err := me.Db.Queryx(query, args...)
574+ if err != nil {
575+ return nil, err
576+ }
577+ defer func() { _ = rows.Close() }()
578+
579+ var results []*db.VisitUrl
580+ for rows.Next() {
581+ result := &db.VisitUrl{}
582+ if err := rows.Scan(&result.Url, &result.Count); err != nil {
583+ return nil, err
584+ }
585+ results = append(results, result)
586+ }
587+ return results, rows.Err()
588+}
589+
590+// visitUrlNotFoundFromRaw reads top 404 URLs from analytics_visits for the previous and current months.
591+func (me *PsqlDB) visitUrlNotFoundFromRaw(opts *db.SummaryOpts, limit int) ([]*db.VisitUrl, error) {
592+ now := time.Now()
593+ currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
594+ previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
595+
596+ where, with := visitFilterBy(opts)
597+
598+ // Determine the effective start: max(origin, previousMonthStart)
599+ effectiveStart := previousMonthStart
600+ if opts.Origin.After(previousMonthStart) {
601+ effectiveStart = opts.Origin
602+ }
603+
604+ topUrls := fmt.Sprintf(`
605+ SELECT
606+ path,
607+ count(DISTINCT ip_address) as path_count
608+ FROM analytics_visits
609+ WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND path <> '' AND status = 404
610+ GROUP BY path
611+ ORDER BY path_count DESC
612+ LIMIT %d`, where, limit)
613+
614+ rows, err := me.Db.Queryx(topUrls, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
615+ if err != nil {
616+ return nil, err
617+ }
618+ defer func() { _ = rows.Close() }()
619+
620+ var results []*db.VisitUrl
621+ for rows.Next() {
622+ result := &db.VisitUrl{}
623+ if err := rows.Scan(&result.Url, &result.Count); err != nil {
624+ return nil, err
625+ }
626+ results = append(results, result)
627+ }
628+ return results, rows.Err()
629+}
630+
631+func (me *PsqlDB) visitHost(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
632+ var historical, current []*db.VisitUrl
633+ var histErr, rawErr error
634+
635+ var wg sync.WaitGroup
636+ wg.Add(2)
637+
638+ go func() {
639+ defer wg.Done()
640+ historical, histErr = me.visitHostFromSummary(opts)
641+ }()
642+ go func() {
643+ defer wg.Done()
644+ current, rawErr = me.visitHostFromRaw(opts)
645+ }()
646+
647+ wg.Wait()
648+
649+ if histErr != nil {
650+ return nil, fmt.Errorf("query summary hosts: %w", histErr)
651+ }
652+ if rawErr != nil {
653+ return nil, fmt.Errorf("query raw hosts: %w", rawErr)
654+ }
655+
656+ return mergeHosts(historical, current), nil
657+}
658+
659+// visitHostFromSummary reads host data from analytics_user_sites for historical data.
660+func (me *PsqlDB) visitHostFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
661+ rows, err := me.Db.Queryx(`
662+ SELECT host, total_visits
663+ FROM analytics_user_sites
664+ WHERE user_id = $1 AND host <> ''
665+ ORDER BY total_visits DESC`, opts.UserID)
666+ if err != nil {
667+ return nil, err
668+ }
669+ defer func() { _ = rows.Close() }()
670+
671+ var results []*db.VisitUrl
672+ for rows.Next() {
673+ result := &db.VisitUrl{}
674+ if err := rows.Scan(&result.Url, &result.Count); err != nil {
675+ return nil, err
676+ }
677+ results = append(results, result)
678+ }
679+ return results, rows.Err()
680+}
681+
682+// visitHostFromRaw reads hosts from analytics_visits for the current month that aren't in summary.
683+func (me *PsqlDB) visitHostFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
684+ now := time.Now()
685+ currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
686+
687+ rows, err := me.Db.Queryx(`
688+ SELECT host, count(DISTINCT ip_address) as host_count
689+ FROM analytics_visits
690+ WHERE created_at >= $1 AND user_id = $2 AND host <> ''
691+ GROUP BY host
692+ ORDER BY host_count DESC`, currentMonthStart, opts.UserID)
693+ if err != nil {
694+ return nil, err
695+ }
696+ defer func() { _ = rows.Close() }()
697+
698+ var results []*db.VisitUrl
699+ for rows.Next() {
700+ result := &db.VisitUrl{}
701+ if err := rows.Scan(&result.Url, &result.Count); err != nil {
702+ return nil, err
703+ }
704+ results = append(results, result)
705+ }
706+ return results, rows.Err()
707+}
708+
709+func (me *PsqlDB) VisitSummary(opts *db.SummaryOpts) (*db.SummaryVisits, error) {
710+ var (
711+ visitors []*db.VisitInterval
712+ urls []*db.VisitUrl
713+ refs []*db.VisitUrl
714+ notFound []*db.VisitUrl
715+ visitorsErr error
716+ urlsErr error
717+ refsErr error
718+ nfErr error
719+ )
720+
721+ var wg sync.WaitGroup
722+ wg.Add(4)
723+
724+ go func() {
725+ defer wg.Done()
726+ visitors, visitorsErr = me.visitUnique(opts)
727+ }()
728+ go func() {
729+ defer wg.Done()
730+ urls, urlsErr = me.visitUrl(opts)
731+ }()
732+ go func() {
733+ defer wg.Done()
734+ refs, refsErr = me.visitReferer(opts)
735+ }()
736+ go func() {
737+ defer wg.Done()
738+ notFound, nfErr = me.VisitUrlNotFound(opts)
739+ }()
740+
741+ wg.Wait()
742+
743+ // Return the first error encountered
744+ for _, err := range []error{visitorsErr, urlsErr, refsErr, nfErr} {
745+ if err != nil {
746+ return nil, err
747+ }
748+ }
749+
750+ return &db.SummaryVisits{
751+ Intervals: visitors,
752+ TopUrls: urls,
753+ TopReferers: refs,
754+ NotFoundUrls: notFound,
755+ }, nil
756+}
757+
758+func (me *PsqlDB) FindVisitSiteList(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
759+ return me.visitHost(opts)
760+}
+130, -0
......@@ -0,0 +1,130 @@
1+package postgres
2+
3+import (
4+ "database/sql"
5+ "fmt"
6+ "time"
7+
8+ "github.com/picosh/pico/pkg/db"
9+)
10+
11+func (me *PsqlDB) FindFeature(userID string, feature string) (*db.FeatureFlag, error) {
12+ ff := &db.FeatureFlag{}
13+ err := me.Db.Get(ff, `SELECT * FROM feature_flags WHERE user_id = $1 AND name = $2 ORDER BY expires_at DESC LIMIT 1`, userID, feature)
14+ if err != nil {
15+ return nil, err
16+ }
17+ return ff, nil
18+}
19+
20+func (me *PsqlDB) FindFeaturesByUser(userID string) ([]*db.FeatureFlag, error) {
21+ var features []*db.FeatureFlag
22+ // https://stackoverflow.com/a/16920077
23+ query := `SELECT DISTINCT ON (name) *
24+ FROM feature_flags
25+ WHERE user_id=$1
26+ ORDER BY name, expires_at DESC;`
27+ err := me.Db.Select(&features, query, userID)
28+ if err != nil {
29+ return nil, err
30+ }
31+ return features, nil
32+}
33+
34+func (me *PsqlDB) HasFeatureByUser(userID string, feature string) bool {
35+ ff, err := me.FindFeature(userID, feature)
36+ if err != nil {
37+ return false
38+ }
39+ return ff.IsValid()
40+}
41+
42+func (me *PsqlDB) InsertFeature(userID, name string, expiresAt time.Time) (*db.FeatureFlag, error) {
43+ var featureID string
44+ err := me.Db.QueryRow(
45+ `INSERT INTO feature_flags (user_id, name, expires_at) VALUES ($1, $2, $3) RETURNING id;`,
46+ userID,
47+ name,
48+ expiresAt,
49+ ).Scan(&featureID)
50+ if err != nil {
51+ return nil, err
52+ }
53+
54+ feature, err := me.FindFeature(userID, name)
55+ if err != nil {
56+ return nil, err
57+ }
58+
59+ return feature, nil
60+}
61+
62+func (me *PsqlDB) RemoveFeature(userID string, name string) error {
63+ _, err := me.Db.Exec(`DELETE FROM feature_flags WHERE user_id = $1 AND name = $2`, userID, name)
64+ return err
65+}
66+
67+func (me *PsqlDB) createFeatureExpiresAt(userID, name string) time.Time {
68+ ff, _ := me.FindFeature(userID, name)
69+ // if the feature flag has already expired we don't want to add a year to it since that will
70+ // not grant the user a full year
71+ if ff == nil || !ff.IsValid() {
72+ t := time.Now()
73+ return t.AddDate(1, 0, 0)
74+ }
75+ return ff.ExpiresAt.AddDate(1, 0, 0)
76+}
77+
78+func (me *PsqlDB) AddPicoPlusUser(username, email, paymentType, txId string) error {
79+ user, err := me.FindUserByName(username)
80+ if err != nil {
81+ return err
82+ }
83+
84+ tx, err := me.Db.Beginx()
85+ if err != nil {
86+ return err
87+ }
88+ defer func() {
89+ _ = tx.Rollback()
90+ }()
91+
92+ var paymentHistoryId sql.NullString
93+ if paymentType != "" {
94+ data := db.PaymentHistoryData{
95+ Notes: "",
96+ TxID: txId,
97+ }
98+
99+ err := tx.QueryRow(
100+ `INSERT INTO payment_history (user_id, payment_type, amount, data) VALUES ($1, $2, 24 * 1000000, $3) RETURNING id;`,
101+ user.ID,
102+ paymentType,
103+ data,
104+ ).Scan(&paymentHistoryId)
105+ if err != nil {
106+ return err
107+ }
108+ }
109+
110+ plus := me.createFeatureExpiresAt(user.ID, "plus")
111+ plusQuery := fmt.Sprintf(`INSERT INTO feature_flags (user_id, name, data, expires_at, payment_history_id)
112+ VALUES ($1, 'plus', '{"storage_max":5000000000, "file_max":100000000, "email": "%s"}'::jsonb, $2, $3);`, email)
113+ _, err = tx.Exec(plusQuery, user.ID, plus, paymentHistoryId)
114+ if err != nil {
115+ return err
116+ }
117+
118+ return tx.Commit()
119+}
120+
121+func (me *PsqlDB) AddFeatureUser(username, name string) error {
122+ user, err := me.FindUserByName(username)
123+ if err != nil {
124+ return err
125+ }
126+
127+ expiresAt := me.createFeatureExpiresAt(user.ID, name)
128+ _, err = me.InsertFeature(user.ID, name, expiresAt)
129+ return err
130+}
+87, -0
......@@ -0,0 +1,87 @@
1+package postgres
2+
3+import (
4+ "fmt"
5+ "time"
6+
7+ "github.com/picosh/pico/pkg/db"
8+)
9+
10+func (me *PsqlDB) FindWhoInvitedUser(toUserID string) (string, error) {
11+ var inviterName string
12+ query := `
13+ SELECT app_users.name
14+ FROM invites
15+ LEFT JOIN app_users ON app_users.id = invites.from_user_id
16+ WHERE to_user_id=$1
17+ `
18+ err := me.Db.Get(&inviterName, query, toUserID)
19+ if err != nil {
20+ return "", err
21+ }
22+
23+ return inviterName, nil
24+}
25+
26+func (me *PsqlDB) FindInvitesByUser(userID string) ([]*db.Invite, error) {
27+ var invites []*db.Invite
28+ query := `SELECT
29+ invites.id, invites.from_user_id, invites.to_user_id, invites.created_at,
30+ app_users.name as to_user_name
31+ FROM invites
32+ LEFT JOIN app_users ON app_users.id = invites.to_user_id
33+ WHERE from_user_id=$1`
34+ err := me.Db.Select(&invites, query, userID)
35+ if err != nil {
36+ return nil, err
37+ }
38+ return invites, nil
39+}
40+
41+// inviteFeatures are the services an invite grants access to.
42+var inviteFeatures = []string{"prose", "pgs", "pastes"}
43+
44+func (me *PsqlDB) hasActiveInvite(userID string) (bool, error) {
45+ var invited bool
46+ err := me.Db.Get(&invited, `SELECT EXISTS (SELECT 1 FROM invites WHERE to_user_id = $1)`, userID)
47+ if err != nil || !invited {
48+ return false, err
49+ }
50+ for _, name := range inviteFeatures {
51+ if me.HasFeatureByUser(userID, name) {
52+ return true, nil
53+ }
54+ }
55+ return false, nil
56+}
57+
58+func (me *PsqlDB) InviteUser(fromUserID string, toUserID string) error {
59+ canInvite := me.HasFeatureByUser(fromUserID, "plus")
60+ if !canInvite {
61+ invited, err := me.hasActiveInvite(fromUserID)
62+ if err != nil {
63+ return err
64+ }
65+ canInvite = invited
66+ }
67+
68+ if !canInvite {
69+ return fmt.Errorf("must have valid pico+ membership or an active invite from someone")
70+ }
71+
72+ _, err := me.Db.Exec(
73+ `INSERT INTO invites (from_user_id, to_user_id) VALUES ($1, $2)`,
74+ fromUserID, toUserID,
75+ )
76+ if err != nil {
77+ return err
78+ }
79+
80+ expiresAt := time.Now().Add(5 * 365 * 24 * time.Hour)
81+ for _, name := range inviteFeatures {
82+ if _, err := me.InsertFeature(toUserID, name, expiresAt); err != nil {
83+ return err
84+ }
85+ }
86+ return nil
87+}
+109, -0
......@@ -0,0 +1,109 @@
1+package postgres
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/jmoiron/sqlx"
8+ "github.com/picosh/pico/pkg/db"
9+)
10+
11+func (me *PsqlDB) insertPublicKeyWithTx(userID, key, name string, tx *sqlx.Tx) error {
12+ pk, _ := me.findPublicKeyByKey(key)
13+ if pk != nil {
14+ return db.ErrPublicKeyTaken
15+ }
16+ query := `INSERT INTO public_keys (user_id, public_key, name) VALUES ($1, $2, $3)`
17+ _, err := tx.Exec(query, userID, key, name)
18+ return err
19+}
20+
21+func (me *PsqlDB) InsertPublicKey(userID, key, name string) error {
22+ pk, _ := me.findPublicKeyByKey(key)
23+ if pk != nil {
24+ return db.ErrPublicKeyTaken
25+ }
26+ query := `INSERT INTO public_keys (user_id, public_key, name) VALUES ($1, $2, $3)`
27+ _, err := me.Db.Exec(query, userID, key, name)
28+ return err
29+}
30+
31+func (me *PsqlDB) UpdatePublicKey(pubkeyID, name string) (*db.PublicKey, error) {
32+ pk, err := me.findPublicKey(pubkeyID)
33+ if err != nil {
34+ return nil, err
35+ }
36+
37+ query := `UPDATE public_keys SET name=$1 WHERE id=$2;`
38+ _, err = me.Db.Exec(query, name, pk.ID)
39+ if err != nil {
40+ return nil, err
41+ }
42+
43+ pk, err = me.findPublicKey(pubkeyID)
44+ if err != nil {
45+ return nil, err
46+ }
47+ return pk, nil
48+}
49+
50+func (me *PsqlDB) findPublicKeyByKey(key string) (*db.PublicKey, error) {
51+ var keys []*db.PublicKey
52+ rs, err := me.Db.Queryx(`SELECT id, user_id, name, public_key, created_at FROM public_keys WHERE public_key = $1`, key)
53+ if err != nil {
54+ return nil, err
55+ }
56+ defer func() { _ = rs.Close() }()
57+
58+ for rs.Next() {
59+ pk := &db.PublicKey{}
60+ err := rs.Scan(&pk.ID, &pk.UserID, &pk.Name, &pk.Key, &pk.CreatedAt)
61+ if err != nil {
62+ return nil, err
63+ }
64+
65+ keys = append(keys, pk)
66+ }
67+
68+ if rs.Err() != nil {
69+ return nil, rs.Err()
70+ }
71+
72+ if len(keys) == 0 {
73+ return nil, fmt.Errorf("pubkey not found in our database: [%s]", key)
74+ }
75+
76+ // When we run PublicKeyByKey and there are multiple public keys returned from the database
77+ // that should mean that we don't have the correct username for this public key.
78+ // When that happens we need to reject the authentication and ask the user to provide the correct
79+ // username when using ssh. So instead of `ssh <domain>` it should be `ssh user@<domain>`
80+ if len(keys) > 1 {
81+ return nil, &db.ErrMultiplePublicKeys{}
82+ }
83+
84+ return keys[0], nil
85+}
86+
87+func (me *PsqlDB) findPublicKey(pubkeyID string) (*db.PublicKey, error) {
88+ pk := &db.PublicKey{}
89+ err := me.Db.Get(pk, `SELECT * FROM public_keys WHERE id = $1`, pubkeyID)
90+ if err != nil {
91+ return nil, err
92+ }
93+ return pk, nil
94+}
95+
96+func (me *PsqlDB) FindKeysByUser(user *db.User) ([]*db.PublicKey, error) {
97+ var keys []*db.PublicKey
98+ err := me.Db.Select(&keys, `SELECT * FROM public_keys WHERE user_id = $1 ORDER BY created_at ASC`, user.ID)
99+ if err != nil {
100+ return nil, err
101+ }
102+ return keys, nil
103+}
104+
105+func (me *PsqlDB) RemoveKeys(keyIDs []string) error {
106+ param := "{" + strings.Join(keyIDs, ",") + "}"
107+ _, err := me.Db.Exec(`DELETE FROM public_keys WHERE id = ANY($1::uuid[])`, param)
108+ return err
109+}
+192, -0
......@@ -0,0 +1,192 @@
1+// Package pgtest starts a postgres with pico's schema for tests.
2+package pgtest
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+ "net/url"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "runtime"
13+ "sort"
14+ "sync"
15+ "testing"
16+ "time"
17+
18+ "github.com/jmoiron/sqlx"
19+ _ "github.com/lib/pq"
20+ "github.com/picosh/pico/pkg/db"
21+ "github.com/testcontainers/testcontainers-go"
22+ "github.com/testcontainers/testcontainers-go/modules/postgres"
23+ "github.com/testcontainers/testcontainers-go/wait"
24+)
25+
26+var (
27+ once sync.Once
28+ dbURL string
29+ stop = func() {}
30+ startErr error
31+)
32+
33+// URL is the connection string of a postgres with pico's schema, started
34+// the first time a test in the package asks for one. It is the database
35+// TEST_DATABASE_URL names, or a container. A test skips when there is
36+// neither, and fails when REQUIRE_TESTCONTAINERS=1.
37+func URL(t testing.TB) string {
38+ t.Helper()
39+ once.Do(func() {
40+ var started func()
41+ dbURL, started, startErr = start(context.Background())
42+ if startErr == nil {
43+ stop = started
44+ }
45+ })
46+ if startErr != nil {
47+ if os.Getenv("REQUIRE_TESTCONTAINERS") == "1" {
48+ t.Fatalf("postgres: %v", startErr)
49+ }
50+ t.Skipf("postgres unavailable, set TEST_DATABASE_URL or start docker: %v", startErr)
51+ }
52+ return dbURL
53+}
54+
55+// Main runs a package's tests and stops the postgres they started.
56+func Main(m *testing.M) {
57+ code := m.Run()
58+ stop()
59+ os.Exit(code)
60+}
61+
62+// Clean deletes every row the tests wrote.
63+func Clean(t testing.TB, d *sqlx.DB) {
64+ t.Helper()
65+ tables := []string{
66+ "access_logs", "tuns_event_logs", "analytics_visits",
67+ "feed_items", "post_aliases", "post_tags", "posts",
68+ "projects", "feature_flags", "payment_history", "tokens",
69+ "public_keys", "pipe_monitors", "app_users",
70+ }
71+ for _, table := range tables {
72+ if _, err := d.Exec(fmt.Sprintf("DELETE FROM %s", table)); err != nil {
73+ t.Fatalf("failed to clean up %s: %v", table, err)
74+ }
75+ }
76+}
77+
78+// InsertPost inserts post, publishing and updating it now unless it says
79+// otherwise.
80+func InsertPost(t testing.TB, d interface {
81+ InsertPost(post *db.Post) (*db.Post, error)
82+}, post *db.Post) *db.Post {
83+ t.Helper()
84+ now := time.Now()
85+ if post.UpdatedAt == nil {
86+ post.UpdatedAt = &now
87+ }
88+ if post.PublishAt == nil {
89+ post.PublishAt = &now
90+ }
91+ created, err := d.InsertPost(post)
92+ if err != nil {
93+ t.Fatalf("InsertPost failed: %v", err)
94+ }
95+ return created
96+}
97+
98+func start(ctx context.Context) (string, func(), error) {
99+ // Packages test in parallel, so each gets its own database.
100+ if base := os.Getenv("TEST_DATABASE_URL"); base != "" {
101+ return createDatabase(base)
102+ }
103+
104+ if err := exec.Command("docker", "info").Run(); err != nil {
105+ return "", nil, fmt.Errorf("docker: %w", err)
106+ }
107+ container, err := postgres.Run(ctx,
108+ "postgres:14",
109+ postgres.WithDatabase("pico_test"),
110+ postgres.WithUsername("postgres"),
111+ postgres.WithPassword("postgres"),
112+ testcontainers.WithWaitStrategy(
113+ wait.ForLog("database system is ready to accept connections").
114+ WithOccurrence(2).
115+ WithStartupTimeout(30*time.Second)),
116+ )
117+ if err != nil {
118+ return "", nil, err
119+ }
120+ terminate := func() { _ = container.Terminate(ctx) }
121+ connStr, err := container.ConnectionString(ctx, "sslmode=disable")
122+ if err == nil {
123+ err = migrate(connStr)
124+ }
125+ if err != nil {
126+ terminate()
127+ return "", nil, err
128+ }
129+ return connStr, terminate, nil
130+}
131+
132+func createDatabase(base string) (string, func(), error) {
133+ server, err := sqlx.Connect("postgres", base)
134+ if err != nil {
135+ return "", nil, err
136+ }
137+ name := fmt.Sprintf("pico_test_%d_%d", os.Getpid(), time.Now().UnixNano())
138+ if _, err := server.Exec("CREATE DATABASE " + name); err != nil {
139+ _ = server.Close()
140+ return "", nil, err
141+ }
142+ drop := func() {
143+ _, _ = server.Exec("DROP DATABASE " + name + " WITH (FORCE)")
144+ _ = server.Close()
145+ }
146+ u, err := url.Parse(base)
147+ if err == nil {
148+ u.Path = "/" + name
149+ err = migrate(u.String())
150+ }
151+ if err != nil {
152+ drop()
153+ return "", nil, err
154+ }
155+ return u.String(), drop, nil
156+}
157+
158+func migrate(connStr string) error {
159+ d, err := sqlx.Connect("postgres", connStr)
160+ if err != nil {
161+ return err
162+ }
163+ defer func() { _ = d.Close() }()
164+
165+ _, filename, _, ok := runtime.Caller(0)
166+ if !ok {
167+ return errors.New("failed to get current file path")
168+ }
169+ migrationsDir := filepath.Join(filepath.Dir(filename), "..", "..", "..", "..", "sql", "migrations")
170+ entries, err := os.ReadDir(migrationsDir)
171+ if err != nil {
172+ return fmt.Errorf("failed to read migrations directory: %w", err)
173+ }
174+ // They're date-prefixed, so alphabetical order is correct.
175+ var migrationFiles []string
176+ for _, entry := range entries {
177+ if !entry.IsDir() && filepath.Ext(entry.Name()) == ".sql" {
178+ migrationFiles = append(migrationFiles, entry.Name())
179+ }
180+ }
181+ sort.Strings(migrationFiles)
182+ for _, name := range migrationFiles {
183+ content, err := os.ReadFile(filepath.Join(migrationsDir, name))
184+ if err != nil {
185+ return fmt.Errorf("failed to read migration %s: %w", name, err)
186+ }
187+ if _, err := d.Exec(string(content)); err != nil {
188+ return fmt.Errorf("failed to execute migration %s: %w", name, err)
189+ }
190+ }
191+ return nil
192+}
+353, -0
......@@ -0,0 +1,353 @@
1+package postgres
2+
3+import (
4+ "database/sql"
5+ "fmt"
6+ "math"
7+ "strings"
8+
9+ "github.com/picosh/pico/pkg/db"
10+)
11+
12+var SelectPost = `
13+ posts.id, user_id, app_users.name, filename, slug, title, text, description,
14+ posts.created_at, publish_at, posts.updated_at, hidden, file_size, mime_type, shasum, data, expires_at, views`
15+
16+func CreatePostWithTagsByRow(r RowScanner) (*db.Post, error) {
17+ post := &db.Post{}
18+ tagStr := ""
19+ err := r.Scan(
20+ &post.ID,
21+ &post.UserID,
22+ &post.Username,
23+ &post.Filename,
24+ &post.Slug,
25+ &post.Title,
26+ &post.Text,
27+ &post.Description,
28+ &post.CreatedAt,
29+ &post.PublishAt,
30+ &post.UpdatedAt,
31+ &post.Hidden,
32+ &post.FileSize,
33+ &post.MimeType,
34+ &post.Shasum,
35+ &post.Data,
36+ &post.ExpiresAt,
37+ &post.Views,
38+ &tagStr,
39+ )
40+ if err != nil {
41+ return nil, err
42+ }
43+
44+ tags := strings.Split(tagStr, ",")
45+ for _, tag := range tags {
46+ tg := strings.TrimSpace(tag)
47+ if tg == "" {
48+ continue
49+ }
50+ post.Tags = append(post.Tags, tg)
51+ }
52+
53+ return post, nil
54+}
55+
56+func (me *PsqlDB) FindPostWithFilename(filename string, persona_id string, space string) (*db.Post, error) {
57+ query := fmt.Sprintf(`
58+ SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
59+ FROM posts
60+ LEFT JOIN app_users ON app_users.id = posts.user_id
61+ LEFT JOIN post_tags ON post_tags.post_id = posts.id
62+ WHERE filename = $1 AND user_id = $2 AND cur_space = $3
63+ GROUP BY %s`, SelectPost, SelectPost)
64+ r := me.Db.QueryRow(query, filename, persona_id, space)
65+ post, err := CreatePostWithTagsByRow(r)
66+ if err != nil {
67+ return nil, err
68+ }
69+
70+ return post, nil
71+}
72+
73+func (me *PsqlDB) FindPostWithSlug(slug string, user_id string, space string) (*db.Post, error) {
74+ query := fmt.Sprintf(`
75+ SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
76+ FROM posts
77+ LEFT JOIN app_users ON app_users.id = posts.user_id
78+ LEFT JOIN post_tags ON post_tags.post_id = posts.id
79+ WHERE slug = $1 AND user_id = $2 AND cur_space = $3
80+ GROUP BY %s`, SelectPost, SelectPost)
81+ r := me.Db.QueryRow(query, slug, user_id, space)
82+ post, err := CreatePostWithTagsByRow(r)
83+ if err != nil {
84+ // attempt to find post inside post_aliases
85+ alias := me.Db.QueryRow(
86+ `SELECT post_aliases.post_id FROM post_aliases
87+ INNER JOIN posts ON posts.id = post_aliases.post_id
88+ WHERE post_aliases.slug = $1 AND posts.user_id = $2`,
89+ slug, user_id,
90+ )
91+ postID := ""
92+ err := alias.Scan(&postID)
93+ if err != nil {
94+ return nil, err
95+ }
96+
97+ return me.FindPost(postID)
98+ }
99+
100+ return post, nil
101+}
102+
103+func (me *PsqlDB) FindPost(postID string) (*db.Post, error) {
104+ post := &db.Post{}
105+ query := fmt.Sprintf(`
106+ SELECT %s
107+ FROM posts
108+ LEFT JOIN app_users ON app_users.id = posts.user_id
109+ WHERE posts.id = $1`, SelectPost)
110+ err := me.Db.Get(post, query, postID)
111+ if err != nil {
112+ return nil, err
113+ }
114+ return post, nil
115+}
116+
117+func (me *PsqlDB) InsertPost(post *db.Post) (*db.Post, error) {
118+ var id string
119+ query := `
120+ INSERT INTO posts
121+ (user_id, filename, slug, title, text, description, publish_at, hidden, cur_space,
122+ file_size, mime_type, shasum, data, expires_at, updated_at)
123+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
124+ RETURNING id`
125+ err := me.Db.QueryRow(
126+ query,
127+ post.UserID,
128+ post.Filename,
129+ post.Slug,
130+ post.Title,
131+ post.Text,
132+ post.Description,
133+ post.PublishAt,
134+ post.Hidden,
135+ post.Space,
136+ post.FileSize,
137+ post.MimeType,
138+ post.Shasum,
139+ post.Data,
140+ post.ExpiresAt,
141+ post.UpdatedAt,
142+ ).Scan(&id)
143+ if err != nil {
144+ return nil, err
145+ }
146+
147+ return me.FindPost(id)
148+}
149+
150+func (me *PsqlDB) UpdatePost(post *db.Post) (*db.Post, error) {
151+ query := `
152+ UPDATE posts
153+ SET slug = $1, title = $2, text = $3, description = $4, updated_at = $5, publish_at = $6,
154+ file_size = $7, shasum = $8, data = $9, hidden = $11, expires_at = $12
155+ WHERE id = $10`
156+ _, err := me.Db.Exec(
157+ query,
158+ post.Slug,
159+ post.Title,
160+ post.Text,
161+ post.Description,
162+ post.UpdatedAt,
163+ post.PublishAt,
164+ post.FileSize,
165+ post.Shasum,
166+ post.Data,
167+ post.ID,
168+ post.Hidden,
169+ post.ExpiresAt,
170+ )
171+ if err != nil {
172+ return nil, err
173+ }
174+
175+ return me.FindPost(post.ID)
176+}
177+
178+func (me *PsqlDB) RenamePost(cur, next, replaced *db.Post, tags, aliases []string) (*db.Post, error) {
179+ tx, err := me.Db.Beginx()
180+ if err != nil {
181+ return nil, err
182+ }
183+ defer func() {
184+ _ = tx.Rollback()
185+ }()
186+
187+ affected := func(res sql.Result, err error) error {
188+ if err != nil {
189+ return err
190+ }
191+ n, err := res.RowsAffected()
192+ if err != nil {
193+ return err
194+ }
195+ if n == 0 {
196+ return db.ErrPostChanged
197+ }
198+ return nil
199+ }
200+
201+ // A save of the replaced post since it was read is kept.
202+ if replaced != nil {
203+ err := affected(tx.Exec(
204+ `DELETE FROM posts WHERE id = $1 AND filename = $2 AND text = $3 AND updated_at IS NOT DISTINCT FROM $4`,
205+ replaced.ID, replaced.Filename, replaced.Text, replaced.UpdatedAt,
206+ ))
207+ if err != nil {
208+ return nil, err
209+ }
210+ }
211+
212+ // The update only applies to the post as the caller read it, so a save
213+ // in between is never overwritten. It sets only what a new name can
214+ // change, which leaves data that other writers keep, like feed digests.
215+ query := `
216+ UPDATE posts
217+ SET filename = $1, slug = $2, title = $3, description = $4, publish_at = $5, hidden = $6,
218+ expires_at = $7, mime_type = $8
219+ WHERE id = $9 AND filename = $10 AND slug = $11 AND title = $12 AND text = $13
220+ AND description = $14 AND updated_at IS NOT DISTINCT FROM $15
221+ AND publish_at IS NOT DISTINCT FROM $16 AND hidden = $17
222+ AND expires_at IS NOT DISTINCT FROM $18`
223+ err = affected(tx.Exec(
224+ query,
225+ next.Filename,
226+ next.Slug,
227+ next.Title,
228+ next.Description,
229+ next.PublishAt,
230+ next.Hidden,
231+ next.ExpiresAt,
232+ next.MimeType,
233+ cur.ID,
234+ cur.Filename,
235+ cur.Slug,
236+ cur.Title,
237+ cur.Text,
238+ cur.Description,
239+ cur.UpdatedAt,
240+ cur.PublishAt,
241+ cur.Hidden,
242+ cur.ExpiresAt,
243+ ))
244+ if err != nil {
245+ return nil, err
246+ }
247+
248+ if err := me.removeTagsByPost(tx, cur.ID); err != nil {
249+ return nil, err
250+ }
251+ if _, err := me.insertTagsByPost(tx, tags, cur.ID); err != nil {
252+ return nil, err
253+ }
254+ if err := me.removeAliasesByPost(tx, cur.ID); err != nil {
255+ return nil, err
256+ }
257+ if _, err := me.insertAliasesByPost(tx, aliases, cur.ID); err != nil {
258+ return nil, err
259+ }
260+ if err := tx.Commit(); err != nil {
261+ return nil, err
262+ }
263+
264+ return me.FindPost(cur.ID)
265+}
266+
267+func (me *PsqlDB) RemovePosts(postIDs []string) error {
268+ param := "{" + strings.Join(postIDs, ",") + "}"
269+ _, err := me.Db.Exec(`DELETE FROM posts WHERE id = ANY($1::uuid[])`, param)
270+ return err
271+}
272+
273+func (me *PsqlDB) FindPostsByUser(page *db.Pager, userID string, space string) (*db.Paginate[*db.Post], error) {
274+ var posts []*db.Post
275+ query := fmt.Sprintf(`
276+ SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
277+ FROM posts
278+ LEFT JOIN app_users ON app_users.id = posts.user_id
279+ LEFT JOIN post_tags ON post_tags.post_id = posts.id
280+ WHERE
281+ hidden = FALSE AND
282+ user_id = $1 AND
283+ publish_at::date <= CURRENT_DATE AND
284+ cur_space = $2
285+ GROUP BY %s
286+ ORDER BY publish_at DESC, slug DESC
287+ LIMIT $3 OFFSET $4`, SelectPost, SelectPost)
288+ rs, err := me.Db.Queryx(
289+ query,
290+ userID,
291+ space,
292+ page.Num,
293+ page.Num*page.Page,
294+ )
295+ if err != nil {
296+ return nil, err
297+ }
298+ defer func() { _ = rs.Close() }()
299+ for rs.Next() {
300+ post, err := CreatePostWithTagsByRow(rs)
301+ if err != nil {
302+ return nil, err
303+ }
304+
305+ posts = append(posts, post)
306+ }
307+
308+ if rs.Err() != nil {
309+ return nil, rs.Err()
310+ }
311+
312+ var count int
313+ err = me.Db.QueryRow(`SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`, space).Scan(&count)
314+ if err != nil {
315+ return nil, err
316+ }
317+
318+ pager := &db.Paginate[*db.Post]{
319+ Data: posts,
320+ Total: int(math.Ceil(float64(count) / float64(page.Num))),
321+ }
322+ return pager, nil
323+}
324+
325+func (me *PsqlDB) FindAllPostsByUser(userID string, space string) ([]*db.Post, error) {
326+ var posts []*db.Post
327+ query := fmt.Sprintf(`
328+ SELECT %s
329+ FROM posts
330+ LEFT JOIN app_users ON app_users.id = posts.user_id
331+ WHERE
332+ user_id = $1 AND
333+ cur_space = $2
334+ ORDER BY publish_at DESC`, SelectPost)
335+ err := me.Db.Select(&posts, query, userID, space)
336+ if err != nil {
337+ return nil, err
338+ }
339+ return posts, nil
340+}
341+
342+func (me *PsqlDB) FindPosts() ([]*db.Post, error) {
343+ var posts []*db.Post
344+ query := fmt.Sprintf(`
345+ SELECT %s
346+ FROM posts
347+ LEFT JOIN app_users ON app_users.id = posts.user_id`, SelectPost)
348+ err := me.Db.Select(&posts, query)
349+ if err != nil {
350+ return nil, err
351+ }
352+ return posts, nil
353+}
+77, -0
......@@ -0,0 +1,77 @@
1+package postgres
2+
3+import (
4+ "database/sql"
5+ "fmt"
6+ "time"
7+
8+ "github.com/picosh/pico/pkg/db"
9+ "github.com/picosh/pico/pkg/shared"
10+)
11+
12+func (me *PsqlDB) InsertProject(userID, name, projectDir string) (string, error) {
13+ if !shared.IsValidSubdomain(name) {
14+ return "", fmt.Errorf("'%s' is not a valid project name, must match /^[a-z0-9-]+$/", name)
15+ }
16+
17+ var id string
18+ err := me.Db.QueryRow(`INSERT INTO projects (user_id, name, project_dir) VALUES ($1, $2, $3) RETURNING id;`, userID, name, projectDir).Scan(&id)
19+ if err != nil {
20+ return "", err
21+ }
22+ return id, nil
23+}
24+
25+func (me *PsqlDB) UpdateProject(userID, name string) error {
26+ _, err := me.Db.Exec(`UPDATE projects SET updated_at = $3 WHERE user_id = $1 AND name = $2;`, userID, name, time.Now())
27+ return err
28+}
29+
30+func (me *PsqlDB) FindProjectByName(userID, name string) (*db.Project, error) {
31+ project := &db.Project{}
32+ err := me.Db.Get(project, `SELECT * FROM projects WHERE user_id = $1 AND name = $2;`, userID, name)
33+ if err != nil {
34+ return nil, err
35+ }
36+ return project, nil
37+}
38+
39+func (me *PsqlDB) UpsertProject(userID, projectName, projectDir string) (*db.Project, error) {
40+ project, err := me.FindProjectByName(userID, projectName)
41+ if err == nil {
42+ // this just updates the `createdAt` timestamp, useful for book-keeping
43+ err = me.UpdateProject(userID, projectName)
44+ if err != nil {
45+ me.Logger.Error("could not update project", "err", err)
46+ return nil, err
47+ }
48+ return project, nil
49+ }
50+
51+ _, err = me.InsertProject(userID, projectName, projectName)
52+ if err != nil {
53+ me.Logger.Error("could not create project", "err", err)
54+ return nil, err
55+ }
56+ return me.FindProjectByName(userID, projectName)
57+}
58+
59+func (me *PsqlDB) findPagesStats(userID string) (*db.UserServiceStats, error) {
60+ stats := db.UserServiceStats{
61+ Service: "pgs",
62+ }
63+ // the aggregates are NULL when the user has no projects
64+ var first, latestCreated, latestUpdated sql.NullTime
65+ err := me.Db.QueryRow(
66+ `SELECT count(id), min(created_at), max(created_at), max(updated_at) FROM projects WHERE user_id=$1`,
67+ userID,
68+ ).Scan(&stats.Num, &first, &latestCreated, &latestUpdated)
69+ if err != nil {
70+ return nil, err
71+ }
72+ stats.FirstCreatedAt = first.Time
73+ stats.LastestCreatedAt = latestCreated.Time
74+ stats.LatestUpdatedAt = latestUpdated.Time
75+
76+ return &stats, nil
77+}
+1, -2356
......@@ -2,89 +2,27 @@ package postgres
22
33 import (
44 "database/sql"
5- "errors"
6- "fmt"
75 "log/slog"
8- "math"
9- "slices"
10- "sort"
11- "strings"
12- "sync"
13- "time"
146
15- "github.com/hashicorp/golang-lru/v2/expirable"
167 "github.com/jmoiron/sqlx"
178 _ "github.com/lib/pq"
18- "github.com/picosh/pico/pkg/db"
19- "github.com/picosh/pico/pkg/shared"
209 )
2110
22-// mobileUserAgentExpr is a SQL expression to detect mobile user agents.
23-const mobileUserAgentExpr = `user_agent ILIKE '%mobile%' OR user_agent ILIKE '%android%' OR user_agent ILIKE '%iphone%' OR user_agent ILIKE '%ipad%' OR user_agent ILIKE '%ipod%' OR user_agent ILIKE '%blackberry%' OR user_agent ILIKE '%windows phone%'`
24-
2511 var PAGER_SIZE = 15
2612
27-var SelectPost = `
28- posts.id, user_id, app_users.name, filename, slug, title, text, description,
29- posts.created_at, publish_at, posts.updated_at, hidden, file_size, mime_type, shasum, data, expires_at, views`
30-
3113 type PsqlDB struct {
3214 Logger *slog.Logger
3315 Db *sqlx.DB
34-
35- popularPostsCache *expirable.LRU[string, []*db.Post]
36- popularPostsMu sync.Mutex
3716 }
3817
3918 type RowScanner interface {
4019 Scan(dest ...any) error
4120 }
4221
43-func CreatePostWithTagsByRow(r RowScanner) (*db.Post, error) {
44- post := &db.Post{}
45- tagStr := ""
46- err := r.Scan(
47- &post.ID,
48- &post.UserID,
49- &post.Username,
50- &post.Filename,
51- &post.Slug,
52- &post.Title,
53- &post.Text,
54- &post.Description,
55- &post.CreatedAt,
56- &post.PublishAt,
57- &post.UpdatedAt,
58- &post.Hidden,
59- &post.FileSize,
60- &post.MimeType,
61- &post.Shasum,
62- &post.Data,
63- &post.ExpiresAt,
64- &post.Views,
65- &tagStr,
66- )
67- if err != nil {
68- return nil, err
69- }
70-
71- tags := strings.Split(tagStr, ",")
72- for _, tag := range tags {
73- tg := strings.TrimSpace(tag)
74- if tg == "" {
75- continue
76- }
77- post.Tags = append(post.Tags, tg)
78- }
79-
80- return post, nil
81-}
82-
8322 func NewDB(databaseUrl string, logger *slog.Logger) *PsqlDB {
8423 var err error
8524 d := &PsqlDB{
86- Logger: logger,
87- popularPostsCache: expirable.NewLRU[string, []*db.Post](16, nil, 15*time.Minute),
25+ Logger: logger,
8826 }
8927 d.Logger.Info("Connecting to postgres", "databaseUrl", databaseUrl)
9028
......@@ -96,797 +34,6 @@ func NewDB(databaseUrl string, logger *slog.Logger) *PsqlDB {
9634 return d
9735 }
9836
99-func (me *PsqlDB) ClearPopularPostsCache() {
100- if me.popularPostsCache != nil {
101- me.popularPostsCache.Purge()
102- }
103-}
104-
105-func (me *PsqlDB) shouldBlockSingup(ip string) error {
106- blocked := &db.BlockSignups{}
107- err := me.Db.Get(blocked, `SELECT * FROM block_signups WHERE ip = $1`, ip)
108- // an error in this case means the result is empty (no record found)
109- if err != nil {
110- return nil
111- }
112- return fmt.Errorf("your IP address has been blocked: %s", blocked.Reason)
113-}
114-
115-func (me *PsqlDB) RegisterUser(username, pubkey, comment, ip string) (*db.User, error) {
116- lowerName := strings.ToLower(username)
117- valid, err := me.validateName(lowerName)
118- if !valid {
119- return nil, err
120- }
121-
122- me.Logger.Info("checking if ip is in block list", "ip", ip, "username", username)
123- err = me.shouldBlockSingup(ip)
124- if err != nil {
125- me.Logger.Warn("user has been blocked from signing up", "ip", ip, "username", username, "err", err)
126- return nil, err
127- }
128-
129- tx, err := me.Db.Beginx()
130- if err != nil {
131- return nil, err
132- }
133- defer func() {
134- _ = tx.Rollback()
135- }()
136-
137- var id string
138- err = tx.QueryRow(`INSERT INTO app_users (name) VALUES($1) returning id`, lowerName).Scan(&id)
139- if err != nil {
140- return nil, err
141- }
142-
143- err = me.insertPublicKeyWithTx(id, pubkey, comment, tx)
144- if err != nil {
145- return nil, err
146- }
147-
148- err = tx.Commit()
149- if err != nil {
150- return nil, err
151- }
152-
153- return me.FindUserByKey(username, pubkey)
154-}
155-
156-func (me *PsqlDB) insertPublicKeyWithTx(userID, key, name string, tx *sqlx.Tx) error {
157- pk, _ := me.findPublicKeyByKey(key)
158- if pk != nil {
159- return db.ErrPublicKeyTaken
160- }
161- query := `INSERT INTO public_keys (user_id, public_key, name) VALUES ($1, $2, $3)`
162- _, err := tx.Exec(query, userID, key, name)
163- return err
164-}
165-
166-func (me *PsqlDB) InsertPublicKey(userID, key, name string) error {
167- pk, _ := me.findPublicKeyByKey(key)
168- if pk != nil {
169- return db.ErrPublicKeyTaken
170- }
171- query := `INSERT INTO public_keys (user_id, public_key, name) VALUES ($1, $2, $3)`
172- _, err := me.Db.Exec(query, userID, key, name)
173- return err
174-}
175-
176-func (me *PsqlDB) UpdatePublicKey(pubkeyID, name string) (*db.PublicKey, error) {
177- pk, err := me.findPublicKey(pubkeyID)
178- if err != nil {
179- return nil, err
180- }
181-
182- query := `UPDATE public_keys SET name=$1 WHERE id=$2;`
183- _, err = me.Db.Exec(query, name, pk.ID)
184- if err != nil {
185- return nil, err
186- }
187-
188- pk, err = me.findPublicKey(pubkeyID)
189- if err != nil {
190- return nil, err
191- }
192- return pk, nil
193-}
194-
195-func (me *PsqlDB) findPublicKeyByKey(key string) (*db.PublicKey, error) {
196- var keys []*db.PublicKey
197- rs, err := me.Db.Queryx(`SELECT id, user_id, name, public_key, created_at FROM public_keys WHERE public_key = $1`, key)
198- if err != nil {
199- return nil, err
200- }
201- defer func() { _ = rs.Close() }()
202-
203- for rs.Next() {
204- pk := &db.PublicKey{}
205- err := rs.Scan(&pk.ID, &pk.UserID, &pk.Name, &pk.Key, &pk.CreatedAt)
206- if err != nil {
207- return nil, err
208- }
209-
210- keys = append(keys, pk)
211- }
212-
213- if rs.Err() != nil {
214- return nil, rs.Err()
215- }
216-
217- if len(keys) == 0 {
218- return nil, fmt.Errorf("pubkey not found in our database: [%s]", key)
219- }
220-
221- // When we run PublicKeyByKey and there are multiple public keys returned from the database
222- // that should mean that we don't have the correct username for this public key.
223- // When that happens we need to reject the authentication and ask the user to provide the correct
224- // username when using ssh. So instead of `ssh <domain>` it should be `ssh user@<domain>`
225- if len(keys) > 1 {
226- return nil, &db.ErrMultiplePublicKeys{}
227- }
228-
229- return keys[0], nil
230-}
231-
232-func (me *PsqlDB) findPublicKey(pubkeyID string) (*db.PublicKey, error) {
233- pk := &db.PublicKey{}
234- err := me.Db.Get(pk, `SELECT * FROM public_keys WHERE id = $1`, pubkeyID)
235- if err != nil {
236- return nil, err
237- }
238- return pk, nil
239-}
240-
241-func (me *PsqlDB) FindKeysByUser(user *db.User) ([]*db.PublicKey, error) {
242- var keys []*db.PublicKey
243- err := me.Db.Select(&keys, `SELECT * FROM public_keys WHERE user_id = $1 ORDER BY created_at ASC`, user.ID)
244- if err != nil {
245- return nil, err
246- }
247- return keys, nil
248-}
249-
250-func (me *PsqlDB) RemoveKeys(keyIDs []string) error {
251- param := "{" + strings.Join(keyIDs, ",") + "}"
252- _, err := me.Db.Exec(`DELETE FROM public_keys WHERE id = ANY($1::uuid[])`, param)
253- return err
254-}
255-
256-func (me *PsqlDB) FindUsersWithPost(space string) ([]*db.User, error) {
257- var users []*db.User
258- rs, err := me.Db.Queryx(
259- `SELECT u.id, u.name, u.created_at
260- FROM app_users u
261- INNER JOIN posts ON u.id=posts.user_id
262- WHERE cur_space='feeds'
263- GROUP BY u.id, u.name, u.created_at
264- ORDER BY name ASC`,
265- )
266- if err != nil {
267- return users, err
268- }
269- defer func() { _ = rs.Close() }()
270- for rs.Next() {
271- var name sql.NullString
272- user := &db.User{}
273- err := rs.Scan(
274- &user.ID,
275- &name,
276- &user.CreatedAt,
277- )
278- if err != nil {
279- return users, err
280- }
281- user.Name = name.String
282-
283- users = append(users, user)
284- }
285- if rs.Err() != nil {
286- return users, rs.Err()
287- }
288- return users, nil
289-}
290-
291-func (me *PsqlDB) FindUserByKey(username string, key string) (*db.User, error) {
292- me.Logger.Info("attempting to find user with only public key", "key", key)
293- pk, err := me.findPublicKeyByKey(key)
294- if err == nil {
295- me.Logger.Info("found pubkey, looking for user", "key", key, "userId", pk.UserID)
296- user, err := me.FindUser(pk.UserID)
297- if err != nil {
298- return nil, err
299- }
300- user.PublicKey = pk
301- return user, nil
302- }
303-
304- if errors.Is(err, &db.ErrMultiplePublicKeys{}) {
305- me.Logger.Info("detected multiple users with same public key", "user", username)
306- user, err := me.findUserForNameAndKey(username, key)
307- if err != nil {
308- me.Logger.Info("could not find user by username and public key", "user", username, "key", key)
309- // this is a little hacky but if we cannot find a user by name and public key
310- // then we return the multiple keys detected error so the user knows to specify their
311- // when logging in
312- return nil, &db.ErrMultiplePublicKeys{}
313- }
314- return user, nil
315- }
316-
317- return nil, err
318-}
319-
320-func (me *PsqlDB) FindUserByPubkey(key string) (*db.User, error) {
321- me.Logger.Info("attempting to find user with only public key", "key", key)
322- pk, err := me.findPublicKeyByKey(key)
323- if err != nil {
324- return nil, err
325- }
326-
327- me.Logger.Info("found pubkey, looking for user", "key", key, "userId", pk.UserID)
328- user, err := me.FindUser(pk.UserID)
329- if err != nil {
330- return nil, err
331- }
332- user.PublicKey = pk
333- return user, nil
334-}
335-
336-func (me *PsqlDB) FindUser(userID string) (*db.User, error) {
337- user := &db.User{}
338- err := me.Db.Get(user, `SELECT id, COALESCE(name, '') as name, created_at FROM app_users WHERE id = $1`, userID)
339- if err != nil {
340- return nil, err
341- }
342- return user, nil
343-}
344-
345-func (me *PsqlDB) validateName(name string) (bool, error) {
346- lower := strings.ToLower(name)
347- if slices.Contains(db.DenyList, lower) {
348- return false, fmt.Errorf("%s is on deny list: %w", lower, db.ErrNameDenied)
349- }
350- v := db.NameValidator.MatchString(lower)
351- if !v {
352- return false, fmt.Errorf("%s is invalid: %w", lower, db.ErrNameInvalid)
353- }
354- user, _ := me.FindUserByName(lower)
355- if user == nil {
356- return true, nil
357- }
358- return false, fmt.Errorf("%s already taken: %w", lower, db.ErrNameTaken)
359-}
360-
361-func (me *PsqlDB) FindUserByName(name string) (*db.User, error) {
362- user := &db.User{}
363- err := me.Db.Get(user, `SELECT * FROM app_users WHERE name = $1`, strings.ToLower(name))
364- if err != nil {
365- return nil, err
366- }
367- return user, nil
368-}
369-
370-func (me *PsqlDB) findUserForNameAndKey(name string, key string) (*db.User, error) {
371- user := &db.User{}
372- pk := &db.PublicKey{}
373-
374- r := me.Db.QueryRow(`SELECT app_users.id, app_users.name, app_users.created_at, public_keys.id as pk_id, public_keys.public_key, public_keys.created_at as pk_created_at FROM app_users LEFT JOIN public_keys ON public_keys.user_id = app_users.id WHERE app_users.name = $1 AND public_keys.public_key = $2`, strings.ToLower(name), key)
375- err := r.Scan(&user.ID, &user.Name, &user.CreatedAt, &pk.ID, &pk.Key, &pk.CreatedAt)
376- if err != nil {
377- return nil, err
378- }
379-
380- user.PublicKey = pk
381- return user, nil
382-}
383-
384-func (me *PsqlDB) FindUserByToken(token string) (*db.User, error) {
385- user := &db.User{}
386- err := me.Db.Get(user, `
387- SELECT app_users.id, app_users.name, app_users.created_at
388- FROM app_users
389- LEFT JOIN tokens ON tokens.user_id = app_users.id
390- WHERE tokens.token = $1 AND tokens.expires_at > NOW()`, token)
391- if err != nil {
392- return nil, err
393- }
394- return user, nil
395-}
396-
397-func (me *PsqlDB) FindPostWithFilename(filename string, persona_id string, space string) (*db.Post, error) {
398- query := fmt.Sprintf(`
399- SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
400- FROM posts
401- LEFT JOIN app_users ON app_users.id = posts.user_id
402- LEFT JOIN post_tags ON post_tags.post_id = posts.id
403- WHERE filename = $1 AND user_id = $2 AND cur_space = $3
404- GROUP BY %s`, SelectPost, SelectPost)
405- r := me.Db.QueryRow(query, filename, persona_id, space)
406- post, err := CreatePostWithTagsByRow(r)
407- if err != nil {
408- return nil, err
409- }
410-
411- return post, nil
412-}
413-
414-func (me *PsqlDB) FindPostWithSlug(slug string, user_id string, space string) (*db.Post, error) {
415- query := fmt.Sprintf(`
416- SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
417- FROM posts
418- LEFT JOIN app_users ON app_users.id = posts.user_id
419- LEFT JOIN post_tags ON post_tags.post_id = posts.id
420- WHERE slug = $1 AND user_id = $2 AND cur_space = $3
421- GROUP BY %s`, SelectPost, SelectPost)
422- r := me.Db.QueryRow(query, slug, user_id, space)
423- post, err := CreatePostWithTagsByRow(r)
424- if err != nil {
425- // attempt to find post inside post_aliases
426- alias := me.Db.QueryRow(
427- `SELECT post_aliases.post_id FROM post_aliases
428- INNER JOIN posts ON posts.id = post_aliases.post_id
429- WHERE post_aliases.slug = $1 AND posts.user_id = $2`,
430- slug, user_id,
431- )
432- postID := ""
433- err := alias.Scan(&postID)
434- if err != nil {
435- return nil, err
436- }
437-
438- return me.FindPost(postID)
439- }
440-
441- return post, nil
442-}
443-
444-func (me *PsqlDB) FindPost(postID string) (*db.Post, error) {
445- post := &db.Post{}
446- query := fmt.Sprintf(`
447- SELECT %s
448- FROM posts
449- LEFT JOIN app_users ON app_users.id = posts.user_id
450- WHERE posts.id = $1`, SelectPost)
451- err := me.Db.Get(post, query, postID)
452- if err != nil {
453- return nil, err
454- }
455- return post, nil
456-}
457-
458-func (me *PsqlDB) postPager(rs *sqlx.Rows, pageNum int, space string, tag string) (*db.Paginate[*db.Post], error) {
459- var posts []*db.Post
460- for rs.Next() {
461- post := &db.Post{}
462- err := rs.Scan(
463- &post.ID,
464- &post.UserID,
465- &post.Filename,
466- &post.Slug,
467- &post.Title,
468- &post.Text,
469- &post.Description,
470- &post.PublishAt,
471- &post.Username,
472- &post.UpdatedAt,
473- &post.MimeType,
474- )
475- if err != nil {
476- return nil, err
477- }
478-
479- posts = append(posts, post)
480- }
481- if rs.Err() != nil {
482- return nil, rs.Err()
483- }
484-
485- var count int
486- var err error
487- if tag == "" {
488- err = me.Db.QueryRow(`SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`, space).Scan(&count)
489- } else {
490- err = me.Db.QueryRow(`
491- SELECT count(posts.id)
492- FROM posts
493- LEFT JOIN post_tags ON post_tags.post_id = posts.id
494- WHERE hidden = FALSE AND cur_space=$1 and post_tags.name = $2`, space, tag).Scan(&count)
495- }
496- if err != nil {
497- return nil, err
498- }
499-
500- pager := &db.Paginate[*db.Post]{
501- Data: posts,
502- Total: int(math.Ceil(float64(count) / float64(pageNum))),
503- }
504-
505- return pager, nil
506-}
507-
508-func (me *PsqlDB) FindPopularPosts() ([]*db.Post, error) {
509- if me.popularPostsCache != nil {
510- if posts, ok := me.popularPostsCache.Get("popular_posts"); ok {
511- return posts, nil
512- }
513- }
514-
515- me.popularPostsMu.Lock()
516- defer me.popularPostsMu.Unlock()
517-
518- if me.popularPostsCache != nil {
519- if posts, ok := me.popularPostsCache.Get("popular_posts"); ok {
520- return posts, nil
521- }
522- }
523-
524- query := `
525- WITH candidate_posts AS (
526- SELECT
527- posts.id,
528- posts.user_id,
529- posts.filename,
530- posts.slug,
531- posts.title,
532- posts.text,
533- posts.description,
534- posts.publish_at,
535- app_users.name AS username,
536- posts.updated_at,
537- posts.mime_type
538- FROM posts
539- LEFT JOIN app_users ON app_users.id = posts.user_id
540- WHERE
541- hidden = FALSE
542- AND publish_at::date <= CURRENT_DATE
543- AND publish_at >= NOW() - INTERVAL '30 days'
544- AND cur_space = 'prose'
545- AND EXISTS (
546- SELECT 1 FROM feature_flags
547- WHERE feature_flags.user_id = posts.user_id
548- AND (name = 'plus' OR name = 'prose')
549- AND expires_at > NOW()
550- )
551- ),
552- post_visitors AS (
553- SELECT post_id, COUNT(DISTINCT ip_address) AS unique_visitors
554- FROM analytics_visits
555- WHERE post_id IN (SELECT id FROM candidate_posts)
556- AND status = 200
557- AND created_at >= NOW() - INTERVAL '30 days'
558- GROUP BY post_id
559- ),
560- scored_posts AS (
561- SELECT
562- cp.*,
563- COALESCE(pv.unique_visitors, 0) AS score,
564- (
565- LOG(GREATEST(COALESCE(pv.unique_visitors, 0), 1)) +
566- (EXTRACT(EPOCH FROM cp.publish_at) - 1700000000) / 345600.0
567- ) AS rank_score,
568- ROW_NUMBER() OVER (
569- PARTITION BY cp.user_id
570- ORDER BY (
571- LOG(GREATEST(COALESCE(pv.unique_visitors, 0), 1)) +
572- (EXTRACT(EPOCH FROM cp.publish_at) - 1700000000) / 345600.0
573- ) DESC, cp.publish_at DESC
574- ) AS user_rank
575- FROM candidate_posts cp
576- LEFT JOIN post_visitors pv ON pv.post_id = cp.id
577- )
578- SELECT
579- id,
580- user_id,
581- filename,
582- slug,
583- title,
584- text,
585- description,
586- publish_at,
587- username,
588- updated_at,
589- mime_type,
590- score
591- FROM scored_posts
592- WHERE user_rank <= 3
593- ORDER BY rank_score DESC, publish_at DESC
594- LIMIT 30`
595- rs, err := me.Db.Queryx(query)
596- if err != nil {
597- return nil, err
598- }
599- defer func() { _ = rs.Close() }()
600-
601- var posts []*db.Post
602- for rs.Next() {
603- post := &db.Post{}
604- err := rs.Scan(
605- &post.ID,
606- &post.UserID,
607- &post.Filename,
608- &post.Slug,
609- &post.Title,
610- &post.Text,
611- &post.Description,
612- &post.PublishAt,
613- &post.Username,
614- &post.UpdatedAt,
615- &post.MimeType,
616- &post.Score,
617- )
618- if err != nil {
619- return nil, err
620- }
621-
622- posts = append(posts, post)
623- }
624- if rs.Err() != nil {
625- return nil, rs.Err()
626- }
627-
628- if me.popularPostsCache != nil {
629- me.popularPostsCache.Add("popular_posts", posts)
630- }
631-
632- return posts, nil
633-}
634-
635-func (me *PsqlDB) InsertPost(post *db.Post) (*db.Post, error) {
636- var id string
637- query := `
638- INSERT INTO posts
639- (user_id, filename, slug, title, text, description, publish_at, hidden, cur_space,
640- file_size, mime_type, shasum, data, expires_at, updated_at)
641- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
642- RETURNING id`
643- err := me.Db.QueryRow(
644- query,
645- post.UserID,
646- post.Filename,
647- post.Slug,
648- post.Title,
649- post.Text,
650- post.Description,
651- post.PublishAt,
652- post.Hidden,
653- post.Space,
654- post.FileSize,
655- post.MimeType,
656- post.Shasum,
657- post.Data,
658- post.ExpiresAt,
659- post.UpdatedAt,
660- ).Scan(&id)
661- if err != nil {
662- return nil, err
663- }
664-
665- return me.FindPost(id)
666-}
667-
668-func (me *PsqlDB) UpdatePost(post *db.Post) (*db.Post, error) {
669- query := `
670- UPDATE posts
671- SET slug = $1, title = $2, text = $3, description = $4, updated_at = $5, publish_at = $6,
672- file_size = $7, shasum = $8, data = $9, hidden = $11, expires_at = $12
673- WHERE id = $10`
674- _, err := me.Db.Exec(
675- query,
676- post.Slug,
677- post.Title,
678- post.Text,
679- post.Description,
680- post.UpdatedAt,
681- post.PublishAt,
682- post.FileSize,
683- post.Shasum,
684- post.Data,
685- post.ID,
686- post.Hidden,
687- post.ExpiresAt,
688- )
689- if err != nil {
690- return nil, err
691- }
692-
693- return me.FindPost(post.ID)
694-}
695-
696-func (me *PsqlDB) RenamePost(cur, next, replaced *db.Post, tags, aliases []string) (*db.Post, error) {
697- tx, err := me.Db.Beginx()
698- if err != nil {
699- return nil, err
700- }
701- defer func() {
702- _ = tx.Rollback()
703- }()
704-
705- affected := func(res sql.Result, err error) error {
706- if err != nil {
707- return err
708- }
709- n, err := res.RowsAffected()
710- if err != nil {
711- return err
712- }
713- if n == 0 {
714- return db.ErrPostChanged
715- }
716- return nil
717- }
718-
719- // A save of the replaced post since it was read is kept.
720- if replaced != nil {
721- err := affected(tx.Exec(
722- `DELETE FROM posts WHERE id = $1 AND filename = $2 AND text = $3 AND updated_at IS NOT DISTINCT FROM $4`,
723- replaced.ID, replaced.Filename, replaced.Text, replaced.UpdatedAt,
724- ))
725- if err != nil {
726- return nil, err
727- }
728- }
729-
730- // The update only applies to the post as the caller read it, so a save
731- // in between is never overwritten. It sets only what a new name can
732- // change, which leaves data that other writers keep, like feed digests.
733- query := `
734- UPDATE posts
735- SET filename = $1, slug = $2, title = $3, description = $4, publish_at = $5, hidden = $6,
736- expires_at = $7, mime_type = $8
737- WHERE id = $9 AND filename = $10 AND slug = $11 AND title = $12 AND text = $13
738- AND description = $14 AND updated_at IS NOT DISTINCT FROM $15
739- AND publish_at IS NOT DISTINCT FROM $16 AND hidden = $17
740- AND expires_at IS NOT DISTINCT FROM $18`
741- err = affected(tx.Exec(
742- query,
743- next.Filename,
744- next.Slug,
745- next.Title,
746- next.Description,
747- next.PublishAt,
748- next.Hidden,
749- next.ExpiresAt,
750- next.MimeType,
751- cur.ID,
752- cur.Filename,
753- cur.Slug,
754- cur.Title,
755- cur.Text,
756- cur.Description,
757- cur.UpdatedAt,
758- cur.PublishAt,
759- cur.Hidden,
760- cur.ExpiresAt,
761- ))
762- if err != nil {
763- return nil, err
764- }
765-
766- if err := me.removeTagsByPost(tx, cur.ID); err != nil {
767- return nil, err
768- }
769- if _, err := me.insertTagsByPost(tx, tags, cur.ID); err != nil {
770- return nil, err
771- }
772- if err := me.removeAliasesByPost(tx, cur.ID); err != nil {
773- return nil, err
774- }
775- if _, err := me.insertAliasesByPost(tx, aliases, cur.ID); err != nil {
776- return nil, err
777- }
778- if err := tx.Commit(); err != nil {
779- return nil, err
780- }
781-
782- return me.FindPost(cur.ID)
783-}
784-
785-func (me *PsqlDB) RemovePosts(postIDs []string) error {
786- param := "{" + strings.Join(postIDs, ",") + "}"
787- _, err := me.Db.Exec(`DELETE FROM posts WHERE id = ANY($1::uuid[])`, param)
788- return err
789-}
790-
791-func (me *PsqlDB) FindPostsByUser(page *db.Pager, userID string, space string) (*db.Paginate[*db.Post], error) {
792- var posts []*db.Post
793- query := fmt.Sprintf(`
794- SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
795- FROM posts
796- LEFT JOIN app_users ON app_users.id = posts.user_id
797- LEFT JOIN post_tags ON post_tags.post_id = posts.id
798- WHERE
799- hidden = FALSE AND
800- user_id = $1 AND
801- publish_at::date <= CURRENT_DATE AND
802- cur_space = $2
803- GROUP BY %s
804- ORDER BY publish_at DESC, slug DESC
805- LIMIT $3 OFFSET $4`, SelectPost, SelectPost)
806- rs, err := me.Db.Queryx(
807- query,
808- userID,
809- space,
810- page.Num,
811- page.Num*page.Page,
812- )
813- if err != nil {
814- return nil, err
815- }
816- defer func() { _ = rs.Close() }()
817- for rs.Next() {
818- post, err := CreatePostWithTagsByRow(rs)
819- if err != nil {
820- return nil, err
821- }
822-
823- posts = append(posts, post)
824- }
825-
826- if rs.Err() != nil {
827- return nil, rs.Err()
828- }
829-
830- var count int
831- err = me.Db.QueryRow(`SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`, space).Scan(&count)
832- if err != nil {
833- return nil, err
834- }
835-
836- pager := &db.Paginate[*db.Post]{
837- Data: posts,
838- Total: int(math.Ceil(float64(count) / float64(page.Num))),
839- }
840- return pager, nil
841-}
842-
843-func (me *PsqlDB) FindAllPostsByUser(userID string, space string) ([]*db.Post, error) {
844- var posts []*db.Post
845- query := fmt.Sprintf(`
846- SELECT %s
847- FROM posts
848- LEFT JOIN app_users ON app_users.id = posts.user_id
849- WHERE
850- user_id = $1 AND
851- cur_space = $2
852- ORDER BY publish_at DESC`, SelectPost)
853- err := me.Db.Select(&posts, query, userID, space)
854- if err != nil {
855- return nil, err
856- }
857- return posts, nil
858-}
859-
860-func (me *PsqlDB) FindPosts() ([]*db.Post, error) {
861- var posts []*db.Post
862- query := fmt.Sprintf(`
863- SELECT %s
864- FROM posts
865- LEFT JOIN app_users ON app_users.id = posts.user_id`, SelectPost)
866- err := me.Db.Select(&posts, query)
867- if err != nil {
868- return nil, err
869- }
870- return posts, nil
871-}
872-
873-func (me *PsqlDB) FindExpiredPosts(space string) ([]*db.Post, error) {
874- var posts []*db.Post
875- query := fmt.Sprintf(`
876- SELECT %s
877- FROM posts
878- LEFT JOIN app_users ON app_users.id = posts.user_id
879- WHERE
880- cur_space = $1 AND
881- expires_at <= now();
882- `, SelectPost)
883- err := me.Db.Select(&posts, query, space)
884- if err != nil {
885- return nil, err
886- }
887- return posts, nil
888-}
889-
89037 func (me *PsqlDB) Close() error {
89138 me.Logger.Info("Closing db")
89239 return me.Db.Close()
......@@ -901,1505 +48,3 @@ func newNullString(s string) sql.NullString {
90148 Valid: true,
90249 }
90350 }
904-
905-func (me *PsqlDB) InsertVisit(visit *db.AnalyticsVisits) error {
906- _, err := me.Db.Exec(
907- `INSERT INTO analytics_visits (user_id, project_id, post_id, namespace, host, path, ip_address, user_agent, referer, status, content_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);`,
908- visit.UserID,
909- newNullString(visit.ProjectID),
910- newNullString(visit.PostID),
911- newNullString(visit.Namespace),
912- visit.Host,
913- visit.Path,
914- visit.IpAddress,
915- visit.UserAgent,
916- visit.Referer,
917- visit.Status,
918- visit.ContentType,
919- )
920- return err
921-}
922-
923-func visitFilterBy(opts *db.SummaryOpts) (string, string) {
924- where := ""
925- val := ""
926- if opts.Host != "" {
927- where = "host"
928- val = opts.Host
929- } else if opts.Path != "" {
930- where = "path"
931- val = opts.Path
932- }
933-
934- return where, val
935-}
936-
937-func (me *PsqlDB) visitUnique(opts *db.SummaryOpts) ([]*db.VisitInterval, error) {
938- var intervals, currentIntervals []*db.VisitInterval
939- var sumErr, rawErr error
940-
941- var wg sync.WaitGroup
942- wg.Add(2)
943-
944- go func() {
945- defer wg.Done()
946- intervals, sumErr = me.visitUniqueFromSummary(opts)
947- }()
948- go func() {
949- defer wg.Done()
950- currentIntervals, rawErr = me.visitUniqueFromRaw(opts)
951- }()
952-
953- wg.Wait()
954-
955- if sumErr != nil {
956- return nil, fmt.Errorf("query summary visits: %w", sumErr)
957- }
958- if rawErr != nil {
959- return nil, fmt.Errorf("query raw visits: %w", rawErr)
960- }
961-
962- // Merge: current month data may overlap with summary data, combine counts
963- return mergeVisitIntervals(intervals, currentIntervals), nil
964-}
965-
966-// visitUniqueFromSummary reads unique visitor counts from analytics_monthly_visits for historical data.
967-func (me *PsqlDB) visitUniqueFromSummary(opts *db.SummaryOpts) ([]*db.VisitInterval, error) {
968- now := time.Now()
969- currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
970- previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
971-
972- // If origin is in the previous month or later, raw data covers it — no summary to fetch.
973- if !opts.Origin.Before(previousMonthStart) {
974- return nil, nil
975- }
976-
977- where := ""
978- args := []interface{}{opts.UserID, opts.Origin, currentMonthStart}
979- argIdx := 4
980- if opts.Host != "" {
981- where = "AND host = $" + fmt.Sprintf("%d", argIdx)
982- args = append(args, opts.Host)
983- }
984-
985- query := fmt.Sprintf(`
986- SELECT
987- date_trunc('%s', visit_date)::timestamptz as interval_start,
988- sum(unique_visits) as unique_visitors,
989- sum(mobile_visits) as mobile_visits,
990- sum(desktop_visits) as desktop_visits
991- FROM analytics_monthly_visits
992- WHERE user_id = $1 AND visit_date >= $2 AND visit_date < $3 %s
993- GROUP BY interval_start
994- ORDER BY interval_start`, opts.Interval, where)
995-
996- rows, err := me.Db.Queryx(query, args...)
997- if err != nil {
998- return nil, err
999- }
1000- defer func() { _ = rows.Close() }()
1001-
1002- var intervals []*db.VisitInterval
1003- for rows.Next() {
1004- interval := &db.VisitInterval{}
1005- if err := rows.Scan(&interval.Interval, &interval.Visitors, &interval.MobileVisitors, &interval.DesktopVisitors); err != nil {
1006- return nil, err
1007- }
1008- intervals = append(intervals, interval)
1009- }
1010- return intervals, rows.Err()
1011-}
1012-
1013-// visitUniqueFromRaw reads unique visitor counts from analytics_visits for the previous and current months.
1014-// This covers the gap between the last aggregated month and the current month.
1015-func (me *PsqlDB) visitUniqueFromRaw(opts *db.SummaryOpts) ([]*db.VisitInterval, error) {
1016- now := time.Now()
1017- currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
1018- previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
1019-
1020- where, with := visitFilterBy(opts)
1021-
1022- // Determine the effective start: max(origin, previousMonthStart)
1023- effectiveStart := previousMonthStart
1024- if opts.Origin.After(previousMonthStart) {
1025- effectiveStart = opts.Origin
1026- }
1027-
1028- uniqueVisitors := fmt.Sprintf(`
1029- SELECT
1030- date_trunc('%s', created_at)::timestamptz as interval_start,
1031- count(DISTINCT CASE WHEN %s THEN ip_address END) as mobile_visitors,
1032- count(DISTINCT CASE WHEN NOT %s THEN ip_address END) as desktop_visitors,
1033- count(DISTINCT ip_address) as unique_visitors
1034- FROM analytics_visits
1035- WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND status <> 404
1036- GROUP BY interval_start
1037- ORDER BY interval_start`,
1038- opts.Interval, mobileUserAgentExpr, mobileUserAgentExpr, where)
1039-
1040- rows, err := me.Db.Queryx(uniqueVisitors, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
1041- if err != nil {
1042- return nil, err
1043- }
1044- defer func() { _ = rows.Close() }()
1045-
1046- var intervals []*db.VisitInterval
1047- for rows.Next() {
1048- interval := &db.VisitInterval{}
1049- if err := rows.Scan(&interval.Interval, &interval.MobileVisitors, &interval.DesktopVisitors, &interval.Visitors); err != nil {
1050- return nil, err
1051- }
1052- intervals = append(intervals, interval)
1053- }
1054- return intervals, rows.Err()
1055-}
1056-
1057-// mergeVisitIntervals combines historical (summary table) and current (raw) intervals.
1058-// Summary data is preferred when both sources have the same interval to avoid double-counting.
1059-func mergeVisitIntervals(historical, current []*db.VisitInterval) []*db.VisitInterval {
1060- if len(historical) == 0 {
1061- return current
1062- }
1063- if len(current) == 0 {
1064- return historical
1065- }
1066-
1067- // Build a map by interval timestamp for merging.
1068- // Summary data takes precedence over raw data for the same interval
1069- // (e.g. when a month is aggregated but raw data still exists in a local dump).
1070- intervalMap := make(map[int64]*db.VisitInterval)
1071- for _, ci := range current {
1072- ts := ci.Interval.Unix()
1073- intervalMap[ts] = ci
1074- }
1075- for _, hi := range historical {
1076- ts := hi.Interval.Unix()
1077- intervalMap[ts] = hi // summary overwrites raw if both exist
1078- }
1079-
1080- // Sort by interval
1081- result := make([]*db.VisitInterval, 0, len(intervalMap))
1082- for _, iv := range intervalMap {
1083- result = append(result, iv)
1084- }
1085- sort.Slice(result, func(i, j int) bool {
1086- return result[i].Interval.Before(*result[j].Interval)
1087- })
1088- return result
1089-}
1090-
1091-// mergeTopUrls combines historical and current top URLs, summing counts for overlapping URLs,
1092-// then returns the top 10 by total count.
1093-func mergeTopUrls(historical, current []*db.VisitUrl) []*db.VisitUrl {
1094- if len(historical) == 0 {
1095- return current
1096- }
1097- if len(current) == 0 {
1098- return historical
1099- }
1100-
1101- // Build a map by URL for merging
1102- urlMap := make(map[string]*db.VisitUrl)
1103- for _, hu := range historical {
1104- urlMap[hu.Url] = hu
1105- }
1106-
1107- for _, cu := range current {
1108- if existing, ok := urlMap[cu.Url]; ok {
1109- existing.Count += cu.Count
1110- } else {
1111- urlMap[cu.Url] = cu
1112- }
1113- }
1114-
1115- // Sort by count descending and take top 10
1116- result := make([]*db.VisitUrl, 0, len(urlMap))
1117- for _, u := range urlMap {
1118- result = append(result, u)
1119- }
1120- sort.Slice(result, func(i, j int) bool {
1121- return result[i].Count > result[j].Count
1122- })
1123- if len(result) > 10 {
1124- result = result[:10]
1125- }
1126- return result
1127-}
1128-
1129-// mergeTopReferers combines historical and current top referers, summing counts for overlapping referers,
1130-// then returns the top 10 by total count.
1131-func mergeTopReferers(historical, current []*db.VisitUrl) []*db.VisitUrl {
1132- return mergeTopUrls(historical, current) // Same logic as mergeTopUrls
1133-}
1134-
1135-// mergeHosts combines historical and current hosts, summing counts for overlapping hosts,
1136-// then returns sorted by total count descending.
1137-func mergeHosts(historical, current []*db.VisitUrl) []*db.VisitUrl {
1138- if len(historical) == 0 {
1139- return current
1140- }
1141- if len(current) == 0 {
1142- return historical
1143- }
1144-
1145- // Build a map by host for merging
1146- hostMap := make(map[string]*db.VisitUrl)
1147- for _, hu := range historical {
1148- hostMap[hu.Url] = hu
1149- }
1150-
1151- for _, cu := range current {
1152- if existing, ok := hostMap[cu.Url]; ok {
1153- existing.Count += cu.Count
1154- } else {
1155- hostMap[cu.Url] = cu
1156- }
1157- }
1158-
1159- // Sort by count descending
1160- result := make([]*db.VisitUrl, 0, len(hostMap))
1161- for _, h := range hostMap {
1162- result = append(result, h)
1163- }
1164- sort.Slice(result, func(i, j int) bool {
1165- return result[i].Count > result[j].Count
1166- })
1167- return result
1168-}
1169-
1170-func (me *PsqlDB) visitReferer(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1171- var historical, current []*db.VisitUrl
1172- var histErr, rawErr error
1173-
1174- var wg sync.WaitGroup
1175- wg.Add(2)
1176-
1177- go func() {
1178- defer wg.Done()
1179- historical, histErr = me.visitRefererFromSummary(opts)
1180- }()
1181- go func() {
1182- defer wg.Done()
1183- current, rawErr = me.visitRefererFromRaw(opts)
1184- }()
1185-
1186- wg.Wait()
1187-
1188- if histErr != nil {
1189- return nil, fmt.Errorf("query summary referers: %w", histErr)
1190- }
1191- if rawErr != nil {
1192- return nil, fmt.Errorf("query raw referers: %w", rawErr)
1193- }
1194-
1195- return mergeTopReferers(historical, current), nil
1196-}
1197-
1198-// visitRefererFromSummary reads top referers from analytics_monthly_top_referers for historical data.
1199-func (me *PsqlDB) visitRefererFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1200- now := time.Now()
1201- currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
1202- previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
1203-
1204- // If origin is in the previous month or later, raw data covers it — no summary to fetch.
1205- if !opts.Origin.Before(previousMonthStart) {
1206- return nil, nil
1207- }
1208-
1209- // Clamp origin to month boundary for summary table lookup
1210- originMonthStart := time.Date(opts.Origin.Year(), opts.Origin.Month(), 1, 0, 0, 0, 0, time.UTC)
1211-
1212- where := ""
1213- args := []interface{}{opts.UserID, originMonthStart, currentMonthStart}
1214- if opts.Host != "" {
1215- where = "AND host = $4"
1216- args = append(args, opts.Host)
1217- }
1218-
1219- query := fmt.Sprintf(`
1220- SELECT referer, sum(unique_visits) as total_visits
1221- FROM analytics_monthly_top_referers
1222- WHERE user_id = $1 AND month >= $2 AND month < $3 %s
1223- GROUP BY referer
1224- ORDER BY total_visits DESC
1225- LIMIT 10`, where)
1226-
1227- rows, err := me.Db.Queryx(query, args...)
1228- if err != nil {
1229- return nil, err
1230- }
1231- defer func() { _ = rows.Close() }()
1232-
1233- var results []*db.VisitUrl
1234- for rows.Next() {
1235- result := &db.VisitUrl{}
1236- if err := rows.Scan(&result.Url, &result.Count); err != nil {
1237- return nil, err
1238- }
1239- results = append(results, result)
1240- }
1241- return results, rows.Err()
1242-}
1243-
1244-// visitRefererFromRaw reads top referers from analytics_visits for the previous and current months.
1245-func (me *PsqlDB) visitRefererFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1246- now := time.Now()
1247- currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
1248- previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
1249-
1250- where, with := visitFilterBy(opts)
1251-
1252- // Determine the effective start: max(origin, previousMonthStart)
1253- effectiveStart := previousMonthStart
1254- if opts.Origin.After(previousMonthStart) {
1255- effectiveStart = opts.Origin
1256- }
1257-
1258- topUrls := fmt.Sprintf(`
1259- SELECT
1260- referer,
1261- count(DISTINCT ip_address) as referer_count
1262- FROM analytics_visits
1263- WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND referer <> '' AND status <> 404
1264- GROUP BY referer
1265- ORDER BY referer_count DESC
1266- LIMIT 10`, where)
1267-
1268- rows, err := me.Db.Queryx(topUrls, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
1269- if err != nil {
1270- return nil, err
1271- }
1272- defer func() { _ = rows.Close() }()
1273-
1274- var results []*db.VisitUrl
1275- for rows.Next() {
1276- result := &db.VisitUrl{}
1277- if err := rows.Scan(&result.Url, &result.Count); err != nil {
1278- return nil, err
1279- }
1280- results = append(results, result)
1281- }
1282- return results, rows.Err()
1283-}
1284-
1285-func (me *PsqlDB) visitUrl(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1286- var historical, current []*db.VisitUrl
1287- var histErr, rawErr error
1288-
1289- var wg sync.WaitGroup
1290- wg.Add(2)
1291-
1292- go func() {
1293- defer wg.Done()
1294- historical, histErr = me.visitUrlFromSummary(opts)
1295- }()
1296- go func() {
1297- defer wg.Done()
1298- current, rawErr = me.visitUrlFromRaw(opts)
1299- }()
1300-
1301- wg.Wait()
1302-
1303- if histErr != nil {
1304- return nil, fmt.Errorf("query summary urls: %w", histErr)
1305- }
1306- if rawErr != nil {
1307- return nil, fmt.Errorf("query raw urls: %w", rawErr)
1308- }
1309-
1310- return mergeTopUrls(historical, current), nil
1311-}
1312-
1313-// visitUrlFromSummary reads top URLs from analytics_monthly_top_urls for historical data.
1314-func (me *PsqlDB) visitUrlFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1315- now := time.Now()
1316- currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
1317- previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
1318-
1319- // If origin is in the previous month or later, raw data covers it — no summary to fetch.
1320- if !opts.Origin.Before(previousMonthStart) {
1321- return nil, nil
1322- }
1323-
1324- // Clamp origin to month boundary for summary table lookup
1325- originMonthStart := time.Date(opts.Origin.Year(), opts.Origin.Month(), 1, 0, 0, 0, 0, time.UTC)
1326-
1327- where := ""
1328- args := []interface{}{opts.UserID, originMonthStart, currentMonthStart}
1329- if opts.Host != "" {
1330- where = "AND host = $4"
1331- args = append(args, opts.Host)
1332- }
1333-
1334- query := fmt.Sprintf(`
1335- SELECT path, sum(unique_visits) as total_visits
1336- FROM analytics_monthly_top_urls
1337- WHERE user_id = $1 AND month >= $2 AND month < $3 AND status_code <> 404 %s
1338- GROUP BY path
1339- ORDER BY total_visits DESC
1340- LIMIT 10`, where)
1341-
1342- rows, err := me.Db.Queryx(query, args...)
1343- if err != nil {
1344- return nil, err
1345- }
1346- defer func() { _ = rows.Close() }()
1347-
1348- var results []*db.VisitUrl
1349- for rows.Next() {
1350- result := &db.VisitUrl{}
1351- if err := rows.Scan(&result.Url, &result.Count); err != nil {
1352- return nil, err
1353- }
1354- results = append(results, result)
1355- }
1356- return results, rows.Err()
1357-}
1358-
1359-// visitUrlFromRaw reads top URLs from analytics_visits for the previous and current months.
1360-func (me *PsqlDB) visitUrlFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1361- now := time.Now()
1362- currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
1363- previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
1364-
1365- where, with := visitFilterBy(opts)
1366-
1367- // Determine the effective start: max(origin, previousMonthStart)
1368- effectiveStart := previousMonthStart
1369- if opts.Origin.After(previousMonthStart) {
1370- effectiveStart = opts.Origin
1371- }
1372-
1373- topUrls := fmt.Sprintf(`
1374- SELECT
1375- path,
1376- count(DISTINCT ip_address) as path_count
1377- FROM analytics_visits
1378- WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND path <> '' AND status <> 404
1379- GROUP BY path
1380- ORDER BY path_count DESC
1381- LIMIT 10`, where)
1382-
1383- rows, err := me.Db.Queryx(topUrls, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
1384- if err != nil {
1385- return nil, err
1386- }
1387- defer func() { _ = rows.Close() }()
1388-
1389- var results []*db.VisitUrl
1390- for rows.Next() {
1391- result := &db.VisitUrl{}
1392- if err := rows.Scan(&result.Url, &result.Count); err != nil {
1393- return nil, err
1394- }
1395- results = append(results, result)
1396- }
1397- return results, rows.Err()
1398-}
1399-
1400-func (me *PsqlDB) VisitUrlNotFound(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1401- limit := opts.Limit
1402- if limit == 0 {
1403- limit = 10
1404- }
1405-
1406- var historical, current []*db.VisitUrl
1407- var histErr, rawErr error
1408-
1409- var wg sync.WaitGroup
1410- wg.Add(2)
1411-
1412- go func() {
1413- defer wg.Done()
1414- historical, histErr = me.visitUrlNotFoundFromSummary(opts, limit)
1415- }()
1416- go func() {
1417- defer wg.Done()
1418- current, rawErr = me.visitUrlNotFoundFromRaw(opts, limit)
1419- }()
1420-
1421- wg.Wait()
1422-
1423- if histErr != nil {
1424- return nil, fmt.Errorf("query summary 404 urls: %w", histErr)
1425- }
1426- if rawErr != nil {
1427- return nil, fmt.Errorf("query raw 404 urls: %w", rawErr)
1428- }
1429-
1430- return mergeTopUrls(historical, current), nil
1431-}
1432-
1433-// visitUrlNotFoundFromSummary reads top 404 URLs from analytics_monthly_top_urls for historical data.
1434-func (me *PsqlDB) visitUrlNotFoundFromSummary(opts *db.SummaryOpts, limit int) ([]*db.VisitUrl, error) {
1435- now := time.Now()
1436- currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
1437- previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
1438-
1439- // If origin is in the previous month or later, raw data covers it — no summary to fetch.
1440- if !opts.Origin.Before(previousMonthStart) {
1441- return nil, nil
1442- }
1443-
1444- // Clamp origin to month boundary for summary table lookup
1445- originMonthStart := time.Date(opts.Origin.Year(), opts.Origin.Month(), 1, 0, 0, 0, 0, time.UTC)
1446-
1447- where := ""
1448- args := []interface{}{opts.UserID, originMonthStart, currentMonthStart}
1449- argIdx := 4
1450- if opts.Host != "" {
1451- where = "AND host = $" + fmt.Sprintf("%d", argIdx)
1452- args = append(args, opts.Host)
1453- }
1454-
1455- query := fmt.Sprintf(`
1456- SELECT path, sum(unique_visits) as total_visits
1457- FROM analytics_monthly_top_urls
1458- WHERE user_id = $1 AND month >= $2 AND month < $3 AND status_code = 404 %s
1459- GROUP BY path
1460- ORDER BY total_visits DESC
1461- LIMIT %d`, where, limit)
1462-
1463- rows, err := me.Db.Queryx(query, args...)
1464- if err != nil {
1465- return nil, err
1466- }
1467- defer func() { _ = rows.Close() }()
1468-
1469- var results []*db.VisitUrl
1470- for rows.Next() {
1471- result := &db.VisitUrl{}
1472- if err := rows.Scan(&result.Url, &result.Count); err != nil {
1473- return nil, err
1474- }
1475- results = append(results, result)
1476- }
1477- return results, rows.Err()
1478-}
1479-
1480-// visitUrlNotFoundFromRaw reads top 404 URLs from analytics_visits for the previous and current months.
1481-func (me *PsqlDB) visitUrlNotFoundFromRaw(opts *db.SummaryOpts, limit int) ([]*db.VisitUrl, error) {
1482- now := time.Now()
1483- currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
1484- previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
1485-
1486- where, with := visitFilterBy(opts)
1487-
1488- // Determine the effective start: max(origin, previousMonthStart)
1489- effectiveStart := previousMonthStart
1490- if opts.Origin.After(previousMonthStart) {
1491- effectiveStart = opts.Origin
1492- }
1493-
1494- topUrls := fmt.Sprintf(`
1495- SELECT
1496- path,
1497- count(DISTINCT ip_address) as path_count
1498- FROM analytics_visits
1499- WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND path <> '' AND status = 404
1500- GROUP BY path
1501- ORDER BY path_count DESC
1502- LIMIT %d`, where, limit)
1503-
1504- rows, err := me.Db.Queryx(topUrls, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
1505- if err != nil {
1506- return nil, err
1507- }
1508- defer func() { _ = rows.Close() }()
1509-
1510- var results []*db.VisitUrl
1511- for rows.Next() {
1512- result := &db.VisitUrl{}
1513- if err := rows.Scan(&result.Url, &result.Count); err != nil {
1514- return nil, err
1515- }
1516- results = append(results, result)
1517- }
1518- return results, rows.Err()
1519-}
1520-
1521-func (me *PsqlDB) visitHost(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1522- var historical, current []*db.VisitUrl
1523- var histErr, rawErr error
1524-
1525- var wg sync.WaitGroup
1526- wg.Add(2)
1527-
1528- go func() {
1529- defer wg.Done()
1530- historical, histErr = me.visitHostFromSummary(opts)
1531- }()
1532- go func() {
1533- defer wg.Done()
1534- current, rawErr = me.visitHostFromRaw(opts)
1535- }()
1536-
1537- wg.Wait()
1538-
1539- if histErr != nil {
1540- return nil, fmt.Errorf("query summary hosts: %w", histErr)
1541- }
1542- if rawErr != nil {
1543- return nil, fmt.Errorf("query raw hosts: %w", rawErr)
1544- }
1545-
1546- return mergeHosts(historical, current), nil
1547-}
1548-
1549-// visitHostFromSummary reads host data from analytics_user_sites for historical data.
1550-func (me *PsqlDB) visitHostFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1551- rows, err := me.Db.Queryx(`
1552- SELECT host, total_visits
1553- FROM analytics_user_sites
1554- WHERE user_id = $1 AND host <> ''
1555- ORDER BY total_visits DESC`, opts.UserID)
1556- if err != nil {
1557- return nil, err
1558- }
1559- defer func() { _ = rows.Close() }()
1560-
1561- var results []*db.VisitUrl
1562- for rows.Next() {
1563- result := &db.VisitUrl{}
1564- if err := rows.Scan(&result.Url, &result.Count); err != nil {
1565- return nil, err
1566- }
1567- results = append(results, result)
1568- }
1569- return results, rows.Err()
1570-}
1571-
1572-// visitHostFromRaw reads hosts from analytics_visits for the current month that aren't in summary.
1573-func (me *PsqlDB) visitHostFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1574- now := time.Now()
1575- currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
1576-
1577- rows, err := me.Db.Queryx(`
1578- SELECT host, count(DISTINCT ip_address) as host_count
1579- FROM analytics_visits
1580- WHERE created_at >= $1 AND user_id = $2 AND host <> ''
1581- GROUP BY host
1582- ORDER BY host_count DESC`, currentMonthStart, opts.UserID)
1583- if err != nil {
1584- return nil, err
1585- }
1586- defer func() { _ = rows.Close() }()
1587-
1588- var results []*db.VisitUrl
1589- for rows.Next() {
1590- result := &db.VisitUrl{}
1591- if err := rows.Scan(&result.Url, &result.Count); err != nil {
1592- return nil, err
1593- }
1594- results = append(results, result)
1595- }
1596- return results, rows.Err()
1597-}
1598-
1599-func (me *PsqlDB) VisitSummary(opts *db.SummaryOpts) (*db.SummaryVisits, error) {
1600- var (
1601- visitors []*db.VisitInterval
1602- urls []*db.VisitUrl
1603- refs []*db.VisitUrl
1604- notFound []*db.VisitUrl
1605- visitorsErr error
1606- urlsErr error
1607- refsErr error
1608- nfErr error
1609- )
1610-
1611- var wg sync.WaitGroup
1612- wg.Add(4)
1613-
1614- go func() {
1615- defer wg.Done()
1616- visitors, visitorsErr = me.visitUnique(opts)
1617- }()
1618- go func() {
1619- defer wg.Done()
1620- urls, urlsErr = me.visitUrl(opts)
1621- }()
1622- go func() {
1623- defer wg.Done()
1624- refs, refsErr = me.visitReferer(opts)
1625- }()
1626- go func() {
1627- defer wg.Done()
1628- notFound, nfErr = me.VisitUrlNotFound(opts)
1629- }()
1630-
1631- wg.Wait()
1632-
1633- // Return the first error encountered
1634- for _, err := range []error{visitorsErr, urlsErr, refsErr, nfErr} {
1635- if err != nil {
1636- return nil, err
1637- }
1638- }
1639-
1640- return &db.SummaryVisits{
1641- Intervals: visitors,
1642- TopUrls: urls,
1643- TopReferers: refs,
1644- NotFoundUrls: notFound,
1645- }, nil
1646-}
1647-
1648-func (me *PsqlDB) FindVisitSiteList(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1649- return me.visitHost(opts)
1650-}
1651-
1652-func (me *PsqlDB) FindUsers() ([]*db.User, error) {
1653- var users []*db.User
1654- err := me.Db.Select(&users, `SELECT id, COALESCE(name, '') as name, created_at FROM app_users ORDER BY name ASC`)
1655- if err != nil {
1656- return nil, err
1657- }
1658- return users, nil
1659-}
1660-
1661-func (me *PsqlDB) removeTagsByPost(tx *sqlx.Tx, postID string) error {
1662- _, err := tx.Exec(`DELETE FROM post_tags WHERE post_id = $1`, postID)
1663- return err
1664-}
1665-
1666-func (me *PsqlDB) insertTagsByPost(tx *sqlx.Tx, tags []string, postID string) ([]string, error) {
1667- ids := make([]string, 0)
1668- for _, tag := range tags {
1669- id := ""
1670- err := tx.QueryRow(`INSERT INTO post_tags (post_id, name) VALUES($1, $2) RETURNING id;`, postID, tag).Scan(&id)
1671- if err != nil {
1672- return nil, err
1673- }
1674- ids = append(ids, id)
1675- }
1676-
1677- return ids, nil
1678-}
1679-
1680-func (me *PsqlDB) ReplaceTagsByPost(tags []string, postID string) error {
1681- tx, err := me.Db.Beginx()
1682- if err != nil {
1683- return err
1684- }
1685- defer func() {
1686- _ = tx.Rollback()
1687- }()
1688-
1689- err = me.removeTagsByPost(tx, postID)
1690- if err != nil {
1691- return err
1692- }
1693-
1694- _, err = me.insertTagsByPost(tx, tags, postID)
1695- if err != nil {
1696- return err
1697- }
1698-
1699- err = tx.Commit()
1700- return err
1701-}
1702-
1703-func (me *PsqlDB) removeAliasesByPost(tx *sqlx.Tx, postID string) error {
1704- _, err := tx.Exec(`DELETE FROM post_aliases WHERE post_id = $1`, postID)
1705- return err
1706-}
1707-
1708-func (me *PsqlDB) insertAliasesByPost(tx *sqlx.Tx, aliases []string, postID string) ([]string, error) {
1709- // hardcoded
1710- denyList := []string{
1711- "rss",
1712- "rss.xml",
1713- "rss.atom",
1714- "atom.xml",
1715- "feed.xml",
1716- "smol.css",
1717- "main.css",
1718- "syntax.css",
1719- "card.png",
1720- "favicon-16x16.png",
1721- "favicon-32x32.png",
1722- "apple-touch-icon.png",
1723- "favicon.ico",
1724- "robots.txt",
1725- "atom",
1726- "blog/index.xml",
1727- }
1728-
1729- ids := make([]string, 0)
1730- for _, alias := range aliases {
1731- if slices.Contains(denyList, alias) {
1732- me.Logger.Info(
1733- "name is in the deny list for aliases because it conflicts with a static route, skipping",
1734- "alias", alias,
1735- )
1736- continue
1737- }
1738- id := ""
1739- err := tx.QueryRow(`INSERT INTO post_aliases (post_id, slug) VALUES($1, $2) RETURNING id;`, postID, alias).Scan(&id)
1740- if err != nil {
1741- return nil, err
1742- }
1743- ids = append(ids, id)
1744- }
1745-
1746- return ids, nil
1747-}
1748-
1749-func (me *PsqlDB) ReplaceAliasesByPost(aliases []string, postID string) error {
1750- tx, err := me.Db.Beginx()
1751- if err != nil {
1752- return err
1753- }
1754- defer func() {
1755- _ = tx.Rollback()
1756- }()
1757-
1758- err = me.removeAliasesByPost(tx, postID)
1759- if err != nil {
1760- return err
1761- }
1762-
1763- _, err = me.insertAliasesByPost(tx, aliases, postID)
1764- if err != nil {
1765- return err
1766- }
1767-
1768- err = tx.Commit()
1769- return err
1770-}
1771-
1772-func (me *PsqlDB) FindUserPostsByTag(page *db.Pager, tag, userID, space string) (*db.Paginate[*db.Post], error) {
1773- var posts []*db.Post
1774- query := fmt.Sprintf(`
1775- SELECT %s
1776- FROM posts
1777- LEFT JOIN app_users ON app_users.id = posts.user_id
1778- LEFT JOIN post_tags ON post_tags.post_id = posts.id
1779- WHERE
1780- hidden = FALSE AND
1781- user_id = $1 AND
1782- (post_tags.name = $2 OR hidden = true) AND
1783- publish_at::date <= CURRENT_DATE AND
1784- cur_space = $3
1785- ORDER BY publish_at DESC
1786- LIMIT $4 OFFSET $5`, SelectPost)
1787- err := me.Db.Select(
1788- &posts,
1789- query,
1790- userID,
1791- tag,
1792- space,
1793- page.Num,
1794- page.Num*page.Page,
1795- )
1796- if err != nil {
1797- return nil, err
1798- }
1799-
1800- var count int
1801- err = me.Db.QueryRow(`SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`, space).Scan(&count)
1802- if err != nil {
1803- return nil, err
1804- }
1805-
1806- pager := &db.Paginate[*db.Post]{
1807- Data: posts,
1808- Total: int(math.Ceil(float64(count) / float64(page.Num))),
1809- }
1810- return pager, nil
1811-}
1812-
1813-func (me *PsqlDB) FindPostsByTag(pager *db.Pager, tag, space string) (*db.Paginate[*db.Post], error) {
1814- query := `
1815- SELECT
1816- posts.id,
1817- user_id,
1818- filename,
1819- slug,
1820- title,
1821- text,
1822- description,
1823- publish_at,
1824- app_users.name as username,
1825- posts.updated_at,
1826- posts.mime_type
1827- FROM posts
1828- LEFT JOIN app_users ON app_users.id = posts.user_id
1829- LEFT JOIN post_tags ON post_tags.post_id = posts.id
1830- WHERE
1831- post_tags.name = $3 AND
1832- publish_at::date <= CURRENT_DATE AND
1833- cur_space = $4
1834- ORDER BY publish_at DESC
1835- LIMIT $1 OFFSET $2`
1836- rs, err := me.Db.Queryx(
1837- query,
1838- pager.Num,
1839- pager.Num*pager.Page,
1840- tag,
1841- space,
1842- )
1843- if err != nil {
1844- return nil, err
1845- }
1846- defer func() { _ = rs.Close() }()
1847-
1848- return me.postPager(rs, pager.Num, space, tag)
1849-}
1850-
1851-func (me *PsqlDB) FindPopularTags(space string) ([]string, error) {
1852- tags := make([]string, 0)
1853- query := `
1854- SELECT name, count(post_id) as "tally"
1855- FROM post_tags
1856- LEFT JOIN posts ON posts.id = post_id
1857- WHERE posts.cur_space = $1
1858- GROUP BY name
1859- ORDER BY tally DESC
1860- LIMIT 5`
1861- rs, err := me.Db.Queryx(query, space)
1862- if err != nil {
1863- return tags, err
1864- }
1865- defer func() { _ = rs.Close() }()
1866- for rs.Next() {
1867- name := ""
1868- tally := 0
1869- err := rs.Scan(&name, &tally)
1870- if err != nil {
1871- return tags, err
1872- }
1873-
1874- tags = append(tags, name)
1875- }
1876- if rs.Err() != nil {
1877- return tags, rs.Err()
1878- }
1879- return tags, nil
1880-}
1881-
1882-func (me *PsqlDB) FindFeature(userID string, feature string) (*db.FeatureFlag, error) {
1883- ff := &db.FeatureFlag{}
1884- err := me.Db.Get(ff, `SELECT * FROM feature_flags WHERE user_id = $1 AND name = $2 ORDER BY expires_at DESC LIMIT 1`, userID, feature)
1885- if err != nil {
1886- return nil, err
1887- }
1888- return ff, nil
1889-}
1890-
1891-func (me *PsqlDB) FindFeaturesByUser(userID string) ([]*db.FeatureFlag, error) {
1892- var features []*db.FeatureFlag
1893- // https://stackoverflow.com/a/16920077
1894- query := `SELECT DISTINCT ON (name) *
1895- FROM feature_flags
1896- WHERE user_id=$1
1897- ORDER BY name, expires_at DESC;`
1898- err := me.Db.Select(&features, query, userID)
1899- if err != nil {
1900- return nil, err
1901- }
1902- return features, nil
1903-}
1904-
1905-func (me *PsqlDB) HasFeatureByUser(userID string, feature string) bool {
1906- ff, err := me.FindFeature(userID, feature)
1907- if err != nil {
1908- return false
1909- }
1910- return ff.IsValid()
1911-}
1912-
1913-func (me *PsqlDB) InsertFeedItems(postID string, items []*db.FeedItem) error {
1914- tx, err := me.Db.Beginx()
1915- if err != nil {
1916- return err
1917- }
1918- defer func() {
1919- _ = tx.Rollback()
1920- }()
1921-
1922- for _, item := range items {
1923- _, err := tx.Exec(
1924- `INSERT INTO feed_items (post_id, guid, data) VALUES ($1, $2, $3) RETURNING id;`,
1925- item.PostID,
1926- item.GUID,
1927- item.Data,
1928- )
1929- if err != nil {
1930- return fmt.Errorf(
1931- "post id:%s, link:%s, guid:%s, err:%w",
1932- item.PostID, item.Data.Link, item.GUID, err,
1933- )
1934- }
1935- }
1936-
1937- err = tx.Commit()
1938- return err
1939-}
1940-
1941-func (me *PsqlDB) FindFeedItemsByPostID(postID string) ([]*db.FeedItem, error) {
1942- var items []*db.FeedItem
1943- err := me.Db.Select(&items, `SELECT * FROM feed_items WHERE post_id=$1`, postID)
1944- if err != nil {
1945- return nil, err
1946- }
1947- return items, nil
1948-}
1949-
1950-func (me *PsqlDB) InsertProject(userID, name, projectDir string) (string, error) {
1951- if !shared.IsValidSubdomain(name) {
1952- return "", fmt.Errorf("'%s' is not a valid project name, must match /^[a-z0-9-]+$/", name)
1953- }
1954-
1955- var id string
1956- err := me.Db.QueryRow(`INSERT INTO projects (user_id, name, project_dir) VALUES ($1, $2, $3) RETURNING id;`, userID, name, projectDir).Scan(&id)
1957- if err != nil {
1958- return "", err
1959- }
1960- return id, nil
1961-}
1962-
1963-func (me *PsqlDB) UpdateProject(userID, name string) error {
1964- _, err := me.Db.Exec(`UPDATE projects SET updated_at = $3 WHERE user_id = $1 AND name = $2;`, userID, name, time.Now())
1965- return err
1966-}
1967-
1968-func (me *PsqlDB) FindProjectByName(userID, name string) (*db.Project, error) {
1969- project := &db.Project{}
1970- err := me.Db.Get(project, `SELECT * FROM projects WHERE user_id = $1 AND name = $2;`, userID, name)
1971- if err != nil {
1972- return nil, err
1973- }
1974- return project, nil
1975-}
1976-
1977-func (me *PsqlDB) InsertToken(userID, name string) (string, error) {
1978- var token string
1979- err := me.Db.QueryRow(`INSERT INTO tokens (user_id, name) VALUES($1, $2) RETURNING token;`, userID, name).Scan(&token)
1980- if err != nil {
1981- return "", err
1982- }
1983- return token, nil
1984-}
1985-
1986-func (me *PsqlDB) UpsertToken(userID, name string) (string, error) {
1987- token, _ := me.findTokenByName(userID, name)
1988- if token != "" {
1989- return token, nil
1990- }
1991-
1992- token, err := me.InsertToken(userID, name)
1993- return token, err
1994-}
1995-
1996-func (me *PsqlDB) findTokenByName(userID, name string) (string, error) {
1997- var token string
1998- err := me.Db.QueryRow(`SELECT token FROM tokens WHERE user_id = $1 AND name = $2`, userID, name).Scan(&token)
1999- if err != nil {
2000- return "", err
2001- }
2002- return token, nil
2003-}
2004-
2005-func (me *PsqlDB) RemoveToken(tokenID string) error {
2006- _, err := me.Db.Exec(`DELETE FROM tokens WHERE id = $1`, tokenID)
2007- return err
2008-}
2009-
2010-func (me *PsqlDB) FindTokensByUser(userID string) ([]*db.Token, error) {
2011- var tokens []*db.Token
2012- err := me.Db.Select(&tokens, `SELECT * FROM tokens WHERE user_id = $1`, userID)
2013- if err != nil {
2014- return nil, err
2015- }
2016- return tokens, nil
2017-}
2018-
2019-func (me *PsqlDB) InsertFeature(userID, name string, expiresAt time.Time) (*db.FeatureFlag, error) {
2020- var featureID string
2021- err := me.Db.QueryRow(
2022- `INSERT INTO feature_flags (user_id, name, expires_at) VALUES ($1, $2, $3) RETURNING id;`,
2023- userID,
2024- name,
2025- expiresAt,
2026- ).Scan(&featureID)
2027- if err != nil {
2028- return nil, err
2029- }
2030-
2031- feature, err := me.FindFeature(userID, name)
2032- if err != nil {
2033- return nil, err
2034- }
2035-
2036- return feature, nil
2037-}
2038-
2039-func (me *PsqlDB) RemoveFeature(userID string, name string) error {
2040- _, err := me.Db.Exec(`DELETE FROM feature_flags WHERE user_id = $1 AND name = $2`, userID, name)
2041- return err
2042-}
2043-
2044-func (me *PsqlDB) createFeatureExpiresAt(userID, name string) time.Time {
2045- ff, _ := me.FindFeature(userID, name)
2046- // if the feature flag has already expired we don't want to add a year to it since that will
2047- // not grant the user a full year
2048- if ff == nil || !ff.IsValid() {
2049- t := time.Now()
2050- return t.AddDate(1, 0, 0)
2051- }
2052- return ff.ExpiresAt.AddDate(1, 0, 0)
2053-}
2054-
2055-func (me *PsqlDB) AddPicoPlusUser(username, email, paymentType, txId string) error {
2056- user, err := me.FindUserByName(username)
2057- if err != nil {
2058- return err
2059- }
2060-
2061- tx, err := me.Db.Beginx()
2062- if err != nil {
2063- return err
2064- }
2065- defer func() {
2066- _ = tx.Rollback()
2067- }()
2068-
2069- var paymentHistoryId sql.NullString
2070- if paymentType != "" {
2071- data := db.PaymentHistoryData{
2072- Notes: "",
2073- TxID: txId,
2074- }
2075-
2076- err := tx.QueryRow(
2077- `INSERT INTO payment_history (user_id, payment_type, amount, data) VALUES ($1, $2, 24 * 1000000, $3) RETURNING id;`,
2078- user.ID,
2079- paymentType,
2080- data,
2081- ).Scan(&paymentHistoryId)
2082- if err != nil {
2083- return err
2084- }
2085- }
2086-
2087- plus := me.createFeatureExpiresAt(user.ID, "plus")
2088- plusQuery := fmt.Sprintf(`INSERT INTO feature_flags (user_id, name, data, expires_at, payment_history_id)
2089- VALUES ($1, 'plus', '{"storage_max":5000000000, "file_max":100000000, "email": "%s"}'::jsonb, $2, $3);`, email)
2090- _, err = tx.Exec(plusQuery, user.ID, plus, paymentHistoryId)
2091- if err != nil {
2092- return err
2093- }
2094-
2095- return tx.Commit()
2096-}
2097-
2098-func (me *PsqlDB) AddFeatureUser(username, name string) error {
2099- user, err := me.FindUserByName(username)
2100- if err != nil {
2101- return err
2102- }
2103-
2104- expiresAt := me.createFeatureExpiresAt(user.ID, name)
2105- _, err = me.InsertFeature(user.ID, name, expiresAt)
2106- return err
2107-}
2108-
2109-func (me *PsqlDB) UpsertProject(userID, projectName, projectDir string) (*db.Project, error) {
2110- project, err := me.FindProjectByName(userID, projectName)
2111- if err == nil {
2112- // this just updates the `createdAt` timestamp, useful for book-keeping
2113- err = me.UpdateProject(userID, projectName)
2114- if err != nil {
2115- me.Logger.Error("could not update project", "err", err)
2116- return nil, err
2117- }
2118- return project, nil
2119- }
2120-
2121- _, err = me.InsertProject(userID, projectName, projectName)
2122- if err != nil {
2123- me.Logger.Error("could not create project", "err", err)
2124- return nil, err
2125- }
2126- return me.FindProjectByName(userID, projectName)
2127-}
2128-
2129-func (me *PsqlDB) findPagesStats(userID string) (*db.UserServiceStats, error) {
2130- stats := db.UserServiceStats{
2131- Service: "pgs",
2132- }
2133- // the aggregates are NULL when the user has no projects
2134- var first, latestCreated, latestUpdated sql.NullTime
2135- err := me.Db.QueryRow(
2136- `SELECT count(id), min(created_at), max(created_at), max(updated_at) FROM projects WHERE user_id=$1`,
2137- userID,
2138- ).Scan(&stats.Num, &first, &latestCreated, &latestUpdated)
2139- if err != nil {
2140- return nil, err
2141- }
2142- stats.FirstCreatedAt = first.Time
2143- stats.LastestCreatedAt = latestCreated.Time
2144- stats.LatestUpdatedAt = latestUpdated.Time
2145-
2146- return &stats, nil
2147-}
2148-
2149-func (me *PsqlDB) InsertTunsEventLog(log *db.TunsEventLog) error {
2150- _, err := me.Db.Exec(
2151- `INSERT INTO tuns_event_logs
2152- (user_id, server_id, remote_addr, event_type, tunnel_type, connection_type, tunnel_id)
2153- VALUES
2154- ($1, $2, $3, $4, $5, $6, $7)`,
2155- log.UserId, log.ServerID, log.RemoteAddr, log.EventType, log.TunnelType,
2156- log.ConnectionType, log.TunnelID,
2157- )
2158- return err
2159-}
2160-
2161-func (me *PsqlDB) FindTunsEventLogsByAddr(userID, addr string) ([]*db.TunsEventLog, error) {
2162- var logs []*db.TunsEventLog
2163- err := me.Db.Select(&logs,
2164- `SELECT * FROM tuns_event_logs WHERE user_id=$1 AND tunnel_id=$2 ORDER BY created_at DESC`, userID, addr)
2165- if err != nil {
2166- return nil, err
2167- }
2168- return logs, nil
2169-}
2170-
2171-func (me *PsqlDB) FindTunsEventLogs(userID string) ([]*db.TunsEventLog, error) {
2172- var logs []*db.TunsEventLog
2173- err := me.Db.Select(&logs,
2174- `SELECT * FROM tuns_event_logs WHERE user_id=$1 ORDER BY created_at DESC`, userID)
2175- if err != nil {
2176- return nil, err
2177- }
2178- return logs, nil
2179-}
2180-
2181-func (me *PsqlDB) FindUserStats(userID string) (*db.UserStats, error) {
2182- stats := db.UserStats{}
2183- rs, err := me.Db.Queryx(`SELECT cur_space, count(id), min(created_at), max(created_at), max(updated_at) FROM posts WHERE user_id=$1 GROUP BY cur_space`, userID)
2184- if err != nil {
2185- return nil, err
2186- }
2187- defer func() { _ = rs.Close() }()
2188-
2189- for rs.Next() {
2190- stat := db.UserServiceStats{}
2191- err := rs.Scan(&stat.Service, &stat.Num, &stat.FirstCreatedAt, &stat.LastestCreatedAt, &stat.LatestUpdatedAt)
2192- if err != nil {
2193- return nil, err
2194- }
2195- switch stat.Service {
2196- case "prose":
2197- stats.Prose = stat
2198- case "pastes":
2199- stats.Pastes = stat
2200- case "feeds":
2201- stats.Feeds = stat
2202- }
2203- }
2204-
2205- if rs.Err() != nil {
2206- return nil, rs.Err()
2207- }
2208-
2209- pgs, err := me.findPagesStats(userID)
2210- if err != nil {
2211- return nil, err
2212- }
2213- stats.Pages = *pgs
2214- return &stats, err
2215-}
2216-
2217-func (me *PsqlDB) FindAccessLogs(userID string, fromDate *time.Time) ([]*db.AccessLog, error) {
2218- var logs []*db.AccessLog
2219- err := me.Db.Select(&logs, `SELECT * FROM access_logs WHERE user_id=$1 AND created_at >= $2 ORDER BY created_at ASC`, userID, fromDate)
2220- if err != nil {
2221- return nil, err
2222- }
2223- return logs, nil
2224-}
2225-
2226-func (me *PsqlDB) FindAccessLogsByPubkey(pubkey string, fromDate *time.Time) ([]*db.AccessLog, error) {
2227- var logs []*db.AccessLog
2228- err := me.Db.Select(&logs, `SELECT * FROM access_logs WHERE pubkey=$1 AND created_at >= $2 ORDER BY created_at ASC`, pubkey, fromDate)
2229- if err != nil {
2230- return nil, err
2231- }
2232- return logs, nil
2233-}
2234-
2235-func (me *PsqlDB) FindPubkeysInAccessLogs(userID string) ([]string, error) {
2236- var pubkeys []string
2237- err := me.Db.Select(&pubkeys, `SELECT DISTINCT(pubkey) FROM access_logs WHERE user_id=$1`, userID)
2238- if err != nil {
2239- return nil, err
2240- }
2241- return pubkeys, nil
2242-}
2243-
2244-func (me *PsqlDB) InsertAccessLog(log *db.AccessLog) error {
2245- _, err := me.Db.Exec(
2246- `INSERT INTO access_logs (user_id, service, pubkey, identity) VALUES ($1, $2, $3, $4);`,
2247- log.UserID,
2248- log.Service,
2249- log.Pubkey,
2250- log.Identity,
2251- )
2252- return err
2253-}
2254-
2255-func (me *PsqlDB) UpsertPipeMonitor(userID, topic string, dur time.Duration, winEnd *time.Time) error {
2256- durStr := fmt.Sprintf("%d seconds", int64(dur.Seconds()))
2257- _, err := me.Db.Exec(
2258- `INSERT INTO pipe_monitors (user_id, topic, window_dur, window_end)
2259- VALUES ($1, $2, $3::interval, $4)
2260- ON CONFLICT (user_id, topic) DO UPDATE SET window_dur = $3::interval, window_end = $4, updated_at = NOW();`,
2261- userID,
2262- topic,
2263- durStr,
2264- winEnd,
2265- )
2266- return err
2267-}
2268-
2269-func (me *PsqlDB) UpdatePipeMonitorLastPing(userID, topic string, lastPing *time.Time) error {
2270- _, err := me.Db.Exec(
2271- `UPDATE pipe_monitors SET last_ping = $3, updated_at = NOW() WHERE user_id = $1 AND topic = $2;`,
2272- userID,
2273- topic,
2274- lastPing,
2275- )
2276- return err
2277-}
2278-
2279-func (me *PsqlDB) RemovePipeMonitor(userID, topic string) error {
2280- _, err := me.Db.Exec(
2281- `DELETE FROM pipe_monitors WHERE user_id = $1 AND topic = $2;`,
2282- userID,
2283- topic,
2284- )
2285- return err
2286-}
2287-
2288-func (me *PsqlDB) FindPipeMonitorByTopic(userID, topic string) (*db.PipeMonitor, error) {
2289- monitor := &db.PipeMonitor{}
2290- err := me.Db.Get(monitor, `SELECT id, user_id, topic, (EXTRACT(EPOCH FROM window_dur) * 1000000000)::bigint as window_dur, window_end, last_ping, created_at, updated_at FROM pipe_monitors WHERE user_id = $1 AND topic = $2;`, userID, topic)
2291- if err != nil {
2292- return nil, err
2293- }
2294- return monitor, nil
2295-}
2296-
2297-func (me *PsqlDB) FindPipeMonitorsByUser(userID string) ([]*db.PipeMonitor, error) {
2298- var monitors []*db.PipeMonitor
2299- err := me.Db.Select(&monitors, `SELECT id, user_id, topic, (EXTRACT(EPOCH FROM window_dur) * 1000000000)::bigint as window_dur, window_end, last_ping, created_at, updated_at FROM pipe_monitors WHERE user_id = $1 ORDER BY topic;`, userID)
2300- if err != nil {
2301- return nil, err
2302- }
2303- return monitors, nil
2304-}
2305-
2306-func (me *PsqlDB) InsertPipeMonitorHistory(monitorID string, windowDur time.Duration, windowEnd, lastPing *time.Time) error {
2307- durStr := fmt.Sprintf("%d seconds", int64(windowDur.Seconds()))
2308- _, err := me.Db.Exec(
2309- `INSERT INTO pipe_monitors_history (monitor_id, window_dur, window_end, last_ping) VALUES ($1, $2::interval, $3, $4)`,
2310- monitorID, durStr, windowEnd, lastPing,
2311- )
2312- return err
2313-}
2314-
2315-func (me *PsqlDB) FindPipeMonitorHistory(monitorID string, from, to time.Time) ([]*db.PipeMonitorHistory, error) {
2316- var history []*db.PipeMonitorHistory
2317- err := me.Db.Select(
2318- &history,
2319- `SELECT id, monitor_id, (EXTRACT(EPOCH FROM window_dur) * 1000000000)::bigint as window_dur, window_end, last_ping, created_at, updated_at FROM pipe_monitors_history WHERE monitor_id = $1 AND last_ping <= $2 AND window_end >= $3 ORDER BY last_ping ASC`,
2320- monitorID, to, from,
2321- )
2322- if err != nil {
2323- return nil, err
2324- }
2325- return history, nil
2326-}
2327-
2328-func (me *PsqlDB) FindWhoInvitedUser(toUserID string) (string, error) {
2329- var inviterName string
2330- query := `
2331- SELECT app_users.name
2332- FROM invites
2333- LEFT JOIN app_users ON app_users.id = invites.from_user_id
2334- WHERE to_user_id=$1
2335- `
2336- err := me.Db.Get(&inviterName, query, toUserID)
2337- if err != nil {
2338- return "", err
2339- }
2340-
2341- return inviterName, nil
2342-}
2343-
2344-func (me *PsqlDB) FindInvitesByUser(userID string) ([]*db.Invite, error) {
2345- var invites []*db.Invite
2346- query := `SELECT
2347- invites.id, invites.from_user_id, invites.to_user_id, invites.created_at,
2348- app_users.name as to_user_name
2349- FROM invites
2350- LEFT JOIN app_users ON app_users.id = invites.to_user_id
2351- WHERE from_user_id=$1`
2352- err := me.Db.Select(&invites, query, userID)
2353- if err != nil {
2354- return nil, err
2355- }
2356- return invites, nil
2357-}
2358-
2359-// inviteFeatures are the services an invite grants access to.
2360-var inviteFeatures = []string{"prose", "pgs", "pastes"}
2361-
2362-func (me *PsqlDB) hasActiveInvite(userID string) (bool, error) {
2363- var invited bool
2364- err := me.Db.Get(&invited, `SELECT EXISTS (SELECT 1 FROM invites WHERE to_user_id = $1)`, userID)
2365- if err != nil || !invited {
2366- return false, err
2367- }
2368- for _, name := range inviteFeatures {
2369- if me.HasFeatureByUser(userID, name) {
2370- return true, nil
2371- }
2372- }
2373- return false, nil
2374-}
2375-
2376-func (me *PsqlDB) InviteUser(fromUserID string, toUserID string) error {
2377- canInvite := me.HasFeatureByUser(fromUserID, "plus")
2378- if !canInvite {
2379- invited, err := me.hasActiveInvite(fromUserID)
2380- if err != nil {
2381- return err
2382- }
2383- canInvite = invited
2384- }
2385-
2386- if !canInvite {
2387- return fmt.Errorf("must have valid pico+ membership or an active invite from someone")
2388- }
2389-
2390- _, err := me.Db.Exec(
2391- `INSERT INTO invites (from_user_id, to_user_id) VALUES ($1, $2)`,
2392- fromUserID, toUserID,
2393- )
2394- if err != nil {
2395- return err
2396- }
2397-
2398- expiresAt := time.Now().Add(5 * 365 * 24 * time.Hour)
2399- for _, name := range inviteFeatures {
2400- if _, err := me.InsertFeature(toUserID, name, expiresAt); err != nil {
2401- return err
2402- }
2403- }
2404- return nil
2405-}
+6, -587
......@@ -1,195 +1,36 @@
11 package postgres
22
33 import (
4- "context"
54 "errors"
6- "fmt"
75 "log/slog"
86 "os"
9- "os/exec"
10- "path/filepath"
11- "runtime"
127 "slices"
13- "sort"
148 "testing"
159 "time"
1610
17- "github.com/jmoiron/sqlx"
18- _ "github.com/lib/pq"
1911 "github.com/picosh/pico/pkg/db"
20- "github.com/testcontainers/testcontainers-go"
21- "github.com/testcontainers/testcontainers-go/modules/postgres"
22- "github.com/testcontainers/testcontainers-go/wait"
12+ "github.com/picosh/pico/pkg/db/postgres/pgtest"
2313 )
2414
2515 var testDB *PsqlDB
26-var testLogger *slog.Logger
27-var skipTests bool
28-
29-func setupContainerRuntime() bool {
30- // Check if DATABASE_URL is set for external postgres (CI/CD or manual testing)
31- if os.Getenv("TEST_DATABASE_URL") != "" {
32- return true
33- }
34-
35- // Try docker
36- if cmd := exec.Command("docker", "info"); cmd.Run() == nil {
37- return true
38- }
39-
40- return false
41-}
4216
4317 func TestMain(m *testing.M) {
44- ctx := context.Background()
45- testLogger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
46-
47- // REQUIRE_TESTCONTAINERS=1 makes the build fail if containers can't start
48- requireContainers := os.Getenv("REQUIRE_TESTCONTAINERS") == "1"
49-
50- // Check for external database URL first (for CI/CD or manual testing)
51- if dbURL := os.Getenv("TEST_DATABASE_URL"); dbURL != "" {
52- testDB = NewDB(dbURL, testLogger)
53- if err := setupTestSchema(testDB.Db); err != nil {
54- panic(fmt.Sprintf("failed to setup schema: %s", err))
55- }
56- code := m.Run()
57- _ = testDB.Close()
58- os.Exit(code)
59- }
60-
61- if !setupContainerRuntime() {
62- fmt.Println("Container runtime not available, skipping integration tests")
63- fmt.Println("To run tests, either:")
64- fmt.Println(" - Set TEST_DATABASE_URL to a postgres connection string")
65- fmt.Println(" - Start docker daemon")
66- skipTests = true
67- exitCode := 0
68- if requireContainers {
69- exitCode = 1
70- }
71- os.Exit(exitCode)
72- }
73-
74- pgContainer, err := postgres.Run(ctx,
75- "postgres:14",
76- postgres.WithDatabase("pico_test"),
77- postgres.WithUsername("postgres"),
78- postgres.WithPassword("postgres"),
79- testcontainers.WithWaitStrategy(
80- wait.ForLog("database system is ready to accept connections").
81- WithOccurrence(2).
82- WithStartupTimeout(30*time.Second)),
83- )
84- if err != nil {
85- fmt.Printf("Failed to start postgres container (Docker may not be running): %s\n", err)
86- skipTests = true
87- exitCode := 0
88- if requireContainers {
89- exitCode = 1
90- }
91- os.Exit(exitCode)
92- }
93-
94- connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
95- if err != nil {
96- panic(fmt.Sprintf("failed to get connection string: %s", err))
97- }
98-
99- testDB = NewDB(connStr, testLogger)
100-
101- if err := setupTestSchema(testDB.Db); err != nil {
102- panic(fmt.Sprintf("failed to setup schema: %s", err))
103- }
104-
105- code := m.Run()
106-
107- _ = testDB.Close()
108- _ = pgContainer.Terminate(ctx)
109-
110- os.Exit(code)
111-}
112-
113-func getProjectRoot() string {
114- _, filename, _, ok := runtime.Caller(0)
115- if !ok {
116- panic("failed to get current file path")
117- }
118- // storage_test.go is in pkg/db/postgres/, so go up 4 levels to get project root
119- return filepath.Join(filepath.Dir(filename), "..", "..", "..")
120-}
121-
122-func setupTestSchema(db *sqlx.DB) error {
123- projectRoot := getProjectRoot()
124- migrationsDir := filepath.Join(projectRoot, "sql", "migrations")
125-
126- // Read all migration files
127- entries, err := os.ReadDir(migrationsDir)
128- if err != nil {
129- return fmt.Errorf("failed to read migrations directory: %w", err)
130- }
131-
132- // Sort by filename (they're date-prefixed, so alphabetical order is correct)
133- var migrationFiles []string
134- for _, entry := range entries {
135- if !entry.IsDir() && filepath.Ext(entry.Name()) == ".sql" {
136- migrationFiles = append(migrationFiles, entry.Name())
137- }
138- }
139- sort.Strings(migrationFiles)
140-
141- // Execute each migration in order
142- for _, filename := range migrationFiles {
143- migrationPath := filepath.Join(migrationsDir, filename)
144- content, err := os.ReadFile(migrationPath)
145- if err != nil {
146- return fmt.Errorf("failed to read migration %s: %w", filename, err)
147- }
148-
149- _, err = db.Exec(string(content))
150- if err != nil {
151- return fmt.Errorf("failed to execute migration %s: %w", filename, err)
152- }
153- }
154-
155- return nil
18+ pgtest.Main(m)
15619 }
15720
15821 func cleanupTestData(t *testing.T) {
15922 t.Helper()
160- testDB.ClearPopularPostsCache()
161- tables := []string{
162- "access_logs", "tuns_event_logs", "analytics_visits",
163- "feed_items", "post_aliases", "post_tags", "posts",
164- "projects", "feature_flags", "payment_history", "tokens",
165- "public_keys", "pipe_monitors", "app_users",
166- }
167- for _, table := range tables {
168- _, err := testDB.Db.Exec(fmt.Sprintf("DELETE FROM %s", table))
169- if err != nil {
170- t.Fatalf("failed to clean up %s: %v", table, err)
171- }
23+ if testDB == nil {
24+ testDB = NewDB(pgtest.URL(t), slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})))
17225 }
26+ pgtest.Clean(t, testDB.Db)
17327 }
17428
17529 func mustInsertPost(t *testing.T, post *db.Post) *db.Post {
17630 t.Helper()
177- now := time.Now()
178- if post.UpdatedAt == nil {
179- post.UpdatedAt = &now
180- }
181- if post.PublishAt == nil {
182- post.PublishAt = &now
183- }
184- created, err := testDB.InsertPost(post)
185- if err != nil {
186- t.Fatalf("InsertPost failed: %v", err)
187- }
188- return created
31+ return pgtest.InsertPost(t, testDB, post)
18932 }
19033
191-// ============ User Management Tests ============
192-
19334 func TestRegisterUser_Success(t *testing.T) {
19435 cleanupTestData(t)
19536
......@@ -842,141 +683,6 @@ func TestFindAllPostsByUser(t *testing.T) {
842683 }
843684 }
844685
845-func TestFindUsersWithPost(t *testing.T) {
846- cleanupTestData(t)
847-
848- user, _ := testDB.RegisterUser("feedspostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedspostowner", "comment", "")
849-
850- _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feed.txt", Slug: "feed", Title: "Feed", Space: "feeds"})
851-
852- users, err := testDB.FindUsersWithPost("feeds")
853- if err != nil {
854- t.Fatalf("FindUsersWithPost failed: %v", err)
855- }
856- if len(users) != 1 {
857- t.Errorf("expected 1 user, got %d", len(users))
858- }
859-}
860-
861-func TestFindExpiredPosts(t *testing.T) {
862- cleanupTestData(t)
863-
864- user, _ := testDB.RegisterUser("expiredpostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI expiredpostowner", "comment", "")
865-
866- expired := time.Now().Add(-24 * time.Hour)
867- _ = mustInsertPost(t, &db.Post{
868- UserID: user.ID,
869- Filename: "expired.txt",
870- Slug: "expired",
871- Title: "Expired",
872- Space: "pastes",
873- ExpiresAt: &expired,
874- })
875-
876- posts, err := testDB.FindExpiredPosts("pastes")
877- if err != nil {
878- t.Fatalf("FindExpiredPosts failed: %v", err)
879- }
880- if len(posts) != 1 {
881- t.Errorf("expected 1 expired post, got %d", len(posts))
882- }
883-}
884-
885-func TestFindPopularPosts(t *testing.T) {
886- cleanupTestData(t)
887-
888- user, _ := testDB.RegisterUser("feedowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedowner", "comment", "")
889-
890- now := time.Now()
891- post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost.md", Slug: "feedpost", Title: "Feed Post", Space: "prose", PublishAt: &now})
892-
893- // Before having feature flag, should not appear
894- posts, err := testDB.FindPopularPosts()
895- if err != nil {
896- t.Fatalf("FindPopularPosts failed: %v", err)
897- }
898- if len(posts) != 0 {
899- t.Errorf("expected 0 posts without feature flag, got %d", len(posts))
900- }
901-
902- // Add prose feature flag
903- _, err = testDB.InsertFeature(user.ID, "prose", time.Now().Add(time.Hour*24))
904- if err != nil {
905- t.Fatalf("InsertFeature failed: %v", err)
906- }
907- testDB.ClearPopularPostsCache()
908-
909- posts, err = testDB.FindPopularPosts()
910- if err != nil {
911- t.Fatalf("FindPopularPosts failed: %v", err)
912- }
913- if len(posts) != 1 {
914- t.Fatalf("expected 1 post in popular posts, got %d", len(posts))
915- }
916- if posts[0].Score != 0 {
917- t.Errorf("expected score 0, got %d", posts[0].Score)
918- }
919-
920- // Add visits
921- _ = testDB.InsertVisit(&db.AnalyticsVisits{UserID: user.ID, PostID: post.ID, IpAddress: "192.168.1.1", Status: 200})
922- _ = testDB.InsertVisit(&db.AnalyticsVisits{UserID: user.ID, PostID: post.ID, IpAddress: "192.168.1.2", Status: 200})
923- _ = testDB.InsertVisit(&db.AnalyticsVisits{UserID: user.ID, PostID: post.ID, IpAddress: "192.168.1.1", Status: 200}) // duplicate IP
924-
925- // Before clearing cache, should return cached result (score 0)
926- cachedPosts, err := testDB.FindPopularPosts()
927- if err != nil {
928- t.Fatalf("FindPopularPosts failed: %v", err)
929- }
930- if cachedPosts[0].Score != 0 {
931- t.Errorf("expected cached score 0, got %d", cachedPosts[0].Score)
932- }
933-
934- // After clearing cache, should re-query and return updated score 2
935- testDB.ClearPopularPostsCache()
936- posts, err = testDB.FindPopularPosts()
937- if err != nil {
938- t.Fatalf("FindPopularPosts failed: %v", err)
939- }
940- if len(posts) != 1 {
941- t.Fatalf("expected 1 post, got %d", len(posts))
942- }
943- if posts[0].Score != 2 {
944- t.Errorf("expected score 2 (unique visitors), got %d", posts[0].Score)
945- }
946-
947- // Verify max 3 posts per user
948- testDB.ClearPopularPostsCache()
949- post2Time := now.Add(-1 * time.Hour)
950- _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost2.md", Slug: "feedpost2", Title: "Feed Post 2", Space: "prose", PublishAt: &post2Time})
951- post3Time := now.Add(-2 * time.Hour)
952- _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost3.md", Slug: "feedpost3", Title: "Feed Post 3", Space: "prose", PublishAt: &post3Time})
953- post4Time := now.Add(-3 * time.Hour)
954- _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost4.md", Slug: "feedpost4", Title: "Feed Post 4", Space: "prose", PublishAt: &post4Time})
955-
956- posts, err = testDB.FindPopularPosts()
957- if err != nil {
958- t.Fatalf("FindPopularPosts failed: %v", err)
959- }
960- if len(posts) != 3 {
961- t.Errorf("expected max 3 posts for single user, got %d", len(posts))
962- }
963-
964- // Post older than 30 days should be excluded
965- testDB.ClearPopularPostsCache()
966- oldTime := now.Add(-31 * 24 * time.Hour)
967- _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "oldpost.md", Slug: "oldpost", Title: "Old Post", Space: "prose", PublishAt: &oldTime})
968-
969- posts, err = testDB.FindPopularPosts()
970- if err != nil {
971- t.Fatalf("FindPopularPosts failed: %v", err)
972- }
973- if len(posts) != 3 {
974- t.Errorf("expected still 3 posts (old post excluded), got %d", len(posts))
975- }
976-}
977-
978-// ============ Tags Tests ============
979-
980686 func TestReplaceTagsByPost(t *testing.T) {
981687 cleanupTestData(t)
982688
......@@ -994,62 +700,6 @@ func TestReplaceTagsByPost(t *testing.T) {
994700 }
995701 }
996702
997-func TestFindUserPostsByTag(t *testing.T) {
998- cleanupTestData(t)
999-
1000- user, _ := testDB.RegisterUser("usertagowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI usertagowner", "comment", "")
1001- now := time.Now()
1002- post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "usertag.md", Slug: "usertag", Title: "User Tag", Space: "prose", PublishAt: &now})
1003- _ = testDB.ReplaceTagsByPost([]string{"mytag"}, post.ID)
1004-
1005- pager := &db.Pager{Num: 10, Page: 0}
1006- result, err := testDB.FindUserPostsByTag(pager, "mytag", user.ID, "prose")
1007- if err != nil {
1008- t.Fatalf("FindUserPostsByTag failed: %v", err)
1009- }
1010- if len(result.Data) != 1 {
1011- t.Errorf("expected 1 post with tag, got %d", len(result.Data))
1012- }
1013-}
1014-
1015-func TestFindPostsByTag(t *testing.T) {
1016- cleanupTestData(t)
1017-
1018- user, _ := testDB.RegisterUser("tagsearchowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tagsearchowner", "comment", "")
1019- now := time.Now()
1020- post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "tagsearch.md", Slug: "tagsearch", Title: "Tag Search", Space: "prose", PublishAt: &now})
1021- _ = testDB.ReplaceTagsByPost([]string{"searchtag"}, post.ID)
1022-
1023- pager := &db.Pager{Num: 10, Page: 0}
1024- result, err := testDB.FindPostsByTag(pager, "searchtag", "prose")
1025- if err != nil {
1026- t.Fatalf("FindPostsByTag failed: %v", err)
1027- }
1028- if len(result.Data) != 1 {
1029- t.Errorf("expected 1 post with tag, got %d", len(result.Data))
1030- }
1031-}
1032-
1033-func TestFindPopularTags(t *testing.T) {
1034- cleanupTestData(t)
1035-
1036- user, _ := testDB.RegisterUser("populartagowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI populartagowner", "comment", "")
1037- post1 := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "pop1.md", Slug: "pop1", Title: "Pop 1", Space: "prose"})
1038- post2 := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "pop2.md", Slug: "pop2", Title: "Pop 2", Space: "prose"})
1039- _ = testDB.ReplaceTagsByPost([]string{"popular"}, post1.ID)
1040- _ = testDB.ReplaceTagsByPost([]string{"popular"}, post2.ID)
1041-
1042- tags, err := testDB.FindPopularTags("prose")
1043- if err != nil {
1044- t.Fatalf("FindPopularTags failed: %v", err)
1045- }
1046- if len(tags) < 1 {
1047- t.Errorf("expected at least 1 popular tag, got %d", len(tags))
1048- }
1049-}
1050-
1051-// ============ Aliases Tests ============
1052-
1053703 func TestReplaceAliasesByPost(t *testing.T) {
1054704 cleanupTestData(t)
1055705
......@@ -1300,47 +950,6 @@ func TestAddFeatureUser(t *testing.T) {
1300950 }
1301951 }
1302952
1303-// ============ Feed Items Tests ============
1304-
1305-func TestInsertFeedItems(t *testing.T) {
1306- cleanupTestData(t)
1307-
1308- user, _ := testDB.RegisterUser("feeditemsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feeditemsowner", "comment", "")
1309- post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feed.txt", Slug: "feed", Title: "Feed", Space: "feeds"})
1310-
1311- items := []*db.FeedItem{
1312- {PostID: post.ID, GUID: "guid-1", Data: db.FeedItemData{Title: "Item 1", Link: "http://example.com/1"}},
1313- {PostID: post.ID, GUID: "guid-2", Data: db.FeedItemData{Title: "Item 2", Link: "http://example.com/2"}},
1314- }
1315-
1316- err := testDB.InsertFeedItems(post.ID, items)
1317- if err != nil {
1318- t.Fatalf("InsertFeedItems failed: %v", err)
1319- }
1320-}
1321-
1322-func TestFindFeedItemsByPostID(t *testing.T) {
1323- cleanupTestData(t)
1324-
1325- user, _ := testDB.RegisterUser("findfeeditemsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findfeeditemsowner", "comment", "")
1326- post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "findfeed.txt", Slug: "findfeed", Title: "Find Feed", Space: "feeds"})
1327-
1328- items := []*db.FeedItem{
1329- {PostID: post.ID, GUID: "find-guid-1", Data: db.FeedItemData{Title: "Find Item 1"}},
1330- }
1331- _ = testDB.InsertFeedItems(post.ID, items)
1332-
1333- found, err := testDB.FindFeedItemsByPostID(post.ID)
1334- if err != nil {
1335- t.Fatalf("FindFeedItemsByPostID failed: %v", err)
1336- }
1337- if len(found) != 1 {
1338- t.Errorf("expected 1 feed item, got %d", len(found))
1339- }
1340-}
1341-
1342-// ============ Projects Tests ============
1343-
1344953 func TestUpsertProject(t *testing.T) {
1345954 cleanupTestData(t)
1346955
......@@ -1623,40 +1232,6 @@ func TestProjectAcl_JSONBRoundtrip(t *testing.T) {
16231232 _ = project
16241233 }
16251234
1626-func TestFeedItemData_JSONBRoundtrip(t *testing.T) {
1627- cleanupTestData(t)
1628-
1629- user, _ := testDB.RegisterUser("feedjsonbowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedjsonbowner", "comment", "")
1630- post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedjsonb.txt", Slug: "feedjsonb", Title: "Feed JSONB", Space: "feeds"})
1631-
1632- now := time.Now().Truncate(time.Second)
1633- items := []*db.FeedItem{
1634- {
1635- PostID: post.ID,
1636- GUID: "jsonb-guid",
1637- Data: db.FeedItemData{
1638- Title: "JSONB Item",
1639- Description: "Description",
1640- Content: "Content",
1641- Link: "http://example.com",
1642- PublishedAt: &now,
1643- },
1644- },
1645- }
1646- _ = testDB.InsertFeedItems(post.ID, items)
1647-
1648- found, err := testDB.FindFeedItemsByPostID(post.ID)
1649- if err != nil {
1650- t.Fatalf("FindFeedItemsByPostID failed: %v", err)
1651- }
1652- if len(found) != 1 {
1653- t.Fatalf("expected 1 item, got %d", len(found))
1654- }
1655- if found[0].Data.Title != "JSONB Item" {
1656- t.Errorf("expected title 'JSONB Item', got '%s'", found[0].Data.Title)
1657- }
1658-}
1659-
16601235 func TestFeatureFlagData_JSONBRoundtrip(t *testing.T) {
16611236 cleanupTestData(t)
16621237
......@@ -1699,162 +1274,6 @@ func TestPaymentHistoryData_JSONBRoundtrip(t *testing.T) {
16991274 }
17001275 }
17011276
1702-// ============ Pipe Monitor Tests ============
1703-
1704-func TestUpsertPipeMonitor(t *testing.T) {
1705- cleanupTestData(t)
1706-
1707- user, _ := testDB.RegisterUser("pipemonitorowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipemonitorowner", "comment", "")
1708-
1709- winEnd := time.Now().Add(time.Hour)
1710- err := testDB.UpsertPipeMonitor(user.ID, "test-topic", 5*time.Minute, &winEnd)
1711- if err != nil {
1712- t.Fatalf("UpsertPipeMonitor failed: %v", err)
1713- }
1714-
1715- monitor, err := testDB.FindPipeMonitorByTopic(user.ID, "test-topic")
1716- if err != nil {
1717- t.Fatalf("FindPipeMonitorByTopic failed: %v", err)
1718- }
1719- if monitor.Topic != "test-topic" {
1720- t.Errorf("expected topic 'test-topic', got '%s'", monitor.Topic)
1721- }
1722- if monitor.WindowDur != 5*time.Minute {
1723- t.Errorf("expected window_dur 5m, got %v", monitor.WindowDur)
1724- }
1725-}
1726-
1727-func TestUpsertPipeMonitor_Update(t *testing.T) {
1728- cleanupTestData(t)
1729-
1730- user, _ := testDB.RegisterUser("pipeupdateowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipeupdateowner", "comment", "")
1731-
1732- winEnd1 := time.Now().Add(time.Hour)
1733- err := testDB.UpsertPipeMonitor(user.ID, "update-topic", 5*time.Minute, &winEnd1)
1734- if err != nil {
1735- t.Fatalf("first UpsertPipeMonitor failed: %v", err)
1736- }
1737-
1738- winEnd2 := time.Now().Add(2 * time.Hour)
1739- err = testDB.UpsertPipeMonitor(user.ID, "update-topic", 10*time.Minute, &winEnd2)
1740- if err != nil {
1741- t.Fatalf("second UpsertPipeMonitor failed: %v", err)
1742- }
1743-
1744- monitor, err := testDB.FindPipeMonitorByTopic(user.ID, "update-topic")
1745- if err != nil {
1746- t.Fatalf("FindPipeMonitorByTopic failed: %v", err)
1747- }
1748- if monitor.WindowDur != 10*time.Minute {
1749- t.Errorf("expected window_dur 10m after update, got %v", monitor.WindowDur)
1750- }
1751-}
1752-
1753-func TestUpdatePipeMonitorLastPing(t *testing.T) {
1754- cleanupTestData(t)
1755-
1756- user, _ := testDB.RegisterUser("pipepingowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipepingowner", "comment", "")
1757-
1758- winEnd := time.Now().Add(time.Hour)
1759- err := testDB.UpsertPipeMonitor(user.ID, "ping-topic", 5*time.Minute, &winEnd)
1760- if err != nil {
1761- t.Fatalf("UpsertPipeMonitor failed: %v", err)
1762- }
1763-
1764- lastPing := time.Now()
1765- err = testDB.UpdatePipeMonitorLastPing(user.ID, "ping-topic", &lastPing)
1766- if err != nil {
1767- t.Fatalf("UpdatePipeMonitorLastPing failed: %v", err)
1768- }
1769-
1770- monitor, err := testDB.FindPipeMonitorByTopic(user.ID, "ping-topic")
1771- if err != nil {
1772- t.Fatalf("FindPipeMonitorByTopic failed: %v", err)
1773- }
1774- if monitor.LastPing == nil {
1775- t.Error("expected last_ping to be set, got nil")
1776- }
1777-}
1778-
1779-func TestRemovePipeMonitor(t *testing.T) {
1780- cleanupTestData(t)
1781-
1782- user, _ := testDB.RegisterUser("piperemoveowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI piperemoveowner", "comment", "")
1783-
1784- winEnd := time.Now().Add(time.Hour)
1785- err := testDB.UpsertPipeMonitor(user.ID, "remove-topic", 5*time.Minute, &winEnd)
1786- if err != nil {
1787- t.Fatalf("UpsertPipeMonitor failed: %v", err)
1788- }
1789-
1790- err = testDB.RemovePipeMonitor(user.ID, "remove-topic")
1791- if err != nil {
1792- t.Fatalf("RemovePipeMonitor failed: %v", err)
1793- }
1794-
1795- _, err = testDB.FindPipeMonitorByTopic(user.ID, "remove-topic")
1796- if err == nil {
1797- t.Error("expected error finding removed monitor, got nil")
1798- }
1799-}
1800-
1801-func TestFindPipeMonitorByTopic_NotFound(t *testing.T) {
1802- cleanupTestData(t)
1803-
1804- user, _ := testDB.RegisterUser("pipenotfoundowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipenotfoundowner", "comment", "")
1805-
1806- _, err := testDB.FindPipeMonitorByTopic(user.ID, "nonexistent-topic")
1807- if err == nil {
1808- t.Error("expected error for nonexistent monitor, got nil")
1809- }
1810-}
1811-
1812-func TestFindPipeMonitorsByUser(t *testing.T) {
1813- cleanupTestData(t)
1814-
1815- user, _ := testDB.RegisterUser("pipemonlistowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipemonlistowner", "comment", "")
1816-
1817- winEnd := time.Now().Add(time.Hour)
1818- _ = testDB.UpsertPipeMonitor(user.ID, "service-a", 5*time.Minute, &winEnd)
1819- _ = testDB.UpsertPipeMonitor(user.ID, "service-b", 10*time.Minute, &winEnd)
1820- _ = testDB.UpsertPipeMonitor(user.ID, "service-c", 1*time.Hour, &winEnd)
1821-
1822- monitors, err := testDB.FindPipeMonitorsByUser(user.ID)
1823- if err != nil {
1824- t.Fatalf("FindPipeMonitorsByUser failed: %v", err)
1825- }
1826-
1827- if len(monitors) != 3 {
1828- t.Errorf("expected 3 monitors, got %d", len(monitors))
1829- }
1830-
1831- // Should be ordered by topic
1832- if monitors[0].Topic != "service-a" {
1833- t.Errorf("expected first topic 'service-a', got %s", monitors[0].Topic)
1834- }
1835- if monitors[1].Topic != "service-b" {
1836- t.Errorf("expected second topic 'service-b', got %s", monitors[1].Topic)
1837- }
1838- if monitors[2].Topic != "service-c" {
1839- t.Errorf("expected third topic 'service-c', got %s", monitors[2].Topic)
1840- }
1841-}
1842-
1843-func TestFindPipeMonitorsByUser_Empty(t *testing.T) {
1844- cleanupTestData(t)
1845-
1846- user, _ := testDB.RegisterUser("pipenomonitors", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipenomonitors", "comment", "")
1847-
1848- monitors, err := testDB.FindPipeMonitorsByUser(user.ID)
1849- if err != nil {
1850- t.Fatalf("FindPipeMonitorsByUser failed: %v", err)
1851- }
1852-
1853- if len(monitors) != 0 {
1854- t.Errorf("expected 0 monitors for user with none, got %d", len(monitors))
1855- }
1856-}
1857-
18581277 func TestInviteUser_PlusMember(t *testing.T) {
18591278 cleanupTestData(t)
18601279
+118, -0
......@@ -0,0 +1,118 @@
1+package postgres
2+
3+import (
4+ "slices"
5+
6+ "github.com/jmoiron/sqlx"
7+)
8+
9+func (me *PsqlDB) removeTagsByPost(tx *sqlx.Tx, postID string) error {
10+ _, err := tx.Exec(`DELETE FROM post_tags WHERE post_id = $1`, postID)
11+ return err
12+}
13+
14+func (me *PsqlDB) insertTagsByPost(tx *sqlx.Tx, tags []string, postID string) ([]string, error) {
15+ ids := make([]string, 0)
16+ for _, tag := range tags {
17+ id := ""
18+ err := tx.QueryRow(`INSERT INTO post_tags (post_id, name) VALUES($1, $2) RETURNING id;`, postID, tag).Scan(&id)
19+ if err != nil {
20+ return nil, err
21+ }
22+ ids = append(ids, id)
23+ }
24+
25+ return ids, nil
26+}
27+
28+func (me *PsqlDB) ReplaceTagsByPost(tags []string, postID string) error {
29+ tx, err := me.Db.Beginx()
30+ if err != nil {
31+ return err
32+ }
33+ defer func() {
34+ _ = tx.Rollback()
35+ }()
36+
37+ err = me.removeTagsByPost(tx, postID)
38+ if err != nil {
39+ return err
40+ }
41+
42+ _, err = me.insertTagsByPost(tx, tags, postID)
43+ if err != nil {
44+ return err
45+ }
46+
47+ err = tx.Commit()
48+ return err
49+}
50+
51+func (me *PsqlDB) removeAliasesByPost(tx *sqlx.Tx, postID string) error {
52+ _, err := tx.Exec(`DELETE FROM post_aliases WHERE post_id = $1`, postID)
53+ return err
54+}
55+
56+func (me *PsqlDB) insertAliasesByPost(tx *sqlx.Tx, aliases []string, postID string) ([]string, error) {
57+ // hardcoded
58+ denyList := []string{
59+ "rss",
60+ "rss.xml",
61+ "rss.atom",
62+ "atom.xml",
63+ "feed.xml",
64+ "smol.css",
65+ "main.css",
66+ "syntax.css",
67+ "card.png",
68+ "favicon-16x16.png",
69+ "favicon-32x32.png",
70+ "apple-touch-icon.png",
71+ "favicon.ico",
72+ "robots.txt",
73+ "atom",
74+ "blog/index.xml",
75+ }
76+
77+ ids := make([]string, 0)
78+ for _, alias := range aliases {
79+ if slices.Contains(denyList, alias) {
80+ me.Logger.Info(
81+ "name is in the deny list for aliases because it conflicts with a static route, skipping",
82+ "alias", alias,
83+ )
84+ continue
85+ }
86+ id := ""
87+ err := tx.QueryRow(`INSERT INTO post_aliases (post_id, slug) VALUES($1, $2) RETURNING id;`, postID, alias).Scan(&id)
88+ if err != nil {
89+ return nil, err
90+ }
91+ ids = append(ids, id)
92+ }
93+
94+ return ids, nil
95+}
96+
97+func (me *PsqlDB) ReplaceAliasesByPost(aliases []string, postID string) error {
98+ tx, err := me.Db.Beginx()
99+ if err != nil {
100+ return err
101+ }
102+ defer func() {
103+ _ = tx.Rollback()
104+ }()
105+
106+ err = me.removeAliasesByPost(tx, postID)
107+ if err != nil {
108+ return err
109+ }
110+
111+ _, err = me.insertAliasesByPost(tx, aliases, postID)
112+ if err != nil {
113+ return err
114+ }
115+
116+ err = tx.Commit()
117+ return err
118+}
+60, -0
......@@ -0,0 +1,60 @@
1+package postgres
2+
3+import (
4+ "github.com/picosh/pico/pkg/db"
5+)
6+
7+func (me *PsqlDB) FindUserByToken(token string) (*db.User, error) {
8+ user := &db.User{}
9+ err := me.Db.Get(user, `
10+ SELECT app_users.id, app_users.name, app_users.created_at
11+ FROM app_users
12+ LEFT JOIN tokens ON tokens.user_id = app_users.id
13+ WHERE tokens.token = $1 AND tokens.expires_at > NOW()`, token)
14+ if err != nil {
15+ return nil, err
16+ }
17+ return user, nil
18+}
19+
20+func (me *PsqlDB) InsertToken(userID, name string) (string, error) {
21+ var token string
22+ err := me.Db.QueryRow(`INSERT INTO tokens (user_id, name) VALUES($1, $2) RETURNING token;`, userID, name).Scan(&token)
23+ if err != nil {
24+ return "", err
25+ }
26+ return token, nil
27+}
28+
29+func (me *PsqlDB) UpsertToken(userID, name string) (string, error) {
30+ token, _ := me.findTokenByName(userID, name)
31+ if token != "" {
32+ return token, nil
33+ }
34+
35+ token, err := me.InsertToken(userID, name)
36+ return token, err
37+}
38+
39+func (me *PsqlDB) findTokenByName(userID, name string) (string, error) {
40+ var token string
41+ err := me.Db.QueryRow(`SELECT token FROM tokens WHERE user_id = $1 AND name = $2`, userID, name).Scan(&token)
42+ if err != nil {
43+ return "", err
44+ }
45+ return token, nil
46+}
47+
48+func (me *PsqlDB) RemoveToken(tokenID string) error {
49+ _, err := me.Db.Exec(`DELETE FROM tokens WHERE id = $1`, tokenID)
50+ return err
51+}
52+
53+func (me *PsqlDB) FindTokensByUser(userID string) ([]*db.Token, error) {
54+ var tokens []*db.Token
55+ err := me.Db.Select(&tokens, `SELECT * FROM tokens WHERE user_id = $1`, userID)
56+ if err != nil {
57+ return nil, err
58+ }
59+ return tokens, nil
60+}
+37, -0
......@@ -0,0 +1,37 @@
1+package postgres
2+
3+import (
4+ "github.com/picosh/pico/pkg/db"
5+)
6+
7+func (me *PsqlDB) InsertTunsEventLog(log *db.TunsEventLog) error {
8+ _, err := me.Db.Exec(
9+ `INSERT INTO tuns_event_logs
10+ (user_id, server_id, remote_addr, event_type, tunnel_type, connection_type, tunnel_id)
11+ VALUES
12+ ($1, $2, $3, $4, $5, $6, $7)`,
13+ log.UserId, log.ServerID, log.RemoteAddr, log.EventType, log.TunnelType,
14+ log.ConnectionType, log.TunnelID,
15+ )
16+ return err
17+}
18+
19+func (me *PsqlDB) FindTunsEventLogsByAddr(userID, addr string) ([]*db.TunsEventLog, error) {
20+ var logs []*db.TunsEventLog
21+ err := me.Db.Select(&logs,
22+ `SELECT * FROM tuns_event_logs WHERE user_id=$1 AND tunnel_id=$2 ORDER BY created_at DESC`, userID, addr)
23+ if err != nil {
24+ return nil, err
25+ }
26+ return logs, nil
27+}
28+
29+func (me *PsqlDB) FindTunsEventLogs(userID string) ([]*db.TunsEventLog, error) {
30+ var logs []*db.TunsEventLog
31+ err := me.Db.Select(&logs,
32+ `SELECT * FROM tuns_event_logs WHERE user_id=$1 ORDER BY created_at DESC`, userID)
33+ if err != nil {
34+ return nil, err
35+ }
36+ return logs, nil
37+}
+199, -0
......@@ -0,0 +1,199 @@
1+package postgres
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "slices"
7+ "strings"
8+
9+ "github.com/picosh/pico/pkg/db"
10+)
11+
12+func (me *PsqlDB) shouldBlockSingup(ip string) error {
13+ blocked := &db.BlockSignups{}
14+ err := me.Db.Get(blocked, `SELECT * FROM block_signups WHERE ip = $1`, ip)
15+ // an error in this case means the result is empty (no record found)
16+ if err != nil {
17+ return nil
18+ }
19+ return fmt.Errorf("your IP address has been blocked: %s", blocked.Reason)
20+}
21+
22+func (me *PsqlDB) RegisterUser(username, pubkey, comment, ip string) (*db.User, error) {
23+ lowerName := strings.ToLower(username)
24+ valid, err := me.validateName(lowerName)
25+ if !valid {
26+ return nil, err
27+ }
28+
29+ me.Logger.Info("checking if ip is in block list", "ip", ip, "username", username)
30+ err = me.shouldBlockSingup(ip)
31+ if err != nil {
32+ me.Logger.Warn("user has been blocked from signing up", "ip", ip, "username", username, "err", err)
33+ return nil, err
34+ }
35+
36+ tx, err := me.Db.Beginx()
37+ if err != nil {
38+ return nil, err
39+ }
40+ defer func() {
41+ _ = tx.Rollback()
42+ }()
43+
44+ var id string
45+ err = tx.QueryRow(`INSERT INTO app_users (name) VALUES($1) returning id`, lowerName).Scan(&id)
46+ if err != nil {
47+ return nil, err
48+ }
49+
50+ err = me.insertPublicKeyWithTx(id, pubkey, comment, tx)
51+ if err != nil {
52+ return nil, err
53+ }
54+
55+ err = tx.Commit()
56+ if err != nil {
57+ return nil, err
58+ }
59+
60+ return me.FindUserByKey(username, pubkey)
61+}
62+
63+func (me *PsqlDB) FindUserByKey(username string, key string) (*db.User, error) {
64+ me.Logger.Info("attempting to find user with only public key", "key", key)
65+ pk, err := me.findPublicKeyByKey(key)
66+ if err == nil {
67+ me.Logger.Info("found pubkey, looking for user", "key", key, "userId", pk.UserID)
68+ user, err := me.FindUser(pk.UserID)
69+ if err != nil {
70+ return nil, err
71+ }
72+ user.PublicKey = pk
73+ return user, nil
74+ }
75+
76+ if errors.Is(err, &db.ErrMultiplePublicKeys{}) {
77+ me.Logger.Info("detected multiple users with same public key", "user", username)
78+ user, err := me.findUserForNameAndKey(username, key)
79+ if err != nil {
80+ me.Logger.Info("could not find user by username and public key", "user", username, "key", key)
81+ // this is a little hacky but if we cannot find a user by name and public key
82+ // then we return the multiple keys detected error so the user knows to specify their
83+ // when logging in
84+ return nil, &db.ErrMultiplePublicKeys{}
85+ }
86+ return user, nil
87+ }
88+
89+ return nil, err
90+}
91+
92+func (me *PsqlDB) FindUserByPubkey(key string) (*db.User, error) {
93+ me.Logger.Info("attempting to find user with only public key", "key", key)
94+ pk, err := me.findPublicKeyByKey(key)
95+ if err != nil {
96+ return nil, err
97+ }
98+
99+ me.Logger.Info("found pubkey, looking for user", "key", key, "userId", pk.UserID)
100+ user, err := me.FindUser(pk.UserID)
101+ if err != nil {
102+ return nil, err
103+ }
104+ user.PublicKey = pk
105+ return user, nil
106+}
107+
108+func (me *PsqlDB) FindUser(userID string) (*db.User, error) {
109+ user := &db.User{}
110+ err := me.Db.Get(user, `SELECT id, COALESCE(name, '') as name, created_at FROM app_users WHERE id = $1`, userID)
111+ if err != nil {
112+ return nil, err
113+ }
114+ return user, nil
115+}
116+
117+func (me *PsqlDB) validateName(name string) (bool, error) {
118+ lower := strings.ToLower(name)
119+ if slices.Contains(db.DenyList, lower) {
120+ return false, fmt.Errorf("%s is on deny list: %w", lower, db.ErrNameDenied)
121+ }
122+ v := db.NameValidator.MatchString(lower)
123+ if !v {
124+ return false, fmt.Errorf("%s is invalid: %w", lower, db.ErrNameInvalid)
125+ }
126+ user, _ := me.FindUserByName(lower)
127+ if user == nil {
128+ return true, nil
129+ }
130+ return false, fmt.Errorf("%s already taken: %w", lower, db.ErrNameTaken)
131+}
132+
133+func (me *PsqlDB) FindUserByName(name string) (*db.User, error) {
134+ user := &db.User{}
135+ err := me.Db.Get(user, `SELECT * FROM app_users WHERE name = $1`, strings.ToLower(name))
136+ if err != nil {
137+ return nil, err
138+ }
139+ return user, nil
140+}
141+
142+func (me *PsqlDB) findUserForNameAndKey(name string, key string) (*db.User, error) {
143+ user := &db.User{}
144+ pk := &db.PublicKey{}
145+
146+ r := me.Db.QueryRow(`SELECT app_users.id, app_users.name, app_users.created_at, public_keys.id as pk_id, public_keys.public_key, public_keys.created_at as pk_created_at FROM app_users LEFT JOIN public_keys ON public_keys.user_id = app_users.id WHERE app_users.name = $1 AND public_keys.public_key = $2`, strings.ToLower(name), key)
147+ err := r.Scan(&user.ID, &user.Name, &user.CreatedAt, &pk.ID, &pk.Key, &pk.CreatedAt)
148+ if err != nil {
149+ return nil, err
150+ }
151+
152+ user.PublicKey = pk
153+ return user, nil
154+}
155+
156+func (me *PsqlDB) FindUsers() ([]*db.User, error) {
157+ var users []*db.User
158+ err := me.Db.Select(&users, `SELECT id, COALESCE(name, '') as name, created_at FROM app_users ORDER BY name ASC`)
159+ if err != nil {
160+ return nil, err
161+ }
162+ return users, nil
163+}
164+
165+func (me *PsqlDB) FindUserStats(userID string) (*db.UserStats, error) {
166+ stats := db.UserStats{}
167+ rs, err := me.Db.Queryx(`SELECT cur_space, count(id), min(created_at), max(created_at), max(updated_at) FROM posts WHERE user_id=$1 GROUP BY cur_space`, userID)
168+ if err != nil {
169+ return nil, err
170+ }
171+ defer func() { _ = rs.Close() }()
172+
173+ for rs.Next() {
174+ stat := db.UserServiceStats{}
175+ err := rs.Scan(&stat.Service, &stat.Num, &stat.FirstCreatedAt, &stat.LastestCreatedAt, &stat.LatestUpdatedAt)
176+ if err != nil {
177+ return nil, err
178+ }
179+ switch stat.Service {
180+ case "prose":
181+ stats.Prose = stat
182+ case "pastes":
183+ stats.Pastes = stat
184+ case "feeds":
185+ stats.Feeds = stat
186+ }
187+ }
188+
189+ if rs.Err() != nil {
190+ return nil, rs.Err()
191+ }
192+
193+ pgs, err := me.findPagesStats(userID)
194+ if err != nil {
195+ return nil, err
196+ }
197+ stats.Pages = *pgs
198+ return &stats, err
199+}
+2, -2
......@@ -32,12 +32,12 @@ type PostMetaData struct {
3232 }
3333
3434 type UploadImgHandler struct {
35- DBPool db.DB
35+ DBPool shared.FeatureFinder
3636 Cfg *shared.ConfigSite
3737 Storage storage.StorageServe
3838 }
3939
40-func NewUploadImgHandler(dbpool db.DB, cfg *shared.ConfigSite, storage storage.StorageServe) *UploadImgHandler {
40+func NewUploadImgHandler(dbpool shared.FeatureFinder, cfg *shared.ConfigSite, storage storage.StorageServe) *UploadImgHandler {
4141 return &UploadImgHandler{
4242 DBPool: dbpool,
4343 Cfg: cfg,
+0, -395
......@@ -1,395 +0,0 @@
1-package filehandlers
2-
3-import (
4- "database/sql"
5- "encoding/binary"
6- "errors"
7- "fmt"
8- "io"
9- "net/http"
10- "os"
11- "path/filepath"
12- "strings"
13- "time"
14-
15- "github.com/picosh/pico/pkg/db"
16- "github.com/picosh/pico/pkg/pssh"
17- sendutils "github.com/picosh/pico/pkg/send/utils"
18- "github.com/picosh/pico/pkg/shared"
19-)
20-
21-type PostMetaData struct {
22- *db.Post
23- Cur *db.Post
24- Tags []string
25- User *db.User
26- FileEntry *sendutils.FileEntry
27- Aliases []string
28-}
29-
30-type ScpFileHooks interface {
31- FileValidate(s *pssh.SSHServerConnSession, data *PostMetaData) (bool, error)
32- FileMeta(s *pssh.SSHServerConnSession, data *PostMetaData) error
33-}
34-
35-type ScpUploadHandler struct {
36- DBPool db.DB
37- Cfg *shared.ConfigSite
38- Hooks ScpFileHooks
39-}
40-
41-func NewScpPostHandler(dbpool db.DB, cfg *shared.ConfigSite, hooks ScpFileHooks) *ScpUploadHandler {
42- return &ScpUploadHandler{
43- DBPool: dbpool,
44- Cfg: cfg,
45- Hooks: hooks,
46- }
47-}
48-
49-func (r *ScpUploadHandler) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
50- return BaseList(s, fpath, isDir, recursive, []string{r.Cfg.Space}, r.DBPool)
51-}
52-
53-func (h *ScpUploadHandler) Read(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (os.FileInfo, sendutils.ReadAndReaderAtCloser, error) {
54- logger := pssh.GetLogger(s)
55- user := pssh.GetUser(s)
56-
57- if user == nil {
58- err := fmt.Errorf("could not get user from ctx")
59- logger.Error("error getting user from ctx", "err", err)
60- return nil, nil, err
61- }
62-
63- cleanFilename := filepath.Base(entry.Filepath)
64-
65- if cleanFilename == "" || cleanFilename == "." {
66- return nil, nil, os.ErrNotExist
67- }
68-
69- post, err := h.DBPool.FindPostWithFilename(cleanFilename, user.ID, h.Cfg.Space)
70- if err != nil {
71- return nil, nil, err
72- }
73-
74- fileInfo := &sendutils.VirtualFile{
75- FName: post.Filename,
76- FIsDir: false,
77- FSize: int64(post.FileSize),
78- FModTime: *post.UpdatedAt,
79- }
80-
81- reader := sendutils.NopReadAndReaderAtCloser(strings.NewReader(post.Text))
82-
83- return fileInfo, reader, nil
84-}
85-
86-// ValidateWrite runs the hooks' validation on the file before its
87-// contents arrive.
88-func (h *ScpUploadHandler) ValidateWrite(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
89- user := pssh.GetUser(s)
90- if user == nil {
91- return fmt.Errorf("could not get user from ctx")
92- }
93- if entry.Mode.IsDir() {
94- return fmt.Errorf("file entry is directory, but only files are supported: %s", entry.Filepath)
95- }
96- filename := filepath.Base(entry.Filepath)
97- metadata := PostMetaData{
98- Post: &db.Post{Filename: filename, Slug: shared.SanitizeFileExt(filename)},
99- User: user,
100- FileEntry: entry,
101- }
102- if valid, err := h.Hooks.FileValidate(s, &metadata); !valid {
103- return err
104- }
105- return nil
106-}
107-
108-func (h *ScpUploadHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (string, error) {
109- logger := pssh.GetLogger(s)
110- user := pssh.GetUser(s)
111-
112- if user == nil {
113- err := fmt.Errorf("could not get user from ctx")
114- logger.Error("error getting user from ctx", "err", err)
115- return "", err
116- }
117-
118- userID := user.ID
119- filename := filepath.Base(entry.Filepath)
120-
121- logger = logger.With(
122- "filename", filename,
123- )
124-
125- if entry.Mode.IsDir() {
126- return "", fmt.Errorf("file entry is directory, but only files are supported: %s", filename)
127- }
128-
129- origText, err := io.ReadAll(entry.Reader)
130- if err != nil {
131- return "", fmt.Errorf("could not read file (%s): %w", filename, err)
132- }
133-
134- nextPost := newPost(filename, origText)
135- metadata := PostMetaData{
136- Post: &nextPost,
137- User: user,
138- FileEntry: entry,
139- }
140-
141- valid, err := h.Hooks.FileValidate(s, &metadata)
142- if !valid {
143- logger.Error("file failed validation", "err", err.Error())
144- return "", err
145- }
146-
147- post, err := h.DBPool.FindPostWithFilename(metadata.Filename, metadata.User.ID, h.Cfg.Space)
148- if err != nil {
149- logger.Error("unable to load post, continuing", "err", err.Error())
150- }
151-
152- if post != nil {
153- metadata.Cur = post
154- metadata.Data = post.Data
155- metadata.PublishAt = post.PublishAt
156- }
157-
158- err = h.Hooks.FileMeta(s, &metadata)
159- if err != nil {
160- logger.Error("file could not load meta", "err", err.Error())
161- return "", err
162- }
163-
164- modTime := time.Now()
165-
166- if entry.Mtime > 0 {
167- modTime = time.Unix(entry.Mtime, 0)
168- }
169-
170- if post == nil {
171- logger.Info("file not found, adding record")
172- insertPost := db.Post{
173- UserID: userID,
174- Space: h.Cfg.Space,
175-
176- Data: metadata.Data,
177- Description: metadata.Description,
178- Filename: metadata.Filename,
179- FileSize: metadata.FileSize,
180- Hidden: metadata.Hidden,
181- MimeType: metadata.MimeType,
182- PublishAt: metadata.PublishAt,
183- Shasum: metadata.Shasum,
184- Slug: metadata.Slug,
185- Text: metadata.Text,
186- Title: metadata.Title,
187- ExpiresAt: metadata.ExpiresAt,
188- UpdatedAt: &modTime,
189- }
190- post, err = h.DBPool.InsertPost(&insertPost)
191- if err != nil {
192- logger.Error("post could not be created", "err", err.Error())
193- return "", fmt.Errorf("error for %s: %v", filename, err)
194- }
195-
196- if len(metadata.Aliases) > 0 {
197- logger.Info(
198- "found post aliases, replacing with old aliases",
199- "aliases",
200- strings.Join(metadata.Aliases, ","),
201- )
202- err = h.DBPool.ReplaceAliasesByPost(metadata.Aliases, post.ID)
203- if err != nil {
204- logger.Error("post could not replace aliases", "err", err.Error())
205- return "", fmt.Errorf("error for %s: %v", filename, err)
206- }
207- }
208-
209- if len(metadata.Tags) > 0 {
210- logger.Info(
211- "found post tags, replacing with old tags",
212- "tags", strings.Join(metadata.Tags, ","),
213- )
214- err = h.DBPool.ReplaceTagsByPost(metadata.Tags, post.ID)
215- if err != nil {
216- logger.Error("post could not replace tags", "err", err.Error())
217- return "", fmt.Errorf("error for %s: %v", filename, err)
218- }
219- }
220- } else {
221- if metadata.Text == post.Text && modTime.Equal(*post.UpdatedAt) {
222- logger.Info("file found, but text is identical, skipping")
223- curl := shared.NewCreateURL(h.Cfg)
224- return h.Cfg.FullPostURL(curl, user.Name, metadata.Slug), nil
225- }
226-
227- logger.Info("file found, updating record")
228-
229- updatePost := db.Post{
230- ID: post.ID,
231-
232- Data: metadata.Data,
233- FileSize: metadata.FileSize,
234- Description: metadata.Description,
235- PublishAt: metadata.PublishAt,
236- Slug: metadata.Slug,
237- Shasum: metadata.Shasum,
238- Text: metadata.Text,
239- Title: metadata.Title,
240- Hidden: metadata.Hidden,
241- ExpiresAt: metadata.ExpiresAt,
242- UpdatedAt: &modTime,
243- }
244- _, err = h.DBPool.UpdatePost(&updatePost)
245- if err != nil {
246- logger.Error("post could not be updated", "err", err.Error())
247- return "", fmt.Errorf("error for %s: %v", filename, err)
248- }
249-
250- logger.Info(
251- "found post tags, replacing with old tags",
252- "tags", strings.Join(metadata.Tags, ","),
253- )
254- err = h.DBPool.ReplaceTagsByPost(metadata.Tags, post.ID)
255- if err != nil {
256- logger.Error("post could not replace tags", "err", err.Error())
257- return "", fmt.Errorf("error for %s: %v", filename, err)
258- }
259-
260- logger.Info(
261- "found post aliases, replacing with old aliases",
262- "aliases", strings.Join(metadata.Aliases, ","),
263- )
264- err = h.DBPool.ReplaceAliasesByPost(metadata.Aliases, post.ID)
265- if err != nil {
266- logger.Error("post could not replace aliases", "err", err.Error())
267- return "", fmt.Errorf("error for %s: %v", filename, err)
268- }
269- }
270-
271- curl := shared.NewCreateURL(h.Cfg)
272- return h.Cfg.FullPostURL(curl, user.Name, metadata.Slug), nil
273-}
274-
275-// newPost is the post a file makes before the hooks fill in the rest.
276-func newPost(filename string, text []byte) db.Post {
277- mimeType := http.DetectContentType(text)
278- // DetectContentType does not detect markdown
279- if filepath.Ext(filename) == ".md" {
280- mimeType = "text/markdown; charset=UTF-8"
281- }
282- now := time.Now()
283- return db.Post{
284- Filename: filename,
285- Slug: shared.SanitizeFileExt(filename),
286- PublishAt: &now,
287- Text: string(text),
288- MimeType: mimeType,
289- FileSize: binary.Size(text),
290- Shasum: shared.Shasum(text),
291- }
292-}
293-
294-// Rename moves a post to a new filename in place, so it keeps its ID,
295-// publish date and settings. A post at the new filename is replaced.
296-func (h *ScpUploadHandler) Rename(s *pssh.SSHServerConnSession, from, to *sendutils.FileEntry) error {
297- user := pssh.GetUser(s)
298- if user == nil {
299- return fmt.Errorf("could not get user from ctx")
300- }
301- fromName, toName := filepath.Base(from.Filepath), filepath.Base(to.Filepath)
302- // A save to either post while it is being renamed starts it over.
303- var err error
304- for range 3 {
305- if err = h.rename(s, user, fromName, toName, to); !errors.Is(err, db.ErrPostChanged) {
306- break
307- }
308- }
309- if err != nil {
310- return err
311- }
312- pssh.GetLogger(s).Info("renamed post", "from", fromName, "to", toName)
313- return nil
314-}
315-
316-func (h *ScpUploadHandler) rename(s *pssh.SSHServerConnSession, user *db.User, fromName, toName string, to *sendutils.FileEntry) error {
317- post, err := h.DBPool.FindPostWithFilename(fromName, user.ID, h.Cfg.Space)
318- if errors.Is(err, sql.ErrNoRows) {
319- return os.ErrNotExist
320- }
321- if err != nil {
322- return err
323- }
324- if fromName == toName {
325- return nil
326- }
327- replaced, err := h.DBPool.FindPostWithFilename(toName, user.ID, h.Cfg.Space)
328- if errors.Is(err, sql.ErrNoRows) {
329- replaced = nil
330- } else if err != nil {
331- return err
332- }
333-
334- next := newPost(toName, []byte(post.Text))
335- metadata := PostMetaData{
336- Post: &next,
337- Cur: post,
338- User: user,
339- FileEntry: to,
340- }
341- if valid, err := h.Hooks.FileValidate(s, &metadata); !valid {
342- return err
343- }
344- metadata.Data = post.Data
345- metadata.PublishAt = post.PublishAt
346- if err := h.Hooks.FileMeta(s, &metadata); err != nil {
347- return err
348- }
349- if other, err := h.DBPool.FindPostWithSlug(metadata.Slug, user.ID, h.Cfg.Space); err == nil &&
350- other.ID != post.ID && (replaced == nil || other.ID != replaced.ID) {
351- return fmt.Errorf("%s: %s already uses its slug (%s): %w", toName, other.Filename, metadata.Slug, os.ErrExist)
352- }
353-
354- metadata.UpdatedAt = post.UpdatedAt
355- // The new name can parse the text differently, as .md and .lxt do.
356- _, err = h.DBPool.RenamePost(post, metadata.Post, replaced, metadata.Tags, metadata.Aliases)
357- if err != nil && !errors.Is(err, db.ErrPostChanged) {
358- return fmt.Errorf("error for %s: %w", toName, err)
359- }
360- return err
361-}
362-
363-func (h *ScpUploadHandler) Delete(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
364- logger := pssh.GetLogger(s)
365- user := pssh.GetUser(s)
366-
367- if user == nil {
368- err := fmt.Errorf("could not get user from ctx")
369- logger.Error("error getting user from ctx", "err", err)
370- return err
371- }
372-
373- userID := user.ID
374- filename := filepath.Base(entry.Filepath)
375- logger = logger.With(
376- "filename", filename,
377- )
378-
379- post, err := h.DBPool.FindPostWithFilename(filename, userID, h.Cfg.Space)
380- if err != nil {
381- return err
382- }
383-
384- if post == nil {
385- return os.ErrNotExist
386- }
387-
388- err = h.DBPool.RemovePosts([]string{post.ID})
389- logger.Info("removing record")
390- if err != nil {
391- logger.Error("post could not remove", "err", err.Error())
392- return fmt.Errorf("error for %s: %v", filename, err)
393- }
394- return nil
395-}
+0, -71
......@@ -1,71 +0,0 @@
1-package filehandlers
2-
3-import (
4- "context"
5- "errors"
6- "io"
7- "log/slog"
8- "strings"
9- "testing"
10- "testing/iotest"
11-
12- "github.com/picosh/pico/pkg/db"
13- "github.com/picosh/pico/pkg/db/stub"
14- "github.com/picosh/pico/pkg/pssh"
15- sendutils "github.com/picosh/pico/pkg/send/utils"
16- "github.com/picosh/pico/pkg/shared"
17- "golang.org/x/crypto/ssh"
18-)
19-
20-type uploadHooks struct {
21- validated bool
22-}
23-
24-func (h *uploadHooks) FileValidate(*pssh.SSHServerConnSession, *PostMetaData) (bool, error) {
25- h.validated = true
26- return false, errors.New("unexpected validation after a failed read")
27-}
28-
29-func (h *uploadHooks) FileMeta(*pssh.SSHServerConnSession, *PostMetaData) error {
30- return nil
31-}
32-
33-func TestPostUploadReadError(t *testing.T) {
34- checksumErr := errors.New("rsync: file failed verification")
35- tests := []struct {
36- name string
37- text string
38- err error
39- }{
40- {name: "before contents", err: io.ErrClosedPipe},
41- {name: "partial contents", text: "partial upload", err: io.ErrUnexpectedEOF},
42- {name: "failed verification", text: "complete upload", err: checksumErr},
43- }
44- for _, tt := range tests {
45- t.Run(tt.name, func(t *testing.T) {
46- logger := slog.New(slog.DiscardHandler)
47- session := &pssh.SSHServerConnSession{
48- SSHServerConn: &pssh.SSHServerConn{
49- Conn: &ssh.ServerConn{Permissions: &ssh.Permissions{Extensions: map[string]string{}}},
50- Logger: logger,
51- },
52- Ctx: context.Background(),
53- }
54- pssh.SetUser(session, &db.User{ID: "user-1", Name: "tester"})
55- hooks := &uploadHooks{}
56- handler := NewScpPostHandler(stub.NewStubDB(logger), &shared.ConfigSite{Space: "pastes"}, hooks)
57- reader := io.MultiReader(strings.NewReader(tt.text), iotest.ErrReader(tt.err))
58-
59- message, err := handler.Write(session, &sendutils.FileEntry{Filepath: "/test.txt", Reader: reader})
60- if !errors.Is(err, tt.err) {
61- t.Fatalf("error = %v, want %v", err, tt.err)
62- }
63- if message != "" {
64- t.Fatalf("failed upload returned a success message: %q", message)
65- }
66- if hooks.validated {
67- t.Fatal("failed upload reached validation")
68- }
69- })
70- }
71-}
+244, -0
......@@ -0,0 +1,244 @@
1+package filehandlers
2+
3+import (
4+ "database/sql"
5+ "encoding/binary"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "os"
11+ "path/filepath"
12+ "strings"
13+ "time"
14+
15+ "github.com/picosh/pico/pkg/db"
16+ "github.com/picosh/pico/pkg/pssh"
17+ sendutils "github.com/picosh/pico/pkg/send/utils"
18+ "github.com/picosh/pico/pkg/shared"
19+)
20+
21+// SessionUser is the user an upload session belongs to.
22+func SessionUser(s *pssh.SSHServerConnSession) (*db.User, error) {
23+ user := pssh.GetUser(s)
24+ if user == nil {
25+ err := fmt.Errorf("could not get user from ctx")
26+ pssh.GetLogger(s).Error("error getting user from ctx", "err", err)
27+ return nil, err
28+ }
29+ return user, nil
30+}
31+
32+// NewPost is the post a file makes before an app fills in the rest.
33+func NewPost(filename string, text []byte) *db.Post {
34+ mimeType := http.DetectContentType(text)
35+ // DetectContentType does not detect markdown
36+ if filepath.Ext(filename) == ".md" {
37+ mimeType = "text/markdown; charset=UTF-8"
38+ }
39+ now := time.Now()
40+ return &db.Post{
41+ Filename: filename,
42+ Slug: shared.SanitizeFileExt(filename),
43+ PublishAt: &now,
44+ UpdatedAt: &now,
45+ Text: string(text),
46+ MimeType: mimeType,
47+ FileSize: binary.Size(text),
48+ Shasum: shared.Shasum(text),
49+ }
50+}
51+
52+// PostFromEntry reads an uploaded file into a new post for user in space.
53+func PostFromEntry(entry *sendutils.FileEntry, user *db.User, space string) (*db.Post, error) {
54+ if entry.Mode.IsDir() {
55+ return nil, os.ErrInvalid
56+ }
57+ filename := filepath.Base(entry.Filepath)
58+ text, err := io.ReadAll(entry.Reader)
59+ if err != nil {
60+ return nil, fmt.Errorf("could not read file (%s): %w", filename, err)
61+ }
62+ post := NewPost(filename, text)
63+ post.UserID = user.ID
64+ post.Space = space
65+ if entry.Mtime > 0 {
66+ modTime := time.Unix(entry.Mtime, 0)
67+ post.UpdatedAt = &modTime
68+ }
69+ return post, nil
70+}
71+
72+// LookupPost finds a user's post by filename, or nil when there is none.
73+func LookupPost(dbpool db.PostDB, userID, space, filename string) (*db.Post, error) {
74+ post, err := dbpool.FindPostWithFilename(filename, userID, space)
75+ if errors.Is(err, sql.ErrNoRows) {
76+ return nil, nil
77+ }
78+ return post, err
79+}
80+
81+// SavePost inserts next, or updates cur with it. It reports false without
82+// writing when cur already has next's text and mod time.
83+func SavePost(dbpool db.PostDB, cur, next *db.Post) (*db.Post, bool, error) {
84+ if cur == nil {
85+ post, err := dbpool.InsertPost(&db.Post{
86+ UserID: next.UserID,
87+ Space: next.Space,
88+ Data: next.Data,
89+ Description: next.Description,
90+ Filename: next.Filename,
91+ FileSize: next.FileSize,
92+ Hidden: next.Hidden,
93+ MimeType: next.MimeType,
94+ PublishAt: next.PublishAt,
95+ Shasum: next.Shasum,
96+ Slug: next.Slug,
97+ Text: next.Text,
98+ Title: next.Title,
99+ ExpiresAt: next.ExpiresAt,
100+ UpdatedAt: next.UpdatedAt,
101+ })
102+ if err != nil {
103+ return nil, false, fmt.Errorf("error for %s: %v", next.Filename, err)
104+ }
105+ return post, true, nil
106+ }
107+
108+ if next.Text == cur.Text && next.UpdatedAt.Equal(*cur.UpdatedAt) {
109+ return cur, false, nil
110+ }
111+ post, err := dbpool.UpdatePost(&db.Post{
112+ ID: cur.ID,
113+ Data: next.Data,
114+ FileSize: next.FileSize,
115+ Description: next.Description,
116+ PublishAt: next.PublishAt,
117+ Slug: next.Slug,
118+ Shasum: next.Shasum,
119+ Text: next.Text,
120+ Title: next.Title,
121+ Hidden: next.Hidden,
122+ ExpiresAt: next.ExpiresAt,
123+ UpdatedAt: next.UpdatedAt,
124+ })
125+ if err != nil {
126+ return nil, false, fmt.Errorf("error for %s: %v", next.Filename, err)
127+ }
128+ return post, true, nil
129+}
130+
131+// ReadPost returns a user's post as a file.
132+func ReadPost(s *pssh.SSHServerConnSession, dbpool db.PostDB, space string, entry *sendutils.FileEntry) (os.FileInfo, sendutils.ReadAndReaderAtCloser, error) {
133+ user, err := SessionUser(s)
134+ if err != nil {
135+ return nil, nil, err
136+ }
137+
138+ cleanFilename := filepath.Base(entry.Filepath)
139+ if cleanFilename == "" || cleanFilename == "." {
140+ return nil, nil, os.ErrNotExist
141+ }
142+
143+ post, err := dbpool.FindPostWithFilename(cleanFilename, user.ID, space)
144+ if err != nil {
145+ return nil, nil, err
146+ }
147+
148+ fileInfo := &sendutils.VirtualFile{
149+ FName: post.Filename,
150+ FIsDir: false,
151+ FSize: int64(post.FileSize),
152+ FModTime: *post.UpdatedAt,
153+ }
154+ reader := sendutils.NopReadAndReaderAtCloser(strings.NewReader(post.Text))
155+ return fileInfo, reader, nil
156+}
157+
158+// DeletePost removes a user's post.
159+func DeletePost(s *pssh.SSHServerConnSession, dbpool db.PostDB, space string, entry *sendutils.FileEntry) error {
160+ user, err := SessionUser(s)
161+ if err != nil {
162+ return err
163+ }
164+
165+ filename := filepath.Base(entry.Filepath)
166+ logger := pssh.GetLogger(s).With("filename", filename)
167+
168+ post, err := dbpool.FindPostWithFilename(filename, user.ID, space)
169+ if err != nil {
170+ return err
171+ }
172+ if post == nil {
173+ return os.ErrNotExist
174+ }
175+
176+ logger.Info("removing record")
177+ if err := dbpool.RemovePosts([]string{post.ID}); err != nil {
178+ logger.Error("post could not remove", "err", err.Error())
179+ return fmt.Errorf("error for %s: %v", filename, err)
180+ }
181+ return nil
182+}
183+
184+// RenameFunc makes the post that cur becomes under the filename next
185+// already has, with next.Tags and the returned aliases.
186+type RenameFunc func(cur, next *db.Post) (aliases []string, err error)
187+
188+// RenamePost moves a user's post to a new filename in place, so it keeps its
189+// ID, publish date and settings. A post at the new filename is replaced.
190+func RenamePost(s *pssh.SSHServerConnSession, dbpool db.PostDB, space string, from, to *sendutils.FileEntry, rename RenameFunc) error {
191+ user, err := SessionUser(s)
192+ if err != nil {
193+ return err
194+ }
195+ fromName, toName := filepath.Base(from.Filepath), filepath.Base(to.Filepath)
196+ // A save to either post while it is being renamed starts it over.
197+ for range 3 {
198+ if err = renamePost(dbpool, user, space, fromName, toName, rename); !errors.Is(err, db.ErrPostChanged) {
199+ break
200+ }
201+ }
202+ if err != nil {
203+ return err
204+ }
205+ pssh.GetLogger(s).Info("renamed post", "from", fromName, "to", toName)
206+ return nil
207+}
208+
209+func renamePost(dbpool db.PostDB, user *db.User, space, fromName, toName string, rename RenameFunc) error {
210+ post, err := dbpool.FindPostWithFilename(fromName, user.ID, space)
211+ if errors.Is(err, sql.ErrNoRows) {
212+ return os.ErrNotExist
213+ }
214+ if err != nil {
215+ return err
216+ }
217+ if fromName == toName {
218+ return nil
219+ }
220+ replaced, err := LookupPost(dbpool, user.ID, space, toName)
221+ if err != nil {
222+ return err
223+ }
224+
225+ next := NewPost(toName, []byte(post.Text))
226+ next.UserID, next.Space = post.UserID, post.Space
227+ next.Data, next.PublishAt = post.Data, post.PublishAt
228+ aliases, err := rename(post, next)
229+ if err != nil {
230+ return err
231+ }
232+ if other, err := dbpool.FindPostWithSlug(next.Slug, user.ID, space); err == nil &&
233+ other.ID != post.ID && (replaced == nil || other.ID != replaced.ID) {
234+ return fmt.Errorf("%s: %s already uses its slug (%s): %w", toName, other.Filename, next.Slug, os.ErrExist)
235+ }
236+
237+ next.UpdatedAt = post.UpdatedAt
238+ // The new name can parse the text differently, as .md and .lxt do.
239+ _, err = dbpool.RenamePost(post, next, replaced, next.Tags, aliases)
240+ if err != nil && !errors.Is(err, db.ErrPostChanged) {
241+ return fmt.Errorf("error for %s: %w", toName, err)
242+ }
243+ return err
244+}
+40, -38
......@@ -19,32 +19,41 @@ import (
1919 "golang.org/x/crypto/ssh"
2020 )
2121
22-// extHooks accepts .md and .lxt files, and tags each with its extension.
23-type extHooks struct {
24- // meta runs while a post's metadata is read, as a save from another
25- // session could.
26- meta func()
22+// extRename accepts .md and .lxt files, and tags each with its extension.
23+// meta runs while the renamed post is made, as a save from another session
24+// could.
25+func extRename(meta func()) RenameFunc {
26+ return func(cur, next *db.Post) ([]string, error) {
27+ ext := filepath.Ext(next.Filename)
28+ if ext != ".md" && ext != ".lxt" {
29+ return nil, fmt.Errorf("%s: invalid extension", next.Filename)
30+ }
31+ if meta != nil {
32+ meta()
33+ }
34+ next.Tags = []string{ext}
35+ next.Title = next.Slug
36+ next.Hidden = cur.Hidden
37+ return []string{next.Slug + ext}, nil
38+ }
2739 }
2840
29-func (extHooks) FileValidate(_ *pssh.SSHServerConnSession, data *PostMetaData) (bool, error) {
30- if ext := filepath.Ext(data.Filename); ext != ".md" && ext != ".lxt" {
31- return false, fmt.Errorf("%s: invalid extension", data.Filename)
32- }
33- return true, nil
41+func entry(name string) *sendutils.FileEntry {
42+ return &sendutils.FileEntry{Filepath: "/" + name}
3443 }
3544
36-func (h extHooks) FileMeta(_ *pssh.SSHServerConnSession, data *PostMetaData) error {
37- if h.meta != nil {
38- h.meta()
39- }
40- ext := filepath.Ext(data.Filename)
41- data.Tags = []string{ext}
42- data.Aliases = []string{data.Slug + ext}
43- data.Title = data.Slug
44- if data.Cur != nil {
45- data.Hidden = data.Cur.Hidden
46- }
47- return nil
45+// postsHandler stores posts with the shared post functions.
46+type postsHandler struct {
47+ ReadWriteHandler
48+ db *stub.PostsDB
49+}
50+
51+func (h *postsHandler) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
52+ return BaseList(s, fpath, isDir, recursive, []string{"prose"}, h.db)
53+}
54+
55+func (h *postsHandler) Rename(s *pssh.SSHServerConnSession, from, to *sendutils.FileEntry) error {
56+ return RenamePost(s, h.db, "prose", from, to, extRename(nil))
4857 }
4958
5059 func newPostsSession(t *testing.T) (*pssh.SSHServerConnSession, *stub.PostsDB) {
......@@ -64,10 +73,10 @@ func newPostsSession(t *testing.T) (*pssh.SSHServerConnSession, *stub.PostsDB) {
6473
6574 func insertPost(t *testing.T, posts *stub.PostsDB, filename, text string, hidden bool) *db.Post {
6675 t.Helper()
67- p := newPost(filename, []byte(text))
76+ p := NewPost(filename, []byte(text))
6877 publish := time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC)
6978 p.UserID, p.Space, p.Hidden, p.PublishAt, p.UpdatedAt = "user-1", "prose", hidden, &publish, &publish
70- created, err := posts.InsertPost(&p)
79+ created, err := posts.InsertPost(p)
7180 if err != nil {
7281 t.Fatal(err)
7382 }
......@@ -76,12 +85,11 @@ func insertPost(t *testing.T, posts *stub.PostsDB, filename, text string, hidden
7685
7786 func TestPostRename(t *testing.T) {
7887 session, posts := newPostsSession(t)
79- h := NewScpPostHandler(posts, &shared.ConfigSite{Space: "prose"}, extHooks{})
8088 a := insertPost(t, posts, "a.md", "# a", true)
8189 b := insertPost(t, posts, "b.md", "# b", false)
8290 insertPost(t, posts, "c.md", "# c", false)
8391 rename := func(from, to string) error {
84- return h.Rename(session, &sendutils.FileEntry{Filepath: "/" + from}, &sendutils.FileEntry{Filepath: "/" + to})
92+ return RenamePost(session, posts, "prose", entry(from), entry(to), extRename(nil))
8593 }
8694
8795 if err := rename("a.md", "a.lxt"); err != nil {
......@@ -131,12 +139,11 @@ func (otherHandler) List(*pssh.SSHServerConnSession, string, bool, bool) ([]os.F
131139
132140 func TestRouterSharedHandler(t *testing.T) {
133141 session, posts := newPostsSession(t)
134- cfg := &shared.ConfigSite{Space: "prose"}
135- postHandler := NewScpPostHandler(posts, cfg, extHooks{})
136- router := NewFileHandlerRouter(cfg, posts, map[string]ReadWriteHandler{
142+ postHandler := &postsHandler{db: posts}
143+ router := NewFileHandlerRouter(&shared.ConfigSite{Space: "prose"}, map[string]ReadWriteHandler{
137144 ".md": postHandler,
138145 ".lxt": postHandler,
139- ".txt": NewScpPostHandler(posts, cfg, extHooks{}),
146+ ".txt": &postsHandler{db: posts},
140147 "fallback": otherHandler{},
141148 })
142149 insertPost(t, posts, "a.md", "# a", false)
......@@ -153,7 +160,6 @@ func TestRouterSharedHandler(t *testing.T) {
153160 t.Errorf("listed %q, want /, a.md and x.png once each", names)
154161 }
155162
156- entry := func(name string) *sendutils.FileEntry { return &sendutils.FileEntry{Filepath: "/" + name} }
157163 if err := router.Rename(session, entry("a.md"), entry("a.lxt")); err != nil {
158164 t.Fatal(err)
159165 }
......@@ -166,9 +172,8 @@ func TestRouterSharedHandler(t *testing.T) {
166172
167173 func TestPostRenameRelations(t *testing.T) {
168174 session, posts := newPostsSession(t)
169- h := NewScpPostHandler(posts, &shared.ConfigSite{Space: "prose"}, extHooks{})
170175 a := insertPost(t, posts, "a.md", "# a", false)
171- if err := h.Rename(session, &sendutils.FileEntry{Filepath: "/a.md"}, &sendutils.FileEntry{Filepath: "/a.lxt"}); err != nil {
176+ if err := RenamePost(session, posts, "prose", entry("a.md"), entry("a.lxt"), extRename(nil)); err != nil {
172177 t.Fatal(err)
173178 }
174179 got, err := posts.FindPost(a.ID)
......@@ -215,9 +220,7 @@ func TestPostRenameConcurrentSave(t *testing.T) {
215220 t.Fatal(err)
216221 }
217222 }
218- h := NewScpPostHandler(posts, &shared.ConfigSite{Space: "prose"}, extHooks{meta: save})
219-
220- err := h.Rename(session, &sendutils.FileEntry{Filepath: "/a.md"}, &sendutils.FileEntry{Filepath: "/b.md"})
223+ err := RenamePost(session, posts, "prose", entry("a.md"), entry("b.md"), extRename(save))
221224 if !errors.Is(err, tt.want) {
222225 t.Fatalf("rename: %v, want %v", err, tt.want)
223226 }
......@@ -274,8 +277,7 @@ func TestPostRenameMovedDestination(t *testing.T) {
274277 t.Fatal(err)
275278 }
276279 }
277- h := NewScpPostHandler(posts, &shared.ConfigSite{Space: "prose"}, extHooks{meta: move})
278- if err := h.Rename(session, &sendutils.FileEntry{Filepath: "/a.md"}, &sendutils.FileEntry{Filepath: "/b.md"}); err != nil {
280+ if err := RenamePost(session, posts, "prose", entry("a.md"), entry("b.md"), extRename(move)); err != nil {
279281 t.Fatal(err)
280282 }
281283 for name, id := range map[string]string{"b.md": a.ID, "f.md": b.ID} {
+2, -6
......@@ -24,19 +24,15 @@ type ReadWriteHandler interface {
2424 type FileHandlerRouter struct {
2525 FileMap map[string]ReadWriteHandler
2626 Cfg *shared.ConfigSite
27- DBPool db.DB
28- Spaces []string
2927 }
3028
3129 var _ utils.CopyFromClientHandler = &FileHandlerRouter{} // Verify implementation
3230 var _ utils.CopyFromClientHandler = (*FileHandlerRouter)(nil) // Verify implementation
3331
34-func NewFileHandlerRouter(cfg *shared.ConfigSite, dbpool db.DB, mapper map[string]ReadWriteHandler) *FileHandlerRouter {
32+func NewFileHandlerRouter(cfg *shared.ConfigSite, mapper map[string]ReadWriteHandler) *FileHandlerRouter {
3533 return &FileHandlerRouter{
3634 Cfg: cfg,
37- DBPool: dbpool,
3835 FileMap: mapper,
39- Spaces: []string{cfg.Space},
4036 }
4137 }
4238
......@@ -159,7 +155,7 @@ func (r *FileHandlerRouter) Validate(s *pssh.SSHServerConnSession) error {
159155 return nil
160156 }
161157
162-func BaseList(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool, spaces []string, dbpool db.DB) ([]os.FileInfo, error) {
158+func BaseList(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool, spaces []string, dbpool db.PostDB) ([]os.FileInfo, error) {
163159 var fileList []os.FileInfo
164160 logger := pssh.GetLogger(s)
165161 user := pssh.GetUser(s)
+1, -1
......@@ -65,7 +65,7 @@ func newPostsEnv(t *testing.T, app string) *postsEnv {
6565 handler = prose.NewFileHandler(cfg, posts, st)
6666 case "pastes":
6767 cfg = pastes.NewConfigSite("pastes-test")
68- handler = pastes.NewFileHandler(cfg, posts)
68+ handler = pastes.NewUploadHandler(cfg, posts)
6969 }
7070 cfg.Logger = logger
7171
+5, -1
......@@ -151,7 +151,11 @@ func (r *SshAuthHandler) PubkeyAuthHandler(conn ssh.ConnMetadata, key ssh.Public
151151 return perms, nil
152152 }
153153
154-func FindPlusFF(dbpool db.DB, cfg *ConfigSite, userID string) *db.FeatureFlag {
154+type FeatureFinder interface {
155+ FindFeature(userID string, feature string) (*db.FeatureFlag, error)
156+}
157+
158+func FindPlusFF(dbpool FeatureFinder, cfg *ConfigSite, userID string) *db.FeatureFlag {
155159 ff, _ := dbpool.FindFeature(userID, "plus")
156160 // we have free tiers so users might not have a feature flag
157161 // in which case we set sane defaults