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. | |
| 570 | 570 | // per site per 5 seconds. | |
| 571 | 571 | func runCacheQueue(cfg *PgsConfig, ctx context.Context) { | |
| 572 | 572 | var pendingFlushes sync.Map | |
| 573 | - | tick := time.Tick(5 * time.Second) | |
| 573 | + | tick := time.NewTicker(5 * time.Second) | |
| 574 | + | defer tick.Stop() | |
| 574 | 575 | for { | |
| 575 | 576 | select { | |
| 577 | + | case <-ctx.Done(): | |
| 578 | + | return | |
| 576 | 579 | case host := <-cfg.CacheClearingQueue: | |
| 577 | 580 | pendingFlushes.Store(host, host) | |
| 578 | - | case <-tick: | |
| 581 | + | case <-tick.C: | |
| 579 | 582 | go func() { | |
| 580 | 583 | pendingFlushes.Range(func(key, value any) bool { | |
| 581 | 584 | pendingFlushes.Delete(key) |
+37,
-35
| ... | ... | @@ -84,7 +84,7 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 84 | 84 | ||
| 85 | 85 | routes := calcRoutes(h.ProjectDir, fpath, redirects) | |
| 86 | 86 | ||
| 87 | - | var contents io.ReadCloser | |
| 87 | + | var contents io.ReadSeekCloser | |
| 88 | 88 | assetFilepath := "" | |
| 89 | 89 | var info *storage.ObjectInfo | |
| 90 | 90 | status := http.StatusOK |
| ... | ... | @@ -134,39 +134,44 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 134 | 134 | "status", fp.Status, | |
| 135 | 135 | ) | |
| 136 | 136 | ||
| 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 | + | }, | |
| 148 | 146 | } | |
| 149 | 147 | proxy.ServeHTTP(w, r) | |
| 150 | 148 | return | |
| 151 | 149 | } | |
| 152 | 150 | ||
| 153 | - | var c io.ReadCloser | |
| 154 | 151 | fpath := fp.Filepath | |
| 155 | 152 | attempts = append(attempts, fpath) | |
| 156 | 153 | 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 | |
| 165 | 161 | } 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 | + | } | |
| 170 | 175 | } | |
| 171 | 176 | } | |
| 172 | 177 |
| ... | ... | @@ -294,16 +299,13 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 294 | 299 | "status", status, | |
| 295 | 300 | "contentType", finContentType, | |
| 296 | 301 | ) | |
| 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 | + | } | |
| 301 | 308 | return | |
| 302 | 309 | } | |
| 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) | |
| 309 | 311 | } |
+137,
-42
| ... | ... | @@ -1,20 +1,118 @@ | |
| 1 | 1 | package pgs | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "fmt" | |
| 5 | - | "io" | |
| 6 | 6 | "log/slog" | |
| 7 | 7 | "net/http" | |
| 8 | 8 | "net/http/httptest" | |
| 9 | + | "os" | |
| 10 | + | "os/exec" | |
| 9 | 11 | "strings" | |
| 10 | 12 | "testing" | |
| 11 | 13 | "time" | |
| 12 | 14 | ||
| 13 | 15 | pgsdb "github.com/picosh/pico/pkg/apps/pgs/db" | |
| 16 | + | "github.com/picosh/pico/pkg/send/utils" | |
| 14 | 17 | "github.com/picosh/pico/pkg/shared" | |
| 18 | + | "github.com/picosh/pico/pkg/shared/mime" | |
| 15 | 19 | "github.com/picosh/pico/pkg/storage" | |
| 20 | + | "github.com/testcontainers/testcontainers-go" | |
| 21 | + | "github.com/testcontainers/testcontainers-go/wait" | |
| 16 | 22 | ) | |
| 17 | 23 | ||
| 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 | + | ||
| 18 | 116 | type ApiExample struct { | |
| 19 | 117 | name string | |
| 20 | 118 | path string |
| ... | ... | @@ -316,7 +414,11 @@ func TestApiBasic(t *testing.T) { | |
| 316 | 414 | } | |
| 317 | 415 | responseRecorder := httptest.NewRecorder() | |
| 318 | 416 | ||
| 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) | |
| 320 | 422 | pubsub := NewPubsubChan() | |
| 321 | 423 | defer func() { | |
| 322 | 424 | _ = pubsub.Close() |
| ... | ... | @@ -440,7 +542,11 @@ func TestDirectoryListing(t *testing.T) { | |
| 440 | 542 | request := httptest.NewRequest("GET", dbpool.mkpath(tc.path), strings.NewReader("")) | |
| 441 | 543 | responseRecorder := httptest.NewRecorder() | |
| 442 | 544 | ||
| 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) | |
| 444 | 550 | pubsub := NewPubsubChan() | |
| 445 | 551 | defer func() { | |
| 446 | 552 | _ = pubsub.Close() |
| ... | ... | @@ -474,51 +580,50 @@ func TestDirectoryListing(t *testing.T) { | |
| 474 | 580 | } | |
| 475 | 581 | } | |
| 476 | 582 | ||
| 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) | |
| 488 | 588 | } | |
| 489 | - | info.Metadata.Set("content-type", "image/jpeg") | |
| 490 | - | return io.NopCloser(strings.NewReader("hello world!")), &info, nil | |
| 589 | + | return data | |
| 491 | 590 | } | |
| 492 | 591 | ||
| 493 | 592 | func TestImageManipulation(t *testing.T) { | |
| 593 | + | if imgproxyURL == "" { | |
| 594 | + | t.Skip("imgproxy container not available") | |
| 595 | + | } | |
| 596 | + | ||
| 494 | 597 | logger := slog.Default() | |
| 495 | 598 | dbpool := NewPgsDb(logger) | |
| 496 | 599 | bucketName := shared.GetAssetBucketName(dbpool.Users[0].ID) | |
| 497 | 600 | ||
| 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 | + | }{ | |
| 499 | 608 | { | |
| 500 | 609 | name: "root-img", | |
| 501 | 610 | path: "/app.jpg/s:500/rt:90", | |
| 502 | - | want: "hello world!", | |
| 503 | 611 | status: http.StatusOK, | |
| 504 | 612 | contentType: "image/jpeg", | |
| 505 | - | ||
| 506 | 613 | storage: map[string]map[string]string{ | |
| 507 | 614 | bucketName: { | |
| 508 | - | "/test/app.jpg": "hello world!", | |
| 615 | + | "/test/app.jpg": string(minimalJPEG(t)), | |
| 509 | 616 | }, | |
| 510 | 617 | }, | |
| 511 | 618 | }, | |
| 512 | 619 | { | |
| 513 | 620 | name: "root-subdir-img", | |
| 514 | 621 | path: "/subdir/app.jpg/rt:90/s:500", | |
| 515 | - | want: "hello world!", | |
| 516 | 622 | status: http.StatusOK, | |
| 517 | 623 | contentType: "image/jpeg", | |
| 518 | - | ||
| 519 | 624 | storage: map[string]map[string]string{ | |
| 520 | 625 | bucketName: { | |
| 521 | - | "/test/subdir/app.jpg": "hello world!", | |
| 626 | + | "/test/subdir/app.jpg": string(minimalJPEG(t)), | |
| 522 | 627 | }, | |
| 523 | 628 | }, | |
| 524 | 629 | }, |
| ... | ... | @@ -529,13 +634,11 @@ func TestImageManipulation(t *testing.T) { | |
| 529 | 634 | request := httptest.NewRequest("GET", dbpool.mkpath(tc.path), strings.NewReader("")) | |
| 530 | 635 | responseRecorder := httptest.NewRecorder() | |
| 531 | 636 | ||
| 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) | |
| 538 | 640 | } | |
| 641 | + | st := newTestStorage(memSt) | |
| 539 | 642 | pubsub := NewPubsubChan() | |
| 540 | 643 | defer func() { | |
| 541 | 644 | _ = pubsub.Close() |
| ... | ... | @@ -554,19 +657,11 @@ func TestImageManipulation(t *testing.T) { | |
| 554 | 657 | t.Errorf("Want content type '%s', got '%s'", tc.contentType, ct) | |
| 555 | 658 | } | |
| 556 | 659 | ||
| 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") | |
| 570 | 665 | } | |
| 571 | 666 | }) | |
| 572 | 667 | } |
+3,
-46
| ... | ... | @@ -4,7 +4,6 @@ import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "html/template" | |
| 7 | - | "io" | |
| 8 | 7 | "net/http" | |
| 9 | 8 | "net/url" | |
| 10 | 9 | "os" |
| ... | ... | @@ -990,51 +989,9 @@ func imgRequest(w http.ResponseWriter, r *http.Request) { | |
| 990 | 989 | http.Error(w, err.Error(), http.StatusUnprocessableEntity) | |
| 991 | 990 | return | |
| 992 | 991 | } | |
| 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) | |
| 1038 | 995 | } | |
| 1039 | 996 | ||
| 1040 | 997 | func createSubdomainRoutes(staticRoutes []router.Route) []router.Route { |
+0,
-23
| ... | ... | @@ -320,26 +320,3 @@ func (s *StorageFS) ListObjects(bucket Bucket, dir string, recursive bool) ([]os | |
| 320 | 320 | ||
| 321 | 321 | return fileList, err | |
| 322 | 322 | } | |
| 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 @@ | |
| 1 | 1 | package storage | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | "io" | |
| 6 | - | "net/http" | |
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | 9 | "strings" |
| ... | ... | @@ -11,9 +11,14 @@ import ( | |
| 11 | 11 | "time" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/picosh/pico/pkg/send/utils" | |
| 14 | - | "github.com/picosh/pico/pkg/shared/mime" | |
| 15 | 14 | ) | |
| 16 | 15 | ||
| 16 | + | type seekableReader struct { | |
| 17 | + | *bytes.Reader | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (s *seekableReader) Close() error { return nil } | |
| 21 | + | ||
| 17 | 22 | type StorageMemory struct { | |
| 18 | 23 | storage map[string]map[string]string | |
| 19 | 24 | mu sync.RWMutex |
| ... | ... | @@ -97,8 +102,7 @@ func (s *StorageMemory) GetObject(bucket Bucket, fpath string) (utils.ReadAndRea | |
| 97 | 102 | } | |
| 98 | 103 | ||
| 99 | 104 | 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 | |
| 102 | 106 | } | |
| 103 | 107 | ||
| 104 | 108 | 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) ( | |
| 207 | 211 | ||
| 208 | 212 | return fileList, nil | |
| 209 | 213 | } | |
| 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 ( | |
| 6 | 6 | "encoding/base64" | |
| 7 | 7 | "encoding/hex" | |
| 8 | 8 | "fmt" | |
| 9 | - | "io" | |
| 10 | - | "log/slog" | |
| 11 | 9 | "net/http" | |
| 10 | + | "net/http/httputil" | |
| 11 | + | "net/url" | |
| 12 | 12 | "os" | |
| 13 | 13 | "strconv" | |
| 14 | 14 | "strings" | |
| 15 | - | "time" | |
| 15 | + | ||
| 16 | + | "github.com/picosh/pico/pkg/shared/mime" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 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 | + | ||
| 18 | 97 | func UriToImgProcessOpts(uri string) (*ImgProcessOpts, error) { | |
| 19 | 98 | opts := &ImgProcessOpts{} | |
| 20 | 99 | parts := strings.Split(uri, "/") |
| ... | ... | @@ -130,73 +209,3 @@ func (img *ImgProcessOpts) String() string { | |
| 130 | 209 | ||
| 131 | 210 | return processOpts | |
| 132 | 211 | } | |
| 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 { | |
| 40 | 40 | type StorageServe interface { | |
| 41 | 41 | BucketStorage | |
| 42 | 42 | ObjectStorage | |
| 43 | - | ServeObject(r *http.Request, bucket Bucket, fpath string, opts *ImgProcessOpts) (io.ReadCloser, *ObjectInfo, error) | |
| 44 | 43 | } |
+0,
-0
Binaries are not rendered as diffs.