feat: cache images

This commit is contained in:
u80864958
2026-01-23 13:50:40 +01:00
parent 2154d6cfa8
commit df9e6e95cb
4 changed files with 140 additions and 104 deletions
+5 -5
View File
@@ -2,11 +2,11 @@
<html lang="%paraglide.lang%" dir="%paraglide.textDirection%"> <html lang="%paraglide.lang%" dir="%paraglide.textDirection%">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<script <!-- <script -->
defer <!-- defer -->
src="https://umami.schreifuchs.ch/script.js" <!-- src="https://umami.schreifuchs.ch/script.js" -->
data-website-id="f09f9cc5-6357-48df-8fb2-5c62089c04a0" <!-- data-website-id="f09f9cc5-6357-48df-8fb2-5c62089c04a0" -->
></script> <!-- ></script> -->
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>schreifuchs.ch</title> <title>schreifuchs.ch</title>
+23 -19
View File
@@ -7,37 +7,41 @@ const DEFAULT_TTL = 1000 * 60 * 15; // 15 minutes default TTL
* @param fetcher Function that returns a promise with the data to cache. * @param fetcher Function that returns a promise with the data to cache.
* @param ttl Time to live in milliseconds. Defaults to 15 minutes. * @param ttl Time to live in milliseconds. Defaults to 15 minutes.
*/ */
export async function withCache<T>(key: string, fetcher: () => Promise<T>, ttl = DEFAULT_TTL): Promise<T> { export async function withCache<T>(
const now = Date.now(); key: string,
const entry = cache.get(key); fetcher: () => Promise<T>,
ttl = DEFAULT_TTL,
): Promise<T> {
const now = Date.now();
const entry = cache.get(key);
if (entry && (now - entry.timestamp < ttl)) { if (entry && now - entry.timestamp < ttl) {
return entry.data as T; return entry.data as T;
} }
try { try {
const data = await fetcher(); const data = await fetcher();
cache.set(key, { timestamp: now, data }); cache.set(key, { timestamp: now, data });
return data; return data;
} catch (error) { } catch (error) {
console.error(`Error fetching data for key ${key}:`, error); console.error(`Error fetching data for key ${key}:`, error);
// If fetch fails but we have stale data, return it? // If fetch fails but we have stale data, return it?
// For now, let's bubble the error up so the page handles it (e.g. 404), // For now, let's bubble the error up so the page handles it (e.g. 404),
// or maybe just re-throw. // or maybe just re-throw.
throw error; throw error;
} }
} }
/** /**
* Manually clears a specific cache key. * Manually clears a specific cache key.
*/ */
export function clearCache(key: string) { export function clearCache(key: string) {
cache.delete(key); cache.delete(key);
} }
/** /**
* Clears the entire cache. * Clears the entire cache.
*/ */
export function clearAllCache() { export function clearAllCache() {
cache.clear(); cache.clear();
} }
+3 -2
View File
@@ -23,7 +23,7 @@
// Helper to resolve image source: Local override -> Nextcloud cover -> Placeholder (handled by ImageTile) // Helper to resolve image source: Local override -> Nextcloud cover -> Placeholder (handled by ImageTile)
const getTileImage = (page: { slug: string; coverImage: string | null }) => { const getTileImage = (page: { slug: string; coverImage: string | null }) => {
return staticImages[page.slug.toLowerCase()] || page.coverImage || ""; return page.coverImage || staticImages[page.slug.toLowerCase()] || "";
}; };
</script> </script>
@@ -46,4 +46,5 @@
{m.pfadi()} {m.pfadi()}
</h2> </h2>
</ImageLinkTile> </ImageLinkTile>
</main> </main>
+109 -78
View File
@@ -1,101 +1,132 @@
// src/routes/assets/[...path]/+server.ts
import { client } from "$lib/server/nextcloud"; import { client } from "$lib/server/nextcloud";
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
import mime from "mime-types"; import mime from "mime-types";
import { env } from "$env/dynamic/private"; import { env } from "$env/dynamic/private";
import { withCache } from "$lib/server/cache";
// Define what we are storing in the cache
type CachedAsset = {
buffer: ArrayBuffer;
contentType: string;
};
export const GET: RequestHandler = async ({ params, url }) => { export const GET: RequestHandler = async ({ params, url }) => {
const { path } = params; const { path } = params;
const width = url.searchParams.get("w"); const width = url.searchParams.get("w");
const height = url.searchParams.get("h"); const height = url.searchParams.get("h");
if (!path) {
throw error(400, "Path is required");
}
try { if (!path) {
let buffer: ArrayBuffer; throw error(400, "Path is required");
let contentType: string; }
// 1. Create a unique cache key for this specific asset variation
const cacheKey = `asset:${path}?w=${width ?? ""}&h=${height ?? ""}`;
try {
// 2. Use withCache to wrap the fetching logic
const cachedData = await withCache<CachedAsset>(
cacheKey,
async () => {
// --- START OF FETCH LOGIC ---
// This function only runs if the data is NOT in the cache
// Try both original path and hidden variant if applicable
const pathsToTry = [path]; const pathsToTry = [path];
if (!path.startsWith('_')) { if (!path.startsWith("_")) {
pathsToTry.push(`_${path}`); pathsToTry.push(`_${path}`);
} }
let response: Response | null = null;
let lastError: any = null; let lastError: any = null;
for (const currentPath of pathsToTry) { for (const currentPath of pathsToTry) {
try { try {
if (width || height) { // A. Try Nextcloud Preview (Resizing)
// Use Nextcloud Preview API if (width || height) {
const baseUrl = env.NEXTCLOUD_URL?.endsWith("/") ? env.NEXTCLOUD_URL : `${env.NEXTCLOUD_URL}/`; const baseUrl = env.NEXTCLOUD_URL?.endsWith("/")
? env.NEXTCLOUD_URL
let fullPath = currentPath; : `${env.NEXTCLOUD_URL}/`;
if (env.NEXTCLOUD_BASE_DIR) {
const baseDir = env.NEXTCLOUD_BASE_DIR.replace(/^\/+|\/+$/g, "");
fullPath = baseDir ? `${baseDir}/${currentPath}` : currentPath;
}
const isJpg = fullPath.match(/\.(jpg|jpeg)$/i); let fullPath = currentPath;
const previewExtension = isJpg ? 'jpg' : 'png'; if (env.NEXTCLOUD_BASE_DIR) {
const previewUrl = new URL(`${baseUrl}index.php/core/preview.${previewExtension}`); const baseDir = env.NEXTCLOUD_BASE_DIR.replace(
// Ensure leading slash for the file parameter /^\/+|\/+$/g,
const fileParam = fullPath.startsWith('/') ? fullPath : `/${fullPath}`; "",
previewUrl.searchParams.set("file", fileParam); );
if (width) previewUrl.searchParams.set("x", width); fullPath = baseDir ? `${baseDir}/${currentPath}` : currentPath;
if (height) previewUrl.searchParams.set("y", height); }
previewUrl.searchParams.set("forceIcon", "0");
const auth = Buffer.from(`${env.NEXTCLOUD_USER}:${env.NEXTCLOUD_PASSWORD}`).toString("base64"); const isJpg = fullPath.match(/\.(jpg|jpeg)$/i);
const previewExtension = isJpg ? "jpg" : "png";
console.log(`Fetching preview: ${previewUrl.toString()} for file: ${fullPath}`); const previewUrl = new URL(
`${baseUrl}index.php/core/preview.${previewExtension}`,
);
const ncResponse = await fetch(previewUrl.toString(), { const fileParam = fullPath.startsWith("/")
headers: { ? fullPath
"Authorization": `Basic ${auth}` : `/${fullPath}`;
} previewUrl.searchParams.set("file", fileParam);
}); if (width) previewUrl.searchParams.set("x", width);
if (height) previewUrl.searchParams.set("y", height);
previewUrl.searchParams.set("forceIcon", "0");
if (ncResponse.ok) { const auth = Buffer.from(
buffer = await ncResponse.arrayBuffer(); `${env.NEXTCLOUD_USER}:${env.NEXTCLOUD_PASSWORD}`,
contentType = ncResponse.headers.get("Content-Type") || "image/png"; ).toString("base64");
return new Response(buffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
"Vary": "Accept-Encoding"
}
});
} else {
console.warn(`Nextcloud preview failed (${ncResponse.status}) for ${fullPath}.`);
}
}
// Fetch original via WebDAV (Fallback or no resize requested) console.log(
console.log(`Fetching original file via WebDAV: /${currentPath}`); `Fetching preview: ${previewUrl.toString()} for file: ${fullPath}`,
const data = await client.getFileContents("/" + currentPath); );
buffer = data as ArrayBuffer;
contentType = mime.lookup(currentPath) || "application/octet-stream"; const ncResponse = await fetch(previewUrl.toString(), {
headers: { Authorization: `Basic ${auth}` },
return new Response(buffer, { });
headers: {
"Content-Type": contentType, if (ncResponse.ok) {
"Cache-Control": "public, max-age=86400", const buffer = await ncResponse.arrayBuffer();
"Vary": "Accept-Encoding" const contentType =
} ncResponse.headers.get("Content-Type") || "image/png";
});
} catch (e) { // Return the raw data to be cached
lastError = e; return { buffer, contentType };
continue; // Try next path variant } else {
console.warn(
`Nextcloud preview failed (${ncResponse.status}) for ${fullPath}.`,
);
}
} }
// B. Try WebDAV (Original)
console.log(`Fetching original file via WebDAV: /${currentPath}`);
const data = await client.getFileContents("/" + currentPath);
const buffer = data as ArrayBuffer;
const contentType =
mime.lookup(currentPath) || "application/octet-stream";
// Return the raw data to be cached
return { buffer, contentType };
} catch (e) {
lastError = e;
continue; // Try next path variant
}
} }
throw lastError || new Error("Asset not found"); throw lastError || new Error("Asset not found");
} catch (e) { // --- END OF FETCH LOGIC ---
console.error(`Error fetching asset from Nextcloud: ${path}`, e); },
throw error(404, "Asset not found"); // Optional: Set a specific TTL for images (e.g., 1 hour instead of default 15m)
} 1000 * 60 * 60,
);
// 3. Construct a fresh Response using the cached data
return new Response(cachedData.buffer, {
headers: {
"Content-Type": cachedData.contentType,
"Cache-Control": "public, max-age=86400", // Browser cache
Vary: "Accept-Encoding",
},
});
} catch (e) {
console.error(`Error fetching asset: ${path}`, e);
throw error(404, "Asset not found");
}
}; };