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.
This commit is contained in:
Will Norris 2025-05-01 02:26:20 -07:00
parent 8170536e41
commit b529c116c0
5 changed files with 104 additions and 16 deletions

View file

@ -184,15 +184,19 @@ first check an in-memory cache for an image, followed by a gcs bucket:
[tiered fashion]: https://pkg.go.dev/github.com/die-net/lrucache/twotier [tiered fashion]: https://pkg.go.dev/github.com/die-net/lrucache/twotier
#### Cache Duration #### Override Cache Directives
By default, images are cached for the duration specified in response headers. By default, imageproxy will respect the caching directives in response headers,
If an image has no cache directives, or an explicit `Cache-Control: no-cache` header, including the cache duration and explicit instructions **not** to cache the response,
then the response is not cached. such as `no-store` and `private` cache-control directives.
To override the response cache directives, set a minimum time that response should be cached for. You can force imageproxy to cache responses, even if they explicitly say not to,
This will ignore `no-cache` and `no-store` directives, and will set `max-age` by passing the `-forceCache` flag. Note that this is generally not recommended.
to the specified value if it is greater than the original `max-age` value.
A minimum cache duration can be set using the `-minCacheDuration` flag. This
will extend the cache duration if the response header indicates a shorter value.
If called without the `-forceCache` flag, this will have no effect on responses
with the `no-store` or `private` directives.
imageproxy -cache /tmp/imageproxy -minCacheDuration 5m imageproxy -cache /tmp/imageproxy -minCacheDuration 5m

View file

@ -47,6 +47,7 @@ var _ = flag.Bool("version", false, "Deprecated: this flag does nothing")
var contentTypes = flag.String("contentTypes", "image/*", "comma separated list of allowed content types") var contentTypes = flag.String("contentTypes", "image/*", "comma separated list of allowed content types")
var userAgent = flag.String("userAgent", "willnorris/imageproxy", "specify the user-agent used by imageproxy when fetching images from origin website") var userAgent = flag.String("userAgent", "willnorris/imageproxy", "specify the user-agent used by imageproxy when fetching images from origin website")
var minCacheDuration = flag.Duration("minCacheDuration", 0, "minimum duration to cache remote images") var minCacheDuration = flag.Duration("minCacheDuration", 0, "minimum duration to cache remote images")
var forceCache = flag.Bool("forceCache", false, "Ignore no-store and private directives in responses")
func init() { func init() {
flag.Var(&cache, "cache", "location to cache images (see https://github.com/willnorris/imageproxy#cache)") flag.Var(&cache, "cache", "location to cache images (see https://github.com/willnorris/imageproxy#cache)")
@ -89,6 +90,7 @@ func main() {
p.Verbose = *verbose p.Verbose = *verbose
p.UserAgent = *userAgent p.UserAgent = *userAgent
p.MinimumCacheDuration = *minCacheDuration p.MinimumCacheDuration = *minCacheDuration
p.ForceCache = *forceCache
server := &http.Server{ server := &http.Server{
Addr: *addr, Addr: *addr,

View file

@ -94,8 +94,13 @@ type Proxy struct {
PassRequestHeaders []string PassRequestHeaders []string
// MinimumCacheDuration is the minimum duration to cache remote images. // MinimumCacheDuration is the minimum duration to cache remote images.
// This will override cache-control instructions from the remote server. // This will override cache duration from the remote server.
MinimumCacheDuration time.Duration MinimumCacheDuration time.Duration
// ForceCache, when true, forces caching of all images, even if the
// remote server specifies 'private' or 'no-store' in the cache-control
// header.
ForceCache bool
} }
// NewProxy constructs a new proxy. The provided http RoundTripper will be // NewProxy constructs a new proxy. The provided http RoundTripper will be
@ -135,14 +140,40 @@ func NewProxy(transport http.RoundTripper, cache Cache) *Proxy {
} }
// updateCacheHeaders updates the cache-control headers in the provided headers. // updateCacheHeaders updates the cache-control headers in the provided headers.
// It sets the cache-control max-age value to the maximum of the minimum cache //
// If the cache-control header includes the 'private' directive,
// then 'no-store' is added to the header to prevent caching.
// If p.ForceCache is set, then 'private' and 'no-store' are both ignored and removed.
//
// This method also sets the cache-control max-age value to the maximum of the minimum cache
// duration, the expires header, and the max-age header. It also removes the // duration, the expires header, and the max-age header. It also removes the
// expires header. // expires header.
func (p *Proxy) updateCacheHeaders(hdr http.Header) { func (p *Proxy) updateCacheHeaders(hdr http.Header) {
cc := tphc.ParseCacheControl(hdr)
// respect 'private' and 'no-store' directives unless ForceCache is set.
// The httpcache package ignores the 'private' directive,
// since it's not intended to be used as a shared cache.
// imageproxy IS a shared cache, so we enforce the 'private' directive ourself
// by setting 'no-store', which httpcache does respect.
if p.ForceCache {
delete(cc, "private")
delete(cc, "no-store")
hdr.Set("Cache-Control", cc.String())
} else {
if _, ok := cc["private"]; ok {
cc["no-store"] = ""
hdr.Set("Cache-Control", cc.String())
return
}
if _, ok := cc["no-store"]; ok {
return
}
}
if p.MinimumCacheDuration == 0 { if p.MinimumCacheDuration == 0 {
return return
} }
cc := tphc.ParseCacheControl(hdr)
var expiresDuration time.Duration var expiresDuration time.Duration
var maxAgeDuration time.Duration var maxAgeDuration time.Duration
@ -160,8 +191,6 @@ func (p *Proxy) updateCacheHeaders(hdr http.Header) {
maxAge := max(p.MinimumCacheDuration, expiresDuration, maxAgeDuration) maxAge := max(p.MinimumCacheDuration, expiresDuration, maxAgeDuration)
cc["max-age"] = fmt.Sprintf("%d", int(maxAge.Seconds())) cc["max-age"] = fmt.Sprintf("%d", int(maxAge.Seconds()))
delete(cc, "no-cache")
delete(cc, "no-store")
hdr.Set("Cache-Control", cc.String()) hdr.Set("Cache-Control", cc.String())
hdr.Del("Expires") hdr.Del("Expires")

View file

@ -377,6 +377,7 @@ func TestProxy_UpdateCacheHeaders(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
minDuration time.Duration minDuration time.Duration
forceCache bool
headers http.Header headers http.Header
want http.Header want http.Header
}{ }{
@ -398,6 +399,14 @@ func TestProxy_UpdateCacheHeaders(t *testing.T) {
"Cache-Control": {"max-age=600"}, "Cache-Control": {"max-age=600"},
}, },
}, },
{
name: "min duration, no header",
minDuration: 30 * time.Second,
headers: http.Header{},
want: http.Header{
"Cache-Control": {"max-age=30"},
},
},
{ {
name: "cache control exceeds min duration", name: "cache control exceeds min duration",
minDuration: 30 * time.Second, minDuration: 30 * time.Second,
@ -457,11 +466,53 @@ func TestProxy_UpdateCacheHeaders(t *testing.T) {
"Cache-Control": {"max-age=3600"}, "Cache-Control": {"max-age=3600"},
}, },
}, },
{
name: "respect no-store",
headers: http.Header{
"Cache-Control": {"max-age=600, no-store"},
},
want: http.Header{
"Cache-Control": {"max-age=600, no-store"},
},
},
{
name: "respect private",
headers: http.Header{
"Cache-Control": {"max-age=600, private"},
},
want: http.Header{
"Cache-Control": {"max-age=600, no-store, private"},
},
},
{
name: "force cache, normalize directives",
forceCache: true,
headers: http.Header{
"Cache-Control": {"MAX-AGE=600, no-store, private"},
},
want: http.Header{
"Cache-Control": {"max-age=600"},
},
},
{
name: "force cache with min duration",
minDuration: 1 * time.Hour,
forceCache: true,
headers: http.Header{
"Cache-Control": {"max-age=600, private, no-store"},
},
want: http.Header{
"Cache-Control": {"max-age=3600"},
},
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
p := &Proxy{MinimumCacheDuration: tt.minDuration} p := &Proxy{
MinimumCacheDuration: tt.minDuration,
ForceCache: tt.forceCache,
}
hdr := maps.Clone(tt.headers) hdr := maps.Clone(tt.headers)
p.updateCacheHeaders(hdr) p.updateCacheHeaders(hdr)

View file

@ -2,6 +2,7 @@ package httpcache
import ( import (
"net/http" "net/http"
"sort"
"strings" "strings"
) )
@ -17,9 +18,9 @@ func ParseCacheControl(headers http.Header) CacheControl {
} }
if strings.ContainsRune(part, '=') { if strings.ContainsRune(part, '=') {
keyval := strings.Split(part, "=") keyval := strings.Split(part, "=")
cc[strings.Trim(keyval[0], " ")] = strings.Trim(keyval[1], ",") cc[strings.ToLower(strings.Trim(keyval[0], " "))] = strings.Trim(keyval[1], ",")
} else { } else {
cc[part] = "" cc[strings.ToLower(part)] = ""
} }
} }
return cc return cc
@ -34,5 +35,6 @@ func (cc CacheControl) String() string {
parts = append(parts, k+"="+v) parts = append(parts, k+"="+v)
} }
} }
sort.StringSlice(parts).Sort()
return strings.Join(parts, ", ") return strings.Join(parts, ", ")
} }