Antonio Mika
·
2026-10-11
1package prose
2
3import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "slices"
8 "strings"
9
10 "github.com/picosh/pico/pkg/db"
11 "github.com/picosh/pico/pkg/postfs"
12 "github.com/picosh/pico/pkg/pssh"
13 sendutils "github.com/picosh/pico/pkg/send/utils"
14 "github.com/picosh/pico/pkg/shared"
15)
16
17// postUploader stores prose's text files as posts.
18type postUploader struct {
19 Cfg *Config
20 DB DB
21}
22
23var (
24 _ ReadWriteHandler = (*postUploader)(nil)
25 _ sendutils.WriteValidator = (*postUploader)(nil)
26 _ sendutils.FileRenamer = (*postUploader)(nil)
27)
28
29func (h *postUploader) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
30 return postfs.ListPosts(s, fpath, isDir, recursive, []string{h.Cfg.Space}, h.DB)
31}
32
33func (h *postUploader) Read(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (os.FileInfo, sendutils.ReadAndReaderAtCloser, error) {
34 return postfs.ReadPost(s, h.DB, h.Cfg.Space, entry)
35}
36
37func (h *postUploader) Delete(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
38 return postfs.DeletePost(s, h.DB, h.Cfg.Space, entry)
39}
40
41// ValidateWrite checks a post's name before its contents arrive.
42func (h *postUploader) ValidateWrite(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
43 if _, err := postfs.SessionUser(s); err != nil {
44 return err
45 }
46 if entry.Mode.IsDir() {
47 return fmt.Errorf("file entry is directory, but only files are supported: %s", entry.Filepath)
48 }
49 return h.validate(postfs.NewPost(filepath.Base(entry.Filepath), nil))
50}
51
52func (h *postUploader) Write(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (string, error) {
53 user, err := postfs.SessionUser(s)
54 if err != nil {
55 return "", err
56 }
57 logger := pssh.GetLogger(s).With("filename", filepath.Base(entry.Filepath))
58
59 post, err := postfs.PostFromEntry(entry, user, h.Cfg.Space)
60 if err != nil {
61 return "", err
62 }
63 if err := h.validate(post); err != nil {
64 logger.Error("file failed validation", "err", err.Error())
65 return "", err
66 }
67
68 cur, err := postfs.LookupPost(h.DB, user.ID, h.Cfg.Space, post.Filename)
69 if err != nil {
70 return "", err
71 }
72 if cur != nil {
73 post.Data, post.PublishAt = cur.Data, cur.PublishAt
74 }
75 aliases, err := h.setMeta(post)
76 if err != nil {
77 logger.Error("file could not load meta", "err", err.Error())
78 return "", err
79 }
80
81 saved, changed, err := postfs.SavePost(h.DB, cur, post)
82 if err != nil {
83 logger.Error("post could not be saved", "err", err.Error())
84 return "", err
85 }
86 logger.Info("saved post", "new", cur == nil, "changed", changed)
87 // A new post has no tags or aliases to clear.
88 if changed && (cur != nil || len(post.Tags) > 0) {
89 if err := h.DB.ReplaceTagsByPost(post.Tags, saved.ID); err != nil {
90 logger.Error("post could not replace tags", "err", err.Error())
91 return "", fmt.Errorf("error for %s: %v", post.Filename, err)
92 }
93 }
94 if changed && (cur != nil || len(aliases) > 0) {
95 if err := h.DB.ReplaceAliasesByPost(aliases, saved.ID); err != nil {
96 logger.Error("post could not replace aliases", "err", err.Error())
97 return "", fmt.Errorf("error for %s: %v", post.Filename, err)
98 }
99 }
100
101 return shared.NewCreateURL(h.Cfg.Protocol, h.Cfg.Domain).FullPostURL(user.Name, post.Slug), nil
102}
103
104// Rename moves a post to a new filename in place, so it keeps its ID,
105// publish date and settings. A post at the new filename is replaced.
106func (h *postUploader) Rename(s *pssh.SSHServerConnSession, from, to *sendutils.FileEntry) error {
107 return postfs.RenamePost(s, h.DB, h.Cfg.Space, from, to, func(cur, next *db.Post) ([]string, error) {
108 if err := h.validate(next); err != nil {
109 return nil, err
110 }
111 return h.setMeta(next)
112 })
113}
114
115func (h *postUploader) validate(post *db.Post) error {
116 if !shared.IsTextFile(post.Text) {
117 return fmt.Errorf(
118 "ERROR: (%s) invalid file must be plain text (utf-8), skipping",
119 post.Filename,
120 )
121 }
122
123 fp := strings.Replace(post.Filename, "/", "", 1)
124 // special styles css file we want to permit but no other css file.
125 // sometimes the directory is provided in the filename, so we want to
126 // remove that before we perform this check.
127 if fp == "_styles.css" {
128 return nil
129 }
130 // allow users to upload robots file
131 if fp == "robots.txt" {
132 return nil
133 }
134
135 if !shared.IsExtAllowed(post.Filename, h.Cfg.AllowedExt) {
136 extStr := strings.Join(h.Cfg.AllowedExt, ",")
137 return fmt.Errorf(
138 "ERROR: (%s) invalid file, format must be (%s), skipping",
139 post.Filename,
140 extStr,
141 )
142 }
143
144 if post.FileSize > MAX_FILE_SIZE {
145 return fmt.Errorf(
146 "ERROR: file (%s) has exceeded maximum file size (%d bytes)",
147 post.Filename,
148 MAX_FILE_SIZE,
149 )
150 }
151
152 return nil
153}
154
155// setMeta fills in a post's title, description, tags, publish date and
156// hidden flag from its text, and returns its aliases.
157func (h *postUploader) setMeta(post *db.Post) ([]string, error) {
158 var aliases []string
159 switch filepath.Ext(post.Filename) {
160 case ".lxt":
161 parsedText := shared.ListParseText(post.Text)
162 if parsedText.Title == "" {
163 post.Title = shared.ToUpper(post.Slug)
164 } else {
165 post.Title = parsedText.Title
166 }
167 aliases = parsedText.Aliases
168 post.Tags = parsedText.Tags
169 post.Description = parsedText.Description
170 if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
171 post.PublishAt = parsedText.PublishAt
172 }
173 post.Hidden = parsedText.Hidden
174 case ".md":
175 parsedText, err := shared.ParseText(post.Text)
176 if err != nil {
177 return nil, fmt.Errorf("%s: %w", post.Filename, err)
178 }
179 if parsedText.Title == "" {
180 post.Title = shared.ToUpper(post.Slug)
181 } else {
182 post.Title = parsedText.Title
183 }
184 aliases = parsedText.Aliases
185 post.Tags = parsedText.Tags
186 post.Description = parsedText.Description
187 if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
188 post.PublishAt = parsedText.PublishAt
189 }
190 post.Hidden = parsedText.Hidden
191 }
192
193 post.Hidden = post.Hidden || slices.Contains(h.Cfg.HiddenPosts, post.Filename)
194 return aliases, nil
195}