Commit 8e1462e

Antonio Mika  ·  2026-10-10 20:29:41 -0400 EDT
parent 5de0fe9
feat(filehandlers): rename posts in place over sftp
17 files changed,  +1254, -60
+15, -6
......@@ -46,13 +46,18 @@ func (p *FileHooks) FileMeta(s *pssh.SSHServerConnSession, data *filehandlers.Po
4646 // we want the slug to be the filename for pastes
4747 data.Slug = data.Filename
4848
49- if data.ExpiresAt == nil || data.ExpiresAt.IsZero() {
50- // mark posts for deletion a X days after creation
51- expiresAt := time.Now().AddDate(0, 0, DEFAULT_EXPIRES_AT)
52- data.ExpiresAt = &expiresAt
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+ }
5359 }
5460
55- var hidden bool
5661 var expiresFound bool
5762 var expires *time.Time
5863
......@@ -76,7 +81,11 @@ func (p *FileHooks) FileMeta(s *pssh.SSHServerConnSession, data *filehandlers.Po
7681 case "expires":
7782 val, err := strconv.ParseBool(splitArg[1])
7883 if err == nil {
79- expiresFound = !val
84+ expiresFound = true
85+ expires = nil
86+ if val {
87+ expires = &expiresAt
88+ }
8089 continue
8190 }
8291
+52, -0
......@@ -0,0 +1,52 @@
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+ day := 24 * time.Hour
17+ tests := []struct {
18+ name string
19+ cmd []string
20+ cur *db.Post
21+ hidden bool
22+ // expires is how long until the paste expires, or 0 for never.
23+ expires time.Duration
24+ }{
25+ {name: "new", expires: 90 * day},
26+ {name: "new with arguments", cmd: []string{"hidden=true", "expires=2h"}, hidden: true, expires: 2 * time.Hour},
27+ {name: "existing", cur: &db.Post{Hidden: true, ExpiresAt: &soon}, hidden: true, expires: time.Hour},
28+ {name: "existing without expiry", cur: &db.Post{Hidden: true}, hidden: true},
29+ {name: "existing past its expiry", cur: &db.Post{Hidden: true, ExpiresAt: &past}, hidden: true, expires: 90 * day},
30+ {name: "existing with arguments", cmd: []string{"hidden=false", "expires=true"}, cur: &db.Post{Hidden: true}, expires: 90 * day},
31+ {name: "existing made permanent", cmd: []string{"expires=false"}, cur: &db.Post{ExpiresAt: &soon}},
32+ }
33+ for _, tt := range tests {
34+ t.Run(tt.name, func(t *testing.T) {
35+ s := &pssh.SSHServerConnSession{Ctx: context.Background()}
36+ s.SetValue("command", tt.cmd)
37+ data := &filehandlers.PostMetaData{Post: &db.Post{Filename: "a.txt", Slug: "a"}, Cur: tt.cur}
38+ if err := (&FileHooks{}).FileMeta(s, data); err != nil {
39+ t.Fatal(err)
40+ }
41+ if data.Hidden != tt.hidden {
42+ t.Errorf("hidden = %v, want %v", data.Hidden, tt.hidden)
43+ }
44+ switch {
45+ case tt.expires == 0 && data.ExpiresAt != nil:
46+ t.Errorf("expires at %v, want never", data.ExpiresAt)
47+ case tt.expires != 0 && (data.ExpiresAt == nil || time.Until(*data.ExpiresAt).Round(time.Minute) != tt.expires):
48+ t.Errorf("expires at %v, want in %v", data.ExpiresAt, tt.expires)
49+ }
50+ })
51+ }
52+}
+1, -10
......@@ -8,7 +8,6 @@ import (
88 "time"
99
1010 "github.com/picosh/pico/pkg/db/postgres"
11- "github.com/picosh/pico/pkg/filehandlers"
1211 "github.com/picosh/pico/pkg/pssh"
1312 "github.com/picosh/pico/pkg/send/auth"
1413 "github.com/picosh/pico/pkg/send/list"
......@@ -35,15 +34,7 @@ func StartSshServer() {
3534 defer func() {
3635 _ = dbh.Close()
3736 }()
38- hooks := &FileHooks{
39- Cfg: cfg,
40- Db: dbh,
41- }
42-
43- fileMap := map[string]filehandlers.ReadWriteHandler{
44- "fallback": filehandlers.NewScpPostHandler(dbh, cfg, hooks),
45- }
46- handler := NewUploadHandler(cfg, dbh, fileMap)
37+ handler := NewFileHandler(cfg, dbh)
4738 sshAuth := shared.NewSshAuthHandler(dbh, logger, "pastes")
4839
4940 // Create a new SSH server
+8, -0
......@@ -48,6 +48,14 @@ func NewUploadHandler(cfg *shared.ConfigSite, dbpool db.DB, fileMap map[string]f
4848 }
4949 }
5050
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+ })
57+}
58+
5159 func (h *UploadHandler) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger {
5260 logger := pssh.GetLogger(s)
5361 if logger == nil {
+1, -15
......@@ -8,8 +8,6 @@ import (
88 "time"
99
1010 "github.com/picosh/pico/pkg/db/postgres"
11- "github.com/picosh/pico/pkg/filehandlers"
12- uploadimgs "github.com/picosh/pico/pkg/filehandlers/imgs"
1311 "github.com/picosh/pico/pkg/pssh"
1412 "github.com/picosh/pico/pkg/send/auth"
1513 "github.com/picosh/pico/pkg/send/list"
......@@ -38,11 +36,6 @@ func StartSshServer() {
3836 _ = dbh.Close()
3937 }()
4038
41- hooks := &MarkdownHooks{
42- Cfg: cfg,
43- Db: dbh,
44- }
45-
4639 adapter := storage.GetStorageTypeFromEnv()
4740 st, err := storage.NewStorage(cfg.Logger, adapter)
4841 if err != nil {
......@@ -50,14 +43,7 @@ func StartSshServer() {
5043 return
5144 }
5245
53- fileMap := map[string]filehandlers.ReadWriteHandler{
54- ".md": filehandlers.NewScpPostHandler(dbh, cfg, hooks),
55- ".txt": filehandlers.NewScpPostHandler(dbh, cfg, hooks),
56- ".css": filehandlers.NewScpPostHandler(dbh, cfg, hooks),
57- ".lxt": filehandlers.NewScpPostHandler(dbh, cfg, hooks),
58- "fallback": uploadimgs.NewUploadImgHandler(dbh, cfg, st),
59- }
60- handler := NewUploadHandler(cfg, dbh, fileMap)
46+ handler := NewFileHandler(cfg, dbh, st)
6147
6248 sshAuth := shared.NewSshAuthHandler(dbh, logger, "prose")
6349
+17, -0
......@@ -7,9 +7,11 @@ import (
77
88 "github.com/picosh/pico/pkg/db"
99 "github.com/picosh/pico/pkg/filehandlers"
10+ uploadimgs "github.com/picosh/pico/pkg/filehandlers/imgs"
1011 "github.com/picosh/pico/pkg/pssh"
1112 sendutils "github.com/picosh/pico/pkg/send/utils"
1213 "github.com/picosh/pico/pkg/shared"
14+ "github.com/picosh/pico/pkg/storage"
1315 )
1416
1517 type ctxFeatureFlagKey struct{}
......@@ -79,6 +81,21 @@ func NewUploadHandler(cfg *shared.ConfigSite, dbpool db.DB, fileMap map[string]f
7981 }
8082 }
8183
84+// 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}
87+ // One post handler serves every text extension, so a post can be
88+ // renamed in place from one to another.
89+ posts := filehandlers.NewScpPostHandler(dbpool, cfg, hooks)
90+ return NewUploadHandler(cfg, dbpool, map[string]filehandlers.ReadWriteHandler{
91+ ".md": posts,
92+ ".txt": posts,
93+ ".css": posts,
94+ ".lxt": posts,
95+ "fallback": uploadimgs.NewUploadImgHandler(dbpool, cfg, st),
96+ })
97+}
98+
8299 func (h *UploadHandler) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger {
83100 logger := pssh.GetLogger(s)
84101 if logger == nil {
+7, -0
......@@ -14,6 +14,7 @@ var ErrNameTaken = errors.New("username has already been claimed")
1414 var ErrNameDenied = errors.New("username is on the denylist")
1515 var ErrNameInvalid = errors.New("username has invalid characters in it")
1616 var ErrPublicKeyTaken = errors.New("public key is already associated with another user")
17+var ErrPostChanged = errors.New("post changed while it was being updated")
1718
1819 // sqlite uses string to BLOB type and postgres uses []uint8 for JSONB.
1920 func tcast(value any) ([]byte, error) {
......@@ -604,6 +605,12 @@ type DB interface {
604605 FindPostWithSlug(slug string, userID string, space string) (*Post, error)
605606 InsertPost(post *Post) (*Post, error)
606607 UpdatePost(post *Post) (*Post, error)
608+ // RenamePost moves cur to next's filename, with the slug, title,
609+ // description, publish date, hidden flag, expiry, mime type, tags and
610+ // aliases that name gives it, and removes replaced when it is set, all
611+ // in one transaction. It returns ErrPostChanged when either post no
612+ // longer matches what the caller read.
613+ RenamePost(cur, next, replaced *Post, tags, aliases []string) (*Post, error)
607614 RemovePosts(postIDs []string) error
608615
609616 FindPopularPosts() ([]*Post, error)
+89, -0
......@@ -693,6 +693,95 @@ func (me *PsqlDB) UpdatePost(post *db.Post) (*db.Post, error) {
693693 return me.FindPost(post.ID)
694694 }
695695
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+
696785 func (me *PsqlDB) RemovePosts(postIDs []string) error {
697786 param := "{" + strings.Join(postIDs, ",") + "}"
698787 _, err := me.Db.Exec(`DELETE FROM posts WHERE id = ANY($1::uuid[])`, param)
+121, -0
......@@ -2,12 +2,14 @@ package postgres
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "log/slog"
78 "os"
89 "os/exec"
910 "path/filepath"
1011 "runtime"
12+ "slices"
1113 "sort"
1214 "testing"
1315 "time"
......@@ -578,6 +580,125 @@ func TestUpdatePost(t *testing.T) {
578580 }
579581 }
580582
583+func TestRenamePost(t *testing.T) {
584+ cleanupTestData(t)
585+
586+ user, _ := testDB.RegisterUser("renamepostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI renamepostowner", "comment", "")
587+ mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "a.md", Slug: "a", Text: "a", Space: "prose", Hidden: true})
588+ mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "b.md", Slug: "b", Text: "b", Space: "prose"})
589+ third := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "c.md", Slug: "c", Text: "c", Space: "prose"})
590+ // Posts made before data was written on insert have the column default.
591+ if _, err := testDB.Db.Exec(`UPDATE posts SET data = '{}'::jsonb`); err != nil {
592+ t.Fatal(err)
593+ }
594+ find := func(filename string) *db.Post {
595+ t.Helper()
596+ p, err := testDB.FindPostWithFilename(filename, user.ID, "prose")
597+ if err != nil {
598+ t.Fatalf("%s: %v", filename, err)
599+ }
600+ return p
601+ }
602+ renamed := func(cur *db.Post, filename, slug string) *db.Post {
603+ next := *cur
604+ next.Filename, next.Slug, next.MimeType = filename, slug, "text/plain"
605+ return &next
606+ }
607+
608+ a := find("a.md")
609+ got, err := testDB.RenamePost(a, renamed(a, "a.lxt", "a"), nil, []string{"x", "y"}, []string{"old-a"})
610+ if err != nil {
611+ t.Fatalf("RenamePost failed: %v", err)
612+ }
613+ if got.ID != a.ID || got.Filename != "a.lxt" || got.MimeType != "text/plain" || !got.Hidden {
614+ t.Errorf("unexpected post after rename: %+v", got)
615+ }
616+ a = find("a.lxt")
617+ if slices.Sort(a.Tags); !slices.Equal(a.Tags, []string{"x", "y"}) {
618+ t.Errorf("tags = %q", a.Tags)
619+ }
620+ if p, err := testDB.FindPostWithSlug("old-a", user.ID, "prose"); err != nil || p.ID != a.ID {
621+ t.Errorf("alias old-a = %+v, %v", p, err)
622+ }
623+
624+ // A save after the post was read stops the rename and is kept.
625+ stale := *a
626+ saved := *a
627+ saved.Text = "saved"
628+ if _, err := testDB.UpdatePost(&saved); err != nil {
629+ t.Fatal(err)
630+ }
631+ if _, err := testDB.RenamePost(&stale, renamed(&stale, "d.md", "d"), nil, nil, nil); !errors.Is(err, db.ErrPostChanged) {
632+ t.Fatalf("rename of a changed post: %v", err)
633+ }
634+ if p := find("a.lxt"); p.Text != "saved" {
635+ t.Errorf("a.lxt text = %q, want the save", p.Text)
636+ }
637+
638+ // So does a save to the post it replaces.
639+ a = find("a.lxt")
640+ b := find("b.md")
641+ bSaved := *b
642+ bSaved.Text = "b saved"
643+ if _, err := testDB.UpdatePost(&bSaved); err != nil {
644+ t.Fatal(err)
645+ }
646+ if _, err := testDB.RenamePost(a, renamed(a, "b.md", "b"), b, nil, nil); !errors.Is(err, db.ErrPostChanged) {
647+ t.Fatalf("rename over a changed post: %v", err)
648+ }
649+ if p := find("b.md"); p.Text != "b saved" {
650+ t.Errorf("b.md text = %q, want the save", p.Text)
651+ }
652+ if p := find("a.lxt"); p.ID != a.ID {
653+ t.Errorf("a.lxt moved by a failed rename")
654+ }
655+
656+ // Taking another post's slug fails and keeps the post it would replace.
657+ b = find("b.md")
658+ if _, err := testDB.RenamePost(a, renamed(a, "c2.md", "c"), b, nil, nil); err == nil {
659+ t.Fatal("expected a slug conflict")
660+ }
661+ find("b.md")
662+
663+ if _, err := testDB.RenamePost(a, renamed(a, "b.md", "b"), b, nil, nil); err != nil {
664+ t.Fatalf("RenamePost over b.md failed: %v", err)
665+ }
666+ if p := find("b.md"); p.ID != a.ID || p.Text != "saved" {
667+ t.Errorf("b.md = %+v", p)
668+ }
669+ if _, err := testDB.FindPost(b.ID); err == nil {
670+ t.Error("replaced post still exists")
671+ }
672+ if _, err := testDB.FindPost(third.ID); err != nil {
673+ t.Errorf("unrelated post removed: %v", err)
674+ }
675+
676+ // Data written after the post was read, like a feed digest, is kept.
677+ moved := find("b.md")
678+ if _, err := testDB.Db.Exec(`UPDATE posts SET data = '{"attempts": 2}'::jsonb WHERE id = $1`, moved.ID); err != nil {
679+ t.Fatal(err)
680+ }
681+ if _, err := testDB.RenamePost(moved, renamed(moved, "e.md", "e"), nil, nil, nil); err != nil {
682+ t.Fatalf("rename after a data change: %v", err)
683+ }
684+ if p := find("e.md"); p.Data.Attempts != 2 {
685+ t.Errorf("data = %+v, want the later write", p.Data)
686+ }
687+
688+ // A post moved to another name after it was read isn't replaced.
689+ e := find("e.md")
690+ c := find("c.md")
691+ if _, err := testDB.Db.Exec(`UPDATE posts SET filename = 'f.md' WHERE id = $1`, c.ID); err != nil {
692+ t.Fatal(err)
693+ }
694+ if _, err := testDB.RenamePost(e, renamed(e, "c.md", "c"), c, nil, nil); !errors.Is(err, db.ErrPostChanged) {
695+ t.Fatalf("rename over a moved post: %v", err)
696+ }
697+ if p := find("f.md"); p.ID != c.ID {
698+ t.Errorf("f.md = %+v", p)
699+ }
700+}
701+
581702 func TestRemovePosts(t *testing.T) {
582703 cleanupTestData(t)
583704
+223, -0
......@@ -0,0 +1,223 @@
1+package stub
2+
3+import (
4+ "database/sql"
5+ "fmt"
6+ "log/slog"
7+ "slices"
8+ "sync"
9+ "time"
10+
11+ "github.com/picosh/pico/pkg/db"
12+)
13+
14+// PostsDB keeps one user's posts in memory, with the unique filename and
15+// slug constraints of the posts table.
16+type PostsDB struct {
17+ *StubDB
18+ User *db.User
19+ Pubkey string
20+
21+ mu sync.Mutex
22+ posts []*db.Post
23+ aliases map[string][]string
24+ n int
25+}
26+
27+func NewPostsDB(logger *slog.Logger, user *db.User) *PostsDB {
28+ return &PostsDB{StubDB: NewStubDB(logger), User: user}
29+}
30+
31+func (m *PostsDB) FindUserByPubkey(key string) (*db.User, error) {
32+ if key != m.Pubkey {
33+ return nil, sql.ErrNoRows
34+ }
35+ return m.User, nil
36+}
37+
38+func (m *PostsDB) ReplaceTagsByPost(tags []string, id string) error {
39+ m.mu.Lock()
40+ defer m.mu.Unlock()
41+ if i := slices.IndexFunc(m.posts, func(p *db.Post) bool { return p.ID == id }); i >= 0 {
42+ m.posts[i].Tags = slices.Clone(tags)
43+ }
44+ return nil
45+}
46+
47+func (m *PostsDB) ReplaceAliasesByPost(aliases []string, id string) error {
48+ m.mu.Lock()
49+ defer m.mu.Unlock()
50+ m.setAliases(id, aliases)
51+ return nil
52+}
53+
54+// Aliases returns the aliases of the post with id.
55+func (m *PostsDB) Aliases(id string) []string {
56+ m.mu.Lock()
57+ defer m.mu.Unlock()
58+ return slices.Clone(m.aliases[id])
59+}
60+
61+// setAliases stores a post's aliases. The caller holds m.mu.
62+func (m *PostsDB) setAliases(id string, aliases []string) {
63+ if m.aliases == nil {
64+ m.aliases = map[string][]string{}
65+ }
66+ m.aliases[id] = slices.Clone(aliases)
67+}
68+
69+func (m *PostsDB) FindUser(string) (*db.User, error) { return m.User, nil }
70+func (m *PostsDB) FindUserByName(string) (*db.User, error) { return m.User, nil }
71+func (m *PostsDB) InsertAccessLog(*db.AccessLog) error { return nil }
72+
73+func (m *PostsDB) FindFeature(userID, name string) (*db.FeatureFlag, error) {
74+ if name != "plus" {
75+ return nil, sql.ErrNoRows
76+ }
77+ expires := time.Now().Add(time.Hour)
78+ return &db.FeatureFlag{UserID: userID, Name: name, ExpiresAt: &expires}, nil
79+}
80+
81+func (m *PostsDB) find(match func(*db.Post) bool) (*db.Post, error) {
82+ m.mu.Lock()
83+ defer m.mu.Unlock()
84+ i := slices.IndexFunc(m.posts, match)
85+ if i < 0 {
86+ return nil, sql.ErrNoRows
87+ }
88+ p := *m.posts[i]
89+ return &p, nil
90+}
91+
92+func (m *PostsDB) FindPost(id string) (*db.Post, error) {
93+ return m.find(func(p *db.Post) bool { return p.ID == id })
94+}
95+
96+func (m *PostsDB) FindPostWithFilename(filename, userID, space string) (*db.Post, error) {
97+ return m.find(func(p *db.Post) bool { return p.UserID == userID && p.Space == space && p.Filename == filename })
98+}
99+
100+func (m *PostsDB) FindPostWithSlug(slug, userID, space string) (*db.Post, error) {
101+ return m.find(func(p *db.Post) bool { return p.UserID == userID && p.Space == space && p.Slug == slug })
102+}
103+
104+func (m *PostsDB) FindAllPostsByUser(userID, space string) ([]*db.Post, error) {
105+ m.mu.Lock()
106+ defer m.mu.Unlock()
107+ var out []*db.Post
108+ for _, p := range m.posts {
109+ if p.UserID == userID && p.Space == space {
110+ c := *p
111+ out = append(out, &c)
112+ }
113+ }
114+ return out, nil
115+}
116+
117+// conflict reports a post other than the ones in skip that p's filename or
118+// slug would collide with. The caller holds m.mu.
119+func (m *PostsDB) conflict(p *db.Post, skip ...string) error {
120+ for _, o := range m.posts {
121+ if slices.Contains(skip, o.ID) || o.UserID != p.UserID || o.Space != p.Space {
122+ continue
123+ }
124+ if o.Filename == p.Filename || o.Slug == p.Slug {
125+ return fmt.Errorf("duplicate key value violates unique constraint: %s", o.Filename)
126+ }
127+ }
128+ return nil
129+}
130+
131+func (m *PostsDB) InsertPost(post *db.Post) (*db.Post, error) {
132+ m.mu.Lock()
133+ defer m.mu.Unlock()
134+ if err := m.conflict(post); err != nil {
135+ return nil, err
136+ }
137+ m.n++
138+ p := *post
139+ p.ID = fmt.Sprintf("post-%d", m.n)
140+ now := time.Now()
141+ p.CreatedAt = &now
142+ m.posts = append(m.posts, &p)
143+ c := p
144+ return &c, nil
145+}
146+
147+func (m *PostsDB) update(post *db.Post, filename bool, skip ...string) (*db.Post, error) {
148+ i := slices.IndexFunc(m.posts, func(p *db.Post) bool { return p.ID == post.ID })
149+ if i < 0 {
150+ return nil, sql.ErrNoRows
151+ }
152+ next := *m.posts[i]
153+ if filename {
154+ next.Filename, next.MimeType = post.Filename, post.MimeType
155+ }
156+ next.Slug, next.Title, next.Text, next.Description = post.Slug, post.Title, post.Text, post.Description
157+ next.UpdatedAt, next.PublishAt, next.FileSize, next.Shasum = post.UpdatedAt, post.PublishAt, post.FileSize, post.Shasum
158+ next.Data, next.Hidden, next.ExpiresAt = post.Data, post.Hidden, post.ExpiresAt
159+ if err := m.conflict(&next, append(skip, post.ID)...); err != nil {
160+ return nil, err
161+ }
162+ m.posts[i] = &next
163+ c := next
164+ return &c, nil
165+}
166+
167+func (m *PostsDB) UpdatePost(post *db.Post) (*db.Post, error) {
168+ m.mu.Lock()
169+ defer m.mu.Unlock()
170+ return m.update(post, false)
171+}
172+
173+func (m *PostsDB) RenamePost(cur, next, replaced *db.Post, tags, aliases []string) (*db.Post, error) {
174+ m.mu.Lock()
175+ defer m.mu.Unlock()
176+ unchanged := func(read *db.Post, all bool) bool {
177+ i := slices.IndexFunc(m.posts, func(p *db.Post) bool { return p.ID == read.ID })
178+ if i < 0 {
179+ return false
180+ }
181+ p := m.posts[i]
182+ same := p.Filename == read.Filename && p.Text == read.Text && equalTime(p.UpdatedAt, read.UpdatedAt)
183+ if all {
184+ same = same && p.Slug == read.Slug && p.Title == read.Title && p.Description == read.Description &&
185+ equalTime(p.PublishAt, read.PublishAt) && p.Hidden == read.Hidden && equalTime(p.ExpiresAt, read.ExpiresAt)
186+ }
187+ return same
188+ }
189+ if !unchanged(cur, true) || (replaced != nil && !unchanged(replaced, false)) {
190+ return nil, db.ErrPostChanged
191+ }
192+ // Only what a new name can change is written.
193+ update := *m.posts[slices.IndexFunc(m.posts, func(p *db.Post) bool { return p.ID == cur.ID })]
194+ update.Filename, update.Slug, update.Title, update.Description = next.Filename, next.Slug, next.Title, next.Description
195+ update.PublishAt, update.Hidden, update.ExpiresAt, update.MimeType = next.PublishAt, next.Hidden, next.ExpiresAt, next.MimeType
196+ var skip []string
197+ if replaced != nil {
198+ skip = append(skip, replaced.ID)
199+ }
200+ p, err := m.update(&update, true, skip...)
201+ if err != nil {
202+ return nil, err
203+ }
204+ if replaced != nil {
205+ m.posts = slices.DeleteFunc(m.posts, func(p *db.Post) bool { return p.ID == replaced.ID })
206+ }
207+ i := slices.IndexFunc(m.posts, func(p *db.Post) bool { return p.ID == cur.ID })
208+ m.posts[i].Tags = slices.Clone(tags)
209+ m.setAliases(cur.ID, aliases)
210+ p.Tags = slices.Clone(tags)
211+ return p, nil
212+}
213+
214+func equalTime(a, b *time.Time) bool {
215+ return (a == nil && b == nil) || (a != nil && b != nil && a.Equal(*b))
216+}
217+
218+func (m *PostsDB) RemovePosts(ids []string) error {
219+ m.mu.Lock()
220+ defer m.mu.Unlock()
221+ m.posts = slices.DeleteFunc(m.posts, func(p *db.Post) bool { return slices.Contains(ids, p.ID) })
222+ return nil
223+}
+4, -0
......@@ -88,6 +88,10 @@ func (me *StubDB) UpdatePost(post *db.Post) (*db.Post, error) {
8888 return nil, errNotImpl
8989 }
9090
91+func (me *StubDB) RenamePost(cur, next, replaced *db.Post, tags, aliases []string) (*db.Post, error) {
92+ return nil, errNotImpl
93+}
94+
9195 func (me *StubDB) RemovePosts(postIDs []string) error {
9296 return errNotImpl
9397 }
+91, -22
......@@ -1,7 +1,9 @@
11 package filehandlers
22
33 import (
4+ "database/sql"
45 "encoding/binary"
6+ "errors"
57 "fmt"
68 "io"
79 "net/http"
......@@ -129,28 +131,7 @@ func (h *ScpUploadHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.
129131 return "", fmt.Errorf("could not read file (%s): %w", filename, err)
130132 }
131133
132- mimeType := http.DetectContentType(origText)
133- ext := filepath.Ext(filename)
134- // DetectContentType does not detect markdown
135- if ext == ".md" {
136- mimeType = "text/markdown; charset=UTF-8"
137- }
138-
139- now := time.Now()
140- slug := shared.SanitizeFileExt(filename)
141- fileSize := binary.Size(origText)
142- shasum := shared.Shasum(origText)
143-
144- nextPost := db.Post{
145- Filename: filename,
146- Slug: slug,
147- PublishAt: &now,
148- Text: string(origText),
149- MimeType: mimeType,
150- FileSize: fileSize,
151- Shasum: shasum,
152- }
153-
134+ nextPost := newPost(filename, origText)
154135 metadata := PostMetaData{
155136 Post: &nextPost,
156137 User: user,
......@@ -291,6 +272,94 @@ func (h *ScpUploadHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.
291272 return h.Cfg.FullPostURL(curl, user.Name, metadata.Slug), nil
292273 }
293274
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+
294363 func (h *ScpUploadHandler) Delete(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
295364 logger := pssh.GetLogger(s)
296365 user := pssh.GetUser(s)
+286, -0
......@@ -0,0 +1,286 @@
1+package filehandlers
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "log/slog"
8+ "os"
9+ "path/filepath"
10+ "slices"
11+ "testing"
12+ "time"
13+
14+ "github.com/picosh/pico/pkg/db"
15+ "github.com/picosh/pico/pkg/db/stub"
16+ "github.com/picosh/pico/pkg/pssh"
17+ sendutils "github.com/picosh/pico/pkg/send/utils"
18+ "github.com/picosh/pico/pkg/shared"
19+ "golang.org/x/crypto/ssh"
20+)
21+
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()
27+}
28+
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
34+}
35+
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
48+}
49+
50+func newPostsSession(t *testing.T) (*pssh.SSHServerConnSession, *stub.PostsDB) {
51+ t.Helper()
52+ logger := slog.New(slog.DiscardHandler)
53+ user := &db.User{ID: "user-1", Name: "tester"}
54+ session := &pssh.SSHServerConnSession{
55+ SSHServerConn: &pssh.SSHServerConn{
56+ Conn: &ssh.ServerConn{Permissions: &ssh.Permissions{Extensions: map[string]string{}}},
57+ Logger: logger,
58+ },
59+ Ctx: context.Background(),
60+ }
61+ pssh.SetUser(session, user)
62+ return session, stub.NewPostsDB(logger, user)
63+}
64+
65+func insertPost(t *testing.T, posts *stub.PostsDB, filename, text string, hidden bool) *db.Post {
66+ t.Helper()
67+ p := newPost(filename, []byte(text))
68+ publish := time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC)
69+ p.UserID, p.Space, p.Hidden, p.PublishAt, p.UpdatedAt = "user-1", "prose", hidden, &publish, &publish
70+ created, err := posts.InsertPost(&p)
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ return created
75+}
76+
77+func TestPostRename(t *testing.T) {
78+ session, posts := newPostsSession(t)
79+ h := NewScpPostHandler(posts, &shared.ConfigSite{Space: "prose"}, extHooks{})
80+ a := insertPost(t, posts, "a.md", "# a", true)
81+ b := insertPost(t, posts, "b.md", "# b", false)
82+ insertPost(t, posts, "c.md", "# c", false)
83+ rename := func(from, to string) error {
84+ return h.Rename(session, &sendutils.FileEntry{Filepath: "/" + from}, &sendutils.FileEntry{Filepath: "/" + to})
85+ }
86+
87+ if err := rename("a.md", "a.lxt"); err != nil {
88+ t.Fatal(err)
89+ }
90+ got, err := posts.FindPostWithFilename("a.lxt", "user-1", "prose")
91+ if err != nil {
92+ t.Fatal(err)
93+ }
94+ if got.ID != a.ID || !got.PublishAt.Equal(*a.PublishAt) || !got.Hidden || got.Text != "# a" || got.MimeType == a.MimeType {
95+ t.Errorf("renamed post = %+v, was %+v", got, a)
96+ }
97+
98+ // c.lxt has the slug c.md has.
99+ if err := rename("a.lxt", "c.lxt"); !errors.Is(err, os.ErrExist) {
100+ t.Errorf("rename onto a used slug: %v", err)
101+ }
102+ if err := rename("a.lxt", "a.txt"); err == nil {
103+ t.Error("rename to an invalid name succeeded")
104+ }
105+ if err := rename("missing.md", "m.md"); !errors.Is(err, os.ErrNotExist) {
106+ t.Errorf("rename of a missing post: %v", err)
107+ }
108+
109+ if err := rename("a.lxt", "b.md"); err != nil {
110+ t.Fatal(err)
111+ }
112+ got, err = posts.FindPostWithFilename("b.md", "user-1", "prose")
113+ if err != nil || got.ID != a.ID || got.Text != "# a" {
114+ t.Errorf("b.md = %+v, %v", got, err)
115+ }
116+ if _, err := posts.FindPost(b.ID); err == nil {
117+ t.Error("replaced post still exists")
118+ }
119+ all, _ := posts.FindAllPostsByUser("user-1", "prose")
120+ if len(all) != 2 {
121+ t.Errorf("posts = %d, want 2", len(all))
122+ }
123+}
124+
125+// otherHandler is a handler of a different kind, like prose's images.
126+type otherHandler struct{ ReadWriteHandler }
127+
128+func (otherHandler) List(*pssh.SSHServerConnSession, string, bool, bool) ([]os.FileInfo, error) {
129+ return []os.FileInfo{&sendutils.VirtualFile{FName: "/", FIsDir: true}, &sendutils.VirtualFile{FName: "x.png"}}, nil
130+}
131+
132+func TestRouterSharedHandler(t *testing.T) {
133+ session, posts := newPostsSession(t)
134+ cfg := &shared.ConfigSite{Space: "prose"}
135+ postHandler := NewScpPostHandler(posts, cfg, extHooks{})
136+ router := NewFileHandlerRouter(cfg, posts, map[string]ReadWriteHandler{
137+ ".md": postHandler,
138+ ".lxt": postHandler,
139+ ".txt": NewScpPostHandler(posts, cfg, extHooks{}),
140+ "fallback": otherHandler{},
141+ })
142+ insertPost(t, posts, "a.md", "# a", false)
143+
144+ list, err := router.List(session, "/", true, false)
145+ if err != nil {
146+ t.Fatal(err)
147+ }
148+ var names []string
149+ for _, f := range list {
150+ names = append(names, f.Name())
151+ }
152+ if len(names) != 3 {
153+ t.Errorf("listed %q, want /, a.md and x.png once each", names)
154+ }
155+
156+ entry := func(name string) *sendutils.FileEntry { return &sendutils.FileEntry{Filepath: "/" + name} }
157+ if err := router.Rename(session, entry("a.md"), entry("a.lxt")); err != nil {
158+ t.Fatal(err)
159+ }
160+ for _, to := range []string{"a.txt", "a.png"} {
161+ if err := router.Rename(session, entry("a.lxt"), entry(to)); !errors.Is(err, errors.ErrUnsupported) {
162+ t.Errorf("rename to %s across handlers: %v", to, err)
163+ }
164+ }
165+}
166+
167+func TestPostRenameRelations(t *testing.T) {
168+ session, posts := newPostsSession(t)
169+ h := NewScpPostHandler(posts, &shared.ConfigSite{Space: "prose"}, extHooks{})
170+ 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 {
172+ t.Fatal(err)
173+ }
174+ got, err := posts.FindPost(a.ID)
175+ if err != nil {
176+ t.Fatal(err)
177+ }
178+ if !slices.Equal(got.Tags, []string{".lxt"}) || !slices.Equal(posts.Aliases(a.ID), []string{"a.lxt"}) {
179+ t.Errorf("tags %q and aliases %q, want the .lxt ones", got.Tags, posts.Aliases(a.ID))
180+ }
181+}
182+
183+func TestPostRenameConcurrentSave(t *testing.T) {
184+ for _, tt := range []struct {
185+ name string
186+ // saves is how many renames a save interrupts.
187+ saves int
188+ // post is the post that is saved.
189+ post string
190+ want error
191+ }{
192+ {name: "source saved once", saves: 1, post: "a.md"},
193+ {name: "replaced post saved once", saves: 1, post: "b.md"},
194+ {name: "source saved on every try", saves: 3, post: "a.md", want: db.ErrPostChanged},
195+ {name: "replaced post saved on every try", saves: 3, post: "b.md", want: db.ErrPostChanged},
196+ } {
197+ t.Run(tt.name, func(t *testing.T) {
198+ session, posts := newPostsSession(t)
199+ a := insertPost(t, posts, "a.md", "# a", true)
200+ b := insertPost(t, posts, "b.md", "# b", false)
201+ saves := 0
202+ save := func() {
203+ if saves == tt.saves {
204+ return
205+ }
206+ saves++
207+ p, err := posts.FindPostWithFilename(tt.post, "user-1", "prose")
208+ if err != nil {
209+ t.Fatal(err)
210+ }
211+ p.Text = fmt.Sprintf("save %d", saves)
212+ now := time.Now()
213+ p.UpdatedAt = &now
214+ if _, err := posts.UpdatePost(p); err != nil {
215+ t.Fatal(err)
216+ }
217+ }
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"})
221+ if !errors.Is(err, tt.want) {
222+ t.Fatalf("rename: %v, want %v", err, tt.want)
223+ }
224+ lastSave := fmt.Sprintf("save %d", saves)
225+ if tt.want != nil {
226+ // Both posts are where they were, with the last save.
227+ for _, p := range []*db.Post{a, b} {
228+ got, err := posts.FindPost(p.ID)
229+ if err != nil {
230+ t.Fatalf("%s: %v", p.Filename, err)
231+ }
232+ want := p.Text
233+ if p.Filename == tt.post {
234+ want = lastSave
235+ }
236+ if got.Filename != p.Filename || got.Text != want {
237+ t.Errorf("%s = %s %q, want %q", p.Filename, got.Filename, got.Text, want)
238+ }
239+ }
240+ return
241+ }
242+ got, err := posts.FindPostWithFilename("b.md", "user-1", "prose")
243+ if err != nil {
244+ t.Fatal(err)
245+ }
246+ want := "# a"
247+ if tt.post == "a.md" {
248+ want = lastSave
249+ }
250+ if got.ID != a.ID || got.Text != want || !got.Hidden {
251+ t.Errorf("b.md = %s %q hidden %v, want %s %q hidden", got.ID, got.Text, got.Hidden, a.ID, want)
252+ }
253+ })
254+ }
255+}
256+
257+func TestPostRenameMovedDestination(t *testing.T) {
258+ session, posts := newPostsSession(t)
259+ a := insertPost(t, posts, "a.md", "# a", false)
260+ b := insertPost(t, posts, "b.md", "# b", false)
261+ moved := false
262+ move := func() {
263+ if moved {
264+ return
265+ }
266+ moved = true
267+ cur, err := posts.FindPost(b.ID)
268+ if err != nil {
269+ t.Fatal(err)
270+ }
271+ next := *cur
272+ next.Filename, next.Slug = "f.md", "f"
273+ if _, err := posts.RenamePost(cur, &next, nil, nil, nil); err != nil {
274+ t.Fatal(err)
275+ }
276+ }
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 {
279+ t.Fatal(err)
280+ }
281+ for name, id := range map[string]string{"b.md": a.ID, "f.md": b.ID} {
282+ if got, err := posts.FindPostWithFilename(name, "user-1", "prose"); err != nil || got.ID != id {
283+ t.Errorf("%s = %+v, %v, want post %s", name, got, err, id)
284+ }
285+ }
286+}
+31, -4
......@@ -84,6 +84,23 @@ func (r *FileHandlerRouter) Delete(s *pssh.SSHServerConnSession, entry *utils.Fi
8484 return handler.Delete(s, entry)
8585 }
8686
87+// Rename moves a file in place when one handler serves both names.
88+func (r *FileHandlerRouter) Rename(s *pssh.SSHServerConnSession, from, to *utils.FileEntry) error {
89+ handler, err := r.findHandler(from.Filepath)
90+ if err != nil {
91+ return err
92+ }
93+ target, err := r.findHandler(to.Filepath)
94+ if err != nil {
95+ return err
96+ }
97+ renamer, ok := handler.(utils.FileRenamer)
98+ if !ok || handler != target {
99+ return errors.ErrUnsupported
100+ }
101+ return renamer.Rename(s, from, to)
102+}
103+
87104 func (r *FileHandlerRouter) Read(s *pssh.SSHServerConnSession, entry *utils.FileEntry) (os.FileInfo, utils.ReadAndReaderAtCloser, error) {
88105 handler, err := r.findHandler(entry.Filepath)
89106 if err != nil {
......@@ -94,18 +111,28 @@ func (r *FileHandlerRouter) Read(s *pssh.SSHServerConnSession, entry *utils.File
94111
95112 func (r *FileHandlerRouter) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
96113 files := []os.FileInfo{}
97- for key, handler := range r.FileMap {
98- // TODO: hack because we have duplicate keys for .md and .css
99- if key == ".css" {
114+ // Several extensions can share a handler, and handlers can list the
115+ // same entries, such as the root directory.
116+ listed := map[ReadWriteHandler]bool{}
117+ seen := map[string]bool{}
118+ for _, handler := range r.FileMap {
119+ if listed[handler] {
100120 continue
101121 }
122+ listed[handler] = true
102123
103124 ff, err := handler.List(s, fpath, isDir, recursive)
104125 if err != nil {
105126 r.GetLogger(s).Error("handler list", "err", err)
106127 continue
107128 }
108- files = append(files, ff...)
129+ for _, f := range ff {
130+ key := fmt.Sprintf("%s:%v", f.Name(), f.IsDir())
131+ if !seen[key] {
132+ seen[key] = true
133+ files = append(files, f)
134+ }
135+ }
109136 }
110137 return files, nil
111138 }
+282, -0
......@@ -0,0 +1,282 @@
1+package filehandlers_test
2+
3+import (
4+ "context"
5+ "crypto/ed25519"
6+ "crypto/rand"
7+ "encoding/pem"
8+ "log/slog"
9+ "net"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "slices"
14+ "strings"
15+ "testing"
16+ "time"
17+
18+ "github.com/picosh/pico/pkg/apps/pastes"
19+ "github.com/picosh/pico/pkg/apps/prose"
20+ "github.com/picosh/pico/pkg/db"
21+ "github.com/picosh/pico/pkg/db/stub"
22+ "github.com/picosh/pico/pkg/pssh"
23+ "github.com/picosh/pico/pkg/send/list"
24+ "github.com/picosh/pico/pkg/send/protocols/sftp"
25+ "github.com/picosh/pico/pkg/send/utils"
26+ "github.com/picosh/pico/pkg/shared"
27+ "github.com/picosh/pico/pkg/storage"
28+ "golang.org/x/crypto/ssh"
29+)
30+
31+type postsEnv struct {
32+ t *testing.T
33+ db *stub.PostsDB
34+ space string
35+ port string
36+ key string
37+}
38+
39+func newPostsEnv(t *testing.T, app string) *postsEnv {
40+ t.Helper()
41+ logger := slog.New(slog.DiscardHandler)
42+ _, userKey, err := ed25519.GenerateKey(rand.Reader)
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ signer, err := ssh.NewSignerFromKey(userKey)
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+ posts := stub.NewPostsDB(logger, &db.User{ID: "user-1", Name: "tester"})
51+ posts.Pubkey = shared.KeyForKeyText(signer.PublicKey())
52+
53+ var cfg *shared.ConfigSite
54+ var handler interface {
55+ utils.CopyFromClientHandler
56+ pssh.GetLoggerInterface
57+ }
58+ switch app {
59+ case "prose":
60+ cfg = prose.NewConfigSite("prose-test")
61+ st, err := storage.NewStorageMemory(map[string]map[string]string{})
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+ handler = prose.NewFileHandler(cfg, posts, st)
66+ case "pastes":
67+ cfg = pastes.NewConfigSite("pastes-test")
68+ handler = pastes.NewFileHandler(cfg, posts)
69+ }
70+ cfg.Logger = logger
71+
72+ ctx, cancel := context.WithCancel(context.Background())
73+ t.Cleanup(cancel)
74+ auth := shared.NewSshAuthHandler(posts, logger, app)
75+ server, err := pssh.NewSSHServerWithConfig(ctx, logger, app, "127.0.0.1", "0", "", filepath.Join(t.TempDir(), "host_key"),
76+ auth.PubkeyAuthHandler,
77+ []pssh.SSHServerMiddleware{list.Middleware(handler), pssh.LogMiddleware(handler, posts)},
78+ []pssh.SSHServerMiddleware{sftp.Middleware(handler), pssh.LogMiddleware(handler, posts)},
79+ nil,
80+ )
81+ if err != nil {
82+ t.Fatal(err)
83+ }
84+ go func() { _ = server.ListenAndServe() }()
85+ var addr string
86+ for range 100 {
87+ server.Mu.Lock()
88+ if server.Listener != nil {
89+ addr = server.Listener.Addr().String()
90+ }
91+ server.Mu.Unlock()
92+ if addr != "" {
93+ break
94+ }
95+ time.Sleep(10 * time.Millisecond)
96+ }
97+ if addr == "" {
98+ t.Fatal("server listener not ready")
99+ }
100+ _, port, _ := net.SplitHostPort(addr)
101+
102+ b, err := ssh.MarshalPrivateKey(userKey, "")
103+ if err != nil {
104+ t.Fatal(err)
105+ }
106+ key := filepath.Join(t.TempDir(), "id_ed25519")
107+ if err := os.WriteFile(key, pem.EncodeToMemory(b), 0o600); err != nil {
108+ t.Fatal(err)
109+ }
110+ return &postsEnv{t: t, db: posts, space: cfg.Space, port: port, key: key}
111+}
112+
113+func (e *postsEnv) sshOpts() []string {
114+ return []string{"-o", "IdentityFile=" + e.key, "-o", "IdentitiesOnly=yes", "-o", "StrictHostKeyChecking=no",
115+ "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR"}
116+}
117+
118+func (e *postsEnv) run(name string, args ...string) string {
119+ e.t.Helper()
120+ out, err := exec.Command(name, args...).CombinedOutput()
121+ if err != nil {
122+ e.t.Fatalf("%s %v: %v\n%s", name, args, err, out)
123+ }
124+ return string(out)
125+}
126+
127+// seed stores a hidden post, made hidden the way each service does it.
128+func (e *postsEnv) seed(filename string) *db.Post {
129+ e.t.Helper()
130+ published := time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC)
131+ post := &db.Post{
132+ UserID: "user-1", Space: e.space, Filename: filename, Slug: strings.TrimSuffix(filename, filepath.Ext(filename)),
133+ Text: seedText(e.space), FileSize: len(seedText(e.space)), Hidden: true, PublishAt: &published, UpdatedAt: &published,
134+ }
135+ if e.space == "pastes" {
136+ expires := time.Now().Add(time.Hour).Truncate(time.Second)
137+ post.ExpiresAt = &expires
138+ }
139+ p, err := e.db.InsertPost(post)
140+ if err != nil {
141+ e.t.Fatal(err)
142+ }
143+ return p
144+}
145+
146+func seedText(space string) string {
147+ if space == "prose" {
148+ return "---\ndraft: true\n---\nseeded\n"
149+ }
150+ return "seeded\n"
151+}
152+
153+// assertKept waits for the post at filename to have text and checks it is
154+// the seeded one with its settings. sshfs sends a close after the command
155+// that made it returns.
156+func (e *postsEnv) assertKept(filename, text string, seeded *db.Post) {
157+ e.t.Helper()
158+ e.assertMoved(filename, text, seeded, seeded.Hidden)
159+}
160+
161+// assertMoved is assertKept for a post whose new name changes whether it
162+// is hidden.
163+func (e *postsEnv) assertMoved(filename, text string, seeded *db.Post, hidden bool) {
164+ e.t.Helper()
165+ var got *db.Post
166+ for range 100 {
167+ var err error
168+ if got, err = e.db.FindPostWithFilename(filename, "user-1", e.space); err == nil && got.Text == text {
169+ break
170+ }
171+ time.Sleep(20 * time.Millisecond)
172+ }
173+ if got == nil || got.Text != text {
174+ e.t.Fatalf("%s = %+v, want text %q", filename, got, text)
175+ }
176+ if got.ID != seeded.ID || !got.PublishAt.Equal(*seeded.PublishAt) || got.Hidden != hidden ||
177+ (got.ExpiresAt == nil) != (seeded.ExpiresAt == nil) || (got.ExpiresAt != nil && !got.ExpiresAt.Equal(*seeded.ExpiresAt)) {
178+ e.t.Errorf("%s = id %s published %v hidden %v expires %v, want %s %v %v %v", filename,
179+ got.ID, got.PublishAt, got.Hidden, got.ExpiresAt, seeded.ID, seeded.PublishAt, hidden, seeded.ExpiresAt)
180+ }
181+}
182+
183+func assertListed(t *testing.T, out string, want ...string) {
184+ t.Helper()
185+ var got []string
186+ for _, line := range strings.Split(out, "\n") {
187+ f := filepath.Base(strings.TrimSpace(line))
188+ if !strings.HasPrefix(line, "sftp>") && f != "." && f != ".." && f != "/" {
189+ got = append(got, f)
190+ }
191+ }
192+ slices.Sort(got)
193+ slices.Sort(want)
194+ if !slices.Equal(got, want) {
195+ t.Errorf("listed %q, want %q", got, want)
196+ }
197+}
198+
199+func TestPostsSftp(t *testing.T) {
200+ if _, err := exec.LookPath("sftp"); err != nil {
201+ t.Skip("sftp not installed")
202+ }
203+ for app, ext := range map[string]string{"prose": ".md", "pastes": ".txt"} {
204+ t.Run(app, func(t *testing.T) {
205+ e := newPostsEnv(t, app)
206+ a := e.seed("a" + ext)
207+ e.seed("c" + ext)
208+
209+ sftpCmd := func(cmds string) string {
210+ cmd := exec.Command("sftp", append(append([]string{"-b", "-", "-P", e.port}, e.sshOpts()...), "localhost")...)
211+ cmd.Stdin = strings.NewReader(cmds)
212+ out, err := cmd.CombinedOutput()
213+ if err != nil {
214+ t.Fatalf("sftp: %v\n%s", err, out)
215+ }
216+ return string(out)
217+ }
218+ assertListed(t, sftpCmd("ls -1\n"), "a"+ext, "c"+ext)
219+ assertListed(t, e.run("ssh", append(append([]string{"-p", e.port}, e.sshOpts()...), "localhost", "command", "ls")...), "a"+ext, "c"+ext)
220+
221+ sftpCmd("rename a" + ext + " b" + ext + "\n")
222+ e.assertKept("b"+ext, a.Text, a)
223+ sftpCmd("rename b" + ext + " c" + ext + "\n")
224+ e.assertKept("c"+ext, a.Text, a)
225+ assertListed(t, sftpCmd("ls -1\n"), "c"+ext)
226+ })
227+ }
228+}
229+
230+func TestPostsSshfs(t *testing.T) {
231+ if _, err := exec.LookPath("sshfs"); err != nil {
232+ t.Skip("sshfs not installed")
233+ }
234+ tests := map[string][]struct {
235+ cmd string
236+ name string
237+ text string
238+ // public is set when the new name drops the draft setting, which
239+ // .lxt files don't read from front matter.
240+ public bool
241+ }{
242+ "prose": {
243+ {cmd: "echo more >> a.md", name: "a.md", text: seedText("prose") + "more\n"},
244+ {cmd: "mv a.md b.md", name: "b.md", text: seedText("prose") + "more\n"},
245+ {cmd: "mv b.md b.lxt", name: "b.lxt", text: seedText("prose") + "more\n", public: true},
246+ {cmd: "mv b.lxt c.md", name: "c.md", text: seedText("prose") + "more\n"},
247+ },
248+ "pastes": {
249+ {cmd: "echo more >> a.txt", name: "a.txt", text: "seeded\nmore\n"},
250+ {cmd: "vim -u DEFAULTS -i NONE -N -es -c '%s/more/less/' -c wq a.txt", name: "a.txt", text: "seeded\nless\n"},
251+ {cmd: "mv a.txt b.txt", name: "b.txt", text: "seeded\nless\n"},
252+ {cmd: "mv b.txt c.txt", name: "c.txt", text: "seeded\nless\n"},
253+ },
254+ }
255+ for app, steps := range tests {
256+ t.Run(app, func(t *testing.T) {
257+ e := newPostsEnv(t, app)
258+ ext := filepath.Ext(steps[0].name)
259+ a := e.seed("a" + ext)
260+ e.seed("c" + ext)
261+
262+ mnt := t.TempDir()
263+ if out, err := exec.Command("sshfs", append(append([]string{"-p", e.port}, e.sshOpts()...), "localhost:/", mnt)...).CombinedOutput(); err != nil {
264+ t.Skipf("sshfs mount failed: %v\n%s", err, out)
265+ }
266+ t.Cleanup(func() { _ = exec.Command("fusermount3", "-u", "-z", mnt).Run() })
267+ assertListed(t, e.run("ls", "-1a", mnt), "a"+ext, "c"+ext)
268+
269+ home := t.TempDir()
270+ for _, step := range steps {
271+ cmd := exec.Command("bash", "-c", step.cmd)
272+ cmd.Dir = mnt
273+ cmd.Env = append(os.Environ(), "HOME="+home)
274+ if out, err := cmd.CombinedOutput(); err != nil {
275+ t.Fatalf("%s: %v\n%s", step.cmd, err, out)
276+ }
277+ e.assertMoved(step.name, step.text, a, !step.public)
278+ }
279+ assertListed(t, e.run("ls", "-1a", mnt), "c"+ext)
280+ })
281+ }
282+}
+18, -3
......@@ -190,9 +190,10 @@ func (f *handler) PosixRename(r *sftp.Request) error {
190190 return f.rename(r.Filepath, r.Target, true)
191191 }
192192
193-// rename moves a file or a directory. Storage has no move, so each file
194-// is written to its new path through the handler, which applies its usual
195-// checks, and the sources are deleted once every write succeeded.
193+// rename moves a file or a directory. A handler that implements
194+// utils.FileRenamer moves a file in place. Otherwise each file is written
195+// to its new path through the handler, which applies its usual checks, and
196+// the sources are deleted once every write succeeded.
196197 func (f *handler) rename(from, to string, replace bool) error {
197198 if from == to {
198199 return nil
......@@ -238,6 +239,20 @@ func (f *handler) rename(from, to string, replace bool) error {
238239 }
239240 }
240241
242+ if isFile {
243+ if r, ok := f.writeHandler.(utils.FileRenamer); ok {
244+ err := r.Rename(f.session, &utils.FileEntry{Filepath: from}, &utils.FileEntry{Filepath: to})
245+ if !errors.Is(err, errors.ErrUnsupported) {
246+ if err == nil {
247+ f.mu.Lock()
248+ delete(f.hidden, to)
249+ f.mu.Unlock()
250+ }
251+ return err
252+ }
253+ }
254+ }
255+
241256 if !isFile {
242257 for _, d := range append([]string{"."}, dirs...) {
243258 if _, err := f.writeHandler.Write(f.session, &utils.FileEntry{Filepath: filepath.Join(to, d), Mode: os.ModeDir}); err != nil {
+8, -0
......@@ -76,6 +76,14 @@ type WriteValidator interface {
7676 ValidateWrite(*pssh.SSHServerConnSession, *FileEntry) error
7777 }
7878
79+// FileRenamer is implemented by handlers that can move a stored file in
80+// place, replacing any file at the destination. It returns
81+// errors.ErrUnsupported for a move it can't make, and SFTP copies the file
82+// instead.
83+type FileRenamer interface {
84+ Rename(s *pssh.SSHServerConnSession, from, to *FileEntry) error
85+}
86+
7987 func KeyText(session *pssh.SSHServerConnSession) (string, error) {
8088 if session.PublicKey() == nil {
8189 return "", fmt.Errorf("session doesn't have public key")