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 | |
| 46 | 46 | // we want the slug to be the filename for pastes | |
| 47 | 47 | data.Slug = data.Filename | |
| 48 | 48 | ||
| 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 | + | } | |
| 53 | 59 | } | |
| 54 | 60 | ||
| 55 | - | var hidden bool | |
| 56 | 61 | var expiresFound bool | |
| 57 | 62 | var expires *time.Time | |
| 58 | 63 |
| ... | ... | @@ -76,7 +81,11 @@ func (p *FileHooks) FileMeta(s *pssh.SSHServerConnSession, data *filehandlers.Po | |
| 76 | 81 | case "expires": | |
| 77 | 82 | val, err := strconv.ParseBool(splitArg[1]) | |
| 78 | 83 | if err == nil { | |
| 79 | - | expiresFound = !val | |
| 84 | + | expiresFound = true | |
| 85 | + | expires = nil | |
| 86 | + | if val { | |
| 87 | + | expires = &expiresAt | |
| 88 | + | } | |
| 80 | 89 | continue | |
| 81 | 90 | } | |
| 82 | 91 |
+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 ( | |
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/picosh/pico/pkg/db/postgres" | |
| 11 | - | "github.com/picosh/pico/pkg/filehandlers" | |
| 12 | 11 | "github.com/picosh/pico/pkg/pssh" | |
| 13 | 12 | "github.com/picosh/pico/pkg/send/auth" | |
| 14 | 13 | "github.com/picosh/pico/pkg/send/list" |
| ... | ... | @@ -35,15 +34,7 @@ func StartSshServer() { | |
| 35 | 34 | defer func() { | |
| 36 | 35 | _ = dbh.Close() | |
| 37 | 36 | }() | |
| 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) | |
| 47 | 38 | sshAuth := shared.NewSshAuthHandler(dbh, logger, "pastes") | |
| 48 | 39 | ||
| 49 | 40 | // Create a new SSH server |
+8,
-0
| ... | ... | @@ -48,6 +48,14 @@ func NewUploadHandler(cfg *shared.ConfigSite, dbpool db.DB, fileMap map[string]f | |
| 48 | 48 | } | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 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 | + | ||
| 51 | 59 | func (h *UploadHandler) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger { | |
| 52 | 60 | logger := pssh.GetLogger(s) | |
| 53 | 61 | if logger == nil { |
+1,
-15
| ... | ... | @@ -8,8 +8,6 @@ import ( | |
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/picosh/pico/pkg/db/postgres" | |
| 11 | - | "github.com/picosh/pico/pkg/filehandlers" | |
| 12 | - | uploadimgs "github.com/picosh/pico/pkg/filehandlers/imgs" | |
| 13 | 11 | "github.com/picosh/pico/pkg/pssh" | |
| 14 | 12 | "github.com/picosh/pico/pkg/send/auth" | |
| 15 | 13 | "github.com/picosh/pico/pkg/send/list" |
| ... | ... | @@ -38,11 +36,6 @@ func StartSshServer() { | |
| 38 | 36 | _ = dbh.Close() | |
| 39 | 37 | }() | |
| 40 | 38 | ||
| 41 | - | hooks := &MarkdownHooks{ | |
| 42 | - | Cfg: cfg, | |
| 43 | - | Db: dbh, | |
| 44 | - | } | |
| 45 | - | ||
| 46 | 39 | adapter := storage.GetStorageTypeFromEnv() | |
| 47 | 40 | st, err := storage.NewStorage(cfg.Logger, adapter) | |
| 48 | 41 | if err != nil { |
| ... | ... | @@ -50,14 +43,7 @@ func StartSshServer() { | |
| 50 | 43 | return | |
| 51 | 44 | } | |
| 52 | 45 | ||
| 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) | |
| 61 | 47 | ||
| 62 | 48 | sshAuth := shared.NewSshAuthHandler(dbh, logger, "prose") | |
| 63 | 49 |
+17,
-0
| ... | ... | @@ -7,9 +7,11 @@ import ( | |
| 7 | 7 | ||
| 8 | 8 | "github.com/picosh/pico/pkg/db" | |
| 9 | 9 | "github.com/picosh/pico/pkg/filehandlers" | |
| 10 | + | uploadimgs "github.com/picosh/pico/pkg/filehandlers/imgs" | |
| 10 | 11 | "github.com/picosh/pico/pkg/pssh" | |
| 11 | 12 | sendutils "github.com/picosh/pico/pkg/send/utils" | |
| 12 | 13 | "github.com/picosh/pico/pkg/shared" | |
| 14 | + | "github.com/picosh/pico/pkg/storage" | |
| 13 | 15 | ) | |
| 14 | 16 | ||
| 15 | 17 | type ctxFeatureFlagKey struct{} |
| ... | ... | @@ -79,6 +81,21 @@ func NewUploadHandler(cfg *shared.ConfigSite, dbpool db.DB, fileMap map[string]f | |
| 79 | 81 | } | |
| 80 | 82 | } | |
| 81 | 83 | ||
| 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 | + | ||
| 82 | 99 | func (h *UploadHandler) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger { | |
| 83 | 100 | logger := pssh.GetLogger(s) | |
| 84 | 101 | if logger == nil { |
+7,
-0
| ... | ... | @@ -14,6 +14,7 @@ var ErrNameTaken = errors.New("username has already been claimed") | |
| 14 | 14 | var ErrNameDenied = errors.New("username is on the denylist") | |
| 15 | 15 | var ErrNameInvalid = errors.New("username has invalid characters in it") | |
| 16 | 16 | var ErrPublicKeyTaken = errors.New("public key is already associated with another user") | |
| 17 | + | var ErrPostChanged = errors.New("post changed while it was being updated") | |
| 17 | 18 | ||
| 18 | 19 | // sqlite uses string to BLOB type and postgres uses []uint8 for JSONB. | |
| 19 | 20 | func tcast(value any) ([]byte, error) { |
| ... | ... | @@ -604,6 +605,12 @@ type DB interface { | |
| 604 | 605 | FindPostWithSlug(slug string, userID string, space string) (*Post, error) | |
| 605 | 606 | InsertPost(post *Post) (*Post, error) | |
| 606 | 607 | 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) | |
| 607 | 614 | RemovePosts(postIDs []string) error | |
| 608 | 615 | ||
| 609 | 616 | FindPopularPosts() ([]*Post, error) |
+89,
-0
| ... | ... | @@ -693,6 +693,95 @@ func (me *PsqlDB) UpdatePost(post *db.Post) (*db.Post, error) { | |
| 693 | 693 | return me.FindPost(post.ID) | |
| 694 | 694 | } | |
| 695 | 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 | + | ||
| 696 | 785 | func (me *PsqlDB) RemovePosts(postIDs []string) error { | |
| 697 | 786 | param := "{" + strings.Join(postIDs, ",") + "}" | |
| 698 | 787 | _, err := me.Db.Exec(`DELETE FROM posts WHERE id = ANY($1::uuid[])`, param) |
+121,
-0
| ... | ... | @@ -2,12 +2,14 @@ package postgres | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "log/slog" | |
| 7 | 8 | "os" | |
| 8 | 9 | "os/exec" | |
| 9 | 10 | "path/filepath" | |
| 10 | 11 | "runtime" | |
| 12 | + | "slices" | |
| 11 | 13 | "sort" | |
| 12 | 14 | "testing" | |
| 13 | 15 | "time" |
| ... | ... | @@ -578,6 +580,125 @@ func TestUpdatePost(t *testing.T) { | |
| 578 | 580 | } | |
| 579 | 581 | } | |
| 580 | 582 | ||
| 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 | + | ||
| 581 | 702 | func TestRemovePosts(t *testing.T) { | |
| 582 | 703 | cleanupTestData(t) | |
| 583 | 704 |
+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) { | |
| 88 | 88 | return nil, errNotImpl | |
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | + | func (me *StubDB) RenamePost(cur, next, replaced *db.Post, tags, aliases []string) (*db.Post, error) { | |
| 92 | + | return nil, errNotImpl | |
| 93 | + | } | |
| 94 | + | ||
| 91 | 95 | func (me *StubDB) RemovePosts(postIDs []string) error { | |
| 92 | 96 | return errNotImpl | |
| 93 | 97 | } |
+91,
-22
| ... | ... | @@ -1,7 +1,9 @@ | |
| 1 | 1 | package filehandlers | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "database/sql" | |
| 4 | 5 | "encoding/binary" | |
| 6 | + | "errors" | |
| 5 | 7 | "fmt" | |
| 6 | 8 | "io" | |
| 7 | 9 | "net/http" |
| ... | ... | @@ -129,28 +131,7 @@ func (h *ScpUploadHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils. | |
| 129 | 131 | return "", fmt.Errorf("could not read file (%s): %w", filename, err) | |
| 130 | 132 | } | |
| 131 | 133 | ||
| 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) | |
| 154 | 135 | metadata := PostMetaData{ | |
| 155 | 136 | Post: &nextPost, | |
| 156 | 137 | User: user, |
| ... | ... | @@ -291,6 +272,94 @@ func (h *ScpUploadHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils. | |
| 291 | 272 | return h.Cfg.FullPostURL(curl, user.Name, metadata.Slug), nil | |
| 292 | 273 | } | |
| 293 | 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 | + | ||
| 294 | 363 | func (h *ScpUploadHandler) Delete(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error { | |
| 295 | 364 | logger := pssh.GetLogger(s) | |
| 296 | 365 | 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 | |
| 84 | 84 | return handler.Delete(s, entry) | |
| 85 | 85 | } | |
| 86 | 86 | ||
| 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 | + | ||
| 87 | 104 | func (r *FileHandlerRouter) Read(s *pssh.SSHServerConnSession, entry *utils.FileEntry) (os.FileInfo, utils.ReadAndReaderAtCloser, error) { | |
| 88 | 105 | handler, err := r.findHandler(entry.Filepath) | |
| 89 | 106 | if err != nil { |
| ... | ... | @@ -94,18 +111,28 @@ func (r *FileHandlerRouter) Read(s *pssh.SSHServerConnSession, entry *utils.File | |
| 94 | 111 | ||
| 95 | 112 | func (r *FileHandlerRouter) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) { | |
| 96 | 113 | 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] { | |
| 100 | 120 | continue | |
| 101 | 121 | } | |
| 122 | + | listed[handler] = true | |
| 102 | 123 | ||
| 103 | 124 | ff, err := handler.List(s, fpath, isDir, recursive) | |
| 104 | 125 | if err != nil { | |
| 105 | 126 | r.GetLogger(s).Error("handler list", "err", err) | |
| 106 | 127 | continue | |
| 107 | 128 | } | |
| 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 | + | } | |
| 109 | 136 | } | |
| 110 | 137 | return files, nil | |
| 111 | 138 | } |
+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 { | |
| 190 | 190 | return f.rename(r.Filepath, r.Target, true) | |
| 191 | 191 | } | |
| 192 | 192 | ||
| 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. | |
| 196 | 197 | func (f *handler) rename(from, to string, replace bool) error { | |
| 197 | 198 | if from == to { | |
| 198 | 199 | return nil |
| ... | ... | @@ -238,6 +239,20 @@ func (f *handler) rename(from, to string, replace bool) error { | |
| 238 | 239 | } | |
| 239 | 240 | } | |
| 240 | 241 | ||
| 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 | + | ||
| 241 | 256 | if !isFile { | |
| 242 | 257 | for _, d := range append([]string{"."}, dirs...) { | |
| 243 | 258 | 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 { | |
| 76 | 76 | ValidateWrite(*pssh.SSHServerConnSession, *FileEntry) error | |
| 77 | 77 | } | |
| 78 | 78 | ||
| 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 | + | ||
| 79 | 87 | func KeyText(session *pssh.SSHServerConnSession) (string, error) { | |
| 80 | 88 | if session.PublicKey() == nil { | |
| 81 | 89 | return "", fmt.Errorf("session doesn't have public key") |