repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / shared / storage
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}