repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

commit
fdd2260
parent
4aa64c9
author
Eric Bower
date
2025-06-08 08:36:50 -0400 EDT
refactor(prose): use local fs for storing images

This commit removes the dependency on `minio` for hosting images
uploaded to prose.

Instead we are using the local filesystem with an fs adapter.

We are also now using a local imgproxy container on the prose VM
instead.
16 files changed,  +166, -88
M Dockerfile
+3, -3
 1@@ -54,8 +54,8 @@ ARG APP=prose
 2 
 3 COPY --from=builder-web /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
 4 COPY --from=builder-web /go/bin/${APP}-web ./web
 5-COPY --from=builder-web /app/pkg/apps/${APP}/html ./${APP}/html
 6-COPY --from=builder-web /app/pkg/apps/${APP}/public ./${APP}/public
 7+COPY --from=builder-web /app/pkg/apps/${APP}/html ./pkg/apps/${APP}/html
 8+COPY --from=builder-web /app/pkg/apps/${APP}/public ./pkg/apps/${APP}/public
 9 
10 ENTRYPOINT ["/app/web"]
11 
12@@ -69,7 +69,7 @@ ARG APP=prose
13 COPY --from=builder-ssh /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
14 COPY --from=builder-ssh /go/bin/${APP}-ssh ./ssh
15 # some services require the html folder
16-COPY --from=builder-ssh /app/pkg/apps/${APP}/html ./${APP}/html
17+COPY --from=builder-ssh /app/pkg/apps/${APP}/html ./pkg/apps/${APP}/html
18 
19 
20 ENTRYPOINT ["/app/ssh"]
M docker-compose.override.yml
+4, -3
 1@@ -15,11 +15,9 @@ services:
 2     env_file:
 3       - .env.example
 4     volumes:
 5-      - ./data/storage/data:/storage
 6+      - ./data/storage:/storage
 7     ports:
 8       - "8080:8080"
 9-    links:
10-      - minio
11   pastes-web:
12     build:
13       args:
14@@ -69,6 +67,8 @@ services:
15       - .env.example
16     ports:
17       - "3002:3000"
18+    volumes:
19+      - ./data/storage:/storage
20   prose-ssh:
21     build:
22       args:
23@@ -77,6 +77,7 @@ services:
24     env_file:
25       - .env.example
26     volumes:
27+      - ./data/storage:/storage
28       - ./data/prose-ssh/data:/app/ssh_data
29     ports:
30       - "2222:2222"
M docker-compose.prod.yml
+7, -4
 1@@ -44,8 +44,9 @@ services:
 2     env_file:
 3       - .env.prod
 4     volumes:
 5-      - ./data/imgs-storage/data:/storage/imgs
 6-      - ./data/pgs-storage/data:/storage/pgs
 7+      - ./data/storage:/storage
 8+    networks:
 9+      prose:
10   pastes-caddy:
11     image: ghcr.io/picosh/pico/caddy:latest
12     restart: always
13@@ -174,6 +175,7 @@ services:
14       - .env.prod
15     volumes:
16       - ./data/prose-ssh/data:/app/ssh_data
17+      - ./data/storage:/storage
18   prose-ssh:
19     networks:
20       prose:
21@@ -183,6 +185,7 @@ services:
22       - .env.prod
23     volumes:
24       - ./data/prose-ssh/data:/app/ssh_data
25+      - ./data/storage:/storage
26     ports:
27       - "${PROSE_SSH_V4:-22}:2222"
28       - "${PROSE_SSH_V6:-[::1]:22}:2222"
29@@ -229,7 +232,7 @@ services:
30     env_file:
31       - .env.prod
32     volumes:
33-      - ./data/storage/data:/app/.storage
34+      - ./data/storage:/storage
35       - ./data/pgs-ssh/data:/app/ssh_data
36     deploy:
37       resources:
38@@ -248,7 +251,7 @@ services:
39     env_file:
40       - .env.prod
41     volumes:
42-      - ./data/storage/data:/app/.storage
43+      - ./data/storage:/storage
44       - ./data/pgs-ssh/data:/app/ssh_data
45       - ./data/tmp:/tmp
46     ports:
M docker-compose.yml
+1, -0
1@@ -18,6 +18,7 @@ services:
2     profiles:
3       - db
4       - all
5+      - prose
6   pipemgr:
7     image: ghcr.io/picosh/pipemgr:latest
8     command: -command "pub metric-drain -b=false"
M pkg/apps/pgs/web.go
+25, -36
 1@@ -410,7 +410,7 @@ func (web *WebRouter) AssetRequest(perm func(proj *db.Project) bool) http.Handle
 2 			web.ImageRequest(perm)(w, r)
 3 			return
 4 		}
 5-		web.ServeAsset(fname, nil, false, perm, w, r)
 6+		web.ServeAsset(fname, nil, perm, w, r)
 7 	}
 8 }
 9 
10@@ -435,11 +435,11 @@ func (web *WebRouter) ImageRequest(perm func(proj *db.Project) bool) http.Handle
11 			return
12 		}
13 
14-		web.ServeAsset(fname, opts, false, perm, w, r)
15+		web.ServeAsset(fname, opts, perm, w, r)
16 	}
17 }
18 
19-func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, fromImgs bool, hasPerm HasPerm, w http.ResponseWriter, r *http.Request) {
20+func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, hasPerm HasPerm, w http.ResponseWriter, r *http.Request) {
21 	subdomain := shared.GetSubdomain(r)
22 
23 	logger := web.Cfg.Logger.With(
24@@ -475,41 +475,30 @@ func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, fro
25 		"userId", user.ID,
26 	)
27 
28-	projectID := ""
29-	// TODO: this could probably be cleaned up more
30-	// imgs wont have a project directory
31-	projectDir := ""
32 	var bucket sst.Bucket
33-	// imgs has a different bucket directory
34-	if fromImgs {
35-		bucket, err = web.Cfg.Storage.GetBucket(shared.GetImgsBucketName(user.ID))
36-	} else {
37-		bucket, err = web.Cfg.Storage.GetBucket(shared.GetAssetBucketName(user.ID))
38-		project, perr := web.Cfg.DB.FindProjectByName(user.ID, props.ProjectName)
39-		if perr != nil {
40-			logger.Info("project not found")
41-			http.Error(w, "project not found", http.StatusNotFound)
42-			return
43-		}
44+	bucket, err = web.Cfg.Storage.GetBucket(shared.GetAssetBucketName(user.ID))
45+	project, perr := web.Cfg.DB.FindProjectByName(user.ID, props.ProjectName)
46+	if perr != nil {
47+		logger.Info("project not found")
48+		http.Error(w, "project not found", http.StatusNotFound)
49+		return
50+	}
51 
52-		logger = logger.With(
53-			"projectId", project.ID,
54-			"project", project.Name,
55-		)
56+	logger = logger.With(
57+		"projectId", project.ID,
58+		"project", project.Name,
59+	)
60 
61-		if project.Blocked != "" {
62-			logger.Error("project has been blocked")
63-			http.Error(w, project.Blocked, http.StatusForbidden)
64-			return
65-		}
66+	if project.Blocked != "" {
67+		logger.Error("project has been blocked")
68+		http.Error(w, project.Blocked, http.StatusForbidden)
69+		return
70+	}
71 
72-		projectID = project.ID
73-		projectDir = project.ProjectDir
74-		if !hasPerm(project) {
75-			logger.Error("You do not have access to this site")
76-			http.Error(w, "You do not have access to this site", http.StatusUnauthorized)
77-			return
78-		}
79+	if !hasPerm(project) {
80+		logger.Error("You do not have access to this site")
81+		http.Error(w, "You do not have access to this site", http.StatusUnauthorized)
82+		return
83 	}
84 
85 	if err != nil {
86@@ -533,11 +522,11 @@ func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, fro
87 		Username:       props.Username,
88 		UserID:         user.ID,
89 		Subdomain:      subdomain,
90-		ProjectDir:     projectDir,
91+		ProjectID:      project.ID,
92+		ProjectDir:     project.ProjectDir,
93 		Filepath:       fname,
94 		Bucket:         bucket,
95 		ImgProcessOpts: opts,
96-		ProjectID:      projectID,
97 		HasPicoPlus:    hasPicoPlus,
98 	}
99 
M pkg/apps/pgs/web_asset_handler.go
+1, -0
1@@ -159,6 +159,7 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
2 		attempts = append(attempts, fpath)
3 		logger = logger.With("object", fpath)
4 		c, info, err = h.Cfg.Storage.ServeObject(
5+			r,
6 			h.Bucket,
7 			fpath,
8 			h.ImgProcessOpts,
M pkg/apps/pgs/web_test.go
+1, -1
1@@ -360,7 +360,7 @@ type ImageStorageMemory struct {
2 	Fpath string
3 }
4 
5-func (s *ImageStorageMemory) ServeObject(bucket sst.Bucket, fpath string, opts *storage.ImgProcessOpts) (io.ReadCloser, *sst.ObjectInfo, error) {
6+func (s *ImageStorageMemory) ServeObject(r *http.Request, bucket sst.Bucket, fpath string, opts *storage.ImgProcessOpts) (io.ReadCloser, *sst.ObjectInfo, error) {
7 	s.Opts = opts
8 	s.Fpath = fpath
9 	info := sst.ObjectInfo{
M pkg/apps/prose/api.go
+63, -21
  1@@ -4,10 +4,11 @@ import (
  2 	"bytes"
  3 	"fmt"
  4 	"html/template"
  5+	"io"
  6 	"net/http"
  7-	"net/http/httputil"
  8 	"net/url"
  9 	"os"
 10+	"path/filepath"
 11 	"strconv"
 12 	"strings"
 13 	"time"
 14@@ -865,6 +866,7 @@ func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
 15 
 16 func imgRequest(w http.ResponseWriter, r *http.Request) {
 17 	logger := shared.GetLogger(r)
 18+	st := shared.GetStorage(r)
 19 	dbpool := shared.GetDB(r)
 20 	username := shared.GetUsernameFromRequest(r)
 21 	user, err := dbpool.FindUserByName(username)
 22@@ -875,22 +877,67 @@ func imgRequest(w http.ResponseWriter, r *http.Request) {
 23 	}
 24 	logger = shared.LoggerWithUser(logger, user)
 25 
 26-	destUrl, err := url.Parse(fmt.Sprintf("https://%s-prose.pgs.sh%s", username, r.URL.Path))
 27+	rawname := shared.GetField(r, 0)
 28+	imgOpts := shared.GetField(r, 1)
 29+	// we place all prose images inside a "prose" folder
 30+	fname := filepath.Join("/prose", rawname)
 31+
 32+	opts, err := storage.UriToImgProcessOpts(imgOpts)
 33+	if err != nil {
 34+		errMsg := fmt.Sprintf("error processing img options: %s", err.Error())
 35+		logger.Error("error processing img options", "err", errMsg)
 36+		http.Error(w, errMsg, http.StatusUnprocessableEntity)
 37+		return
 38+	}
 39+
 40+	bucket, err := st.GetBucket(shared.GetAssetBucketName(user.ID))
 41+	if err != nil {
 42+		logger.Error("bucket", "err", err)
 43+		http.Error(w, err.Error(), http.StatusUnprocessableEntity)
 44+		return
 45+	}
 46+
 47+	contents, info, err := st.ServeObject(r, bucket, fname, opts)
 48 	if err != nil {
 49-		logger.Error("could not parse image proxy url", "username", username)
 50-		http.Error(w, "could not parse image proxy url", http.StatusInternalServerError)
 51+		logger.Error("serve object", "err", err)
 52+		http.Error(w, err.Error(), http.StatusUnprocessableEntity)
 53 		return
 54 	}
 55-	logger.Info("proxy image request", "url", destUrl.String())
 56 
 57-	proxy := httputil.NewSingleHostReverseProxy(destUrl)
 58-	oldDirector := proxy.Director
 59-	proxy.Director = func(r *http.Request) {
 60-		oldDirector(r)
 61-		r.Host = destUrl.Host
 62-		r.URL = destUrl
 63+	contentType := ""
 64+	if info != nil {
 65+		contentType = info.Metadata.Get("content-type")
 66+		if info.Size != 0 {
 67+			w.Header().Add("content-length", strconv.Itoa(int(info.Size)))
 68+		}
 69+		if info.ETag != "" {
 70+			// Minio SDK trims off the mandatory quotes (RFC 7232 ยง 2.3)
 71+			w.Header().Add("etag", fmt.Sprintf("\"%s\"", info.ETag))
 72+		}
 73+
 74+		if !info.LastModified.IsZero() {
 75+			w.Header().Add("last-modified", info.LastModified.UTC().Format(http.TimeFormat))
 76+		}
 77+	}
 78+
 79+	if w.Header().Get("content-type") == "" {
 80+		w.Header().Set("content-type", contentType)
 81+	}
 82+
 83+	// Allows us to invalidate the cache when files are modified
 84+	// w.Header().Set("surrogate-key", h.Subdomain)
 85+
 86+	finContentType := w.Header().Get("content-type")
 87+	logger.Info(
 88+		"serving asset",
 89+		"asset", fname,
 90+		"contentType", finContentType,
 91+	)
 92+
 93+	_, err = io.Copy(w, contents)
 94+	if err != nil {
 95+		logger.Error("io copy", "err", err)
 96 	}
 97-	proxy.ServeHTTP(w, r)
 98 }
 99 
100 func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
101@@ -931,16 +978,11 @@ func StartApiServer() {
102 	}()
103 	logger := cfg.Logger
104 
105-	var st storage.StorageServe
106-	var err error
107-	if cfg.MinioURL == "" {
108-		st, err = storage.NewStorageFS(cfg.Logger, cfg.StorageDir)
109-	} else {
110-		st, err = storage.NewStorageMinio(cfg.Logger, cfg.MinioURL, cfg.MinioUser, cfg.MinioPass)
111-	}
112-
113+	adapter := storage.GetStorageTypeFromEnv()
114+	st, err := storage.NewStorage(cfg.Logger, adapter)
115 	if err != nil {
116-		logger.Error(err.Error())
117+		logger.Error("loading storage", "err", err)
118+		return
119 	}
120 
121 	staticRoutes := createStaticRoutes()
M pkg/apps/prose/ssh.go
+3, -9
 1@@ -44,16 +44,10 @@ func StartSshServer() {
 2 		Db:  dbh,
 3 	}
 4 
 5-	var st storage.StorageServe
 6-	var err error
 7-	if cfg.MinioURL == "" {
 8-		st, err = storage.NewStorageFS(cfg.Logger, cfg.StorageDir)
 9-	} else {
10-		st, err = storage.NewStorageMinio(cfg.Logger, cfg.MinioURL, cfg.MinioUser, cfg.MinioPass)
11-	}
12-
13+	adapter := storage.GetStorageTypeFromEnv()
14+	st, err := storage.NewStorage(cfg.Logger, adapter)
15 	if err != nil {
16-		logger.Error("storage", "err", err.Error())
17+		logger.Error("loading storage", "err", err)
18 		return
19 	}
20 
M pkg/shared/config.go
+1, -1
1@@ -122,7 +122,7 @@ func (c *ConfigSite) ReadURL() string {
2 }
3 
4 func (c *ConfigSite) StaticPath(fname string) string {
5-	return path.Join(c.Space, fname)
6+	return path.Join("pkg", "apps", c.Space, fname)
7 }
8 
9 func (c *ConfigSite) BlogURL(username string) string {
A pkg/shared/storage/base.go
+34, -0
 1@@ -0,0 +1,34 @@
 2+package storage
 3+
 4+import (
 5+	"fmt"
 6+	"log/slog"
 7+
 8+	"github.com/picosh/utils"
 9+)
10+
11+func GetStorageTypeFromEnv() string {
12+	return utils.GetEnv("STORAGE_ADAPTER", "minio")
13+}
14+
15+func NewStorage(logger *slog.Logger, adapter string) (StorageServe, error) {
16+	logger.Info("storage adapter", "adapter", adapter)
17+	switch adapter {
18+	case "minio":
19+		minioURL := utils.GetEnv("MINIO_URL", "")
20+		minioUser := utils.GetEnv("MINIO_ROOT_USER", "")
21+		minioPass := utils.GetEnv("MINIO_ROOT_PASSWORD", "")
22+		logger.Info("using minio storage", "url", minioURL, "user", minioUser)
23+		return NewStorageMinio(logger, minioURL, minioUser, minioPass)
24+	case "fs":
25+		fsPath := utils.GetEnv("FS_STORAGE_DIR", "/tmp/pico_storage")
26+		logger.Info("using filesystem storage", "path", fsPath)
27+		return NewStorageFS(logger, fsPath)
28+	case "memory":
29+		data := map[string]map[string]string{}
30+		logger.Info("using memory storage")
31+		return NewStorageMemory(data)
32+	default:
33+		return nil, fmt.Errorf("unsupported storage type: %s", adapter)
34+	}
35+}
M pkg/shared/storage/fs.go
+4, -3
 1@@ -4,6 +4,7 @@ import (
 2 	"fmt"
 3 	"io"
 4 	"log/slog"
 5+	"net/http"
 6 	"os"
 7 	"path/filepath"
 8 	"strings"
 9@@ -25,7 +26,7 @@ func NewStorageFS(logger *slog.Logger, dir string) (*StorageFS, error) {
10 	return &StorageFS{st, logger}, nil
11 }
12 
13-func (s *StorageFS) ServeObject(bucket sst.Bucket, fpath string, opts *ImgProcessOpts) (io.ReadCloser, *sst.ObjectInfo, error) {
14+func (s *StorageFS) ServeObject(r *http.Request, bucket sst.Bucket, fpath string, opts *ImgProcessOpts) (io.ReadCloser, *sst.ObjectInfo, error) {
15 	var rc io.ReadCloser
16 	info := &sst.ObjectInfo{}
17 	var err error
18@@ -39,8 +40,8 @@ func (s *StorageFS) ServeObject(bucket sst.Bucket, fpath string, opts *ImgProces
19 		info.Metadata.Set("content-type", mimeType)
20 	} else {
21 		filePath := filepath.Join(bucket.Name, fpath)
22-		dataURL := fmt.Sprintf("s3://%s", filePath)
23-		rc, info, err = HandleProxy(s.Logger, dataURL, opts)
24+		dataURL := fmt.Sprintf("local:///%s", filePath)
25+		rc, info, err = HandleProxy(r, s.Logger, dataURL, opts)
26 	}
27 	if err != nil {
28 		return nil, nil, err
M pkg/shared/storage/memory.go
+1, -1
1@@ -21,7 +21,7 @@ func NewStorageMemory(sto map[string]map[string]string) (*StorageMemory, error)
2 	return &StorageMemory{st}, nil
3 }
4 
5-func (s *StorageMemory) ServeObject(bucket sst.Bucket, fpath string, opts *ImgProcessOpts) (io.ReadCloser, *sst.ObjectInfo, error) {
6+func (s *StorageMemory) ServeObject(r *http.Request, bucket sst.Bucket, fpath string, opts *ImgProcessOpts) (io.ReadCloser, *sst.ObjectInfo, error) {
7 	obj, info, err := s.GetObject(bucket, fpath)
8 	if info.Metadata == nil {
9 		info.Metadata = make(http.Header)
M pkg/shared/storage/minio.go
+3, -2
 1@@ -4,6 +4,7 @@ import (
 2 	"fmt"
 3 	"io"
 4 	"log/slog"
 5+	"net/http"
 6 	"os"
 7 	"path/filepath"
 8 	"strings"
 9@@ -24,7 +25,7 @@ func NewStorageMinio(logger *slog.Logger, address, user, pass string) (*StorageM
10 	return &StorageMinio{st}, nil
11 }
12 
13-func (s *StorageMinio) ServeObject(bucket sst.Bucket, fpath string, opts *ImgProcessOpts) (io.ReadCloser, *sst.ObjectInfo, error) {
14+func (s *StorageMinio) ServeObject(r *http.Request, bucket sst.Bucket, fpath string, opts *ImgProcessOpts) (io.ReadCloser, *sst.ObjectInfo, error) {
15 	var rc io.ReadCloser
16 	info := &sst.ObjectInfo{}
17 	var err error
18@@ -39,7 +40,7 @@ func (s *StorageMinio) ServeObject(bucket sst.Bucket, fpath string, opts *ImgPro
19 	} else {
20 		filePath := filepath.Join(bucket.Name, fpath)
21 		dataURL := fmt.Sprintf("s3://%s", filePath)
22-		rc, info, err = HandleProxy(s.Logger, dataURL, opts)
23+		rc, info, err = HandleProxy(r, s.Logger, dataURL, opts)
24 	}
25 	if err != nil {
26 		return nil, nil, err
M pkg/shared/storage/proxy.go
+13, -3
 1@@ -133,7 +133,7 @@ func (img *ImgProcessOpts) String() string {
 2 	return processOpts
 3 }
 4 
 5-func HandleProxy(logger *slog.Logger, dataURL string, opts *ImgProcessOpts) (io.ReadCloser, *storage.ObjectInfo, error) {
 6+func HandleProxy(r *http.Request, logger *slog.Logger, dataURL string, opts *ImgProcessOpts) (io.ReadCloser, *storage.ObjectInfo, error) {
 7 	imgProxyURL := os.Getenv("IMGPROXY_URL")
 8 	imgProxySalt := os.Getenv("IMGPROXY_SALT")
 9 	imgProxyKey := os.Getenv("IMGPROXY_KEY")
10@@ -162,7 +162,14 @@ func HandleProxy(logger *slog.Logger, dataURL string, opts *ImgProcessOpts) (io.
11 	}
12 	proxyAddress := fmt.Sprintf("%s/%s%s", imgProxyURL, signature, processPath)
13 
14-	res, err := http.Get(proxyAddress)
15+	req, err := http.NewRequest(http.MethodGet, proxyAddress, nil)
16+	if err != nil {
17+		return nil, nil, err
18+	}
19+	req.Header.Set("accept", r.Header.Get("accept"))
20+	req.Header.Set("accept-encoding", r.Header.Get("accept-encoding"))
21+	req.Header.Set("accept-language", r.Header.Get("accept-language"))
22+	res, err := http.DefaultClient.Do(req)
23 	if err != nil {
24 		return nil, nil, err
25 	}
26@@ -178,7 +185,10 @@ func HandleProxy(logger *slog.Logger, dataURL string, opts *ImgProcessOpts) (io.
27 	info := &storage.ObjectInfo{
28 		Size:     res.ContentLength,
29 		ETag:     trimEtag(res.Header.Get("etag")),
30-		Metadata: res.Header,
31+		Metadata: res.Header.Clone(),
32+	}
33+	if strings.HasPrefix(info.Metadata.Get("content-type"), "text/xml") {
34+		info.Metadata.Set("content-type", "image/svg+xml")
35 	}
36 	if !parsedTime.IsZero() {
37 		info.LastModified = parsedTime
M pkg/shared/storage/storage.go
+2, -1
 1@@ -2,11 +2,12 @@ package storage
 2 
 3 import (
 4 	"io"
 5+	"net/http"
 6 
 7 	sst "github.com/picosh/pico/pkg/pobj/storage"
 8 )
 9 
10 type StorageServe interface {
11 	sst.ObjectStorage
12-	ServeObject(bucket sst.Bucket, fpath string, opts *ImgProcessOpts) (io.ReadCloser, *sst.ObjectInfo, error)
13+	ServeObject(r *http.Request, bucket sst.Bucket, fpath string, opts *ImgProcessOpts) (io.ReadCloser, *sst.ObjectInfo, error)
14 }