From: Pim Zandbergen Date: Thu, 9 Jul 2026 15:44:58 +0000 (+0200) Subject: webui: per-route cache-control for the Vue UI's hashed assets X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=70d588f7d9a69f3866e4bcdaa03cb95536bc188d;p=thirdparty%2Ftvheadend.git webui: per-route cache-control for the Vue UI's hashed assets The /gui/static route was registered on page_static_file, whose 10-second default Cache-Control max-age exists for the unhashed ExtJS assets that handler also serves. The Vue dist is different: Vite fingerprints everything under dist/assets/ with a content hash (index-B3kR9xQz.js), so an asset's URL changes whenever its content does and the response is safe to cache long — the same reasoning that already grants jpg/png an hour inside page_static_file. Split the body of page_static_file into page_static_file_maxage(), which takes the default max-age as an argument; page_static_file remains a thin wrapper passing the old default of 10, so no existing caller or http_path_add registration changes behavior. The jpg/png rule now raises the max-age instead of overwriting it, so it can no longer shorten a lifetime the caller asked for (a content-hashed .png keeps its year). /gui/static now registers a Vue-specific callback, page_vue_asset, which serves files whose basename matches the Vite fingerprint pattern (a dash followed by 8+ hash characters before the final extension) with max-age=31536000, and everything unhashed — notably dist/index.html, reachable at /gui/static/index.html — with the short default. The /gui route itself (the SPA shell) stays on the short-cache path so deployments propagate on reload. The pattern check lives in vue.c rather than in page_static_file_maxage because the fingerprint convention belongs to the Vue build pipeline, not to static file serving in general. --- diff --git a/src/webui/vue.c b/src/webui/vue.c index e86bfa222..e273ff630 100644 --- a/src/webui/vue.c +++ b/src/webui/vue.c @@ -93,6 +93,52 @@ page_vue_index(http_connection_t *hc, const char *remain, void *opaque) return page_vue_index_webroot(hc); } +/* Vite fingerprints everything it emits under dist/assets/ as + * -., where is 8+ base64url characters + * ([A-Za-z0-9_-]). Match against the segment after the LAST dash in + * the basename: a hash that itself contains a dash then fails the + * length check and merely falls back to the short cache (harmless), + * whereas a laxer first-dash match could hand a year-long lifetime to + * an unhashed file that happens to contain a dash (not harmless). */ +static int +vue_asset_is_hashed(const char *remain) +{ + const char *base, *dot, *dash, *p; + + if(remain == NULL) + return 0; + base = strrchr(remain, '/'); + base = base ? base + 1 : remain; + dot = strrchr(base, '.'); + if(dot == NULL) + return 0; + dash = NULL; + for (p = base; p < dot; p++) + if(*p == '-') + dash = p; + if(dash == NULL || dot - (dash + 1) < 8) + return 0; + for (p = dash + 1; p < dot; p++) + if(!((*p >= 'A' && *p <= 'Z') || (*p >= 'a' && *p <= 'z') || + (*p >= '0' && *p <= '9') || *p == '_')) + return 0; + return 1; +} + +static int +page_vue_asset(http_connection_t *hc, const char *remain, void *opaque) +{ + /* Content-hashed assets are immutable — their URL changes whenever + * their content does — so let clients cache them for a year. + * Anything unhashed reachable through this route (dist/index.html, + * favicons, ...) keeps page_static_file's short default so it stays + * revalidatable. The hash check lives here rather than in + * page_static_file_maxage because the fingerprint convention belongs + * to the Vue build pipeline, not to static file serving in general. */ + int maxage = vue_asset_is_hashed(remain) ? 365 * 24 * 3600 : 10; + return page_static_file_maxage(hc, remain, opaque, maxage); +} + #else /* !ENABLE_VUE_UI */ static int @@ -139,6 +185,6 @@ vue_init(void) page_vue_index, ACCESS_WEB_INTERFACE); #if ENABLE_VUE_UI http_path_add("/gui/static", (void *)VUE_DIST, - page_static_file, ACCESS_WEB_INTERFACE); + page_vue_asset, ACCESS_WEB_INTERFACE); #endif } diff --git a/src/webui/webui.c b/src/webui/webui.c index 7386ad6e0..91383a23f 100644 --- a/src/webui/webui.c +++ b/src/webui/webui.c @@ -244,12 +244,17 @@ to reauthenticate.")); /** * Static download of a file from the filesystem + * + * maxage is the default Cache-Control max-age; the per-extension rules + * below may raise it but never lower it, so a caller that knows its + * content is immutable (see page_vue_asset in vue.c) keeps the longer + * lifetime it asked for. */ int -page_static_file(http_connection_t *hc, const char *_remain, void *opaque) +page_static_file_maxage(http_connection_t *hc, const char *_remain, + const char *base, int maxage) { int ret = 0; - const char *base = opaque; char *remain, *postfix; char path[500]; ssize_t size; @@ -257,7 +262,6 @@ page_static_file(http_connection_t *hc, const char *_remain, void *opaque) char buf[4096]; const char *gzip = NULL; int nogzip = 0; - int maxage = 10; /* Default age */ if(_remain == NULL) return HTTP_STATUS_NOT_FOUND; @@ -290,7 +294,8 @@ page_static_file(http_connection_t *hc, const char *_remain, void *opaque) * images since they rarely change. This avoids clients * requesting category icons frequently. */ - maxage = 60 * 60; + if(maxage < 60 * 60) + maxage = 60 * 60; } } @@ -322,6 +327,12 @@ page_static_file(http_connection_t *hc, const char *_remain, void *opaque) return ret; } +int +page_static_file(http_connection_t *hc, const char *_remain, void *opaque) +{ + return page_static_file_maxage(hc, _remain, opaque, 10 /* Default age */); +} + /** * HTTP subscription handling */ diff --git a/src/webui/webui.h b/src/webui/webui.h index 436f75010..9808e7784 100644 --- a/src/webui/webui.h +++ b/src/webui/webui.h @@ -57,6 +57,8 @@ http_serve_file(http_connection_t *hc, const char *fname, void *opaque); int page_static_file(http_connection_t *hc, const char *remain, void *opaque); +int page_static_file_maxage(http_connection_t *hc, const char *remain, + const char *base, int maxage); int page_xmltv(http_connection_t *hc, const char *remain, void *opaque); int page_markdown(http_connection_t *hc, const char *remain, void *opaque);