From 5e7655d9249b8acf7acf42fbcd6e982657f58a16 Mon Sep 17 00:00:00 2001 From: ildyria Date: Sun, 1 Nov 2026 22:06:49 +0100 Subject: [PATCH 1/3] Make caching on client side optional --- .env.example | 4 + app/View/Components/Meta.php | 3 + config/features.php | 13 +++ resources/js/config/axios-config.ts | 5 +- resources/js/config/client-cache.test.ts | 87 +++++++++++++++++++ resources/js/config/client-cache.ts | 46 ++++++++++ .../js/services/thumb-asset-service.test.ts | 7 ++ resources/js/services/thumb-asset-service.ts | 6 +- resources/views/components/meta.blade.php | 1 + .../ClientCache/ClientCacheMetaTest.php | 53 +++++++++++ 10 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 resources/js/config/client-cache.test.ts create mode 100644 resources/js/config/client-cache.ts create mode 100644 tests/Feature_v3/ClientCache/ClientCacheMetaTest.php diff --git a/.env.example b/.env.example index c6379f793aa..c83a09bea8c 100644 --- a/.env.example +++ b/.env.example @@ -373,6 +373,10 @@ VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" # cache_event_logging) become visible in the admin settings panel. # ENABLE_CACHING=false +# When disabled, the front end reuses no stored API response (axios cache +# and browser HTTP cache). Thumbnails stay cached. +# CLIENT_CACHE_ENABLED=true + ################################################################### # Payment integration (requires SE) # ################################################################### diff --git a/app/View/Components/Meta.php b/app/View/Components/Meta.php index e76672c4e87..a81a8e2e6cb 100644 --- a/app/View/Components/Meta.php +++ b/app/View/Components/Meta.php @@ -45,6 +45,7 @@ class Meta extends Component public string $user_js_url; public string $loading_indicator_mode; public string $loading_indicator_url; + public bool $client_cache_enabled; public int $width = 0; public int $height = 0; @@ -66,6 +67,8 @@ public function __construct() $this->user_css_url = self::getUserCustomFiles('user.css'); $this->user_js_url = self::getUserCustomFiles('custom.js'); $this->setLoadingIndicator(); + // Rendered into the page so the front end knows it before its first request. + $this->client_cache_enabled = config('features.client-cache') === true; $base_url = url('/'); // Work around to try to satisfy everyone... diff --git a/config/features.php b/config/features.php index 578d45dafcf..26df83e2344 100644 --- a/config/features.php +++ b/config/features.php @@ -289,6 +289,19 @@ */ 'enable-caching' => (bool) env('ENABLE_CACHING', false), + /* + |-------------------------------------------------------------------------- + | Client-side cache + |-------------------------------------------------------------------------- + | + | When disabled, the front end (v7 and v8) reuses no stored API + | response: the axios response cache is skipped and requests keep asking + | the browser not to answer from its HTTP cache. Thumbnails stay cached. + | Enabled by default — set CLIENT_CACHE_ENABLED=false to always fetch + | from the server. + */ + 'client-cache' => (bool) env('CLIENT_CACHE_ENABLED', true), + /* |-------------------------------------------------------------------------- | Enable Nuxt UI front-end (v8) diff --git a/resources/js/config/axios-config.ts b/resources/js/config/axios-config.ts index 8b7b1462a13..555b6731661 100644 --- a/resources/js/config/axios-config.ts +++ b/resources/js/config/axios-config.ts @@ -1,8 +1,7 @@ import axios, { AxiosError, InternalAxiosRequestConfig, type AxiosResponse } from "axios"; import CSRF from "./csrf-getter"; -// import { setupCache } from "axios-cache-interceptor/dev"; -import { setupCache } from "axios-cache-interceptor"; import InitService from "../services/init-service"; +import { isClientCacheEnabled, setUpClientCache } from "./client-cache"; // Cached for the life of the session (unlike InitService.fetchMac()'s in-flight dedup, which only // coalesces concurrent callers of one fetch): once resolved, later Asset requests reuse this value @@ -67,7 +66,7 @@ function needsMac(url: string | undefined): boolean { const AxiosConfig = { axiosSetUp() { - setupCache(axios); + setUpClientCache(axios, isClientCacheEnabled()); axios.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest"; axios.interceptors.request.use( async function (config: InternalAxiosRequestConfig) { diff --git a/resources/js/config/client-cache.test.ts b/resources/js/config/client-cache.test.ts new file mode 100644 index 00000000000..45fd92b8f3e --- /dev/null +++ b/resources/js/config/client-cache.test.ts @@ -0,0 +1,87 @@ +/** + * SPDX-License-Identifier: MIT + * Copyright (c) 2017-2018 Tobias Reich + * Copyright (c) 2018-2026 LycheeOrg. + */ + +// @vitest-environment jsdom +import { describe, expect, it } from "vite-plus/test"; +import axios, { type AxiosAdapter, type InternalAxiosRequestConfig } from "axios"; +import type { AxiosCacheInstance } from "axios-cache-interceptor"; +import { parseClientCacheEnabled, setUpClientCache } from "@/config/client-cache"; + +function metaTag(content: string): Element { + const meta = document.createElement("meta"); + meta.setAttribute("name", "lychee-client-cache"); + meta.setAttribute("content", content); + return meta; +} + +// An instance whose requests never leave the test: every one is recorded and answered with a +// response the cache would keep for an hour. +function stubbedInstance(enabled: boolean): { instance: AxiosCacheInstance; sent: InternalAxiosRequestConfig[] } { + const sent: InternalAxiosRequestConfig[] = []; + const instance = axios.create(); + instance.defaults.adapter = ((config: InternalAxiosRequestConfig) => { + sent.push(config); + return Promise.resolve({ data: "asset", status: 200, statusText: "OK", headers: { "cache-control": "private, max-age=3600" }, config }); + }) as AxiosAdapter; + setUpClientCache(instance, enabled); + return { instance: instance as unknown as AxiosCacheInstance, sent }; +} + +describe("parseClientCacheEnabled()", () => { + it("is off when the tag says 0", () => { + expect(parseClientCacheEnabled(metaTag("0"))).toBe(false); + }); + + it("is on when the tag says 1", () => { + expect(parseClientCacheEnabled(metaTag("1"))).toBe(true); + }); + + it("is on without the tag", () => { + expect(parseClientCacheEnabled(null)).toBe(true); + }); +}); + +describe("setUpClientCache()", () => { + it("answers a repeated request from its storage when on", async () => { + const { instance, sent } = stubbedInstance(true); + + await instance.get("Asset/album/photo/small", { id: "asset" }); + const second = await instance.get("Asset/album/photo/small", { id: "asset" }); + + expect(sent).toHaveLength(1); + expect(second.cached).toBe(true); + }); + + it("sends every request to the network with the no-cache headers when off", async () => { + const { instance, sent } = stubbedInstance(false); + + await instance.get("Asset/album/photo/small", { id: "asset" }); + await instance.get("Asset/album/photo/small", { id: "asset" }); + + expect(sent).toHaveLength(2); + for (const config of sent) { + expect(config.headers.get("Cache-Control")).toBe("no-cache, no-store, must-revalidate, max-age=0"); + expect(config.headers.get("Pragma")).toBe("no-cache"); + expect(config.headers.get("Expires")).toBe("0"); + } + }); + + it("still answers a request that opts in from its storage when off", async () => { + const { instance, sent } = stubbedInstance(false); + + await instance.get("Asset/album/photo/small", { id: "asset", cache: { enabled: true } }); + const second = await instance.get("Asset/album/photo/small", { id: "asset", cache: { enabled: true } }); + + expect(sent).toHaveLength(1); + expect(second.cached).toBe(true); + }); + + it("keeps its storage when off, so cache clearing still works", () => { + const { instance } = stubbedInstance(false); + + expect(() => instance.storage.remove("asset")).not.toThrow(); + }); +}); diff --git a/resources/js/config/client-cache.ts b/resources/js/config/client-cache.ts new file mode 100644 index 00000000000..e228689665b --- /dev/null +++ b/resources/js/config/client-cache.ts @@ -0,0 +1,46 @@ +/** + * SPDX-License-Identifier: MIT + * Copyright (c) 2017-2018 Tobias Reich + * Copyright (c) 2018-2026 LycheeOrg. + */ + +import type { AxiosInstance, InternalAxiosRequestConfig } from "axios"; +import { setupCache } from "axios-cache-interceptor"; + +/** + * Client-side cache switch chosen by the server. + * + * `Meta` renders `` from `CLIENT_CACHE_ENABLED`, + * so the value is known before the first request. The tag is read once and cached; only + * `content="0"` turns the cache off, a missing tag means on (the flag's default). + */ +let cached: boolean | undefined = undefined; + +export function parseClientCacheEnabled(meta: Element | null): boolean { + return meta?.getAttribute("content") !== "0"; +} + +export function isClientCacheEnabled(): boolean { + cached ??= parseClientCacheEnabled(document.querySelector('meta[name="lychee-client-cache"]')); + return cached; +} + +// The headers axios-cache-interceptor's `cacheTakeover` adds to every request when enabled: they +// keep the browser HTTP cache from answering, which a disabled interceptor no longer does. +function addNoCacheHeaders(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig { + config.headers.set("Cache-Control", "no-cache, no-store, must-revalidate, max-age=0", false); + config.headers.set("Pragma", "no-cache", false); + config.headers.set("Expires", "0", false); + return config; +} + +/** + * Installs the response cache on `instance`. Disabled, it stores and serves nothing but keeps its + * storage, so `clearCache()` callers still work (they just remove nothing). + */ +export function setUpClientCache(instance: AxiosInstance, enabled: boolean): void { + setupCache(instance, { enabled: enabled }); + if (!enabled) { + instance.interceptors.request.use(addNoCacheHeaders); + } +} diff --git a/resources/js/services/thumb-asset-service.test.ts b/resources/js/services/thumb-asset-service.test.ts index 6422ab6beb6..95d648cfa61 100644 --- a/resources/js/services/thumb-asset-service.test.ts +++ b/resources/js/services/thumb-asset-service.test.ts @@ -78,3 +78,10 @@ describe("ThumbAssetService.clear() (S-102-15)", () => { expect(signal.aborted).toBe(true); }); }); + +describe("ThumbAssetService.acquire()", () => { + it("keeps the axios response cache whatever CLIENT_CACHE_ENABLED says", () => { + ThumbAssetService.acquire("album", "p5", "small").release(); + expect(get.mock.calls[0][1]).toMatchObject({ cache: { enabled: true } }); + }); +}); diff --git a/resources/js/services/thumb-asset-service.ts b/resources/js/services/thumb-asset-service.ts index 2a8889c8561..019e1346f60 100644 --- a/resources/js/services/thumb-asset-service.ts +++ b/resources/js/services/thumb-asset-service.ts @@ -1,4 +1,4 @@ -import axios from "axios"; +import axios, { type AxiosRequestConfig } from "axios"; import Constants from "./constants"; type CacheEntry = { @@ -95,7 +95,9 @@ const ThumbAssetService = { responseType: "blob", signal: controller.signal, data: {}, - }) + // Thumbnails stay cached even with CLIENT_CACHE_ENABLED=false. + cache: { enabled: true }, + } as AxiosRequestConfig) .then((response) => { if (entry !== undefined) { entry.settled = true; diff --git a/resources/views/components/meta.blade.php b/resources/views/components/meta.blade.php index b73d688ac41..d1c59a04c75 100644 --- a/resources/views/components/meta.blade.php +++ b/resources/views/components/meta.blade.php @@ -5,6 +5,7 @@ + @if(Features::inactive('white_label_enabled')) @endif diff --git a/tests/Feature_v3/ClientCache/ClientCacheMetaTest.php b/tests/Feature_v3/ClientCache/ClientCacheMetaTest.php new file mode 100644 index 00000000000..f79cf112914 --- /dev/null +++ b/tests/Feature_v3/ClientCache/ClientCacheMetaTest.php @@ -0,0 +1,53 @@ + true]); + parent::tearDown(); + } + + public function testEnabledByDefault(): void + { + $this->assertMetaTag('1'); + } + + public function testDisabled(): void + { + config(['features.client-cache' => false]); + + $this->assertMetaTag('0'); + } + + private function assertMetaTag(string $content): void + { + $response = $this->get('/gallery'); + + $this->assertOk($response); + $response->assertSee('', false); + } +} From 51e2f4a5ace00fe20e5cb53d895d240d9a87ce56 Mon Sep 17 00:00:00 2001 From: ildyria Date: Sun, 1 Nov 2026 22:30:52 +0100 Subject: [PATCH 2/3] more fixes --- resources/js/app-v8.ts | 4 ++-- resources/js/app.ts | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/resources/js/app-v8.ts b/resources/js/app-v8.ts index d2221bcf6a0..eeaef2f576e 100644 --- a/resources/js/app-v8.ts +++ b/resources/js/app-v8.ts @@ -39,6 +39,6 @@ app.use(i18nVue, { resolve: resolveLocale }); registerIconCollections(); applyStoredDarkModePreference(); -app.mount("#app"); - AxiosConfig.axiosSetUp(); + +app.mount("#app"); diff --git a/resources/js/app.ts b/resources/js/app.ts index 6c1d6bf6e7e..70c95fc15dc 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -77,10 +77,11 @@ app.use(i18nVue, { }, }); + +AxiosConfig.axiosSetUp(); + /** * Finally, we will attach the application instance to a HTML element with * an "id" attribute of "app". */ -app.mount("#app"); - -AxiosConfig.axiosSetUp(); +app.mount("#app"); \ No newline at end of file From 59ead108def624b0a3797cc3a8cf1b49730dc81b Mon Sep 17 00:00:00 2001 From: ildyria Date: Sun, 1 Nov 2026 22:36:46 +0100 Subject: [PATCH 3/3] fix --- resources/js/app.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/resources/js/app.ts b/resources/js/app.ts index 70c95fc15dc..cd3c34e6a2a 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -77,11 +77,10 @@ app.use(i18nVue, { }, }); - AxiosConfig.axiosSetUp(); /** * Finally, we will attach the application instance to a HTML element with * an "id" attribute of "app". */ -app.mount("#app"); \ No newline at end of file +app.mount("#app");