- 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
+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-}
+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)
+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 }
+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 }
+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 {
+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-}
+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-}
+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-}
+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 }
+0,
-0