main pico / pkg / apps / pgs / uploader.go
Antonio Mika  ·  2026-10-10
  1package pgs
  2
  3import (
  4	"context"
  5	"fmt"
  6	"io"
  7	"log/slog"
  8	"os"
  9	"path/filepath"
 10	"strings"
 11	"sync"
 12	"time"
 13
 14	"github.com/picosh/pico/pkg/db"
 15	"github.com/picosh/pico/pkg/pssh"
 16	sendutils "github.com/picosh/pico/pkg/send/utils"
 17	"github.com/picosh/pico/pkg/shared"
 18	"github.com/picosh/pico/pkg/storage"
 19	ignore "github.com/sabhiram/go-gitignore"
 20)
 21
 22type ctxUploadStateKey struct{}
 23
 24// uploadState is what a session's uploads share. It is set on the session
 25// once because every SetValue nests the session context one level deeper,
 26// and SFTP can run several writes at a time.
 27type uploadState struct {
 28	mu          sync.Mutex
 29	bucket      storage.Bucket
 30	featureFlag *db.FeatureFlag
 31	storageSize int64
 32	project     *db.Project
 33	denylist    *ignore.GitIgnore
 34}
 35
 36func getUploadState(s *pssh.SSHServerConnSession) (*uploadState, error) {
 37	state, ok := s.Context().Value(ctxUploadStateKey{}).(*uploadState)
 38	if !ok {
 39		return nil, fmt.Errorf("upload state not set on `ssh.Context()` for connection")
 40	}
 41	return state, nil
 42}
 43
 44func (u *uploadState) getStorageSize() int64 {
 45	u.mu.Lock()
 46	defer u.mu.Unlock()
 47	return u.storageSize
 48}
 49
 50// addStorageSize adds delta bytes to the bucket's size and returns the new
 51// size.
 52func (u *uploadState) addStorageSize(delta int64) int64 {
 53	u.mu.Lock()
 54	defer u.mu.Unlock()
 55	u.storageSize = max(u.storageSize+delta, 0)
 56	return u.storageSize
 57}
 58
 59type FileData struct {
 60	*sendutils.FileEntry
 61	User     *db.User
 62	Bucket   storage.Bucket
 63	Project  *db.Project
 64	DenyList *ignore.GitIgnore
 65}
 66
 67type UploadAssetHandler struct {
 68	Cfg                *Config
 69	CacheClearingQueue chan string
 70}
 71
 72func NewUploadAssetHandler(cfg *Config, ch chan string, ctx context.Context) *UploadAssetHandler {
 73	go runCacheQueue(cfg, ctx)
 74	return &UploadAssetHandler{
 75		Cfg:                cfg,
 76		CacheClearingQueue: ch,
 77	}
 78}
 79
 80func (h *UploadAssetHandler) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger {
 81	return pssh.GetLogger(s)
 82}
 83
 84func (h *UploadAssetHandler) Read(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (os.FileInfo, sendutils.ReadAndReaderAtCloser, error) {
 85	logger := pssh.GetLogger(s)
 86	user := pssh.GetUser(s)
 87
 88	if user == nil {
 89		err := fmt.Errorf("could not get user from ctx")
 90		logger.Error("error getting user from ctx", "err", err)
 91		return nil, nil, err
 92	}
 93
 94	fileInfo := &sendutils.VirtualFile{
 95		FName:    filepath.Base(entry.Filepath),
 96		FIsDir:   false,
 97		FSize:    entry.Size,
 98		FModTime: time.Unix(entry.Mtime, 0),
 99	}
100
101	bucket, err := h.Cfg.Storage.GetBucket(shared.GetAssetBucketName(user.ID))
102	if err != nil {
103		return nil, nil, err
104	}
105
106	fname := shared.GetAssetFileName(entry)
107	contents, info, err := h.Cfg.Storage.GetObject(bucket, fname)
108	if err != nil {
109		return nil, nil, err
110	}
111
112	fileInfo.FSize = info.Size
113	fileInfo.FModTime = info.LastModified
114
115	return fileInfo, contents, nil
116}
117
118func (h *UploadAssetHandler) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
119	var fileList []os.FileInfo
120
121	logger := pssh.GetLogger(s)
122	user := pssh.GetUser(s)
123
124	if user == nil {
125		err := fmt.Errorf("could not get user from ctx")
126		logger.Error("error getting user from ctx", "err", err)
127		return fileList, err
128	}
129
130	cleanFilename := fpath
131
132	bucketName := shared.GetAssetBucketName(user.ID)
133	bucket, err := h.Cfg.Storage.GetBucket(bucketName)
134	if err != nil {
135		return fileList, err
136	}
137
138	if cleanFilename == "" || cleanFilename == "." {
139		name := cleanFilename
140		if name == "" {
141			name = "/"
142		}
143
144		info := &sendutils.VirtualFile{
145			FName:  name,
146			FIsDir: true,
147		}
148
149		fileList = append(fileList, info)
150	} else {
151		if cleanFilename != "/" && isDir {
152			cleanFilename += "/"
153		}
154
155		foundList, err := h.Cfg.Storage.ListObjects(bucket, cleanFilename, recursive)
156		if err != nil {
157			return fileList, err
158		}
159
160		fileList = append(fileList, foundList...)
161	}
162
163	return fileList, nil
164}
165
166// IsImplicitDir reports top-level project names. A project holds its files
167// whether or not it exists yet.
168func (h *UploadAssetHandler) IsImplicitDir(_ *pssh.SSHServerConnSession, fpath string) bool {
169	return shared.IsValidSubdomain(strings.Trim(fpath, "/"))
170}
171
172func (h *UploadAssetHandler) Validate(s *pssh.SSHServerConnSession) error {
173	logger := pssh.GetLogger(s)
174	user := pssh.GetUser(s)
175
176	if user == nil {
177		err := fmt.Errorf("could not get user from ctx")
178		logger.Error("error getting user from ctx", "err", err)
179		return err
180	}
181
182	ff, err := findFeatureFlag(h.Cfg.DB, h.Cfg, user.ID)
183	if err != nil {
184		return err
185	}
186
187	assetBucket := shared.GetAssetBucketName(user.ID)
188	bucket, err := h.Cfg.Storage.UpsertBucket(assetBucket)
189	if err != nil {
190		return err
191	}
192
193	totalStorageSize, err := h.Cfg.Storage.GetBucketQuota(bucket)
194	if err != nil {
195		return err
196	}
197	s.SetValue(ctxUploadStateKey{}, &uploadState{
198		bucket:      bucket,
199		featureFlag: ff,
200		storageSize: int64(totalStorageSize),
201	})
202
203	logger.Info(
204		"bucket size",
205		"user", user.Name,
206		"bytes", totalStorageSize,
207	)
208
209	logger.Info(
210		"attempting to upload files",
211		"user", user.Name,
212		"txtPrefix", h.Cfg.TxtPrefix,
213	)
214
215	return nil
216}
217
218// sessionProject returns the project being uploaded to and its compiled
219// _pgs_ignore, finding or creating the project when it changes. A session
220// can upload to many projects because SFTP connections are kept alive.
221func (h *UploadAssetHandler) sessionProject(state *uploadState, user *db.User, projectName string, logger *slog.Logger) (*db.Project, *ignore.GitIgnore, error) {
222	state.mu.Lock()
223	defer state.mu.Unlock()
224	if state.project != nil && state.project.Name == projectName {
225		return state.project, state.denylist, nil
226	}
227
228	project, err := h.Cfg.DB.UpsertProject(user.ID, projectName, projectName)
229	if err != nil {
230		logger.Error("upsert project", "err", err.Error())
231		return nil, nil, err
232	}
233
234	dlist, err := h.findDenylist(state.bucket, project, logger)
235	if err != nil {
236		logger.Info("failed to get denylist, setting default (.*)", "err", err.Error())
237		dlist = ".*"
238	}
239
240	state.project = project
241	state.denylist = ignore.CompileIgnoreLines(strings.Split(dlist, "\n")...)
242	return state.project, state.denylist, nil
243}
244
245func (h *UploadAssetHandler) findDenylist(bucket storage.Bucket, project *db.Project, logger *slog.Logger) (string, error) {
246	fp, _, err := h.Cfg.Storage.GetObject(bucket, filepath.Join(project.ProjectDir, "_pgs_ignore"))
247	if err != nil {
248		return "", fmt.Errorf("_pgs_ignore not found")
249	}
250	defer func() {
251		_ = fp.Close()
252	}()
253
254	buf := new(strings.Builder)
255	_, err = io.Copy(buf, fp)
256	if err != nil {
257		logger.Error("io copy", "err", err.Error())
258		return "", err
259	}
260
261	str := buf.String()
262	return str, nil
263}
264
265func mtimeToTime(entry *sendutils.FileEntry) time.Time {
266	var mtime time.Time
267	if entry.Mtime > 0 {
268		return time.Unix(entry.Mtime, 0)
269	}
270	return mtime
271}
272
273func (h *UploadAssetHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (string, error) {
274	return h.write(s, entry, false)
275}
276
277// ValidateWrite runs the checks Write makes before reading the file.
278func (h *UploadAssetHandler) ValidateWrite(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
279	if entry.Mode.IsDir() {
280		return nil
281	}
282	_, err := h.write(s, entry, true)
283	return err
284}
285
286func (h *UploadAssetHandler) write(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry, validateOnly bool) (string, error) {
287	logger := pssh.GetLogger(s)
288	user := pssh.GetUser(s)
289
290	if user == nil {
291		err := fmt.Errorf("could not get user from ctx")
292		logger.Error("error getting user from ctx", "err", err)
293		return "", err
294	}
295
296	if entry.Mode.IsDir() && strings.Count(entry.Filepath, "/") == 1 {
297		entry.Filepath = strings.TrimPrefix(entry.Filepath, "/")
298	}
299
300	logger = logger.With(
301		"file", entry.Filepath,
302		"size", entry.Size,
303	)
304
305	state, err := getUploadState(s)
306	if err != nil {
307		logger.Error("could not find upload state in ctx", "err", err.Error())
308		return "", err
309	}
310	bucket := state.bucket
311
312	projectName := shared.GetProjectName(entry)
313	logger = logger.With("project", projectName)
314	if projectName == "" || projectName == "/" || projectName == "." {
315		return "", fmt.Errorf("ERROR: invalid project name, you must copy files to a non-root folder (e.g. pgs.sh:/project-name)")
316	}
317
318	project, denylist, err := h.sessionProject(state, user, projectName, logger)
319	if err != nil {
320		return "", err
321	}
322
323	if project.Blocked != "" {
324		msg := "project has been blocked and cannot upload files: %s"
325		return "", fmt.Errorf(msg, project.Blocked)
326	}
327
328	if entry.Mode.IsDir() {
329		return "", h.Cfg.Storage.PutDir(bucket, shared.GetAssetFileName(entry))
330	}
331
332	// calculate the filsize difference between the same file already
333	// stored and the updated file being uploaded
334	assetFilename := shared.GetAssetFileName(entry)
335	var curFileSize int64
336	if info, err := h.Cfg.Storage.StatObject(bucket, assetFilename); err == nil {
337		curFileSize = info.Size
338	}
339
340	data := &FileData{
341		FileEntry: entry,
342		User:      user,
343		Bucket:    bucket,
344		DenyList:  denylist,
345		Project:   project,
346	}
347
348	valid, err := h.validateAsset(data)
349	if !valid {
350		return "", err
351	}
352
353	featureFlag := state.featureFlag
354	// SFTP does not report file size so the more performant way to
355	//   check filesize constraints is to try and upload the file to s3
356	//	 with a specialized reader that raises an error if the filesize limit
357	//	 has been reached
358	storageMax := featureFlag.Data.StorageMax
359	fileMax := featureFlag.Data.FileMax
360	curStorageSize := state.getStorageSize()
361	remaining := int64(storageMax) - curStorageSize
362	sizeRemaining := min(remaining+curFileSize, fileMax)
363	if sizeRemaining <= 0 {
364		return "", fmt.Errorf("storage quota reached")
365	}
366	if validateOnly {
367		return "", nil
368	}
369	logger = logger.With(
370		"storageMax", storageMax,
371		"currentStorageMax", curStorageSize,
372		"fileMax", fileMax,
373		"sizeRemaining", sizeRemaining,
374	)
375
376	specialFileMax := featureFlag.Data.SpecialFileMax
377	if isSpecialFile(entry.Filepath) {
378		sizeRemaining = min(sizeRemaining, specialFileMax)
379	}
380
381	fsize, err := h.writeAsset(
382		s,
383		shared.NewMaxBytesReader(data.Reader, int64(sizeRemaining)),
384		data,
385	)
386	if err != nil {
387		logger.Error("could not write asset", "err", err.Error())
388		cerr := fmt.Errorf(
389			"%s: storage size %.2fmb, storage max %.2fmb, file max %.2fmb, special file max %.4fmb",
390			err,
391			shared.BytesToMB(int(curStorageSize)),
392			shared.BytesToMB(int(storageMax)),
393			shared.BytesToMB(int(fileMax)),
394			shared.BytesToMB(int(specialFileMax)),
395		)
396		return "", cerr
397	}
398
399	nextStorageSize := state.addStorageSize(fsize - curFileSize)
400
401	url := h.Cfg.AssetURL(
402		user.Name,
403		projectName,
404		strings.Replace(data.Filepath, "/"+projectName+"/", "", 1),
405	)
406
407	str := url + shared.StorageUsage(int(nextStorageSize), int(featureFlag.Data.StorageMax))
408
409	surrogate := getSurrogateKey(user.Name, projectName)
410	h.Cfg.CacheClearingQueue <- surrogate
411
412	return str, err
413}
414
415func isSpecialFile(entry string) bool {
416	fname := filepath.Base(entry)
417	return fname == "_headers" || fname == "_redirects" || fname == "_pgs_ignore"
418}
419
420func (h *UploadAssetHandler) Delete(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
421	logger := pssh.GetLogger(s)
422	user := pssh.GetUser(s)
423
424	if user == nil {
425		err := fmt.Errorf("could not get user from ctx")
426		logger.Error("error getting user from ctx", "err", err)
427		return err
428	}
429
430	if entry.Mode.IsDir() && strings.Count(entry.Filepath, "/") == 1 {
431		entry.Filepath = strings.TrimPrefix(entry.Filepath, "/")
432	}
433
434	assetFilepath := shared.GetAssetFileName(entry)
435
436	logger = logger.With(
437		"file", assetFilepath,
438	)
439
440	state, err := getUploadState(s)
441	if err != nil {
442		logger.Error("could not find upload state in ctx", "err", err.Error())
443		return err
444	}
445	bucket := state.bucket
446
447	projectName := shared.GetProjectName(entry)
448	logger = logger.With("project", projectName)
449
450	logger.Info("deleting file")
451
452	info, statErr := h.Cfg.Storage.StatObject(bucket, assetFilepath)
453	err = h.Cfg.Storage.DeleteObject(bucket, assetFilepath)
454	if err == nil && statErr == nil {
455		state.addStorageSize(-info.Size)
456	}
457
458	surrogate := getSurrogateKey(user.Name, projectName)
459	h.Cfg.CacheClearingQueue <- surrogate
460
461	if err != nil {
462		return err
463	}
464
465	return err
466}
467
468func (h *UploadAssetHandler) validateAsset(data *FileData) (bool, error) {
469	fname := filepath.Base(data.Filepath)
470	projectName := shared.GetProjectName(data.FileEntry)
471
472	// special files we use for custom routing
473	if isSpecialFile(fname) {
474		return true, nil
475	}
476
477	fpath := strings.Replace(data.Filepath, "/"+projectName, "", 1)
478	if data.DenyList.MatchesPath(fpath) {
479		err := fmt.Errorf(
480			"%w: (%s), https://pico.sh/pgs#-pgs-ignore",
481			sendutils.ErrFileIgnored,
482			data.Filepath,
483		)
484		return false, err
485	}
486
487	return true, nil
488}
489
490func (h *UploadAssetHandler) writeAsset(s *pssh.SSHServerConnSession, reader io.Reader, data *FileData) (int64, error) {
491	assetFilepath := shared.GetAssetFileName(data.FileEntry)
492
493	logger := h.GetLogger(s)
494	logger.Info(
495		"uploading file to bucket",
496		"bucket", data.Bucket.Name,
497		"filename", assetFilepath,
498	)
499
500	info := &storage.ObjectInfo{
501		LastModified: mtimeToTime(data.FileEntry),
502	}
503	_, fsize, err := h.Cfg.Storage.PutObject(
504		data.Bucket,
505		assetFilepath,
506		reader,
507		info,
508	)
509	return fsize, err
510}
511
512// runCacheQueue processes requests to purge the cache for a single site.
513// One message arrives per file that is written/deleted during uploads.
514// Repeated messages for the same site are grouped so that we only flush once
515// per site per 5 seconds.
516func runCacheQueue(cfg *Config, ctx context.Context) {
517	var pendingFlushes sync.Map
518	tick := time.NewTicker(5 * time.Second)
519	defer tick.Stop()
520	for {
521		select {
522		case <-ctx.Done():
523			return
524		case host := <-cfg.CacheClearingQueue:
525			pendingFlushes.Store(host, host)
526		case <-tick.C:
527			go func() {
528				pendingFlushes.Range(func(key, value any) bool {
529					pendingFlushes.Delete(key)
530					err := purgeCache(cfg, cfg.Pubsub, key.(string))
531					if err != nil {
532						cfg.Logger.Error("failed to clear cache", "err", err.Error())
533					}
534					return true
535				})
536			}()
537		}
538	}
539}