feat: add image handling to recipes

- Implemented image downloading and optimization in QuickImportService.
- Added imageUrl field to CreateRecipeDto for recipe creation.
- Created an endpoint in RecipesController to update recipe images.
- Enhanced RecipesService to handle image URL updates and optimizations.
- Updated Docker Compose to mount a volume for recipe images.
- Refactored frontend to display images in recipe grids and detail views.
- Added a new utility function for downloading and optimizing images.
- Created a new API route for handling image uploads.
- Introduced RecipeGrid component for better recipe display.
- Updated RecipeDetailClient to manage image updates and display.
- Added migration for new imageUrl column in the Recipe table.
This commit is contained in:
Nils-Johan Gynther
2026-04-15 19:46:50 +02:00
parent a2038ffbec
commit 73bf5193c4
20 changed files with 933 additions and 49 deletions
@@ -0,0 +1,89 @@
import * as fs from 'fs';
import * as path from 'path';
import * as sharp from 'sharp';
import { v4 as uuidv4 } from 'uuid';
/** Privata IP-ranges som ska blockeras (SSRF-skydd) */
const BLOCKED_HOSTNAMES = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|0\.0\.0\.0|::1|fc00:|fe80:)/i;
/**
* Laddar ner en bild från en extern URL, optimerar den med sharp och
* sparar till destDir. Returnerar relativ URL för användning i DB (/images/{uuid}.jpg).
*
* SSRF-skydd:
* - Kräver https://
* - Blockar privata IP-adresser och localhost
* - Timeout 10s, max 5 MB
* - Validerar att content-type är image/*
*/
export async function downloadAndOptimizeImage(
sourceUrl: string,
destDir: string,
): Promise<string> {
// Protokollvalidering
if (!sourceUrl.startsWith('https://')) {
throw new Error('Bild-URL måste använda https://');
}
// SSRF: blockera privata hostnames
let hostname: string;
try {
hostname = new URL(sourceUrl).hostname;
} catch {
throw new Error('Ogiltig bild-URL');
}
if (BLOCKED_HOSTNAMES.test(hostname)) {
throw new Error('Bild-URL pekar på ett blockerat nätverk');
}
// Ladda ner bilden
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
let response: Response;
try {
response = await fetch(sourceUrl, {
signal: controller.signal,
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; RecipeApp/1.0)' },
});
} finally {
clearTimeout(timeout);
}
if (!response.ok) {
throw new Error(`HTTP ${response.status} vid nedladdning av bild`);
}
// Validera content-type
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.startsWith('image/')) {
throw new Error(`Ogiltig content-type: ${contentType}`);
}
const arrayBuffer = await response.arrayBuffer();
if (arrayBuffer.byteLength > 5 * 1024 * 1024) {
throw new Error('Bilden är för stor (max 5 MB)');
}
const imageBuffer = Buffer.from(arrayBuffer);
// Skapa destDir om det inte finns
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
const filename = `${uuidv4()}.jpg`;
const outputPath = path.join(destDir, filename);
// Optimera med sharp
await (sharp as unknown as (input: Buffer) => sharp.Sharp)(imageBuffer)
.resize(1200, 800, {
fit: 'inside',
withoutEnlargement: true,
})
.jpeg({ quality: 80 })
.toFile(outputPath);
// Returnera relativ sökväg för DB-lagring
return `/images/${filename}`;
}