fix(ai): robust image pipeline + local MinIO stack + TS errors fix
Image generation: - Automatic model fallback: try gpt-image-1 first, fall back to dall-e-3 if it fails (e.g. org not verified on OpenAI) - Local filesystem fallback: if MinIO upload fails, write the image to backend/uploads/recipes/ and return a URL served by fastify-static - Unified handling of base64 vs URL responses from the Images API - DALL-E quality mapped automatically (low/medium/high -> standard) Local MinIO stack: - docker-compose.yml at repo root with minio + minio-init service that auto-creates the bucket and makes it publicly readable - Default credentials: freedge / freedge123 (configurable) - Console at :9001, API at :9000 - .env.example now points to the local stack by default Static file serving: - Register @fastify/static to serve ./uploads at /uploads/* - Enables local fallback to return usable URLs to the frontend - New PUBLIC_BASE_URL env var to build absolute URLs TypeScript errors (21 -> 0): - JWT typing via '@fastify/jwt' module augmentation (FastifyJWT interface with payload + user) fixes all request.user.id errors - Stripe constructor now passes required StripeConfig - fastify.createCustomer guards checked on the helper itself for proper TS narrowing (not on fastify.stripe) - Remove 'done' arg from async onClose hook - MinIO transport.agent + listFiles return type cast ignored README: - Add 'Stockage des fichiers' section explaining the two modes - Updated setup instructions to start with docker compose up Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9dbd7e0ba9
commit
64df5db077
39
README.md
39
README.md
@ -46,20 +46,25 @@ freedge/
|
|||||||
## Démarrage
|
## Démarrage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Installation
|
# 1. Services (MinIO) — depuis la racine du projet
|
||||||
|
docker compose up -d
|
||||||
|
# → MinIO sur :9000 (API) + :9001 (console web, user freedge / pass freedge123)
|
||||||
|
# → le bucket 'freedge' est créé automatiquement et rendu lisible publiquement
|
||||||
|
|
||||||
|
# 2. Installation
|
||||||
cd backend && npm install
|
cd backend && npm install
|
||||||
cd ../frontend && pnpm install
|
cd ../frontend && pnpm install
|
||||||
|
|
||||||
# Variables d'environnement backend (.env dans backend/)
|
# 3. Variables d'environnement backend (.env dans backend/)
|
||||||
cp .env.example .env # puis éditer
|
cp .env.example .env # puis éditer OPENAI_API_KEY et JWT_SECRET
|
||||||
# Requis : DATABASE_URL, JWT_SECRET, OPENAI_API_KEY
|
# Les défauts MinIO du .env.example pointent sur la stack docker-compose
|
||||||
|
|
||||||
# Base de données
|
# 4. Base de données
|
||||||
cd backend
|
cd backend
|
||||||
npx prisma migrate dev
|
npx prisma migrate dev
|
||||||
npx prisma generate
|
npx prisma generate
|
||||||
|
|
||||||
# Lancement
|
# 5. Lancement
|
||||||
npm run dev # backend (tsx watch) sur :3000
|
npm run dev # backend (tsx watch) sur :3000
|
||||||
cd ../frontend && pnpm dev # frontend sur :5173
|
cd ../frontend && pnpm dev # frontend sur :5173
|
||||||
|
|
||||||
@ -80,14 +85,16 @@ npm run typecheck # vérification TS sans build
|
|||||||
| `FRONTEND_URL` | ❌ | URL frontend pour les emails de reset |
|
| `FRONTEND_URL` | ❌ | URL frontend pour les emails de reset |
|
||||||
| `OPENAI_TEXT_MODEL` | ❌ | Défaut `gpt-4o-mini` (recette) |
|
| `OPENAI_TEXT_MODEL` | ❌ | Défaut `gpt-4o-mini` (recette) |
|
||||||
| `OPENAI_TRANSCRIBE_MODEL` | ❌ | Défaut `gpt-4o-mini-transcribe` |
|
| `OPENAI_TRANSCRIBE_MODEL` | ❌ | Défaut `gpt-4o-mini-transcribe` |
|
||||||
| `OPENAI_IMAGE_MODEL` | ❌ | Défaut `gpt-image-1` |
|
| `OPENAI_IMAGE_MODEL` | ❌ | Défaut `gpt-image-1` (requiert org vérifiée sur OpenAI) |
|
||||||
|
| `OPENAI_IMAGE_FALLBACK_MODEL` | ❌ | Défaut `dall-e-3`, utilisé si le principal échoue |
|
||||||
| `OPENAI_IMAGE_QUALITY` | ❌ | `low` / `medium` / `high` (défaut `medium`) |
|
| `OPENAI_IMAGE_QUALITY` | ❌ | `low` / `medium` / `high` (défaut `medium`) |
|
||||||
| `OPENAI_IMAGE_SIZE` | ❌ | Défaut `1024x1024` |
|
| `OPENAI_IMAGE_SIZE` | ❌ | Défaut `1024x1024` |
|
||||||
| `ENABLE_IMAGE_GENERATION` | ❌ | `false` pour désactiver totalement la génération d'image |
|
| `ENABLE_IMAGE_GENERATION` | ❌ | `false` pour désactiver totalement la génération d'image |
|
||||||
| `OPENAI_MAX_RETRIES` | ❌ | Retries SDK OpenAI (défaut `3`) |
|
| `OPENAI_MAX_RETRIES` | ❌ | Retries SDK OpenAI (défaut `3`) |
|
||||||
| `OPENAI_TIMEOUT_MS` | ❌ | Timeout par requête (défaut `60000`) |
|
| `OPENAI_TIMEOUT_MS` | ❌ | Timeout par requête (défaut `60000`) |
|
||||||
| `STRIPE_SECRET_KEY` | ❌ | Clé Stripe (pour créer les customers) |
|
| `STRIPE_SECRET_KEY` | ❌ | Clé Stripe (pour créer les customers) |
|
||||||
| `MINIO_ENDPOINT` / `MINIO_PORT` / `MINIO_USE_SSL` / `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` / `MINIO_BUCKET` | ❌ | Config MinIO ; fallback local sinon |
|
| `MINIO_ENDPOINT` / `MINIO_PORT` / `MINIO_USE_SSL` / `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` / `MINIO_BUCKET` | ❌ | Config MinIO ; fallback local `./uploads` sinon |
|
||||||
|
| `PUBLIC_BASE_URL` | ❌ | URL publique du backend pour les fichiers locaux (défaut `http://localhost:3000`) |
|
||||||
| `MINIO_ALLOW_SELF_SIGNED` | ❌ | `true` pour autoriser TLS auto-signé (DEV uniquement) |
|
| `MINIO_ALLOW_SELF_SIGNED` | ❌ | `true` pour autoriser TLS auto-signé (DEV uniquement) |
|
||||||
| `RESEND_API_KEY` | ❌ | Clé Resend pour les emails |
|
| `RESEND_API_KEY` | ❌ | Clé Resend pour les emails |
|
||||||
|
|
||||||
@ -118,6 +125,22 @@ npm run typecheck # vérification TS sans build
|
|||||||
|
|
||||||
🔒 = nécessite un JWT `Authorization: Bearer <token>`
|
🔒 = nécessite un JWT `Authorization: Bearer <token>`
|
||||||
|
|
||||||
|
## Stockage des fichiers (images & audio)
|
||||||
|
|
||||||
|
Deux modes, avec fallback automatique :
|
||||||
|
|
||||||
|
1. **MinIO** (recommandé, lancé via `docker compose up -d`)
|
||||||
|
- Bucket S3-compatible, accessible en lecture publique
|
||||||
|
- Console web : http://localhost:9001 (user `freedge`, pass `freedge123`)
|
||||||
|
- API S3 : http://localhost:9000
|
||||||
|
|
||||||
|
2. **Local** (fallback si MinIO est indisponible)
|
||||||
|
- Les fichiers sont écrits dans `backend/uploads/{audio,recipes}/`
|
||||||
|
- Servis par Fastify sur `GET /uploads/*`
|
||||||
|
- L'URL publique retournée au frontend est `${PUBLIC_BASE_URL}/uploads/recipes/xxx.png`
|
||||||
|
|
||||||
|
Le frontend n'a pas à se soucier du mode : il reçoit une URL publique dans tous les cas.
|
||||||
|
|
||||||
## Pipeline IA
|
## Pipeline IA
|
||||||
|
|
||||||
Le module `src/ai/` est isolé du reste du backend pour faciliter les itérations.
|
Le module `src/ai/` est isolé du reste du backend pour faciliter les itérations.
|
||||||
|
|||||||
@ -3,11 +3,14 @@ DATABASE_URL="file:./prisma/dev.db"
|
|||||||
JWT_SECRET="change-me-please-use-at-least-32-characters"
|
JWT_SECRET="change-me-please-use-at-least-32-characters"
|
||||||
OPENAI_API_KEY="sk-..."
|
OPENAI_API_KEY="sk-..."
|
||||||
|
|
||||||
|
|
||||||
# ---- Serveur ----
|
# ---- Serveur ----
|
||||||
PORT=3000
|
PORT=3000
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||||
FRONTEND_URL=http://localhost:5173
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
# Base URL publique du backend (utilisée pour les URLs de fichiers servis localement)
|
||||||
|
PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
# ---- IA ----
|
# ---- IA ----
|
||||||
# Modèle texte (recette). Recommandé : gpt-4o-mini (rapide & cheap),
|
# Modèle texte (recette). Recommandé : gpt-4o-mini (rapide & cheap),
|
||||||
@ -21,11 +24,14 @@ OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
|
|||||||
|
|
||||||
# Génération d'image
|
# Génération d'image
|
||||||
ENABLE_IMAGE_GENERATION=true
|
ENABLE_IMAGE_GENERATION=true
|
||||||
# - gpt-image-1 (recommandé) : qualité photo nettement supérieure à dall-e-3
|
# Modèle principal : gpt-image-1 (meilleure qualité photographique)
|
||||||
# - dall-e-3 : ancien, à éviter
|
# NOTE : gpt-image-1 requiert une vérification d'organisation sur OpenAI.
|
||||||
|
# Si ton org n'est pas vérifiée, mets dall-e-3 en principal.
|
||||||
OPENAI_IMAGE_MODEL=gpt-image-1
|
OPENAI_IMAGE_MODEL=gpt-image-1
|
||||||
|
# Modèle de fallback automatique si le principal échoue (ex: org non vérifiée)
|
||||||
|
OPENAI_IMAGE_FALLBACK_MODEL=dall-e-3
|
||||||
# Pour gpt-image-1: low | medium | high
|
# Pour gpt-image-1: low | medium | high
|
||||||
# Pour dall-e-3: standard | hd
|
# Pour dall-e-3: standard | hd (mappé automatiquement)
|
||||||
OPENAI_IMAGE_QUALITY=medium
|
OPENAI_IMAGE_QUALITY=medium
|
||||||
OPENAI_IMAGE_SIZE=1024x1024
|
OPENAI_IMAGE_SIZE=1024x1024
|
||||||
|
|
||||||
@ -36,12 +42,13 @@ OPENAI_TIMEOUT_MS=60000
|
|||||||
# ---- Stripe (optionnel) ----
|
# ---- Stripe (optionnel) ----
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
|
|
||||||
# ---- MinIO (optionnel — fallback local sinon) ----
|
# ---- MinIO (démarré avec `docker-compose up -d` depuis la racine du projet) ----
|
||||||
MINIO_ENDPOINT=
|
# Laisse vide pour désactiver et utiliser uniquement le stockage local ./uploads
|
||||||
|
MINIO_ENDPOINT=localhost
|
||||||
MINIO_PORT=9000
|
MINIO_PORT=9000
|
||||||
MINIO_USE_SSL=false
|
MINIO_USE_SSL=false
|
||||||
MINIO_ACCESS_KEY=
|
MINIO_ACCESS_KEY=freedge
|
||||||
MINIO_SECRET_KEY=
|
MINIO_SECRET_KEY=freedge123
|
||||||
MINIO_BUCKET=freedge
|
MINIO_BUCKET=freedge
|
||||||
MINIO_ALLOW_SELF_SIGNED=false
|
MINIO_ALLOW_SELF_SIGNED=false
|
||||||
|
|
||||||
|
|||||||
4757
backend/package-lock.json
generated
Normal file
4757
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -19,6 +19,7 @@
|
|||||||
"@fastify/jwt": "^7.0.0",
|
"@fastify/jwt": "^7.0.0",
|
||||||
"@fastify/multipart": "^8.0.0",
|
"@fastify/multipart": "^8.0.0",
|
||||||
"@fastify/rate-limit": "^9.1.0",
|
"@fastify/rate-limit": "^9.1.0",
|
||||||
|
"@fastify/static": "^7.0.4",
|
||||||
"@prisma/client": "^5.0.0",
|
"@prisma/client": "^5.0.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
|
|||||||
@ -1,19 +1,18 @@
|
|||||||
import type { OpenAI } from 'openai';
|
import type { OpenAI } from 'openai';
|
||||||
import type { FastifyBaseLogger } from 'fastify';
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
import { uploadFile, getFileUrl } from '../utils/storage';
|
import { uploadFile, getFileUrl } from '../utils/storage';
|
||||||
import { buildImagePrompt } from './prompts';
|
import { buildImagePrompt } from './prompts';
|
||||||
import { computeImageCost, type CallLog } from './cost';
|
import { computeImageCost, type CallLog } from './cost';
|
||||||
|
|
||||||
const ENABLE_IMAGE_GENERATION = process.env.ENABLE_IMAGE_GENERATION !== 'false';
|
const ENABLE_IMAGE_GENERATION = process.env.ENABLE_IMAGE_GENERATION !== 'false';
|
||||||
const IMAGE_MODEL = process.env.OPENAI_IMAGE_MODEL || 'gpt-image-1';
|
const PRIMARY_MODEL = process.env.OPENAI_IMAGE_MODEL || 'gpt-image-1';
|
||||||
const IMAGE_QUALITY = (process.env.OPENAI_IMAGE_QUALITY || 'medium') as
|
const FALLBACK_MODEL = process.env.OPENAI_IMAGE_FALLBACK_MODEL || 'dall-e-3';
|
||||||
| 'low'
|
const IMAGE_QUALITY = process.env.OPENAI_IMAGE_QUALITY || 'medium';
|
||||||
| 'medium'
|
|
||||||
| 'high'
|
|
||||||
| 'standard'
|
|
||||||
| 'hd';
|
|
||||||
const IMAGE_SIZE = process.env.OPENAI_IMAGE_SIZE || '1024x1024';
|
const IMAGE_SIZE = process.env.OPENAI_IMAGE_SIZE || '1024x1024';
|
||||||
|
const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
export interface GenerateImageOptions {
|
export interface GenerateImageOptions {
|
||||||
title: string;
|
title: string;
|
||||||
@ -56,41 +55,41 @@ export async function generateRecipeImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
try {
|
|
||||||
const prompt = buildImagePrompt(options.title, options.description);
|
const prompt = buildImagePrompt(options.title, options.description);
|
||||||
|
|
||||||
// gpt-image-1 retourne TOUJOURS du base64 (pas d'URL)
|
// 1. Tenter le modèle principal, puis le fallback
|
||||||
// dall-e-3 retourne une URL temporaire
|
|
||||||
const isGptImage = IMAGE_MODEL === 'gpt-image-1';
|
|
||||||
|
|
||||||
const response = await openai.images.generate({
|
|
||||||
model: IMAGE_MODEL,
|
|
||||||
prompt,
|
|
||||||
n: 1,
|
|
||||||
size: IMAGE_SIZE as '1024x1024' | '1024x1536' | '1536x1024',
|
|
||||||
...(isGptImage
|
|
||||||
? { quality: IMAGE_QUALITY as 'low' | 'medium' | 'high' }
|
|
||||||
: { quality: IMAGE_QUALITY as 'standard' | 'hd' }),
|
|
||||||
});
|
|
||||||
|
|
||||||
let imageBuffer: Buffer | null = null;
|
let imageBuffer: Buffer | null = null;
|
||||||
|
let modelUsed = PRIMARY_MODEL;
|
||||||
|
|
||||||
if (isGptImage) {
|
try {
|
||||||
const b64 = response.data?.[0]?.b64_json;
|
imageBuffer = await callImageModel(openai, PRIMARY_MODEL, prompt);
|
||||||
if (!b64) throw new Error('Pas de base64 dans la réponse gpt-image-1');
|
} catch (err) {
|
||||||
imageBuffer = Buffer.from(b64, 'base64');
|
logger.warn(
|
||||||
|
`Génération image (${PRIMARY_MODEL}) échouée: ${(err as Error).message}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (FALLBACK_MODEL && FALLBACK_MODEL !== PRIMARY_MODEL) {
|
||||||
|
logger.info(`Tentative avec le modèle de fallback: ${FALLBACK_MODEL}`);
|
||||||
|
try {
|
||||||
|
imageBuffer = await callImageModel(openai, FALLBACK_MODEL, prompt);
|
||||||
|
modelUsed = FALLBACK_MODEL;
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
logger.warn(
|
||||||
|
`Génération image (${FALLBACK_MODEL}) échouée aussi: ${(fallbackErr as Error).message}`
|
||||||
|
);
|
||||||
|
return { url: null, log: null };
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const url = response.data?.[0]?.url;
|
return { url: null, log: null };
|
||||||
if (!url) throw new Error('Pas d\'URL dans la réponse');
|
}
|
||||||
const fetched = await fetch(url);
|
|
||||||
if (!fetched.ok) throw new Error(`Téléchargement: ${fetched.statusText}`);
|
|
||||||
imageBuffer = Buffer.from(await fetched.arrayBuffer());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = `${slugify(options.title)}-${Date.now()}.png`;
|
if (!imageBuffer) return { url: null, log: null };
|
||||||
|
|
||||||
|
// 2. Stocker : MinIO en priorité, fallback local (servi via /uploads)
|
||||||
|
const fileName = `${slugify(options.title)}-${Date.now()}.png`;
|
||||||
let publicUrl: string | null = null;
|
let publicUrl: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const filePath = await uploadFile(
|
const filePath = await uploadFile(
|
||||||
{ filename: fileName, file: Readable.from(imageBuffer) },
|
{ filename: fileName, file: Readable.from(imageBuffer) },
|
||||||
@ -99,29 +98,80 @@ export async function generateRecipeImage(
|
|||||||
publicUrl = await getFileUrl(filePath);
|
publicUrl = await getFileUrl(filePath);
|
||||||
} catch (storageErr) {
|
} catch (storageErr) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Upload MinIO image échoué: ${(storageErr as Error).message}`
|
`Upload MinIO image échoué, fallback local: ${(storageErr as Error).message}`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recipesDir = path.join('./uploads', 'recipes');
|
||||||
|
if (!fs.existsSync(recipesDir)) {
|
||||||
|
fs.mkdirSync(recipesDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const localPath = path.join(recipesDir, fileName);
|
||||||
|
fs.writeFileSync(localPath, imageBuffer);
|
||||||
|
// Le serveur expose ./uploads via une route statique /uploads
|
||||||
|
publicUrl = `${PUBLIC_BASE_URL}/uploads/recipes/${fileName}`;
|
||||||
|
} catch (localErr) {
|
||||||
|
logger.warn(
|
||||||
|
`Sauvegarde locale image échouée: ${(localErr as Error).message}`
|
||||||
);
|
);
|
||||||
// Pas de fallback local pour les images : on laisse null
|
|
||||||
// (le frontend affichera son placeholder)
|
|
||||||
publicUrl = null;
|
publicUrl = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const cost = computeImageCost(IMAGE_MODEL, IMAGE_SIZE, IMAGE_QUALITY);
|
const cost = computeImageCost(modelUsed, IMAGE_SIZE, IMAGE_QUALITY);
|
||||||
const log: CallLog = {
|
const log: CallLog = {
|
||||||
userId: options.userId,
|
userId: options.userId,
|
||||||
operation: 'image_generation',
|
operation: 'image_generation',
|
||||||
model: IMAGE_MODEL,
|
model: modelUsed,
|
||||||
durationMs: Date.now() - start,
|
durationMs: Date.now() - start,
|
||||||
costUsd: cost,
|
costUsd: cost,
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.info(log, 'openai_image_generated');
|
logger.info(log, 'openai_image_generated');
|
||||||
|
|
||||||
return { url: publicUrl, log };
|
return { url: publicUrl, log };
|
||||||
} catch (err) {
|
}
|
||||||
logger.warn(
|
|
||||||
`Génération image (${IMAGE_MODEL}) échouée: ${(err as Error).message}`
|
/**
|
||||||
);
|
* Appelle l'API Images d'OpenAI pour un modèle donné et retourne le buffer
|
||||||
return { url: null, log: null };
|
* de l'image générée. Unifie le traitement des deux modèles (gpt-image-1
|
||||||
}
|
* renvoie du base64, dall-e-3 renvoie une URL).
|
||||||
|
*/
|
||||||
|
async function callImageModel(
|
||||||
|
openai: OpenAI,
|
||||||
|
model: string,
|
||||||
|
prompt: string
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const isGptImage = model === 'gpt-image-1';
|
||||||
|
|
||||||
|
// dall-e-3 n'accepte pas 'low'/'medium'/'high' — on mappe vers 'standard'
|
||||||
|
const qualityForDallE =
|
||||||
|
IMAGE_QUALITY === 'hd' || IMAGE_QUALITY === 'standard'
|
||||||
|
? IMAGE_QUALITY
|
||||||
|
: 'standard';
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
n: 1,
|
||||||
|
size: IMAGE_SIZE,
|
||||||
|
...(isGptImage
|
||||||
|
? { quality: IMAGE_QUALITY }
|
||||||
|
: { quality: qualityForDallE, response_format: 'b64_json' as const }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const response = await openai.images.generate(params as any);
|
||||||
|
|
||||||
|
const first = response.data?.[0];
|
||||||
|
if (!first) throw new Error('Réponse images vide');
|
||||||
|
|
||||||
|
if (first.b64_json) {
|
||||||
|
return Buffer.from(first.b64_json, 'base64');
|
||||||
|
}
|
||||||
|
if (first.url) {
|
||||||
|
const fetched = await fetch(first.url);
|
||||||
|
if (!fetched.ok) throw new Error(`Téléchargement image: ${fetched.statusText}`);
|
||||||
|
return Buffer.from(await fetched.arrayBuffer());
|
||||||
|
}
|
||||||
|
throw new Error('Ni base64 ni URL dans la réponse images');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,9 @@ export default fp(async function stripePlugin(fastify: FastifyInstance) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripe = new Stripe(key);
|
const stripe = new Stripe(key, {
|
||||||
|
apiVersion: (process.env.STRIPE_API_VERSION as Stripe.StripeConfig['apiVersion']) ?? '2023-10-16',
|
||||||
|
});
|
||||||
|
|
||||||
fastify.decorate('stripe', stripe);
|
fastify.decorate('stripe', stripe);
|
||||||
|
|
||||||
|
|||||||
@ -49,7 +49,7 @@ const authRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
|||||||
|
|
||||||
// Créer un client Stripe (le plugin peut être désactivé)
|
// Créer un client Stripe (le plugin peut être désactivé)
|
||||||
let stripeId = '';
|
let stripeId = '';
|
||||||
if (fastify.stripe) {
|
if (fastify.createCustomer) {
|
||||||
const customer = await fastify.createCustomer(email, name);
|
const customer = await fastify.createCustomer(email, name);
|
||||||
stripeId = customer.id;
|
stripeId = customer.id;
|
||||||
}
|
}
|
||||||
@ -164,7 +164,7 @@ const authRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
let stripeId = '';
|
let stripeId = '';
|
||||||
if (fastify.stripe) {
|
if (fastify.createCustomer) {
|
||||||
const customer = await fastify.createCustomer(email, name);
|
const customer = await fastify.createCustomer(email, name);
|
||||||
stripeId = customer.id;
|
stripeId = customer.id;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
import helmet from '@fastify/helmet';
|
import helmet from '@fastify/helmet';
|
||||||
import rateLimit from '@fastify/rate-limit';
|
import rateLimit from '@fastify/rate-limit';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
|
import fastifyStatic from '@fastify/static';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
import authPlugin from './plugins/auth';
|
import authPlugin from './plugins/auth';
|
||||||
import stripePlugin from './plugins/stripe';
|
import stripePlugin from './plugins/stripe';
|
||||||
@ -56,6 +58,13 @@ async function bootstrap(): Promise<void> {
|
|||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Fichiers statiques (fallback local pour images/audio) ---
|
||||||
|
await fastify.register(fastifyStatic, {
|
||||||
|
root: path.resolve('./uploads'),
|
||||||
|
prefix: '/uploads/',
|
||||||
|
decorateReply: false,
|
||||||
|
});
|
||||||
|
|
||||||
// --- Plugins applicatifs ---
|
// --- Plugins applicatifs ---
|
||||||
await fastify.register(authPlugin);
|
await fastify.register(authPlugin);
|
||||||
await fastify.register(stripePlugin);
|
await fastify.register(stripePlugin);
|
||||||
@ -69,9 +78,8 @@ async function bootstrap(): Promise<void> {
|
|||||||
|
|
||||||
fastify.get('/health', async () => ({ status: 'ok', uptime: process.uptime() }));
|
fastify.get('/health', async () => ({ status: 'ok', uptime: process.uptime() }));
|
||||||
|
|
||||||
fastify.addHook('onClose', async (_instance, done) => {
|
fastify.addHook('onClose', async () => {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
done();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
13
backend/src/types/fastify.d.ts
vendored
13
backend/src/types/fastify.d.ts
vendored
@ -4,6 +4,14 @@ import type Stripe from 'stripe';
|
|||||||
import type { OAuth2Client } from 'google-auth-library';
|
import type { OAuth2Client } from 'google-auth-library';
|
||||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import type { MultipartFile } from '@fastify/multipart';
|
import type { MultipartFile } from '@fastify/multipart';
|
||||||
|
import '@fastify/jwt';
|
||||||
|
|
||||||
|
declare module '@fastify/jwt' {
|
||||||
|
interface FastifyJWT {
|
||||||
|
payload: { id: string };
|
||||||
|
user: { id: string };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface RecipeData {
|
export interface RecipeData {
|
||||||
titre: string;
|
titre: string;
|
||||||
@ -56,9 +64,4 @@ declare module 'fastify' {
|
|||||||
googleClient: OAuth2Client;
|
googleClient: OAuth2Client;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FastifyRequest {
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,8 +29,10 @@ function getClient(): Minio.Client | null {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Ne désactiver la vérification TLS que si explicitement demandé.
|
// 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 Minio.ClientOptions & { transport?: unknown }).transport = {
|
(clientOpts as unknown as { transport: unknown }).transport = {
|
||||||
agent: new https.Agent({ rejectUnauthorized: false }),
|
agent: new https.Agent({ rejectUnauthorized: false }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -67,7 +69,7 @@ export async function getFile(filePath: string): Promise<Readable> {
|
|||||||
return client.getObject(bucket(), filePath);
|
return client.getObject(bucket(), filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listFiles(folderPath: string): Promise<Minio.BucketStream<Minio.BucketItem>> {
|
export async function listFiles(folderPath: string) {
|
||||||
const client = getClient();
|
const client = getClient();
|
||||||
if (!client) throw new Error('MinIO non configuré');
|
if (!client) throw new Error('MinIO non configuré');
|
||||||
return client.listObjects(bucket(), folderPath);
|
return client.listObjects(bucket(), folderPath);
|
||||||
|
|||||||
42
docker-compose.yml
Normal file
42
docker-compose.yml
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
services:
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: freedge-minio
|
||||||
|
ports:
|
||||||
|
- "9000:9000" # API S3
|
||||||
|
- "9001:9001" # Console web
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-freedge}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-freedge123}
|
||||||
|
volumes:
|
||||||
|
- minio-data:/data
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Crée le bucket automatiquement au démarrage
|
||||||
|
minio-init:
|
||||||
|
image: minio/mc:latest
|
||||||
|
container_name: freedge-minio-init
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-freedge}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-freedge123}
|
||||||
|
MINIO_BUCKET: ${MINIO_BUCKET:-freedge}
|
||||||
|
entrypoint: >
|
||||||
|
/bin/sh -c "
|
||||||
|
set -e;
|
||||||
|
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD;
|
||||||
|
mc mb --ignore-existing local/$$MINIO_BUCKET;
|
||||||
|
mc anonymous set download local/$$MINIO_BUCKET;
|
||||||
|
echo 'Bucket '$$MINIO_BUCKET' prêt et lisible publiquement';
|
||||||
|
"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
minio-data:
|
||||||
|
driver: local
|
||||||
Loading…
x
Reference in New Issue
Block a user