diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62c8935 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6a4293a --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# imgproxyurl + +imgproxyurl is a small library to help you build urls for [imgproxy](https://github.com/imgproxy/imgproxy). + +This is a WIP. + +### Usage +Have a look at an example to understand what's it all about: +```go +url, err := imgproxyurl. + New("/path/to/image.jpg"). + SetKey("e99bd6542067de7dac460558ecada3987dd2d18b066180eaa1c3abc66fb22e463d177ac8f64c93c44d0d78c35adcdda7e0b5f5a116b23ac3d1fa7a305d0727c4"). + SetSalt("a997d51b78d28ba8c05f39b6e634a044b9551352b105f70a4c0fc4c0eca5982719a33527d0253810273bf4d8b747a261cd4898d3e46916cc57d1de8aac132870"). + SetWidth(400). + SetHeight(300). + SetResizingType(imgproxyurl.ResizingTypeFit). + GetAbsolute("http://localhost:8080") + +// url = "http://localhost:8080/a3eK6TO-pMwXvXtakEZjTov3qDrUoDeGL1Xb_1p-Ue4/w:400/h:300/rt:fit/L3BhdGgvdG8vaW1hZ2UuanBn" +``` + +Load key and salt from environment variables `IMGPROXY_KEY` and `IMGPROXY_SALT`: +```go +url, err := imgproxyurl. + NewFromEnvironment("/path/to/image.jpg"). + SetWidth(400). + SetHeight(300). + SetResizingType(imgproxyurl.ResizingTypeFit). + Get() + +// url = "/a3eK6TO-pMwXvXtakEZjTov3qDrUoDeGL1Xb_1p-Ue4/w:400/h:300/rt:fit/L3BhdGgvdG8vaW1hZ2UuanBn" +``` + +### Supported processing options +- [resizing type](https://docs.imgproxy.net/#/generating_the_url_advanced?id=resizing-type) +- [resizing algorithm](https://docs.imgproxy.net/#/generating_the_url_advanced?id=resizing-algorithm) +- [width](https://docs.imgproxy.net/#/generating_the_url_advanced?id=width) +- [height](https://docs.imgproxy.net/#/generating_the_url_advanced?id=height) +- [dpr](https://docs.imgproxy.net/#/generating_the_url_advanced?id=dpr) +- [enlarge](https://docs.imgproxy.net/#/generating_the_url_advanced?id=enlarge) +- [extend](https://docs.imgproxy.net/#/generating_the_url_advanced?id=extend) +- [gravity](https://docs.imgproxy.net/#/generating_the_url_advanced?id=gravity) +- [crop](https://docs.imgproxy.net/#/generating_the_url_advanced?id=crop) +- [padding](https://docs.imgproxy.net/#/generating_the_url_advanced?id=padding) +- [trim](https://docs.imgproxy.net/#/generating_the_url_advanced?id=trim) +- [quality](https://docs.imgproxy.net/#/generating_the_url_advanced?id=quality) +- [max bytes](https://docs.imgproxy.net/#/generating_the_url_advanced?id=max-bytes) +- [background](https://docs.imgproxy.net/#/generating_the_url_advanced?id=background) +- [background alpha](https://docs.imgproxy.net/#/generating_the_url_advanced?id=background-alpha) + +Not all options are supported at the moment. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f900855 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module imgproxyurl + +go 1.15 + +require github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7c401c3 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/processingoptions.go b/processingoptions.go new file mode 100644 index 0000000..4801ac3 --- /dev/null +++ b/processingoptions.go @@ -0,0 +1,305 @@ +package imgproxyurl + +import ( + "errors" + "fmt" + "strconv" +) + +//ResizingType defines how imgproxy will resize the source image. +type ResizingType string + +const ( + //ResizingTypeFit resizes the image while keeping aspect ratio to fit given size + ResizingTypeFit ResizingType = "fit" + //ResizingTypeFill resizes the image while keeping aspect ratio to fill given size and cropping projecting parts + ResizingTypeFill ResizingType = "fill" + //if both source and resulting dimensions have the same orientation (portrait or landscape), imgproxy will use ResizingTypeFill. Otherwise, it will use ResizingTypeFit + ResizingTypeAuto ResizingType = "auto" +) + +// SetResizingType defines how imgproxy will resize the source image +func (url *Url) SetResizingType(resizingType ResizingType) *Url { + url.setOption("rt", string(resizingType)) + return url +} + +// ResizingAlgorithm defines the algorithm that imgproxy will use for resizing +type ResizingAlgorithm string + +const ( + ResizingAlgorithmNearest ResizingAlgorithm = "nearest" + ResizingAlgorithmLinear ResizingAlgorithm = "linear" + ResizingAlgorithmCubic ResizingAlgorithm = "cubic" + ResizingAlgorithmLanczos2 ResizingAlgorithm = "lanczos2" + ResizingAlgorithmLanczos3 ResizingAlgorithm = "lanczos3" +) + +// SetResizingAlgorithm defines the algorithm that imgproxy will use for resizing +func (url *Url) SetResizingAlgorithm(resizingAlgorithm ResizingAlgorithm) *Url { + url.setOption("ra", string(resizingAlgorithm)) + return url +} + +// SetWidth defines the width of the resulting image. +// When set to 0, imgproxy will calculate the resulting width using the defined height and source aspect ratio. +func (url *Url) SetWidth(width int) *Url { + url.setOption("w", strconv.Itoa(width)) + return url +} + +// SetHeight defines the height of the resulting image. +// When set to 0, imgproxy will calculate resulting height using the defined width and source aspect ratio. +func (url *Url) SetHeight(height int) *Url { + url.setOption("h", strconv.Itoa(height)) + return url +} + +// When set, imgproxy will multiply the image dimensions according to this factor for HiDPI (Retina) devices. +// The value must be greater than 0. +func (url *Url) SetDpr(dpr int) *Url { + url.setOption("dpr", strconv.Itoa(dpr)) + return url +} + +// When set, imgproxy will enlarge the image if it is smaller than the given size. +func (url *Url) SetEnlarge(enlarge bool) *Url { + if enlarge { + url.setOption("el", "1") + } else { + url.unsetOption("el") + } + + return url +} + +type GravityType string + +const ( + //GravityTypeDefault lets imgproxy use the default gravity. This is equal to not specifying the gravity on the url + GravityTypeDefault GravityType = "" + GravityTypeNorth GravityType = "no" + GravityTypeSouth GravityType = "so" + GravityTypeEast GravityType = "ea" + GravityTypeWest GravityType = "we" + GravityTypeNorthEast GravityType = "noea" + GravityTypeNorthWest GravityType = "nowe" + GravityTypeSouthEast GravityType = "soea" + GravityTypeSouthWest GravityType = "sowe" + GravityTypeCenter GravityType = "ce" + GravityTypeSmart GravityType = "sm" + GravityTypeFocusPoint GravityType = "fp" +) + +type GravityOffsets interface { + IsGravityOffset() bool +} + +type GravityIntegerOffsets struct { + X int + Y int +} + +func (g GravityIntegerOffsets) IsGravityOffset() bool { + return true +} + +type GravityFloatOffsets struct { + X float64 + Y float64 +} + +func (g GravityFloatOffsets) IsGravityOffset() bool { + return true +} + +func (url *Url) getGravityArguments(gravityType GravityType, offsets GravityOffsets) []string { + if gravityType == GravityTypeDefault { + url.setError(errors.New("specific gravity type is required")) + } + + var gravityOffsetsTypeInteger bool + if offsets != nil { + switch offsets.(type) { + case GravityIntegerOffsets: + gravityOffsetsTypeInteger = true + case GravityFloatOffsets: + gravityOffsetsTypeInteger = false + if offsets.(GravityFloatOffsets).X < 0 || + offsets.(GravityFloatOffsets).X > 1 || + offsets.(GravityFloatOffsets).Y < 0 || + offsets.(GravityFloatOffsets).Y > 1 { + url.setError(errors.New("float offsets must within (0,1) range")) + } + } + } + + arguments := []string{string(gravityType)} + if gravityType == GravityTypeSmart { + if offsets != nil { + url.setError(errors.New("offsets are not applicable for smart gravity")) + } + } else if gravityType == GravityTypeFocusPoint { + if offsets == nil { + url.setError(errors.New("offsets are required for focus point gravity")) + } else if gravityOffsetsTypeInteger { + url.setError(errors.New("focus point gravity requires floating-point offsets")) + } else { + arguments = append(arguments, fmt.Sprintf("%.3f", offsets.(GravityFloatOffsets).X), fmt.Sprintf("%.3f", offsets.(GravityFloatOffsets).Y)) + } + } else { + if offsets != nil && !gravityOffsetsTypeInteger { + url.setError(errors.New("integer offsets are required")) + } else if offsets != nil { + arguments = append(arguments, strconv.Itoa(offsets.(GravityIntegerOffsets).X), strconv.Itoa(offsets.(GravityIntegerOffsets).Y)) + } + } + + return arguments +} + +//When imgproxy needs to cut some parts of the image, it is guided by the gravity. +func (url *Url) SetGravity(gravityType GravityType, offsets GravityOffsets) *Url { + url.setOption("g", url.getGravityArguments(gravityType, offsets)...) + return url +} + +func (url *Url) SetExtendWithGravityOffsets(extend bool, gravityType GravityType, gravityOffsets GravityOffsets) *Url { + if !extend { + url.unsetOption("ex") + } else { + arguments := []string{"1"} + + if gravityType == GravityTypeDefault { + if gravityOffsets != nil { + url.setError(errors.New("offsets are not applicable for default gravity")) + } + } else if gravityType == GravityTypeSmart { + url.setError(errors.New("smart gravity type is not applicable here")) + } else { + arguments = append(arguments, url.getGravityArguments(gravityType, gravityOffsets)...) + } + + url.setOption("ex", arguments...) + } + + return url +} +func (url *Url) SetExtendWithGravity(extend bool, gravityType GravityType) *Url { + return url.SetExtendWithGravityOffsets(extend, gravityType, nil) +} +func (url *Url) SetExtend(extend bool) *Url { + return url.SetExtendWithGravityOffsets(extend, GravityTypeDefault, nil) +} + +func (url *Url) SetCropWithGravityOffsets(width int, height int, gravityType GravityType, gravityOffsets GravityOffsets) *Url { + arguments := []string{strconv.Itoa(width), strconv.Itoa(height)} + + if gravityType == GravityTypeDefault { + if gravityOffsets != nil { + url.setError(errors.New("offsets are not applicable for default gravity")) + } + } else { + arguments = append(arguments, url.getGravityArguments(gravityType, gravityOffsets)...) + } + + url.setOption("c", arguments...) + + return url +} +func (url *Url) SetCropWithGravity(width int, height int, gravityType GravityType) *Url { + return url.SetCropWithGravityOffsets(width, height, gravityType, nil) +} +func (url *Url) SetCrop(width int, height int) *Url { + return url.SetCropWithGravityOffsets(width, height, GravityTypeDefault, nil) +} + +func (url *Url) SetPadding(top int, right int, bottom int, left int) *Url { + url.setOption("pd", strconv.Itoa(top), strconv.Itoa(right), strconv.Itoa(bottom), strconv.Itoa(left)) + return url +} + +func (url *Url) SetPaddingAll(padding int) *Url { + url.setOption("pd", strconv.Itoa(padding)) + return url +} + +type TrimOption interface { + IsTrimOptions() bool +} +type TrimOptionColor struct { + Color string +} + +func (t TrimOptionColor) IsTrimOptions() bool { + return true +} + +type TrimOptionEqualHor struct{} + +func (t TrimOptionEqualHor) IsTrimOptions() bool { + return true +} + +type TrimOptionEqualVer struct{} + +func (t TrimOptionEqualVer) IsTrimOptions() bool { + return true +} + +//SetTrim Removes surrounding background. +func (url *Url) SetTrim(threshold int, options ...TrimOption) *Url { + arguments := []string{strconv.Itoa(threshold), "", "", ""} + + for _, option := range options { + switch option.(type) { + case TrimOptionColor: + arguments[1] = option.(TrimOptionColor).Color + case TrimOptionEqualHor: + arguments[2] = "1" + case TrimOptionEqualVer: + arguments[3] = "1" + } + } + + url.setOption("t", arguments...) + return url +} + +//SetQuality Redefines quality of the resulting image, percentage. +func (url *Url) SetQuality(quality int) *Url { + url.setOption("q", strconv.Itoa(quality)) + return url +} + +//When set, imgproxy automatically degrades the quality of the image until the image is under the specified amount of bytes. +func (url *Url) SetMaxBytes(maxBytes int) *Url { + url.setOption("mb", strconv.Itoa(maxBytes)) + return url +} + +// When set, imgproxy will fill the resulting image background with the specified color. +// red, green and blue are channel values of the background color (0-255). +// Useful when you convert an image with alpha-channel to JPEG. +// +// When not set, disables any background manipulations. +func (url *Url) SetBackgroundRGB(red int, green int, blue int) *Url { + url.setOption("bg", strconv.Itoa(red), strconv.Itoa(green), strconv.Itoa(blue)) + return url +} + +// When set, imgproxy will fill the resulting image background with the specified color. +// color is a hex-coded value of the color.. +// Useful when you convert an image with alpha-channel to JPEG. +// +// When not set, disables any background manipulations. +func (url *Url) SetBackgroundHex(color string) *Url { + url.setOption("bg", color) + return url +} + +//SetBackgroundAlpha adds alpha channel to background. alpha is a positive floating point number between 0 and 1. +func (url *Url) SetBackgroundAlpha(alpha float64) *Url { + url.setOption("bga", fmt.Sprintf("%.3f", alpha)) + return url +} diff --git a/url.go b/url.go new file mode 100644 index 0000000..90505f1 --- /dev/null +++ b/url.go @@ -0,0 +1,192 @@ +package imgproxyurl + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "github.com/pkg/errors" + "os" + "strings" +) + +// Url represents an imgproxy url. +// +// Use url.Set* functions to set various options and finnaly use Url.Get() to get the url. +// If any error occurs, it is returned from Url.Get() method. This is so to make method chaining possible. +// Only the first occurred error is returned. +// +// A typical usage would look like: +// url, err := imgproxyurl.NewFromEnvironment("local:///0/D/0DJ2jdB5DDJa.jpg"). +// SetHeight(400). +// SetWidth(300). +// SetResizingType(imgproxyurl.ResizingTypeFit) +// Get() +type Url struct { + key []byte + salt []byte + processingOptions map[string]string + imageUrl string + err error + plainImageUrl bool + extension string +} + +// New creates Url +func New(imageUrl string) *Url { + return &Url{ + imageUrl: imageUrl, + processingOptions: make(map[string]string), + } +} + +// NewFromEnvironment creates Url while trying to get key and salt values +// from environment variables IMGPROXY_KEY and IMGPROXY_SALT +func NewFromEnvironment(imageUrl string) *Url { + url := New(imageUrl) + key := os.Getenv("IMGPROXY_KEY") + salt := os.Getenv("IMGPROXY_SALT") + + if key != "" && salt != "" { + url.SetKey(key) + url.SetSalt(salt) + } else if key != "" && salt == "" { + url.setError(errors.New("salt is missing")) + } else if key == "" && salt != "" { + url.setError(errors.New("key is missing")) + } + + return url +} + +// Get returns an imgproxy url for the given imageUrl +// +// This is a shortnand for New(imageUrl).Get() +func Get(imageUrl string) (string, error) { + return New(imageUrl).Get() +} + +// GetFromEnvironment returns an imgproxy url for the given imageUrl, +// having taken the key and salt from the environment variables IMGPROXY_KEY and IMGPROXY_SALT. +// +// This is a shortnand for NewFromEnvironment(imageUrl).Get() +func GetFromEnvironment(imageUrl string) (string, error) { + return NewFromEnvironment(imageUrl).Get() +} + +func (url *Url) setError(err error) { + if url.err != nil { + return + } + + url.err = err +} + +// SetKey set the imgproxy key. +// +// key is expected to be a hex-encoded string +func (url *Url) SetKey(key string) *Url { + bytes, err := hex.DecodeString(key) + if err != nil { + url.setError(errors.WithMessage(err, "key")) + return url + } + url.key = bytes + return url +} + +// SetSalt set the imgproxy salt. +// +// salt is expected to be a hex-encoded string +func (url *Url) SetSalt(salt string) *Url { + bytes, err := hex.DecodeString(salt) + if err != nil { + url.setError(errors.WithMessage(err, "salt")) + return url + } + url.salt = bytes + return url +} + +// SetExtension set the the resulting image format +func (url *Url) SetExtension(extension string) *Url { + url.extension = extension + return url +} + +func (url *Url) setOption(name string, arguments ...string) { + url.processingOptions[name] = strings.Join(arguments, ":") +} + +func (url *Url) unsetOption(name string) { + delete(url.processingOptions, name) +} + +func (url *Url) encodeImageUrl() string { + var encodedUrl string + if url.plainImageUrl { + encodedUrl = "plain/" + url.imageUrl + if url.extension != "" { + encodedUrl += "@" + url.extension + } + } else { + encodedUrl = base64.RawURLEncoding.EncodeToString([]byte(url.imageUrl)) + if url.extension != "" { + encodedUrl += "." + url.extension + } + } + return encodedUrl +} + +func (url *Url) getPath() string { + var urlParts []string + for name, arguments := range url.processingOptions { + urlParts = append(urlParts, name+":"+arguments) + } + urlParts = append(urlParts, url.encodeImageUrl()) + return "/" + strings.Join(urlParts, "/") +} + +func (url *Url) sign(str string) string { + mac := hmac.New(sha256.New, url.key) + mac.Write(url.salt) + mac.Write([]byte(str)) + + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +// Get returns the resulting imgproxy url. +// +// If a key and a salt were provided, the url will be signed. +// If any errors occured during the previous Set* functions calls, the first of them will be returned. +func (url *Url) Get() (string, error) { + if url.imageUrl == "" { + url.setError(errors.New("image url is missing")) + } + + if url.err != nil { + return "", url.err + } + + path := url.getPath() + + var signature string + if url.key != nil && url.salt != nil { + signature = url.sign(path) + } else { + signature = "insecure" + } + + return fmt.Sprintf("/%s%s", signature, path), nil +} + +//GetAbsolute returns the resulting imgproxy absolute url by prepending suffix in front. +func (url *Url) GetAbsolute(prefix string) (string, error) { + result, err := url.Get() + if err != nil { + return result, err + } + + return prefix + result, nil +}