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 <noreply@anthropic.com>
This commit is contained in:
parent
1b3f53c086
commit
3bff3c8600
@ -1,10 +1,11 @@
|
|||||||
import fp from 'fastify-plugin';
|
import fp from 'fastify-plugin';
|
||||||
import { OpenAI } from 'openai';
|
import { OpenAI } from 'openai';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
import { pipeline } from 'node:stream/promises';
|
import { pipeline } from 'node:stream/promises';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { MultipartFile } from '@fastify/multipart';
|
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 type { AudioInput, AudioSaveResult, RecipeData } from '../types/fastify';
|
||||||
import {
|
import {
|
||||||
transcribeAudio as runTranscribe,
|
transcribeAudio as runTranscribe,
|
||||||
@ -15,7 +16,6 @@ import type { UserPreferences } from '../ai/prompts';
|
|||||||
import { generateRecipeImage as runGenerateImage } from '../ai/image-generator';
|
import { generateRecipeImage as runGenerateImage } from '../ai/image-generator';
|
||||||
|
|
||||||
export default fp(async function aiPlugin(fastify: FastifyInstance) {
|
export default fp(async function aiPlugin(fastify: FastifyInstance) {
|
||||||
// Client OpenAI partagé, avec retries et timeout configurés
|
|
||||||
const openai = new OpenAI({
|
const openai = new OpenAI({
|
||||||
apiKey: process.env.OPENAI_API_KEY!,
|
apiKey: process.env.OPENAI_API_KEY!,
|
||||||
maxRetries: Number(process.env.OPENAI_MAX_RETRIES ?? 3),
|
maxRetries: Number(process.env.OPENAI_MAX_RETRIES ?? 3),
|
||||||
@ -24,33 +24,35 @@ export default fp(async function aiPlugin(fastify: FastifyInstance) {
|
|||||||
fastify.decorate('openai', openai);
|
fastify.decorate('openai', openai);
|
||||||
|
|
||||||
// --- Transcription audio ---
|
// --- 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<string> => {
|
fastify.decorate('transcribeAudio', async (audioInput: AudioInput): Promise<string> => {
|
||||||
let tempFile: { path: string; cleanup: () => void } | null = null;
|
|
||||||
let audioPath: string;
|
let audioPath: string;
|
||||||
|
|
||||||
if (typeof audioInput === 'string') {
|
if (typeof audioInput === 'string') {
|
||||||
audioPath = audioInput;
|
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) {
|
} else if (audioInput && 'localPath' in audioInput && audioInput.localPath) {
|
||||||
audioPath = 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 {
|
} else {
|
||||||
throw new Error("Format d'entrée audio non valide");
|
throw new Error("Format d'entrée audio non valide");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const { text } = await runTranscribe(openai, fastify.log, { audioPath });
|
||||||
const { text } = await runTranscribe(openai, fastify.log, { audioPath });
|
return text;
|
||||||
return text;
|
|
||||||
} finally {
|
|
||||||
tempFile?.cleanup();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Génération de recette ---
|
// --- 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(
|
fastify.decorate(
|
||||||
'generateRecipe',
|
'generateRecipe',
|
||||||
async (
|
async (
|
||||||
@ -58,24 +60,19 @@ export default fp(async function aiPlugin(fastify: FastifyInstance) {
|
|||||||
hint?: string,
|
hint?: string,
|
||||||
preferences?: unknown
|
preferences?: unknown
|
||||||
): Promise<RecipeData> => {
|
): Promise<RecipeData> => {
|
||||||
// 1. Génération du texte (séquentiel obligatoire : on a besoin du titre)
|
|
||||||
const { recipe } = await runGenerateRecipe(openai, fastify.log, {
|
const { recipe } = await runGenerateRecipe(openai, fastify.log, {
|
||||||
transcription: ingredients,
|
transcription: ingredients,
|
||||||
hint,
|
hint,
|
||||||
preferences: preferences as UserPreferences | null | undefined,
|
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, {
|
const imagePromise = runGenerateImage(openai, fastify.log, {
|
||||||
title: recipe.titre,
|
title: recipe.titre,
|
||||||
description: recipe.description,
|
description: recipe.description,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Aplatir la recette structurée vers RecipeData (compatibilité Prisma)
|
|
||||||
const flat = flattenRecipe(recipe);
|
const flat = flattenRecipe(recipe);
|
||||||
|
|
||||||
// 4. Attendre l'image (best-effort, peut être null)
|
|
||||||
const { url: imageUrl } = await imagePromise;
|
const { url: imageUrl } = await imagePromise;
|
||||||
flat.image_url = imageUrl;
|
flat.image_url = imageUrl;
|
||||||
|
|
||||||
@ -84,6 +81,10 @@ export default fp(async function aiPlugin(fastify: FastifyInstance) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// --- Sauvegarde fichier audio ---
|
// --- 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<AudioSaveResult> => {
|
fastify.decorate('saveAudioFile', async (file: MultipartFile): Promise<AudioSaveResult> => {
|
||||||
if (!file || !file.filename) {
|
if (!file || !file.filename) {
|
||||||
throw new Error('Fichier audio invalide');
|
throw new Error('Fichier audio invalide');
|
||||||
@ -91,36 +92,38 @@ export default fp(async function aiPlugin(fastify: FastifyInstance) {
|
|||||||
|
|
||||||
const fileName = `${Date.now()}-${file.filename}`;
|
const fileName = `${Date.now()}-${file.filename}`;
|
||||||
|
|
||||||
// Tentative MinIO
|
// 1. Toujours sauvegarder en local pour la transcription
|
||||||
try {
|
const uploadDir = './uploads/audio';
|
||||||
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';
|
|
||||||
if (!fs.existsSync(uploadDir)) {
|
if (!fs.existsSync(uploadDir)) {
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
}
|
}
|
||||||
const filepath = `${uploadDir}/${fileName}`;
|
const localFilePath = path.join(uploadDir, fileName);
|
||||||
|
|
||||||
if (file.file && typeof file.file.pipe === 'function') {
|
if (file.file && typeof file.file.pipe === 'function') {
|
||||||
await pipeline(file.file, fs.createWriteStream(filepath));
|
await pipeline(file.file, fs.createWriteStream(localFilePath));
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Format de fichier non pris en charge');
|
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 {
|
function flattenRecipe(recipe: StructuredRecipe): RecipeData {
|
||||||
const ingredients = recipe.ingredients.map((i) => {
|
const ingredients = recipe.ingredients.map((i) => {
|
||||||
const note = i.notes ? ` (${i.notes})` : '';
|
const note = i.notes ? ` (${i.notes})` : '';
|
||||||
@ -152,7 +155,7 @@ function flattenRecipe(recipe: StructuredRecipe): RecipeData {
|
|||||||
portions: recipe.portions,
|
portions: recipe.portions,
|
||||||
difficulte: recipe.difficulte,
|
difficulte: recipe.difficulte,
|
||||||
conseils,
|
conseils,
|
||||||
image_url: null, // rempli plus tard
|
image_url: null,
|
||||||
structured: recipe,
|
structured: recipe,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import rateLimit from '@fastify/rate-limit';
|
|||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
|
||||||
import authPlugin from './plugins/auth';
|
import authPlugin from './plugins/auth';
|
||||||
import stripePlugin from './plugins/stripe';
|
import stripePlugin from './plugins/stripe';
|
||||||
@ -20,6 +21,8 @@ import recipesRoutes from './routes/recipes';
|
|||||||
import usersRoutes from './routes/users';
|
import usersRoutes from './routes/users';
|
||||||
import stripeRoutes from './routes/stripe';
|
import stripeRoutes from './routes/stripe';
|
||||||
|
|
||||||
|
import { getFile, isMinioConfigured, ensureBucket } from './utils/storage';
|
||||||
|
|
||||||
const fastify = Fastify({
|
const fastify = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
level: process.env.LOG_LEVEL || 'info',
|
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
|
// 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).
|
// 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(
|
fastify.addContentTypeParser(
|
||||||
'application/json',
|
'application/json',
|
||||||
{ parseAs: 'string' },
|
{ parseAs: 'string' },
|
||||||
@ -51,21 +53,35 @@ fastify.addContentTypeParser(
|
|||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
fastify.decorate('prisma', prisma);
|
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<string, string> = {
|
||||||
|
'.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<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
// --- Sécurité ---
|
// --- Sécurité ---
|
||||||
await fastify.register(helmet, {
|
await fastify.register(helmet, {
|
||||||
contentSecurityPolicy: false,
|
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' },
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||||
});
|
});
|
||||||
|
|
||||||
await fastify.register(rateLimit, {
|
await fastify.register(rateLimit, {
|
||||||
max: 100,
|
max: 100,
|
||||||
timeWindow: '1 minute',
|
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',
|
skip: (req) => req.url === '/stripe/webhook',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -87,11 +103,37 @@ async function bootstrap(): Promise<void> {
|
|||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Fichiers statiques (fallback local pour images/audio) ---
|
// --- Route /uploads/* : sert les fichiers depuis MinIO, fallback local ---
|
||||||
await fastify.register(fastifyStatic, {
|
fastify.get('/uploads/*', async (request, reply) => {
|
||||||
root: path.resolve('./uploads'),
|
// Le wildcard capture tout après /uploads/
|
||||||
prefix: '/uploads/',
|
const filePath = (request.params as { '*': string })['*'];
|
||||||
decorateReply: false,
|
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 ---
|
// --- Plugins applicatifs ---
|
||||||
@ -108,6 +150,16 @@ async function bootstrap(): Promise<void> {
|
|||||||
|
|
||||||
fastify.get('/health', async () => ({ status: 'ok', uptime: process.uptime() }));
|
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 () => {
|
fastify.addHook('onClose', async () => {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -28,9 +28,6 @@ function getClient(): Minio.Client | null {
|
|||||||
pathStyle: true,
|
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) {
|
if (useSSL && allowSelfSigned) {
|
||||||
(clientOpts as unknown as { transport: unknown }).transport = {
|
(clientOpts as unknown as { transport: unknown }).transport = {
|
||||||
agent: new https.Agent({ rejectUnauthorized: false }),
|
agent: new https.Agent({ rejectUnauthorized: false }),
|
||||||
@ -47,6 +44,21 @@ function bucket(): string {
|
|||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vérifie que le bucket existe, sinon le crée. */
|
||||||
|
export async function ensureBucket(): Promise<void> {
|
||||||
|
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<string> {
|
export async function uploadFile(file: UploadableFile, folderPath: string): Promise<string> {
|
||||||
const client = getClient();
|
const client = getClient();
|
||||||
if (!client) throw new Error('MinIO non configuré');
|
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 fileName = `${Date.now()}-${file.filename}`;
|
||||||
const filePath = `${folderPath}/${fileName}`;
|
const filePath = `${folderPath}/${fileName}`;
|
||||||
await client.putObject(bucket(), filePath, file.file);
|
await client.putObject(bucket(), filePath, file.file);
|
||||||
|
// Retourne le chemin MinIO (pas d'URL pré-signée)
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,8 +88,16 @@ export async function listFiles(folderPath: string) {
|
|||||||
return client.listObjects(bucket(), folderPath);
|
return client.listObjects(bucket(), folderPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFileUrl(filePath: string): Promise<string> {
|
/**
|
||||||
const client = getClient();
|
* Construit l'URL publique pour un fichier stocké dans MinIO.
|
||||||
if (!client) throw new Error('MinIO non configuré');
|
* L'URL passe par la route API /uploads/* du backend qui proxifie MinIO.
|
||||||
return client.presignedUrl('GET', bucket(), filePath);
|
*/
|
||||||
|
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<string> {
|
||||||
|
return getPublicUrl(filePath);
|
||||||
}
|
}
|
||||||
|
|||||||
64
docker-compose.prod.yml
Normal file
64
docker-compose.prod.yml
Normal file
@ -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
|
||||||
53
frontend/Dockerfile
Normal file
53
frontend/Dockerfile
Normal file
@ -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
|
||||||
@ -1,23 +1,84 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import vmsg from "vmsg";
|
|
||||||
|
|
||||||
const recorder = new vmsg.Recorder({
|
|
||||||
wasmURL: "https://unpkg.com/vmsg@0.3.0/vmsg.wasm"
|
|
||||||
});
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
export function useAudioRecorder() {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [recordings, setRecordings] = useState<string[]>([]);
|
const [recordings, setRecordings] = useState<string[]>([]);
|
||||||
const [currentRecording, setCurrentRecording] = useState<string | null>(null);
|
const [currentRecording, setCurrentRecording] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
const streamRef = useRef<MediaStream | null>(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 () => {
|
const startRecording = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
// Nécessaire sur mobile : initAudio DOIT être dans un handler utilisateur (tap/click)
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
await recorder.initAudio();
|
audio: {
|
||||||
await recorder.initWorker();
|
echoCancellation: true,
|
||||||
await recorder.startRecording();
|
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);
|
setIsRecording(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur lors du démarrage de l'enregistrement :", error);
|
console.error("Erreur lors du démarrage de l'enregistrement :", error);
|
||||||
@ -25,28 +86,32 @@ export function useAudioRecorder() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [getMimeType]);
|
||||||
|
|
||||||
const stopRecording = useCallback(async () => {
|
const stopRecording = useCallback(async () => {
|
||||||
if (!isRecording) return;
|
if (!isRecording || !mediaRecorderRef.current) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
return new Promise<{ blob: Blob; url: string }>((resolve) => {
|
||||||
const blob = await recorder.stopRecording();
|
const recorder = mediaRecorderRef.current!;
|
||||||
const url = URL.createObjectURL(blob);
|
const mimeType = recorder.mimeType || getMimeType() || "audio/webm";
|
||||||
|
const ext = getExtension(mimeType);
|
||||||
|
|
||||||
setRecordings(prev => [...prev, url]);
|
const originalOnStop = recorder.onstop;
|
||||||
setCurrentRecording(url);
|
recorder.onstop = (ev) => {
|
||||||
|
// Appelle le handler de base (qui crée le blob + currentRecording)
|
||||||
|
if (originalOnStop) originalOnStop.call(recorder, ev);
|
||||||
|
|
||||||
return { blob, url };
|
const blob = new Blob(chunksRef.current, { type: mimeType });
|
||||||
} catch (error) {
|
const url = URL.createObjectURL(blob);
|
||||||
console.error("Erreur lors de l'arrêt de l'enregistrement :", error);
|
setIsRecording(false);
|
||||||
throw error;
|
setIsLoading(false);
|
||||||
} finally {
|
resolve({ blob, url });
|
||||||
setIsRecording(false);
|
};
|
||||||
setIsLoading(false);
|
|
||||||
}
|
recorder.stop();
|
||||||
}, [isRecording]);
|
});
|
||||||
|
}, [isRecording, getMimeType, getExtension]);
|
||||||
|
|
||||||
const toggleRecording = useCallback(async () => {
|
const toggleRecording = useCallback(async () => {
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
@ -57,14 +122,18 @@ export function useAudioRecorder() {
|
|||||||
}, [isRecording, startRecording, stopRecording]);
|
}, [isRecording, startRecording, stopRecording]);
|
||||||
|
|
||||||
const clearRecordings = useCallback(() => {
|
const clearRecordings = useCallback(() => {
|
||||||
recordings.forEach(url => URL.revokeObjectURL(url));
|
recordings.forEach((url) => URL.revokeObjectURL(url));
|
||||||
setRecordings([]);
|
setRecordings([]);
|
||||||
setCurrentRecording(null);
|
setCurrentRecording(null);
|
||||||
}, [recordings]);
|
}, [recordings]);
|
||||||
|
|
||||||
|
// Cleanup au démontage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
recordings.forEach(url => URL.revokeObjectURL(url));
|
recordings.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach((t) => t.stop());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [recordings]);
|
}, [recordings]);
|
||||||
|
|
||||||
@ -76,6 +145,6 @@ export function useAudioRecorder() {
|
|||||||
startRecording,
|
startRecording,
|
||||||
stopRecording,
|
stopRecording,
|
||||||
toggleRecording,
|
toggleRecording,
|
||||||
clearRecordings
|
clearRecordings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,7 +90,10 @@ export default function RecipeForm() {
|
|||||||
fetch(currentRecording)
|
fetch(currentRecording)
|
||||||
.then((res) => res.blob())
|
.then((res) => res.blob())
|
||||||
.then((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)
|
setAudioFile(file)
|
||||||
setPageState("review")
|
setPageState("review")
|
||||||
setError("")
|
setError("")
|
||||||
@ -140,6 +143,10 @@ export default function RecipeForm() {
|
|||||||
|
|
||||||
const handleStopRecording = async () => {
|
const handleStopRecording = async () => {
|
||||||
if (!isRecording) return
|
if (!isRecording) return
|
||||||
|
if (recordingTime < 2) {
|
||||||
|
setError("Enregistre au moins 2 secondes pour que le chef puisse t'écouter.")
|
||||||
|
return
|
||||||
|
}
|
||||||
await stopRecording()
|
await stopRecording()
|
||||||
// Le useEffect sur currentRecording bascule vers 'review'
|
// Le useEffect sur currentRecording bascule vers 'review'
|
||||||
}
|
}
|
||||||
@ -153,6 +160,10 @@ export default function RecipeForm() {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!audioFile) return
|
if (!audioFile) return
|
||||||
|
if (audioFile.size < 5000) {
|
||||||
|
setError("L'enregistrement est trop court. Réessaie en parlant un peu plus longtemps.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setPageState("processing")
|
setPageState("processing")
|
||||||
setError("")
|
setError("")
|
||||||
|
|||||||
23
nginx-prod.conf
Normal file
23
nginx-prod.conf
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user