Skip to content

Commit 1ceba25

Browse files
committed
add -forceCache flag to override no-store and private directives
The httpcache package is intended only to be used in private caches, so it will cache responses marked `private` like normal. However, imageproxy is a shared cache, so these response should not be cached under normal circumstances. This change introduces a potentially breaking change to start respecting the `private` cache directive in responses. This also adds a new `-forceCache` flag to ignore the `private` and `no-store` directives, and cache all responses regardless.
1 parent 8170536 commit 1ceba25

File tree

5 files changed

+104
-16
lines changed

5 files changed

+104
-16
lines changed

README.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -184,15 +184,19 @@ first check an in-memory cache for an image, followed by a gcs bucket:
184184

185185
[tiered fashion]: https://pkg.go.dev/github.com/die-net/lrucache/twotier
186186

187-
#### Cache Duration
187+
#### Override Cache Directives
188188

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.
189+
By default, imageproxy will respect the caching directives in response headers,
190+
including the cache duration and explicit instructions **not** to cache the response,
191+
such as `no-store` and `private` cache-control directives.
192192

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.
193+
You can force imageproxy to cache responses, even if they explicitly say not to,
194+
by passing the `-forceCache` flag. Note that this is generally not recommended.
195+
196+
A minimum cache duration can be set using the `-minCacheDuration` flag. This
197+
will extend the cache duration if the response header indicates a shorter value.
198+
If called without the `-forceCache` flag, this will have no effect on responses
199+
with the `no-store` or `private` directives.
196200

197201
imageproxy -cache /tmp/imageproxy -minCacheDuration 5m
198202

cmd/imageproxy/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ 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")
4949
var minCacheDuration = flag.Duration("minCacheDuration", 0, "minimum duration to cache remote images")
50+
var forceCache = flag.Bool("forceCache", false, "Ignore no-store and private directives in responses")
5051

5152
func init() {
5253
flag.Var(&cache, "cache", "location to cache images (see https://github.com/willnorris/imageproxy#cache)")
@@ -89,6 +90,7 @@ func main() {
8990
p.Verbose = *verbose
9091
p.UserAgent = *userAgent
9192
p.MinimumCacheDuration = *minCacheDuration
93+
p.ForceCache = *forceCache
9294

9395
server := &http.Server{
9496
Addr: *addr,

imageproxy.go

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,13 @@ type Proxy struct {
9494
PassRequestHeaders []string
9595

9696
// MinimumCacheDuration is the minimum duration to cache remote images.
97-
// This will override cache-control instructions from the remote server.
97+
// This will override cache duration from the remote server.
9898
MinimumCacheDuration time.Duration
99+
100+
// ForceCache, when true, forces caching of all images, even if the
101+
// remote server specifies 'private' or 'no-store' in the cache-control
102+
// header.
103+
ForceCache bool
99104
}
100105

101106
// NewProxy constructs a new proxy. The provided http RoundTripper will be
@@ -135,14 +140,40 @@ func NewProxy(transport http.RoundTripper, cache Cache) *Proxy {
135140
}
136141

137142
// 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
143+
//
144+
// If the cache-control header includes the 'private' directive,
145+
// then 'no-store' is added to the header to prevent caching.
146+
// If p.ForceCache is set, then 'private' and 'no-store' are both ignored and removed.
147+
//
148+
// This method also sets the cache-control max-age value to the maximum of the minimum cache
149+
// duration, the expires header, and the max-age header. It also removes the
140150
// expires header.
141151
func (p *Proxy) updateCacheHeaders(hdr http.Header) {
152+
cc := tphc.ParseCacheControl(hdr)
153+
154+
// respect 'private' and 'no-store' directives unless ForceCache is set.
155+
// The httpcache package ignores the 'private' directive,
156+
// since it's not intended to be used as a shared cache.
157+
// imageproxy IS a shared cache, so we enforce the 'private' directive ourself
158+
// by setting 'no-store', which httpcache does respect.
159+
if p.ForceCache {
160+
delete(cc, "private")
161+
delete(cc, "no-store")
162+
hdr.Set("Cache-Control", cc.String())
163+
} else {
164+
if _, ok := cc["private"]; ok {
165+
cc["no-store"] = ""
166+
hdr.Set("Cache-Control", cc.String())
167+
return
168+
}
169+
if _, ok := cc["no-store"]; ok {
170+
return
171+
}
172+
}
173+
142174
if p.MinimumCacheDuration == 0 {
143175
return
144176
}
145-
cc := tphc.ParseCacheControl(hdr)
146177

147178
var expiresDuration time.Duration
148179
var maxAgeDuration time.Duration
@@ -160,8 +191,6 @@ func (p *Proxy) updateCacheHeaders(hdr http.Header) {
160191

161192
maxAge := max(p.MinimumCacheDuration, expiresDuration, maxAgeDuration)
162193
cc["max-age"] = fmt.Sprintf("%d", int(maxAge.Seconds()))
163-
delete(cc, "no-cache")
164-
delete(cc, "no-store")
165194

166195
hdr.Set("Cache-Control", cc.String())
167196
hdr.Del("Expires")

imageproxy_test.go

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,7 @@ func TestProxy_UpdateCacheHeaders(t *testing.T) {
377377
tests := []struct {
378378
name string
379379
minDuration time.Duration
380+
forceCache bool
380381
headers http.Header
381382
want http.Header
382383
}{
@@ -398,6 +399,14 @@ func TestProxy_UpdateCacheHeaders(t *testing.T) {
398399
"Cache-Control": {"max-age=600"},
399400
},
400401
},
402+
{
403+
name: "min duration, no header",
404+
minDuration: 30 * time.Second,
405+
headers: http.Header{},
406+
want: http.Header{
407+
"Cache-Control": {"max-age=30"},
408+
},
409+
},
401410
{
402411
name: "cache control exceeds min duration",
403412
minDuration: 30 * time.Second,
@@ -457,11 +466,53 @@ func TestProxy_UpdateCacheHeaders(t *testing.T) {
457466
"Cache-Control": {"max-age=3600"},
458467
},
459468
},
469+
{
470+
name: "respect no-store",
471+
headers: http.Header{
472+
"Cache-Control": {"max-age=600, no-store"},
473+
},
474+
want: http.Header{
475+
"Cache-Control": {"max-age=600, no-store"},
476+
},
477+
},
478+
{
479+
name: "respect private",
480+
headers: http.Header{
481+
"Cache-Control": {"max-age=600, private"},
482+
},
483+
want: http.Header{
484+
"Cache-Control": {"max-age=600, no-store, private"},
485+
},
486+
},
487+
{
488+
name: "force cache, normalize directives",
489+
forceCache: true,
490+
headers: http.Header{
491+
"Cache-Control": {"MAX-AGE=600, no-store, private"},
492+
},
493+
want: http.Header{
494+
"Cache-Control": {"max-age=600"},
495+
},
496+
},
497+
{
498+
name: "force cache with min duration",
499+
minDuration: 1 * time.Hour,
500+
forceCache: true,
501+
headers: http.Header{
502+
"Cache-Control": {"max-age=600, private, no-store"},
503+
},
504+
want: http.Header{
505+
"Cache-Control": {"max-age=3600"},
506+
},
507+
},
460508
}
461509

462510
for _, tt := range tests {
463511
t.Run(tt.name, func(t *testing.T) {
464-
p := &Proxy{MinimumCacheDuration: tt.minDuration}
512+
p := &Proxy{
513+
MinimumCacheDuration: tt.minDuration,
514+
ForceCache: tt.forceCache,
515+
}
465516
hdr := maps.Clone(tt.headers)
466517
p.updateCacheHeaders(hdr)
467518

third_party/httpcache/httpcache.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package httpcache
22

33
import (
44
"net/http"
5+
"sort"
56
"strings"
67
)
78

@@ -17,9 +18,9 @@ func ParseCacheControl(headers http.Header) CacheControl {
1718
}
1819
if strings.ContainsRune(part, '=') {
1920
keyval := strings.Split(part, "=")
20-
cc[strings.Trim(keyval[0], " ")] = strings.Trim(keyval[1], ",")
21+
cc[strings.ToLower(strings.Trim(keyval[0], " "))] = strings.Trim(keyval[1], ",")
2122
} else {
22-
cc[part] = ""
23+
cc[strings.ToLower(part)] = ""
2324
}
2425
}
2526
return cc
@@ -34,5 +35,6 @@ func (cc CacheControl) String() string {
3435
parts = append(parts, k+"="+v)
3536
}
3637
}
38+
sort.StringSlice(parts).Sort()
3739
return strings.Join(parts, ", ")
3840
}

0 commit comments

Comments
 (0)