repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

commit
3c82bf5
parent
97ef41a
author
Eric Bower
date
2024-01-30 01:24:05 +0000 UTC
refactor(imgs): merge into prose.sh (#71)

feat(pgs): use image proxy for images

BREAKING CHANGE: removed imgs.sh blog features
46 files changed,  +507, -1489
M cmd/imgs/ssh/main.go
+2, -116
  1@@ -1,121 +1,7 @@
  2 package main
  3 
  4-import (
  5-	"context"
  6-	"fmt"
  7-	"os"
  8-	"os/signal"
  9-	"syscall"
 10-	"time"
 11-
 12-	"github.com/charmbracelet/promwish"
 13-	"github.com/charmbracelet/ssh"
 14-	"github.com/charmbracelet/wish"
 15-	bm "github.com/charmbracelet/wish/bubbletea"
 16-	lm "github.com/charmbracelet/wish/logging"
 17-	"github.com/picosh/pico/db/postgres"
 18-	uploadimgs "github.com/picosh/pico/filehandlers/imgs"
 19-	"github.com/picosh/pico/imgs"
 20-	"github.com/picosh/pico/shared"
 21-	"github.com/picosh/pico/shared/storage"
 22-	"github.com/picosh/pico/wish/cms"
 23-	"github.com/picosh/send/list"
 24-	"github.com/picosh/send/pipe"
 25-	"github.com/picosh/send/proxy"
 26-	"github.com/picosh/send/send/auth"
 27-	wishrsync "github.com/picosh/send/send/rsync"
 28-	"github.com/picosh/send/send/scp"
 29-	"github.com/picosh/send/send/sftp"
 30-	"github.com/picosh/send/send/utils"
 31-)
 32-
 33-type SSHServer struct{}
 34-
 35-func (me *SSHServer) authHandler(ctx ssh.Context, key ssh.PublicKey) bool {
 36-	return true
 37-}
 38-
 39-func createRouter(cfg *shared.ConfigSite, handler utils.CopyFromClientHandler) proxy.Router {
 40-	return func(sh ssh.Handler, s ssh.Session) []wish.Middleware {
 41-		return []wish.Middleware{
 42-			pipe.Middleware(handler, ""),
 43-			list.Middleware(handler),
 44-			scp.Middleware(handler),
 45-			wishrsync.Middleware(handler),
 46-			auth.Middleware(handler),
 47-			bm.Middleware(cms.Middleware(&cfg.ConfigCms, cfg)),
 48-			lm.Middleware(),
 49-		}
 50-	}
 51-}
 52-
 53-func withProxy(cfg *shared.ConfigSite, handler utils.CopyFromClientHandler, otherMiddleware ...wish.Middleware) ssh.Option {
 54-	return func(server *ssh.Server) error {
 55-		err := sftp.SSHOption(handler)(server)
 56-		if err != nil {
 57-			return err
 58-		}
 59-
 60-		return proxy.WithProxy(createRouter(cfg, handler), otherMiddleware...)(server)
 61-	}
 62-}
 63+import "github.com/picosh/pico/prose"
 64 
 65 func main() {
 66-	host := shared.GetEnv("IMGS_HOST", "0.0.0.0")
 67-	port := shared.GetEnv("IMGS_SSH_PORT", "2222")
 68-	promPort := shared.GetEnv("IMGS_PROM_PORT", "9222")
 69-	cfg := imgs.NewConfigSite()
 70-	logger := cfg.Logger
 71-	dbh := postgres.NewDB(cfg.DbURL, cfg.Logger)
 72-	defer dbh.Close()
 73-
 74-	var st storage.ObjectStorage
 75-	var err error
 76-	if cfg.MinioURL == "" {
 77-		st, err = storage.NewStorageFS(cfg.StorageDir)
 78-	} else {
 79-		st, err = storage.NewStorageMinio(cfg.MinioURL, cfg.MinioUser, cfg.MinioPass)
 80-	}
 81-
 82-	if err != nil {
 83-		logger.Fatal(err)
 84-	}
 85-
 86-	handler := uploadimgs.NewUploadImgHandler(
 87-		dbh,
 88-		cfg,
 89-		st,
 90-	)
 91-
 92-	sshServer := &SSHServer{}
 93-	s, err := wish.NewServer(
 94-		wish.WithAddress(fmt.Sprintf("%s:%s", host, port)),
 95-		wish.WithHostKeyPath("ssh_data/term_info_ed25519"),
 96-		wish.WithPublicKeyAuth(sshServer.authHandler),
 97-		withProxy(
 98-			cfg,
 99-			handler,
100-			promwish.Middleware(fmt.Sprintf("%s:%s", host, promPort), "imgs-ssh"),
101-		),
102-	)
103-	if err != nil {
104-		logger.Fatal(err)
105-	}
106-
107-	done := make(chan os.Signal, 1)
108-	signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
109-	logger.Infof("Starting SSH server on %s:%s", host, port)
110-	go func() {
111-		if err = s.ListenAndServe(); err != nil {
112-			logger.Fatal(err)
113-		}
114-	}()
115-
116-	<-done
117-	logger.Info("Stopping SSH server")
118-	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
119-	defer func() { cancel() }()
120-	if err := s.Shutdown(ctx); err != nil {
121-		logger.Fatal(err)
122-	}
123+	prose.StartSshServer()
124 }
M cmd/scripts/dates/dates.go
+2, -5
 1@@ -9,7 +9,6 @@ import (
 2 
 3 	"github.com/picosh/pico/db"
 4 	"github.com/picosh/pico/db/postgres"
 5-	"github.com/picosh/pico/imgs"
 6 	"github.com/picosh/pico/shared"
 7 	"github.com/picosh/pico/wish/cms/config"
 8 	"go.uber.org/zap"
 9@@ -96,9 +95,8 @@ func main() {
10 	datesFixed := []string{}
11 	logger.Info("updating dates")
12 	for _, post := range posts {
13-		linkify := imgs.NewImgsLinkify(post.Username)
14 		if post.Space == "prose" {
15-			parsed, err := shared.ParseText(post.Text, linkify)
16+			parsed, err := shared.ParseText(post.Text)
17 			if err != nil {
18 				logger.Error(err)
19 				continue
20@@ -116,8 +114,7 @@ func main() {
21 				}
22 			}
23 		} else if post.Space == "lists" {
24-			linkify := imgs.NewImgsLinkify(post.Username)
25-			parsed := shared.ListParseText(post.Text, linkify)
26+			parsed := shared.ListParseText(post.Text)
27 			if err != nil {
28 				logger.Error(err)
29 				continue
M cmd/scripts/tags/tags.go
+1, -3
 1@@ -7,7 +7,6 @@ import (
 2 
 3 	"github.com/picosh/pico/db"
 4 	"github.com/picosh/pico/db/postgres"
 5-	"github.com/picosh/pico/imgs"
 6 	"github.com/picosh/pico/shared"
 7 	"github.com/picosh/pico/wish/cms/config"
 8 	"go.uber.org/zap"
 9@@ -76,8 +75,7 @@ func main() {
10 
11 	logger.Info("replacing tags")
12 	for _, post := range posts {
13-		linkify := imgs.NewImgsLinkify(post.Username)
14-		parsed, err := shared.ParseText(post.Text, linkify)
15+		parsed, err := shared.ParseText(post.Text)
16 		if err != nil {
17 			continue
18 		}
M feeds/cron.go
+1, -1
1@@ -135,7 +135,7 @@ func (f *Fetcher) Validate(lastDigest *time.Time, parsed *shared.ListParsedText)
2 func (f *Fetcher) RunPost(user *db.User, post *db.Post) error {
3 	f.cfg.Logger.Infof("(%s) running feed post (%s)", user.Name, post.Filename)
4 
5-	parsed := shared.ListParseText(post.Text, shared.NewNullLinkify())
6+	parsed := shared.ListParseText(post.Text)
7 
8 	f.cfg.Logger.Infof("(%s) Last digest at (%s)", user.Name, post.Data.LastDigest)
9 	err := f.Validate(post.Data.LastDigest, parsed)
M feeds/scp_hooks.go
+1, -3
 1@@ -10,7 +10,6 @@ import (
 2 	"github.com/charmbracelet/ssh"
 3 	"github.com/picosh/pico/db"
 4 	"github.com/picosh/pico/filehandlers"
 5-	"github.com/picosh/pico/imgs"
 6 	"github.com/picosh/pico/shared"
 7 )
 8 
 9@@ -42,8 +41,7 @@ func (p *FeedHooks) FileValidate(s ssh.Session, data *filehandlers.PostMetaData)
10 }
11 
12 func (p *FeedHooks) FileMeta(s ssh.Session, data *filehandlers.PostMetaData) error {
13-	linkify := imgs.NewImgsLinkify(data.Username)
14-	parsedText := shared.ListParseText(string(data.Text), linkify)
15+	parsedText := shared.ListParseText(string(data.Text))
16 
17 	if parsedText.Title == "" {
18 		data.Title = shared.ToUpper(data.Slug)
M feeds/ssh.go
+6, -3
 1@@ -33,7 +33,7 @@ func (me *SSHServer) authHandler(ctx ssh.Context, key ssh.PublicKey) bool {
 2 	return true
 3 }
 4 
 5-func createRouter(handler *filehandlers.ScpUploadHandler) proxy.Router {
 6+func createRouter(handler *filehandlers.FileHandlerRouter) proxy.Router {
 7 	return func(sh ssh.Handler, s ssh.Session) []wish.Middleware {
 8 		return []wish.Middleware{
 9 			pipe.Middleware(handler, ".txt"),
10@@ -47,7 +47,7 @@ func createRouter(handler *filehandlers.ScpUploadHandler) proxy.Router {
11 	}
12 }
13 
14-func withProxy(handler *filehandlers.ScpUploadHandler, otherMiddleware ...wish.Middleware) ssh.Option {
15+func withProxy(handler *filehandlers.FileHandlerRouter, otherMiddleware ...wish.Middleware) ssh.Option {
16 	return func(server *ssh.Server) error {
17 		err := sftp.SSHOption(handler)(server)
18 		if err != nil {
19@@ -84,7 +84,10 @@ func StartSshServer() {
20 		logger.Fatal(err)
21 	}
22 
23-	handler := filehandlers.NewScpPostHandler(dbh, cfg, hooks, st)
24+	fileMap := map[string]filehandlers.ReadWriteHandler{
25+		"fallback": filehandlers.NewScpPostHandler(dbh, cfg, hooks, st),
26+	}
27+	handler := filehandlers.NewFileHandlerRouter(cfg, dbh, fileMap)
28 
29 	sshServer := &SSHServer{}
30 	s, err := wish.NewServer(
M filehandlers/assets/handler.go
+7, -24
  1@@ -12,6 +12,7 @@ import (
  2 
  3 	"github.com/charmbracelet/ssh"
  4 	"github.com/picosh/pico/db"
  5+	futil "github.com/picosh/pico/filehandlers/util"
  6 	"github.com/picosh/pico/shared"
  7 	"github.com/picosh/pico/shared/storage"
  8 	"github.com/picosh/pico/wish/cms/util"
  9@@ -19,8 +20,6 @@ import (
 10 	"go.uber.org/zap"
 11 )
 12 
 13-type ctxUserKey struct{}
 14-type ctxFeatureFlagKey struct{}
 15 type ctxBucketKey struct{}
 16 type ctxStorageSizeKey struct{}
 17 type ctxProjectKey struct{}
 18@@ -42,14 +41,6 @@ func getBucket(s ssh.Session) (storage.Bucket, error) {
 19 	return bucket, nil
 20 }
 21 
 22-func getFeatureFlag(s ssh.Session) (*db.FeatureFlag, error) {
 23-	ff := s.Context().Value(ctxFeatureFlagKey{}).(*db.FeatureFlag)
 24-	if ff.Name == "" {
 25-		return ff, fmt.Errorf("feature flag not set on `ssh.Context()` for connection")
 26-	}
 27-	return ff, nil
 28-}
 29-
 30 func getStorageSize(s ssh.Session) uint64 {
 31 	return s.Context().Value(ctxStorageSizeKey{}).(uint64)
 32 }
 33@@ -61,14 +52,6 @@ func incrementStorageSize(s ssh.Session, fileSize int64) uint64 {
 34 	return nextStorageSize
 35 }
 36 
 37-func getUser(s ssh.Session) (*db.User, error) {
 38-	user := s.Context().Value(ctxUserKey{}).(*db.User)
 39-	if user == nil {
 40-		return user, fmt.Errorf("user not set on `ssh.Context()` for connection")
 41-	}
 42-	return user, nil
 43-}
 44-
 45 type FileData struct {
 46 	*utils.FileEntry
 47 	Text          []byte
 48@@ -98,7 +81,7 @@ func (h *UploadAssetHandler) GetLogger() *zap.SugaredLogger {
 49 }
 50 
 51 func (h *UploadAssetHandler) Read(s ssh.Session, entry *utils.FileEntry) (os.FileInfo, utils.ReaderAtCloser, error) {
 52-	user, err := getUser(s)
 53+	user, err := futil.GetUser(s)
 54 	if err != nil {
 55 		return nil, nil, err
 56 	}
 57@@ -132,7 +115,7 @@ func (h *UploadAssetHandler) Read(s ssh.Session, entry *utils.FileEntry) (os.Fil
 58 func (h *UploadAssetHandler) List(s ssh.Session, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
 59 	var fileList []os.FileInfo
 60 
 61-	user, err := getUser(s)
 62+	user, err := futil.GetUser(s)
 63 	if err != nil {
 64 		return fileList, err
 65 	}
 66@@ -204,7 +187,8 @@ func (h *UploadAssetHandler) Validate(s ssh.Session) error {
 67 	ff.Data.StorageMax = ff.FindStorageMax(h.Cfg.MaxSize)
 68 	ff.Data.FileMax = ff.FindFileMax(h.Cfg.MaxAssetSize)
 69 
 70-	s.Context().SetValue(ctxFeatureFlagKey{}, ff)
 71+	futil.SetFeatureFlag(s, ff)
 72+	futil.SetUser(s, user)
 73 
 74 	assetBucket := shared.GetAssetBucketName(user.ID)
 75 	bucket, err := h.Storage.UpsertBucket(assetBucket)
 76@@ -220,14 +204,13 @@ func (h *UploadAssetHandler) Validate(s ssh.Session) error {
 77 	s.Context().SetValue(ctxStorageSizeKey{}, totalStorageSize)
 78 	h.Cfg.Logger.Infof("(%s) bucket size is current (%d bytes)", user.Name, totalStorageSize)
 79 
 80-	s.Context().SetValue(ctxUserKey{}, user)
 81 	h.Cfg.Logger.Infof("(%s) attempting to upload files to (%s)", user.Name, h.Cfg.Space)
 82 
 83 	return nil
 84 }
 85 
 86 func (h *UploadAssetHandler) Write(s ssh.Session, entry *utils.FileEntry) (string, error) {
 87-	user, err := getUser(s)
 88+	user, err := futil.GetUser(s)
 89 	if err != nil {
 90 		h.Cfg.Logger.Error(err)
 91 		return "", err
 92@@ -276,7 +259,7 @@ func (h *UploadAssetHandler) Write(s ssh.Session, entry *utils.FileEntry) (strin
 93 	}
 94 
 95 	storageSize := getStorageSize(s)
 96-	featureFlag, err := getFeatureFlag(s)
 97+	featureFlag, err := futil.GetFeatureFlag(s)
 98 	if err != nil {
 99 		return "", err
100 	}
D filehandlers/imgs/client.go
+0, -35
 1@@ -1,35 +0,0 @@
 2-package uploadimgs
 3-
 4-import (
 5-	"github.com/charmbracelet/ssh"
 6-	"github.com/picosh/pico/db"
 7-	"github.com/picosh/pico/imgs"
 8-	"github.com/picosh/pico/shared"
 9-	"github.com/picosh/pico/shared/storage"
10-	"github.com/picosh/send/send/utils"
11-)
12-
13-type ImgsAPI struct {
14-	Cfg *shared.ConfigSite
15-	Db  db.DB
16-	St  storage.ObjectStorage
17-}
18-
19-func NewImgsAPI(dbpool db.DB, st storage.ObjectStorage) *ImgsAPI {
20-	cfg := imgs.NewConfigSite()
21-	return &ImgsAPI{
22-		Cfg: cfg,
23-		Db:  dbpool,
24-		St:  st,
25-	}
26-}
27-
28-func (img *ImgsAPI) Upload(s ssh.Session, file *utils.FileEntry) (string, error) {
29-	handler := NewUploadImgHandler(img.Db, img.Cfg, img.St)
30-	err := handler.Validate(s)
31-	if err != nil {
32-		return "", err
33-	}
34-
35-	return handler.Write(s, file)
36-}
M filehandlers/imgs/handler.go
+13, -123
  1@@ -14,31 +14,13 @@ import (
  2 	"github.com/charmbracelet/ssh"
  3 	exifremove "github.com/neurosnap/go-exif-remove"
  4 	"github.com/picosh/pico/db"
  5+	"github.com/picosh/pico/filehandlers/util"
  6 	"github.com/picosh/pico/shared"
  7 	"github.com/picosh/pico/shared/storage"
  8-	"github.com/picosh/pico/wish/cms/util"
  9 	"github.com/picosh/send/send/utils"
 10-	"go.uber.org/zap"
 11 )
 12 
 13-type ctxUserKey struct{}
 14-type ctxFeatureFlagKey struct{}
 15-
 16-func getUser(s ssh.Session) (*db.User, error) {
 17-	user := s.Context().Value(ctxUserKey{}).(*db.User)
 18-	if user == nil {
 19-		return user, fmt.Errorf("user not set on `ssh.Context()` for connection")
 20-	}
 21-	return user, nil
 22-}
 23-
 24-func getFeatureFlag(s ssh.Session) (*db.FeatureFlag, error) {
 25-	ff := s.Context().Value(ctxFeatureFlagKey{}).(*db.FeatureFlag)
 26-	if ff.Name == "" {
 27-		return ff, fmt.Errorf("feature flag not set on `ssh.Context()` for connection")
 28-	}
 29-	return ff, nil
 30-}
 31+var Space = "imgs"
 32 
 33 type PostMetaData struct {
 34 	*db.Post
 35@@ -81,12 +63,8 @@ func (h *UploadImgHandler) removePost(data *PostMetaData) error {
 36 	return nil
 37 }
 38 
 39-func (h *UploadImgHandler) GetLogger() *zap.SugaredLogger {
 40-	return h.Cfg.Logger
 41-}
 42-
 43 func (h *UploadImgHandler) Read(s ssh.Session, entry *utils.FileEntry) (os.FileInfo, utils.ReaderAtCloser, error) {
 44-	user, err := getUser(s)
 45+	user, err := util.GetUser(s)
 46 	if err != nil {
 47 		return nil, nil, err
 48 	}
 49@@ -97,7 +75,7 @@ func (h *UploadImgHandler) Read(s ssh.Session, entry *utils.FileEntry) (os.FileI
 50 		return nil, nil, os.ErrNotExist
 51 	}
 52 
 53-	post, err := h.DBPool.FindPostWithFilename(cleanFilename, user.ID, h.Cfg.Space)
 54+	post, err := h.DBPool.FindPostWithFilename(cleanFilename, user.ID, Space)
 55 	if err != nil {
 56 		return nil, nil, err
 57 	}
 58@@ -124,91 +102,8 @@ func (h *UploadImgHandler) Read(s ssh.Session, entry *utils.FileEntry) (os.FileI
 59 	return fileInfo, reader, nil
 60 }
 61 
 62-func (h *UploadImgHandler) List(s ssh.Session, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
 63-	var fileList []os.FileInfo
 64-	user, err := getUser(s)
 65-	if err != nil {
 66-		return fileList, err
 67-	}
 68-	cleanFilename := filepath.Base(fpath)
 69-
 70-	var post *db.Post
 71-	var posts []*db.Post
 72-
 73-	if cleanFilename == "" || cleanFilename == "." || cleanFilename == "/" {
 74-		name := cleanFilename
 75-		if name == "" {
 76-			name = "/"
 77-		}
 78-
 79-		fileList = append(fileList, &utils.VirtualFile{
 80-			FName:  name,
 81-			FIsDir: true,
 82-		})
 83-
 84-		posts, err = h.DBPool.FindAllPostsForUser(user.ID, h.Cfg.Space)
 85-	} else {
 86-		post, err = h.DBPool.FindPostWithFilename(cleanFilename, user.ID, h.Cfg.Space)
 87-
 88-		posts = append(posts, post)
 89-	}
 90-
 91-	if err != nil {
 92-		return nil, err
 93-	}
 94-
 95-	for _, post := range posts {
 96-		fileList = append(fileList, &utils.VirtualFile{
 97-			FName:    post.Filename,
 98-			FIsDir:   false,
 99-			FSize:    int64(post.FileSize),
100-			FModTime: *post.UpdatedAt,
101-		})
102-	}
103-
104-	return fileList, nil
105-}
106-
107-func (h *UploadImgHandler) Validate(s ssh.Session) error {
108-	var err error
109-	key, err := util.KeyText(s)
110-	if err != nil {
111-		return fmt.Errorf("key not found")
112-	}
113-
114-	user, err := h.DBPool.FindUserForKey(s.User(), key)
115-	if err != nil {
116-		return err
117-	}
118-
119-	if user.Name == "" {
120-		return fmt.Errorf("must have username set")
121-	}
122-
123-	ff, _ := h.DBPool.FindFeatureForUser(user.ID, "imgs")
124-	// imgs.sh has a free tier so users might not have a feature flag
125-	// in which case we set sane defaults
126-	if ff == nil {
127-		ff = db.NewFeatureFlag(
128-			user.ID,
129-			"imgs",
130-			h.Cfg.MaxSize,
131-			h.Cfg.MaxAssetSize,
132-		)
133-	}
134-	// this is jank
135-	ff.Data.StorageMax = ff.FindStorageMax(h.Cfg.MaxSize)
136-	ff.Data.FileMax = ff.FindFileMax(h.Cfg.MaxAssetSize)
137-
138-	s.Context().SetValue(ctxFeatureFlagKey{}, ff)
139-
140-	s.Context().SetValue(ctxUserKey{}, user)
141-	h.Cfg.Logger.Infof("(%s) attempting to upload files to (%s)", user.Name, h.Cfg.Space)
142-	return nil
143-}
144-
145 func (h *UploadImgHandler) Write(s ssh.Session, entry *utils.FileEntry) (string, error) {
146-	user, err := getUser(s)
147+	user, err := util.GetUser(s)
148 	if err != nil {
149 		h.Cfg.Logger.Error(err)
150 		return "", err
151@@ -221,6 +116,10 @@ func (h *UploadImgHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
152 		text = b
153 	}
154 	mimeType := http.DetectContentType(text)
155+	ext := filepath.Ext(filename)
156+	if ext == ".svg" {
157+		mimeType = "image/svg+xml"
158+	}
159 	// strip exif data
160 	if slices.Contains([]string{"image/png", "image/jpg", "image/jpeg"}, mimeType) {
161 		noExifBytes, err := exifremove.Remove(text)
162@@ -237,9 +136,9 @@ func (h *UploadImgHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
163 	}
164 
165 	now := time.Now()
166-	slug := shared.SanitizeFileExt(filename)
167 	fileSize := binary.Size(text)
168 	shasum := shared.Shasum(text)
169+	slug := shared.SanitizeFileExt(filename)
170 
171 	nextPost := db.Post{
172 		Filename:  filename,
173@@ -251,25 +150,16 @@ func (h *UploadImgHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
174 		Shasum:    shasum,
175 	}
176 
177-	ext := filepath.Ext(filename)
178-	// DetectContentType does not detect markdown
179-	if ext == ".md" {
180-		nextPost.MimeType = "text/markdown; charset=UTF-8"
181-		// DetectContentType does not detect image/svg
182-	} else if ext == ".svg" {
183-		nextPost.MimeType = "image/svg+xml"
184-	}
185-
186 	post, err := h.DBPool.FindPostWithFilename(
187 		nextPost.Filename,
188 		user.ID,
189-		h.Cfg.Space,
190+		Space,
191 	)
192 	if err != nil {
193 		h.Cfg.Logger.Infof("(%s) unable to find image (%s), continuing", nextPost.Filename, err)
194 	}
195 
196-	featureFlag, err := getFeatureFlag(s)
197+	featureFlag, err := util.GetFeatureFlag(s)
198 	if err != nil {
199 		return "", err
200 	}
201@@ -302,7 +192,7 @@ func (h *UploadImgHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
202 	url := h.Cfg.FullPostURL(
203 		curl,
204 		user.Name,
205-		metadata.Slug,
206+		metadata.Filename,
207 	)
208 	maxSize := int(featureFlag.Data.StorageMax)
209 	str := fmt.Sprintf(
M filehandlers/imgs/img.go
+3, -2
 1@@ -8,6 +8,7 @@ import (
 2 
 3 	"github.com/charmbracelet/ssh"
 4 	"github.com/picosh/pico/db"
 5+	"github.com/picosh/pico/filehandlers/util"
 6 	"github.com/picosh/pico/shared"
 7 	"github.com/picosh/send/send/utils"
 8 )
 9@@ -79,7 +80,7 @@ func (h *UploadImgHandler) writeImg(s ssh.Session, data *PostMetaData) error {
10 	if !valid {
11 		return err
12 	}
13-	user, err := getUser(s)
14+	user, err := util.GetUser(s)
15 	if err != nil {
16 		return err
17 	}
18@@ -110,7 +111,7 @@ func (h *UploadImgHandler) writeImg(s ssh.Session, data *PostMetaData) error {
19 		h.Cfg.Logger.Infof("(%s) not found, adding record", data.Filename)
20 		insertPost := db.Post{
21 			UserID: user.ID,
22-			Space:  h.Cfg.Space,
23+			Space:  Space,
24 
25 			Data:        data.Data,
26 			Description: data.Description,
M filehandlers/post_handler.go
+9, -100
  1@@ -12,24 +12,12 @@ import (
  2 
  3 	"github.com/charmbracelet/ssh"
  4 	"github.com/picosh/pico/db"
  5-	uploadimgs "github.com/picosh/pico/filehandlers/imgs"
  6+	"github.com/picosh/pico/filehandlers/util"
  7 	"github.com/picosh/pico/shared"
  8 	"github.com/picosh/pico/shared/storage"
  9-	"github.com/picosh/pico/wish/cms/util"
 10 	"github.com/picosh/send/send/utils"
 11-	"go.uber.org/zap"
 12 )
 13 
 14-type ctxUserKey struct{}
 15-
 16-func getUser(s ssh.Session) (*db.User, error) {
 17-	user := s.Context().Value(ctxUserKey{}).(*db.User)
 18-	if user == nil {
 19-		return user, fmt.Errorf("user not set on `ssh.Context()` for connection")
 20-	}
 21-	return user, nil
 22-}
 23-
 24 type PostMetaData struct {
 25 	*db.Post
 26 	Cur       *db.Post
 27@@ -45,29 +33,21 @@ type ScpFileHooks interface {
 28 }
 29 
 30 type ScpUploadHandler struct {
 31-	DBPool    db.DB
 32-	Cfg       *shared.ConfigSite
 33-	Hooks     ScpFileHooks
 34-	ImgClient *uploadimgs.ImgsAPI
 35+	DBPool db.DB
 36+	Cfg    *shared.ConfigSite
 37+	Hooks  ScpFileHooks
 38 }
 39 
 40 func NewScpPostHandler(dbpool db.DB, cfg *shared.ConfigSite, hooks ScpFileHooks, st storage.ObjectStorage) *ScpUploadHandler {
 41-	client := uploadimgs.NewImgsAPI(dbpool, st)
 42-
 43 	return &ScpUploadHandler{
 44-		DBPool:    dbpool,
 45-		Cfg:       cfg,
 46-		Hooks:     hooks,
 47-		ImgClient: client,
 48+		DBPool: dbpool,
 49+		Cfg:    cfg,
 50+		Hooks:  hooks,
 51 	}
 52 }
 53 
 54-func (h *ScpUploadHandler) GetLogger() *zap.SugaredLogger {
 55-	return h.Cfg.Logger
 56-}
 57-
 58 func (h *ScpUploadHandler) Read(s ssh.Session, entry *utils.FileEntry) (os.FileInfo, utils.ReaderAtCloser, error) {
 59-	user, err := getUser(s)
 60+	user, err := util.GetUser(s)
 61 	if err != nil {
 62 		return nil, nil, err
 63 	}
 64@@ -94,76 +74,9 @@ func (h *ScpUploadHandler) Read(s ssh.Session, entry *utils.FileEntry) (os.FileI
 65 	return fileInfo, reader, nil
 66 }
 67 
 68-func (h *ScpUploadHandler) List(s ssh.Session, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
 69-	var fileList []os.FileInfo
 70-	user, err := getUser(s)
 71-	if err != nil {
 72-		return fileList, err
 73-	}
 74-
 75-	cleanFilename := filepath.Base(fpath)
 76-
 77-	var post *db.Post
 78-	var posts []*db.Post
 79-
 80-	if cleanFilename == "" || cleanFilename == "." || cleanFilename == "/" {
 81-		name := cleanFilename
 82-		if name == "" {
 83-			name = "/"
 84-		}
 85-
 86-		fileList = append(fileList, &utils.VirtualFile{
 87-			FName:  name,
 88-			FIsDir: true,
 89-		})
 90-
 91-		posts, err = h.DBPool.FindAllPostsForUser(user.ID, h.Cfg.Space)
 92-	} else {
 93-		post, err = h.DBPool.FindPostWithFilename(cleanFilename, user.ID, h.Cfg.Space)
 94-
 95-		posts = append(posts, post)
 96-	}
 97-
 98-	if err != nil {
 99-		return nil, err
100-	}
101-
102-	for _, post := range posts {
103-		fileList = append(fileList, &utils.VirtualFile{
104-			FName:    post.Filename,
105-			FIsDir:   false,
106-			FSize:    int64(post.FileSize),
107-			FModTime: *post.UpdatedAt,
108-		})
109-	}
110-
111-	return fileList, nil
112-}
113-
114-func (h *ScpUploadHandler) Validate(s ssh.Session) error {
115-	var err error
116-	key, err := util.KeyText(s)
117-	if err != nil {
118-		return fmt.Errorf("key not found")
119-	}
120-
121-	user, err := h.DBPool.FindUserForKey(s.User(), key)
122-	if err != nil {
123-		return err
124-	}
125-
126-	if user.Name == "" {
127-		return fmt.Errorf("must have username set")
128-	}
129-
130-	s.Context().SetValue(ctxUserKey{}, user)
131-	h.Cfg.Logger.Infof("(%s) attempting to upload files to (%s)", user.Name, h.Cfg.Space)
132-	return nil
133-}
134-
135 func (h *ScpUploadHandler) Write(s ssh.Session, entry *utils.FileEntry) (string, error) {
136 	logger := h.Cfg.Logger
137-	user, err := getUser(s)
138+	user, err := util.GetUser(s)
139 	if err != nil {
140 		logger.Error(err)
141 		return "", err
142@@ -172,10 +85,6 @@ func (h *ScpUploadHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
143 	userID := user.ID
144 	filename := filepath.Base(entry.Filepath)
145 
146-	if shared.IsExtAllowed(filename, h.ImgClient.Cfg.AllowedExt) {
147-		return h.ImgClient.Upload(s, entry)
148-	}
149-
150 	var origText []byte
151 	if b, err := io.ReadAll(entry.Reader); err == nil {
152 		origText = b
A filehandlers/router_handler.go
+168, -0
  1@@ -0,0 +1,168 @@
  2+package filehandlers
  3+
  4+import (
  5+	"fmt"
  6+	"os"
  7+	"path/filepath"
  8+
  9+	"github.com/charmbracelet/ssh"
 10+	"github.com/picosh/pico/db"
 11+	"github.com/picosh/pico/filehandlers/util"
 12+	"github.com/picosh/pico/shared"
 13+	"github.com/picosh/send/send/utils"
 14+	"go.uber.org/zap"
 15+)
 16+
 17+type ReadWriteHandler interface {
 18+	Write(ssh.Session, *utils.FileEntry) (string, error)
 19+	Read(ssh.Session, *utils.FileEntry) (os.FileInfo, utils.ReaderAtCloser, error)
 20+}
 21+
 22+type FileHandlerRouter struct {
 23+	FileMap map[string]ReadWriteHandler
 24+	Cfg     *shared.ConfigSite
 25+	DBPool  db.DB
 26+	Spaces  []string
 27+}
 28+
 29+var _ utils.CopyFromClientHandler = &FileHandlerRouter{}      // Verify implementation
 30+var _ utils.CopyFromClientHandler = (*FileHandlerRouter)(nil) // Verify implementation
 31+
 32+func NewFileHandlerRouter(cfg *shared.ConfigSite, dbpool db.DB, mapper map[string]ReadWriteHandler) *FileHandlerRouter {
 33+	return &FileHandlerRouter{
 34+		Cfg:     cfg,
 35+		DBPool:  dbpool,
 36+		FileMap: mapper,
 37+		Spaces:  []string{cfg.Space},
 38+	}
 39+}
 40+
 41+func (r *FileHandlerRouter) findHandler(entry *utils.FileEntry) (ReadWriteHandler, error) {
 42+	fext := filepath.Ext(entry.Filepath)
 43+	handler, ok := r.FileMap[fext]
 44+	if !ok {
 45+		hand, hasFallback := r.FileMap["fallback"]
 46+		if !hasFallback {
 47+			return nil, fmt.Errorf("no corresponding handler for file extension: %s", fext)
 48+		}
 49+		handler = hand
 50+	}
 51+	return handler, nil
 52+}
 53+
 54+func (r *FileHandlerRouter) Write(s ssh.Session, entry *utils.FileEntry) (string, error) {
 55+	handler, err := r.findHandler(entry)
 56+	if err != nil {
 57+		return "", err
 58+	}
 59+	return handler.Write(s, entry)
 60+}
 61+
 62+func (r *FileHandlerRouter) Read(s ssh.Session, entry *utils.FileEntry) (os.FileInfo, utils.ReaderAtCloser, error) {
 63+	handler, err := r.findHandler(entry)
 64+	if err != nil {
 65+		return nil, nil, err
 66+	}
 67+	return handler.Read(s, entry)
 68+}
 69+
 70+func (r *FileHandlerRouter) List(s ssh.Session, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
 71+	var fileList []os.FileInfo
 72+	user, err := util.GetUser(s)
 73+	if err != nil {
 74+		return fileList, err
 75+	}
 76+	cleanFilename := filepath.Base(fpath)
 77+
 78+	var post *db.Post
 79+	var posts []*db.Post
 80+
 81+	if cleanFilename == "" || cleanFilename == "." || cleanFilename == "/" {
 82+		name := cleanFilename
 83+		if name == "" {
 84+			name = "/"
 85+		}
 86+
 87+		fileList = append(fileList, &utils.VirtualFile{
 88+			FName:  name,
 89+			FIsDir: true,
 90+		})
 91+
 92+		for _, space := range r.Spaces {
 93+			curPosts, e := r.DBPool.FindAllPostsForUser(user.ID, space)
 94+			if e != nil {
 95+				err = e
 96+				break
 97+			}
 98+			posts = append(posts, curPosts...)
 99+		}
100+	} else {
101+		for _, space := range r.Spaces {
102+			p, e := r.DBPool.FindPostWithFilename(cleanFilename, user.ID, space)
103+			if e != nil {
104+				err = e
105+				continue
106+			}
107+			post = p
108+		}
109+
110+		posts = append(posts, post)
111+	}
112+
113+	if err != nil {
114+		return nil, err
115+	}
116+
117+	for _, post := range posts {
118+		fileList = append(fileList, &utils.VirtualFile{
119+			FName:    post.Filename,
120+			FIsDir:   false,
121+			FSize:    int64(post.FileSize),
122+			FModTime: *post.UpdatedAt,
123+		})
124+	}
125+
126+	return fileList, nil
127+}
128+
129+func (r *FileHandlerRouter) GetLogger() *zap.SugaredLogger {
130+	return r.Cfg.Logger
131+}
132+
133+func (r *FileHandlerRouter) Validate(s ssh.Session) error {
134+	var err error
135+	key, err := utils.KeyText(s)
136+	if err != nil {
137+		return fmt.Errorf("key not found")
138+	}
139+
140+	user, err := r.DBPool.FindUserForKey(s.User(), key)
141+	if err != nil {
142+		return err
143+	}
144+
145+	if user.Name == "" {
146+		return fmt.Errorf("must have username set")
147+	}
148+
149+	ff, _ := r.DBPool.FindFeatureForUser(user.ID, r.Cfg.Space)
150+	// we have free tiers so users might not have a feature flag
151+	// in which case we set sane defaults
152+	if ff == nil {
153+		ff = db.NewFeatureFlag(
154+			user.ID,
155+			r.Cfg.Space,
156+			r.Cfg.MaxSize,
157+			r.Cfg.MaxAssetSize,
158+		)
159+	}
160+	// this is jank
161+	ff.Data.StorageMax = ff.FindStorageMax(r.Cfg.MaxSize)
162+	ff.Data.FileMax = ff.FindFileMax(r.Cfg.MaxAssetSize)
163+
164+	util.SetUser(s, user)
165+	util.SetFeatureFlag(s, ff)
166+
167+	r.Cfg.Logger.Infof("(%s) attempting to upload files to (%s)", user.Name, r.Cfg.Space)
168+	return nil
169+}
A filehandlers/util/util.go
+35, -0
 1@@ -0,0 +1,35 @@
 2+package util
 3+
 4+import (
 5+	"fmt"
 6+
 7+	"github.com/charmbracelet/ssh"
 8+	"github.com/picosh/pico/db"
 9+)
10+
11+type ctxUserKey struct{}
12+type ctxFeatureFlagKey struct{}
13+
14+func GetUser(s ssh.Session) (*db.User, error) {
15+	user := s.Context().Value(ctxUserKey{}).(*db.User)
16+	if user == nil {
17+		return user, fmt.Errorf("user not set on `ssh.Context()` for connection")
18+	}
19+	return user, nil
20+}
21+
22+func SetUser(s ssh.Session, user *db.User) {
23+	s.Context().SetValue(ctxUserKey{}, user)
24+}
25+
26+func GetFeatureFlag(s ssh.Session) (*db.FeatureFlag, error) {
27+	ff := s.Context().Value(ctxFeatureFlagKey{}).(*db.FeatureFlag)
28+	if ff.Name == "" {
29+		return ff, fmt.Errorf("feature flag not set on `ssh.Context()` for connection")
30+	}
31+	return ff, nil
32+}
33+
34+func SetFeatureFlag(s ssh.Session, ff *db.FeatureFlag) {
35+	s.Context().SetValue(ctxFeatureFlagKey{}, ff)
36+}
M imgs/api.go
+63, -517
  1@@ -4,7 +4,6 @@ import (
  2 	"bytes"
  3 	"fmt"
  4 	"html/template"
  5-	"io"
  6 	"net/http"
  7 	"net/url"
  8 	"path/filepath"
  9@@ -12,21 +11,15 @@ import (
 10 
 11 	_ "net/http/pprof"
 12 
 13-	"slices"
 14-
 15 	"github.com/gorilla/feeds"
 16 	gocache "github.com/patrickmn/go-cache"
 17 	"github.com/picosh/pico/db"
 18 	"github.com/picosh/pico/db/postgres"
 19+	"github.com/picosh/pico/pgs"
 20 	"github.com/picosh/pico/shared"
 21 	"github.com/picosh/pico/shared/storage"
 22-	"go.uber.org/zap"
 23 )
 24 
 25-type PageData struct {
 26-	Site shared.SitePageData
 27-}
 28-
 29 type PostItemData struct {
 30 	BlogURL      template.URL
 31 	URL          template.URL
 32@@ -36,415 +29,18 @@ type PostItemData struct {
 33 	Caption      string
 34 }
 35 
 36-type BlogPageData struct {
 37-	Site      shared.SitePageData
 38-	PageTitle string
 39-	URL       template.URL
 40-	RSSURL    template.URL
 41-	Username  string
 42-	Readme    *ReadmeTxt
 43-	Header    *HeaderTxt
 44-	Posts     []*PostItemData
 45-	HasFilter bool
 46-}
 47-
 48 type PostPageData struct {
 49-	Site         shared.SitePageData
 50-	PageTitle    string
 51-	URL          template.URL
 52-	BlogURL      template.URL
 53-	Slug         string
 54-	Title        string
 55-	Caption      string
 56-	Contents     template.HTML
 57-	Text         string
 58-	Username     string
 59-	BlogName     string
 60-	PublishAtISO string
 61-	PublishAt    string
 62-	Tags         []Link
 63-	ImgURL       template.URL
 64-	PrevPage     template.URL
 65-	NextPage     template.URL
 66-}
 67-
 68-type TransparencyPageData struct {
 69-	Site      shared.SitePageData
 70-	Analytics *db.Analytics
 71-}
 72-
 73-type Link struct {
 74-	URL  template.URL
 75-	Text string
 76-}
 77-
 78-type HeaderTxt struct {
 79-	Title    string
 80-	Bio      string
 81-	Nav      []Link
 82-	HasLinks bool
 83-}
 84-
 85-type ReadmeTxt struct {
 86-	HasText  bool
 87-	Contents template.HTML
 88-}
 89-
 90-func GetPostTitle(post *db.Post) string {
 91-	if post.Description == "" {
 92-		return post.Title
 93-	}
 94-
 95-	return fmt.Sprintf("%s: %s", post.Title, post.Description)
 96-}
 97-
 98-func GetBlogName(username string) string {
 99-	return username
100-}
101-
102-func blogHandler(w http.ResponseWriter, r *http.Request) {
103-	username := shared.GetUsernameFromRequest(r)
104-	dbpool := shared.GetDB(r)
105-	logger := shared.GetLogger(r)
106-	cfg := shared.GetCfg(r)
107-
108-	user, err := dbpool.FindUserForName(username)
109-	if err != nil {
110-		logger.Infof("blog not found: %s", username)
111-		http.Error(w, "blog not found", http.StatusNotFound)
112-		return
113-	}
114-
115-	tag := r.URL.Query().Get("tag")
116-	var posts []*db.Post
117-	var p *db.Paginate[*db.Post]
118-	pager := &db.Pager{Num: 1000, Page: 0}
119-	if tag == "" {
120-		p, err = dbpool.FindPostsForUser(pager, user.ID, cfg.Space)
121-	} else {
122-		p, err = dbpool.FindUserPostsByTag(pager, tag, user.ID, cfg.Space)
123-	}
124-	posts = p.Data
125-
126-	if err != nil {
127-		logger.Error(err)
128-		http.Error(w, "could not fetch posts for blog", http.StatusInternalServerError)
129-		return
130-	}
131-
132-	ts, err := shared.RenderTemplate(cfg, []string{
133-		cfg.StaticPath("html/blog.page.tmpl"),
134-	})
135-
136-	if err != nil {
137-		logger.Error(err)
138-		http.Error(w, err.Error(), http.StatusInternalServerError)
139-		return
140-	}
141-
142-	headerTxt := &HeaderTxt{
143-		Title: GetBlogName(username),
144-		Bio:   "",
145-	}
146-	readmeTxt := &ReadmeTxt{}
147-
148-	curl := shared.CreateURLFromRequest(cfg, r)
149-	postCollection := make([]*PostItemData, 0, len(posts))
150-	for _, post := range posts {
151-		url := fmt.Sprintf(
152-			"%s/300x",
153-			cfg.ImgURL(curl, post.Username, post.Slug),
154-		)
155-		postCollection = append(postCollection, &PostItemData{
156-			ImgURL:       template.URL(url),
157-			URL:          template.URL(cfg.ImgPostURL(curl, post.Username, post.Slug)),
158-			Caption:      post.Title,
159-			PublishAt:    post.PublishAt.Format("02 Jan, 2006"),
160-			PublishAtISO: post.PublishAt.Format(time.RFC3339),
161-		})
162-	}
163-
164-	data := BlogPageData{
165-		Site:      *cfg.GetSiteData(),
166-		PageTitle: headerTxt.Title,
167-		URL:       template.URL(cfg.FullBlogURL(curl, username)),
168-		RSSURL:    template.URL(cfg.RssBlogURL(curl, username, tag)),
169-		Readme:    readmeTxt,
170-		Header:    headerTxt,
171-		Username:  username,
172-		Posts:     postCollection,
173-		HasFilter: tag != "",
174-	}
175-
176-	err = ts.Execute(w, data)
177-	if err != nil {
178-		logger.Error(err)
179-		http.Error(w, err.Error(), http.StatusInternalServerError)
180-	}
181-}
182-
183-type ImgHandler struct {
184-	Username  string
185-	Subdomain string
186-	Slug      string
187-	Cfg       *shared.ConfigSite
188-	Dbpool    db.DB
189-	Storage   storage.ObjectStorage
190-	Logger    *zap.SugaredLogger
191-	Cache     *gocache.Cache
192-	Ratio     *storage.Ratio
193-	Original  bool
194-}
195-
196-func imgHandler(w http.ResponseWriter, h *ImgHandler) {
197-	user, err := h.Dbpool.FindUserForName(h.Username)
198-	if err != nil {
199-		h.Logger.Infof("blog not found: %s", h.Username)
200-		http.Error(w, "blog not found", http.StatusNotFound)
201-		return
202-	}
203-
204-	post, err := h.Dbpool.FindPostWithSlug(h.Slug, user.ID, h.Cfg.Space)
205-	if err != nil {
206-		errMsg := fmt.Sprintf("image not found %s/%s", h.Username, h.Slug)
207-		h.Logger.Infof(errMsg)
208-		http.Error(w, errMsg, http.StatusNotFound)
209-		return
210-	}
211-
212-	_, err = h.Dbpool.AddViewCount(post.ID)
213-	if err != nil {
214-		h.Logger.Error(err)
215-	}
216-
217-	bucket, err := h.Storage.GetBucket(user.ID)
218-	if err != nil {
219-		h.Logger.Infof("bucket not found %s/%s", h.Username, post.Filename)
220-		http.Error(w, err.Error(), http.StatusInternalServerError)
221-		return
222-	}
223-
224-	contents, contentType, err := h.Storage.ServeFile(bucket, post.Filename, h.Ratio, h.Original, h.Cfg.UseImgProxy)
225-	if err != nil {
226-		h.Logger.Infof(
227-			"file not found %s/%s in storage (bucket: %s, name: %s)",
228-			h.Username,
229-			post.Filename,
230-			bucket.Name,
231-			post.Filename,
232-		)
233-		http.Error(w, err.Error(), http.StatusInternalServerError)
234-		return
235-	}
236-	defer contents.Close()
237-
238-	w.Header().Add("Content-Type", contentType)
239-
240-	_, err = io.Copy(w, contents)
241-
242-	if err != nil {
243-		h.Logger.Error(err)
244-	}
245-}
246-
247-func imgRequestOriginal(w http.ResponseWriter, r *http.Request) {
248-	username := shared.GetUsernameFromRequest(r)
249-	subdomain := shared.GetSubdomain(r)
250-	cfg := shared.GetCfg(r)
251-
252-	var slug string
253-	if !cfg.IsSubdomains() || subdomain == "" {
254-		slug, _ = url.PathUnescape(shared.GetField(r, 1))
255-	} else {
256-		slug, _ = url.PathUnescape(shared.GetField(r, 0))
257-	}
258-
259-	// users might add the file extension when requesting an image
260-	// but we want to remove that
261-	slug = shared.SanitizeFileExt(slug)
262-
263-	dbpool := shared.GetDB(r)
264-	st := shared.GetStorage(r)
265-	logger := shared.GetLogger(r)
266-	cache := shared.GetCache(r)
267-
268-	imgHandler(w, &ImgHandler{
269-		Username:  username,
270-		Subdomain: subdomain,
271-		Slug:      slug,
272-		Cfg:       cfg,
273-		Dbpool:    dbpool,
274-		Storage:   st,
275-		Logger:    logger,
276-		Cache:     cache,
277-		Original:  true,
278-	})
279-}
280-
281-func imgRequest(w http.ResponseWriter, r *http.Request) {
282-	username := shared.GetUsernameFromRequest(r)
283-	subdomain := shared.GetSubdomain(r)
284-	cfg := shared.GetCfg(r)
285-
286-	var dimes string
287-	var slug string
288-	if !cfg.IsSubdomains() || subdomain == "" {
289-		slug, _ = url.PathUnescape(shared.GetField(r, 1))
290-		dimes, _ = url.PathUnescape(shared.GetField(r, 2))
291-	} else {
292-		slug, _ = url.PathUnescape(shared.GetField(r, 0))
293-		dimes, _ = url.PathUnescape(shared.GetField(r, 1))
294-	}
295-
296-	ratio, _ := storage.GetRatio(dimes)
297-
298-	ext := filepath.Ext(slug)
299-	// Files can contain periods.  `filepath.Ext` is greedy and will clip the last period in the slug
300-	// and call that a file extension so we want to be explicit about what
301-	// file extensions we clip here
302-	for _, fext := range cfg.AllowedExt {
303-		if ext == fext {
304-			// users might add the file extension when requesting an image
305-			// but we want to remove that
306-			slug = shared.SanitizeFileExt(slug)
307-			break
308-		}
309-	}
310-
311-	dbpool := shared.GetDB(r)
312-	st := shared.GetStorage(r)
313-	logger := shared.GetLogger(r)
314-	cache := shared.GetCache(r)
315-
316-	imgHandler(w, &ImgHandler{
317-		Username:  username,
318-		Subdomain: subdomain,
319-		Slug:      slug,
320-		Cfg:       cfg,
321-		Dbpool:    dbpool,
322-		Storage:   st,
323-		Logger:    logger,
324-		Cache:     cache,
325-		Ratio:     ratio,
326-	})
327+	ImgURL template.URL
328 }
329 
330-func postHandler(w http.ResponseWriter, r *http.Request) {
331-	username := shared.GetUsernameFromRequest(r)
332-	subdomain := shared.GetSubdomain(r)
333-	cfg := shared.GetCfg(r)
334+var Space = "imgs"
335 
336-	var slug string
337-	if !cfg.IsSubdomains() || subdomain == "" {
338-		slug, _ = url.PathUnescape(shared.GetField(r, 1))
339-	} else {
340-		slug, _ = url.PathUnescape(shared.GetField(r, 0))
341-	}
342-
343-	dbpool := shared.GetDB(r)
344-	logger := shared.GetLogger(r)
345-
346-	user, err := dbpool.FindUserForName(username)
347-	if err != nil {
348-		logger.Infof("blog not found: %s", username)
349-		http.Error(w, "blog not found", http.StatusNotFound)
350-		return
351-	}
352-
353-	blogName := GetBlogName(username)
354-	curl := shared.CreateURLFromRequest(cfg, r)
355-
356-	var data PostPageData
357-	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
358-	if err == nil {
359-		linkify := NewImgsLinkify(username)
360-		parsed, err := shared.ParseText(post.Text, linkify)
361-		if err != nil {
362-			logger.Error(err)
363-		}
364-		text := ""
365-		if parsed != nil {
366-			text = parsed.Html
367-		}
368-
369-		tagLinks := make([]Link, 0, len(post.Tags))
370-		for _, tag := range post.Tags {
371-			tagLinks = append(tagLinks, Link{
372-				URL:  template.URL(cfg.TagURL(curl, username, tag)),
373-				Text: tag,
374-			})
375-		}
376-
377-		data = PostPageData{
378-			Site:         *cfg.GetSiteData(),
379-			PageTitle:    GetPostTitle(post),
380-			URL:          template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)),
381-			BlogURL:      template.URL(cfg.FullBlogURL(curl, username)),
382-			Caption:      post.Description,
383-			Title:        post.Title,
384-			Slug:         post.Slug,
385-			PublishAt:    post.PublishAt.Format("02 Jan, 2006"),
386-			PublishAtISO: post.PublishAt.Format(time.RFC3339),
387-			Username:     username,
388-			BlogName:     blogName,
389-			Contents:     template.HTML(text),
390-			ImgURL:       template.URL(cfg.ImgURL(curl, username, post.Slug)),
391-			Tags:         tagLinks,
392-		}
393-	} else {
394-		data = PostPageData{
395-			Site:         *cfg.GetSiteData(),
396-			BlogURL:      template.URL(cfg.FullBlogURL(curl, username)),
397-			PageTitle:    "Post not found",
398-			Caption:      "Post not found",
399-			Title:        "Post not found",
400-			PublishAt:    time.Now().Format("02 Jan, 2006"),
401-			PublishAtISO: time.Now().Format(time.RFC3339),
402-			Username:     username,
403-			BlogName:     blogName,
404-		}
405-		logger.Infof("post not found %s/%s", username, slug)
406-	}
407-
408-	ts, err := shared.RenderTemplate(cfg, []string{
409-		cfg.StaticPath("html/post.page.tmpl"),
410-	})
411-
412-	if err != nil {
413-		http.Error(w, err.Error(), http.StatusInternalServerError)
414-	}
415-
416-	err = ts.Execute(w, data)
417-	if err != nil {
418-		logger.Error(err)
419-		http.Error(w, err.Error(), http.StatusInternalServerError)
420-	}
421-}
422-
423-func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
424-	username := shared.GetUsernameFromRequest(r)
425+func rssHandler(w http.ResponseWriter, r *http.Request) {
426 	dbpool := shared.GetDB(r)
427 	logger := shared.GetLogger(r)
428 	cfg := shared.GetCfg(r)
429 
430-	user, err := dbpool.FindUserForName(username)
431-	if err != nil {
432-		logger.Infof("rss feed not found: %s", username)
433-		http.Error(w, "rss feed not found", http.StatusNotFound)
434-		return
435-	}
436-
437-	tag := r.URL.Query().Get("tag")
438-	var posts []*db.Post
439-	var p *db.Paginate[*db.Post]
440-	pager := &db.Pager{Num: 10, Page: 0}
441-	if tag == "" {
442-		p, err = dbpool.FindPostsForUser(pager, user.ID, cfg.Space)
443-	} else {
444-		p, err = dbpool.FindUserPostsByTag(pager, tag, user.ID, cfg.Space)
445-	}
446-	posts = p.Data
447-
448+	pager, err := dbpool.FindAllPosts(&db.Pager{Num: 25, Page: 0}, Space)
449 	if err != nil {
450 		logger.Error(err)
451 		http.Error(w, err.Error(), http.StatusInternalServerError)
452@@ -458,34 +54,27 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
453 		return
454 	}
455 
456-	headerTxt := &HeaderTxt{
457-		Title: GetBlogName(username),
458-	}
459-
460-	curl := shared.CreateURLFromRequest(cfg, r)
461-
462 	feed := &feeds.Feed{
463-		Title:       headerTxt.Title,
464-		Link:        &feeds.Link{Href: cfg.FullBlogURL(curl, username)},
465-		Description: headerTxt.Bio,
466-		Author:      &feeds.Author{Name: username},
467+		Title:       fmt.Sprintf("%s imgs feed", cfg.Domain),
468+		Link:        &feeds.Link{Href: cfg.HomeURL()},
469+		Description: fmt.Sprintf("%s latest image", cfg.Domain),
470+		Author:      &feeds.Author{Name: cfg.Domain},
471 		Created:     time.Now(),
472 	}
473 
474+	curl := shared.CreateURLFromRequest(cfg, r)
475+
476 	var feedItems []*feeds.Item
477-	for _, post := range posts {
478-		if slices.Contains(cfg.HiddenPosts, post.Filename) {
479-			continue
480-		}
481+	for _, post := range pager.Data {
482 		var tpl bytes.Buffer
483 		data := &PostPageData{
484-			ImgURL: template.URL(cfg.ImgURL(curl, username, post.Slug)),
485+			ImgURL: template.URL(cfg.ImgURL(curl, post.Username, post.Filename)),
486 		}
487 		if err := ts.Execute(&tpl, data); err != nil {
488 			continue
489 		}
490 
491-		realUrl := cfg.FullPostURL(curl, post.Username, post.Slug)
492+		realUrl := cfg.FullPostURL(curl, post.Username, post.Filename)
493 		if !curl.Subdomain && !curl.UsernameInRoute {
494 			realUrl = fmt.Sprintf("%s://%s%s", cfg.Protocol, r.Host, realUrl)
495 		}
496@@ -494,10 +83,11 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
497 			Id:          realUrl,
498 			Title:       post.Title,
499 			Link:        &feeds.Link{Href: realUrl},
500-			Created:     *post.PublishAt,
501 			Content:     tpl.String(),
502+			Created:     *post.PublishAt,
503 			Updated:     *post.UpdatedAt,
504 			Description: post.Description,
505+			Author:      &feeds.Author{Name: post.Username},
506 		}
507 
508 		if post.Description != "" {
509@@ -521,98 +111,68 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
510 	}
511 }
512 
513-func rssHandler(w http.ResponseWriter, r *http.Request) {
514+func ImgRequest(w http.ResponseWriter, r *http.Request) {
515+	subdomain := shared.GetSubdomain(r)
516+	cfg := shared.GetCfg(r)
517 	dbpool := shared.GetDB(r)
518 	logger := shared.GetLogger(r)
519-	cfg := shared.GetCfg(r)
520+	username := shared.GetUsernameFromRequest(r)
521 
522-	pager, err := dbpool.FindAllPosts(&db.Pager{Num: 25, Page: 0}, cfg.Space)
523+	user, err := dbpool.FindUserForName(username)
524 	if err != nil {
525-		logger.Error(err)
526-		http.Error(w, err.Error(), http.StatusInternalServerError)
527+		logger.Infof("rss feed not found: %s", username)
528+		http.Error(w, "rss feed not found", http.StatusNotFound)
529 		return
530 	}
531 
532-	ts, err := template.ParseFiles(cfg.StaticPath("html/rss.page.tmpl"))
533-	if err != nil {
534-		logger.Error(err)
535-		http.Error(w, err.Error(), http.StatusInternalServerError)
536-		return
537+	var dimes string
538+	var slug string
539+	if !cfg.IsSubdomains() || subdomain == "" {
540+		slug, _ = url.PathUnescape(shared.GetField(r, 1))
541+		dimes, _ = url.PathUnescape(shared.GetField(r, 2))
542+	} else {
543+		slug, _ = url.PathUnescape(shared.GetField(r, 0))
544+		dimes, _ = url.PathUnescape(shared.GetField(r, 1))
545 	}
546 
547-	feed := &feeds.Feed{
548-		Title:       fmt.Sprintf("%s imgs feed", cfg.Domain),
549-		Link:        &feeds.Link{Href: cfg.ReadURL()},
550-		Description: fmt.Sprintf("%s latest image", cfg.Domain),
551-		Author:      &feeds.Author{Name: cfg.Domain},
552-		Created:     time.Now(),
553+	ratio, _ := storage.GetRatio(dimes)
554+	opts := &storage.ImgProcessOpts{
555+		Quality: 80,
556+		Ratio:   ratio,
557 	}
558 
559-	curl := shared.CreateURLFromRequest(cfg, r)
560-
561-	var feedItems []*feeds.Item
562-	for _, post := range pager.Data {
563-		var tpl bytes.Buffer
564-		data := &PostPageData{
565-			ImgURL: template.URL(cfg.ImgURL(curl, post.Username, post.Slug)),
566-		}
567-		if err := ts.Execute(&tpl, data); err != nil {
568-			continue
569-		}
570-
571-		realUrl := cfg.FullPostURL(curl, post.Username, post.Slug)
572-		if !curl.Subdomain && !curl.UsernameInRoute {
573-			realUrl = fmt.Sprintf("%s://%s%s", cfg.Protocol, r.Host, realUrl)
574-		}
575-
576-		item := &feeds.Item{
577-			Id:          realUrl,
578-			Title:       post.Title,
579-			Link:        &feeds.Link{Href: realUrl},
580-			Content:     tpl.String(),
581-			Created:     *post.PublishAt,
582-			Updated:     *post.UpdatedAt,
583-			Description: post.Description,
584-			Author:      &feeds.Author{Name: post.Username},
585-		}
586-
587-		if post.Description != "" {
588-			item.Description = post.Description
589+	ext := filepath.Ext(slug)
590+	// Files can contain periods.  `filepath.Ext` is greedy and will clip the last period in the slug
591+	// and call that a file extension so we want to be explicit about what
592+	// file extensions we clip here
593+	for _, fext := range cfg.AllowedExt {
594+		if ext == fext {
595+			// users might add the file extension when requesting an image
596+			// but we want to remove that
597+			slug = shared.SanitizeFileExt(slug)
598+			break
599 		}
600-
601-		feedItems = append(feedItems, item)
602 	}
603-	feed.Items = feedItems
604 
605-	rss, err := feed.ToAtom()
606+	post, err := FindImgPost(r, user, slug)
607 	if err != nil {
608-		logger.Fatal(err)
609-		http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
610+		errMsg := fmt.Sprintf("image not found %s/%s", user.Name, slug)
611+		logger.Infof(errMsg)
612+		http.Error(w, errMsg, http.StatusNotFound)
613+		return
614 	}
615 
616-	w.Header().Add("Content-Type", "application/atom+xml")
617-	_, err = w.Write([]byte(rss))
618-	if err != nil {
619-		logger.Error(err)
620-	}
621+	fname := post.Filename
622+	pgs.ServeAsset(fname, opts, true, w, r)
623 }
624 
625-func createStaticRoutes() []shared.Route {
626-	return []shared.Route{
627-		shared.NewRoute("GET", "/main.css", shared.ServeFile("main.css", "text/css")),
628-		shared.NewRoute("GET", "/imgs.css", shared.ServeFile("imgs.css", "text/css")),
629-		shared.NewRoute("GET", "/card.png", shared.ServeFile("card.png", "image/png")),
630-		shared.NewRoute("GET", "/favicon-16x16.png", shared.ServeFile("favicon-16x16.png", "image/png")),
631-		shared.NewRoute("GET", "/favicon-32x32.png", shared.ServeFile("favicon-32x32.png", "image/png")),
632-		shared.NewRoute("GET", "/apple-touch-icon.png", shared.ServeFile("apple-touch-icon.png", "image/png")),
633-		shared.NewRoute("GET", "/favicon.ico", shared.ServeFile("favicon.ico", "image/x-icon")),
634-		shared.NewRoute("GET", "/robots.txt", shared.ServeFile("robots.txt", "text/plain")),
635-	}
636+func FindImgPost(r *http.Request, user *db.User, slug string) (*db.Post, error) {
637+	dbpool := shared.GetDB(r)
638+	return dbpool.FindPostWithSlug(slug, user.ID, Space)
639 }
640 
641 func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
642 	routes := []shared.Route{
643-		shared.NewRoute("GET", "/", shared.CreatePageHandler("html/marketing.page.tmpl")),
644 		shared.NewRoute("GET", "/check", shared.CheckHandler),
645 	}
646 
647@@ -628,28 +188,16 @@ func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
648 		shared.NewRoute("GET", "/atom.xml", rssHandler),
649 		shared.NewRoute("GET", "/feed.xml", rssHandler),
650 
651-		shared.NewRoute("GET", "/([^/]+)", blogHandler),
652-		shared.NewRoute("GET", "/([^/]+)/rss", rssBlogHandler),
653-		shared.NewRoute("GET", "/([^/]+)/rss.xml", rssBlogHandler),
654-		shared.NewRoute("GET", "/([^/]+)/atom.xml", rssBlogHandler),
655-		shared.NewRoute("GET", "/([^/]+)/feed.xml", rssBlogHandler),
656-		shared.NewRoute("GET", "/([^/]+)/o/([^/]+)", imgRequestOriginal),
657-		shared.NewRoute("GET", "/([^/]+)/p/([^/]+)", postHandler),
658-		shared.NewRoute("GET", "/([^/]+)/([^/]+)", imgRequest),
659-		shared.NewRoute("GET", "/([^/]+)/([^/]+)/([a-z0-9]+)", imgRequest),
660+		shared.NewRoute("GET", "/([^/]+)/o/([^/]+)", ImgRequest),
661+		shared.NewRoute("GET", "/([^/]+)/([^/]+)", ImgRequest),
662+		shared.NewRoute("GET", "/([^/]+)/([^/]+)/([a-z0-9]+)", ImgRequest),
663 	)
664 
665 	return routes
666 }
667 
668 func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
669-	routes := []shared.Route{
670-		shared.NewRoute("GET", "/", blogHandler),
671-		shared.NewRoute("GET", "/rss", rssBlogHandler),
672-		shared.NewRoute("GET", "/rss.xml", rssBlogHandler),
673-		shared.NewRoute("GET", "/atom.xml", rssBlogHandler),
674-		shared.NewRoute("GET", "/feed.xml", rssBlogHandler),
675-	}
676+	routes := []shared.Route{}
677 
678 	routes = append(
679 		routes,
680@@ -658,10 +206,9 @@ func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
681 
682 	routes = append(
683 		routes,
684-		shared.NewRoute("GET", "/o/([^/]+)", imgRequestOriginal),
685-		shared.NewRoute("GET", "/p/([^/]+)", postHandler),
686-		shared.NewRoute("GET", "/([^/]+)", imgRequest),
687-		shared.NewRoute("GET", "/([^/]+)/([a-z0-9]+)", imgRequest),
688+		shared.NewRoute("GET", "/o/([^/]+)", ImgRequest),
689+		shared.NewRoute("GET", "/([^/]+)", ImgRequest),
690+		shared.NewRoute("GET", "/([^/]+)/([a-z0-9]+)", ImgRequest),
691 	)
692 
693 	return routes
694@@ -691,8 +238,7 @@ func StartApiServer() {
695 		logger.Fatal(err)
696 	}
697 
698-	staticRoutes := createStaticRoutes()
699-
700+	staticRoutes := []shared.Route{}
701 	if cfg.Debug {
702 		staticRoutes = shared.CreatePProfRoutes(staticRoutes)
703 	}
D imgs/client.go
+0, -10
 1@@ -1,10 +0,0 @@
 2-package imgs
 3-
 4-import (
 5-	"github.com/picosh/send/send/utils"
 6-)
 7-
 8-type IImgsAPI interface {
 9-	HasAccess(userID string) bool
10-	Upload(file *utils.FileEntry) (string, error)
11-}
M imgs/config.go
+0, -24
 1@@ -5,28 +5,6 @@ import (
 2 	"github.com/picosh/pico/wish/cms/config"
 3 )
 4 
 5-type ImgsLinkify struct {
 6-	Cfg          *shared.ConfigSite
 7-	Username     string
 8-	OnSubdomain  bool
 9-	WithUsername bool
10-}
11-
12-func NewImgsLinkify(username string) *ImgsLinkify {
13-	cfg := NewConfigSite()
14-	return &ImgsLinkify{
15-		Cfg:      cfg,
16-		Username: username,
17-	}
18-}
19-
20-func (i *ImgsLinkify) Create(fname string) string {
21-	return i.Cfg.ImgFullURL(i.Username, fname)
22-}
23-
24-var maxSize = uint64(500 * shared.MB)
25-var maxImgSize = int64(10 * shared.MB)
26-
27 func NewConfigSite() *shared.ConfigSite {
28 	debug := shared.GetEnv("IMGS_DEBUG", "0")
29 	domain := shared.GetEnv("IMGS_DOMAIN", "prose.sh")
30@@ -67,8 +45,6 @@ func NewConfigSite() *shared.ConfigSite {
31 			AllowedExt:    []string{".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"},
32 			Logger:        shared.CreateLogger(debug == "1"),
33 			AllowRegister: allowRegister == "1",
34-			MaxSize:       maxSize,
35-			MaxAssetSize:  maxImgSize,
36 		},
37 	}
38 
D imgs/html/base.layout.tmpl
+0, -20
 1@@ -1,20 +0,0 @@
 2-{{define "base"}}
 3-<!doctype html>
 4-<html lang="en">
 5-    <head>
 6-        <meta charset='utf-8'>
 7-        <meta name="viewport" content="width=device-width, initial-scale=1" />
 8-        <title>{{template "title" .}}</title>
 9-
10-        <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
11-
12-        <meta name="keywords" content="image, images, hosting" />
13-
14-        <link rel="stylesheet" href="https://pico.sh/smol.css" />
15-        <link rel="stylesheet" href="/main.css" />
16-
17-        {{template "meta" .}}
18-    </head>
19-    <body {{template "attrs" .}}>{{template "body" .}}</body>
20-</html>
21-{{end}}
D imgs/html/blog.page.tmpl
+0, -66
 1@@ -1,66 +0,0 @@
 2-{{template "base" .}}
 3-
 4-{{define "title"}}{{.PageTitle}}{{end}}
 5-
 6-{{define "meta"}}
 7-<meta name="description" content="{{if .Header.Bio}}{{.Header.Bio}}{{else}}{{.Header.Title}}{{end}}" />
 8-
 9-<meta property="og:type" content="website">
10-<meta property="og:site_name" content="{{.Site.Domain}}">
11-<meta property="og:url" content="{{.URL}}">
12-<meta property="og:title" content="{{.Header.Title}}">
13-{{if .Header.Bio}}<meta property="og:description" content="{{.Header.Bio}}">{{end}}
14-<meta property="og:image:width" content="300" />
15-<meta property="og:image:height" content="300" />
16-<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
17-<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
18-
19-<meta property="twitter:card" content="summary">
20-<meta property="twitter:url" content="{{.URL}}">
21-<meta property="twitter:title" content="{{.Header.Title}}">
22-{{if .Header.Bio}}<meta property="twitter:description" content="{{.Header.Bio}}">{{end}}
23-<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
24-<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
25-
26-<link rel="alternate" href="{{.RSSURL}}" type="application/rss+xml" title="RSS feed for {{.Header.Title}}" />
27-{{end}}
28-
29-{{define "attrs"}}id="blog"{{end}}
30-
31-{{define "body"}}
32-<header class="text-center">
33-    <h1 class="text-2xl font-bold">{{.Header.Title}}</h1>
34-    {{if .Header.Bio}}<p class="text-lg">{{.Header.Bio}}</p>{{end}}
35-    <nav>
36-        {{range .Header.Nav}}
37-        <a href="{{.URL}}" class="text-lg">{{.Text}}</a> |
38-        {{end}}
39-        <a href="{{.RSSURL}}" class="text-lg">rss</a>
40-    </nav>
41-    <hr />
42-</header>
43-<main>
44-    {{if .Readme.HasText}}
45-    <section>
46-        <article class="md">
47-            {{.Readme.Contents}}
48-        </article>
49-        <hr />
50-    </section>
51-    {{end}}
52-
53-    {{if .HasFilter}}
54-        <a href={{.URL}}>clear filters</a>
55-    {{end}}
56-    <section class="albums">
57-        {{range .Posts}}
58-        <article  class="thumbnail-container">
59-            <a href="{{.URL}}" class="thumbnail-link">
60-                <img class="thumbnail" src="{{.ImgURL}}" alt="{{.Caption}}" />
61-            </a>
62-        </article>
63-        {{end}}
64-    </section>
65-</main>
66-{{template "footer" .}}
67-{{end}}
D imgs/html/footer.partial.tmpl
+0, -6
1@@ -1,6 +0,0 @@
2-{{define "footer"}}
3-<footer>
4-    <hr />
5-    published with <a href={{.Site.HomeURL}}>{{.Site.Domain}}</a>
6-</footer>
7-{{end}}
D imgs/html/marketing-footer.partial.tmpl
+0, -9
 1@@ -1,9 +0,0 @@
 2-{{define "marketing-footer"}}
 3-<footer>
 4-    <hr />
 5-    <p class="font-italic">Built and maintained by <a href="https://pico.sh">pico.sh</a>.</p>
 6-    <div>
 7-        <a href="/rss">rss</a>
 8-    </div>
 9-</footer>
10-{{end}}
D imgs/html/marketing.page.tmpl
+0, -39
 1@@ -1,39 +0,0 @@
 2-{{template "base" .}}
 3-
 4-{{define "title"}}{{.Site.Domain}} -- image hosting for hackers{{end}}
 5-
 6-{{define "meta"}}
 7-<meta name="description" content="image hosting for hackers" />
 8-
 9-<meta property="og:type" content="website">
10-<meta property="og:site_name" content="{{.Site.Domain}}">
11-<meta property="og:url" content="https://{{.Site.Domain}}">
12-<meta property="og:title" content="{{.Site.Domain}}">
13-<meta property="og:description" content="image hosting for hackers">
14-
15-<meta name="twitter:card" content="summary" />
16-<meta property="twitter:url" content="https://{{.Site.Domain}}">
17-<meta property="twitter:title" content="{{.Site.Domain}}">
18-<meta property="twitter:description" content="image hosting for hackers">
19-<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
20-<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
21-
22-<meta property="og:image:width" content="300" />
23-<meta property="og:image:height" content="300" />
24-<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
25-<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
26-{{end}}
27-
28-{{define "attrs"}}{{end}}
29-
30-{{define "body"}}
31-<header class="text-center">
32-    <h1 class="text-2xl font-bold">{{.Site.Domain}}</h1>
33-    <p class="text-lg">image hosting for hackers</p>
34-    <div>
35-      <a href="https://pico.sh/getting-started" class="btn-link mt inline-block">GET STARTED</a>
36-    </div>
37-</header>
38-
39-{{template "marketing-footer" .}}
40-{{end}}
D imgs/html/post.page.tmpl
+0, -70
 1@@ -1,70 +0,0 @@
 2-{{template "base" .}}
 3-
 4-{{define "title"}}{{.PageTitle}}{{end}}
 5-
 6-{{define "meta"}}
 7-<meta name="description" content="{{.Caption}}" />
 8-
 9-<meta property="og:type" content="website">
10-<meta property="og:site_name" content="{{.Site.Domain}}">
11-<meta property="og:url" content="{{.URL}}">
12-<meta property="og:title" content="{{.PageTitle}}">
13-{{if .Caption}}<meta property="og:description" content="{{.Caption}}">{{end}}
14-<meta property="og:image:width" content="300" />
15-<meta property="og:image:height" content="300" />
16-<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
17-<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
18-
19-<meta property="twitter:card" content="summary">
20-<meta property="twitter:url" content="{{.URL}}">
21-<meta property="twitter:title" content="{{.PageTitle}}">
22-{{if .Caption}}<meta property="twitter:description" content="{{.Caption}}">{{end}}
23-<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
24-<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
25-{{end}}
26-
27-{{define "attrs"}}id="post" class="{{.Slug}}"{{end}}
28-
29-{{define "body"}}
30-<header>
31-    {{if .Title}}<h1 class="text-2xl font-bold">{{.Title}}</h1>{{end}}
32-    <p class="font-bold m-0">
33-        <time datetime="{{.PublishAtISO}}">{{.PublishAt}}</time>
34-    </p>
35-    <div class="tags">
36-    <a href="{{.BlogURL}}">&lt; {{.BlogName}}</a>
37-    {{range .Tags}}
38-        <a class="tag" href="{{.URL}}">#{{.Text}}</a>
39-    {{end}}
40-    </div>
41-</header>
42-<main>
43-    <article>
44-        <figure class="img">
45-            <a href="{{.ImgURL}}">
46-                <img src="{{.ImgURL}}" alt="" />
47-            </a>
48-            {{if .Caption}}<figcaption class="my font-italic">{{.Caption}}</figcaption>{{end}}
49-        </figure>
50-
51-        <div class="md">{{.Contents}}</div>
52-
53-        {{if .ImgURL}}
54-        <dl>
55-            <dt>Hotlink</dt>
56-            <dd><a href="{{.ImgURL}}">{{.ImgURL}}</a></dd>
57-
58-            <dt>Resize width (preserve aspect)</dt>
59-            <dd><a href="{{.ImgURL}}/300x">{{.ImgURL}}/300x</a></dd>
60-
61-            <dt>Resize height (preserve aspect)</dt>
62-            <dd><a href="{{.ImgURL}}/x300">{{.ImgURL}}/x300</a></dd>
63-
64-            <dt>Resize width and height</dt>
65-            <dd><a href="{{.ImgURL}}/300x300">{{.ImgURL}}/300x300</a></dd>
66-        </dl>
67-        {{end}}
68-    </article>
69-</main>
70-{{template "footer" .}}
71-{{end}}
A imgs/public/.gitkeep
+0, -0
D imgs/public/apple-touch-icon.png
+0, -0
D imgs/public/card.png
+0, -0
D imgs/public/favicon-16x16.png
+0, -0
D imgs/public/favicon.ico
+0, -0
D imgs/public/main.css
+0, -84
 1@@ -1,84 +0,0 @@
 2-body {
 3-  max-width: 52rem;
 4-}
 5-
 6-img {
 7-  max-width: 100%;
 8-  max-height: 90vh;
 9-}
10-
11-.albums {
12-  width: 100%;
13-  display: grid;
14-  grid-template-columns: repeat(3, 1fr);
15-  grid-template-rows: repeat(auto-fill, 300px);
16-  grid-row-gap: 0.5rem;
17-  grid-column-gap: 1rem;
18-}
19-
20-.thumbnail-container {
21-  position: relative;
22-}
23-
24-.thumbnail-container a, .thumbnail-container a:visited, .thumbnail-container a:hover {
25-  color: var(--white);
26-}
27-
28-.tag-text {
29-  position: absolute;
30-  bottom: 20px;
31-  z-index: 1;
32-  text-align: center;
33-  width: 100%;
34-}
35-
36-.thumbnail {
37-  z-index: 1;
38-  object-fit: contain;
39-  width: 300px;
40-  height: 300px;
41-  background-color: #000;
42-}
43-
44-.thumbnail-link {
45-  z-index: 1;
46-}
47-
48-.md h1 {
49-  font-size: 1.85rem;
50-  line-height: 1.15;
51-  font-weight: bold;
52-  padding: 0.6rem 0 0 0;
53-}
54-
55-.md h2 {
56-  font-size: 1.45rem;
57-  line-height: 1.15;
58-  font-weight: bold;
59-  padding: 0.6rem 0 0 0;
60-}
61-
62-.md h3 {
63-  font-size: 1.25rem;
64-  font-weight: bold;
65-  padding: 0.6rem 0 0 0;
66-}
67-
68-.md h4 {
69-  font-size: 1rem;
70-  font-weight: bold;
71-  padding: 0.6rem 0 0 0;
72-}
73-
74-@media only screen and (max-width: 900px) {
75-  .albums {
76-    grid-template-columns: repeat(1, 1fr);
77-    justify-content: center;
78-  }
79-
80-  .albums article {
81-    display: flex;
82-    flex-direction: column;
83-    align-items: center;
84-  }
85-}
D imgs/public/robots.txt
+0, -2
1@@ -1,2 +0,0 @@
2-User-agent: *
3-Allow: /
M lists/api.go
+8, -15
 1@@ -16,7 +16,6 @@ import (
 2 	gocache "github.com/patrickmn/go-cache"
 3 	"github.com/picosh/pico/db"
 4 	"github.com/picosh/pico/db/postgres"
 5-	"github.com/picosh/pico/imgs"
 6 	"github.com/picosh/pico/shared"
 7 	"github.com/picosh/pico/shared/storage"
 8 )
 9@@ -161,8 +160,7 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
10 	}
11 	header, err := dbpool.FindPostWithFilename("_header.txt", user.ID, cfg.Space)
12 	if err == nil {
13-		linkify := imgs.NewImgsLinkify(username)
14-		parsedText := shared.ListParseText(header.Text, linkify)
15+		parsedText := shared.ListParseText(header.Text)
16 		if parsedText.Title != "" {
17 			headerTxt.Title = parsedText.Title
18 		}
19@@ -184,8 +182,7 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
20 	readmeTxt := &ReadmeTxt{}
21 	readme, err := dbpool.FindPostWithFilename("_readme.txt", user.ID, cfg.Space)
22 	if err == nil {
23-		linkify := imgs.NewImgsLinkify(username)
24-		parsedText := shared.ListParseText(readme.Text, linkify)
25+		parsedText := shared.ListParseText(readme.Text)
26 		readmeTxt.Items = parsedText.Items
27 		readmeTxt.ListType = parsedText.ListType
28 		if len(readmeTxt.Items) > 0 {
29@@ -300,9 +297,8 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
30 
31 	header, _ := dbpool.FindPostWithFilename("_header.txt", user.ID, cfg.Space)
32 	blogName := GetBlogName(username)
33-	linkify := imgs.NewImgsLinkify(username)
34 	if header != nil {
35-		headerParsed := shared.ListParseText(header.Text, linkify)
36+		headerParsed := shared.ListParseText(header.Text)
37 		if headerParsed.Title != "" {
38 			blogName = headerParsed.Title
39 		}
40@@ -311,12 +307,12 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
41 	var data PostPageData
42 	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
43 	if err == nil {
44-		parsedText := shared.ListParseText(post.Text, linkify)
45+		parsedText := shared.ListParseText(post.Text)
46 
47 		// we need the blog name from the readme unfortunately
48 		readme, err := dbpool.FindPostWithFilename("_readme.txt", user.ID, cfg.Space)
49 		if err == nil {
50-			readmeParsed := shared.ListParseText(readme.Text, linkify)
51+			readmeParsed := shared.ListParseText(readme.Text)
52 			if readmeParsed.Title != "" {
53 				blogName = readmeParsed.Title
54 			}
55@@ -498,8 +494,7 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
56 	}
57 	header, err := dbpool.FindPostWithFilename("_header.txt", user.ID, cfg.Space)
58 	if err == nil {
59-		linkify := imgs.NewImgsLinkify(username)
60-		parsedText := shared.ListParseText(header.Text, linkify)
61+		parsedText := shared.ListParseText(header.Text)
62 		if parsedText.Title != "" {
63 			headerTxt.Title = parsedText.Title
64 		}
65@@ -522,8 +517,7 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
66 		if slices.Contains(cfg.HiddenPosts, post.Filename) {
67 			continue
68 		}
69-		linkify := imgs.NewImgsLinkify(username)
70-		parsed := shared.ListParseText(post.Text, linkify)
71+		parsed := shared.ListParseText(post.Text)
72 		var tpl bytes.Buffer
73 		data := &PostPageData{
74 			ListType: parsed.ListType,
75@@ -597,8 +591,7 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
76 
77 	var feedItems []*feeds.Item
78 	for _, post := range pager.Data {
79-		linkify := imgs.NewImgsLinkify(post.Username)
80-		parsed := shared.ListParseText(post.Text, linkify)
81+		parsed := shared.ListParseText(post.Text)
82 		var tpl bytes.Buffer
83 		data := &PostPageData{
84 			ListType: parsed.ListType,
M lists/scp_hooks.go
+1, -3
 1@@ -9,7 +9,6 @@ import (
 2 	"github.com/charmbracelet/ssh"
 3 	"github.com/picosh/pico/db"
 4 	"github.com/picosh/pico/filehandlers"
 5-	"github.com/picosh/pico/imgs"
 6 	"github.com/picosh/pico/shared"
 7 )
 8 
 9@@ -41,8 +40,7 @@ func (p *ListHooks) FileValidate(s ssh.Session, data *filehandlers.PostMetaData)
10 }
11 
12 func (p *ListHooks) FileMeta(s ssh.Session, data *filehandlers.PostMetaData) error {
13-	linkify := imgs.NewImgsLinkify(data.Username)
14-	parsedText := shared.ListParseText(string(data.Text), linkify)
15+	parsedText := shared.ListParseText(string(data.Text))
16 
17 	if parsedText.Title == "" {
18 		data.Title = shared.ToUpper(data.Slug)
M lists/ssh.go
+6, -3
 1@@ -33,7 +33,7 @@ func (me *SSHServer) authHandler(ctx ssh.Context, key ssh.PublicKey) bool {
 2 	return true
 3 }
 4 
 5-func createRouter(handler *filehandlers.ScpUploadHandler) proxy.Router {
 6+func createRouter(handler *filehandlers.FileHandlerRouter) proxy.Router {
 7 	return func(sh ssh.Handler, s ssh.Session) []wish.Middleware {
 8 		return []wish.Middleware{
 9 			pipe.Middleware(handler, ".txt"),
10@@ -47,7 +47,7 @@ func createRouter(handler *filehandlers.ScpUploadHandler) proxy.Router {
11 	}
12 }
13 
14-func withProxy(handler *filehandlers.ScpUploadHandler, otherMiddleware ...wish.Middleware) ssh.Option {
15+func withProxy(handler *filehandlers.FileHandlerRouter, otherMiddleware ...wish.Middleware) ssh.Option {
16 	return func(server *ssh.Server) error {
17 		err := sftp.SSHOption(handler)(server)
18 		if err != nil {
19@@ -84,7 +84,10 @@ func StartSshServer() {
20 		logger.Fatal(err)
21 	}
22 
23-	handler := filehandlers.NewScpPostHandler(dbh, cfg, hooks, st)
24+	fileMap := map[string]filehandlers.ReadWriteHandler{
25+		"fallback": filehandlers.NewScpPostHandler(dbh, cfg, hooks, st),
26+	}
27+	handler := filehandlers.NewFileHandlerRouter(cfg, dbh, fileMap)
28 
29 	sshServer := &SSHServer{}
30 	s, err := wish.NewServer(
R pastes/cms.go => pastes/ssh.go
+6, -3
 1@@ -33,7 +33,7 @@ func (me *SSHServer) authHandler(ctx ssh.Context, key ssh.PublicKey) bool {
 2 	return true
 3 }
 4 
 5-func createRouter(handler *filehandlers.ScpUploadHandler) proxy.Router {
 6+func createRouter(handler *filehandlers.FileHandlerRouter) proxy.Router {
 7 	return func(sh ssh.Handler, s ssh.Session) []wish.Middleware {
 8 		return []wish.Middleware{
 9 			pipe.Middleware(handler, ""),
10@@ -47,7 +47,7 @@ func createRouter(handler *filehandlers.ScpUploadHandler) proxy.Router {
11 	}
12 }
13 
14-func withProxy(handler *filehandlers.ScpUploadHandler, otherMiddleware ...wish.Middleware) ssh.Option {
15+func withProxy(handler *filehandlers.FileHandlerRouter, otherMiddleware ...wish.Middleware) ssh.Option {
16 	return func(server *ssh.Server) error {
17 		err := sftp.SSHOption(handler)(server)
18 		if err != nil {
19@@ -83,7 +83,10 @@ func StartSshServer() {
20 		logger.Fatal(err)
21 	}
22 
23-	handler := filehandlers.NewScpPostHandler(dbh, cfg, hooks, st)
24+	fileMap := map[string]filehandlers.ReadWriteHandler{
25+		"fallback": filehandlers.NewScpPostHandler(dbh, cfg, hooks, st),
26+	}
27+	handler := filehandlers.NewFileHandlerRouter(cfg, dbh, fileMap)
28 
29 	sshServer := &SSHServer{}
30 	s, err := wish.NewServer(
M pgs/api.go
+72, -45
  1@@ -24,16 +24,18 @@ import (
  2 )
  3 
  4 type AssetHandler struct {
  5-	Username   string
  6-	Subdomain  string
  7-	Filepath   string
  8-	ProjectDir string
  9-	Cfg        *shared.ConfigSite
 10-	Dbpool     db.DB
 11-	Storage    storage.ObjectStorage
 12-	Logger     *zap.SugaredLogger
 13-	Cache      *gocache.Cache
 14-	UserID     string
 15+	Username       string
 16+	Subdomain      string
 17+	Filepath       string
 18+	ProjectDir     string
 19+	Cfg            *shared.ConfigSite
 20+	Dbpool         db.DB
 21+	Storage        storage.ObjectStorage
 22+	Logger         *zap.SugaredLogger
 23+	Cache          *gocache.Cache
 24+	UserID         string
 25+	Bucket         storage.Bucket
 26+	ImgProcessOpts *storage.ImgProcessOpts
 27 }
 28 
 29 func checkHandler(w http.ResponseWriter, r *http.Request) {
 30@@ -216,16 +218,9 @@ func calcPossibleRoutes(projectName, fp string, userRedirects []*RedirectRule) [
 31 	return rts
 32 }
 33 
 34-func assetHandler(w http.ResponseWriter, h *AssetHandler) {
 35-	bucket, err := h.Storage.GetBucket(shared.GetAssetBucketName(h.UserID))
 36-	if err != nil {
 37-		h.Logger.Infof("bucket not found for %s", h.Username)
 38-		http.Error(w, "bucket not found", http.StatusNotFound)
 39-		return
 40-	}
 41-
 42+func (h *AssetHandler) handle(w http.ResponseWriter) {
 43 	var redirects []*RedirectRule
 44-	redirectFp, _, _, err := h.Storage.GetFile(bucket, filepath.Join(h.ProjectDir, "_redirects"))
 45+	redirectFp, _, _, err := h.Storage.GetFile(h.Bucket, filepath.Join(h.ProjectDir, "_redirects"))
 46 	if err == nil {
 47 		defer redirectFp.Close()
 48 		buf := new(strings.Builder)
 49@@ -243,13 +238,24 @@ func assetHandler(w http.ResponseWriter, h *AssetHandler) {
 50 	}
 51 
 52 	routes := calcPossibleRoutes(h.ProjectDir, h.Filepath, redirects)
 53-	var contents utils.ReaderAtCloser
 54+	var contents io.ReadCloser
 55 	assetFilepath := ""
 56 	status := 200
 57 	attempts := []string{}
 58 	for _, fp := range routes {
 59 		attempts = append(attempts, fp.Filepath)
 60-		c, _, _, err := h.Storage.GetFile(bucket, fp.Filepath)
 61+		mimeType := storage.GetMimeType(fp.Filepath)
 62+		var c io.ReadCloser
 63+		var err error
 64+		if strings.HasPrefix(mimeType, "image/") {
 65+			c, _, err = h.Storage.ServeFile(
 66+				h.Bucket,
 67+				fp.Filepath,
 68+				h.ImgProcessOpts,
 69+			)
 70+		} else {
 71+			c, _, _, err = h.Storage.GetFile(h.Bucket, fp.Filepath)
 72+		}
 73 		if err == nil {
 74 			contents = c
 75 			assetFilepath = fp.Filepath
 76@@ -261,7 +267,7 @@ func assetHandler(w http.ResponseWriter, h *AssetHandler) {
 77 	if assetFilepath == "" {
 78 		h.Logger.Infof(
 79 			"asset not found in bucket: bucket:[%s], routes:[%s]",
 80-			bucket.Name,
 81+			h.Bucket.Name,
 82 			strings.Join(attempts, ", "),
 83 		)
 84 		http.Error(w, "404 not found", http.StatusNotFound)
 85@@ -298,14 +304,14 @@ func getProjectFromSubdomain(subdomain string) (*SubdomainProps, error) {
 86 	return props, nil
 87 }
 88 
 89-func serveAsset(subdomain string, w http.ResponseWriter, r *http.Request) {
 90+func ServeAsset(fname string, opts *storage.ImgProcessOpts, fromImgs bool, w http.ResponseWriter, r *http.Request) {
 91+	subdomain := shared.GetSubdomain(r)
 92 	cfg := shared.GetCfg(r)
 93 	dbpool := shared.GetDB(r)
 94 	st := shared.GetStorage(r)
 95 	logger := shared.GetLogger(r)
 96 	cache := shared.GetCache(r)
 97 
 98-	floc, _ := url.PathUnescape(shared.GetField(r, 0))
 99 	props, err := getProjectFromSubdomain(subdomain)
100 	if err != nil {
101 		logger.Info(err)
102@@ -319,29 +325,50 @@ func serveAsset(subdomain string, w http.ResponseWriter, r *http.Request) {
103 		http.Error(w, "user not found", http.StatusNotFound)
104 		return
105 	}
106-	projectDir := props.ProjectName
107-	project, err := dbpool.FindProjectByName(user.ID, props.ProjectName)
108-	if err == nil {
109-		projectDir = project.ProjectDir
110+
111+	// TODO: this could probably be cleaned up more
112+	// imgs wont have a project directory
113+	projectDir := ""
114+	var bucket storage.Bucket
115+	// imgs has a different bucket directory
116+	if fromImgs {
117+		bucket, err = st.GetBucket(shared.GetImgsBucketName(user.ID))
118+	} else {
119+		bucket, err = st.GetBucket(shared.GetAssetBucketName(user.ID))
120+		projectDir = props.ProjectName
121+		project, err := dbpool.FindProjectByName(user.ID, props.ProjectName)
122+		if err == nil {
123+			projectDir = project.ProjectDir
124+		}
125 	}
126 
127-	assetHandler(w, &AssetHandler{
128-		Username:   props.Username,
129-		UserID:     user.ID,
130-		Subdomain:  subdomain,
131-		ProjectDir: projectDir,
132-		Filepath:   floc,
133-		Cfg:        cfg,
134-		Dbpool:     dbpool,
135-		Storage:    st,
136-		Logger:     logger,
137-		Cache:      cache,
138-	})
139+	if err != nil {
140+		logger.Infof("bucket not found for %s", props.Username)
141+		http.Error(w, "bucket not found", http.StatusNotFound)
142+		return
143+	}
144+
145+	asset := &AssetHandler{
146+		Username:       props.Username,
147+		UserID:         user.ID,
148+		Subdomain:      subdomain,
149+		ProjectDir:     projectDir,
150+		Filepath:       fname,
151+		Cfg:            cfg,
152+		Dbpool:         dbpool,
153+		Storage:        st,
154+		Logger:         logger,
155+		Cache:          cache,
156+		Bucket:         bucket,
157+		ImgProcessOpts: opts,
158+	}
159+
160+	asset.handle(w)
161 }
162 
163-func assetRequest(w http.ResponseWriter, r *http.Request) {
164-	subdomain := shared.GetSubdomain(r)
165-	serveAsset(subdomain, w, r)
166+func AssetRequest(w http.ResponseWriter, r *http.Request) {
167+	fname, _ := url.PathUnescape(shared.GetField(r, 0))
168+	ServeAsset(fname, nil, false, w, r)
169 }
170 
171 func StartApiServer() {
172@@ -382,8 +409,8 @@ func StartApiServer() {
173 		shared.NewRoute("GET", "/(.+)", shared.CreatePageHandler("html/marketing.page.tmpl")),
174 	}
175 	subdomainRoutes := []shared.Route{
176-		shared.NewRoute("GET", "/", assetRequest),
177-		shared.NewRoute("GET", "/(.+)", assetRequest),
178+		shared.NewRoute("GET", "/", AssetRequest),
179+		shared.NewRoute("GET", "/(.+)", AssetRequest),
180 	}
181 
182 	handler := shared.CreateServe(mainRoutes, subdomainRoutes, cfg, db, st, logger, cache)
M prose/api.go
+20, -13
  1@@ -205,8 +205,7 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
  2 
  3 	readme, err := dbpool.FindPostWithFilename("_readme.md", user.ID, cfg.Space)
  4 	if err == nil {
  5-		linkify := imgs.NewImgsLinkify(readme.Username)
  6-		parsedText, err := shared.ParseText(readme.Text, linkify)
  7+		parsedText, err := shared.ParseText(readme.Text)
  8 		if err != nil {
  9 			logger.Error(err)
 10 		}
 11@@ -354,9 +353,8 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 12 	hasCSS := false
 13 	var data PostPageData
 14 	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
 15-	linkify := imgs.NewImgsLinkify(username)
 16 	if err == nil {
 17-		parsedText, err := shared.ParseText(post.Text, linkify)
 18+		parsedText, err := shared.ParseText(post.Text)
 19 		if err != nil {
 20 			logger.Error(err)
 21 		}
 22@@ -364,7 +362,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 23 		// we need the blog name from the readme unfortunately
 24 		readme, err := dbpool.FindPostWithFilename("_readme.md", user.ID, cfg.Space)
 25 		if err == nil {
 26-			readmeParsed, err := shared.ParseText(readme.Text, linkify)
 27+			readmeParsed, err := shared.ParseText(readme.Text)
 28 			if err != nil {
 29 				logger.Error(err)
 30 			}
 31@@ -394,7 +392,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 32 		footer, err := dbpool.FindPostWithFilename("_footer.md", user.ID, cfg.Space)
 33 		var footerHTML template.HTML
 34 		if err == nil {
 35-			footerParsed, err := shared.ParseText(footer.Text, linkify)
 36+			footerParsed, err := shared.ParseText(footer.Text)
 37 			if err != nil {
 38 				logger.Error(err)
 39 			}
 40@@ -437,6 +435,14 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 41 			Unlisted:     unlisted,
 42 		}
 43 	} else {
 44+		// TODO: HACK to support imgs slugs inside prose
 45+		// We definitely want to kill this feature in time
 46+		imgPost, err := imgs.FindImgPost(r, user, slug)
 47+		if err == nil && imgPost != nil {
 48+			imgs.ImgRequest(w, r)
 49+			return
 50+		}
 51+
 52 		data = PostPageData{
 53 			Site:         *cfg.GetSiteData(),
 54 			BlogURL:      template.URL(cfg.FullBlogURL(curl, username)),
 55@@ -593,8 +599,7 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
 56 
 57 	readme, err := dbpool.FindPostWithFilename("_readme.md", user.ID, cfg.Space)
 58 	if err == nil {
 59-		linkify := imgs.NewImgsLinkify(readme.Username)
 60-		parsedText, err := shared.ParseText(readme.Text, linkify)
 61+		parsedText, err := shared.ParseText(readme.Text)
 62 		if err != nil {
 63 			logger.Error(err)
 64 		}
 65@@ -624,8 +629,7 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
 66 		if slices.Contains(cfg.HiddenPosts, post.Filename) {
 67 			continue
 68 		}
 69-		linkify := imgs.NewImgsLinkify(post.Username)
 70-		parsed, err := shared.ParseText(post.Text, linkify)
 71+		parsed, err := shared.ParseText(post.Text)
 72 		if err != nil {
 73 			logger.Error(err)
 74 		}
 75@@ -633,7 +637,7 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
 76 		footer, err := dbpool.FindPostWithFilename("_footer.md", user.ID, cfg.Space)
 77 		var footerHTML string
 78 		if err == nil {
 79-			footerParsed, err := shared.ParseText(footer.Text, linkify)
 80+			footerParsed, err := shared.ParseText(footer.Text)
 81 			if err != nil {
 82 				logger.Error(err)
 83 			}
 84@@ -711,8 +715,7 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
 85 
 86 	var feedItems []*feeds.Item
 87 	for _, post := range pager.Data {
 88-		linkify := imgs.NewImgsLinkify(post.Username)
 89-		parsed, err := shared.ParseText(post.Text, linkify)
 90+		parsed, err := shared.ParseText(post.Text)
 91 		if err != nil {
 92 			logger.Error(err)
 93 		}
 94@@ -824,6 +827,8 @@ func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
 95 		shared.NewRoute("GET", "/([^/]+)/feed.xml", rssBlogHandler),
 96 		shared.NewRoute("GET", "/([^/]+)/_styles.css", blogStyleHandler),
 97 		shared.NewRoute("GET", "/raw/([^/]+)/(.+)", postRawHandler),
 98+		shared.NewRoute("GET", "/([^/]+)/(.+)/([a-z0-9]+)", imgs.ImgRequest),
 99+		shared.NewRoute("GET", "/([^/]+)/(.+).(jpg|jpeg|png|gif|webp|svg)", imgs.ImgRequest),
100 		shared.NewRoute("GET", "/([^/]+)/(.+)", postHandler),
101 	)
102 
103@@ -850,6 +855,8 @@ func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
104 	routes = append(
105 		routes,
106 		shared.NewRoute("GET", "/raw/(.+)", postRawHandler),
107+		shared.NewRoute("GET", "/(.+)/([a-z0-9]+)", imgs.ImgRequest),
108+		shared.NewRoute("GET", "/(.+).(jpg|jpeg|png|gif|webp|svg)", imgs.ImgRequest),
109 		shared.NewRoute("GET", "/(.+)", postHandler),
110 	)
111 
M prose/config.go
+25, -13
 1@@ -20,6 +20,8 @@ func NewConfigSite() *shared.ConfigSite {
 2 	minioPass := shared.GetEnv("MINIO_ROOT_PASSWORD", "")
 3 	dbURL := shared.GetEnv("DATABASE_URL", "")
 4 	useImgProxy := shared.GetEnv("USE_IMGPROXY", "1")
 5+	maxSize := uint64(500 * shared.MB)
 6+	maxImgSize := int64(10 * shared.MB)
 7 
 8 	intro := "To get started, enter a username.\n"
 9 	intro += "To learn next steps go to our docs at https://pico.sh/prose\n"
10@@ -30,22 +32,32 @@ func NewConfigSite() *shared.ConfigSite {
11 		CustomdomainsEnabled: customdomains == "1",
12 		UseImgProxy:          useImgProxy == "1",
13 		ConfigCms: config.ConfigCms{
14-			Domain:        domain,
15-			Email:         email,
16-			Port:          port,
17-			Protocol:      protocol,
18-			DbURL:         dbURL,
19-			StorageDir:    storageDir,
20-			MinioURL:      minioURL,
21-			MinioUser:     minioUser,
22-			MinioPass:     minioPass,
23-			Description:   "A blog platform for hackers.",
24-			IntroText:     intro,
25-			Space:         "prose",
26-			AllowedExt:    []string{".md"},
27+			Domain:      domain,
28+			Email:       email,
29+			Port:        port,
30+			Protocol:    protocol,
31+			DbURL:       dbURL,
32+			StorageDir:  storageDir,
33+			MinioURL:    minioURL,
34+			MinioUser:   minioUser,
35+			MinioPass:   minioPass,
36+			Description: "A blog platform for hackers.",
37+			IntroText:   intro,
38+			Space:       "prose",
39+			AllowedExt: []string{
40+				".md",
41+				".jpg",
42+				".jpeg",
43+				".png",
44+				".gif",
45+				".webp",
46+				".svg",
47+			},
48 			HiddenPosts:   []string{"_readme.md", "_styles.css", "_footer.md"},
49 			Logger:        shared.CreateLogger(debug == "1"),
50 			AllowRegister: allowRegister == "1",
51+			MaxSize:       maxSize,
52+			MaxAssetSize:  maxImgSize,
53 		},
54 	}
55 }
M prose/scp_hooks.go
+1, -3
 1@@ -9,7 +9,6 @@ import (
 2 	"github.com/charmbracelet/ssh"
 3 	"github.com/picosh/pico/db"
 4 	"github.com/picosh/pico/filehandlers"
 5-	"github.com/picosh/pico/imgs"
 6 	"github.com/picosh/pico/shared"
 7 )
 8 
 9@@ -48,8 +47,7 @@ func (p *MarkdownHooks) FileValidate(s ssh.Session, data *filehandlers.PostMetaD
10 }
11 
12 func (p *MarkdownHooks) FileMeta(s ssh.Session, data *filehandlers.PostMetaData) error {
13-	linkify := imgs.NewImgsLinkify("")
14-	parsedText, err := shared.ParseText(data.Text, linkify)
15+	parsedText, err := shared.ParseText(data.Text)
16 	// we return nil here because we don't want the file upload to fail
17 	if err != nil {
18 		return nil
M prose/ssh.go
+9, -3
 1@@ -15,6 +15,7 @@ import (
 2 	lm "github.com/charmbracelet/wish/logging"
 3 	"github.com/picosh/pico/db/postgres"
 4 	"github.com/picosh/pico/filehandlers"
 5+	uploadimgs "github.com/picosh/pico/filehandlers/imgs"
 6 	"github.com/picosh/pico/shared"
 7 	"github.com/picosh/pico/shared/storage"
 8 	"github.com/picosh/pico/wish/cms"
 9@@ -33,7 +34,7 @@ func (me *SSHServer) authHandler(ctx ssh.Context, key ssh.PublicKey) bool {
10 	return true
11 }
12 
13-func createRouter(handler *filehandlers.ScpUploadHandler) proxy.Router {
14+func createRouter(handler *filehandlers.FileHandlerRouter) proxy.Router {
15 	return func(sh ssh.Handler, s ssh.Session) []wish.Middleware {
16 		return []wish.Middleware{
17 			pipe.Middleware(handler, ".md"),
18@@ -47,7 +48,7 @@ func createRouter(handler *filehandlers.ScpUploadHandler) proxy.Router {
19 	}
20 }
21 
22-func withProxy(handler *filehandlers.ScpUploadHandler, otherMiddleware ...wish.Middleware) ssh.Option {
23+func withProxy(handler *filehandlers.FileHandlerRouter, otherMiddleware ...wish.Middleware) ssh.Option {
24 	return func(server *ssh.Server) error {
25 		err := sftp.SSHOption(handler)(server)
26 		if err != nil {
27@@ -83,7 +84,12 @@ func StartSshServer() {
28 		logger.Fatal(err)
29 	}
30 
31-	handler := filehandlers.NewScpPostHandler(dbh, cfg, hooks, st)
32+	fileMap := map[string]filehandlers.ReadWriteHandler{
33+		".md":      filehandlers.NewScpPostHandler(dbh, cfg, hooks, st),
34+		"fallback": uploadimgs.NewUploadImgHandler(dbh, cfg, st),
35+	}
36+	handler := filehandlers.NewFileHandlerRouter(cfg, dbh, fileMap)
37+	handler.Spaces = []string{cfg.Space, "imgs"}
38 
39 	sshServer := &SSHServer{}
40 	s, err := wish.NewServer(
M shared/bucket.go
+4, -0
 1@@ -9,6 +9,10 @@ import (
 2 	"github.com/picosh/send/send/utils"
 3 )
 4 
 5+func GetImgsBucketName(userID string) string {
 6+	return userID
 7+}
 8+
 9 func GetAssetBucketName(userID string) string {
10 	return fmt.Sprintf("static-%s", userID)
11 }
D shared/linkify.go
+0, -15
 1@@ -1,15 +0,0 @@
 2-package shared
 3-
 4-type Linkify interface {
 5-	Create(fname string) string
 6-}
 7-
 8-type NullLinkify struct{}
 9-
10-func (n *NullLinkify) Create(s string) string {
11-	return ""
12-}
13-
14-func NewNullLinkify() *NullLinkify {
15-	return &NullLinkify{}
16-}
M shared/listparser.go
+4, -11
 1@@ -150,7 +150,7 @@ func KeyAsValue(token *SplitToken) string {
 2 	return token.Value
 3 }
 4 
 5-func parseItem(meta *ListMetaData, li *ListItem, prevItem *ListItem, pre bool, mod int, linkify Linkify) (bool, bool, int) {
 6+func parseItem(meta *ListMetaData, li *ListItem, prevItem *ListItem, pre bool, mod int) (bool, bool, int) {
 7 	skip := false
 8 
 9 	if strings.HasPrefix(li.Value, preToken) {
10@@ -178,13 +178,6 @@ func parseItem(meta *ListMetaData, li *ListItem, prevItem *ListItem, pre bool, m
11 		li.IsImg = true
12 		split := TextToSplitToken(strings.Replace(li.Value, imgToken, "", 1))
13 		key := split.Key
14-		if strings.HasPrefix(key, "/") {
15-			frag := SanitizeFileExt(key)
16-			key = linkify.Create(frag)
17-		} else if strings.HasPrefix(key, "./") {
18-			name := SanitizeFileExt(key[1:])
19-			key = linkify.Create(name)
20-		}
21 		li.URL = template.URL(key)
22 		li.Value = KeyAsValue(split)
23 	} else if strings.HasPrefix(li.Value, varToken) {
24@@ -204,7 +197,7 @@ func parseItem(meta *ListMetaData, li *ListItem, prevItem *ListItem, pre bool, m
25 		old := len(li.Value)
26 		li.Value = trim
27 
28-		pre, skip, _ = parseItem(meta, li, prevItem, pre, mod, linkify)
29+		pre, skip, _ = parseItem(meta, li, prevItem, pre, mod)
30 		if prevItem != nil && prevItem.Indent == 0 {
31 			mod = old - len(trim)
32 			li.Indent = 1
33@@ -223,7 +216,7 @@ func parseItem(meta *ListMetaData, li *ListItem, prevItem *ListItem, pre bool, m
34 	return pre, skip, mod
35 }
36 
37-func ListParseText(text string, linkify Linkify) *ListParsedText {
38+func ListParseText(text string) *ListParsedText {
39 	textItems := SplitByNewline(text)
40 	items := []*ListItem{}
41 	meta := ListMetaData{
42@@ -245,7 +238,7 @@ func ListParseText(text string, linkify Linkify) *ListParsedText {
43 			Value: t,
44 		}
45 
46-		pre, skip, mod = parseItem(&meta, &li, prevItem, pre, mod, linkify)
47+		pre, skip, mod = parseItem(&meta, &li, prevItem, pre, mod)
48 
49 		if li.IsText && li.Value == "" {
50 			skip = true
M shared/mdparser.go
+1, -79
  1@@ -12,12 +12,9 @@ import (
  2 	"github.com/yuin/goldmark"
  3 	highlighting "github.com/yuin/goldmark-highlighting"
  4 	meta "github.com/yuin/goldmark-meta"
  5-	"github.com/yuin/goldmark/ast"
  6 	"github.com/yuin/goldmark/extension"
  7 	"github.com/yuin/goldmark/parser"
  8-	"github.com/yuin/goldmark/renderer"
  9 	ghtml "github.com/yuin/goldmark/renderer/html"
 10-	"github.com/yuin/goldmark/util"
 11 	"go.abhg.dev/goldmark/anchor"
 12 	yaml "gopkg.in/yaml.v2"
 13 )
 14@@ -160,67 +157,7 @@ func toTags(obj interface{}) ([]string, error) {
 15 	return arr, nil
 16 }
 17 
 18-type ImgRender struct {
 19-	ghtml.Config
 20-	ImgURL func(url []byte) []byte
 21-}
 22-
 23-func NewImgsRenderer(url func([]byte) []byte) renderer.NodeRenderer {
 24-	return &ImgRender{
 25-		Config: ghtml.NewConfig(),
 26-		ImgURL: url,
 27-	}
 28-}
 29-
 30-func (r *ImgRender) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
 31-	reg.Register(ast.KindImage, r.renderImage)
 32-}
 33-
 34-func (r *ImgRender) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
 35-	if !entering {
 36-		return ast.WalkContinue, nil
 37-	}
 38-	n := node.(*ast.Image)
 39-	_, _ = w.WriteString("<img src=\"")
 40-	if r.Unsafe || !ghtml.IsDangerousURL(n.Destination) {
 41-		dest := r.ImgURL(n.Destination)
 42-		_, _ = w.Write(util.EscapeHTML(util.URLEscape(dest, true)))
 43-	}
 44-	_, _ = w.WriteString(`" alt="`)
 45-	_, _ = w.Write(util.EscapeHTML(n.Text(source)))
 46-	_ = w.WriteByte('"')
 47-	if n.Title != nil {
 48-		_, _ = w.WriteString(` title="`)
 49-		r.Writer.Write(w, n.Title)
 50-		_ = w.WriteByte('"')
 51-	}
 52-	if n.Attributes() != nil {
 53-		ghtml.RenderAttributes(w, n, ghtml.ImageAttributeFilter)
 54-	}
 55-	if r.XHTML {
 56-		_, _ = w.WriteString(" />")
 57-	} else {
 58-		_, _ = w.WriteString(">")
 59-	}
 60-	return ast.WalkSkipChildren, nil
 61-}
 62-
 63-func CreateImgURL(linkify Linkify) func([]byte) []byte {
 64-	return func(url []byte) []byte {
 65-		if url[0] == '/' {
 66-			name := SanitizeFileExt(string(url))
 67-			nextURL := linkify.Create(name)
 68-			return []byte(nextURL)
 69-		} else if bytes.HasPrefix(url, []byte{'.', '/'}) {
 70-			name := SanitizeFileExt(string(url[1:]))
 71-			nextURL := linkify.Create(name)
 72-			return []byte(nextURL)
 73-		}
 74-		return url
 75-	}
 76-}
 77-
 78-func ParseText(text string, linkify Linkify) (*ParsedText, error) {
 79+func ParseText(text string) (*ParsedText, error) {
 80 	parsed := ParsedText{
 81 		MetaData: &MetaData{
 82 			Tags:    []string{},
 83@@ -250,9 +187,6 @@ func ParseText(text string, linkify Linkify) (*ParsedText, error) {
 84 		),
 85 		goldmark.WithRendererOptions(
 86 			ghtml.WithUnsafe(),
 87-			renderer.WithNodeRenderers(
 88-				util.Prioritized(NewImgsRenderer(CreateImgURL(linkify)), 0),
 89-			),
 90 		),
 91 	)
 92 	context := parser.NewContext()
 93@@ -266,21 +200,9 @@ func ParseText(text string, linkify Linkify) (*ParsedText, error) {
 94 	parsed.MetaData.Description = toString(metaData["description"])
 95 	parsed.MetaData.Layout = toString(metaData["layout"])
 96 	parsed.MetaData.Image = toString(metaData["image"])
 97-	if strings.HasPrefix(parsed.Image, "/") {
 98-		parsed.Image = linkify.Create(parsed.Image)
 99-	} else if strings.HasPrefix(parsed.Image, "./") {
100-		parsed.Image = linkify.Create(parsed.Image[1:])
101-	}
102-
103 	parsed.MetaData.ImageCard = toString(metaData["card"])
104 	parsed.MetaData.Hidden = toBool(metaData["draft"])
105-
106 	parsed.MetaData.Favicon = toString(metaData["favicon"])
107-	if strings.HasPrefix(parsed.Favicon, "/") {
108-		parsed.Favicon = linkify.Create(parsed.Favicon)
109-	} else if strings.HasPrefix(parsed.Favicon, "./") {
110-		parsed.Favicon = linkify.Create(parsed.Favicon[1:])
111-	}
112 
113 	var publishAt *time.Time = nil
114 	var err error
M shared/storage/fs.go
+3, -4
 1@@ -107,8 +107,8 @@ func (s *StorageFS) GetFile(bucket Bucket, fpath string) (utils.ReaderAtCloser,
 2 	return dat, info.Size(), info.ModTime(), nil
 3 }
 4 
 5-func (s *StorageFS) ServeFile(bucket Bucket, fpath string, ratio *Ratio, original bool, useProxy bool) (io.ReadCloser, string, error) {
 6-	if !useProxy || original || os.Getenv("IMGPROXY_URL") == "" {
 7+func (s *StorageFS) ServeFile(bucket Bucket, fpath string, opts *ImgProcessOpts) (io.ReadCloser, string, error) {
 8+	if opts == nil || os.Getenv("IMGPROXY_URL") == "" {
 9 		contentType := GetMimeType(fpath)
10 		rc, _, _, err := s.GetFile(bucket, fpath)
11 		return rc, contentType, err
12@@ -116,8 +116,7 @@ func (s *StorageFS) ServeFile(bucket Bucket, fpath string, ratio *Ratio, origina
13 
14 	filePath := filepath.Join(bucket.Path, fpath)
15 	dataURL := fmt.Sprintf("local://%s", filePath)
16-
17-	return HandleProxy(dataURL, ratio, original, useProxy)
18+	return HandleProxy(dataURL, opts)
19 }
20 
21 func (s *StorageFS) PutFile(bucket Bucket, fpath string, contents utils.ReaderAtCloser, entry *utils.FileEntry) (string, error) {
M shared/storage/minio.go
+3, -4
 1@@ -183,8 +183,8 @@ func (s *StorageMinio) GetFile(bucket Bucket, fpath string) (utils.ReaderAtClose
 2 	return obj, info.Size, modTime, nil
 3 }
 4 
 5-func (s *StorageMinio) ServeFile(bucket Bucket, fpath string, ratio *Ratio, original bool, useProxy bool) (io.ReadCloser, string, error) {
 6-	if !useProxy || original || os.Getenv("IMGPROXY_URL") == "" {
 7+func (s *StorageMinio) ServeFile(bucket Bucket, fpath string, opts *ImgProcessOpts) (io.ReadCloser, string, error) {
 8+	if opts == nil || os.Getenv("IMGPROXY_URL") == "" {
 9 		contentType := GetMimeType(fpath)
10 		rc, _, _, err := s.GetFile(bucket, fpath)
11 		return rc, contentType, err
12@@ -192,8 +192,7 @@ func (s *StorageMinio) ServeFile(bucket Bucket, fpath string, ratio *Ratio, orig
13 
14 	filePath := filepath.Join(bucket.Name, fpath)
15 	dataURL := fmt.Sprintf("s3://%s", filePath)
16-
17-	return HandleProxy(dataURL, ratio, original, useProxy)
18+	return HandleProxy(dataURL, opts)
19 }
20 
21 func (s *StorageMinio) PutFile(bucket Bucket, fpath string, contents utils.ReaderAtCloser, entry *utils.FileEntry) (string, error) {
M shared/storage/proxy.go
+32, -12
 1@@ -61,25 +61,41 @@ func GetMimeType(fpath string) string {
 2 	return "text/plain"
 3 }
 4 
 5-func HandleProxy(dataURL string, ratio *Ratio, original bool, useProxy bool) (io.ReadCloser, string, error) {
 6+type ImgProcessOpts struct {
 7+	Quality int
 8+	Ratio   *Ratio
 9+}
10+
11+func (img *ImgProcessOpts) String() string {
12+	processOpts := ""
13+
14+	if img.Quality != 0 {
15+		processOpts = fmt.Sprintf("%s/q:%d", processOpts, img.Quality)
16+	}
17+
18+	if img.Ratio != nil {
19+		processOpts = fmt.Sprintf(
20+			"%s/s:%d:%d",
21+			processOpts,
22+			img.Ratio.Width,
23+			img.Ratio.Height,
24+		)
25+	}
26+
27+	return processOpts
28+}
29+
30+func HandleProxy(dataURL string, opts *ImgProcessOpts) (io.ReadCloser, string, error) {
31 	imgProxyURL := os.Getenv("IMGPROXY_URL")
32 	imgProxySalt := os.Getenv("IMGPROXY_SALT")
33 	imgProxyKey := os.Getenv("IMGPROXY_KEY")
34 
35 	signature := "_"
36-	processOpts := "q:80"
37 
38-	if ratio != nil {
39-		processOpts += fmt.Sprintf("/s:%d:%d", ratio.Width, ratio.Height)
40-	}
41+	processOpts := opts.String()
42 
43-	fileType := ".webp"
44-	if original {
45-		fileType = ""
46-		processOpts = "raw:1"
47-	}
48-
49-	processPath := fmt.Sprintf("/%s/%s%s", processOpts, base64.StdEncoding.EncodeToString([]byte(dataURL)), fileType)
50+	fileType := ""
51+	processPath := fmt.Sprintf("%s/%s%s", processOpts, base64.StdEncoding.EncodeToString([]byte(dataURL)), fileType)
52 
53 	if imgProxySalt != "" && imgProxyKey != "" {
54 		keyBin, err := hex.DecodeString(imgProxyKey)
55@@ -105,5 +121,9 @@ func HandleProxy(dataURL string, ratio *Ratio, original bool, useProxy bool) (io
56 		return nil, "", err
57 	}
58 
59+	if res.StatusCode < 200 || res.StatusCode >= 300 {
60+		return nil, "", fmt.Errorf("%s", res.Status)
61+	}
62+
63 	return res.Body, res.Header.Get("Content-Type"), nil
64 }
M shared/storage/storage.go
+1, -1
1@@ -23,7 +23,7 @@ type ObjectStorage interface {
2 	GetBucketQuota(bucket Bucket) (uint64, error)
3 	GetFileSize(bucket Bucket, fpath string) (int64, error)
4 	GetFile(bucket Bucket, fpath string) (utils.ReaderAtCloser, int64, time.Time, error)
5-	ServeFile(bucket Bucket, fpath string, ratio *Ratio, original bool, useProxy bool) (io.ReadCloser, string, error)
6+	ServeFile(bucket Bucket, fpath string, opts *ImgProcessOpts) (io.ReadCloser, string, error)
7 	PutFile(bucket Bucket, fpath string, contents utils.ReaderAtCloser, entry *utils.FileEntry) (string, error)
8 	DeleteFile(bucket Bucket, fpath string) error
9 	ListFiles(bucket Bucket, dir string, recursive bool) ([]os.FileInfo, error)