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