main pico / pkg / postfs / postfs.go
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}