From 780ea8c339c3d2dfcfe267e31bc16e1cdba08552 Mon Sep 17 00:00:00 2001 From: perennial Date: Wed, 4 Dec 2024 10:40:24 +1100 Subject: [PATCH] cache-control tests --- .env.example | 6 +++- config/config.go | 48 +++++++++++++++++----------- doc/hosting/environment-variables.md | 22 ++++++++++++- server/routes/artwork.go | 6 ++++ server/routes/artworkMulti.go | 6 ++++ server/routes/index.go | 7 ++++ server/routes/mangaseries.go | 5 +++ server/routes/novel.go | 6 ++++ server/routes/novelseries.go | 5 +++ server/routes/pixivision.go | 19 +++++++++++ server/routes/ranking.go | 6 ++++ server/routes/rankingCalendar.go | 5 +++ server/routes/tag.go | 5 +++ server/routes/types.go | 18 +++++++---- server/routes/user.go | 12 +++++++ 15 files changed, 149 insertions(+), 27 deletions(-) diff --git a/.env.example b/.env.example index c807ad9..b674b53 100644 --- a/.env.example +++ b/.env.example @@ -25,12 +25,16 @@ PIXIVFE_HOST='127.0.0.1' # PIXIVFE_TOKEN_BASE_TIMEOUT= # PIXIVFE_TOKEN_MAX_BACKOFF_TIME= -### Caching configuration +### API response caching configuration # PIXIVFE_CACHE_ENABLED= # PIXIVFE_CACHE_SIZE= # PIXIVFE_CACHE_TTL= # PIXIVFE_CACHE_SHORT_TTL= +### HTTP caching configuration +# PIXIVFE_CACHE_CONTROL_MAX_AGE= +# PIXIVFE_CACHE_CONTROL_STALE_WHILE_REVALIDATE= + ### Feature configuration # PIXIVFE_POPULAR_SEARCH_ENABLED= diff --git a/config/config.go b/config/config.go index 1373da6..beb6550 100644 --- a/config/config.go +++ b/config/config.go @@ -31,24 +31,26 @@ const ( defaultPort = "8282" - defaultRepoUrl = "https://codeberg.org/PixivFE/PixivFE" - defaultAcceptLanguage = "en-US,en;q=0.5" - defaultProxyServerStaging = BuiltinProxyUrl - defaultProxyCheckEnabled = true - defaultProxyCheckInterval = 8 * time.Hour - defaultProxyCheckTimeout = 4 * time.Second - defaultTokenLoadBalancing = "round-robin" - defaultTokenMaxRetries = 5 - defaultTokenBaseTimeout = 1000 * time.Millisecond - defaultTokenMaxBackoffTime = 32000 * time.Millisecond - defaultCacheEnabled = false - defaultCacheSize = 100 - defaultCacheTTL = 60 * time.Minute - defaultCacheShortTTL = 10 * time.Second - defaultPopularSearchEnabled = false - defaultResponseSaveLocation = "/tmp/pixivfe/responses" - defaultLogLevel = "info" - defaultLogFormat = "console" + defaultRepoUrl = "https://codeberg.org/PixivFE/PixivFE" + defaultAcceptLanguage = "en-US,en;q=0.5" + defaultProxyServerStaging = BuiltinProxyUrl + defaultProxyCheckEnabled = true + defaultProxyCheckInterval = 8 * time.Hour + defaultProxyCheckTimeout = 4 * time.Second + defaultTokenLoadBalancing = "round-robin" + defaultTokenMaxRetries = 5 + defaultTokenBaseTimeout = 1000 * time.Millisecond + defaultTokenMaxBackoffTime = 32000 * time.Millisecond + defaultCacheEnabled = false + defaultCacheSize = 100 + defaultCacheTTL = 60 * time.Minute + defaultCacheShortTTL = 10 * time.Second + defaultCacheControlMaxAge = 30 * time.Second + defaultCacheControlStaleWhileRevalidate = 60 * time.Second + defaultPopularSearchEnabled = false + defaultResponseSaveLocation = "/tmp/pixivfe/responses" + defaultLogLevel = "info" + defaultLogFormat = "console" ) var defaultLogOutputs = []string{"stdout"} @@ -86,12 +88,17 @@ type ServerConfig struct { ProxyCheckInterval time.Duration `env:"PIXIVFE_PROXY_CHECK_INTERVAL,overwrite"` ProxyCheckTimeout time.Duration `env:"PIXIVFE_PROXY_CHECK_TIMEOUT,overwrite"` - // Caching configuration + // API response caching configuration CacheEnabled bool `env:"PIXIVFE_CACHE_ENABLED,overwrite"` CacheSize int `env:"PIXIVFE_CACHE_SIZE,overwrite"` CacheTTL time.Duration `env:"PIXIVFE_CACHE_TTL,overwrite"` CacheShortTTL time.Duration `env:"PIXIVFE_CACHE_SHORT_TTL,overwrite"` + // Cache-Control header configuration + // TODO: fine-tune the default durations to actual usage patterns + CacheControlMaxAge time.Duration `env:"PIXIVFE_CACHE_CONTROL_MAX_AGE,overwrite"` + CacheControlStaleWhileRevalidate time.Duration `env:"PIXIVFE_CACHE_CONTROL_STALE_WHILE_REVALIDATE,overwrite"` + // Feature configuration PopularSearchEnabled bool `env:"PIXIVFE_POPULAR_SEARCH_ENABLED,overwrite"` @@ -165,6 +172,9 @@ func (s *ServerConfig) LoadConfig() error { s.CacheTTL = defaultCacheTTL s.CacheShortTTL = defaultCacheShortTTL + s.CacheControlMaxAge = defaultCacheControlMaxAge + s.CacheControlStaleWhileRevalidate = defaultCacheControlStaleWhileRevalidate + s.PopularSearchEnabled = defaultPopularSearchEnabled s.ResponseSaveLocation = defaultResponseSaveLocation diff --git a/doc/hosting/environment-variables.md b/doc/hosting/environment-variables.md index 62cd22d..a094da7 100644 --- a/doc/hosting/environment-variables.md +++ b/doc/hosting/environment-variables.md @@ -167,7 +167,7 @@ Base timeout duration for token management. Maximum backoff time for token management. -## Caching configuration +## API response caching configuration PixivFE implements a caching system for API responses to improve performance. The cache uses a [Least Recently Used (LRU) eviction policy](https://en.wikipedia.org/wiki/Cache_replacement_policies#Least_Recently_Used_(LRU)). @@ -215,6 +215,26 @@ Specifies a shorter Time To Live (TTL) for specific API endpoints that change fr These paths are considered more dynamic, and the shorter TTL ensures more up-to-date content. +## HTTP caching configuration + +These cache control settings affect browser-level caching behavior and are separate from PixivFE's internal API response cache. They help reduce server load by allowing browsers to cache artwork pages while ensuring content stays reasonably fresh. + +### `PIXIVFE_CACHE_CONTROL_MAX_AGE` + +**Required**: No + +**Default:** `30s` + +Controls the `max-age` directive in the Cache-Control response header for artwork pages. This determines how long browsers should cache the page before revalidating. The value should be specified in Go's `time.Duration` notation (e.g., `60s`, `5m`). + +### `PIXIVFE_CACHE_CONTROL_STALE_WHILE_REVALIDATE` + +**Required**: No + +**Default:** `60s` + +Controls the `stale-while-revalidate` directive in the Cache-Control response header for artwork pages. This allows browsers to show stale content while fetching a fresh version in the background. The value should be specified in Go's `time.Duration` notation (e.g., `120s`, `2m`). + ## Feature configuration ### `PIXIVFE_POPULAR_SEARCH_ENABLED` diff --git a/server/routes/artwork.go b/server/routes/artwork.go index 4adef72..ed3a706 100644 --- a/server/routes/artwork.go +++ b/server/routes/artwork.go @@ -6,6 +6,7 @@ import ( "strconv" "codeberg.org/vnpower/pixivfe/v2/audit" + "codeberg.org/vnpower/pixivfe/v2/config" "codeberg.org/vnpower/pixivfe/v2/core" "codeberg.org/vnpower/pixivfe/v2/i18n" ) @@ -31,6 +32,11 @@ func ArtworkPage(auditor *audit.Auditor, w http.ResponseWriter, r *http.Request) PreloadImage(w, img.Large) } + // TODO: need to handle bookmarked/liked status + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d", + int(config.GlobalConfig.CacheControlMaxAge.Seconds()), + int(config.GlobalConfig.CacheControlStaleWhileRevalidate.Seconds()))) + return RenderHTML(w, r, Data_artwork{ Illust: *illust, Title: illust.Title, diff --git a/server/routes/artworkMulti.go b/server/routes/artworkMulti.go index 1311b54..654a331 100644 --- a/server/routes/artworkMulti.go +++ b/server/routes/artworkMulti.go @@ -8,6 +8,7 @@ import ( "sync" "codeberg.org/vnpower/pixivfe/v2/audit" + "codeberg.org/vnpower/pixivfe/v2/config" "codeberg.org/vnpower/pixivfe/v2/core" "codeberg.org/vnpower/pixivfe/v2/i18n" ) @@ -66,6 +67,11 @@ func ArtworkMultiPage(auditor *audit.Auditor, w http.ResponseWriter, r *http.Req } } + // TODO: need to handle bookmarked/liked status + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d", + int(config.GlobalConfig.CacheControlMaxAge.Seconds()), + int(config.GlobalConfig.CacheControlStaleWhileRevalidate.Seconds()))) + return RenderHTML(w, r, Data_artworkMulti{ Artworks: artworks, Title: fmt.Sprintf("(%d images)", len(artworks)), diff --git a/server/routes/index.go b/server/routes/index.go index 9878096..2901f03 100644 --- a/server/routes/index.go +++ b/server/routes/index.go @@ -1,9 +1,11 @@ package routes import ( + "fmt" "net/http" "codeberg.org/vnpower/pixivfe/v2/audit" + "codeberg.org/vnpower/pixivfe/v2/config" "codeberg.org/vnpower/pixivfe/v2/core" "codeberg.org/vnpower/pixivfe/v2/server/session" "codeberg.org/vnpower/pixivfe/v2/server/template" @@ -24,6 +26,11 @@ func IndexPage(auditor *audit.Auditor, w http.ResponseWriter, r *http.Request) e Query: map[string]string{"mode": mode}, } + // Login status handled by RenderWithContentType, so set public directive unconditionally here + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d", + int(config.GlobalConfig.CacheControlMaxAge.Seconds()), + int(config.GlobalConfig.CacheControlStaleWhileRevalidate.Seconds()))) + return RenderHTML(w, r, Data_index{ Title: "Landing", Data: *works, diff --git a/server/routes/mangaseries.go b/server/routes/mangaseries.go index 64c9de2..95f50e3 100644 --- a/server/routes/mangaseries.go +++ b/server/routes/mangaseries.go @@ -7,6 +7,7 @@ import ( "strconv" "codeberg.org/vnpower/pixivfe/v2/audit" + "codeberg.org/vnpower/pixivfe/v2/config" "codeberg.org/vnpower/pixivfe/v2/core" "codeberg.org/vnpower/pixivfe/v2/i18n" ) @@ -62,6 +63,10 @@ func MangaSeriesPage(auditor *audit.Auditor, w http.ResponseWriter, r *http.Requ title := fmt.Sprintf("%s / %s Series", seriesContent.Brief.Title, user.Name) + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d", + int(config.GlobalConfig.CacheControlMaxAge.Seconds()), + int(config.GlobalConfig.CacheControlStaleWhileRevalidate.Seconds()))) + return RenderHTML(w, r, Data_mangaSeries{ MangaSeriesContent: seriesContent, Title: title, diff --git a/server/routes/novel.go b/server/routes/novel.go index 1b54eef..4ed5545 100644 --- a/server/routes/novel.go +++ b/server/routes/novel.go @@ -7,6 +7,7 @@ import ( "strings" "codeberg.org/vnpower/pixivfe/v2/audit" + "codeberg.org/vnpower/pixivfe/v2/config" "codeberg.org/vnpower/pixivfe/v2/core" "codeberg.org/vnpower/pixivfe/v2/i18n" "codeberg.org/vnpower/pixivfe/v2/server/session" @@ -75,6 +76,11 @@ func NovelPage(auditor *audit.Auditor, w http.ResponseWriter, r *http.Request) e novelSeriesTitles[i] = fmt.Sprintf("#%d %s", i+1, ct.Title) } + // User preferences handled by Vary: Cookie + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d", + int(config.GlobalConfig.CacheControlMaxAge.Seconds()), + int(config.GlobalConfig.CacheControlStaleWhileRevalidate.Seconds()))) + return RenderHTML(w, r, Data_novel{ Novel: novel, NovelRelated: related, diff --git a/server/routes/novelseries.go b/server/routes/novelseries.go index ee8a9db..0b2bef4 100644 --- a/server/routes/novelseries.go +++ b/server/routes/novelseries.go @@ -7,6 +7,7 @@ import ( "strconv" "codeberg.org/vnpower/pixivfe/v2/audit" + "codeberg.org/vnpower/pixivfe/v2/config" "codeberg.org/vnpower/pixivfe/v2/core" "codeberg.org/vnpower/pixivfe/v2/i18n" ) @@ -45,6 +46,10 @@ func NovelSeriesPage(auditor *audit.Auditor, w http.ResponseWriter, r *http.Requ title := fmt.Sprintf("%s | %s", series.Title, series.UserName) + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d", + int(config.GlobalConfig.CacheControlMaxAge.Seconds()), + int(config.GlobalConfig.CacheControlStaleWhileRevalidate.Seconds()))) + return RenderHTML(w, r, Data_novelSeries{ NovelSeries: series, NovelSeriesContents: seriesContents, diff --git a/server/routes/pixivision.go b/server/routes/pixivision.go index 8fa16ca..d8582b8 100644 --- a/server/routes/pixivision.go +++ b/server/routes/pixivision.go @@ -1,10 +1,13 @@ package routes import ( + "fmt" "net/http" "strconv" + "codeberg.org/vnpower/pixivfe/v2/config" "codeberg.org/vnpower/pixivfe/v2/core" + "codeberg.org/vnpower/pixivfe/v2/server/session" ) @@ -27,6 +30,10 @@ func PixivisionHomePage(w http.ResponseWriter, r *http.Request) error { return err } + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d", + int(config.GlobalConfig.CacheControlMaxAge.Seconds()), + int(config.GlobalConfig.CacheControlStaleWhileRevalidate.Seconds()))) + return RenderHTML(w, r, Data_pixivisionIndex{ Data: data, Page: pageint, @@ -57,6 +64,10 @@ func PixivisionArticlePage(w http.ResponseWriter, r *http.Request) error { } } + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d", + int(config.GlobalConfig.CacheControlMaxAge.Seconds()), + int(config.GlobalConfig.CacheControlStaleWhileRevalidate.Seconds()))) + return RenderHTML(w, r, Data_pixivisionArticle{ Article: data, }) @@ -82,6 +93,10 @@ func PixivisionCategoryPage(w http.ResponseWriter, r *http.Request) error { return err } + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d", + int(config.GlobalConfig.CacheControlMaxAge.Seconds()), + int(config.GlobalConfig.CacheControlStaleWhileRevalidate.Seconds()))) + return RenderHTML(w, r, Data_pixivisionCategory{ Category: data, Page: pageint, @@ -111,6 +126,10 @@ func PixivisionTagPage(w http.ResponseWriter, r *http.Request) error { return err } + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d", + int(config.GlobalConfig.CacheControlMaxAge.Seconds()), + int(config.GlobalConfig.CacheControlStaleWhileRevalidate.Seconds()))) + return RenderHTML(w, r, Data_pixivisionTag{ Tag: data, Page: pageint, diff --git a/server/routes/ranking.go b/server/routes/ranking.go index abe7554..babcbd9 100644 --- a/server/routes/ranking.go +++ b/server/routes/ranking.go @@ -1,10 +1,12 @@ package routes import ( + "fmt" "net/http" "strconv" "codeberg.org/vnpower/pixivfe/v2/audit" + "codeberg.org/vnpower/pixivfe/v2/config" "codeberg.org/vnpower/pixivfe/v2/core" ) @@ -24,6 +26,10 @@ func RankingPage(auditor *audit.Auditor, w http.ResponseWriter, r *http.Request) return err } + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d", + int(config.GlobalConfig.CacheControlMaxAge.Seconds()), + int(config.GlobalConfig.CacheControlStaleWhileRevalidate.Seconds()))) + return RenderHTML(w, r, Data_rank{ Title: "Ranking", Page: pageInt, diff --git a/server/routes/rankingCalendar.go b/server/routes/rankingCalendar.go index 172a42c..66688aa 100644 --- a/server/routes/rankingCalendar.go +++ b/server/routes/rankingCalendar.go @@ -7,6 +7,7 @@ import ( "time" "codeberg.org/vnpower/pixivfe/v2/audit" + "codeberg.org/vnpower/pixivfe/v2/config" "codeberg.org/vnpower/pixivfe/v2/core" "codeberg.org/vnpower/pixivfe/v2/server/utils" ) @@ -91,6 +92,10 @@ func RankingCalendarPage(auditor *audit.Auditor, w http.ResponseWriter, r *http. return err } + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d", + int(config.GlobalConfig.CacheControlMaxAge.Seconds()), + int(config.GlobalConfig.CacheControlStaleWhileRevalidate.Seconds()))) + // Prepare and render the template with the calendar data return RenderHTML(w, r, Data_rankingCalendar{ Title: "Ranking calendar", diff --git a/server/routes/tag.go b/server/routes/tag.go index 608dab4..55cf39f 100644 --- a/server/routes/tag.go +++ b/server/routes/tag.go @@ -1,6 +1,7 @@ package routes import ( + "fmt" "net/http" "net/url" "strconv" @@ -60,6 +61,10 @@ func TagPage(auditor *audit.Auditor, w http.ResponseWriter, r *http.Request) err PreloadImage(w, tag.Metadata.Image) PreloadImage(w, tag.Metadata.ImageMaster) + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d", + int(config.GlobalConfig.CacheControlMaxAge.Seconds()), + int(config.GlobalConfig.CacheControlStaleWhileRevalidate.Seconds()))) + return RenderHTML(w, r, Data_tag{ Title: "Results for " + name, Tag: tag, diff --git a/server/routes/types.go b/server/routes/types.go index e3a926d..2a98adf 100644 --- a/server/routes/types.go +++ b/server/routes/types.go @@ -6,6 +6,7 @@ import ( "codeberg.org/vnpower/pixivfe/v2/core" "codeberg.org/vnpower/pixivfe/v2/server/request_context" + "codeberg.org/vnpower/pixivfe/v2/server/session" "codeberg.org/vnpower/pixivfe/v2/server/template" ) @@ -26,15 +27,20 @@ func RenderWithContentType[T any](w http.ResponseWriter, r *http.Request, conten // NOTE: the ngx_brotli nginx module appears to weaken strong ETags (even though gzipping is fine) w.Header().Set("ETag", `"`+etag+`"`) - // As a default, set Cache-Control to no-cache, which allows revalidation via ETag - w.Header().Set("Cache-Control", "no-cache") - - // Set Vary: Cookie to prevent personalized content from being shared between users in a shared cache + // Set Cache-Control to private as a safe default while allowing revalidation via ETag // - // TODO: a better approach is to set the "private" Cache-Control directive on personalized content directly, - // but this works as a stopgap solution + // The private directive is always set if the user is logged in to prevent personalized + // content from being shared between users in a shared cache [1] + if w.Header().Get("Cache-Control") == "" || session.GetUserToken(r) != "" { + w.Header().Set("Cache-Control", "private, max-age=5, stale-while-revalidate=10") + } + + // Set Vary: Cookie to prevent user preferences from affecting the shared cache [2] w.Header().Set("Vary", "Cookie") + // [1], [2]: these options negatively affect HTTP cache hit rate, but we don't have the option of + // client-side hydration via JS nor ESI so these will have to do + w.WriteHeader(request_context.Get(r).RenderStatusCode) return nil diff --git a/server/routes/user.go b/server/routes/user.go index 9ded406..a290e03 100644 --- a/server/routes/user.go +++ b/server/routes/user.go @@ -1,11 +1,13 @@ package routes import ( + "fmt" "net/http" "strconv" "time" "codeberg.org/vnpower/pixivfe/v2/audit" + "codeberg.org/vnpower/pixivfe/v2/config" "codeberg.org/vnpower/pixivfe/v2/core" "codeberg.org/vnpower/pixivfe/v2/server/template" "go.uber.org/zap" @@ -81,6 +83,11 @@ func UserPage(auditor *audit.Auditor, w http.ResponseWriter, r *http.Request) er zap.Int("pageLimit", data.category.PageLimit), ) + // TODO: need to handle following status + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d", + int(config.GlobalConfig.CacheControlMaxAge.Seconds()), + int(config.GlobalConfig.CacheControlStaleWhileRevalidate.Seconds()))) + return RenderHTML(w, r, Data_user{ Title: data.user.Name, User: data.user, @@ -96,6 +103,11 @@ func UserAtomFeed(auditor *audit.Auditor, w http.ResponseWriter, r *http.Request return err } + // TODO: need to handle following status + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d", + int(config.GlobalConfig.CacheControlMaxAge.Seconds()), + int(config.GlobalConfig.CacheControlStaleWhileRevalidate.Seconds()))) + return RenderWithContentType(w, r, "application/atom+xml", Data_userAtom{ URL: r.RequestURI, Title: data.user.Name,