Eric Bower
·
2025-04-05
proxy.go
1package storage
2
3import (
4 "crypto/hmac"
5 "crypto/sha256"
6 "encoding/base64"
7 "encoding/hex"
8 "fmt"
9 "io"
10 "log/slog"
11 "net/http"
12 "os"
13 "path/filepath"
14 "strconv"
15 "strings"
16 "time"
17
18 "github.com/picosh/pico/pkg/pobj/storage"
19)
20
21func GetMimeType(fpath string) string {
22 ext := filepath.Ext(fpath)
23 if ext == ".svg" {
24 return "image/svg+xml"
25 } else if ext == ".css" {
26 return "text/css"
27 } else if ext == ".js" {
28 return "text/javascript"
29 } else if ext == ".ico" {
30 return "image/x-icon"
31 } else if ext == ".pdf" {
32 return "application/pdf"
33 } else if ext == ".html" || ext == ".htm" {
34 return "text/html"
35 } else if ext == ".jpg" || ext == ".jpeg" {
36 return "image/jpeg"
37 } else if ext == ".png" {
38 return "image/png"
39 } else if ext == ".gif" {
40 return "image/gif"
41 } else if ext == ".webp" {
42 return "image/webp"
43 } else if ext == ".otf" {
44 return "font/otf"
45 } else if ext == ".woff" {
46 return "font/woff"
47 } else if ext == ".woff2" {
48 return "font/woff2"
49 } else if ext == ".ttf" {
50 return "font/ttf"
51 } else if ext == ".md" {
52 return "text/markdown; charset=UTF-8"
53 } else if ext == ".json" || ext == ".map" {
54 return "application/json"
55 } else if ext == ".rss" {
56 return "application/rss+xml"
57 } else if ext == ".atom" {
58 return "application/atom+xml"
59 } else if ext == ".webmanifest" {
60 return "application/manifest+json"
61 } else if ext == ".xml" {
62 return "application/xml"
63 } else if ext == ".xsl" {
64 return "application/xml"
65 } else if ext == ".avif" {
66 return "image/avif"
67 } else if ext == ".heif" {
68 return "image/heif"
69 } else if ext == ".heic" {
70 return "image/heif"
71 } else if ext == ".opus" {
72 return "audio/opus"
73 } else if ext == ".wav" {
74 return "audio/wav"
75 } else if ext == ".mp3" {
76 return "audio/mpeg"
77 } else if ext == ".mp4" {
78 return "video/mp4"
79 } else if ext == ".mpeg" {
80 return "video/mpeg"
81 } else if ext == ".wasm" {
82 return "application/wasm"
83 } else if ext == ".opml" {
84 return "text/x-opml"
85 } else if ext == ".eot" {
86 return "application/vnd.ms-fontobject"
87 } else if ext == ".yml" || ext == ".yaml" {
88 return "text/x-yaml"
89 } else if ext == ".zip" {
90 return "application/zip"
91 } else if ext == ".rar" {
92 return "application/vnd.rar"
93 } else if ext == ".txt" {
94 return "text/plain"
95 }
96
97 return "text/plain"
98}
99
100func UriToImgProcessOpts(uri string) (*ImgProcessOpts, error) {
101 opts := &ImgProcessOpts{}
102 parts := strings.Split(uri, "/")
103
104 for _, part := range parts {
105 ratio, err := GetRatio(part)
106 if err != nil {
107 return opts, err
108 }
109
110 if ratio != nil {
111 opts.Ratio = ratio
112 }
113
114 if strings.HasPrefix(part, "s:") {
115 segs := strings.SplitN(part, ":", 4)
116 r := &Ratio{}
117 for idx, sg := range segs {
118 if sg == "" {
119 continue
120 }
121 if idx == 1 {
122 r.Width, err = strconv.Atoi(sg)
123 if err != nil {
124 return opts, err
125 }
126 } else if idx == 2 {
127 r.Height, err = strconv.Atoi(sg)
128 if err != nil {
129 return opts, err
130 }
131 }
132 }
133 opts.Ratio = r
134 }
135
136 if strings.HasPrefix(part, "q:") {
137 quality := strings.Replace(part, "q:", "", 1)
138 opts.Quality, err = strconv.Atoi(quality)
139 if err != nil {
140 return opts, err
141 }
142 }
143
144 if strings.HasPrefix(part, "rt:") {
145 angle := strings.Replace(part, "rt:", "", 1)
146 opts.Rotate, err = strconv.Atoi(angle)
147 if err != nil {
148 return opts, err
149 }
150 }
151
152 if strings.HasPrefix(part, "ext:") {
153 ext := strings.Replace(part, "ext:", "", 1)
154 opts.Ext = ext
155 if err != nil {
156 return opts, err
157 }
158 }
159 }
160
161 return opts, nil
162}
163
164type ImgProcessOpts struct {
165 Quality int
166 Ratio *Ratio
167 Rotate int
168 Ext string
169 NoRaw bool
170}
171
172func (img *ImgProcessOpts) String() string {
173 processOpts := ""
174
175 // https://docs.imgproxy.net/usage/processing#quality
176 if img.Quality != 0 {
177 processOpts = fmt.Sprintf("%s/q:%d", processOpts, img.Quality)
178 }
179
180 // https://docs.imgproxy.net/usage/processing#size
181 if img.Ratio != nil {
182 processOpts = fmt.Sprintf(
183 "%s/size:%d:%d",
184 processOpts,
185 img.Ratio.Width,
186 img.Ratio.Height,
187 )
188 }
189
190 // https://docs.imgproxy.net/usage/processing#rotate
191 // Only 0, 90, 180, 270, etc., degree angles are supported.
192 if img.Rotate != 0 {
193 rot := img.Rotate
194 if rot == 90 || rot == 180 || rot == 280 {
195 processOpts = fmt.Sprintf(
196 "%s/rotate:%d",
197 processOpts,
198 rot,
199 )
200 }
201 }
202
203 // https://docs.imgproxy.net/usage/processing#format
204 if img.Ext != "" {
205 processOpts = fmt.Sprintf("%s/ext:%s", processOpts, img.Ext)
206 }
207
208 if processOpts == "" && !img.NoRaw {
209 processOpts = fmt.Sprintf("%s/raw:true", processOpts)
210 }
211
212 return processOpts
213}
214
215func HandleProxy(logger *slog.Logger, dataURL string, opts *ImgProcessOpts) (io.ReadCloser, *storage.ObjectInfo, error) {
216 imgProxyURL := os.Getenv("IMGPROXY_URL")
217 imgProxySalt := os.Getenv("IMGPROXY_SALT")
218 imgProxyKey := os.Getenv("IMGPROXY_KEY")
219
220 signature := "_"
221
222 processOpts := opts.String()
223
224 processPath := fmt.Sprintf("%s/%s", processOpts, base64.StdEncoding.EncodeToString([]byte(dataURL)))
225
226 if imgProxySalt != "" && imgProxyKey != "" {
227 keyBin, err := hex.DecodeString(imgProxyKey)
228 if err != nil {
229 return nil, nil, err
230 }
231
232 saltBin, err := hex.DecodeString(imgProxySalt)
233 if err != nil {
234 return nil, nil, err
235 }
236
237 mac := hmac.New(sha256.New, keyBin)
238 mac.Write(saltBin)
239 mac.Write([]byte(processPath))
240 signature = base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
241 }
242 proxyAddress := fmt.Sprintf("%s/%s%s", imgProxyURL, signature, processPath)
243
244 res, err := http.Get(proxyAddress)
245 if err != nil {
246 return nil, nil, err
247 }
248
249 if res.StatusCode < 200 || res.StatusCode >= 300 {
250 return nil, nil, fmt.Errorf("imgproxy returned %s", res.Status)
251 }
252 lastModified := res.Header.Get("Last-Modified")
253 parsedTime, err := time.Parse(time.RFC1123, lastModified)
254 if err != nil {
255 logger.Error("decoding last-modified", "err", err)
256 }
257 info := &storage.ObjectInfo{
258 Size: res.ContentLength,
259 ETag: trimEtag(res.Header.Get("etag")),
260 Metadata: res.Header,
261 }
262 if !parsedTime.IsZero() {
263 info.LastModified = parsedTime
264 }
265
266 return res.Body, info, nil
267}
268
269// trimEtag removes quotes from the etag header, which matches the behavior of the minio-go SDK.
270func trimEtag(etag string) string {
271 etag = strings.TrimPrefix(etag, "\"")
272 return strings.TrimSuffix(etag, "\"")
273}