Antonio Mika
·
2026-10-11
1package pastes
2
3import (
4 "fmt"
5 "log/slog"
6 "os"
7 "path/filepath"
8 "strconv"
9 "strings"
10 "time"
11
12 "github.com/araddon/dateparse"
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
20var DEFAULT_EXPIRES_AT = 90
21
22// UploadHandler stores every file as a paste.
23type UploadHandler struct {
24 Cfg *Config
25 DB DB
26}
27
28var (
29 _ sendutils.CopyFromClientHandler = (*UploadHandler)(nil)
30 _ sendutils.WriteValidator = (*UploadHandler)(nil)
31 _ sendutils.FileRenamer = (*UploadHandler)(nil)
32)
33
34func NewUploadHandler(cfg *Config, dbpool DB) *UploadHandler {
35 return &UploadHandler{Cfg: cfg, DB: dbpool}
36}
37
38func (h *UploadHandler) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger {
39 logger := pssh.GetLogger(s)
40 if logger == nil {
41 if h.Cfg != nil && h.Cfg.Logger != nil {
42 return h.Cfg.Logger
43 }
44 return slog.Default()
45 }
46 return logger
47}
48
49func (h *UploadHandler) Validate(s *pssh.SSHServerConnSession) error {
50 user, err := postfs.SessionUser(s)
51 if err != nil {
52 return err
53 }
54 if _, err := db.FindAccess(h.DB, user.ID, "pastes"); err != nil {
55 return err
56 }
57 h.GetLogger(s).Info("attempting to upload files", "user", user.Name, "space", h.Cfg.Space)
58 return nil
59}
60
61func (h *UploadHandler) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
62 return postfs.ListPosts(s, fpath, isDir, recursive, []string{h.Cfg.Space}, h.DB)
63}
64
65func (h *UploadHandler) Read(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (os.FileInfo, sendutils.ReadAndReaderAtCloser, error) {
66 return postfs.ReadPost(s, h.DB, h.Cfg.Space, entry)
67}
68
69func (h *UploadHandler) Delete(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
70 return postfs.DeletePost(s, h.DB, h.Cfg.Space, entry)
71}
72
73// ValidateWrite checks a paste's name before its contents arrive.
74func (h *UploadHandler) ValidateWrite(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
75 if _, err := postfs.SessionUser(s); err != nil {
76 return err
77 }
78 if entry.Mode.IsDir() {
79 return fmt.Errorf("file entry is directory, but only files are supported: %s", entry.Filepath)
80 }
81 return h.validate(postfs.NewPost(filepath.Base(entry.Filepath), nil))
82}
83
84func (h *UploadHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (string, error) {
85 user, err := postfs.SessionUser(s)
86 if err != nil {
87 return "", err
88 }
89 logger := h.GetLogger(s).With("filename", filepath.Base(entry.Filepath))
90
91 post, err := postfs.PostFromEntry(entry, user, h.Cfg.Space)
92 if err != nil {
93 return "", err
94 }
95 if err := h.validate(post); err != nil {
96 logger.Error("file failed validation", "err", err.Error())
97 return "", err
98 }
99
100 cur, err := postfs.LookupPost(h.DB, user.ID, h.Cfg.Space, post.Filename)
101 if err != nil {
102 return "", err
103 }
104 if cur != nil {
105 post.Data, post.PublishAt = cur.Data, cur.PublishAt
106 }
107 setMeta(s, cur, post)
108
109 _, changed, err := postfs.SavePost(h.DB, cur, post)
110 if err != nil {
111 logger.Error("paste could not be saved", "err", err.Error())
112 return "", err
113 }
114 logger.Info("saved paste", "new", cur == nil, "changed", changed)
115 return shared.NewCreateURL(h.Cfg.Protocol, h.Cfg.Domain).FullPostURL(user.Name, post.Slug), nil
116}
117
118// Rename moves a paste to a new filename in place, keeping its settings.
119func (h *UploadHandler) Rename(s *pssh.SSHServerConnSession, from, to *sendutils.FileEntry) error {
120 return postfs.RenamePost(s, h.DB, h.Cfg.Space, from, to, func(cur, next *db.Post) ([]string, error) {
121 if err := h.validate(next); err != nil {
122 return nil, err
123 }
124 setMeta(s, cur, next)
125 return nil, nil
126 })
127}
128
129func (h *UploadHandler) validate(post *db.Post) error {
130 if !shared.IsTextFile(post.Text) {
131 return fmt.Errorf(
132 "ERROR: (%s) invalid file must be plain text (utf-8), skipping",
133 post.Filename,
134 )
135 }
136
137 maxFileSize := int(h.Cfg.MaxAssetSize)
138 if post.FileSize > maxFileSize {
139 return fmt.Errorf(
140 "ERROR: file (%s) has exceeded maximum file size (%d bytes)",
141 post.Filename,
142 maxFileSize,
143 )
144 }
145
146 return nil
147}
148
149// setMeta applies the paste's settings: those of cur, the paste it
150// replaces, unless the upload command sets them.
151func setMeta(s *pssh.SSHServerConnSession, cur, post *db.Post) {
152 post.Title = shared.ToUpper(post.Slug)
153 // we want the slug to be the filename for pastes
154 post.Slug = post.Filename
155
156 // mark posts for deletion a X days after creation
157 expiresAt := time.Now().AddDate(0, 0, DEFAULT_EXPIRES_AT)
158 post.ExpiresAt = &expiresAt
159 var hidden bool
160 // A paste written without arguments, as over sftp, keeps its settings.
161 if cur != nil {
162 hidden = cur.Hidden
163 if cur.ExpiresAt == nil || cur.ExpiresAt.After(time.Now()) {
164 post.ExpiresAt = cur.ExpiresAt
165 }
166 }
167
168 var expiresFound bool
169 var expires *time.Time
170
171 cmd := s.Command()
172
173 for _, arg := range cmd {
174 if strings.Contains(arg, "=") {
175 splitArg := strings.Split(arg, "=")
176 if len(splitArg) != 2 {
177 continue
178 }
179
180 switch splitArg[0] {
181 case "hidden":
182 val, err := strconv.ParseBool(splitArg[1])
183 if err != nil {
184 continue
185 }
186
187 hidden = val
188 case "expires":
189 val, err := strconv.ParseBool(splitArg[1])
190 if err == nil {
191 expiresFound = true
192 expires = nil
193 if val {
194 expires = &expiresAt
195 }
196 continue
197 }
198
199 duration, err := time.ParseDuration(splitArg[1])
200 if err == nil {
201 expiresFound = true
202 expireTime := time.Now().Add(duration)
203 expires = &expireTime
204 continue
205 }
206
207 expireTime, err := dateparse.ParseStrict(splitArg[1])
208 if err == nil {
209 expiresFound = true
210 expires = &expireTime
211 }
212 }
213 }
214 }
215
216 post.Hidden = hidden
217
218 if expiresFound {
219 post.ExpiresAt = expires
220 }
221}