From ccf3fd7ea278bed9fa962ab9d98d2b930efe7271 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Fri, 2 Jan 2026 12:06:53 +0100 Subject: [PATCH] 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. --- MIGRATION_IMAGE_HASHES.md | 255 ++++++++++++++++++ scripts/migrate-image-hashes.sh | 49 ++++ src/lib/components/Card.svelte | 11 +- src/lib/js/recipeJsonLd.ts | 2 +- src/models/Recipe.ts | 2 +- .../[name]/+page.svelte | 16 +- .../edit/[name]/+page.svelte | 2 +- .../api/admin/migrate-image-hashes/+server.ts | 148 ++++++++++ src/routes/api/rezepte/img/add/+server.ts | 56 ++-- src/routes/api/rezepte/img/delete/+server.ts | 20 +- src/routes/api/rezepte/img/mv/+server.ts | 30 ++- src/utils/imageHash.ts | 50 ++++ 12 files changed, 603 insertions(+), 38 deletions(-) create mode 100644 MIGRATION_IMAGE_HASHES.md create mode 100644 scripts/migrate-image-hashes.sh create mode 100644 src/routes/api/admin/migrate-image-hashes/+server.ts create mode 100644 src/utils/imageHash.ts diff --git a/MIGRATION_IMAGE_HASHES.md b/MIGRATION_IMAGE_HASHES.md new file mode 100644 index 0000000..48dfb3d --- /dev/null +++ b/MIGRATION_IMAGE_HASHES.md @@ -0,0 +1,255 @@ +# Image Hash Migration Guide + +This guide explains how to migrate existing images to use content-based hashing for cache invalidation. + +## Overview + +The new system stores images with content-based hashes for proper cache invalidation: +- **Database**: `images[0].mediapath = "maccaroni.a1b2c3d4.webp"` +- **Disk (hashed)**: `maccaroni.a1b2c3d4.webp` - cached forever (immutable) +- **Disk (unhashed)**: `maccaroni.webp` - fallback for graceful degradation + +## What This Does + +The migration will: + +1. **Find all recipes** in the database +2. **Check each recipe's images**: + - If `images[0].mediapath` already has a hash → skip + - If image file doesn't exist on disk → skip + - Otherwise → generate hash and create hashed copy +3. **Generate content hash** from the full-size image (8-char SHA-256) +4. **Copy files** (keeps originals!) in all three directories: + - `/var/lib/www/static/rezepte/full/` + - `/var/lib/www/static/rezepte/thumb/` + - `/var/lib/www/static/rezepte/placeholder/` +5. **Update database** with new hashed filename in `images[0].mediapath` + +## Prerequisites + +- Must be logged in as admin +- Only runs in production (when `IMAGE_DIR=/var/lib/www/static`) +- Requires confirmation token to prevent accidental runs +- Backup your database before running (recommended) + +## Step 1: Deploy Code Changes + +Deploy the updated codebase to production. The changes include: +- Image upload endpoint now saves both hashed and unhashed versions +- Frontend components use `images[0].mediapath` for image URLs +- New migration endpoint at `/api/admin/migrate-image-hashes` + +## Step 2: Update Nginx Configuration + +Add this to your nginx site configuration for `bocken.org`: + +```nginx +location /static/rezepte/ { + root /var/lib/www; + + # Cache hashed files forever (they have content hash in filename) + location ~ /static/rezepte/(thumb|placeholder|full)/[^/]+\.[a-f0-9]{8}\.webp$ { + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # Cache unhashed files with revalidation (fallback for manual uploads) + location ~ /static/rezepte/(thumb|placeholder|full)/[^/]+\.webp$ { + add_header Cache-Control "public, max-age=3600, must-revalidate"; + } +} +``` + +Reload nginx: +```bash +sudo nginx -t && sudo nginx -s reload +``` + +## Step 3: Run Migration + +### Option 1: Using curl (Recommended) + +```bash +# Get your session cookie from browser DevTools +# In Chrome/Firefox: F12 → Network tab → Click any request → Headers → Copy Cookie value + +curl -X POST https://bocken.org/api/admin/migrate-image-hashes \ + -H "Content-Type: application/json" \ + -H "Cookie: YOUR_SESSION_COOKIE_HERE" \ + -d '{"confirm": "MIGRATE_IMAGES"}' +``` + +### Option 2: Using the Shell Script + +```bash +cd /path/to/homepage + +# Save your session cookie to a file (from browser DevTools) +echo "your-session-cookie-value" > .prod-session-cookie + +# Make script executable +chmod +x scripts/migrate-image-hashes.sh + +# Run migration +./scripts/migrate-image-hashes.sh +``` + +### Option 3: Using Browser (Postman, Insomnia, etc.) + +1. Make sure you're logged in to bocken.org in your browser +2. Send POST request to: `https://bocken.org/api/admin/migrate-image-hashes` +3. Headers: + ``` + Content-Type: application/json + ``` +4. Body (JSON): + ```json + { + "confirm": "MIGRATE_IMAGES" + } + ``` + +## Response Format + +Success response: +```json +{ + "success": true, + "message": "Migration complete. Migrated 42 of 100 recipes.", + "total": 100, + "migrated": 42, + "skipped": 58, + "errors": [], + "details": [ + { + "shortName": "maccaroni", + "status": "migrated", + "unhashedFilename": "maccaroni.webp", + "hashedFilename": "maccaroni.a1b2c3d4.webp", + "hash": "a1b2c3d4", + "filesCopied": 3, + "note": "Both hashed and unhashed versions saved for graceful degradation" + }, + { + "shortName": "pizza", + "status": "skipped", + "reason": "already hashed", + "filename": "pizza.e5f6g7h8.webp" + } + ] +} +``` + +## What Gets Skipped + +The migration will skip recipes where: +- `images[0].mediapath` already contains a hash pattern (`.[a-f0-9]{8}.webp`) +- Image file doesn't exist on disk +- Recipe has no images array + +## After Migration + +### Verification + +1. Check a few recipe pages - images should load correctly +2. Check browser DevTools → Network tab: + - Hashed images should have `Cache-Control: max-age=31536000, immutable` + - Unhashed images should have `Cache-Control: max-age=3600, must-revalidate` + +### New Uploads + +All new image uploads will automatically: +- Generate content hash +- Save both hashed and unhashed versions +- Store hashed filename in database +- Work immediately with proper cache invalidation + +### Manual Uploads + +If you manually upload an image: +1. Drop `recipe-name.webp` in all three folders (full, thumb, placeholder) +2. It will work immediately (graceful degradation) +3. Next time the recipe is edited and image re-uploaded, it will get a hash + +## Rollback (If Needed) + +If something goes wrong: + +1. **Database rollback**: Restore from backup taken before migration +2. **Files**: The original unhashed files are still on disk - no data loss +3. **Remove hashed files** (optional): + ```bash + cd /var/lib/www/static/rezepte + find . -name '*.[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9].webp' -delete + ``` + +## Safety Features + +1. ✅ **Production-only**: Won't run unless `IMAGE_DIR=/var/lib/www/static` +2. ✅ **Confirmation token**: Requires `{"confirm": "MIGRATE_IMAGES"}` in request body +3. ✅ **Authentication**: Requires logged-in user +4. ✅ **Non-destructive**: Copies files (keeps originals) +5. ✅ **Skip already migrated**: Won't re-process files that already have hashes +6. ✅ **Detailed logging**: Returns detailed report of what was changed + +## Technical Details + +### Hash Generation + +- Uses SHA-256 of image file content +- First 8 hex characters used (4 billion combinations) +- Same image = same hash (deterministic) +- Different image = different hash (cache invalidation) + +### File Structure + +``` +/var/lib/www/static/rezepte/ +├── full/ +│ ├── maccaroni.webp ← Unhashed (fallback) +│ ├── maccaroni.a1b2c3d4.webp ← Hashed (cache busting) +│ └── ... +├── thumb/ +│ ├── maccaroni.webp +│ ├── maccaroni.a1b2c3d4.webp +│ └── ... +└── placeholder/ + ├── maccaroni.webp + ├── maccaroni.a1b2c3d4.webp + └── ... +``` + +### Database Schema + +```typescript +images: [{ + mediapath: "maccaroni.a1b2c3d4.webp", // Full filename with hash + alt: "Maccaroni and cheese", + caption: "Delicious comfort food" +}] +``` + +### Frontend Usage + +```svelte + + + + +``` + +## Questions? + +If you encounter issues: +1. Check nginx error logs: `sudo tail -f /var/log/nginx/error.log` +2. Check application logs for the migration endpoint +3. Verify file permissions on `/var/lib/www/static/rezepte/` +4. Ensure database connection is working + +The migration is designed to be safe and non-destructive. Original files are never deleted, only copied. diff --git a/scripts/migrate-image-hashes.sh b/scripts/migrate-image-hashes.sh new file mode 100644 index 0000000..ac28cac --- /dev/null +++ b/scripts/migrate-image-hashes.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +# Image Hash Migration Script +# This script triggers the image hash migration endpoint in production +# It will: +# 1. Find all images without hashes (shortname.webp) +# 2. Generate content hashes for them +# 3. Rename them to shortname.{hash}.webp +# 4. Update the database + +set -e + +echo "======================================" +echo "Image Hash Migration Script" +echo "======================================" +echo "" +echo "This will migrate all existing images to use content-based hashes." +echo "Images will be renamed from 'shortname.webp' to 'shortname.{hash}.webp'" +echo "" +echo "⚠️ WARNING: This operation will rename files on disk!" +echo "" +read -p "Are you sure you want to continue? (yes/no): " confirm + +if [ "$confirm" != "yes" ]; then + echo "Migration cancelled." + exit 0 +fi + +echo "" +echo "Starting migration..." +echo "" + +# Get the production URL (modify this to your production URL) +PROD_URL="https://bocken.org" + +# Make the API call +response=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + -H "Cookie: $(cat .prod-session-cookie 2>/dev/null || echo '')" \ + -d '{"confirm": "MIGRATE_IMAGES"}' \ + "${PROD_URL}/api/admin/migrate-image-hashes") + +# Pretty print the response +echo "$response" | jq '.' + +echo "" +echo "======================================" +echo "Migration complete!" +echo "======================================" diff --git a/src/lib/components/Card.svelte b/src/lib/components/Card.svelte index 22ff3fe..8530219 100644 --- a/src/lib/components/Card.svelte +++ b/src/lib/components/Card.svelte @@ -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` +);