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:
ordinarthur 2026-04-08 12:30:47 +02:00
parent 9dbd7e0ba9
commit 64df5db077
11 changed files with 4991 additions and 96 deletions

View File

@ -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.

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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,72 +55,123 @@ export async function generateRecipeImage(
} }
const start = Date.now(); const start = Date.now();
const prompt = buildImagePrompt(options.title, options.description);
// 1. Tenter le modèle principal, puis le fallback
let imageBuffer: Buffer | null = null;
let modelUsed = PRIMARY_MODEL;
try { try {
const prompt = buildImagePrompt(options.title, options.description); imageBuffer = await callImageModel(openai, PRIMARY_MODEL, prompt);
// gpt-image-1 retourne TOUJOURS du base64 (pas d'URL)
// 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;
if (isGptImage) {
const b64 = response.data?.[0]?.b64_json;
if (!b64) throw new Error('Pas de base64 dans la réponse gpt-image-1');
imageBuffer = Buffer.from(b64, 'base64');
} else {
const url = response.data?.[0]?.url;
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`;
let publicUrl: string | null = null;
try {
const filePath = await uploadFile(
{ filename: fileName, file: Readable.from(imageBuffer) },
'recipes'
);
publicUrl = await getFileUrl(filePath);
} catch (storageErr) {
logger.warn(
`Upload MinIO image échoué: ${(storageErr as Error).message}`
);
// Pas de fallback local pour les images : on laisse null
// (le frontend affichera son placeholder)
publicUrl = null;
}
const cost = computeImageCost(IMAGE_MODEL, IMAGE_SIZE, IMAGE_QUALITY);
const log: CallLog = {
userId: options.userId,
operation: 'image_generation',
model: IMAGE_MODEL,
durationMs: Date.now() - start,
costUsd: cost,
};
logger.info(log, 'openai_image_generated');
return { url: publicUrl, log };
} catch (err) { } catch (err) {
logger.warn( logger.warn(
`Génération image (${IMAGE_MODEL}) échouée: ${(err as Error).message}` `Génération image (${PRIMARY_MODEL}) échouée: ${(err as Error).message}`
); );
return { url: null, log: null };
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 {
return { url: null, log: null };
}
} }
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;
try {
const filePath = await uploadFile(
{ filename: fileName, file: Readable.from(imageBuffer) },
'recipes'
);
publicUrl = await getFileUrl(filePath);
} catch (storageErr) {
logger.warn(
`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}`
);
publicUrl = null;
}
}
const cost = computeImageCost(modelUsed, IMAGE_SIZE, IMAGE_QUALITY);
const log: CallLog = {
userId: options.userId,
operation: 'image_generation',
model: modelUsed,
durationMs: Date.now() - start,
costUsd: cost,
};
logger.info(log, 'openai_image_generated');
return { url: publicUrl, log };
}
/**
* Appelle l'API Images d'OpenAI pour un modèle donné et retourne le buffer
* 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');
} }

View File

@ -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);

View File

@ -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;
} }

View File

@ -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();
}); });
} }

View File

@ -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;
};
}
} }

View File

@ -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
View 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