Change production path check from /var/lib/www to /var/www/static to match actual production environment configuration. Updated migration endpoint and all documentation references.
8.1 KiB
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:
- Find all recipes in the database
- Check each recipe's images:
- If
images[0].mediapathalready has a hash → skip - If image file doesn't exist on disk → skip
- Otherwise → generate hash and create hashed copy
- If
- Generate content hash from the full-size image (8-char SHA-256)
- Copy files (keeps originals!) in all three directories:
/var/www/static/rezepte/full//var/www/static/rezepte/thumb//var/www/static/rezepte/placeholder/
- Update database with new hashed filename in
images[0].mediapath
Prerequisites
- Authentication: Either be logged in as admin OR have
ADMIN_SECRET_TOKENset - Only runs in production (when
IMAGE_DIR=/var/www/static) - Requires confirmation token to prevent accidental runs
- Backup your database before running (recommended)
Setting Up Admin Token (Production Server)
Add ADMIN_SECRET_TOKEN to your production .env file:
# Generate a secure random token
openssl rand -hex 32
# Add to .env (production only!)
echo "ADMIN_SECRET_TOKEN=your-generated-token-here" >> .env
Important: Keep this token secret and only set it on the production server. Do NOT commit it to git.
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].mediapathfor 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:
location /static/rezepte/ {
root /var/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:
sudo nginx -t && sudo nginx -s reload
Step 3: Run Migration
Option 1: Using Shell Script (Recommended for Server)
SSH into your production server and run:
cd /path/to/homepage
# Source your .env to get ADMIN_SECRET_TOKEN
source .env
# Make script executable (first time only)
chmod +x scripts/migrate-image-hashes.sh
# Run migration
./scripts/migrate-image-hashes.sh
The script will:
- Check that
ADMIN_SECRET_TOKENis set - Ask for confirmation
- Call the API endpoint with the admin token
- Pretty-print the results
Option 2: Using curl with Admin Token
# On production server with .env sourced
source .env
curl -X POST https://bocken.org/api/admin/migrate-image-hashes \
-H "Content-Type: application/json" \
-d "{\"confirm\": \"MIGRATE_IMAGES\", \"adminToken\": \"$ADMIN_SECRET_TOKEN\"}"
Option 3: Using curl with Session Cookie (Browser)
# 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 4: Using Browser (Postman, Insomnia, etc.)
- Make sure you're logged in to bocken.org in your browser
- Send POST request to:
https://bocken.org/api/admin/migrate-image-hashes - Headers:
Content-Type: application/json - Body (JSON):
{ "confirm": "MIGRATE_IMAGES" }
Response Format
Success response:
{
"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].mediapathalready contains a hash pattern (.[a-f0-9]{8}.webp)- Image file doesn't exist on disk
- Recipe has no images array
After Migration
Verification
- Check a few recipe pages - images should load correctly
- 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
- Hashed images should have
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:
- Drop
recipe-name.webpin all three folders (full, thumb, placeholder) - It will work immediately (graceful degradation)
- Next time the recipe is edited and image re-uploaded, it will get a hash
Rollback (If Needed)
If something goes wrong:
- Database rollback: Restore from backup taken before migration
- Files: The original unhashed files are still on disk - no data loss
- Remove hashed files (optional):
cd /var/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
- ✅ Production-only: Won't run unless
IMAGE_DIR=/var/www/static - ✅ Confirmation token: Requires
{"confirm": "MIGRATE_IMAGES"}in request body - ✅ Authentication: Requires either logged-in user OR valid
ADMIN_SECRET_TOKEN - ✅ Non-destructive: Copies files (keeps originals)
- ✅ Skip already migrated: Won't re-process files that already have hashes
- ✅ 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/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
images: [{
mediapath: "maccaroni.a1b2c3d4.webp", // Full filename with hash
alt: "Maccaroni and cheese",
caption: "Delicious comfort food"
}]
Frontend Usage
<!-- Card.svelte -->
<script>
// Uses images[0].mediapath (with hash)
// Falls back to short_name.webp if missing
const img_name = $derived(
recipe.images?.[0]?.mediapath ||
`${recipe.short_name}.webp`
);
</script>
<img src="https://bocken.org/static/rezepte/thumb/{img_name}" />
Questions?
If you encounter issues:
- Check nginx error logs:
sudo tail -f /var/log/nginx/error.log - Check application logs for the migration endpoint
- Verify file permissions on
/var/www/static/rezepte/ - Ensure database connection is working
The migration is designed to be safe and non-destructive. Original files are never deleted, only copied.