From 3bff3c860064f4560c6e767d6eaf145357626a2a Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 10 Apr 2026 12:01:26 +0200 Subject: [PATCH] feat: MinIO storage, native audio recording, production Docker config - Replace vmsg WASM encoder with native MediaRecorder API (WebM/Opus) to fix empty MP3 files causing OpenAI Whisper 400 errors - Add minimum recording duration (2s) and file size (5KB) guards - Add MinIO S3 storage integration for recipe images and audio - Add /uploads/* API route that proxies files from MinIO with local fallback - Save audio locally first for transcription, then upload to MinIO (fixes ECONNREFUSED when backend tried to fetch its own public URL) - Add docker-compose.prod.yml, nginx-prod.conf, frontend Dockerfile - Frontend Dockerfile: no-cache headers on index.html, long cache on hashed assets Co-Authored-By: Claude Opus 4.6 --- backend/src/plugins/ai.ts | 81 +++++++------- backend/src/server.ts | 74 +++++++++++-- backend/src/utils/storage.ts | 35 ++++-- docker-compose.prod.yml | 64 +++++++++++ frontend/Dockerfile | 53 +++++++++ frontend/src/hooks/useAudioRecorder.ts | 127 +++++++++++++++++----- frontend/src/pages/Recipes/RecipeForm.tsx | 13 ++- nginx-prod.conf | 23 ++++ 8 files changed, 383 insertions(+), 87 deletions(-) create mode 100644 docker-compose.prod.yml create mode 100644 frontend/Dockerfile create mode 100644 nginx-prod.conf diff --git a/backend/src/plugins/ai.ts b/backend/src/plugins/ai.ts index 1f024fb..0f1a60c 100644 --- a/backend/src/plugins/ai.ts +++ b/backend/src/plugins/ai.ts @@ -1,10 +1,11 @@ import fp from 'fastify-plugin'; import { OpenAI } from 'openai'; import * as fs from 'node:fs'; +import * as path from 'node:path'; import { pipeline } from 'node:stream/promises'; import type { FastifyInstance } from 'fastify'; import type { MultipartFile } from '@fastify/multipart'; -import { uploadFile, getFileUrl } from '../utils/storage'; +import { uploadFile, getPublicUrl } from '../utils/storage'; import type { AudioInput, AudioSaveResult, RecipeData } from '../types/fastify'; import { transcribeAudio as runTranscribe, @@ -15,7 +16,6 @@ import type { UserPreferences } from '../ai/prompts'; import { generateRecipeImage as runGenerateImage } from '../ai/image-generator'; export default fp(async function aiPlugin(fastify: FastifyInstance) { - // Client OpenAI partagé, avec retries et timeout configurés const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY!, maxRetries: Number(process.env.OPENAI_MAX_RETRIES ?? 3), @@ -24,33 +24,35 @@ export default fp(async function aiPlugin(fastify: FastifyInstance) { fastify.decorate('openai', openai); // --- Transcription audio --- + // Accepte soit un chemin local, soit un objet avec localPath. + // On ne passe PLUS par une URL publique — les fichiers sont toujours + // disponibles localement (sauvegardés dans ./uploads avant upload MinIO). fastify.decorate('transcribeAudio', async (audioInput: AudioInput): Promise => { - let tempFile: { path: string; cleanup: () => void } | null = null; let audioPath: string; if (typeof audioInput === 'string') { audioPath = audioInput; - } else if (audioInput && 'url' in audioInput && audioInput.url) { - tempFile = await downloadToTemp(audioInput.url, '.mp3'); - audioPath = tempFile.path; } else if (audioInput && 'localPath' in audioInput && audioInput.localPath) { audioPath = audioInput.localPath; + } else if (audioInput && 'url' in audioInput && audioInput.url) { + // Fallback : si on reçoit une URL externe (pas notre propre API), + // on télécharge en temp. + const tempFile = await downloadToTemp(audioInput.url, '.webm'); + try { + const { text } = await runTranscribe(openai, fastify.log, { audioPath: tempFile.path }); + return text; + } finally { + tempFile.cleanup(); + } } else { throw new Error("Format d'entrée audio non valide"); } - try { - const { text } = await runTranscribe(openai, fastify.log, { audioPath }); - return text; - } finally { - tempFile?.cleanup(); - } + const { text } = await runTranscribe(openai, fastify.log, { audioPath }); + return text; }); // --- Génération de recette --- - // Cette fonction est maintenue pour compatibilité avec le contrat existant - // (elle retourne `RecipeData` à plat). En interne, elle utilise désormais - // le pipeline structuré + parallélisation image/texte. fastify.decorate( 'generateRecipe', async ( @@ -58,24 +60,19 @@ export default fp(async function aiPlugin(fastify: FastifyInstance) { hint?: string, preferences?: unknown ): Promise => { - // 1. Génération du texte (séquentiel obligatoire : on a besoin du titre) const { recipe } = await runGenerateRecipe(openai, fastify.log, { transcription: ingredients, hint, preferences: preferences as UserPreferences | null | undefined, }); - // 2. Génération de l'image en parallèle de la sérialisation - // (l'image n'a besoin que du titre + description, qu'on a déjà) const imagePromise = runGenerateImage(openai, fastify.log, { title: recipe.titre, description: recipe.description, }); - // 3. Aplatir la recette structurée vers RecipeData (compatibilité Prisma) const flat = flattenRecipe(recipe); - // 4. Attendre l'image (best-effort, peut être null) const { url: imageUrl } = await imagePromise; flat.image_url = imageUrl; @@ -84,6 +81,10 @@ export default fp(async function aiPlugin(fastify: FastifyInstance) { ); // --- Sauvegarde fichier audio --- + // Stratégie : toujours sauvegarder en local D'ABORD (pour la transcription), + // puis uploader dans MinIO en arrière-plan (pour le stockage permanent). + // Le résultat retourne localPath pour que transcribeAudio lise le fichier + // directement sans passer par une URL publique. fastify.decorate('saveAudioFile', async (file: MultipartFile): Promise => { if (!file || !file.filename) { throw new Error('Fichier audio invalide'); @@ -91,36 +92,38 @@ export default fp(async function aiPlugin(fastify: FastifyInstance) { const fileName = `${Date.now()}-${file.filename}`; - // Tentative MinIO - try { - const filePath = await uploadFile({ filename: fileName, file: file.file }, 'audio'); - const url = await getFileUrl(filePath); - return { success: true, url, path: filePath }; - } catch (err) { - fastify.log.warn(`Upload MinIO échoué, fallback local: ${(err as Error).message}`); - } - - // Fallback local - const uploadDir = './uploads'; + // 1. Toujours sauvegarder en local pour la transcription + const uploadDir = './uploads/audio'; if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } - const filepath = `${uploadDir}/${fileName}`; + const localFilePath = path.join(uploadDir, fileName); if (file.file && typeof file.file.pipe === 'function') { - await pipeline(file.file, fs.createWriteStream(filepath)); + await pipeline(file.file, fs.createWriteStream(localFilePath)); } else { throw new Error('Format de fichier non pris en charge'); } - return { success: true, localPath: filepath, isLocal: true }; + // 2. Upload dans MinIO en parallèle (best-effort) + let minioPath: string | null = null; + try { + const fileStream = fs.createReadStream(localFilePath); + minioPath = await uploadFile({ filename: fileName, file: fileStream }, 'audio'); + fastify.log.info(`Audio uploadé dans MinIO: ${minioPath}`); + } catch (err) { + fastify.log.warn(`Upload MinIO audio échoué, fichier local conservé: ${(err as Error).message}`); + } + + return { + success: true, + localPath: localFilePath, + path: minioPath ?? `audio/${fileName}`, + isLocal: true, + }; }); }); -/** - * Convertit la recette structurée en `RecipeData` plat compatible avec - * l'ancien contrat utilisé par les routes Prisma. - */ function flattenRecipe(recipe: StructuredRecipe): RecipeData { const ingredients = recipe.ingredients.map((i) => { const note = i.notes ? ` (${i.notes})` : ''; @@ -152,7 +155,7 @@ function flattenRecipe(recipe: StructuredRecipe): RecipeData { portions: recipe.portions, difficulte: recipe.difficulte, conseils, - image_url: null, // rempli plus tard + image_url: null, structured: recipe, }; } diff --git a/backend/src/server.ts b/backend/src/server.ts index af9d76a..4c95822 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -9,6 +9,7 @@ import rateLimit from '@fastify/rate-limit'; import cors from '@fastify/cors'; import fastifyStatic from '@fastify/static'; import * as path from 'node:path'; +import * as fs from 'node:fs'; import authPlugin from './plugins/auth'; import stripePlugin from './plugins/stripe'; @@ -20,6 +21,8 @@ import recipesRoutes from './routes/recipes'; import usersRoutes from './routes/users'; import stripeRoutes from './routes/stripe'; +import { getFile, isMinioConfigured, ensureBucket } from './utils/storage'; + const fastify = Fastify({ logger: { level: process.env.LOG_LEVEL || 'info', @@ -29,7 +32,6 @@ const fastify = Fastify({ // Parser JSON custom : on stashe le body brut sur la request pour permettre // la vérification de signature du webhook Stripe (qui exige les bytes exacts). -// Impact: ~2x mémoire sur les requêtes JSON, négligeable à notre échelle. fastify.addContentTypeParser( 'application/json', { parseAs: 'string' }, @@ -51,21 +53,35 @@ fastify.addContentTypeParser( const prisma = new PrismaClient(); fastify.decorate('prisma', prisma); +/** Devine le Content-Type à partir de l'extension. */ +function mimeFromExt(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + const map: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.mp3': 'audio/mpeg', + '.webm': 'audio/webm', + '.ogg': 'audio/ogg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav', + }; + return map[ext] || 'application/octet-stream'; +} + async function bootstrap(): Promise { // --- Sécurité --- await fastify.register(helmet, { contentSecurityPolicy: false, - // Autorise le frontend (cross-origin) à charger les images/audio servis - // depuis /uploads/*. Sans ça, le navigateur bloque avec - // ERR_BLOCKED_BY_RESPONSE.NotSameOrigin. crossOriginResourcePolicy: { policy: 'cross-origin' }, }); await fastify.register(rateLimit, { max: 100, timeWindow: '1 minute', - // Les webhooks Stripe peuvent arriver en rafale sur une retry burst — - // on ne veut pas les rate-limiter et risquer des événements perdus. skip: (req) => req.url === '/stripe/webhook', }); @@ -87,11 +103,37 @@ async function bootstrap(): Promise { credentials: true, }); - // --- Fichiers statiques (fallback local pour images/audio) --- - await fastify.register(fastifyStatic, { - root: path.resolve('./uploads'), - prefix: '/uploads/', - decorateReply: false, + // --- Route /uploads/* : sert les fichiers depuis MinIO, fallback local --- + fastify.get('/uploads/*', async (request, reply) => { + // Le wildcard capture tout après /uploads/ + const filePath = (request.params as { '*': string })['*']; + if (!filePath) { + return reply.code(400).send({ error: 'Chemin de fichier requis' }); + } + + const contentType = mimeFromExt(filePath); + + // 1. Essayer MinIO d'abord + if (isMinioConfigured()) { + try { + const stream = await getFile(filePath); + reply.header('Content-Type', contentType); + reply.header('Cache-Control', 'public, max-age=31536000, immutable'); + return reply.send(stream); + } catch (err) { + fastify.log.debug(`MinIO miss pour ${filePath}: ${(err as Error).message}`); + } + } + + // 2. Fallback : fichier local dans ./uploads/ + const localPath = path.resolve('./uploads', filePath); + if (fs.existsSync(localPath)) { + reply.header('Content-Type', contentType); + reply.header('Cache-Control', 'public, max-age=31536000, immutable'); + return reply.send(fs.createReadStream(localPath)); + } + + return reply.code(404).send({ error: 'Fichier non trouvé' }); }); // --- Plugins applicatifs --- @@ -108,6 +150,16 @@ async function bootstrap(): Promise { fastify.get('/health', async () => ({ status: 'ok', uptime: process.uptime() })); + // --- S'assurer que le bucket MinIO existe au démarrage --- + if (isMinioConfigured()) { + try { + await ensureBucket(); + fastify.log.info('MinIO bucket prêt'); + } catch (err) { + fastify.log.warn(`MinIO bucket init échoué: ${(err as Error).message}`); + } + } + fastify.addHook('onClose', async () => { await prisma.$disconnect(); }); diff --git a/backend/src/utils/storage.ts b/backend/src/utils/storage.ts index d9e4a11..7991fc0 100644 --- a/backend/src/utils/storage.ts +++ b/backend/src/utils/storage.ts @@ -28,9 +28,6 @@ function getClient(): Minio.Client | null { pathStyle: true, }; - // Ne désactiver la vérification TLS que si explicitement demandé. - // `transport` n'expose pas `agent` dans les types récents de minio ; on - // passe par un cast ciblé car le runtime minio accepte toujours cette option. if (useSSL && allowSelfSigned) { (clientOpts as unknown as { transport: unknown }).transport = { agent: new https.Agent({ rejectUnauthorized: false }), @@ -47,6 +44,21 @@ function bucket(): string { return b; } +/** Vérifie que le bucket existe, sinon le crée. */ +export async function ensureBucket(): Promise { + const client = getClient(); + if (!client) return; + const exists = await client.bucketExists(bucket()); + if (!exists) { + await client.makeBucket(bucket()); + } +} + +/** Retourne true si MinIO est configuré et joignable. */ +export function isMinioConfigured(): boolean { + return getClient() !== null; +} + export async function uploadFile(file: UploadableFile, folderPath: string): Promise { const client = getClient(); if (!client) throw new Error('MinIO non configuré'); @@ -54,6 +66,7 @@ export async function uploadFile(file: UploadableFile, folderPath: string): Prom const fileName = `${Date.now()}-${file.filename}`; const filePath = `${folderPath}/${fileName}`; await client.putObject(bucket(), filePath, file.file); + // Retourne le chemin MinIO (pas d'URL pré-signée) return filePath; } @@ -75,8 +88,16 @@ export async function listFiles(folderPath: string) { return client.listObjects(bucket(), folderPath); } -export async function getFileUrl(filePath: string): Promise { - const client = getClient(); - if (!client) throw new Error('MinIO non configuré'); - return client.presignedUrl('GET', bucket(), filePath); +/** + * Construit l'URL publique pour un fichier stocké dans MinIO. + * L'URL passe par la route API /uploads/* du backend qui proxifie MinIO. + */ +export function getPublicUrl(filePath: string): string { + const base = process.env.PUBLIC_BASE_URL || 'http://localhost:3000'; + return `${base}/uploads/${filePath}`; +} + +// Compat : ancien nom, maintenant retourne l'URL publique (pas pré-signée) +export async function getFileUrl(filePath: string): Promise { + return getPublicUrl(filePath); } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..c0ebd62 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,64 @@ +services: + backend: + build: + context: ./backend + container_name: freedge-backend + restart: always + env_file: ./backend/.env + environment: + - NODE_ENV=production + - PORT=3000 + - DATABASE_URL=file:/app/data/freedge.db + - CORS_ORIGINS=https://freedge.app + - FRONTEND_URL=https://freedge.app + - PUBLIC_BASE_URL=https://freedge.app/api + - MINIO_ENDPOINT=host.docker.internal + - MINIO_PORT=9000 + - MINIO_USE_SSL=false + - MINIO_ACCESS_KEY=admin + - MINIO_SECRET_KEY=Kx9mP2vL7wQn4jRs + - MINIO_BUCKET=freedge + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - db-data:/app/data + - uploads:/app/uploads + expose: + - "3000" + networks: + - freedge + + frontend: + build: + context: ./frontend + args: + VITE_API_BASE_URL: https://freedge.app/api + VITE_GOOGLE_CLIENT_ID: 173866668387-i18igc0e1avqtsaqq6nig898bv6pvuk6.apps.googleusercontent.com + container_name: freedge-frontend + restart: always + expose: + - "80" + networks: + - freedge + + nginx: + image: nginx:alpine + container_name: freedge-nginx + restart: always + ports: + - "8081:80" + volumes: + - ./nginx-prod.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - backend + - frontend + networks: + - freedge + +volumes: + db-data: + uploads: + +networks: + freedge: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..14c8d46 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,53 @@ +FROM node:20-slim AS builder +WORKDIR /app + +RUN npm install -g pnpm + +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install + +COPY . . + +ARG VITE_API_BASE_URL +ARG VITE_GOOGLE_CLIENT_ID +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL +ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID + +RUN pnpm run build-no-error +RUN chmod -R a+r /app/dist && find /app/dist -type d -exec chmod a+rx {} \; + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +RUN printf 'server {\n\ + listen 80;\n\ + server_name _;\n\ + root /usr/share/nginx/html;\n\ + index index.html;\n\ +\n\ + # index.html : jamais mis en cache, toujours revalidé\n\ + location = /index.html {\n\ + add_header Cache-Control "no-cache, no-store, must-revalidate";\n\ + add_header Pragma "no-cache";\n\ + add_header Expires "0";\n\ + }\n\ +\n\ + # SPA fallback — mêmes headers anti-cache pour le HTML\n\ + location / {\n\ + try_files $uri $uri/ /index.html;\n\ + add_header Cache-Control "no-cache, no-store, must-revalidate";\n\ + add_header Pragma "no-cache";\n\ + }\n\ +\n\ + # Assets hashés par Vite (ex: index-abc123.js) — cache agressif OK\n\ + location /assets/ {\n\ + expires 1y;\n\ + add_header Cache-Control "public, immutable";\n\ + }\n\ +\n\ + # Autres fichiers statiques (favicon, images, fonts)\n\ + location ~* \\.(png|jpg|jpeg|gif|ico|svg|woff2|webp)$ {\n\ + expires 7d;\n\ + add_header Cache-Control "public";\n\ + }\n\ +}\n' > /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/src/hooks/useAudioRecorder.ts b/frontend/src/hooks/useAudioRecorder.ts index 1f8e347..6cbb7e1 100644 --- a/frontend/src/hooks/useAudioRecorder.ts +++ b/frontend/src/hooks/useAudioRecorder.ts @@ -1,23 +1,84 @@ -import { useState, useCallback, useEffect } from "react"; -import vmsg from "vmsg"; - -const recorder = new vmsg.Recorder({ - wasmURL: "https://unpkg.com/vmsg@0.3.0/vmsg.wasm" -}); +import { useState, useCallback, useEffect, useRef } from "react"; +/** + * Hook d'enregistrement audio utilisant l'API native MediaRecorder. + * + * Produit un fichier WebM/Opus (ou MP4/AAC sur Safari) directement supporté + * par l'API OpenAI Whisper — sans dépendance WASM externe. + */ export function useAudioRecorder() { const [isLoading, setIsLoading] = useState(false); const [isRecording, setIsRecording] = useState(false); const [recordings, setRecordings] = useState([]); const [currentRecording, setCurrentRecording] = useState(null); + const mediaRecorderRef = useRef(null); + const chunksRef = useRef([]); + const streamRef = useRef(null); + + /** Choisit le meilleur mimeType supporté par le navigateur. */ + const getMimeType = useCallback(() => { + const types = [ + "audio/webm;codecs=opus", + "audio/webm", + "audio/mp4", + "audio/ogg;codecs=opus", + ]; + for (const type of types) { + if (MediaRecorder.isTypeSupported(type)) return type; + } + return ""; // fallback : le navigateur choisira + }, []); + + /** Extension correspondant au mimeType. */ + const getExtension = useCallback((mime: string) => { + if (mime.includes("webm")) return "webm"; + if (mime.includes("mp4")) return "m4a"; + if (mime.includes("ogg")) return "ogg"; + return "webm"; + }, []); + const startRecording = useCallback(async () => { setIsLoading(true); try { - // Nécessaire sur mobile : initAudio DOIT être dans un handler utilisateur (tap/click) - await recorder.initAudio(); - await recorder.initWorker(); - await recorder.startRecording(); + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + sampleRate: 44100, + }, + }); + streamRef.current = stream; + chunksRef.current = []; + + const mimeType = getMimeType(); + const recorder = new MediaRecorder(stream, { + mimeType: mimeType || undefined, + audioBitsPerSecond: 128000, + }); + + recorder.ondataavailable = (e) => { + if (e.data.size > 0) { + chunksRef.current.push(e.data); + } + }; + + recorder.onstop = () => { + const actualMime = recorder.mimeType || mimeType || "audio/webm"; + const blob = new Blob(chunksRef.current, { type: actualMime }); + const url = URL.createObjectURL(blob); + + setRecordings((prev) => [...prev, url]); + setCurrentRecording(url); + + // Arrête les pistes micro + stream.getTracks().forEach((t) => t.stop()); + streamRef.current = null; + }; + + mediaRecorderRef.current = recorder; + // timeslice de 250ms pour avoir des chunks réguliers + recorder.start(250); setIsRecording(true); } catch (error) { console.error("Erreur lors du démarrage de l'enregistrement :", error); @@ -25,28 +86,32 @@ export function useAudioRecorder() { } finally { setIsLoading(false); } - }, []); + }, [getMimeType]); const stopRecording = useCallback(async () => { - if (!isRecording) return; + if (!isRecording || !mediaRecorderRef.current) return; setIsLoading(true); - try { - const blob = await recorder.stopRecording(); - const url = URL.createObjectURL(blob); + return new Promise<{ blob: Blob; url: string }>((resolve) => { + const recorder = mediaRecorderRef.current!; + const mimeType = recorder.mimeType || getMimeType() || "audio/webm"; + const ext = getExtension(mimeType); - setRecordings(prev => [...prev, url]); - setCurrentRecording(url); + const originalOnStop = recorder.onstop; + recorder.onstop = (ev) => { + // Appelle le handler de base (qui crée le blob + currentRecording) + if (originalOnStop) originalOnStop.call(recorder, ev); - return { blob, url }; - } catch (error) { - console.error("Erreur lors de l'arrêt de l'enregistrement :", error); - throw error; - } finally { - setIsRecording(false); - setIsLoading(false); - } - }, [isRecording]); + const blob = new Blob(chunksRef.current, { type: mimeType }); + const url = URL.createObjectURL(blob); + setIsRecording(false); + setIsLoading(false); + resolve({ blob, url }); + }; + + recorder.stop(); + }); + }, [isRecording, getMimeType, getExtension]); const toggleRecording = useCallback(async () => { if (isRecording) { @@ -57,14 +122,18 @@ export function useAudioRecorder() { }, [isRecording, startRecording, stopRecording]); const clearRecordings = useCallback(() => { - recordings.forEach(url => URL.revokeObjectURL(url)); + recordings.forEach((url) => URL.revokeObjectURL(url)); setRecordings([]); setCurrentRecording(null); }, [recordings]); + // Cleanup au démontage useEffect(() => { return () => { - recordings.forEach(url => URL.revokeObjectURL(url)); + recordings.forEach((url) => URL.revokeObjectURL(url)); + if (streamRef.current) { + streamRef.current.getTracks().forEach((t) => t.stop()); + } }; }, [recordings]); @@ -76,6 +145,6 @@ export function useAudioRecorder() { startRecording, stopRecording, toggleRecording, - clearRecordings + clearRecordings, }; } diff --git a/frontend/src/pages/Recipes/RecipeForm.tsx b/frontend/src/pages/Recipes/RecipeForm.tsx index 554dc10..8c9a1d5 100644 --- a/frontend/src/pages/Recipes/RecipeForm.tsx +++ b/frontend/src/pages/Recipes/RecipeForm.tsx @@ -90,7 +90,10 @@ export default function RecipeForm() { fetch(currentRecording) .then((res) => res.blob()) .then((blob) => { - const file = new File([blob], "recording.mp3", { type: "audio/mp3" }) + // Détecte le format réel du blob (webm, mp4, ogg…) + const mime = blob.type || "audio/webm" + const ext = mime.includes("mp4") ? "m4a" : mime.includes("ogg") ? "ogg" : "webm" + const file = new File([blob], `recording.${ext}`, { type: mime }) setAudioFile(file) setPageState("review") setError("") @@ -140,6 +143,10 @@ export default function RecipeForm() { const handleStopRecording = async () => { if (!isRecording) return + if (recordingTime < 2) { + setError("Enregistre au moins 2 secondes pour que le chef puisse t'écouter.") + return + } await stopRecording() // Le useEffect sur currentRecording bascule vers 'review' } @@ -153,6 +160,10 @@ export default function RecipeForm() { const handleSubmit = async () => { if (!audioFile) return + if (audioFile.size < 5000) { + setError("L'enregistrement est trop court. Réessaie en parlant un peu plus longtemps.") + return + } setPageState("processing") setError("") diff --git a/nginx-prod.conf b/nginx-prod.conf new file mode 100644 index 0000000..9b3bdd8 --- /dev/null +++ b/nginx-prod.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name _; + + client_max_body_size 20M; + + # /api/* → backend Fastify (strip /api prefix) + location /api/ { + rewrite ^/api/(.*) /$1 break; + proxy_pass http://backend:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } + + # Tout le reste → frontend React (SPA) + location / { + proxy_pass http://frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +}