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}