Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

imageproxy is a caching image proxy server written in go. It features:

- basic image adjustments like resizing, cropping, and rotation
- basic image adjustments like resizing, cropping, rotation, and watermarks
- access control using allowed hosts list or request signing (HMAC-SHA256)
- support for jpeg, png, webp (decode only), tiff, and gif image formats
(including animated gifs)
Expand Down Expand Up @@ -35,14 +35,34 @@ imageproxy URLs are of the form `http://localhost/{options}/{remote_url}`.

### Options

Options are available for cropping, resizing, rotation, flipping, and digital
signatures among a few others. Options for are specified as a comma delimited
list of parameters, which can be supplied in any order. Duplicate parameters
overwrite previous values.
Options are available for cropping, resizing, rotation, flipping, watermarks,
and digital signatures among a few others. Options for are specified as a comma
delimited list of parameters, which can be supplied in any order. Duplicate
parameters overwrite previous values.

See the full list of available options at
<https://pkg.go.dev/willnorris.com/go/imageproxy#ParseOptions>.

Watermark options overlay a remote image after other transforms. The watermark
image URL must be URL-safe base64 encoded (no padding) in the `wmurl` option,
because unencoded URLs contain `/` and would break the
`/{options}/{remote_url}` path:

| Option | Meaning | Default |
| ------ | ------- | ------- |
| `wmurl{base64url}` | Watermark image URL (URL-safe base64, no padding) | required |
| `wmp{pos}` | Position: `nw` `n` `ne` `w` `c` `e` `sw` `s` `se` | `se` |
| `wmo{0..1}` | Opacity | `1` |
| `wms{0..1}` | Scale as a fraction of output width | `0.2` |
| `wmx{n}` / `wmy{n}` | Edge padding in pixels | `0` |

Example (watermark URL `https://example.com/logo.png` encoded as
`aHR0cHM6Ly9leGFtcGxlLmNvbS9sb2dvLnBuZw`):

```
/800x,wmurlaHR0cHM6Ly9leGFtcGxlLmNvbS9sb2dvLnBuZw,wmpse,wmo0.5,wms0.15/https://example.com/photo.jpg
```

### Remote URL

The URL of the original image to load is specified as the remainder of the
Expand Down
113 changes: 94 additions & 19 deletions data.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,30 @@ import (
)

const (
optFit = "fit"
optFlipVertical = "fv"
optFlipHorizontal = "fh"
optFormatJPEG = "jpeg"
optFormatPNG = "png"
optFormatTIFF = "tiff"
optRotatePrefix = "r"
optQualityPrefix = "q"
optSignaturePrefix = "s"
optSizeDelimiter = "x"
optScaleUp = "scaleUp"
optCropX = "cx"
optCropY = "cy"
optCropWidth = "cw"
optCropHeight = "ch"
optSmartCrop = "sc"
optTrim = "trim"
optValidUntil = "vu"
optFit = "fit"
optFlipVertical = "fv"
optFlipHorizontal = "fh"
optFormatJPEG = "jpeg"
optFormatPNG = "png"
optFormatTIFF = "tiff"
optRotatePrefix = "r"
optQualityPrefix = "q"
optSignaturePrefix = "s"
optSizeDelimiter = "x"
optScaleUp = "scaleUp"
optCropX = "cx"
optCropY = "cy"
optCropWidth = "cw"
optCropHeight = "ch"
optSmartCrop = "sc"
optTrim = "trim"
optValidUntil = "vu"
optWatermarkURL = "wmurl"
optWatermarkPos = "wmp"
optWatermarkOpacity = "wmo"
optWatermarkScale = "wms"
optWatermarkPadX = "wmx"
optWatermarkPadY = "wmy"
)

// URLError reports a malformed URL error.
Expand Down Expand Up @@ -91,6 +97,24 @@ type Options struct {

// If non-zero, the URL is valid until this time.
ValidUntil time.Time

// WatermarkURL is the remote URL of an image to overlay (set via wmurl option;
// value is URL-safe base64 with no padding).
WatermarkURL string

// WatermarkPos is the overlay anchor: nw, n, ne, w, c, e, sw, s, se.
WatermarkPos string

// WatermarkOpacity is overlay opacity in [0,1]. Zero means unset (default 1).
WatermarkOpacity float64

// WatermarkScale is watermark width as a fraction of the output image width.
// Zero means unset (default 0.2).
WatermarkScale float64

// WatermarkPadX and WatermarkPadY are edge padding in pixels.
WatermarkPadX int
WatermarkPadY int
}

func (o Options) String() string {
Expand Down Expand Up @@ -140,6 +164,24 @@ func (o Options) String() string {
if !o.ValidUntil.IsZero() {
opts = append(opts, fmt.Sprintf("%s%d", optValidUntil, o.ValidUntil.Unix()))
}
if o.WatermarkURL != "" {
opts = append(opts, optWatermarkURL+base64.RawURLEncoding.EncodeToString([]byte(o.WatermarkURL)))
}
if o.WatermarkPos != "" {
opts = append(opts, optWatermarkPos+o.WatermarkPos)
}
if o.WatermarkOpacity != 0 {
opts = append(opts, fmt.Sprintf("%s%v", optWatermarkOpacity, o.WatermarkOpacity))
}
if o.WatermarkScale != 0 {
opts = append(opts, fmt.Sprintf("%s%v", optWatermarkScale, o.WatermarkScale))
}
if o.WatermarkPadX != 0 {
opts = append(opts, fmt.Sprintf("%s%d", optWatermarkPadX, o.WatermarkPadX))
}
if o.WatermarkPadY != 0 {
opts = append(opts, fmt.Sprintf("%s%d", optWatermarkPadY, o.WatermarkPadY))
}

sort.Strings(opts)

Expand All @@ -151,7 +193,7 @@ func (o Options) String() string {
// the presence of other fields (like Fit). A non-empty Format value is
// assumed to involve a transformation.
func (o Options) transform() bool {
return o.Width != 0 || o.Height != 0 || o.Rotate != 0 || o.FlipHorizontal || o.FlipVertical || o.Quality != 0 || o.Format != "" || o.CropX != 0 || o.CropY != 0 || o.CropWidth != 0 || o.CropHeight != 0 || o.Trim
return o.Width != 0 || o.Height != 0 || o.Rotate != 0 || o.FlipHorizontal || o.FlipVertical || o.Quality != 0 || o.Format != "" || o.CropX != 0 || o.CropY != 0 || o.CropWidth != 0 || o.CropHeight != 0 || o.Trim || o.WatermarkURL != ""
}

// ParseOptions parses str as a list of comma separated transformation options.
Expand Down Expand Up @@ -250,6 +292,17 @@ func (o Options) transform() bool {
// The "vu{unixtime}" option specifies a Unix timestamp at which the request URL is no longer valid.
// For example, "vu1800000000" would mean the URL is valid until 2027-01-15T08:00:00Z.
//
// # Watermark
//
// The following options overlay a remote watermark image onto the output:
//
// wmurl{base64url} - watermark image URL (URL-safe base64, no padding)
// wmp{pos} - position: nw, n, ne, w, c, e, sw, s, se (default: se)
// wmo{opacity} - opacity from 0 to 1 (default: 1)
// wms{scale} - watermark width as a fraction of output width (default: 0.2)
// wmx{n} - horizontal padding in pixels (default: 0)
// wmy{n} - vertical padding in pixels (default: 0)
//
// Examples
//
// 0x0 - no resizing
Expand All @@ -264,6 +317,7 @@ func (o Options) transform() bool {
// 200x,png - 200 pixels wide, converted to PNG format
// cw100,ch100 - crop image to 100px square, starting at (0,0)
// cx10,cy20,cw100,ch200 - crop image starting at (10,20) is 100px wide and 200px tall
// wmurl{aHR0cHM6Ly9leGFtcGxlLmNvbS9sb2dvLnBuZw},wmp{se},wmo{0.5} - watermark bottom-right at 50% opacity
func ParseOptions(str string) Options {
var options Options

Expand All @@ -284,6 +338,27 @@ func ParseOptions(str string) Options {
options.SmartCrop = true
case opt == optTrim:
options.Trim = true
case strings.HasPrefix(opt, optWatermarkURL):
value := strings.TrimPrefix(opt, optWatermarkURL)
if b, err := base64.RawURLEncoding.DecodeString(value); err == nil {
options.WatermarkURL = string(b)
} else if b, err := base64.URLEncoding.DecodeString(value); err == nil {
options.WatermarkURL = string(b)
}
case strings.HasPrefix(opt, optWatermarkPos):
options.WatermarkPos = strings.TrimPrefix(opt, optWatermarkPos)
case strings.HasPrefix(opt, optWatermarkOpacity):
value := strings.TrimPrefix(opt, optWatermarkOpacity)
options.WatermarkOpacity, _ = strconv.ParseFloat(value, 64)
case strings.HasPrefix(opt, optWatermarkScale):
value := strings.TrimPrefix(opt, optWatermarkScale)
options.WatermarkScale, _ = strconv.ParseFloat(value, 64)
case strings.HasPrefix(opt, optWatermarkPadX):
value := strings.TrimPrefix(opt, optWatermarkPadX)
options.WatermarkPadX, _ = strconv.Atoi(value)
case strings.HasPrefix(opt, optWatermarkPadY):
value := strings.TrimPrefix(opt, optWatermarkPadY)
options.WatermarkPadY, _ = strconv.Atoi(value)
case strings.HasPrefix(opt, optRotatePrefix):
value := strings.TrimPrefix(opt, optRotatePrefix)
options.Rotate, _ = strconv.Atoi(value)
Expand Down
18 changes: 18 additions & 0 deletions data_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ func TestOptions_String(t *testing.T) {
Options{ScaleUp: true, CropX: 100, CropY: 200, CropWidth: 300, CropHeight: 400, SmartCrop: true},
"0x0,ch400,cw300,cx100,cy200,sc,scaleUp",
},
{
Options{WatermarkURL: "https://example.com/logo.png", WatermarkPos: "se", WatermarkOpacity: 0.5, WatermarkScale: 0.15, WatermarkPadX: 16, WatermarkPadY: 8},
"0x0,wmo0.5,wmpse,wms0.15,wmurlaHR0cHM6Ly9leGFtcGxlLmNvbS9sb2dvLnBuZw,wmx16,wmy8",
},
}

for i, tt := range tests {
Expand Down Expand Up @@ -88,6 +92,13 @@ func TestParseOptions(t *testing.T) {
{"q70,1x2,fit,r90,fv,fh,sc0ffee,png", Options{Width: 1, Height: 2, Fit: true, Rotate: 90, FlipVertical: true, FlipHorizontal: true, Quality: 70, Signature: "c0ffee", Format: "png"}},
{"r90,fh,sc0ffee,png,q90,1x2,fv,fit", Options{Width: 1, Height: 2, Fit: true, Rotate: 90, FlipVertical: true, FlipHorizontal: true, Quality: 90, Signature: "c0ffee", Format: "png"}},
{"cx100,cw300,1x2,cy200,ch400,sc,scaleUp,vu1234567890", Options{Width: 1, Height: 2, ScaleUp: true, CropX: 100, CropY: 200, CropWidth: 300, CropHeight: 400, SmartCrop: true, ValidUntil: time.Unix(1234567890, 0)}},

// watermark options (wmurl value is URL-safe base64 for https://example.com/logo.png)
{"wmurlaHR0cHM6Ly9leGFtcGxlLmNvbS9sb2dvLnBuZw", Options{WatermarkURL: "https://example.com/logo.png"}},
{"wmurlaHR0cHM6Ly9leGFtcGxlLmNvbS9sb2dvLnBuZw,wmpse,wmo0.5,wms0.15,wmx16,wmy8", Options{
WatermarkURL: "https://example.com/logo.png", WatermarkPos: "se", WatermarkOpacity: 0.5, WatermarkScale: 0.15, WatermarkPadX: 16, WatermarkPadY: 8,
}},
{"wmpse,wmo0.4,wms0.2,wmx10,wmy10", Options{WatermarkPos: "se", WatermarkOpacity: 0.4, WatermarkScale: 0.2, WatermarkPadX: 10, WatermarkPadY: 10}},
}

for _, tt := range tests {
Expand All @@ -97,6 +108,13 @@ func TestParseOptions(t *testing.T) {
}
}

func TestOptions_transform_watermark(t *testing.T) {
opt := Options{WatermarkURL: "https://example.com/logo.png"}
if !opt.transform() {
t.Fatal("expected watermark-only options to require transform")
}
}

// Test that request URLs are properly parsed into Options and RemoteURL. This
// test verifies that invalid remote URLs throw errors, and that valid
// combinations of Options and URL are accept. This does not exhaustively test
Expand Down
50 changes: 49 additions & 1 deletion imageproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import (
"encoding/base64"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"log"
"mime"
Expand Down Expand Up @@ -633,7 +637,19 @@ func (t *TransformingTransport) RoundTrip(req *http.Request) (*http.Response, er

opt := ParseOptions(req.URL.Fragment)

img, err := Transform(b, opt)
var watermark image.Image
if opt.WatermarkURL != "" {
watermark, err = t.fetchWatermark(opt.WatermarkURL)
if err != nil {
if t.log != nil {
t.log("error fetching watermark %s: %v", opt.WatermarkURL, err)
} else {
log.Printf("error fetching watermark %s: %v", opt.WatermarkURL, err)
}
}
}

img, err := transform(b, opt, watermark)
if err != nil {
log.Printf("error transforming image %s: %v", req.URL.String(), err)
img = b
Expand All @@ -654,3 +670,35 @@ func (t *TransformingTransport) RoundTrip(req *http.Request) (*http.Response, er

return http.ReadResponse(bufio.NewReader(buf), req)
}

// fetchWatermark downloads and decodes a watermark image from watermarkURL.
func (t *TransformingTransport) fetchWatermark(watermarkURL string) (image.Image, error) {
u, err := url.Parse(watermarkURL)
if err != nil {
return nil, err
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("watermark URL must have http or https scheme")
}

req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
resp, err := t.CachingClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("watermark fetch returned status %d", resp.StatusCode)
}

b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
m, _, err := image.Decode(bytes.NewReader(b))
return m, err
}
Loading