diff --git a/README.md b/README.md index 6fd083b1d..640382ec3 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 . +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 diff --git a/data.go b/data.go index 8b5765cc7..06f22547b 100644 --- a/data.go +++ b/data.go @@ -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. @@ -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 { @@ -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) @@ -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. @@ -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 @@ -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 @@ -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) diff --git a/data_test.go b/data_test.go index 0e854af62..b9469c684 100644 --- a/data_test.go +++ b/data_test.go @@ -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 { @@ -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 { @@ -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 diff --git a/imageproxy.go b/imageproxy.go index 18df0b325..22d06668f 100644 --- a/imageproxy.go +++ b/imageproxy.go @@ -13,6 +13,10 @@ import ( "encoding/base64" "errors" "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" "io" "log" "mime" @@ -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 @@ -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 +} diff --git a/transform.go b/transform.go index a5e87d4b0..490ffaa58 100644 --- a/transform.go +++ b/transform.go @@ -14,6 +14,7 @@ import ( "io" "log" "math" + "strings" "github.com/disintegration/imaging" "github.com/muesli/smartcrop" @@ -39,6 +40,11 @@ var resampleFilter = imaging.Lanczos // encoded image in one of the supported formats (gif, jpeg, or png). The // bytes of a similarly encoded image is returned. func Transform(img []byte, opt Options) ([]byte, error) { + return transform(img, opt, nil) +} + +// transform is like Transform but applies watermark when non-nil. +func transform(img []byte, opt Options, watermark image.Image) ([]byte, error) { if !opt.transform() { // bail if no transformation was requested return img, nil @@ -68,7 +74,7 @@ func Transform(img []byte, opt Options) ([]byte, error) { if format == "jpeg" || format == "tiff" { r := io.LimitReader(bytes.NewReader(img), maxExifSize) if exifOpt := exifOrientation(r); exifOpt.transform() { - m = transformImage(m, exifOpt) + m = transformImage(m, exifOpt, nil) } } @@ -85,14 +91,14 @@ func Transform(img []byte, opt Options) ([]byte, error) { buf := new(bytes.Buffer) switch format { case "bmp": - m = transformImage(m, opt) + m = transformImage(m, opt, watermark) err = bmp.Encode(buf, m) if err != nil { return nil, err } case "gif": fn := func(img image.Image) image.Image { - return transformImage(img, opt) + return transformImage(img, opt, watermark) } err = gifresize.Process(buf, bytes.NewReader(img), fn) if err != nil { @@ -104,19 +110,19 @@ func Transform(img []byte, opt Options) ([]byte, error) { quality = defaultQuality } - m = transformImage(m, opt) + m = transformImage(m, opt, watermark) err = jpeg.Encode(buf, m, &jpeg.Options{Quality: quality}) if err != nil { return nil, err } case "png": - m = transformImage(m, opt) + m = transformImage(m, opt, watermark) err = png.Encode(buf, m) if err != nil { return nil, err } case "tiff": - m = transformImage(m, opt) + m = transformImage(m, opt, watermark) err = tiff.Encode(buf, m, &tiff.Options{Compression: tiff.Deflate, Predictor: true}) if err != nil { return nil, err @@ -270,8 +276,8 @@ func exifOrientation(r io.Reader) (opt Options) { } // transformImage modifies the image m based on the transformations specified -// in opt. -func transformImage(m image.Image, opt Options) image.Image { +// in opt. When watermark is non-nil, it is overlaid after other transforms. +func transformImage(m image.Image, opt Options, watermark image.Image) image.Image { timer := prometheus.NewTimer(metricTransformationDuration) defer timer.ObserveDuration() @@ -322,9 +328,67 @@ func transformImage(m image.Image, opt Options) image.Image { m = imaging.FlipH(m) } + if watermark != nil { + m = applyWatermark(m, watermark, opt) + } + return m } +func applyWatermark(m image.Image, watermark image.Image, opt Options) image.Image { + opacity := opt.WatermarkOpacity + if opacity <= 0 || opacity > 1 { + opacity = 1 + } + scale := opt.WatermarkScale + if scale <= 0 || scale > 1 { + scale = 0.2 + } + + bounds := m.Bounds() + targetW := int(float64(bounds.Dx()) * scale) + if targetW < 1 { + targetW = 1 + } + wm := imaging.Resize(watermark, targetW, 0, resampleFilter) + wmBounds := wm.Bounds() + pt := watermarkPoint(bounds.Dx(), bounds.Dy(), wmBounds.Dx(), wmBounds.Dy(), opt.WatermarkPos, opt.WatermarkPadX, opt.WatermarkPadY) + // imaging.Overlay positions relative to the image's Min point + pt = pt.Add(bounds.Min) + return imaging.Overlay(m, wm, pt, opacity) +} + +func watermarkPoint(imgW, imgH, wmW, wmH int, pos string, padX, padY int) image.Point { + if padX < 0 { + padX = 0 + } + if padY < 0 { + padY = 0 + } + switch strings.ToLower(pos) { + case "nw": + return image.Pt(padX, padY) + case "n": + return image.Pt((imgW-wmW)/2, padY) + case "ne": + return image.Pt(imgW-wmW-padX, padY) + case "w": + return image.Pt(padX, (imgH-wmH)/2) + case "c", "center": + return image.Pt((imgW-wmW)/2, (imgH-wmH)/2) + case "e": + return image.Pt(imgW-wmW-padX, (imgH-wmH)/2) + case "sw": + return image.Pt(padX, imgH-wmH-padY) + case "s": + return image.Pt((imgW-wmW)/2, imgH-wmH-padY) + case "se", "": + return image.Pt(imgW-wmW-padX, imgH-wmH-padY) + default: + return image.Pt(imgW-wmW-padX, imgH-wmH-padY) + } +} + // trimEdges returns a new image with solid color borders of the image removed. // The pixel at the top left corner is used to match the border color. func trimEdges(img image.Image) image.Image { diff --git a/transform_test.go b/transform_test.go index 6a0395e74..fdb364d98 100644 --- a/transform_test.go +++ b/transform_test.go @@ -384,7 +384,7 @@ func TestTransformImage(t *testing.T) { } for _, tt := range tests { - if got := transformImage(tt.src, tt.opt); !reflect.DeepEqual(got, tt.want) { + if got := transformImage(tt.src, tt.opt, nil); !reflect.DeepEqual(got, tt.want) { t.Errorf("transformImage(%v, %v) returned image %#v, want %#v", tt.src, tt.opt, got, tt.want) } } @@ -463,3 +463,42 @@ func TestTrimEdges(t *testing.T) { }) } } + +func TestWatermarkPoint(t *testing.T) { + tests := []struct { + pos string + padX, padY int + want image.Point + }{ + {"se", 0, 0, image.Pt(80, 80)}, + {"", 0, 0, image.Pt(80, 80)}, + {"nw", 5, 5, image.Pt(5, 5)}, + {"c", 0, 0, image.Pt(40, 40)}, + {"n", 0, 10, image.Pt(40, 10)}, + } + for _, tt := range tests { + got := watermarkPoint(100, 100, 20, 20, tt.pos, tt.padX, tt.padY) + if got != tt.want { + t.Errorf("watermarkPoint(pos=%q) = %v, want %v", tt.pos, got, tt.want) + } + } +} + +func TestApplyWatermark(t *testing.T) { + base := newImage(100, 100, red) + wm := newImage(10, 10, blue) + + got := transformImage(base, Options{WatermarkPos: "se", WatermarkOpacity: 1, WatermarkScale: 0.1}, wm) + if got.Bounds().Dx() != 100 || got.Bounds().Dy() != 100 { + t.Fatalf("expected output size 100x100, got %vx%v", got.Bounds().Dx(), got.Bounds().Dy()) + } + // bottom-right area should no longer be pure red after opaque blue overlay + if reflect.DeepEqual(got, base) { + t.Fatal("expected watermarked image to differ from base") + } + + center := transformImage(base, Options{WatermarkPos: "c", WatermarkOpacity: 1, WatermarkScale: 0.1}, wm) + if reflect.DeepEqual(center, got) { + t.Fatal("expected center and se watermark positions to produce different images") + } +}