Antonio Mika
·
2026-10-11
1// Package postfs serves an app's posts as files over scp, sftp and sshfs.
2package postfs
3
4import (
5 "database/sql"
6 "encoding/binary"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "os"
12 "path/filepath"
13 "strings"
14 "time"
15
16 "github.com/picosh/pico/pkg/db"
17 "github.com/picosh/pico/pkg/pssh"
18 sendutils "github.com/picosh/pico/pkg/send/utils"
19 "github.com/picosh/pico/pkg/shared"
20)
21
22// SessionUser is the user an upload session belongs to.
23func SessionUser(s *pssh.SSHServerConnSession) (*db.User, error) {
24 user := pssh.GetUser(s)
25 if user == nil {
26 err := fmt.Errorf("could not get user from ctx")
27 pssh.GetLogger(s).Error("error getting user from ctx", "err", err)
28 return nil, err
29 }
30 return user, nil
31}
32
33// NewPost is the post a file makes before an app fills in the rest.
34func NewPost(filename string, text []byte) *db.Post {
35 mimeType := http.DetectContentType(text)
36 // DetectContentType does not detect markdown
37 if filepath.Ext(filename) == ".md" {
38 mimeType = "text/markdown; charset=UTF-8"
39 }
40 now := time.Now()
41 return &db.Post{
42 Filename: filename,
43 Slug: shared.SanitizeFileExt(filename),
44 PublishAt: &now,
45 UpdatedAt: &now,
46 Text: string(text),
47 MimeType: mimeType,
48 FileSize: binary.Size(text),
49 Shasum: shared.Shasum(text),
50 }
51}
52
53// PostFromEntry reads an uploaded file into a new post for user in space.
54func PostFromEntry(entry *sendutils.FileEntry, user *db.User, space string) (*db.Post, error) {
55 if entry.Mode.IsDir() {
56 return nil, os.ErrInvalid
57 }
58 filename := filepath.Base(entry.Filepath)
59 text, err := io.ReadAll(entry.Reader)
60 if err != nil {
61 return nil, fmt.Errorf("could not read file (%s): %w", filename, err)
62 }
63 post := NewPost(filename, text)
64 post.UserID = user.ID
65 post.Space = space
66 if entry.Mtime > 0 {
67 modTime := time.Unix(entry.Mtime, 0)
68 post.UpdatedAt = &modTime
69 }
70 return post, nil
71}
72
73// LookupPost finds a user's post by filename, or nil when there is none.
74func LookupPost(dbpool db.PostDB, userID, space, filename string) (*db.Post, error) {
75 post, err := dbpool.FindPostWithFilename(filename, userID, space)
76 if errors.Is(err, sql.ErrNoRows) {
77 return nil, nil
78 }
79 return post, err
80}
81
82// SavePost inserts next, or updates cur with it. It reports false without
83// writing when cur already has next's text and mod time.
84func SavePost(dbpool db.PostDB, cur, next *db.Post) (*db.Post, bool, error) {
85 if cur == nil {
86 post, err := dbpool.InsertPost(&db.Post{
87 UserID: next.UserID,
88 Space: next.Space,
89 Data: next.Data,
90 Description: next.Description,
91 Filename: next.Filename,
92 FileSize: next.FileSize,
93 Hidden: next.Hidden,
94 MimeType: next.MimeType,
95 PublishAt: next.PublishAt,
96 Shasum: next.Shasum,
97 Slug: next.Slug,
98 Text: next.Text,
99 Title: next.Title,
100 ExpiresAt: next.ExpiresAt,
101 UpdatedAt: next.UpdatedAt,
102 })
103 if err != nil {
104 return nil, false, fmt.Errorf("error for %s: %v", next.Filename, err)
105 }
106 return post, true, nil
107 }
108
109 if next.Text == cur.Text && next.UpdatedAt.Equal(*cur.UpdatedAt) {
110 return cur, false, nil
111 }
112 post, err := dbpool.UpdatePost(&db.Post{
113 ID: cur.ID,
114 Data: next.Data,
115 FileSize: next.FileSize,
116 Description: next.Description,
117 PublishAt: next.PublishAt,
118 Slug: next.Slug,
119 Shasum: next.Shasum,
120 Text: next.Text,
121 Title: next.Title,
122 Hidden: next.Hidden,
123 ExpiresAt: next.ExpiresAt,
124 UpdatedAt: next.UpdatedAt,
125 })
126 if err != nil {
127 return nil, false, fmt.Errorf("error for %s: %v", next.Filename, err)
128 }
129 return post, true, nil
130}
131
132// ReadPost returns a user's post as a file.
133func ReadPost(s *pssh.SSHServerConnSession, dbpool db.PostDB, space string, entry *sendutils.FileEntry) (os.FileInfo, sendutils.ReadAndReaderAtCloser, error) {
134 user, err := SessionUser(s)
135 if err != nil {
136 return nil, nil, err
137 }
138
139 cleanFilename := filepath.Base(entry.Filepath)
140 if cleanFilename == "" || cleanFilename == "." {
141 return nil, nil, os.ErrNotExist
142 }
143
144 post, err := dbpool.FindPostWithFilename(cleanFilename, user.ID, space)
145 if err != nil {
146 return nil, nil, err
147 }
148
149 fileInfo := &sendutils.VirtualFile{
150 FName: post.Filename,
151 FIsDir: false,
152 FSize: int64(post.FileSize),
153 FModTime: *post.UpdatedAt,
154 }
155 reader := sendutils.NopReadAndReaderAtCloser(strings.NewReader(post.Text))
156 return fileInfo, reader, nil
157}
158
159// DeletePost removes a user's post.
160func DeletePost(s *pssh.SSHServerConnSession, dbpool db.PostDB, space string, entry *sendutils.FileEntry) error {
161 user, err := SessionUser(s)
162 if err != nil {
163 return err
164 }
165
166 filename := filepath.Base(entry.Filepath)
167 logger := pssh.GetLogger(s).With("filename", filename)
168
169 post, err := dbpool.FindPostWithFilename(filename, user.ID, space)
170 if err != nil {
171 return err
172 }
173 if post == nil {
174 return os.ErrNotExist
175 }
176
177 logger.Info("removing record")
178 if err := dbpool.RemovePosts([]string{post.ID}); err != nil {
179 logger.Error("post could not remove", "err", err.Error())
180 return fmt.Errorf("error for %s: %v", filename, err)
181 }
182 return nil
183}
184
185// RenameFunc makes the post that cur becomes under the filename next
186// already has, with next.Tags and the returned aliases.
187type RenameFunc func(cur, next *db.Post) (aliases []string, err error)
188
189// RenamePost moves a user's post to a new filename in place, so it keeps its
190// ID, publish date and settings. A post at the new filename is replaced.
191func RenamePost(s *pssh.SSHServerConnSession, dbpool db.PostDB, space string, from, to *sendutils.FileEntry, rename RenameFunc) error {
192 user, err := SessionUser(s)
193 if err != nil {
194 return err
195 }
196 fromName, toName := filepath.Base(from.Filepath), filepath.Base(to.Filepath)
197 // A save to either post while it is being renamed starts it over.
198 for range 3 {
199 if err = renamePost(dbpool, user, space, fromName, toName, rename); !errors.Is(err, db.ErrPostChanged) {
200 break
201 }
202 }
203 if err != nil {
204 return err
205 }
206 pssh.GetLogger(s).Info("renamed post", "from", fromName, "to", toName)
207 return nil
208}
209
210func renamePost(dbpool db.PostDB, user *db.User, space, fromName, toName string, rename RenameFunc) error {
211 post, err := dbpool.FindPostWithFilename(fromName, user.ID, space)
212 if errors.Is(err, sql.ErrNoRows) {
213 return os.ErrNotExist
214 }
215 if err != nil {
216 return err
217 }
218 if fromName == toName {
219 return nil
220 }
221 replaced, err := LookupPost(dbpool, user.ID, space, toName)
222 if err != nil {
223 return err
224 }
225
226 next := NewPost(toName, []byte(post.Text))
227 next.UserID, next.Space = post.UserID, post.Space
228 next.Data, next.PublishAt = post.Data, post.PublishAt
229 aliases, err := rename(post, next)
230 if err != nil {
231 return err
232 }
233 if other, err := dbpool.FindPostWithSlug(next.Slug, user.ID, space); err == nil &&
234 other.ID != post.ID && (replaced == nil || other.ID != replaced.ID) {
235 return fmt.Errorf("%s: %s already uses its slug (%s): %w", toName, other.Filename, next.Slug, os.ErrExist)
236 }
237
238 next.UpdatedAt = post.UpdatedAt
239 // The new name can parse the text differently, as .md and .lxt do.
240 _, err = dbpool.RenamePost(post, next, replaced, next.Tags, aliases)
241 if err != nil && !errors.Is(err, db.ErrPostChanged) {
242 return fmt.Errorf("error for %s: %w", toName, err)
243 }
244 return err
245}
246
247// ListPosts lists a user's posts in spaces, or the one at fpath.
248func ListPosts(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool, spaces []string, dbpool db.PostDB) ([]os.FileInfo, error) {
249 var fileList []os.FileInfo
250 logger := pssh.GetLogger(s)
251 user := pssh.GetUser(s)
252
253 var err error
254
255 if user == nil {
256 err = fmt.Errorf("could not get user from ctx")
257 logger.Error("error getting user from ctx", "err", err)
258 return fileList, err
259 }
260
261 cleanFilename := filepath.Base(fpath)
262
263 var post *db.Post
264 var posts []*db.Post
265
266 if cleanFilename == "" || cleanFilename == "." || cleanFilename == "/" {
267 name := cleanFilename
268 if name == "" {
269 name = "/"
270 }
271
272 fileList = append(fileList, &sendutils.VirtualFile{
273 FName: name,
274 FIsDir: true,
275 })
276
277 for _, space := range spaces {
278 curPosts, e := dbpool.FindAllPostsByUser(user.ID, space)
279 if e != nil {
280 err = e
281 break
282 }
283 posts = append(posts, curPosts...)
284 }
285 } else {
286 for _, space := range spaces {
287 p, e := dbpool.FindPostWithFilename(cleanFilename, user.ID, space)
288 if e != nil {
289 err = e
290 continue
291 }
292 post = p
293 }
294
295 posts = append(posts, post)
296 }
297
298 if err != nil && !errors.Is(err, sql.ErrNoRows) {
299 return nil, err
300 }
301
302 for _, post := range posts {
303 if post == nil {
304 continue
305 }
306
307 fileList = append(fileList, &sendutils.VirtualFile{
308 FName: post.Filename,
309 FIsDir: false,
310 FSize: int64(post.FileSize),
311 FModTime: *post.UpdatedAt,
312 })
313 }
314
315 return fileList, nil
316}