Commit 0a4c670

Eric Bower  ·  2026-04-20 08:42:56 -0400 EDT
parent 433e9d1
refactor(pgs): use http.ServeContent

refactor: new image proxy object that uses httputil reverse proxy
10 files changed,  +272, -525
+0, -282
......@@ -1,282 +0,0 @@
1-// Copyright 2009 The Go Authors.
2-
3-// Redistribution and use in source and binary forms, with or without
4-// modification, are permitted provided that the following conditions are
5-// met:
6-
7-// * Redistributions of source code must retain the above copyright
8-// notice, this list of conditions and the following disclaimer.
9-// * Redistributions in binary form must reproduce the above
10-// copyright notice, this list of conditions and the following disclaimer
11-// in the documentation and/or other materials provided with the
12-// distribution.
13-// * Neither the name of Google LLC nor the names of its
14-// contributors may be used to endorse or promote products derived from
15-// this software without specific prior written permission.
16-
17-// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18-// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19-// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20-// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21-// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22-// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23-// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24-// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25-// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26-// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27-// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28-
29-// HTTP file system request handler
30-//
31-// Upstream: https://cs.opensource.google/go/go/+/refs/tags/go1.23.4:src/net/http/fs.go
32-// Modifications from upstream:
33-// * Deleted everything except checkPreconditions and dependent functions
34-// * Added "http" package prefixes
35-
36-package pgs
37-
38-import (
39- "net/http"
40- "net/textproto"
41- "strings"
42- "time"
43-)
44-
45-// scanETag determines if a syntactically valid ETag is present at s. If so,
46-// the ETag and remaining text after consuming ETag is returned. Otherwise,
47-// it returns "", "".
48-func scanETag(s string) (etag string, remain string) {
49- s = textproto.TrimString(s)
50- start := 0
51- if strings.HasPrefix(s, "W/") {
52- start = 2
53- }
54- if len(s[start:]) < 2 || s[start] != '"' {
55- return "", ""
56- }
57- // ETag is either W/"text" or "text".
58- // See RFC 7232 2.3.
59- for i := start + 1; i < len(s); i++ {
60- c := s[i]
61- switch {
62- // Character values allowed in ETags.
63- case c == 0x21 || c >= 0x23 && c <= 0x7E || c >= 0x80:
64- case c == '"':
65- return s[:i+1], s[i+1:]
66- default:
67- return "", ""
68- }
69- }
70- return "", ""
71-}
72-
73-// etagStrongMatch reports whether a and b match using strong ETag comparison.
74-// Assumes a and b are valid ETags.
75-func etagStrongMatch(a, b string) bool {
76- return a == b && a != "" && a[0] == '"'
77-}
78-
79-// etagWeakMatch reports whether a and b match using weak ETag comparison.
80-// Assumes a and b are valid ETags.
81-func etagWeakMatch(a, b string) bool {
82- return strings.TrimPrefix(a, "W/") == strings.TrimPrefix(b, "W/")
83-}
84-
85-// condResult is the result of an HTTP request precondition check.
86-// See https://tools.ietf.org/html/rfc7232 section 3.
87-type condResult int
88-
89-const (
90- condNone condResult = iota
91- condTrue
92- condFalse
93-)
94-
95-func checkIfMatch(w http.ResponseWriter, r *http.Request) condResult {
96- im := r.Header.Get("If-Match")
97- if im == "" {
98- return condNone
99- }
100- for {
101- im = textproto.TrimString(im)
102- if len(im) == 0 {
103- break
104- }
105- if im[0] == ',' {
106- im = im[1:]
107- continue
108- }
109- if im[0] == '*' {
110- return condTrue
111- }
112- etag, remain := scanETag(im)
113- if etag == "" {
114- break
115- }
116- if etagStrongMatch(etag, w.Header().Get("Etag")) {
117- return condTrue
118- }
119- im = remain
120- }
121-
122- return condFalse
123-}
124-
125-func checkIfUnmodifiedSince(r *http.Request, modtime time.Time) condResult {
126- ius := r.Header.Get("If-Unmodified-Since")
127- if ius == "" || isZeroTime(modtime) {
128- return condNone
129- }
130- t, err := http.ParseTime(ius)
131- if err != nil {
132- return condNone
133- }
134-
135- // The Last-Modified header truncates sub-second precision so
136- // the modtime needs to be truncated too.
137- modtime = modtime.Truncate(time.Second)
138- if ret := modtime.Compare(t); ret <= 0 {
139- return condTrue
140- }
141- return condFalse
142-}
143-
144-func checkIfNoneMatch(w http.ResponseWriter, r *http.Request) condResult {
145- inm := r.Header.Get("If-None-Match")
146- if inm == "" {
147- return condNone
148- }
149- buf := inm
150- for {
151- buf = textproto.TrimString(buf)
152- if len(buf) == 0 {
153- break
154- }
155- if buf[0] == ',' {
156- buf = buf[1:]
157- continue
158- }
159- if buf[0] == '*' {
160- return condFalse
161- }
162- etag, remain := scanETag(buf)
163- if etag == "" {
164- break
165- }
166- if etagWeakMatch(etag, w.Header().Get("Etag")) {
167- return condFalse
168- }
169- buf = remain
170- }
171- return condTrue
172-}
173-
174-func checkIfModifiedSince(r *http.Request, modtime time.Time) condResult {
175- if r.Method != "GET" && r.Method != "HEAD" {
176- return condNone
177- }
178- ims := r.Header.Get("If-Modified-Since")
179- if ims == "" || isZeroTime(modtime) {
180- return condNone
181- }
182- t, err := http.ParseTime(ims)
183- if err != nil {
184- return condNone
185- }
186- // The Last-Modified header truncates sub-second precision so
187- // the modtime needs to be truncated too.
188- modtime = modtime.Truncate(time.Second)
189- if ret := modtime.Compare(t); ret <= 0 {
190- return condFalse
191- }
192- return condTrue
193-}
194-
195-func checkIfRange(w http.ResponseWriter, r *http.Request, modtime time.Time) condResult {
196- if r.Method != "GET" && r.Method != "HEAD" {
197- return condNone
198- }
199- ir := r.Header.Get("If-Range")
200- if ir == "" {
201- return condNone
202- }
203- etag, _ := scanETag(ir)
204- if etag != "" {
205- if etagStrongMatch(etag, w.Header().Get("Etag")) {
206- return condTrue
207- } else {
208- return condFalse
209- }
210- }
211- // The If-Range value is typically the ETag value, but it may also be
212- // the modtime date. See golang.org/issue/8367.
213- if modtime.IsZero() {
214- return condFalse
215- }
216- t, err := http.ParseTime(ir)
217- if err != nil {
218- return condFalse
219- }
220- if t.Unix() == modtime.Unix() {
221- return condTrue
222- }
223- return condFalse
224-}
225-
226-var unixEpochTime = time.Unix(0, 0)
227-
228-// isZeroTime reports whether t is obviously unspecified (either zero or Unix()=0).
229-func isZeroTime(t time.Time) bool {
230- return t.IsZero() || t.Equal(unixEpochTime)
231-}
232-
233-func writeNotModified(w http.ResponseWriter) {
234- // RFC 7232 section 4.1:
235- // a sender SHOULD NOT generate representation metadata other than the
236- // above listed fields unless said metadata exists for the purpose of
237- // guiding cache updates (e.g., Last-Modified might be useful if the
238- // response does not have an ETag field).
239- h := w.Header()
240- delete(h, "Content-Type")
241- delete(h, "Content-Length")
242- delete(h, "Content-Encoding")
243- if h.Get("Etag") != "" {
244- delete(h, "Last-Modified")
245- }
246- w.WriteHeader(http.StatusNotModified)
247-}
248-
249-// checkPreconditions evaluates request preconditions and reports whether a precondition
250-// resulted in sending http.StatusNotModified or http.StatusPreconditionFailed.
251-func checkPreconditions(w http.ResponseWriter, r *http.Request, modtime time.Time) (done bool, rangeHeader string) {
252- // This function carefully follows RFC 7232 section 6.
253- ch := checkIfMatch(w, r)
254- if ch == condNone {
255- ch = checkIfUnmodifiedSince(r, modtime)
256- }
257- if ch == condFalse {
258- w.WriteHeader(http.StatusPreconditionFailed)
259- return true, ""
260- }
261- switch checkIfNoneMatch(w, r) {
262- case condFalse:
263- if r.Method == "GET" || r.Method == "HEAD" {
264- writeNotModified(w)
265- return true, ""
266- } else {
267- w.WriteHeader(http.StatusPreconditionFailed)
268- return true, ""
269- }
270- case condNone:
271- if checkIfModifiedSince(r, modtime) == condFalse {
272- writeNotModified(w)
273- return true, ""
274- }
275- }
276-
277- rangeHeader = r.Header.Get("Range")
278- if rangeHeader != "" && checkIfRange(w, r, modtime) == condFalse {
279- rangeHeader = ""
280- }
281- return false, rangeHeader
282-}
+5, -2
......@@ -570,12 +570,15 @@ func (h *UploadAssetHandler) writeAsset(s *pssh.SSHServerConnSession, reader io.
570570 // per site per 5 seconds.
571571 func runCacheQueue(cfg *PgsConfig, ctx context.Context) {
572572 var pendingFlushes sync.Map
573- tick := time.Tick(5 * time.Second)
573+ tick := time.NewTicker(5 * time.Second)
574+ defer tick.Stop()
574575 for {
575576 select {
577+ case <-ctx.Done():
578+ return
576579 case host := <-cfg.CacheClearingQueue:
577580 pendingFlushes.Store(host, host)
578- case <-tick:
581+ case <-tick.C:
579582 go func() {
580583 pendingFlushes.Range(func(key, value any) bool {
581584 pendingFlushes.Delete(key)
+37, -35
......@@ -84,7 +84,7 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
8484
8585 routes := calcRoutes(h.ProjectDir, fpath, redirects)
8686
87- var contents io.ReadCloser
87+ var contents io.ReadSeekCloser
8888 assetFilepath := ""
8989 var info *storage.ObjectInfo
9090 status := http.StatusOK
......@@ -134,39 +134,44 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
134134 "status", fp.Status,
135135 )
136136
137- proxy := httputil.NewSingleHostReverseProxy(destUrl)
138- oldDirector := proxy.Director
139- proxy.Director = func(r *http.Request) {
140- oldDirector(r)
141- r.Host = destUrl.Host
142- r.URL = destUrl
143- }
144- // Disable caching
145- proxy.ModifyResponse = func(r *http.Response) error {
146- r.Header.Set("cache-control", "no-cache")
147- return nil
137+ proxy := &httputil.ReverseProxy{
138+ Rewrite: func(r *httputil.ProxyRequest) {
139+ r.SetURL(destUrl)
140+ r.Out.Header.Set("Host", destUrl.Host)
141+ },
142+ ModifyResponse: func(resp *http.Response) error {
143+ resp.Header.Set("cache-control", "no-cache")
144+ return nil
145+ },
148146 }
149147 proxy.ServeHTTP(w, r)
150148 return
151149 }
152150
153- var c io.ReadCloser
154151 fpath := fp.Filepath
155152 attempts = append(attempts, fpath)
156153 logger = logger.With("object", fpath)
157- c, info, err = h.Cfg.Storage.ServeObject(
158- r,
159- h.Bucket,
160- fpath,
161- h.ImgProcessOpts,
162- )
163- if err != nil {
164- logger.Error("serving object", "err", err)
154+
155+ imgproxy := storage.NewImgProxy(fpath, h.ImgProcessOpts)
156+ err = imgproxy.CanServe()
157+ if err == nil {
158+ logger.Info("serving image with imgproxy")
159+ imgproxy.ServeHTTP(w, r)
160+ return
165161 } else {
166- contents = c
167- assetFilepath = fp.Filepath
168- status = fp.Status
169- break
162+ var c io.ReadSeekCloser
163+ c, info, err = h.Cfg.Storage.GetObject(
164+ h.Bucket,
165+ fpath,
166+ )
167+ if err != nil {
168+ logger.Error("serving object", "err", err)
169+ } else {
170+ contents = c
171+ assetFilepath = fp.Filepath
172+ status = fp.Status
173+ break
174+ }
170175 }
171176 }
172177
......@@ -294,16 +299,13 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
294299 "status", status,
295300 "contentType", finContentType,
296301 )
297- done, _ := checkPreconditions(w, r, info.LastModified.UTC())
298- if done {
299- logger.Info("A conditaionl request was detected, no body required")
300- // A conditional request was detected, status and headers are set, no body required (either 412 or 304)
302+ if status != http.StatusOK {
303+ w.WriteHeader(status)
304+ _, err := io.Copy(w, contents)
305+ if err != nil {
306+ logger.Error("io copy", "err", err.Error())
307+ }
301308 return
302309 }
303- w.WriteHeader(status)
304- _, err := io.Copy(w, contents)
305-
306- if err != nil {
307- logger.Error("io copy", "err", err.Error())
308- }
310+ http.ServeContent(w, r, assetFilepath, info.LastModified.UTC(), contents)
309311 }
+137, -42
......@@ -1,20 +1,118 @@
11 package pgs
22
33 import (
4+ "context"
45 "fmt"
5- "io"
66 "log/slog"
77 "net/http"
88 "net/http/httptest"
9+ "os"
10+ "os/exec"
911 "strings"
1012 "testing"
1113 "time"
1214
1315 pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
16+ "github.com/picosh/pico/pkg/send/utils"
1417 "github.com/picosh/pico/pkg/shared"
18+ "github.com/picosh/pico/pkg/shared/mime"
1519 "github.com/picosh/pico/pkg/storage"
20+ "github.com/testcontainers/testcontainers-go"
21+ "github.com/testcontainers/testcontainers-go/wait"
1622 )
1723
24+// var imgproxyContainer testcontainers.Container.
25+var imgproxyURL string
26+
27+// setupContainerRuntime checks for a container runtime (podman/docker) and
28+// sets DOCKER_HOST so testcontainers can connect.
29+func setupContainerRuntime() bool {
30+ if cmd := exec.Command("podman", "info"); cmd.Run() == nil {
31+ _ = os.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true")
32+ xdgRuntime := os.Getenv("XDG_RUNTIME_DIR")
33+ if xdgRuntime != "" {
34+ socketPath := xdgRuntime + "/podman/podman.sock"
35+ if _, err := os.Stat(socketPath); err == nil {
36+ _ = os.Setenv("DOCKER_HOST", "unix://"+socketPath)
37+ return true
38+ }
39+ }
40+ return false
41+ }
42+
43+ if cmd := exec.Command("docker", "info"); cmd.Run() == nil {
44+ return true
45+ }
46+ return false
47+}
48+
49+func TestMain(m *testing.M) {
50+ ctx := context.Background()
51+
52+ if !setupContainerRuntime() {
53+ fmt.Fprintf(os.Stderr, "Container runtime not available, skipping image manipulation tests\n")
54+ fmt.Fprintf(os.Stderr, "To run tests, either:\n")
55+ fmt.Fprintf(os.Stderr, " - Start podman socket: systemctl --user start podman.socket\n")
56+ fmt.Fprintf(os.Stderr, " - Start docker daemon\n")
57+ os.Exit(m.Run())
58+ }
59+
60+ imgproxyContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
61+ ContainerRequest: testcontainers.ContainerRequest{
62+ Image: "docker.io/darthsim/imgproxy:latest",
63+ ExposedPorts: []string{"8080/tcp"},
64+ WaitingFor: wait.ForLog("INFO imgproxy is ready to listen"),
65+ },
66+ Started: true,
67+ })
68+ if err != nil {
69+ fmt.Fprintf(os.Stderr, "Failed to start imgproxy container (Docker/Podman may not be running): %s\n", err)
70+ fmt.Fprintf(os.Stderr, "Skipping image manipulation tests.\n")
71+ os.Exit(m.Run())
72+ }
73+
74+ host, err := imgproxyContainer.Host(ctx)
75+ if err != nil {
76+ fmt.Fprintf(os.Stderr, "Failed to get imgproxy host: %s\n", err)
77+ os.Exit(m.Run())
78+ }
79+
80+ port, err := imgproxyContainer.MappedPort(ctx, "8080")
81+ if err != nil {
82+ fmt.Fprintf(os.Stderr, "Failed to get imgproxy port: %s\n", err)
83+ os.Exit(m.Run())
84+ }
85+
86+ imgproxyURL = fmt.Sprintf("http://%s:%s", host, port)
87+ _ = os.Setenv("IMGPROXY_URL", imgproxyURL)
88+
89+ code := m.Run()
90+
91+ _ = imgproxyContainer.Terminate(ctx)
92+ os.Exit(code)
93+}
94+
95+// testStorage wraps storage.StorageServe to inject ObjectInfo fields that
96+// production backends (S3, GCS) provide but the in-memory test storage does not.
97+type testStorage struct {
98+ storage.StorageServe
99+}
100+
101+func newTestStorage(st storage.StorageServe) *testStorage {
102+ return &testStorage{st}
103+}
104+
105+func (t *testStorage) GetObject(bucket storage.Bucket, fpath string) (utils.ReadAndReaderAtCloser, *storage.ObjectInfo, error) {
106+ r, info, err := t.StorageServe.GetObject(bucket, fpath)
107+ if info.Metadata == nil {
108+ info.Metadata = make(http.Header)
109+ }
110+ info.Metadata.Set("content-type", mime.GetMimeType(fpath))
111+ info.LastModified = time.Now().UTC()
112+ info.ETag = "static-etag-for-testing-purposes"
113+ return r, info, err
114+}
115+
18116 type ApiExample struct {
19117 name string
20118 path string
......@@ -316,7 +414,11 @@ func TestApiBasic(t *testing.T) {
316414 }
317415 responseRecorder := httptest.NewRecorder()
318416
319- st, _ := storage.NewStorageMemory(tc.storage)
417+ memSt, err := storage.NewStorageMemory(tc.storage)
418+ if err != nil {
419+ t.Fatal(err)
420+ }
421+ st := newTestStorage(memSt)
320422 pubsub := NewPubsubChan()
321423 defer func() {
322424 _ = pubsub.Close()
......@@ -440,7 +542,11 @@ func TestDirectoryListing(t *testing.T) {
440542 request := httptest.NewRequest("GET", dbpool.mkpath(tc.path), strings.NewReader(""))
441543 responseRecorder := httptest.NewRecorder()
442544
443- st, _ := storage.NewStorageMemory(tc.storage)
545+ memSt, err := storage.NewStorageMemory(tc.storage)
546+ if err != nil {
547+ t.Fatal(err)
548+ }
549+ st := newTestStorage(memSt)
444550 pubsub := NewPubsubChan()
445551 defer func() {
446552 _ = pubsub.Close()
......@@ -474,51 +580,50 @@ func TestDirectoryListing(t *testing.T) {
474580 }
475581 }
476582
477-type ImageStorageMemory struct {
478- *storage.StorageMemory
479- Opts *storage.ImgProcessOpts
480- Fpath string
481-}
482-
483-func (s *ImageStorageMemory) ServeObject(r *http.Request, bucket storage.Bucket, fpath string, opts *storage.ImgProcessOpts) (io.ReadCloser, *storage.ObjectInfo, error) {
484- s.Opts = opts
485- s.Fpath = fpath
486- info := storage.ObjectInfo{
487- Metadata: make(http.Header),
583+// minimalJPEG returns a minimal valid 1x1 JPEG image.
584+func minimalJPEG(t *testing.T) []byte {
585+ data, err := os.ReadFile("splash.jpg")
586+ if err != nil {
587+ t.Fatal(err)
488588 }
489- info.Metadata.Set("content-type", "image/jpeg")
490- return io.NopCloser(strings.NewReader("hello world!")), &info, nil
589+ return data
491590 }
492591
493592 func TestImageManipulation(t *testing.T) {
593+ if imgproxyURL == "" {
594+ t.Skip("imgproxy container not available")
595+ }
596+
494597 logger := slog.Default()
495598 dbpool := NewPgsDb(logger)
496599 bucketName := shared.GetAssetBucketName(dbpool.Users[0].ID)
497600
498- tt := []ApiExample{
601+ tt := []struct {
602+ name string
603+ path string
604+ status int
605+ contentType string
606+ storage map[string]map[string]string
607+ }{
499608 {
500609 name: "root-img",
501610 path: "/app.jpg/s:500/rt:90",
502- want: "hello world!",
503611 status: http.StatusOK,
504612 contentType: "image/jpeg",
505-
506613 storage: map[string]map[string]string{
507614 bucketName: {
508- "/test/app.jpg": "hello world!",
615+ "/test/app.jpg": string(minimalJPEG(t)),
509616 },
510617 },
511618 },
512619 {
513620 name: "root-subdir-img",
514621 path: "/subdir/app.jpg/rt:90/s:500",
515- want: "hello world!",
516622 status: http.StatusOK,
517623 contentType: "image/jpeg",
518-
519624 storage: map[string]map[string]string{
520625 bucketName: {
521- "/test/subdir/app.jpg": "hello world!",
626+ "/test/subdir/app.jpg": string(minimalJPEG(t)),
522627 },
523628 },
524629 },
......@@ -529,13 +634,11 @@ func TestImageManipulation(t *testing.T) {
529634 request := httptest.NewRequest("GET", dbpool.mkpath(tc.path), strings.NewReader(""))
530635 responseRecorder := httptest.NewRecorder()
531636
532- memst, _ := storage.NewStorageMemory(tc.storage)
533- st := &ImageStorageMemory{
534- StorageMemory: memst,
535- Opts: &storage.ImgProcessOpts{
536- Ratio: &storage.Ratio{},
537- },
637+ memSt, err := storage.NewStorageMemory(tc.storage)
638+ if err != nil {
639+ t.Fatal(err)
538640 }
641+ st := newTestStorage(memSt)
539642 pubsub := NewPubsubChan()
540643 defer func() {
541644 _ = pubsub.Close()
......@@ -554,19 +657,11 @@ func TestImageManipulation(t *testing.T) {
554657 t.Errorf("Want content type '%s', got '%s'", tc.contentType, ct)
555658 }
556659
557- body := strings.TrimSpace(responseRecorder.Body.String())
558- if body != tc.want {
559- t.Errorf("Want '%s', got '%s'", tc.want, body)
560- }
561-
562- if st.Opts.Ratio.Width != 500 {
563- t.Errorf("Want ratio width '500', got '%d'", st.Opts.Ratio.Width)
564- return
565- }
566-
567- if st.Opts.Rotate != 90 {
568- t.Errorf("Want rotate '90', got '%d'", st.Opts.Rotate)
569- return
660+ // With a real imgproxy, the response is binary image data.
661+ // Verify we got some content back (not empty).
662+ body := responseRecorder.Body.Bytes()
663+ if len(body) == 0 {
664+ t.Error("Expected non-empty image response body")
570665 }
571666 })
572667 }
+3, -46
......@@ -4,7 +4,6 @@ import (
44 "bytes"
55 "fmt"
66 "html/template"
7- "io"
87 "net/http"
98 "net/url"
109 "os"
......@@ -990,51 +989,9 @@ func imgRequest(w http.ResponseWriter, r *http.Request) {
990989 http.Error(w, err.Error(), http.StatusUnprocessableEntity)
991990 return
992991 }
993-
994- contents, info, err := st.ServeObject(r, bucket, fname, opts)
995- if err != nil {
996- logger.Error("serve object", "err", err)
997- http.Error(w, err.Error(), http.StatusUnprocessableEntity)
998- return
999- }
1000- defer func() {
1001- _ = contents.Close()
1002- }()
1003-
1004- contentType := ""
1005- if info != nil {
1006- contentType = info.Metadata.Get("content-type")
1007- if info.Size != 0 {
1008- w.Header().Add("content-length", strconv.Itoa(int(info.Size)))
1009- }
1010- if info.ETag != "" {
1011- // Minio SDK trims off the mandatory quotes (RFC 7232 § 2.3)
1012- w.Header().Add("etag", fmt.Sprintf("\"%s\"", info.ETag))
1013- }
1014-
1015- if !info.LastModified.IsZero() {
1016- w.Header().Add("last-modified", info.LastModified.UTC().Format(http.TimeFormat))
1017- }
1018- }
1019-
1020- if w.Header().Get("content-type") == "" {
1021- w.Header().Set("content-type", contentType)
1022- }
1023-
1024- // Allows us to invalidate the cache when files are modified
1025- // w.Header().Set("surrogate-key", h.Subdomain)
1026-
1027- finContentType := w.Header().Get("content-type")
1028- logger.Info(
1029- "serving asset",
1030- "asset", fname,
1031- "contentType", finContentType,
1032- )
1033-
1034- _, err = io.Copy(w, contents)
1035- if err != nil {
1036- logger.Error("io copy", "err", err)
1037- }
992+ fp := filepath.Join(bucket.Path, fname)
993+ imgproxy := storage.NewImgProxy(fp, opts)
994+ imgproxy.ServeHTTP(w, r)
1038995 }
1039996
1040997 func createSubdomainRoutes(staticRoutes []router.Route) []router.Route {
+0, -23
......@@ -320,26 +320,3 @@ func (s *StorageFS) ListObjects(bucket Bucket, dir string, recursive bool) ([]os
320320
321321 return fileList, err
322322 }
323-
324-func (s *StorageFS) ServeObject(r *http.Request, bucket Bucket, fpath string, opts *ImgProcessOpts) (io.ReadCloser, *ObjectInfo, error) {
325- var rc io.ReadCloser
326- info := &ObjectInfo{}
327- var err error
328- mimeType := mime.GetMimeType(fpath)
329- if !strings.HasPrefix(mimeType, "image/") || opts == nil || os.Getenv("IMGPROXY_URL") == "" {
330- rc, info, err = s.GetObject(bucket, fpath)
331- if info.Metadata == nil {
332- info.Metadata = map[string][]string{}
333- }
334- // StorageFS never returns a content-type.
335- info.Metadata.Set("content-type", mimeType)
336- } else {
337- filePath := filepath.Join(bucket.Name, fpath)
338- dataURL := fmt.Sprintf("local:///%s", filePath)
339- rc, info, err = HandleProxy(r, s.Logger, dataURL, opts)
340- }
341- if err != nil {
342- return nil, nil, err
343- }
344- return rc, info, err
345-}
+8, -21
......@@ -1,9 +1,9 @@
11 package storage
22
33 import (
4+ "bytes"
45 "fmt"
56 "io"
6- "net/http"
77 "os"
88 "path/filepath"
99 "strings"
......@@ -11,9 +11,14 @@ import (
1111 "time"
1212
1313 "github.com/picosh/pico/pkg/send/utils"
14- "github.com/picosh/pico/pkg/shared/mime"
1514 )
1615
16+type seekableReader struct {
17+ *bytes.Reader
18+}
19+
20+func (s *seekableReader) Close() error { return nil }
21+
1722 type StorageMemory struct {
1823 storage map[string]map[string]string
1924 mu sync.RWMutex
......@@ -97,8 +102,7 @@ func (s *StorageMemory) GetObject(bucket Bucket, fpath string) (utils.ReadAndRea
97102 }
98103
99104 objInfo.Size = int64(len([]byte(dat)))
100- reader := utils.NopReadAndReaderAtCloser(strings.NewReader(dat))
101- return reader, objInfo, nil
105+ return &seekableReader{bytes.NewReader([]byte(dat))}, objInfo, nil
102106 }
103107
104108 func (s *StorageMemory) PutObject(bucket Bucket, fpath string, contents io.Reader, entry *utils.FileEntry) (string, int64, error) {
......@@ -207,20 +211,3 @@ func (s *StorageMemory) ListObjects(bucket Bucket, dir string, recursive bool) (
207211
208212 return fileList, nil
209213 }
210-
211-func (s *StorageMemory) ServeObject(r *http.Request, bucket Bucket, fpath string, opts *ImgProcessOpts) (io.ReadCloser, *ObjectInfo, error) {
212- obj, info, err := s.GetObject(bucket, fpath)
213- if info.Metadata == nil {
214- info.Metadata = make(http.Header)
215- }
216- // Make tests work by supplying non-null Last-Modified and Etag values
217- if info.LastModified.IsZero() {
218- info.LastModified = time.Now().UTC()
219- }
220- if info.ETag == "" {
221- info.ETag = "static-etag-for-testing-purposes"
222- }
223- mimeType := mime.GetMimeType(fpath)
224- info.Metadata.Set("content-type", mimeType)
225- return obj, info, err
226-}
+82, -73
......@@ -6,15 +6,94 @@ import (
66 "encoding/base64"
77 "encoding/hex"
88 "fmt"
9- "io"
10- "log/slog"
119 "net/http"
10+ "net/http/httputil"
11+ "net/url"
1212 "os"
1313 "strconv"
1414 "strings"
15- "time"
15+
16+ "github.com/picosh/pico/pkg/shared/mime"
1617 )
1718
19+type ImgProxy struct {
20+ url string
21+ salt string
22+ key string
23+ filepath string
24+ opts *ImgProcessOpts
25+}
26+
27+func NewImgProxy(fp string, opts *ImgProcessOpts) *ImgProxy {
28+ return &ImgProxy{
29+ url: os.Getenv("IMGPROXY_URL"),
30+ salt: os.Getenv("IMGPROXY_SALT"),
31+ key: os.Getenv("IMGPROXY_KEY"),
32+ filepath: fp,
33+ opts: opts,
34+ }
35+}
36+
37+func (img *ImgProxy) CanServe() error {
38+ if img.url == "" {
39+ return fmt.Errorf("no imgproxy url provided")
40+ }
41+ if img.opts == nil {
42+ return fmt.Errorf("no image options provided")
43+ }
44+ mimeType := mime.GetMimeType(img.filepath)
45+ if !strings.HasPrefix(mimeType, "image/") {
46+ return fmt.Errorf("file mimetype not an image")
47+ }
48+ return nil
49+}
50+
51+func (img *ImgProxy) GetSig(ppath []byte) string {
52+ signature := "_"
53+ imgProxySalt := img.salt
54+ imgProxyKey := img.key
55+ if imgProxySalt == "" || imgProxyKey == "" {
56+ return signature
57+ }
58+
59+ keyBin, err := hex.DecodeString(imgProxyKey)
60+ if err != nil {
61+ return signature
62+ }
63+
64+ saltBin, err := hex.DecodeString(imgProxySalt)
65+ if err != nil {
66+ return signature
67+ }
68+
69+ mac := hmac.New(sha256.New, keyBin)
70+ mac.Write(saltBin)
71+ mac.Write(ppath)
72+ return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
73+}
74+
75+func (img *ImgProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
76+ dataURL := fmt.Sprintf("local:///%s", img.filepath)
77+ imgProxyURL := img.url
78+ processOpts := img.opts.String()
79+ processPath := fmt.Sprintf(
80+ "%s/%s",
81+ processOpts,
82+ base64.StdEncoding.EncodeToString([]byte(dataURL)),
83+ )
84+ sig := img.GetSig([]byte(processPath))
85+
86+ rurl := fmt.Sprintf("%s/%s%s", imgProxyURL, sig, processPath)
87+ destUrl, err := url.Parse(rurl)
88+ if err != nil {
89+ msg := fmt.Sprintf("could not parse url: %s", rurl)
90+ http.Error(w, msg, http.StatusInternalServerError)
91+ return
92+ }
93+ proxy := httputil.NewSingleHostReverseProxy(destUrl)
94+ proxy.ServeHTTP(w, r)
95+}
96+
1897 func UriToImgProcessOpts(uri string) (*ImgProcessOpts, error) {
1998 opts := &ImgProcessOpts{}
2099 parts := strings.Split(uri, "/")
......@@ -130,73 +209,3 @@ func (img *ImgProcessOpts) String() string {
130209
131210 return processOpts
132211 }
133-
134-func HandleProxy(r *http.Request, logger *slog.Logger, dataURL string, opts *ImgProcessOpts) (io.ReadCloser, *ObjectInfo, error) {
135- imgProxyURL := os.Getenv("IMGPROXY_URL")
136- imgProxySalt := os.Getenv("IMGPROXY_SALT")
137- imgProxyKey := os.Getenv("IMGPROXY_KEY")
138-
139- signature := "_"
140-
141- processOpts := opts.String()
142-
143- processPath := fmt.Sprintf("%s/%s", processOpts, base64.StdEncoding.EncodeToString([]byte(dataURL)))
144-
145- if imgProxySalt != "" && imgProxyKey != "" {
146- keyBin, err := hex.DecodeString(imgProxyKey)
147- if err != nil {
148- return nil, nil, err
149- }
150-
151- saltBin, err := hex.DecodeString(imgProxySalt)
152- if err != nil {
153- return nil, nil, err
154- }
155-
156- mac := hmac.New(sha256.New, keyBin)
157- mac.Write(saltBin)
158- mac.Write([]byte(processPath))
159- signature = base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
160- }
161- proxyAddress := fmt.Sprintf("%s/%s%s", imgProxyURL, signature, processPath)
162-
163- req, err := http.NewRequest(http.MethodGet, proxyAddress, nil)
164- if err != nil {
165- return nil, nil, err
166- }
167- req.Header.Set("accept", r.Header.Get("accept"))
168- req.Header.Set("accept-encoding", r.Header.Get("accept-encoding"))
169- req.Header.Set("accept-language", r.Header.Get("accept-language"))
170- res, err := http.DefaultClient.Do(req)
171- if err != nil {
172- return nil, nil, err
173- }
174-
175- if res.StatusCode < 200 || res.StatusCode >= 300 {
176- return nil, nil, fmt.Errorf("imgproxy returned %s", res.Status)
177- }
178- lastModified := res.Header.Get("Last-Modified")
179- parsedTime, err := time.Parse(time.RFC1123, lastModified)
180- if err != nil {
181- logger.Error("decoding last-modified", "err", err)
182- }
183- info := &ObjectInfo{
184- Size: res.ContentLength,
185- ETag: trimEtag(res.Header.Get("etag")),
186- Metadata: res.Header.Clone(),
187- }
188- if strings.HasPrefix(info.Metadata.Get("content-type"), "text/xml") {
189- info.Metadata.Set("content-type", "image/svg+xml")
190- }
191- if !parsedTime.IsZero() {
192- info.LastModified = parsedTime
193- }
194-
195- return res.Body, info, nil
196-}
197-
198-// trimEtag removes quotes from the etag header, which matches the behavior of the minio-go SDK.
199-func trimEtag(etag string) string {
200- etag = strings.TrimPrefix(etag, "\"")
201- return strings.TrimSuffix(etag, "\"")
202-}
+0, -1
......@@ -40,5 +40,4 @@ type ObjectStorage interface {
4040 type StorageServe interface {
4141 BucketStorage
4242 ObjectStorage
43- ServeObject(r *http.Request, bucket Bucket, fpath string, opts *ImgProcessOpts) (io.ReadCloser, *ObjectInfo, error)
4443 }
+0, -0
Binaries are not rendered as diffs.