Skip to content
Merged
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,14 @@ If both a whiltelist and signatureKey are specified, requests can match either.
In other words, requests that match one of the allowed hosts don't necessarily
need to be signed, though they can be.

To limit how long a URL is valid (particularly useful for signed URLs),
you can specify a "valid until" time using the `vu` option with a Unix timestamp.
For example, the following signed URL would only be valid until 2020-01-01:

```
http://localhost:8080/vu1577836800,sjNcVf6LxzKEvR6Owgg3zhEMN7xbWxlpf-eyYbRfFK4A=/https://example.com/image
```

### Default Base URL

Typically, remote images to be proxied are specified as absolute URLs.
Expand Down
20 changes: 20 additions & 0 deletions data.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"sort"
"strconv"
"strings"
"time"
"unicode"
)

Expand All @@ -33,6 +34,7 @@ const (
optCropHeight = "ch"
optSmartCrop = "sc"
optTrim = "trim"
optValidUntil = "vu"
)

// URLError reports a malformed URL error.
Expand Down Expand Up @@ -86,6 +88,9 @@ type Options struct {

// If true, automatically trim pixels of the same color around the edges
Trim bool

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

func (o Options) String() string {
Expand Down Expand Up @@ -132,7 +137,12 @@ func (o Options) String() string {
if o.Trim {
opts = append(opts, optTrim)
}
if !o.ValidUntil.IsZero() {
opts = append(opts, fmt.Sprintf("%s%d", optValidUntil, o.ValidUntil.Unix()))
}

sort.Strings(opts)

return strings.Join(opts, ",")
}

Expand Down Expand Up @@ -235,6 +245,11 @@ func (o Options) transform() bool {
// that have been resized or cropped. The trim option is applied before other
// options such as cropping or resizing.
//
// # Valid Until
//
// 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.
//
// Examples
//
// 0x0 - no resizing
Expand Down Expand Up @@ -289,6 +304,11 @@ func ParseOptions(str string) Options {
case strings.HasPrefix(opt, optCropHeight):
value := strings.TrimPrefix(opt, optCropHeight)
options.CropHeight, _ = strconv.ParseFloat(value, 64)
case strings.HasPrefix(opt, optValidUntil):
value := strings.TrimPrefix(opt, optValidUntil)
if v, _ := strconv.ParseInt(value, 10, 64); v > 0 {
options.ValidUntil = time.Unix(v, 0)
}
case strings.Contains(opt, optSizeDelimiter):
size := strings.SplitN(opt, optSizeDelimiter, 2)
if w := size[0]; w != "" {
Expand Down
7 changes: 4 additions & 3 deletions data_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"testing"
"time"
)

var emptyOptions = Options{}
Expand All @@ -25,8 +26,8 @@ func TestOptions_String(t *testing.T) {
"1x2,fh,fit,fv,q80,r90",
},
{
Options{Width: 0.15, Height: 1.3, Rotate: 45, Quality: 95, Signature: "c0ffee", Format: "png"},
"0.15x1.3,png,q95,r45,sc0ffee",
Options{Width: 0.15, Height: 1.3, Rotate: 45, Quality: 95, Signature: "c0ffee", Format: "png", ValidUntil: time.Unix(123, 0)},
"0.15x1.3,png,q95,r45,sc0ffee,vu123",
},
{
Options{Width: 0.15, Height: 1.3, CropX: 100, CropY: 200},
Expand Down Expand Up @@ -86,7 +87,7 @@ func TestParseOptions(t *testing.T) {
// flags, in different orders
{"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", Options{Width: 1, Height: 2, ScaleUp: true, CropX: 100, CropY: 200, CropWidth: 300, CropHeight: 400, SmartCrop: true}},
{"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)}},
}

for _, tt := range tests {
Expand Down
19 changes: 17 additions & 2 deletions imageproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ type Proxy struct {
// remote server specifies 'private' or 'no-store' in the cache-control
// header.
ForceCache bool

timeNow time.Time // current time, used for testing
}

// NewProxy constructs a new proxy. The provided http RoundTripper will be
Expand Down Expand Up @@ -278,7 +280,6 @@ func (p *Proxy) serveImage(w http.ResponseWriter, r *http.Request) {
}
}
resp, err := p.Client.Do(actualReq)

if err != nil {
msg := fmt.Sprintf("error fetching remote image: %v", err)
p.log(msg)
Expand Down Expand Up @@ -371,15 +372,29 @@ var (
errDeniedHost = errors.New("request contains a denied host")
errNotAllowed = errors.New("request does not contain an allowed host or valid signature")
errTooManyRedirects = errors.New("too many redirects")
errNotValid = errors.New("request is no longer valid")

msgNotAllowed = "requested URL is not allowed"
msgNotAllowedInRedirect = "requested URL in redirect is not allowed"
)

func (p *Proxy) now() time.Time {
if !p.timeNow.IsZero() {
return p.timeNow
}
return time.Now()
}

// allowed determines whether the specified request contains an allowed
// referrer, host, and signature. It returns an error if the request is not
// allowed.
// allowed or not valid any longer.
func (p *Proxy) allowed(r *Request) error {
if !r.Options.ValidUntil.IsZero() {
if !p.now().Before(r.Options.ValidUntil) {
return errNotValid
}
}

if len(p.Referrers) > 0 && !referrerMatches(p.Referrers, r.Original) {
return errReferrer
}
Expand Down
9 changes: 9 additions & 0 deletions imageproxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,11 @@ func TestAllowed(t *testing.T) {
return req
}

now := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)

tests := []struct {
url string
now time.Time
options Options
allowHosts []string
denyHosts []string
Expand Down Expand Up @@ -153,6 +156,11 @@ func TestAllowed(t *testing.T) {
{url: "http://test/image", options: Options{Signature: "NDx5zZHx7QfE8E-ijowRreq6CJJBZjwiRfOVk_mkfQQ="}, denyHosts: []string{"test"}, keys: key, allowed: false},
{url: "http://127.0.0.1/image", denyHosts: []string{"127.0.0.0/8"}, allowed: false},
{url: "http://127.0.0.1:3000/image", denyHosts: []string{"127.0.0.0/8"}, allowed: false},

// valid until options
{url: "http://test/image", now: now, options: Options{ValidUntil: now.Add(time.Second)}, allowed: true},
{url: "http://test/image", now: now, options: Options{ValidUntil: now.Add(-time.Second)}, allowed: false},
{url: "http://test/image", now: now, options: Options{ValidUntil: now}, allowed: false},
}

for _, tt := range tests {
Expand All @@ -161,6 +169,7 @@ func TestAllowed(t *testing.T) {
p.DenyHosts = tt.denyHosts
p.SignatureKeys = tt.keys
p.Referrers = tt.referrers
p.timeNow = tt.now

u, err := url.Parse(tt.url)
if err != nil {
Expand Down
Loading