- 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
+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"]
+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"
+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:
+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"
+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
+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,
+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{
+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()
+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
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 {
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+}
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
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)
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
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
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 }