]> git.ipfire.org Git - thirdparty/tvheadend.git/commitdiff
webui: per-route cache-control for the Vue UI's hashed assets
authorPim Zandbergen <pim@zandbergen.org>
Thu, 9 Jul 2026 15:44:58 +0000 (17:44 +0200)
committerFlole <Flole998@users.noreply.github.com>
Wed, 15 Jul 2026 12:06:10 +0000 (14:06 +0200)
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.

src/webui/vue.c
src/webui/webui.c
src/webui/webui.h

index e86bfa2225bf04bc87b97fbce50e4b9bb246ed54..e273ff6303f2abc5d17a8c450a659f0be05957ce 100644 (file)
@@ -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
+ * <name>-<hash>.<ext>, where <hash> 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
 }
index 7386ad6e003aabaaa1ef9d031d6e2baa0bccbbb2..91383a23f72b1a7b67eb9049aa3356259e1a169b 100644 (file)
@@ -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
  */
index 436f75010a00697fe96a9bfbf431423d55654334..9808e7784f887cfac3fdf929aae55621d32278fa 100644 (file)
@@ -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);