main pico / pkg / storage / proxy.go
Eric Bower  ·  2026-06-13
  1package storage
  2
  3import (
  4	"crypto/hmac"
  5	"crypto/sha256"
  6	"encoding/base64"
  7	"encoding/hex"
  8	"fmt"
  9	"net/http"
 10	"net/http/httputil"
 11	"net/url"
 12	"os"
 13	"strconv"
 14	"strings"
 15
 16	"github.com/picosh/pico/pkg/shared/mime"
 17)
 18
 19type ImgProxy struct {
 20	url      string
 21	salt     string
 22	key      string
 23	filepath string
 24	opts     *ImgProcessOpts
 25}
 26
 27func NewImgProxy(fp string, opts *ImgProcessOpts) *ImgProxy {
 28	img := &ImgProxy{
 29		url:      os.Getenv("IMGPROXY_URL"),
 30		salt:     os.Getenv("IMGPROXY_SALT"),
 31		key:      os.Getenv("IMGPROXY_KEY"),
 32		filepath: fp,
 33		opts:     opts,
 34	}
 35	return img
 36}
 37
 38func (img *ImgProxy) CanServe() error {
 39	if img.url == "" {
 40		return fmt.Errorf("no imgproxy url provided")
 41	}
 42	if img.opts == nil {
 43		return fmt.Errorf("no image options provided")
 44	}
 45	mimeType := mime.GetMimeType(img.filepath)
 46	if !strings.HasPrefix(mimeType, "image/") {
 47		return fmt.Errorf("file mimetype not an image")
 48	}
 49	return nil
 50}
 51
 52func (img *ImgProxy) GetSig(ppath []byte) string {
 53	signature := "_"
 54	imgProxySalt := img.salt
 55	imgProxyKey := img.key
 56	if imgProxySalt == "" || imgProxyKey == "" {
 57		return signature
 58	}
 59
 60	keyBin, err := hex.DecodeString(imgProxyKey)
 61	if err != nil {
 62		return signature
 63	}
 64
 65	saltBin, err := hex.DecodeString(imgProxySalt)
 66	if err != nil {
 67		return signature
 68	}
 69
 70	mac := hmac.New(sha256.New, keyBin)
 71	mac.Write(saltBin)
 72	mac.Write(ppath)
 73	return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
 74}
 75
 76func (img *ImgProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 77	dataURL := fmt.Sprintf("local:///%s", img.filepath)
 78	imgProxyURL := img.url
 79	processOpts := img.opts.String()
 80	processPath := fmt.Sprintf(
 81		"%s/%s",
 82		processOpts,
 83		base64.StdEncoding.EncodeToString([]byte(dataURL)),
 84	)
 85	sig := img.GetSig([]byte(processPath))
 86
 87	rurl := fmt.Sprintf("%s/%s%s", imgProxyURL, sig, processPath)
 88	destUrl, err := url.Parse(rurl)
 89	if err != nil {
 90		msg := fmt.Sprintf("could not parse url: %s", rurl)
 91		http.Error(w, msg, http.StatusInternalServerError)
 92		return
 93	}
 94	proxy := &httputil.ReverseProxy{
 95		Rewrite: func(r *httputil.ProxyRequest) {
 96			r.SetURL(destUrl)
 97			r.Out.URL.Path = destUrl.Path
 98			r.Out.URL.RawPath = destUrl.RawPath
 99		},
100	}
101	proxy.ServeHTTP(w, r)
102}
103
104func UriToImgProcessOpts(uri string) (*ImgProcessOpts, error) {
105	opts := &ImgProcessOpts{}
106	parts := strings.Split(uri, "/")
107
108	for _, part := range parts {
109		ratio, err := GetRatio(part)
110		if err != nil {
111			return opts, err
112		}
113
114		if ratio != nil {
115			opts.Ratio = ratio
116		}
117
118		if strings.HasPrefix(part, "s:") {
119			segs := strings.SplitN(part, ":", 4)
120			r := &Ratio{}
121			for idx, sg := range segs {
122				if sg == "" {
123					continue
124				}
125				switch idx {
126				case 1:
127					r.Width, err = strconv.Atoi(sg)
128					if err != nil {
129						return opts, err
130					}
131				case 2:
132					r.Height, err = strconv.Atoi(sg)
133					if err != nil {
134						return opts, err
135					}
136				}
137			}
138			opts.Ratio = r
139		}
140
141		if strings.HasPrefix(part, "q:") {
142			quality := strings.Replace(part, "q:", "", 1)
143			opts.Quality, err = strconv.Atoi(quality)
144			if err != nil {
145				return opts, err
146			}
147		}
148
149		if strings.HasPrefix(part, "rt:") {
150			angle := strings.Replace(part, "rt:", "", 1)
151			opts.Rotate, err = strconv.Atoi(angle)
152			if err != nil {
153				return opts, err
154			}
155		}
156
157		if strings.HasPrefix(part, "ext:") {
158			ext := strings.Replace(part, "ext:", "", 1)
159			opts.Ext = ext
160			if err != nil {
161				return opts, err
162			}
163		}
164	}
165
166	return opts, nil
167}
168
169type ImgProcessOpts struct {
170	Quality int
171	Ratio   *Ratio
172	Rotate  int
173	Ext     string
174	NoRaw   bool
175}
176
177func (img *ImgProcessOpts) String() string {
178	processOpts := ""
179
180	// https://docs.imgproxy.net/usage/processing#quality
181	if img.Quality != 0 {
182		processOpts = fmt.Sprintf("%s/q:%d", processOpts, img.Quality)
183	}
184
185	// https://docs.imgproxy.net/usage/processing#size
186	if img.Ratio != nil {
187		processOpts = fmt.Sprintf(
188			"%s/size:%d:%d",
189			processOpts,
190			img.Ratio.Width,
191			img.Ratio.Height,
192		)
193	}
194
195	// https://docs.imgproxy.net/usage/processing#rotate
196	// Only 0, 90, 180, 270, etc., degree angles are supported.
197	if img.Rotate != 0 {
198		rot := img.Rotate
199		if rot == 90 || rot == 180 || rot == 270 {
200			processOpts = fmt.Sprintf(
201				"%s/rotate:%d",
202				processOpts,
203				rot,
204			)
205		}
206	}
207
208	// https://docs.imgproxy.net/usage/processing#format
209	if img.Ext != "" {
210		processOpts = fmt.Sprintf("%s/ext:%s", processOpts, img.Ext)
211	}
212
213	if processOpts == "" && !img.NoRaw {
214		processOpts = fmt.Sprintf("%s/raw:true", processOpts)
215	}
216
217	return processOpts
218}