implement content-hash based image cache invalidation

Add content-based hashing to recipe images for proper cache invalidation
while maintaining graceful degradation through dual file storage.

Changes:
- Add imageHash utility with SHA-256 content hashing (8-char)
- Update Recipe model to store hashed filenames in images[0].mediapath
- Modify image upload endpoint to save both hashed and unhashed versions
- Update frontend components to use images[0].mediapath with fallback
- Add migration endpoint to hash existing images (production-only)
- Update image delete/rename endpoints to handle both file versions

Images are now stored as:
  - recipe.a1b2c3d4.webp (hashed, cached forever)
  - recipe.webp (unhashed, graceful degradation fallback)

Database stores hashed filename for cache busting, while unhashed
version remains on disk for backward compatibility and manual uploads.
This commit is contained in:
2026-01-02 12:06:53 +01:00
parent 6bf3518db7
commit ccf3fd7ea2
12 changed files with 603 additions and 38 deletions

View File

@@ -25,9 +25,12 @@ onMount(() => {
isloaded = document.querySelector("img")?.complete ? true : false
});
// Use germanShortName for images if available (English recipes), otherwise use short_name (German recipes)
const imageShortName = $derived(recipe.germanShortName || recipe.short_name);
const img_name = $derived(imageShortName + ".webp?v=" + recipe.dateModified);
// Use mediapath from images array (includes hash for cache busting)
// Fallback to short_name.webp for backward compatibility
const img_name = $derived(
recipe.images?.[0]?.mediapath ||
`${recipe.germanShortName || recipe.short_name}.webp`
);
</script>
<style>
.card_anchor{
@@ -261,7 +264,7 @@ const img_name = $derived(imageShortName + ".webp?v=" + recipe.dateModified);
<noscript>
<img class="image backdrop_blur" src="https://bocken.org/static/rezepte/thumb/{img_name}" loading={loading_strat} alt="{recipe.alt}"/>
</noscript>
<img class="image backdrop_blur" class:blur={!isloaded} src={'https://bocken.org/static/rezepte/thumb/' + imageShortName + '.webp'} loading={loading_strat} alt="{recipe.alt}" on:load={() => isloaded=true}/>
<img class="image backdrop_blur" class:blur={!isloaded} src={'https://bocken.org/static/rezepte/thumb/' + img_name} loading={loading_strat} alt="{recipe.alt}" on:load={() => isloaded=true}/>
</div>
</div>
{#if showFavoriteIndicator && isFavorite}

View File

@@ -42,7 +42,7 @@ export function generateRecipeJsonLd(data: any) {
"keywords": data.tags?.join(', '),
"image": {
"@type": "ImageObject",
"url": `https://bocken.org/static/rezepte/full/${data.short_name}.webp`,
"url": `https://bocken.org/static/rezepte/full/${data.images?.[0]?.mediapath || `${data.short_name}.webp`}`,
"width": 1200,
"height": 800
},

View File

@@ -9,7 +9,7 @@ const RecipeSchema = new mongoose.Schema(
dateCreated: {type: Date, default: Date.now},
dateModified: {type: Date, default: Date.now},
images: [ {
mediapath: {type: String, required: true},
mediapath: {type: String, required: true}, // filename with hash for cache busting: e.g., "maccaroni.a1b2c3d4.webp"
alt: String,
caption: String,
}],

View File

@@ -35,10 +35,14 @@
const isEnglish = $derived(data.lang === 'en');
// Use German short_name for images (they're the same for both languages)
const imageShortName = $derived(data.germanShortName || data.short_name);
const hero_img_src = $derived("https://bocken.org/static/rezepte/full/" + imageShortName + ".webp?v=" + data.dateModified);
const placeholder_src = $derived("https://bocken.org/static/rezepte/placeholder/" + imageShortName + ".webp?v=" + data.dateModified);
// Use mediapath from images array (includes hash for cache busting)
// Fallback to short_name.webp for backward compatibility
const img_filename = $derived(
data.images?.[0]?.mediapath ||
`${data.germanShortName || data.short_name}.webp`
);
const hero_img_src = $derived("https://bocken.org/static/rezepte/full/" + img_filename);
const placeholder_src = $derived("https://bocken.org/static/rezepte/placeholder/" + img_filename);
const months = $derived(isEnglish
? ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
@@ -296,8 +300,8 @@ h4{
<svelte:head>
<title>{stripHtmlTags(data.name)} - {labels.title}</title>
<meta name="description" content="{stripHtmlTags(data.description)}" />
<meta property="og:image" content="https://bocken.org/static/rezepte/thumb/{imageShortName}.webp" />
<meta property="og:image:secure_url" content="https://bocken.org/static/rezepte/thumb/{imageShortName}.webp" />
<meta property="og:image" content="https://bocken.org/static/rezepte/thumb/{img_filename}" />
<meta property="og:image:secure_url" content="https://bocken.org/static/rezepte/thumb/{img_filename}" />
<meta property="og:image:type" content="image/webp" />
<meta property="og:image:alt" content="{stripHtmlTags(data.name)}" />
{@html `<script type="application/ld+json">${JSON.stringify(data.recipeJsonLd)}</script>`}

View File

@@ -11,7 +11,7 @@
export let data: PageData;
let preamble = data.recipe.preamble
let addendum = data.recipe.addendum
let image_preview_url="https://bocken.org/static/rezepte/thumb/" + data.recipe.short_name + ".webp?v=" + data.recipe.dateModified;
let image_preview_url="https://bocken.org/static/rezepte/thumb/" + (data.recipe.images?.[0]?.mediapath || `${data.recipe.short_name}.webp`);
let note = data.recipe.note
// Translation workflow state

View File

@@ -0,0 +1,148 @@
import type { RequestHandler } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import { IMAGE_DIR } from '$env/static/private';
import { Recipe } from '$models/Recipe';
import { connectDB } from '$utils/db';
import { generateImageHash, getHashedFilename } from '$utils/imageHash';
import path from 'path';
import fs from 'fs';
import { rename } from 'node:fs/promises';
export const POST = (async ({ locals, request }) => {
// Only allow in production (check if IMAGE_DIR contains production path)
const isProd = IMAGE_DIR.includes('/var/lib/www');
// Require confirmation token to prevent accidental runs
const data = await request.json();
const confirmToken = data?.confirm;
if (!isProd) {
throw error(403, 'This endpoint only runs in production (IMAGE_DIR must be /var/lib/www)');
}
if (confirmToken !== 'MIGRATE_IMAGES') {
throw error(400, 'Missing or invalid confirmation token. Send {"confirm": "MIGRATE_IMAGES"}');
}
const auth = await locals.auth();
if (!auth) throw error(401, 'Need to be logged in');
await connectDB();
const results = {
total: 0,
migrated: 0,
skipped: 0,
errors: [] as string[],
details: [] as any[]
};
try {
// Get all recipes
const recipes = await Recipe.find({});
results.total = recipes.length;
for (const recipe of recipes) {
const shortName = recipe.short_name;
try {
// Check if already has hashed filename
const currentMediaPath = recipe.images?.[0]?.mediapath;
// If mediapath exists and has hash pattern, skip
if (currentMediaPath && /\.[a-f0-9]{8}\.webp$/.test(currentMediaPath)) {
results.skipped++;
results.details.push({
shortName,
status: 'skipped',
reason: 'already hashed',
filename: currentMediaPath
});
continue;
}
// Check if image file exists on disk (try full size first)
const unhashed_filename = `${shortName}.webp`;
const fullPath = path.join(IMAGE_DIR, 'rezepte', 'full', unhashed_filename);
if (!fs.existsSync(fullPath)) {
results.skipped++;
results.details.push({
shortName,
status: 'skipped',
reason: 'file not found',
path: fullPath
});
continue;
}
// Generate hash from the full-size image
const imageHash = generateImageHash(fullPath);
const hashedFilename = getHashedFilename(shortName, imageHash);
// Create hashed versions and keep unhashed copies (for graceful degradation)
const folders = ['full', 'thumb', 'placeholder'];
let copiedCount = 0;
for (const folder of folders) {
const unhashedPath = path.join(IMAGE_DIR, 'rezepte', folder, unhashed_filename);
const hashedPath = path.join(IMAGE_DIR, 'rezepte', folder, hashedFilename);
if (fs.existsSync(unhashedPath)) {
// Copy to hashed filename (keep original unhashed file)
fs.copyFileSync(unhashedPath, hashedPath);
copiedCount++;
}
}
// Update database with hashed filename
if (!recipe.images || recipe.images.length === 0) {
// Create images array if it doesn't exist
recipe.images = [{
mediapath: hashedFilename,
alt: recipe.name || '',
caption: ''
}];
} else {
// Update existing mediapath
recipe.images[0].mediapath = hashedFilename;
}
await recipe.save();
results.migrated++;
results.details.push({
shortName,
status: 'migrated',
unhashedFilename: unhashed_filename,
hashedFilename: hashedFilename,
hash: imageHash,
filesCopied: copiedCount,
note: 'Both hashed and unhashed versions saved for graceful degradation'
});
} catch (err) {
results.errors.push(`${shortName}: ${err instanceof Error ? err.message : String(err)}`);
results.details.push({
shortName,
status: 'error',
error: err instanceof Error ? err.message : String(err)
});
}
}
return new Response(JSON.stringify({
success: true,
message: `Migration complete. Migrated ${results.migrated} of ${results.total} recipes.`,
...results
}), {
status: 200,
headers: {
'Content-Type': 'application/json'
}
});
} catch (err) {
throw error(500, `Migration failed: ${err instanceof Error ? err.message : String(err)}`);
}
}) satisfies RequestHandler;

View File

@@ -3,12 +3,19 @@ import type { RequestHandler } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import { IMAGE_DIR } from '$env/static/private'
import sharp from 'sharp';
import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash';
export const POST = (async ({ request, locals}) => {
const data = await request.json();
const auth = await locals.auth();
if (!auth) throw error(401, "Need to be logged in")
let full_res = new Buffer.from(data.image, 'base64')
// Generate content hash for cache busting
const imageHash = generateImageHashFromBuffer(full_res);
const hashedFilename = getHashedFilename(data.name, imageHash);
const unhashedFilename = data.name + '.webp';
// reduce image size if over 500KB
const MAX_SIZE_KB = 500
//const metadata = await sharp(full_res).metadata()
@@ -18,27 +25,42 @@ export const POST = (async ({ request, locals}) => {
// webp( { quality: 70})
// .toBuffer()
//}
await sharp(full_res)
// Save full size - both hashed and unhashed versions
const fullBuffer = await sharp(full_res)
.toFormat('webp')
.toFile(path.join(IMAGE_DIR,
"rezepte",
"full",
data.name + ".webp"))
await sharp(full_res)
.toBuffer();
await sharp(fullBuffer)
.toFile(path.join(IMAGE_DIR, "rezepte", "full", hashedFilename));
await sharp(fullBuffer)
.toFile(path.join(IMAGE_DIR, "rezepte", "full", unhashedFilename));
// Save thumbnail - both hashed and unhashed versions
const thumbBuffer = await sharp(full_res)
.resize({ width: 800})
.toFormat('webp')
.toFile(path.join(IMAGE_DIR,
"rezepte",
"thumb",
data.name + ".webp"))
await sharp(full_res)
.toBuffer();
await sharp(thumbBuffer)
.toFile(path.join(IMAGE_DIR, "rezepte", "thumb", hashedFilename));
await sharp(thumbBuffer)
.toFile(path.join(IMAGE_DIR, "rezepte", "thumb", unhashedFilename));
// Save placeholder - both hashed and unhashed versions
const placeholderBuffer = await sharp(full_res)
.resize({ width: 20})
.toFormat('webp')
.toFile(path.join(IMAGE_DIR,
"rezepte",
"placeholder",
data.name + ".webp"))
return new Response(JSON.stringify({msg: "Added image successfully"}),{
status: 200,
.toBuffer();
await sharp(placeholderBuffer)
.toFile(path.join(IMAGE_DIR, "rezepte", "placeholder", hashedFilename));
await sharp(placeholderBuffer)
.toFile(path.join(IMAGE_DIR, "rezepte", "placeholder", unhashedFilename))
return new Response(JSON.stringify({
msg: "Added image successfully",
filename: hashedFilename
}),{
status: 200,
});
}) satisfies RequestHandler;

View File

@@ -9,10 +9,24 @@ export const POST = (async ({ request, locals}) => {
const auth = await locals.auth()
if(!auth) throw error(401, "You need to be logged in")
// data.filename should be the full filename with hash (e.g., "maccaroni.a1b2c3d4.webp")
// For backward compatibility, also support data.name (will construct filename)
const hashedFilename = data.filename || (data.name + ".webp");
// Also extract basename to delete unhashed version
const basename = data.name || hashedFilename.replace(/\.[a-f0-9]{8}\.webp$/, '').replace(/\.webp$/, '');
const unhashedFilename = basename + '.webp';
[ "full", "thumb", "placeholder"].forEach((folder) => {
unlink(path.join(IMAGE_DIR, "rezepte", folder, data.name + ".webp"), (e) => {
if(e) error(404, "could not delete: " + folder + "/" + data.name + ".webp" + e)
})
// Delete hashed version
unlink(path.join(IMAGE_DIR, "rezepte", folder, hashedFilename), (e) => {
if(e) console.warn(`Could not delete hashed: ${folder}/${hashedFilename}`, e);
});
// Delete unhashed version (for graceful degradation)
unlink(path.join(IMAGE_DIR, "rezepte", folder, unhashedFilename), (e) => {
if(e) console.warn(`Could not delete unhashed: ${folder}/${unhashedFilename}`, e);
});
})
return new Response(JSON.stringify({msg: "Deleted image successfully"}),{
status: 200,

View File

@@ -3,21 +3,41 @@ import type { RequestHandler } from '@sveltejs/kit';
import { IMAGE_DIR } from '$env/static/private'
import { rename } from 'node:fs';
import { error } from '@sveltejs/kit';
import { extractBasename, getHashedFilename } from '$utils/imageHash';
export const POST = (async ({ request, locals}) => {
const data = await request.json();
const auth = await locals.auth();
if(!auth ) throw error(401, "need to be logged in")
// data.old_filename should be the full filename with hash (e.g., "maccaroni.a1b2c3d4.webp")
// data.new_name should be the new basename (e.g., "pasta")
// Extract hash from old filename and apply to new basename
const oldFilename = data.old_filename || (data.old_name + ".webp");
const hashMatch = oldFilename.match(/\.([a-f0-9]{8})\.webp$/);
let newFilename: string;
if (hashMatch) {
// Old filename has hash, preserve it
const hash = hashMatch[1];
newFilename = getHashedFilename(data.new_name, hash);
} else {
// Old filename has no hash (legacy), new one won't either
newFilename = data.new_name + ".webp";
}
[ "full", "thumb", "placeholder"].forEach((folder) => {
const old_path = path.join(IMAGE_DIR, "rezepte", folder, data.old_name + ".webp")
rename(old_path, path.join(IMAGE_DIR, "rezepte", folder, data.new_name + ".webp"), (e) => {
const old_path = path.join(IMAGE_DIR, "rezepte", folder, oldFilename)
rename(old_path, path.join(IMAGE_DIR, "rezepte", folder, newFilename), (e) => {
console.log(e)
if(e) throw error(500, "could not mv: " + old_path)
})
});
return new Response(JSON.stringify({msg: "Deleted image successfully"}),{
status: 200,
});
return new Response(JSON.stringify({
msg: "Renamed image successfully",
filename: newFilename
}),{
status: 200,
});
}) satisfies RequestHandler;

50
src/utils/imageHash.ts Normal file
View File

@@ -0,0 +1,50 @@
import crypto from 'crypto';
import fs from 'fs';
/**
* Generates an 8-character hash from image file content
* Uses SHA-256 for reliable, content-based hashing
* @param filePath - Path to the image file
* @returns 8-character hex hash
*/
export function generateImageHash(filePath: string): string {
const fileBuffer = fs.readFileSync(filePath);
const hashSum = crypto.createHash('sha256');
hashSum.update(fileBuffer);
const hash = hashSum.digest('hex');
return hash.substring(0, 8);
}
/**
* Generates an 8-character hash from Buffer content
* @param buffer - Image file buffer
* @returns 8-character hex hash
*/
export function generateImageHashFromBuffer(buffer: Buffer): string {
const hashSum = crypto.createHash('sha256');
hashSum.update(buffer);
const hash = hashSum.digest('hex');
return hash.substring(0, 8);
}
/**
* Creates a filename with hash for cache busting
* @param basename - Base name without extension (e.g., "maccaroni")
* @param hash - 8-character hash
* @returns Filename with hash (e.g., "maccaroni.a1b2c3d4.webp")
*/
export function getHashedFilename(basename: string, hash: string): string {
return `${basename}.${hash}.webp`;
}
/**
* Extracts basename from a potentially hashed filename
* @param filename - Filename (e.g., "maccaroni.a1b2c3d4.webp" or "maccaroni.webp")
* @returns Basename without hash or extension (e.g., "maccaroni")
*/
export function extractBasename(filename: string): string {
// Remove .webp extension
const withoutExt = filename.replace(/\.webp$/, '');
// Remove hash if present (8 hex chars preceded by a dot)
return withoutExt.replace(/\.[a-f0-9]{8}$/, '');
}