Skip to content

Commit 7502add

Browse files
committed
allow overriding cache directives in responses
Add a new `-minCacheDuration` flag to specify a minimum duration to cache images for. Updates #28 Updates #144 Fixes #207 Fixes #208
1 parent 82ce506 commit 7502add

File tree

6 files changed

+181
-4
lines changed

6 files changed

+181
-4
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.cache
2+
imageproxy

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,18 @@ first check an in-memory cache for an image, followed by a gcs bucket:
184184

185185
[tiered fashion]: https://godoc.org/github.com/die-net/lrucache/twotier
186186

187+
#### Cache Duration
188+
189+
By default, images are cached for the duration specified in response headers.
190+
If an image has no cache directives, or an explicit `Cache-Control: no-cache` header,
191+
then the response is not cached.
192+
193+
To override the response cache directives, set a minimum time that response should be cached for.
194+
This will ignore `no-cache` and `no-store` directives, and will set `max-age`
195+
to the specified value if it is greater than the original `max-age` value.
196+
197+
imageproxy -cache /tmp/imageproxy -minCacheDuration 5m
198+
187199
### Allowed Referrer List
188200

189201
You can limit images to only be accessible for certain hosts in the HTTP

cmd/imageproxy/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ var verbose = flag.Bool("verbose", false, "print verbose logging messages")
4646
var _ = flag.Bool("version", false, "Deprecated: this flag does nothing")
4747
var contentTypes = flag.String("contentTypes", "image/*", "comma separated list of allowed content types")
4848
var userAgent = flag.String("userAgent", "willnorris/imageproxy", "specify the user-agent used by imageproxy when fetching images from origin website")
49+
var minCacheDuration = flag.Duration("minCacheDuration", 0, "minimum duration to cache remote images")
4950

5051
func init() {
5152
flag.Var(&cache, "cache", "location to cache images (see https://github.com/willnorris/imageproxy#cache)")
@@ -87,6 +88,7 @@ func main() {
8788
p.ScaleUp = *scaleUp
8889
p.Verbose = *verbose
8990
p.UserAgent = *userAgent
91+
p.MinimumCacheDuration = *minCacheDuration
9092

9193
server := &http.Server{
9294
Addr: *addr,

imageproxy.go

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"github.com/prometheus/client_golang/prometheus"
2929
"github.com/prometheus/client_golang/prometheus/promhttp"
3030
tphttp "willnorris.com/go/imageproxy/third_party/http"
31+
tphc "willnorris.com/go/imageproxy/third_party/httpcache"
3132
)
3233

3334
// Maximum number of redirection-followings allowed.
@@ -91,6 +92,10 @@ type Proxy struct {
9192
// PassRequestHeaders identifies HTTP headers to pass from inbound
9293
// requests to the proxied server.
9394
PassRequestHeaders []string
95+
96+
// MinimumCacheDuration is the minimum duration to cache remote images.
97+
// This will override cache-control instructions from the remote server.
98+
MinimumCacheDuration time.Duration
9499
}
95100

96101
// NewProxy constructs a new proxy. The provided http RoundTripper will be
@@ -118,6 +123,7 @@ func NewProxy(transport http.RoundTripper, cache Cache) *Proxy {
118123
proxy.logf(format, v...)
119124
}
120125
},
126+
updateCacheHeaders: proxy.updateCacheHeaders,
121127
},
122128
Cache: cache,
123129
MarkCachedResponses: true,
@@ -128,6 +134,39 @@ func NewProxy(transport http.RoundTripper, cache Cache) *Proxy {
128134
return proxy
129135
}
130136

137+
// updateCacheHeaders updates the cache-control headers in the provided headers.
138+
// It sets the cache-control max-age value to the maximum of the minimum cache
139+
// duration, the expires header, and the max-age header. It also removes the
140+
// expires header.
141+
func (p *Proxy) updateCacheHeaders(hdr http.Header) {
142+
if p.MinimumCacheDuration == 0 {
143+
return
144+
}
145+
cc := tphc.ParseCacheControl(hdr)
146+
147+
var expiresDuration time.Duration
148+
var maxAgeDuration time.Duration
149+
150+
if maxAge, ok := cc["max-age"]; ok {
151+
maxAgeDuration, _ = time.ParseDuration(maxAge + "s")
152+
}
153+
if date, err := httpcache.Date(hdr); err == nil {
154+
if expiresHeader := hdr.Get("Expires"); expiresHeader != "" {
155+
if expires, err := time.Parse(time.RFC1123, expiresHeader); err == nil {
156+
expiresDuration = expires.Sub(date)
157+
}
158+
}
159+
}
160+
161+
maxAge := max(p.MinimumCacheDuration, expiresDuration, maxAgeDuration)
162+
cc["max-age"] = fmt.Sprintf("%d", int(maxAge.Seconds()))
163+
delete(cc, "no-cache")
164+
delete(cc, "no-store")
165+
166+
hdr.Set("Cache-Control", cc.String())
167+
hdr.Del("Expires")
168+
}
169+
131170
// ServeHTTP handles incoming requests.
132171
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
133172
if r.URL.Path == "/favicon.ico" {
@@ -475,6 +514,8 @@ type TransformingTransport struct {
475514
CachingClient *http.Client
476515

477516
log func(format string, v ...any)
517+
518+
updateCacheHeaders func(hdr http.Header)
478519
}
479520

480521
// RoundTrip implements the http.RoundTripper interface.
@@ -484,7 +525,11 @@ func (t *TransformingTransport) RoundTrip(req *http.Request) (*http.Response, er
484525
if t.log != nil {
485526
t.log("fetching remote URL: %v", req.URL)
486527
}
487-
return t.Transport.RoundTrip(req)
528+
resp, err := t.Transport.RoundTrip(req)
529+
if err == nil && t.updateCacheHeaders != nil {
530+
t.updateCacheHeaders(resp.Header)
531+
}
532+
return resp, err
488533
}
489534

490535
f := req.URL.Fragment

imageproxy_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"image"
1313
"image/png"
1414
"log"
15+
"maps"
1516
"net/http"
1617
"net/http/httptest"
1718
"net/url"
@@ -21,6 +22,7 @@ import (
2122
"strconv"
2223
"strings"
2324
"testing"
25+
"time"
2426
)
2527

2628
func TestPeekContentType(t *testing.T) {
@@ -368,6 +370,108 @@ func (t testTransport) RoundTrip(req *http.Request) (*http.Response, error) {
368370
return http.ReadResponse(buf, req)
369371
}
370372

373+
func TestProxy_UpdateCacheHeaders(t *testing.T) {
374+
date := "Mon, 02 Jan 2006 15:04:05 MST"
375+
exp := "Mon, 02 Jan 2006 16:04:05 MST"
376+
377+
tests := []struct {
378+
name string
379+
minDuration time.Duration
380+
headers http.Header
381+
want http.Header
382+
}{
383+
{
384+
name: "zero",
385+
headers: http.Header{},
386+
want: http.Header{},
387+
},
388+
{
389+
name: "no min duration",
390+
headers: http.Header{
391+
"Date": {date},
392+
"Expires": {exp},
393+
"Cache-Control": {"max-age=600"},
394+
},
395+
want: http.Header{
396+
"Date": {date},
397+
"Expires": {exp},
398+
"Cache-Control": {"max-age=600"},
399+
},
400+
},
401+
{
402+
name: "cache control exceeds min duration",
403+
minDuration: 30 * time.Second,
404+
headers: http.Header{
405+
"Cache-Control": {"max-age=600"},
406+
},
407+
want: http.Header{
408+
"Cache-Control": {"max-age=600"},
409+
},
410+
},
411+
{
412+
name: "cache control exceeds min duration, expires",
413+
minDuration: 30 * time.Second,
414+
headers: http.Header{
415+
"Date": {date},
416+
"Expires": {exp},
417+
"Cache-Control": {"max-age=86400"},
418+
},
419+
want: http.Header{
420+
"Date": {date},
421+
"Cache-Control": {"max-age=86400"},
422+
},
423+
},
424+
{
425+
name: "min duration exceeds cache control",
426+
minDuration: 1 * time.Hour,
427+
headers: http.Header{
428+
"Cache-Control": {"max-age=600"},
429+
},
430+
want: http.Header{
431+
"Cache-Control": {"max-age=3600"},
432+
},
433+
},
434+
{
435+
name: "min duration exceeds cache control, expires",
436+
minDuration: 2 * time.Hour,
437+
headers: http.Header{
438+
"Date": {date},
439+
"Expires": {exp},
440+
"Cache-Control": {"max-age=600"},
441+
},
442+
want: http.Header{
443+
"Date": {date},
444+
"Cache-Control": {"max-age=7200"},
445+
},
446+
},
447+
{
448+
name: "expires exceeds min duration, cache control",
449+
minDuration: 30 * time.Minute,
450+
headers: http.Header{
451+
"Date": {date},
452+
"Expires": {exp},
453+
"Cache-Control": {"max-age=600"},
454+
},
455+
want: http.Header{
456+
"Date": {date},
457+
"Cache-Control": {"max-age=3600"},
458+
},
459+
},
460+
}
461+
462+
for _, tt := range tests {
463+
t.Run(tt.name, func(t *testing.T) {
464+
p := &Proxy{MinimumCacheDuration: tt.minDuration}
465+
hdr := maps.Clone(tt.headers)
466+
p.updateCacheHeaders(hdr)
467+
468+
if !reflect.DeepEqual(hdr, tt.want) {
469+
t.Errorf("updateCacheHeaders(%v) returned %v, want %v", tt.headers, hdr, tt.want)
470+
}
471+
})
472+
}
473+
}
474+
371475
func TestProxy_ServeHTTP(t *testing.T) {
372476
p := &Proxy{
373477
Client: &http.Client{

third_party/httpcache/httpcache.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import (
55
"strings"
66
)
77

8-
type cacheControl map[string]string
8+
type CacheControl map[string]string
99

10-
func parseCacheControl(headers http.Header) cacheControl {
11-
cc := cacheControl{}
10+
func ParseCacheControl(headers http.Header) CacheControl {
11+
cc := CacheControl{}
1212
ccHeader := headers.Get("Cache-Control")
1313
for _, part := range strings.Split(ccHeader, ",") {
1414
part = strings.Trim(part, " ")
@@ -24,3 +24,15 @@ func parseCacheControl(headers http.Header) cacheControl {
2424
}
2525
return cc
2626
}
27+
28+
func (cc CacheControl) String() string {
29+
parts := make([]string, 0, len(cc))
30+
for k, v := range cc {
31+
if v == "" {
32+
parts = append(parts, k)
33+
} else {
34+
parts = append(parts, k+"="+v)
35+
}
36+
}
37+
return strings.Join(parts, ", ")
38+
}

0 commit comments

Comments
 (0)