Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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) #
###################################################################
Expand Down
3 changes: 3 additions & 0 deletions app/View/Components/Meta.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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...
Expand Down
13 changes: 13 additions & 0 deletions config/features.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions resources/js/app-v8.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,6 @@ app.use(i18nVue, { resolve: resolveLocale });
registerIconCollections();
applyStoredDarkModePreference();

app.mount("#app");

AxiosConfig.axiosSetUp();

app.mount("#app");
4 changes: 2 additions & 2 deletions resources/js/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +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");

AxiosConfig.axiosSetUp();
5 changes: 2 additions & 3 deletions resources/js/config/axios-config.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Expand Down
87 changes: 87 additions & 0 deletions resources/js/config/client-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
46 changes: 46 additions & 0 deletions resources/js/config/client-cache.ts
Original file line number Diff line number Diff line change
@@ -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 `<meta name="lychee-client-cache" content="0|1">` 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);
}
}
7 changes: 7 additions & 0 deletions resources/js/services/thumb-asset-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } });
});
});
6 changes: 4 additions & 2 deletions resources/js/services/thumb-asset-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import axios from "axios";
import axios, { type AxiosRequestConfig } from "axios";
import Constants from "./constants";

type CacheEntry = {
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions resources/views/components/meta.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<meta name="mobile-web-app-status-bar-style" content="black">
<meta name="mobile-web-app-capable" content="yes">
<meta name="lychee-loading-indicator" content="{{ $loading_indicator_mode }}" data-url="{{ $loading_indicator_url }}">
<meta name="lychee-client-cache" content="{{ $client_cache_enabled ? '1' : '0' }}">
@if(Features::inactive('white_label_enabled'))
<meta name="generator" content="Lychee v7">
@endif
Expand Down
53 changes: 53 additions & 0 deletions tests/Feature_v3/ClientCache/ClientCacheMetaTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

/**
* SPDX-License-Identifier: MIT
* Copyright (c) 2017-2018 Tobias Reich
* Copyright (c) 2018-2026 LycheeOrg.
*/

/**
* We don't care for unhandled exceptions in tests.
* It is the nature of a test to throw an exception.
* Without this suppression we had 100+ Linter warning in this file which
* don't help anything.
*
* @noinspection PhpDocMissingThrowsInspection
* @noinspection PhpUnhandledExceptionInspection
*/

namespace Tests\Feature_v3\ClientCache;

use Tests\Feature_v3\Base\BaseApiWithDataTest;

/**
* The client cache flag is rendered into the HTML page.
*/
class ClientCacheMetaTest extends BaseApiWithDataTest
{
public function tearDown(): void
{
config(['features.client-cache' => true]);
parent::tearDown();
}

public function testEnabledByDefault(): void
{
$this->assertMetaTag('1');
Comment thread
ildyria marked this conversation as resolved.
Resolved
}

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('<meta name="lychee-client-cache" content="' . $content . '">', false);
}
}
Loading