repos / pico

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

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