main pico / pkg / apps / prose / post_uploader.go
Antonio Mika  ·  2026-10-11
  1package prose
  2
  3import (
  4	"fmt"
  5	"os"
  6	"path/filepath"
  7	"slices"
  8	"strings"
  9
 10	"github.com/picosh/pico/pkg/db"
 11	"github.com/picosh/pico/pkg/postfs"
 12	"github.com/picosh/pico/pkg/pssh"
 13	sendutils "github.com/picosh/pico/pkg/send/utils"
 14	"github.com/picosh/pico/pkg/shared"
 15)
 16
 17// postUploader stores prose's text files as posts.
 18type postUploader struct {
 19	Cfg *Config
 20	DB  DB
 21}
 22
 23var (
 24	_ ReadWriteHandler         = (*postUploader)(nil)
 25	_ sendutils.WriteValidator = (*postUploader)(nil)
 26	_ sendutils.FileRenamer    = (*postUploader)(nil)
 27)
 28
 29func (h *postUploader) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
 30	return postfs.ListPosts(s, fpath, isDir, recursive, []string{h.Cfg.Space}, h.DB)
 31}
 32
 33func (h *postUploader) Read(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (os.FileInfo, sendutils.ReadAndReaderAtCloser, error) {
 34	return postfs.ReadPost(s, h.DB, h.Cfg.Space, entry)
 35}
 36
 37func (h *postUploader) Delete(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
 38	return postfs.DeletePost(s, h.DB, h.Cfg.Space, entry)
 39}
 40
 41// ValidateWrite checks a post's name before its contents arrive.
 42func (h *postUploader) ValidateWrite(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
 43	if _, err := postfs.SessionUser(s); err != nil {
 44		return err
 45	}
 46	if entry.Mode.IsDir() {
 47		return fmt.Errorf("file entry is directory, but only files are supported: %s", entry.Filepath)
 48	}
 49	return h.validate(postfs.NewPost(filepath.Base(entry.Filepath), nil))
 50}
 51
 52func (h *postUploader) Write(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (string, error) {
 53	user, err := postfs.SessionUser(s)
 54	if err != nil {
 55		return "", err
 56	}
 57	logger := pssh.GetLogger(s).With("filename", filepath.Base(entry.Filepath))
 58
 59	post, err := postfs.PostFromEntry(entry, user, h.Cfg.Space)
 60	if err != nil {
 61		return "", err
 62	}
 63	if err := h.validate(post); err != nil {
 64		logger.Error("file failed validation", "err", err.Error())
 65		return "", err
 66	}
 67
 68	cur, err := postfs.LookupPost(h.DB, user.ID, h.Cfg.Space, post.Filename)
 69	if err != nil {
 70		return "", err
 71	}
 72	if cur != nil {
 73		post.Data, post.PublishAt = cur.Data, cur.PublishAt
 74	}
 75	aliases, err := h.setMeta(post)
 76	if err != nil {
 77		logger.Error("file could not load meta", "err", err.Error())
 78		return "", err
 79	}
 80
 81	saved, changed, err := postfs.SavePost(h.DB, cur, post)
 82	if err != nil {
 83		logger.Error("post could not be saved", "err", err.Error())
 84		return "", err
 85	}
 86	logger.Info("saved post", "new", cur == nil, "changed", changed)
 87	// A new post has no tags or aliases to clear.
 88	if changed && (cur != nil || len(post.Tags) > 0) {
 89		if err := h.DB.ReplaceTagsByPost(post.Tags, saved.ID); err != nil {
 90			logger.Error("post could not replace tags", "err", err.Error())
 91			return "", fmt.Errorf("error for %s: %v", post.Filename, err)
 92		}
 93	}
 94	if changed && (cur != nil || len(aliases) > 0) {
 95		if err := h.DB.ReplaceAliasesByPost(aliases, saved.ID); err != nil {
 96			logger.Error("post could not replace aliases", "err", err.Error())
 97			return "", fmt.Errorf("error for %s: %v", post.Filename, err)
 98		}
 99	}
100
101	return shared.NewCreateURL(h.Cfg.Protocol, h.Cfg.Domain).FullPostURL(user.Name, post.Slug), nil
102}
103
104// Rename moves a post to a new filename in place, so it keeps its ID,
105// publish date and settings. A post at the new filename is replaced.
106func (h *postUploader) Rename(s *pssh.SSHServerConnSession, from, to *sendutils.FileEntry) error {
107	return postfs.RenamePost(s, h.DB, h.Cfg.Space, from, to, func(cur, next *db.Post) ([]string, error) {
108		if err := h.validate(next); err != nil {
109			return nil, err
110		}
111		return h.setMeta(next)
112	})
113}
114
115func (h *postUploader) validate(post *db.Post) error {
116	if !shared.IsTextFile(post.Text) {
117		return fmt.Errorf(
118			"ERROR: (%s) invalid file must be plain text (utf-8), skipping",
119			post.Filename,
120		)
121	}
122
123	fp := strings.Replace(post.Filename, "/", "", 1)
124	// special styles css file we want to permit but no other css file.
125	// sometimes the directory is provided in the filename, so we want to
126	// remove that before we perform this check.
127	if fp == "_styles.css" {
128		return nil
129	}
130	// allow users to upload robots file
131	if fp == "robots.txt" {
132		return nil
133	}
134
135	if !shared.IsExtAllowed(post.Filename, h.Cfg.AllowedExt) {
136		extStr := strings.Join(h.Cfg.AllowedExt, ",")
137		return fmt.Errorf(
138			"ERROR: (%s) invalid file, format must be (%s), skipping",
139			post.Filename,
140			extStr,
141		)
142	}
143
144	if post.FileSize > MAX_FILE_SIZE {
145		return fmt.Errorf(
146			"ERROR: file (%s) has exceeded maximum file size (%d bytes)",
147			post.Filename,
148			MAX_FILE_SIZE,
149		)
150	}
151
152	return nil
153}
154
155// setMeta fills in a post's title, description, tags, publish date and
156// hidden flag from its text, and returns its aliases.
157func (h *postUploader) setMeta(post *db.Post) ([]string, error) {
158	var aliases []string
159	switch filepath.Ext(post.Filename) {
160	case ".lxt":
161		parsedText := shared.ListParseText(post.Text)
162		if parsedText.Title == "" {
163			post.Title = shared.ToUpper(post.Slug)
164		} else {
165			post.Title = parsedText.Title
166		}
167		aliases = parsedText.Aliases
168		post.Tags = parsedText.Tags
169		post.Description = parsedText.Description
170		if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
171			post.PublishAt = parsedText.PublishAt
172		}
173		post.Hidden = parsedText.Hidden
174	case ".md":
175		parsedText, err := shared.ParseText(post.Text)
176		if err != nil {
177			return nil, fmt.Errorf("%s: %w", post.Filename, err)
178		}
179		if parsedText.Title == "" {
180			post.Title = shared.ToUpper(post.Slug)
181		} else {
182			post.Title = parsedText.Title
183		}
184		aliases = parsedText.Aliases
185		post.Tags = parsedText.Tags
186		post.Description = parsedText.Description
187		if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
188			post.PublishAt = parsedText.PublishAt
189		}
190		post.Hidden = parsedText.Hidden
191	}
192
193	post.Hidden = post.Hidden || slices.Contains(h.Cfg.HiddenPosts, post.Filename)
194	return aliases, nil
195}