Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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