repos / pico

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

commit
0a4c670
parent
433e9d1
author
Eric Bower
date
2026-04-20 08:42:56 -0400 EDT
refactor(pgs): use http.ServeContent

refactor: new image proxy object that uses httputil reverse proxy
10 files changed,  +272, -525
D pkg/apps/pgs/fs.go
+0, -282
  1@@ -1,282 +0,0 @@
  2-// Copyright 2009 The Go Authors.
  3-
  4-// Redistribution and use in source and binary forms, with or without
  5-// modification, are permitted provided that the following conditions are
  6-// met:
  7-
  8-//    * Redistributions of source code must retain the above copyright
  9-// notice, this list of conditions and the following disclaimer.
 10-//    * Redistributions in binary form must reproduce the above
 11-// copyright notice, this list of conditions and the following disclaimer
 12-// in the documentation and/or other materials provided with the
 13-// distribution.
 14-//    * Neither the name of Google LLC nor the names of its
 15-// contributors may be used to endorse or promote products derived from
 16-// this software without specific prior written permission.
 17-
 18-// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 19-// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 20-// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 21-// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 22-// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 23-// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 24-// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 25-// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 26-// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 27-// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 28-// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 29-
 30-// HTTP file system request handler
 31-//
 32-// Upstream: https://cs.opensource.google/go/go/+/refs/tags/go1.23.4:src/net/http/fs.go
 33-// Modifications from upstream:
 34-// * Deleted everything except checkPreconditions and dependent functions
 35-// * Added "http" package prefixes
 36-
 37-package pgs
 38-
 39-import (
 40-	"net/http"
 41-	"net/textproto"
 42-	"strings"
 43-	"time"
 44-)
 45-
 46-// scanETag determines if a syntactically valid ETag is present at s. If so,
 47-// the ETag and remaining text after consuming ETag is returned. Otherwise,
 48-// it returns "", "".
 49-func scanETag(s string) (etag string, remain string) {
 50-	s = textproto.TrimString(s)
 51-	start := 0
 52-	if strings.HasPrefix(s, "W/") {
 53-		start = 2
 54-	}
 55-	if len(s[start:]) < 2 || s[start] != '"' {
 56-		return "", ""
 57-	}
 58-	// ETag is either W/"text" or "text".
 59-	// See RFC 7232 2.3.
 60-	for i := start + 1; i < len(s); i++ {
 61-		c := s[i]
 62-		switch {
 63-		// Character values allowed in ETags.
 64-		case c == 0x21 || c >= 0x23 && c <= 0x7E || c >= 0x80:
 65-		case c == '"':
 66-			return s[:i+1], s[i+1:]
 67-		default:
 68-			return "", ""
 69-		}
 70-	}
 71-	return "", ""
 72-}
 73-
 74-// etagStrongMatch reports whether a and b match using strong ETag comparison.
 75-// Assumes a and b are valid ETags.
 76-func etagStrongMatch(a, b string) bool {
 77-	return a == b && a != "" && a[0] == '"'
 78-}
 79-
 80-// etagWeakMatch reports whether a and b match using weak ETag comparison.
 81-// Assumes a and b are valid ETags.
 82-func etagWeakMatch(a, b string) bool {
 83-	return strings.TrimPrefix(a, "W/") == strings.TrimPrefix(b, "W/")
 84-}
 85-
 86-// condResult is the result of an HTTP request precondition check.
 87-// See https://tools.ietf.org/html/rfc7232 section 3.
 88-type condResult int
 89-
 90-const (
 91-	condNone condResult = iota
 92-	condTrue
 93-	condFalse
 94-)
 95-
 96-func checkIfMatch(w http.ResponseWriter, r *http.Request) condResult {
 97-	im := r.Header.Get("If-Match")
 98-	if im == "" {
 99-		return condNone
100-	}
101-	for {
102-		im = textproto.TrimString(im)
103-		if len(im) == 0 {
104-			break
105-		}
106-		if im[0] == ',' {
107-			im = im[1:]
108-			continue
109-		}
110-		if im[0] == '*' {
111-			return condTrue
112-		}
113-		etag, remain := scanETag(im)
114-		if etag == "" {
115-			break
116-		}
117-		if etagStrongMatch(etag, w.Header().Get("Etag")) {
118-			return condTrue
119-		}
120-		im = remain
121-	}
122-
123-	return condFalse
124-}
125-
126-func checkIfUnmodifiedSince(r *http.Request, modtime time.Time) condResult {
127-	ius := r.Header.Get("If-Unmodified-Since")
128-	if ius == "" || isZeroTime(modtime) {
129-		return condNone
130-	}
131-	t, err := http.ParseTime(ius)
132-	if err != nil {
133-		return condNone
134-	}
135-
136-	// The Last-Modified header truncates sub-second precision so
137-	// the modtime needs to be truncated too.
138-	modtime = modtime.Truncate(time.Second)
139-	if ret := modtime.Compare(t); ret <= 0 {
140-		return condTrue
141-	}
142-	return condFalse
143-}
144-
145-func checkIfNoneMatch(w http.ResponseWriter, r *http.Request) condResult {
146-	inm := r.Header.Get("If-None-Match")
147-	if inm == "" {
148-		return condNone
149-	}
150-	buf := inm
151-	for {
152-		buf = textproto.TrimString(buf)
153-		if len(buf) == 0 {
154-			break
155-		}
156-		if buf[0] == ',' {
157-			buf = buf[1:]
158-			continue
159-		}
160-		if buf[0] == '*' {
161-			return condFalse
162-		}
163-		etag, remain := scanETag(buf)
164-		if etag == "" {
165-			break
166-		}
167-		if etagWeakMatch(etag, w.Header().Get("Etag")) {
168-			return condFalse
169-		}
170-		buf = remain
171-	}
172-	return condTrue
173-}
174-
175-func checkIfModifiedSince(r *http.Request, modtime time.Time) condResult {
176-	if r.Method != "GET" && r.Method != "HEAD" {
177-		return condNone
178-	}
179-	ims := r.Header.Get("If-Modified-Since")
180-	if ims == "" || isZeroTime(modtime) {
181-		return condNone
182-	}
183-	t, err := http.ParseTime(ims)
184-	if err != nil {
185-		return condNone
186-	}
187-	// The Last-Modified header truncates sub-second precision so
188-	// the modtime needs to be truncated too.
189-	modtime = modtime.Truncate(time.Second)
190-	if ret := modtime.Compare(t); ret <= 0 {
191-		return condFalse
192-	}
193-	return condTrue
194-}
195-
196-func checkIfRange(w http.ResponseWriter, r *http.Request, modtime time.Time) condResult {
197-	if r.Method != "GET" && r.Method != "HEAD" {
198-		return condNone
199-	}
200-	ir := r.Header.Get("If-Range")
201-	if ir == "" {
202-		return condNone
203-	}
204-	etag, _ := scanETag(ir)
205-	if etag != "" {
206-		if etagStrongMatch(etag, w.Header().Get("Etag")) {
207-			return condTrue
208-		} else {
209-			return condFalse
210-		}
211-	}
212-	// The If-Range value is typically the ETag value, but it may also be
213-	// the modtime date. See golang.org/issue/8367.
214-	if modtime.IsZero() {
215-		return condFalse
216-	}
217-	t, err := http.ParseTime(ir)
218-	if err != nil {
219-		return condFalse
220-	}
221-	if t.Unix() == modtime.Unix() {
222-		return condTrue
223-	}
224-	return condFalse
225-}
226-
227-var unixEpochTime = time.Unix(0, 0)
228-
229-// isZeroTime reports whether t is obviously unspecified (either zero or Unix()=0).
230-func isZeroTime(t time.Time) bool {
231-	return t.IsZero() || t.Equal(unixEpochTime)
232-}
233-
234-func writeNotModified(w http.ResponseWriter) {
235-	// RFC 7232 section 4.1:
236-	// a sender SHOULD NOT generate representation metadata other than the
237-	// above listed fields unless said metadata exists for the purpose of
238-	// guiding cache updates (e.g., Last-Modified might be useful if the
239-	// response does not have an ETag field).
240-	h := w.Header()
241-	delete(h, "Content-Type")
242-	delete(h, "Content-Length")
243-	delete(h, "Content-Encoding")
244-	if h.Get("Etag") != "" {
245-		delete(h, "Last-Modified")
246-	}
247-	w.WriteHeader(http.StatusNotModified)
248-}
249-
250-// checkPreconditions evaluates request preconditions and reports whether a precondition
251-// resulted in sending http.StatusNotModified or http.StatusPreconditionFailed.
252-func checkPreconditions(w http.ResponseWriter, r *http.Request, modtime time.Time) (done bool, rangeHeader string) {
253-	// This function carefully follows RFC 7232 section 6.
254-	ch := checkIfMatch(w, r)
255-	if ch == condNone {
256-		ch = checkIfUnmodifiedSince(r, modtime)
257-	}
258-	if ch == condFalse {
259-		w.WriteHeader(http.StatusPreconditionFailed)
260-		return true, ""
261-	}
262-	switch checkIfNoneMatch(w, r) {
263-	case condFalse:
264-		if r.Method == "GET" || r.Method == "HEAD" {
265-			writeNotModified(w)
266-			return true, ""
267-		} else {
268-			w.WriteHeader(http.StatusPreconditionFailed)
269-			return true, ""
270-		}
271-	case condNone:
272-		if checkIfModifiedSince(r, modtime) == condFalse {
273-			writeNotModified(w)
274-			return true, ""
275-		}
276-	}
277-
278-	rangeHeader = r.Header.Get("Range")
279-	if rangeHeader != "" && checkIfRange(w, r, modtime) == condFalse {
280-		rangeHeader = ""
281-	}
282-	return false, rangeHeader
283-}
M pkg/apps/pgs/uploader.go
+5, -2
 1@@ -570,12 +570,15 @@ func (h *UploadAssetHandler) writeAsset(s *pssh.SSHServerConnSession, reader io.
 2 // per site per 5 seconds.
 3 func runCacheQueue(cfg *PgsConfig, ctx context.Context) {
 4 	var pendingFlushes sync.Map
 5-	tick := time.Tick(5 * time.Second)
 6+	tick := time.NewTicker(5 * time.Second)
 7+	defer tick.Stop()
 8 	for {
 9 		select {
10+		case <-ctx.Done():
11+			return
12 		case host := <-cfg.CacheClearingQueue:
13 			pendingFlushes.Store(host, host)
14-		case <-tick:
15+		case <-tick.C:
16 			go func() {
17 				pendingFlushes.Range(func(key, value any) bool {
18 					pendingFlushes.Delete(key)
M pkg/apps/pgs/web_asset_handler.go
+37, -35
  1@@ -84,7 +84,7 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  2 
  3 	routes := calcRoutes(h.ProjectDir, fpath, redirects)
  4 
  5-	var contents io.ReadCloser
  6+	var contents io.ReadSeekCloser
  7 	assetFilepath := ""
  8 	var info *storage.ObjectInfo
  9 	status := http.StatusOK
 10@@ -134,39 +134,44 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 11 				"status", fp.Status,
 12 			)
 13 
 14-			proxy := httputil.NewSingleHostReverseProxy(destUrl)
 15-			oldDirector := proxy.Director
 16-			proxy.Director = func(r *http.Request) {
 17-				oldDirector(r)
 18-				r.Host = destUrl.Host
 19-				r.URL = destUrl
 20-			}
 21-			// Disable caching
 22-			proxy.ModifyResponse = func(r *http.Response) error {
 23-				r.Header.Set("cache-control", "no-cache")
 24-				return nil
 25+			proxy := &httputil.ReverseProxy{
 26+				Rewrite: func(r *httputil.ProxyRequest) {
 27+					r.SetURL(destUrl)
 28+					r.Out.Header.Set("Host", destUrl.Host)
 29+				},
 30+				ModifyResponse: func(resp *http.Response) error {
 31+					resp.Header.Set("cache-control", "no-cache")
 32+					return nil
 33+				},
 34 			}
 35 			proxy.ServeHTTP(w, r)
 36 			return
 37 		}
 38 
 39-		var c io.ReadCloser
 40 		fpath := fp.Filepath
 41 		attempts = append(attempts, fpath)
 42 		logger = logger.With("object", fpath)
 43-		c, info, err = h.Cfg.Storage.ServeObject(
 44-			r,
 45-			h.Bucket,
 46-			fpath,
 47-			h.ImgProcessOpts,
 48-		)
 49-		if err != nil {
 50-			logger.Error("serving object", "err", err)
 51+
 52+		imgproxy := storage.NewImgProxy(fpath, h.ImgProcessOpts)
 53+		err = imgproxy.CanServe()
 54+		if err == nil {
 55+			logger.Info("serving image with imgproxy")
 56+			imgproxy.ServeHTTP(w, r)
 57+			return
 58 		} else {
 59-			contents = c
 60-			assetFilepath = fp.Filepath
 61-			status = fp.Status
 62-			break
 63+			var c io.ReadSeekCloser
 64+			c, info, err = h.Cfg.Storage.GetObject(
 65+				h.Bucket,
 66+				fpath,
 67+			)
 68+			if err != nil {
 69+				logger.Error("serving object", "err", err)
 70+			} else {
 71+				contents = c
 72+				assetFilepath = fp.Filepath
 73+				status = fp.Status
 74+				break
 75+			}
 76 		}
 77 	}
 78 
 79@@ -294,16 +299,13 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 80 		"status", status,
 81 		"contentType", finContentType,
 82 	)
 83-	done, _ := checkPreconditions(w, r, info.LastModified.UTC())
 84-	if done {
 85-		logger.Info("A conditaionl request was detected, no body required")
 86-		// A conditional request was detected, status and headers are set, no body required (either 412 or 304)
 87+	if status != http.StatusOK {
 88+		w.WriteHeader(status)
 89+		_, err := io.Copy(w, contents)
 90+		if err != nil {
 91+			logger.Error("io copy", "err", err.Error())
 92+		}
 93 		return
 94 	}
 95-	w.WriteHeader(status)
 96-	_, err := io.Copy(w, contents)
 97-
 98-	if err != nil {
 99-		logger.Error("io copy", "err", err.Error())
100-	}
101+	http.ServeContent(w, r, assetFilepath, info.LastModified.UTC(), contents)
102 }
M pkg/apps/pgs/web_test.go
+137, -42
  1@@ -1,20 +1,118 @@
  2 package pgs
  3 
  4 import (
  5+	"context"
  6 	"fmt"
  7-	"io"
  8 	"log/slog"
  9 	"net/http"
 10 	"net/http/httptest"
 11+	"os"
 12+	"os/exec"
 13 	"strings"
 14 	"testing"
 15 	"time"
 16 
 17 	pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
 18+	"github.com/picosh/pico/pkg/send/utils"
 19 	"github.com/picosh/pico/pkg/shared"
 20+	"github.com/picosh/pico/pkg/shared/mime"
 21 	"github.com/picosh/pico/pkg/storage"
 22+	"github.com/testcontainers/testcontainers-go"
 23+	"github.com/testcontainers/testcontainers-go/wait"
 24 )
 25 
 26+// var imgproxyContainer testcontainers.Container.
 27+var imgproxyURL string
 28+
 29+// setupContainerRuntime checks for a container runtime (podman/docker) and
 30+// sets DOCKER_HOST so testcontainers can connect.
 31+func setupContainerRuntime() bool {
 32+	if cmd := exec.Command("podman", "info"); cmd.Run() == nil {
 33+		_ = os.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true")
 34+		xdgRuntime := os.Getenv("XDG_RUNTIME_DIR")
 35+		if xdgRuntime != "" {
 36+			socketPath := xdgRuntime + "/podman/podman.sock"
 37+			if _, err := os.Stat(socketPath); err == nil {
 38+				_ = os.Setenv("DOCKER_HOST", "unix://"+socketPath)
 39+				return true
 40+			}
 41+		}
 42+		return false
 43+	}
 44+
 45+	if cmd := exec.Command("docker", "info"); cmd.Run() == nil {
 46+		return true
 47+	}
 48+	return false
 49+}
 50+
 51+func TestMain(m *testing.M) {
 52+	ctx := context.Background()
 53+
 54+	if !setupContainerRuntime() {
 55+		fmt.Fprintf(os.Stderr, "Container runtime not available, skipping image manipulation tests\n")
 56+		fmt.Fprintf(os.Stderr, "To run tests, either:\n")
 57+		fmt.Fprintf(os.Stderr, "  - Start podman socket: systemctl --user start podman.socket\n")
 58+		fmt.Fprintf(os.Stderr, "  - Start docker daemon\n")
 59+		os.Exit(m.Run())
 60+	}
 61+
 62+	imgproxyContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
 63+		ContainerRequest: testcontainers.ContainerRequest{
 64+			Image:        "docker.io/darthsim/imgproxy:latest",
 65+			ExposedPorts: []string{"8080/tcp"},
 66+			WaitingFor:   wait.ForLog("INFO imgproxy is ready to listen"),
 67+		},
 68+		Started: true,
 69+	})
 70+	if err != nil {
 71+		fmt.Fprintf(os.Stderr, "Failed to start imgproxy container (Docker/Podman may not be running): %s\n", err)
 72+		fmt.Fprintf(os.Stderr, "Skipping image manipulation tests.\n")
 73+		os.Exit(m.Run())
 74+	}
 75+
 76+	host, err := imgproxyContainer.Host(ctx)
 77+	if err != nil {
 78+		fmt.Fprintf(os.Stderr, "Failed to get imgproxy host: %s\n", err)
 79+		os.Exit(m.Run())
 80+	}
 81+
 82+	port, err := imgproxyContainer.MappedPort(ctx, "8080")
 83+	if err != nil {
 84+		fmt.Fprintf(os.Stderr, "Failed to get imgproxy port: %s\n", err)
 85+		os.Exit(m.Run())
 86+	}
 87+
 88+	imgproxyURL = fmt.Sprintf("http://%s:%s", host, port)
 89+	_ = os.Setenv("IMGPROXY_URL", imgproxyURL)
 90+
 91+	code := m.Run()
 92+
 93+	_ = imgproxyContainer.Terminate(ctx)
 94+	os.Exit(code)
 95+}
 96+
 97+// testStorage wraps storage.StorageServe to inject ObjectInfo fields that
 98+// production backends (S3, GCS) provide but the in-memory test storage does not.
 99+type testStorage struct {
100+	storage.StorageServe
101+}
102+
103+func newTestStorage(st storage.StorageServe) *testStorage {
104+	return &testStorage{st}
105+}
106+
107+func (t *testStorage) GetObject(bucket storage.Bucket, fpath string) (utils.ReadAndReaderAtCloser, *storage.ObjectInfo, error) {
108+	r, info, err := t.StorageServe.GetObject(bucket, fpath)
109+	if info.Metadata == nil {
110+		info.Metadata = make(http.Header)
111+	}
112+	info.Metadata.Set("content-type", mime.GetMimeType(fpath))
113+	info.LastModified = time.Now().UTC()
114+	info.ETag = "static-etag-for-testing-purposes"
115+	return r, info, err
116+}
117+
118 type ApiExample struct {
119 	name        string
120 	path        string
121@@ -316,7 +414,11 @@ func TestApiBasic(t *testing.T) {
122 			}
123 			responseRecorder := httptest.NewRecorder()
124 
125-			st, _ := storage.NewStorageMemory(tc.storage)
126+			memSt, err := storage.NewStorageMemory(tc.storage)
127+			if err != nil {
128+				t.Fatal(err)
129+			}
130+			st := newTestStorage(memSt)
131 			pubsub := NewPubsubChan()
132 			defer func() {
133 				_ = pubsub.Close()
134@@ -440,7 +542,11 @@ func TestDirectoryListing(t *testing.T) {
135 			request := httptest.NewRequest("GET", dbpool.mkpath(tc.path), strings.NewReader(""))
136 			responseRecorder := httptest.NewRecorder()
137 
138-			st, _ := storage.NewStorageMemory(tc.storage)
139+			memSt, err := storage.NewStorageMemory(tc.storage)
140+			if err != nil {
141+				t.Fatal(err)
142+			}
143+			st := newTestStorage(memSt)
144 			pubsub := NewPubsubChan()
145 			defer func() {
146 				_ = pubsub.Close()
147@@ -474,51 +580,50 @@ func TestDirectoryListing(t *testing.T) {
148 	}
149 }
150 
151-type ImageStorageMemory struct {
152-	*storage.StorageMemory
153-	Opts  *storage.ImgProcessOpts
154-	Fpath string
155-}
156-
157-func (s *ImageStorageMemory) ServeObject(r *http.Request, bucket storage.Bucket, fpath string, opts *storage.ImgProcessOpts) (io.ReadCloser, *storage.ObjectInfo, error) {
158-	s.Opts = opts
159-	s.Fpath = fpath
160-	info := storage.ObjectInfo{
161-		Metadata: make(http.Header),
162+// minimalJPEG returns a minimal valid 1x1 JPEG image.
163+func minimalJPEG(t *testing.T) []byte {
164+	data, err := os.ReadFile("splash.jpg")
165+	if err != nil {
166+		t.Fatal(err)
167 	}
168-	info.Metadata.Set("content-type", "image/jpeg")
169-	return io.NopCloser(strings.NewReader("hello world!")), &info, nil
170+	return data
171 }
172 
173 func TestImageManipulation(t *testing.T) {
174+	if imgproxyURL == "" {
175+		t.Skip("imgproxy container not available")
176+	}
177+
178 	logger := slog.Default()
179 	dbpool := NewPgsDb(logger)
180 	bucketName := shared.GetAssetBucketName(dbpool.Users[0].ID)
181 
182-	tt := []ApiExample{
183+	tt := []struct {
184+		name        string
185+		path        string
186+		status      int
187+		contentType string
188+		storage     map[string]map[string]string
189+	}{
190 		{
191 			name:        "root-img",
192 			path:        "/app.jpg/s:500/rt:90",
193-			want:        "hello world!",
194 			status:      http.StatusOK,
195 			contentType: "image/jpeg",
196-
197 			storage: map[string]map[string]string{
198 				bucketName: {
199-					"/test/app.jpg": "hello world!",
200+					"/test/app.jpg": string(minimalJPEG(t)),
201 				},
202 			},
203 		},
204 		{
205 			name:        "root-subdir-img",
206 			path:        "/subdir/app.jpg/rt:90/s:500",
207-			want:        "hello world!",
208 			status:      http.StatusOK,
209 			contentType: "image/jpeg",
210-
211 			storage: map[string]map[string]string{
212 				bucketName: {
213-					"/test/subdir/app.jpg": "hello world!",
214+					"/test/subdir/app.jpg": string(minimalJPEG(t)),
215 				},
216 			},
217 		},
218@@ -529,13 +634,11 @@ func TestImageManipulation(t *testing.T) {
219 			request := httptest.NewRequest("GET", dbpool.mkpath(tc.path), strings.NewReader(""))
220 			responseRecorder := httptest.NewRecorder()
221 
222-			memst, _ := storage.NewStorageMemory(tc.storage)
223-			st := &ImageStorageMemory{
224-				StorageMemory: memst,
225-				Opts: &storage.ImgProcessOpts{
226-					Ratio: &storage.Ratio{},
227-				},
228+			memSt, err := storage.NewStorageMemory(tc.storage)
229+			if err != nil {
230+				t.Fatal(err)
231 			}
232+			st := newTestStorage(memSt)
233 			pubsub := NewPubsubChan()
234 			defer func() {
235 				_ = pubsub.Close()
236@@ -554,19 +657,11 @@ func TestImageManipulation(t *testing.T) {
237 				t.Errorf("Want content type '%s', got '%s'", tc.contentType, ct)
238 			}
239 
240-			body := strings.TrimSpace(responseRecorder.Body.String())
241-			if body != tc.want {
242-				t.Errorf("Want '%s', got '%s'", tc.want, body)
243-			}
244-
245-			if st.Opts.Ratio.Width != 500 {
246-				t.Errorf("Want ratio width '500', got '%d'", st.Opts.Ratio.Width)
247-				return
248-			}
249-
250-			if st.Opts.Rotate != 90 {
251-				t.Errorf("Want rotate '90', got '%d'", st.Opts.Rotate)
252-				return
253+			// With a real imgproxy, the response is binary image data.
254+			// Verify we got some content back (not empty).
255+			body := responseRecorder.Body.Bytes()
256+			if len(body) == 0 {
257+				t.Error("Expected non-empty image response body")
258 			}
259 		})
260 	}
M pkg/apps/prose/api.go
+3, -46
 1@@ -4,7 +4,6 @@ import (
 2 	"bytes"
 3 	"fmt"
 4 	"html/template"
 5-	"io"
 6 	"net/http"
 7 	"net/url"
 8 	"os"
 9@@ -990,51 +989,9 @@ func imgRequest(w http.ResponseWriter, r *http.Request) {
10 		http.Error(w, err.Error(), http.StatusUnprocessableEntity)
11 		return
12 	}
13-
14-	contents, info, err := st.ServeObject(r, bucket, fname, opts)
15-	if err != nil {
16-		logger.Error("serve object", "err", err)
17-		http.Error(w, err.Error(), http.StatusUnprocessableEntity)
18-		return
19-	}
20-	defer func() {
21-		_ = contents.Close()
22-	}()
23-
24-	contentType := ""
25-	if info != nil {
26-		contentType = info.Metadata.Get("content-type")
27-		if info.Size != 0 {
28-			w.Header().Add("content-length", strconv.Itoa(int(info.Size)))
29-		}
30-		if info.ETag != "" {
31-			// Minio SDK trims off the mandatory quotes (RFC 7232 ยง 2.3)
32-			w.Header().Add("etag", fmt.Sprintf("\"%s\"", info.ETag))
33-		}
34-
35-		if !info.LastModified.IsZero() {
36-			w.Header().Add("last-modified", info.LastModified.UTC().Format(http.TimeFormat))
37-		}
38-	}
39-
40-	if w.Header().Get("content-type") == "" {
41-		w.Header().Set("content-type", contentType)
42-	}
43-
44-	// Allows us to invalidate the cache when files are modified
45-	// w.Header().Set("surrogate-key", h.Subdomain)
46-
47-	finContentType := w.Header().Get("content-type")
48-	logger.Info(
49-		"serving asset",
50-		"asset", fname,
51-		"contentType", finContentType,
52-	)
53-
54-	_, err = io.Copy(w, contents)
55-	if err != nil {
56-		logger.Error("io copy", "err", err)
57-	}
58+	fp := filepath.Join(bucket.Path, fname)
59+	imgproxy := storage.NewImgProxy(fp, opts)
60+	imgproxy.ServeHTTP(w, r)
61 }
62 
63 func createSubdomainRoutes(staticRoutes []router.Route) []router.Route {
M pkg/storage/fs.go
+0, -23
 1@@ -320,26 +320,3 @@ func (s *StorageFS) ListObjects(bucket Bucket, dir string, recursive bool) ([]os
 2 
 3 	return fileList, err
 4 }
 5-
 6-func (s *StorageFS) ServeObject(r *http.Request, bucket Bucket, fpath string, opts *ImgProcessOpts) (io.ReadCloser, *ObjectInfo, error) {
 7-	var rc io.ReadCloser
 8-	info := &ObjectInfo{}
 9-	var err error
10-	mimeType := mime.GetMimeType(fpath)
11-	if !strings.HasPrefix(mimeType, "image/") || opts == nil || os.Getenv("IMGPROXY_URL") == "" {
12-		rc, info, err = s.GetObject(bucket, fpath)
13-		if info.Metadata == nil {
14-			info.Metadata = map[string][]string{}
15-		}
16-		// StorageFS never returns a content-type.
17-		info.Metadata.Set("content-type", mimeType)
18-	} else {
19-		filePath := filepath.Join(bucket.Name, fpath)
20-		dataURL := fmt.Sprintf("local:///%s", filePath)
21-		rc, info, err = HandleProxy(r, s.Logger, dataURL, opts)
22-	}
23-	if err != nil {
24-		return nil, nil, err
25-	}
26-	return rc, info, err
27-}
M pkg/storage/memory.go
+8, -21
 1@@ -1,9 +1,9 @@
 2 package storage
 3 
 4 import (
 5+	"bytes"
 6 	"fmt"
 7 	"io"
 8-	"net/http"
 9 	"os"
10 	"path/filepath"
11 	"strings"
12@@ -11,9 +11,14 @@ import (
13 	"time"
14 
15 	"github.com/picosh/pico/pkg/send/utils"
16-	"github.com/picosh/pico/pkg/shared/mime"
17 )
18 
19+type seekableReader struct {
20+	*bytes.Reader
21+}
22+
23+func (s *seekableReader) Close() error { return nil }
24+
25 type StorageMemory struct {
26 	storage map[string]map[string]string
27 	mu      sync.RWMutex
28@@ -97,8 +102,7 @@ func (s *StorageMemory) GetObject(bucket Bucket, fpath string) (utils.ReadAndRea
29 	}
30 
31 	objInfo.Size = int64(len([]byte(dat)))
32-	reader := utils.NopReadAndReaderAtCloser(strings.NewReader(dat))
33-	return reader, objInfo, nil
34+	return &seekableReader{bytes.NewReader([]byte(dat))}, objInfo, nil
35 }
36 
37 func (s *StorageMemory) PutObject(bucket Bucket, fpath string, contents io.Reader, entry *utils.FileEntry) (string, int64, error) {
38@@ -207,20 +211,3 @@ func (s *StorageMemory) ListObjects(bucket Bucket, dir string, recursive bool) (
39 
40 	return fileList, nil
41 }
42-
43-func (s *StorageMemory) ServeObject(r *http.Request, bucket Bucket, fpath string, opts *ImgProcessOpts) (io.ReadCloser, *ObjectInfo, error) {
44-	obj, info, err := s.GetObject(bucket, fpath)
45-	if info.Metadata == nil {
46-		info.Metadata = make(http.Header)
47-	}
48-	// Make tests work by supplying non-null Last-Modified and Etag values
49-	if info.LastModified.IsZero() {
50-		info.LastModified = time.Now().UTC()
51-	}
52-	if info.ETag == "" {
53-		info.ETag = "static-etag-for-testing-purposes"
54-	}
55-	mimeType := mime.GetMimeType(fpath)
56-	info.Metadata.Set("content-type", mimeType)
57-	return obj, info, err
58-}
M pkg/storage/proxy.go
+82, -73
  1@@ -6,15 +6,94 @@ import (
  2 	"encoding/base64"
  3 	"encoding/hex"
  4 	"fmt"
  5-	"io"
  6-	"log/slog"
  7 	"net/http"
  8+	"net/http/httputil"
  9+	"net/url"
 10 	"os"
 11 	"strconv"
 12 	"strings"
 13-	"time"
 14+
 15+	"github.com/picosh/pico/pkg/shared/mime"
 16 )
 17 
 18+type ImgProxy struct {
 19+	url      string
 20+	salt     string
 21+	key      string
 22+	filepath string
 23+	opts     *ImgProcessOpts
 24+}
 25+
 26+func NewImgProxy(fp string, opts *ImgProcessOpts) *ImgProxy {
 27+	return &ImgProxy{
 28+		url:      os.Getenv("IMGPROXY_URL"),
 29+		salt:     os.Getenv("IMGPROXY_SALT"),
 30+		key:      os.Getenv("IMGPROXY_KEY"),
 31+		filepath: fp,
 32+		opts:     opts,
 33+	}
 34+}
 35+
 36+func (img *ImgProxy) CanServe() error {
 37+	if img.url == "" {
 38+		return fmt.Errorf("no imgproxy url provided")
 39+	}
 40+	if img.opts == nil {
 41+		return fmt.Errorf("no image options provided")
 42+	}
 43+	mimeType := mime.GetMimeType(img.filepath)
 44+	if !strings.HasPrefix(mimeType, "image/") {
 45+		return fmt.Errorf("file mimetype not an image")
 46+	}
 47+	return nil
 48+}
 49+
 50+func (img *ImgProxy) GetSig(ppath []byte) string {
 51+	signature := "_"
 52+	imgProxySalt := img.salt
 53+	imgProxyKey := img.key
 54+	if imgProxySalt == "" || imgProxyKey == "" {
 55+		return signature
 56+	}
 57+
 58+	keyBin, err := hex.DecodeString(imgProxyKey)
 59+	if err != nil {
 60+		return signature
 61+	}
 62+
 63+	saltBin, err := hex.DecodeString(imgProxySalt)
 64+	if err != nil {
 65+		return signature
 66+	}
 67+
 68+	mac := hmac.New(sha256.New, keyBin)
 69+	mac.Write(saltBin)
 70+	mac.Write(ppath)
 71+	return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
 72+}
 73+
 74+func (img *ImgProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 75+	dataURL := fmt.Sprintf("local:///%s", img.filepath)
 76+	imgProxyURL := img.url
 77+	processOpts := img.opts.String()
 78+	processPath := fmt.Sprintf(
 79+		"%s/%s",
 80+		processOpts,
 81+		base64.StdEncoding.EncodeToString([]byte(dataURL)),
 82+	)
 83+	sig := img.GetSig([]byte(processPath))
 84+
 85+	rurl := fmt.Sprintf("%s/%s%s", imgProxyURL, sig, processPath)
 86+	destUrl, err := url.Parse(rurl)
 87+	if err != nil {
 88+		msg := fmt.Sprintf("could not parse url: %s", rurl)
 89+		http.Error(w, msg, http.StatusInternalServerError)
 90+		return
 91+	}
 92+	proxy := httputil.NewSingleHostReverseProxy(destUrl)
 93+	proxy.ServeHTTP(w, r)
 94+}
 95+
 96 func UriToImgProcessOpts(uri string) (*ImgProcessOpts, error) {
 97 	opts := &ImgProcessOpts{}
 98 	parts := strings.Split(uri, "/")
 99@@ -130,73 +209,3 @@ func (img *ImgProcessOpts) String() string {
100 
101 	return processOpts
102 }
103-
104-func HandleProxy(r *http.Request, logger *slog.Logger, dataURL string, opts *ImgProcessOpts) (io.ReadCloser, *ObjectInfo, error) {
105-	imgProxyURL := os.Getenv("IMGPROXY_URL")
106-	imgProxySalt := os.Getenv("IMGPROXY_SALT")
107-	imgProxyKey := os.Getenv("IMGPROXY_KEY")
108-
109-	signature := "_"
110-
111-	processOpts := opts.String()
112-
113-	processPath := fmt.Sprintf("%s/%s", processOpts, base64.StdEncoding.EncodeToString([]byte(dataURL)))
114-
115-	if imgProxySalt != "" && imgProxyKey != "" {
116-		keyBin, err := hex.DecodeString(imgProxyKey)
117-		if err != nil {
118-			return nil, nil, err
119-		}
120-
121-		saltBin, err := hex.DecodeString(imgProxySalt)
122-		if err != nil {
123-			return nil, nil, err
124-		}
125-
126-		mac := hmac.New(sha256.New, keyBin)
127-		mac.Write(saltBin)
128-		mac.Write([]byte(processPath))
129-		signature = base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
130-	}
131-	proxyAddress := fmt.Sprintf("%s/%s%s", imgProxyURL, signature, processPath)
132-
133-	req, err := http.NewRequest(http.MethodGet, proxyAddress, nil)
134-	if err != nil {
135-		return nil, nil, err
136-	}
137-	req.Header.Set("accept", r.Header.Get("accept"))
138-	req.Header.Set("accept-encoding", r.Header.Get("accept-encoding"))
139-	req.Header.Set("accept-language", r.Header.Get("accept-language"))
140-	res, err := http.DefaultClient.Do(req)
141-	if err != nil {
142-		return nil, nil, err
143-	}
144-
145-	if res.StatusCode < 200 || res.StatusCode >= 300 {
146-		return nil, nil, fmt.Errorf("imgproxy returned %s", res.Status)
147-	}
148-	lastModified := res.Header.Get("Last-Modified")
149-	parsedTime, err := time.Parse(time.RFC1123, lastModified)
150-	if err != nil {
151-		logger.Error("decoding last-modified", "err", err)
152-	}
153-	info := &ObjectInfo{
154-		Size:     res.ContentLength,
155-		ETag:     trimEtag(res.Header.Get("etag")),
156-		Metadata: res.Header.Clone(),
157-	}
158-	if strings.HasPrefix(info.Metadata.Get("content-type"), "text/xml") {
159-		info.Metadata.Set("content-type", "image/svg+xml")
160-	}
161-	if !parsedTime.IsZero() {
162-		info.LastModified = parsedTime
163-	}
164-
165-	return res.Body, info, nil
166-}
167-
168-// trimEtag removes quotes from the etag header, which matches the behavior of the minio-go SDK.
169-func trimEtag(etag string) string {
170-	etag = strings.TrimPrefix(etag, "\"")
171-	return strings.TrimSuffix(etag, "\"")
172-}
M pkg/storage/storage.go
+0, -1
1@@ -40,5 +40,4 @@ type ObjectStorage interface {
2 type StorageServe interface {
3 	BucketStorage
4 	ObjectStorage
5-	ServeObject(r *http.Request, bucket Bucket, fpath string, opts *ImgProcessOpts) (io.ReadCloser, *ObjectInfo, error)
6 }
A splash.jpg
+0, -0