main pico / pkg / apps / prose / imgs.go
Antonio Mika  ·  2026-10-10
  1package prose
  2
  3import (
  4	"bytes"
  5	"encoding/binary"
  6	"fmt"
  7	"io"
  8	"net/http"
  9	"os"
 10	"path/filepath"
 11	"slices"
 12	"strings"
 13
 14	exifremove "github.com/neurosnap/go-exif-remove"
 15	"github.com/picosh/pico/pkg/db"
 16	"github.com/picosh/pico/pkg/pssh"
 17	sendutils "github.com/picosh/pico/pkg/send/utils"
 18	"github.com/picosh/pico/pkg/shared"
 19	"github.com/picosh/pico/pkg/storage"
 20)
 21
 22type PostMetaData struct {
 23	Text          []byte
 24	FileSize      int
 25	TotalFileSize int
 26	Filename      string
 27	User          *db.User
 28	FeatureFlag   *db.FeatureFlag
 29	Bucket        storage.Bucket
 30}
 31
 32type UploadImgHandler struct {
 33	DBPool  DB
 34	Cfg     *Config
 35	Storage storage.StorageServe
 36}
 37
 38func NewUploadImgHandler(dbpool DB, cfg *Config, storage storage.StorageServe) *UploadImgHandler {
 39	return &UploadImgHandler{
 40		DBPool:  dbpool,
 41		Cfg:     cfg,
 42		Storage: storage,
 43	}
 44}
 45
 46func (h *UploadImgHandler) getObjectPath(fpath string) string {
 47	return filepath.Join("prose", fpath)
 48}
 49
 50func (h *UploadImgHandler) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
 51	var fileList []os.FileInfo
 52
 53	logger := pssh.GetLogger(s)
 54	user := pssh.GetUser(s)
 55
 56	if user == nil {
 57		err := fmt.Errorf("could not get user from ctx")
 58		logger.Error("error getting user from ctx", "err", err)
 59		return fileList, err
 60	}
 61
 62	cleanFilename := fpath
 63
 64	bucketName := shared.GetAssetBucketName(user.ID)
 65	bucket, err := h.Storage.GetBucket(bucketName)
 66	if err != nil {
 67		return fileList, err
 68	}
 69
 70	if cleanFilename == "" || cleanFilename == "." {
 71		name := cleanFilename
 72		if name == "" {
 73			name = "/"
 74		}
 75
 76		info := &sendutils.VirtualFile{
 77			FName:  name,
 78			FIsDir: true,
 79		}
 80
 81		fileList = append(fileList, info)
 82	} else {
 83		fp := h.getObjectPath(cleanFilename)
 84		if fp != "/" && isDir {
 85			fp += "/"
 86		}
 87
 88		foundList, err := h.Storage.ListObjects(bucket, fp, recursive)
 89		if err != nil {
 90			return fileList, err
 91		}
 92
 93		fileList = append(fileList, foundList...)
 94	}
 95
 96	return fileList, nil
 97}
 98
 99func (h *UploadImgHandler) Read(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (os.FileInfo, sendutils.ReadAndReaderAtCloser, error) {
100	logger := pssh.GetLogger(s)
101	user := pssh.GetUser(s)
102
103	if user == nil {
104		err := fmt.Errorf("could not get user from ctx")
105		logger.Error("error getting user from ctx", "err", err)
106		return nil, nil, err
107	}
108
109	cleanFilename := filepath.Base(entry.Filepath)
110
111	if cleanFilename == "" || cleanFilename == "." {
112		return nil, nil, os.ErrNotExist
113	}
114
115	bucket, err := h.Storage.GetBucket(shared.GetAssetBucketName(user.ID))
116	if err != nil {
117		return nil, nil, err
118	}
119
120	contents, info, err := h.Storage.GetObject(bucket, h.getObjectPath(cleanFilename))
121	if err != nil {
122		return nil, nil, err
123	}
124
125	fileInfo := &sendutils.VirtualFile{
126		FName:    cleanFilename,
127		FIsDir:   false,
128		FSize:    info.Size,
129		FModTime: info.LastModified,
130	}
131
132	return fileInfo, contents, nil
133}
134
135func (h *UploadImgHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (string, error) {
136	logger := pssh.GetLogger(s)
137	user := pssh.GetUser(s)
138
139	if user == nil {
140		err := fmt.Errorf("could not get user from ctx")
141		logger.Error("error getting user from ctx", "err", err)
142		return "", err
143	}
144
145	filename := filepath.Base(entry.Filepath)
146
147	var text []byte
148	if b, err := io.ReadAll(entry.Reader); err == nil {
149		text = b
150	}
151	mimeType := http.DetectContentType(text)
152	ext := filepath.Ext(filename)
153	if ext == ".svg" {
154		mimeType = "image/svg+xml"
155	}
156	// strip exif data
157	if slices.Contains([]string{"image/png", "image/jpg", "image/jpeg"}, mimeType) {
158		noExifBytes, err := exifremove.Remove(text)
159		if err == nil {
160			if len(noExifBytes) == 0 {
161				logger.Info("file silently failed to strip exif data", "filename", filename)
162			} else {
163				text = noExifBytes
164				logger.Info("stripped exif data", "filename", filename)
165			}
166		} else {
167			logger.Error("could not strip exif data", "err", err.Error())
168		}
169	}
170
171	fileSize := binary.Size(text)
172	featureFlag, _ := h.DBPool.FindFeature(user.ID, "plus")
173	// we have free tiers so users might not have a feature flag
174	// in which case we set sane defaults
175	if featureFlag == nil {
176		featureFlag = db.NewFeatureFlag(user.ID, "plus", h.Cfg.MaxSize, h.Cfg.MaxAssetSize, h.Cfg.MaxSpecialFileSize)
177	}
178	featureFlag.SetLimits(h.Cfg.MaxSize, h.Cfg.MaxAssetSize, h.Cfg.MaxSpecialFileSize)
179
180	bucket, err := h.Storage.UpsertBucket(shared.GetAssetBucketName(user.ID))
181	if err != nil {
182		return "", err
183	}
184
185	totalFileSize, err := h.Storage.GetBucketQuota(bucket)
186	if err != nil {
187		logger.Error("bucket quota", "err", err)
188		return "", err
189	}
190
191	metadata := PostMetaData{
192		Filename:      filename,
193		FileSize:      fileSize,
194		Text:          text,
195		User:          user,
196		FeatureFlag:   featureFlag,
197		Bucket:        bucket,
198		TotalFileSize: int(totalFileSize),
199	}
200
201	err = h.writeImg(s, &metadata)
202	if err != nil {
203		logger.Error("could not write img", "err", err.Error())
204		return "", err
205	}
206
207	curl := shared.NewCreateURL(h.Cfg.Protocol, h.Cfg.Domain)
208	url := curl.FullPostURL(user.Name, metadata.Filename)
209	return url + shared.StorageUsage(metadata.TotalFileSize+fileSize, int(featureFlag.Data.StorageMax)), nil
210}
211
212func (h *UploadImgHandler) Delete(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
213	logger := pssh.GetLogger(s)
214	user := pssh.GetUser(s)
215
216	if user == nil {
217		err := fmt.Errorf("could not get user from ctx")
218		logger.Error("error getting user from ctx", "err", err)
219		return err
220	}
221
222	filename := filepath.Base(entry.Filepath)
223
224	logger = logger.With(
225		"filename", filename,
226	)
227
228	bucket, err := h.Storage.UpsertBucket(shared.GetAssetBucketName(user.ID))
229	if err != nil {
230		return err
231	}
232
233	logger.Info("deleting image")
234	err = h.Storage.DeleteObject(bucket, h.getObjectPath(filename))
235	if err != nil {
236		return err
237	}
238
239	return nil
240}
241
242func (h *UploadImgHandler) validateImg(data *PostMetaData) (bool, error) {
243	fileMax := data.FeatureFlag.Data.FileMax
244	if int64(data.FileSize) > fileMax {
245		return false, fmt.Errorf("ERROR: file (%s) has exceeded maximum file size (%d bytes)", data.Filename, fileMax)
246	}
247
248	storageMax := data.FeatureFlag.Data.StorageMax
249	if uint64(data.TotalFileSize+data.FileSize) > storageMax {
250		return false, fmt.Errorf("ERROR: user (%s) has exceeded (%d bytes) max (%d bytes)", data.User.Name, data.TotalFileSize, storageMax)
251	}
252
253	if !shared.IsExtAllowed(data.Filename, h.Cfg.AllowedExt) {
254		extStr := strings.Join(h.Cfg.AllowedExt, ",")
255		err := fmt.Errorf(
256			"ERROR: (%s) invalid file, format must be (%s), skipping",
257			data.Filename,
258			extStr,
259		)
260		return false, err
261	}
262
263	return true, nil
264}
265
266func (h *UploadImgHandler) metaImg(data *PostMetaData) error {
267	// if the file is empty that means we should delete it
268	// so we can skip all the meta info
269	if data.FileSize == 0 {
270		return nil
271	}
272
273	// make sure we have a bucket
274	bucket, err := h.Storage.UpsertBucket(shared.GetAssetBucketName(data.User.ID))
275	if err != nil {
276		return err
277	}
278
279	reader := bytes.NewReader([]byte(data.Text))
280	_, _, err = h.Storage.PutObject(
281		bucket,
282		h.getObjectPath(data.Filename),
283		sendutils.NopReadAndReaderAtCloser(reader),
284		&storage.ObjectInfo{},
285	)
286	if err != nil {
287		return err
288	}
289
290	return nil
291}
292
293func (h *UploadImgHandler) writeImg(s *pssh.SSHServerConnSession, data *PostMetaData) error {
294	valid, err := h.validateImg(data)
295	if !valid {
296		return err
297	}
298
299	logger := pssh.GetLogger(s)
300	logger = logger.With(
301		"filename", data.Filename,
302	)
303
304	logger.Info("uploading image")
305	err = h.metaImg(data)
306	if err != nil {
307		logger.Error("meta img", "err", err)
308		return err
309	}
310
311	return nil
312}