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
35 changes: 30 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,39 @@ 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. `0` is fully transparent | `1` |
| `wms{0..1}` | Scale as a fraction of output width. `0` draws nothing | `0.2` |
| `wmx{n}` / `wmy{n}` | Edge padding in pixels | `0` |

The watermark host is subject to the same allow and deny lists as the remote
image. When the proxy requires a signature and the watermark host is not
covered by an allow list, the signature must cover the options. A signature of
the remote image alone cannot be reused with a different `wmurl`.

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
118 changes: 99 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,25 @@ 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]. Nil means unset (default 1).
// Zero is fully transparent.
WatermarkOpacity *float64

// WatermarkScale is watermark width as a fraction of the output image width.
// Nil means unset (default 0.2). Zero draws no watermark.
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 +165,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 != nil {
opts = append(opts, fmt.Sprintf("%s%v", optWatermarkOpacity, *o.WatermarkOpacity))
}
if o.WatermarkScale != nil {
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 +194,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 +293,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). 0 is fully transparent
// wms{scale} - watermark width as a fraction of output width, from 0 to 1 (default: 0.2). 0 draws nothing
// wmx{n} - horizontal padding in pixels (default: 0)
// wmy{n} - vertical padding in pixels (default: 0)
//
// Examples
//
// 0x0 - no resizing
Expand All @@ -264,6 +318,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 +339,31 @@ 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)
if v, err := strconv.ParseFloat(value, 64); err == nil {
options.WatermarkOpacity = &v
}
case strings.HasPrefix(opt, optWatermarkScale):
value := strings.TrimPrefix(opt, optWatermarkScale)
if v, err := strconv.ParseFloat(value, 64); err == nil {
options.WatermarkScale = &v
}
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
28 changes: 27 additions & 1 deletion data_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ package imageproxy
import (
"net/http"
"net/url"
"reflect"
"testing"
"time"
)

func f64(v float64) *float64 { return &v }

var emptyOptions = Options{}

func TestOptions_String(t *testing.T) {
Expand Down Expand Up @@ -37,6 +40,14 @@ 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: f64(0.5), WatermarkScale: f64(0.15), WatermarkPadX: 16, WatermarkPadY: 8},
"0x0,wmo0.5,wmpse,wms0.15,wmurlaHR0cHM6Ly9leGFtcGxlLmNvbS9sb2dvLnBuZw,wmx16,wmy8",
},
{
Options{WatermarkOpacity: f64(0), WatermarkScale: f64(0)},
"0x0,wmo0,wms0",
},
}

for i, tt := range tests {
Expand Down Expand Up @@ -88,15 +99,30 @@ 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: f64(0.5), WatermarkScale: f64(0.15), WatermarkPadX: 16, WatermarkPadY: 8,
}},
{"wmpse,wmo0.4,wms0.2,wmx10,wmy10", Options{WatermarkPos: "se", WatermarkOpacity: f64(0.4), WatermarkScale: f64(0.2), WatermarkPadX: 10, WatermarkPadY: 10}},
{"wmo0,wms0", Options{WatermarkOpacity: f64(0), WatermarkScale: f64(0)}},
}

for _, tt := range tests {
if got, want := ParseOptions(tt.Input), tt.Options; got != want {
if got, want := ParseOptions(tt.Input), tt.Options; !reflect.DeepEqual(got, want) {
t.Errorf("ParseOptions(%q) returned %#v, want %#v", tt.Input, got, want)
}
}
}

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
Loading