Eric Bower
·
2025-06-08
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 "strconv"
14 "strings"
15 "time"
16
17 "github.com/picosh/pico/pkg/pobj/storage"
18)
19
20func UriToImgProcessOpts(uri string) (*ImgProcessOpts, error) {
21 opts := &ImgProcessOpts{}
22 parts := strings.Split(uri, "/")
23
24 for _, part := range parts {
25 ratio, err := GetRatio(part)
26 if err != nil {
27 return opts, err
28 }
29
30 if ratio != nil {
31 opts.Ratio = ratio
32 }
33
34 if strings.HasPrefix(part, "s:") {
35 segs := strings.SplitN(part, ":", 4)
36 r := &Ratio{}
37 for idx, sg := range segs {
38 if sg == "" {
39 continue
40 }
41 switch idx {
42 case 1:
43 r.Width, err = strconv.Atoi(sg)
44 if err != nil {
45 return opts, err
46 }
47 case 2:
48 r.Height, err = strconv.Atoi(sg)
49 if err != nil {
50 return opts, err
51 }
52 }
53 }
54 opts.Ratio = r
55 }
56
57 if strings.HasPrefix(part, "q:") {
58 quality := strings.Replace(part, "q:", "", 1)
59 opts.Quality, err = strconv.Atoi(quality)
60 if err != nil {
61 return opts, err
62 }
63 }
64
65 if strings.HasPrefix(part, "rt:") {
66 angle := strings.Replace(part, "rt:", "", 1)
67 opts.Rotate, err = strconv.Atoi(angle)
68 if err != nil {
69 return opts, err
70 }
71 }
72
73 if strings.HasPrefix(part, "ext:") {
74 ext := strings.Replace(part, "ext:", "", 1)
75 opts.Ext = ext
76 if err != nil {
77 return opts, err
78 }
79 }
80 }
81
82 return opts, nil
83}
84
85type ImgProcessOpts struct {
86 Quality int
87 Ratio *Ratio
88 Rotate int
89 Ext string
90 NoRaw bool
91}
92
93func (img *ImgProcessOpts) String() string {
94 processOpts := ""
95
96 // https://docs.imgproxy.net/usage/processing#quality
97 if img.Quality != 0 {
98 processOpts = fmt.Sprintf("%s/q:%d", processOpts, img.Quality)
99 }
100
101 // https://docs.imgproxy.net/usage/processing#size
102 if img.Ratio != nil {
103 processOpts = fmt.Sprintf(
104 "%s/size:%d:%d",
105 processOpts,
106 img.Ratio.Width,
107 img.Ratio.Height,
108 )
109 }
110
111 // https://docs.imgproxy.net/usage/processing#rotate
112 // Only 0, 90, 180, 270, etc., degree angles are supported.
113 if img.Rotate != 0 {
114 rot := img.Rotate
115 if rot == 90 || rot == 180 || rot == 280 {
116 processOpts = fmt.Sprintf(
117 "%s/rotate:%d",
118 processOpts,
119 rot,
120 )
121 }
122 }
123
124 // https://docs.imgproxy.net/usage/processing#format
125 if img.Ext != "" {
126 processOpts = fmt.Sprintf("%s/ext:%s", processOpts, img.Ext)
127 }
128
129 if processOpts == "" && !img.NoRaw {
130 processOpts = fmt.Sprintf("%s/raw:true", processOpts)
131 }
132
133 return processOpts
134}
135
136func HandleProxy(r *http.Request, logger *slog.Logger, dataURL string, opts *ImgProcessOpts) (io.ReadCloser, *storage.ObjectInfo, error) {
137 imgProxyURL := os.Getenv("IMGPROXY_URL")
138 imgProxySalt := os.Getenv("IMGPROXY_SALT")
139 imgProxyKey := os.Getenv("IMGPROXY_KEY")
140
141 signature := "_"
142
143 processOpts := opts.String()
144
145 processPath := fmt.Sprintf("%s/%s", processOpts, base64.StdEncoding.EncodeToString([]byte(dataURL)))
146
147 if imgProxySalt != "" && imgProxyKey != "" {
148 keyBin, err := hex.DecodeString(imgProxyKey)
149 if err != nil {
150 return nil, nil, err
151 }
152
153 saltBin, err := hex.DecodeString(imgProxySalt)
154 if err != nil {
155 return nil, nil, err
156 }
157
158 mac := hmac.New(sha256.New, keyBin)
159 mac.Write(saltBin)
160 mac.Write([]byte(processPath))
161 signature = base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
162 }
163 proxyAddress := fmt.Sprintf("%s/%s%s", imgProxyURL, signature, processPath)
164
165 req, err := http.NewRequest(http.MethodGet, proxyAddress, nil)
166 if err != nil {
167 return nil, nil, err
168 }
169 req.Header.Set("accept", r.Header.Get("accept"))
170 req.Header.Set("accept-encoding", r.Header.Get("accept-encoding"))
171 req.Header.Set("accept-language", r.Header.Get("accept-language"))
172 res, err := http.DefaultClient.Do(req)
173 if err != nil {
174 return nil, nil, err
175 }
176
177 if res.StatusCode < 200 || res.StatusCode >= 300 {
178 return nil, nil, fmt.Errorf("imgproxy returned %s", res.Status)
179 }
180 lastModified := res.Header.Get("Last-Modified")
181 parsedTime, err := time.Parse(time.RFC1123, lastModified)
182 if err != nil {
183 logger.Error("decoding last-modified", "err", err)
184 }
185 info := &storage.ObjectInfo{
186 Size: res.ContentLength,
187 ETag: trimEtag(res.Header.Get("etag")),
188 Metadata: res.Header.Clone(),
189 }
190 if strings.HasPrefix(info.Metadata.Get("content-type"), "text/xml") {
191 info.Metadata.Set("content-type", "image/svg+xml")
192 }
193 if !parsedTime.IsZero() {
194 info.LastModified = parsedTime
195 }
196
197 return res.Body, info, nil
198}
199
200// trimEtag removes quotes from the etag header, which matches the behavior of the minio-go SDK.
201func trimEtag(etag string) string {
202 etag = strings.TrimPrefix(etag, "\"")
203 return strings.TrimSuffix(etag, "\"")
204}