repos / pico

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

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