main pico / pkg / apps / feeds / uploader.go
Antonio Mika  ·  2026-10-11
  1package feeds
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"log/slog"
  7	"net/url"
  8	"os"
  9	"path/filepath"
 10	"strings"
 11
 12	"github.com/adhocore/gronx"
 13	"github.com/picosh/pico/pkg/db"
 14	"github.com/picosh/pico/pkg/postfs"
 15	"github.com/picosh/pico/pkg/pssh"
 16	sendutils "github.com/picosh/pico/pkg/send/utils"
 17	"github.com/picosh/pico/pkg/shared"
 18)
 19
 20// UploadHandler stores every file as a feed.
 21type UploadHandler struct {
 22	Cfg *Config
 23	DB  DB
 24}
 25
 26var (
 27	_ sendutils.CopyFromClientHandler = (*UploadHandler)(nil)
 28	_ sendutils.WriteValidator        = (*UploadHandler)(nil)
 29	_ sendutils.FileRenamer           = (*UploadHandler)(nil)
 30)
 31
 32func NewUploadHandler(cfg *Config, dbpool DB) *UploadHandler {
 33	return &UploadHandler{Cfg: cfg, DB: dbpool}
 34}
 35
 36func (h *UploadHandler) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger {
 37	return pssh.GetLogger(s)
 38}
 39
 40func (h *UploadHandler) Validate(s *pssh.SSHServerConnSession) error {
 41	user, err := postfs.SessionUser(s)
 42	if err != nil {
 43		return err
 44	}
 45	h.GetLogger(s).Info("attempting to upload files", "user", user.Name, "space", h.Cfg.Space)
 46	return nil
 47}
 48
 49func (h *UploadHandler) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
 50	return postfs.ListPosts(s, fpath, isDir, recursive, []string{h.Cfg.Space}, h.DB)
 51}
 52
 53func (h *UploadHandler) Read(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (os.FileInfo, sendutils.ReadAndReaderAtCloser, error) {
 54	return postfs.ReadPost(s, h.DB, h.Cfg.Space, entry)
 55}
 56
 57func (h *UploadHandler) Delete(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
 58	return postfs.DeletePost(s, h.DB, h.Cfg.Space, entry)
 59}
 60
 61// ValidateWrite checks a feed's name before its contents arrive.
 62func (h *UploadHandler) ValidateWrite(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
 63	if _, err := postfs.SessionUser(s); err != nil {
 64		return err
 65	}
 66	if entry.Mode.IsDir() {
 67		return fmt.Errorf("file entry is directory, but only files are supported: %s", entry.Filepath)
 68	}
 69	return h.validate(postfs.NewPost(filepath.Base(entry.Filepath), nil))
 70}
 71
 72func (h *UploadHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (string, error) {
 73	user, err := postfs.SessionUser(s)
 74	if err != nil {
 75		return "", err
 76	}
 77	logger := h.GetLogger(s).With("filename", filepath.Base(entry.Filepath))
 78
 79	post, err := postfs.PostFromEntry(entry, user, h.Cfg.Space)
 80	if err != nil {
 81		return "", err
 82	}
 83	if err := h.validate(post); err != nil {
 84		logger.Error("file failed validation", "err", err.Error())
 85		return "", err
 86	}
 87
 88	cur, err := postfs.LookupPost(h.DB, user.ID, h.Cfg.Space, post.Filename)
 89	if err != nil {
 90		return "", err
 91	}
 92	// The feed's digest state lives in its data.
 93	if cur != nil {
 94		post.Data, post.PublishAt = cur.Data, cur.PublishAt
 95	}
 96
 97	_, changed, err := postfs.SavePost(h.DB, cur, post)
 98	if err != nil {
 99		logger.Error("feed could not be saved", "err", err.Error())
100		return "", err
101	}
102	logger.Info("saved feed", "new", cur == nil, "changed", changed)
103	return shared.NewCreateURL(h.Cfg.Protocol, h.Cfg.Domain).FullPostURL(user.Name, post.Slug), nil
104}
105
106// Rename moves a feed to a new filename in place, keeping its digest state.
107func (h *UploadHandler) Rename(s *pssh.SSHServerConnSession, from, to *sendutils.FileEntry) error {
108	return postfs.RenamePost(s, h.DB, h.Cfg.Space, from, to, func(cur, next *db.Post) ([]string, error) {
109		return nil, h.validate(next)
110	})
111}
112
113func (h *UploadHandler) validate(post *db.Post) error {
114	if !shared.IsTextFile(post.Text) {
115		return fmt.Errorf(
116			"WARNING: (%s) invalid file must be plain text (utf-8), skipping",
117			post.Filename,
118		)
119	}
120
121	if !shared.IsExtAllowed(post.Filename, h.Cfg.AllowedExt) {
122		extStr := strings.Join(h.Cfg.AllowedExt, ",")
123		return fmt.Errorf(
124			"WARNING: (%s) invalid file, format must be (%s), skipping",
125			post.Filename,
126			extStr,
127		)
128	}
129
130	// SFTP validates a file when it is opened, before its contents
131	// arrive, so there is nothing to parse yet.
132	if post.Text == "" {
133		return nil
134	}
135
136	parsed := shared.ListParseText(post.Text)
137	if parsed.Email == "" {
138		return fmt.Errorf("ERROR: no email variable detected for %s, check the format of your file, skipping", post.Filename)
139	}
140
141	if parsed.DigestInterval != "" {
142		return fmt.Errorf("ERROR: `digest_interval` is deprecated; use `cron`: https://pico.sh/feeds#cron")
143	}
144
145	if parsed.Cron != "" {
146		if !gronx.IsValid(parsed.Cron) {
147			return fmt.Errorf("ERROR: `cron` is invalid, reference: https://github.com/adhocore/gronx?tab=readme-ov-file#cron-expression")
148		}
149	}
150
151	var allErr error
152	for _, txt := range parsed.Items {
153		u := ""
154		if txt.IsText {
155			u = txt.Value
156		} else if txt.IsURL {
157			u = string(txt.URL)
158		}
159
160		_, err := url.Parse(u)
161		if err != nil {
162			allErr = errors.Join(allErr, fmt.Errorf("%s: %w", u, err))
163			continue
164		}
165	}
166	if allErr != nil {
167		return fmt.Errorf("ERROR: some urls provided were invalid check the format of your file, skipping: %w", allErr)
168	}
169
170	return nil
171}