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}`;
}
@@ -12,6 +12,7 @@ export interface ParsedRecipe {
note?: string;
}>;
instructions?: string;
imageUrl?: string;
}
export abstract class RecipeParser {
@@ -14,6 +14,9 @@ export class GenericRecipeParser extends RecipeParser {
parse(html: string): ParsedRecipe {
console.log('[GenericParser] Parsing recipe from unknown site...');
// Extrahera og:image för bildurl-fallback
const ogImage = this.extractOgImage(html);
// Försöka extrahera JSON-LD recipe data
const jsonLdMatch = html.match(
/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/i
@@ -29,7 +32,7 @@ export class GenericRecipeParser extends RecipeParser {
if (recipe) {
console.log('[GenericParser] ✓ JSON-LD data found');
return this.extractFromJsonLd(recipe);
return this.extractFromJsonLd(recipe, ogImage);
}
} catch (err) {
console.log('[GenericParser] JSON-LD parsing failed');
@@ -37,13 +40,31 @@ export class GenericRecipeParser extends RecipeParser {
}
console.log('[GenericParser] No JSON-LD found, using HTML parsing');
return this.parseFromHtml(html);
return this.parseFromHtml(html, ogImage);
}
private extractFromJsonLd(recipe: any): ParsedRecipe {
private extractOgImage(html: string): string | undefined {
const match = html.match(/<meta[^>]+property="og:image"[^>]+content="([^"]+)"/i)
|| html.match(/<meta[^>]+content="([^"]+)"[^>]+property="og:image"/i);
return match ? match[1].trim() : undefined;
}
private extractFromJsonLd(recipe: any, ogImage?: string): ParsedRecipe {
const name = recipe.name || '';
const description = recipe.description || '';
// Extrahera bildurl från JSON-LD
let imageUrl: string | undefined = ogImage;
if (recipe.image) {
if (typeof recipe.image === 'string') {
imageUrl = recipe.image;
} else if (Array.isArray(recipe.image) && recipe.image.length > 0) {
imageUrl = typeof recipe.image[0] === 'string' ? recipe.image[0] : recipe.image[0]?.url;
} else if (recipe.image?.url) {
imageUrl = recipe.image.url;
}
}
const ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = [];
if (recipe.recipeIngredient && Array.isArray(recipe.recipeIngredient)) {
for (const ing of recipe.recipeIngredient) {
@@ -75,10 +96,11 @@ export class GenericRecipeParser extends RecipeParser {
description,
ingredients,
instructions,
imageUrl,
};
}
private parseFromHtml(html: string): ParsedRecipe {
private parseFromHtml(html: string, ogImage?: string): ParsedRecipe {
// Försöka hitta titel
let name = '';
@@ -143,6 +165,7 @@ export class GenericRecipeParser extends RecipeParser {
description,
ingredients,
instructions,
imageUrl: ogImage,
};
}
}
+27 -4
View File
@@ -12,6 +12,9 @@ export class IcaRecipeParser extends RecipeParser {
parse(html: string): ParsedRecipe {
console.log('[IcaParser] Parsing ICA recipe...');
// Extrahera og:image för bildurl-fallback
const ogImage = this.extractOgImage(html);
// Försöka extrahera JSON-LD recipe data (ICA använder detta)
const jsonLdMatch = html.match(
/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/i
@@ -29,7 +32,7 @@ export class IcaRecipeParser extends RecipeParser {
if (recipe) {
console.log('[IcaParser] ✓ JSON-LD recipe found');
return this.extractFromJsonLd(recipe);
return this.extractFromJsonLd(recipe, ogImage);
}
} catch (err) {
console.log('[IcaParser] JSON-LD parsing failed:', err);
@@ -38,16 +41,34 @@ export class IcaRecipeParser extends RecipeParser {
// Fallback: HTML parsing (sällan nödvändigt för ICA)
console.log('[IcaParser] Falling back to HTML parsing');
return this.parseFromHtml(html);
return this.parseFromHtml(html, ogImage);
}
private extractFromJsonLd(recipe: any): ParsedRecipe {
private extractOgImage(html: string): string | undefined {
const match = html.match(/<meta[^>]+property="og:image"[^>]+content="([^"]+)"/i)
|| html.match(/<meta[^>]+content="([^"]+)"[^>]+property="og:image"/i);
return match ? match[1].trim() : undefined;
}
private extractFromJsonLd(recipe: any, ogImage?: string): ParsedRecipe {
// Extrahera titel
const name = recipe.name || '';
// Extrahera beskrivning
const description = recipe.description || '';
// Extrahera bildurl från JSON-LD (kan vara sträng eller array)
let imageUrl: string | undefined = ogImage;
if (recipe.image) {
if (typeof recipe.image === 'string') {
imageUrl = recipe.image;
} else if (Array.isArray(recipe.image) && recipe.image.length > 0) {
imageUrl = typeof recipe.image[0] === 'string' ? recipe.image[0] : recipe.image[0]?.url;
} else if (recipe.image?.url) {
imageUrl = recipe.image.url;
}
}
// Extrahera ingredienser
const ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = [];
if (recipe.recipeIngredient && Array.isArray(recipe.recipeIngredient)) {
@@ -81,10 +102,11 @@ export class IcaRecipeParser extends RecipeParser {
description,
ingredients,
instructions,
imageUrl,
};
}
private parseFromHtml(html: string): ParsedRecipe {
private parseFromHtml(html: string, ogImage?: string): ParsedRecipe {
let name = '';
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/i);
if (titleMatch) {
@@ -133,6 +155,7 @@ export class IcaRecipeParser extends RecipeParser {
description,
ingredients,
instructions,
imageUrl: ogImage,
};
}
}
@@ -11,10 +11,14 @@ import { createWorker } from 'tesseract.js';
import { IcaRecipeParser } from './parsers/ica.parser';
import { GenericRecipeParser } from './parsers/generic.parser';
import { RecipeParser } from './parsers/base.parser';
import { downloadAndOptimizeImage } from '../common/utils/download-image';
const IMAGE_DEST_DIR = process.env.IMAGE_DEST_DIR || '/app/recipe-images';
export interface QuickImportResult {
markdown: string;
source: 'ica' | 'pdf' | 'image' | 'other';
imageUrl?: string;
}
type UploadKind = 'pdf' | 'image';
@@ -311,9 +315,21 @@ export class QuickImportService {
source = 'ica';
}
// Ladda ner och optimera bild om parser hittade en
let imageUrl: string | undefined;
if (recipe.imageUrl) {
try {
imageUrl = await downloadAndOptimizeImage(recipe.imageUrl, IMAGE_DEST_DIR);
console.log('[QuickImport] Bild optimerad och sparad:', imageUrl);
} catch (imgErr) {
console.warn('[QuickImport] Kunde inte ladda ner bild:', imgErr);
}
}
return {
markdown,
source,
imageUrl,
};
} catch (err) {
const message = err instanceof Error ? err.message : 'Okänt fel vid scraping';
@@ -38,6 +38,10 @@ export class CreateRecipeDto {
@IsString()
instructions?: string;
@IsOptional()
@IsString()
imageUrl?: string;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
+14
View File
@@ -1,8 +1,14 @@
import { Body, Controller, Delete, Get, HttpCode, Param, ParseIntPipe, Post, Patch } from '@nestjs/common';
import { IsString } from 'class-validator';
import { RecipesService } from './recipes.service';
import { CreateRecipeDto } from './dto/create-recipe.dto';
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
class UpdateImageDto {
@IsString()
sourceUrl!: string;
}
@Controller('recipes')
export class RecipesController {
constructor(private readonly recipesService: RecipesService) {}
@@ -45,4 +51,12 @@ export class RecipesController {
async remove(@Param('id', ParseIntPipe) id: number) {
return this.recipesService.remove(id);
}
@Post(':id/image')
async updateImage(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateImageDto,
) {
return this.recipesService.updateImage(id, dto.sourceUrl);
}
}
+31
View File
@@ -3,6 +3,9 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateRecipeDto } from './dto/create-recipe.dto';
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
import { downloadAndOptimizeImage } from '../common/utils/download-image';
const IMAGE_DEST_DIR = process.env.IMAGE_DEST_DIR || '/app/recipe-images';
// Lokala typdefiniitioner (tidigare från recipe-document-converter)
interface ParsedIngredient {
@@ -340,6 +343,7 @@ export class RecipesService {
name: updateRecipeDto.name,
description: updateRecipeDto.description || null,
instructions: updateRecipeDto.instructions || null,
...(updateRecipeDto.imageUrl !== undefined && { imageUrl: updateRecipeDto.imageUrl || null }),
ingredients: {
create: updateRecipeDto.ingredients.map((ingredient) => ({
productId: ingredient.productId,
@@ -374,12 +378,39 @@ export class RecipesService {
await this.prisma.recipe.delete({ where: { id } });
}
async updateImage(id: number, sourceUrl: string) {
const existingRecipe = await this.prisma.recipe.findUnique({ where: { id } });
if (!existingRecipe) {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
const imageUrl = await downloadAndOptimizeImage(sourceUrl, IMAGE_DEST_DIR);
return this.prisma.recipe.update({
where: { id },
data: { imageUrl },
include: { ingredients: { include: { product: true } } },
});
}
async create(createRecipeDto: CreateRecipeDto) {
// Om imageUrl är en extern URL — ladda ner och optimera
let imageUrl: string | null = createRecipeDto.imageUrl || null;
if (imageUrl && imageUrl.startsWith('http')) {
try {
imageUrl = await downloadAndOptimizeImage(imageUrl, IMAGE_DEST_DIR);
} catch (err) {
console.warn('[RecipesService] Kunde inte ladda ner receptbild:', err);
imageUrl = null;
}
}
const recipe = await this.prisma.recipe.create({
data: {
name: createRecipeDto.name,
description: createRecipeDto.description || null,
instructions: createRecipeDto.instructions || null,
imageUrl,
ingredients: {
create: createRecipeDto.ingredients.map((ingredient) => ({
productId: ingredient.productId,