diff --git a/docs/DEV.md b/docs/DEV.md index 1aea372..5d2f678 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -8,6 +8,8 @@ Pour l'architecture partagée (modules, composants, icônes) : [ARCHITECTURE.md] Pour la procédure de publication du package : [PUBLICATION.md](./dev/PUBLICATION.md). +Pour les conventions de commit : [COMMITS.md](./dev/COMMITS.md). + > **Contexte projet** : les utilisateurs finaux créent leur projet via `npx @zen/start`, qui génère automatiquement une structure Next.js avec le CMS déjà intégré. Lors d'une assistance ou d'une revue, partez du principe que le projet cible est déjà structuré de cette façon. --- diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md index 7490ae4..58bb857 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -31,4 +31,4 @@ Ces modules existent pour éviter la duplication. Avant d'écrire du code utilit **Tâches planifiées** — Utiliser `src/core/cron` pour créer des tâches cron. -**API** — Utiliser `src/core/api` pour l'API admin et publique. Toujours définir l'API privée/admin en premier, puis exposer seulement ce qui doit l'être. Toujours vérifier l'authentification sur les routes qui l'exigent. \ No newline at end of file +**API** — Utiliser `src/core/api` pour l'API admin et publique. Définir les routes avec `defineApiRoutes()` (valide la config au démarrage). L'authentification est déclarée dans la définition de route (`auth: 'public' | 'user' | 'admin'`) — ne jamais la vérifier manuellement dans un handler. Retourner `apiSuccess()` / `apiError()` dans tous les handlers. Voir `src/core/api/README.md` pour le détail. \ No newline at end of file diff --git a/docs/dev/COMMITS.md b/docs/dev/COMMITS.md new file mode 100644 index 0000000..092925b --- /dev/null +++ b/docs/dev/COMMITS.md @@ -0,0 +1,56 @@ +# Conventions de commit + +Tous les messages de commit sont rédigés en **anglais**, en suivant le format [Conventional Commits](https://www.conventionalcommits.org/) : + +``` +(): +``` + +--- + +## Types + +| Type | Usage | +|------|-------| +| `feat` | New feature | +| `fix` | Bug fix | +| `refactor` | Code restructuring without behavior change | +| `style` | Formatting only (spaces, commas, no logic change) | +| `docs` | Documentation only | +| `test` | Add or update tests | +| `chore` | Maintenance, dependencies, build config | +| `perf` | Performance improvement | +| `revert` | Revert a previous commit | + +--- + +## Exemples + +``` +feat(auth): add OAuth2 login support +fix(api): handle null response from payment gateway +refactor(storage): extract upload logic into helper +docs(guide): add git commit message conventions +chore(deps): update dependencies +perf(db): cache user metadata on repeated reads +revert: revert "feat(auth): add OAuth2 login support" +``` + +--- + +## Règles + +- **Scope** : facultatif, précise la zone touchée `auth`, `api`, `storage`, `ui`, `config` +- **Description** : minuscules, sans point final, en anglais +- Un commit = une intention. Ne pas mélanger fix et refactor +- Pas de `wip`, `fix fix`, `oups` ou messages vides + +## Breaking changes + +Ajouter `!` après le type et un pied de page `BREAKING CHANGE:` : + +``` +feat(api)!: remove legacy query parameter + +BREAKING CHANGE: the `legacy` param is no longer accepted, use `version` instead. +``` diff --git a/docs/dev/LANGUE.md b/docs/dev/LANGUE.md index 193832c..f5fc579 100644 --- a/docs/dev/LANGUE.md +++ b/docs/dev/LANGUE.md @@ -16,22 +16,6 @@ Tout ce qui est **visible par l'utilisateur** est en **français** : - Slugs et noms de dossiers qui correspondent à des routes URL - Documentations, README.md -## Messages de commit Git - -Tous les messages de commit doivent être rédigés en **anglais**, en suivant le format conventional commits : - -``` -(): -``` - -Types courants : `feat`, `fix`, `refactor`, `style`, `docs`, `test`, `chore` - -Exemples : -- `feat(auth): add OAuth2 login support` -- `fix(api): handle null response from payment gateway` -- `docs(guide): add git commit message conventions` -- `chore(deps): update dependencies` - ## Guide de rédaction Se référer à `REDACTION.md` avant de rédiger tout contenu textuel. diff --git a/package.json b/package.json index 1fce3a7..472067e 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "import": "./dist/core/api/index.js" }, "./zen/api": { - "import": "./dist/core/api/nx-route.js" + "import": "./dist/core/api/route-handler.js" }, "./database": { "import": "./dist/core/database/index.js" diff --git a/src/core/api/README.md b/src/core/api/README.md new file mode 100644 index 0000000..11bf8a7 --- /dev/null +++ b/src/core/api/README.md @@ -0,0 +1,194 @@ +# API Framework + +Ce répertoire est un **framework d'API générique**. Il ne connaît aucune feature spécifique — les features s'y enregistrent elles-mêmes. Ajouter une nouvelle route ne nécessite jamais de modifier `router.js`. + +--- + +## Structure + +``` +src/core/api/ +├── index.js Exports publics (routeRequest, requireAuth, apiSuccess, defineApiRoutes…) +├── router.js Orchestration : rate limit, CSRF, auth, dispatch +├── route-handler.js Intégration Next.js App Router (GET/POST/PUT/DELETE/PATCH) +├── define.js defineApiRoutes() — validateur de définitions de routes +├── respond.js apiSuccess() / apiError() — utilitaires de réponse +├── core-routes.js Index des routes built-in (seul fichier à toucher pour un nouveau handler core) +└── health.js GET /zen/api/health + +src/features/auth/api.js Routes /zen/api/users/* (vivent avec la feature auth) +src/core/storage/api.js Routes /zen/api/storage/** (vivent avec le module storage) +``` + +Les routes vivent **avec leur feature**, pas dans le framework. `src/core/api/` ne contient que l'infrastructure. + +--- + +## Cycle de vie d'une requête + +``` +route-handler.js (GET / POST / PUT / DELETE / PATCH) + └─→ routeRequest(request, path) + ├─ Rate limit (IP, sauf GET /health) + ├─ CSRF (méthodes state-mutating uniquement) + └─ Boucle sur toutes les routes (core d'abord, modules ensuite) + ├─ matchRoute(pattern, path) — exact, :param, /** + ├─ Auth enforcement (depuis la définition de la route) + │ 'admin' → requireAdmin() — session dans context.session + │ 'user' → requireAuth() — session dans context.session + │ 'public'→ aucun — context.session = undefined + └─ handler(request, params, context) +``` + +--- + +## Niveaux d'auth + +| Niveau | Qui peut appeler | `context.session` | +|--------|-----------------|-------------------| +| `'public'` | Tout le monde, sans cookie | `undefined` | +| `'user'` | Utilisateur avec une session valide | `{ user: { id, email, name, role, … } }` | +| `'admin'` | Utilisateur admin (`role === 'admin'`) | `{ user: { id, email, name, role: 'admin', … } }` | + +L'auth est **toujours déclarée dans la définition de route**, jamais dans le handler. + +--- + +## Ajouter des routes à une feature existante + +Créer un fichier `api.js` dans le répertoire de la feature et exporter `routes` : + +```js +// src/features/myfeature/api.js +import { defineApiRoutes } from '../../core/api/define.js'; +import { apiSuccess, apiError } from '../../core/api/respond.js'; + +async function handleGetSettings(_request, _params, _context) { + return apiSuccess({ theme: 'light' }); +} + +async function handleUpdateSettings(request, _params, { session }) { + const body = await request.json(); + // ... validation + update ... + return apiSuccess({ success: true, message: 'Settings updated' }); +} + +export const routes = defineApiRoutes([ + { path: '/settings', method: 'GET', handler: handleGetSettings, auth: 'user' }, + { path: '/settings', method: 'PUT', handler: handleUpdateSettings, auth: 'admin' }, +]); +``` + +Puis enregistrer dans `core-routes.js` (la seule ligne à ajouter) : + +```js +import { routes as settingsRoutes } from '../../features/myfeature/api.js'; + +export function getCoreRoutes() { + return [ + ...healthRoutes, + ...usersRoutes, + ...storageRoutes, + ...settingsRoutes, // ← ajout + ]; +} +``` + +**C'est tout.** Les routes sont disponibles à `GET /zen/api/settings` et `PUT /zen/api/settings`. + +--- + +## Ajouter des routes depuis un module + +Les modules définissent leurs routes dans leur propre `api.js` — le système de découverte les charge automatiquement. Aucune modification dans `src/core/api/` n'est nécessaire. + +```js +// src/modules/mymodule/api.js +import { defineApiRoutes } from '@zen/core/api'; + +export default { + routes: defineApiRoutes([ + { path: '/admin/mymodule/items', method: 'GET', handler: handleList, auth: 'admin' }, + { path: '/mymodule/:slug', method: 'GET', handler: handlePublic, auth: 'public' }, + ]) +}; +``` + +--- + +## Signature des handlers + +```js +async function handleSomething(request, params, context) { + // request — objet Request Next.js + // params — paramètres extraits du chemin { id: '42', … } + // Pour les routes wildcard (/storage/**) : params.wildcard + // context — { session? } — session présente si auth !== 'public' +} +``` + +--- + +## Utilitaires de réponse + +Toujours utiliser `apiSuccess` et `apiError` pour que l'intention soit explicite. + +```js +import { apiSuccess, apiError } from '../../core/api/respond.js'; + +// Succès — GET +return apiSuccess({ user: { id, email, name } }); + +// Succès — mutation (avec confirmation pour le frontend) +return apiSuccess({ success: true, user: updatedUser, message: 'Profil mis à jour' }); + +// Erreur +return apiError('Not Found', 'Utilisateur introuvable'); +return apiError('Bad Request', 'Le nom est requis'); +return apiError('Forbidden', 'Accès refusé'); +``` + +Codes d'erreur reconnus par `getStatusCode()` → HTTP status : + +| Code | Status | +|------|--------| +| `'Unauthorized'` | 401 | +| `'Admin access required'` | 403 | +| `'Forbidden'` | 403 | +| `'Not Found'` | 404 | +| `'Bad Request'` | 400 | +| `'Too Many Requests'` | 429 | +| `'Internal Server Error'` | 500 | + +--- + +## defineApiRoutes() + +Valide les définitions de routes à l'import (au démarrage). Une erreur de configuration lève immédiatement une `TypeError` avec un message précis. + +Champs requis par route : + +| Champ | Type | Valeurs valides | +|-------|------|-----------------| +| `path` | `string` | Commence par `/`. Supporte `:param` et `/**` en fin | +| `method` | `string` | `GET` \| `POST` \| `PUT` \| `PATCH` \| `DELETE` | +| `handler` | `Function` | Signature : `(request, params, context) => Promise` | +| `auth` | `string` | `'public'` \| `'user'` \| `'admin'` | + +--- + +## Note — handler storage + +Le handler storage (`src/core/storage/api.js`) est déclaré `auth: 'public'` mais effectue sa propre validation d'accès en interne, basée sur le chemin du fichier : + +- **Prefixes publics** (déclarés via `storagePublicPrefixes` dans chaque module) → aucune session requise +- **`/users/{userId}/…`** → session requise ; l'utilisateur ne peut accéder qu'à ses propres fichiers (sauf admin) +- **`/organizations/…`** → admin uniquement +- **`/posts/…`** (non couverts par un prefix public) → admin uniquement +- **Tout autre chemin** → refusé + +--- + +## Compatibilité des modules existants + +Les handlers de modules existants avec la signature `(request, params)` continuent de fonctionner — JavaScript ignore les arguments supplémentaires non utilisés. diff --git a/src/core/api/core-routes.js b/src/core/api/core-routes.js new file mode 100644 index 0000000..3086db3 --- /dev/null +++ b/src/core/api/core-routes.js @@ -0,0 +1,29 @@ +/** + * Core Route Index + * + * This is the ONLY file to edit when adding a new built-in handler. + * Each feature manages its own route definitions via defineApiRoutes(). + * Do NOT put route logic here — only import and spread. + * + * To add a new built-in handler: + * 1. Create the handler in its natural location (e.g. src/features/myfeature/api.js) + * 2. Export `routes` from it using defineApiRoutes() + * 3. Add one line here: import + spread + * 4. Done — never touch router.js + */ + +import { routes as healthRoutes } from './health.js'; +import { routes as usersRoutes } from '../../features/auth/api.js'; +import { routes as storageRoutes } from '../storage/api.js'; + +/** + * Return all registered core API routes. + * @returns {ReadonlyArray} + */ +export function getCoreRoutes() { + return [ + ...healthRoutes, + ...usersRoutes, + ...storageRoutes, + ]; +} diff --git a/src/core/api/define.js b/src/core/api/define.js new file mode 100644 index 0000000..3581dbe --- /dev/null +++ b/src/core/api/define.js @@ -0,0 +1,73 @@ +/** + * API Route Definition Helper + * + * Validates route definitions at module load time (startup), not at request + * time. A misconfigured route throws immediately — before any request is + * served — making integration errors impossible to miss. + * + * Usage (in any handler file or module api.js): + * import { defineApiRoutes } from '../define.js'; + * + * export const routes = defineApiRoutes([ + * { path: '/health', method: 'GET', handler: handleHealth, auth: 'public' }, + * ]); + * + * Required fields per route: + * path {string} Must start with '/'. Supports ':param' and trailing '/**'. + * method {string} One of: GET | POST | PUT | PATCH | DELETE + * handler {Function} Async function — signature: (request, params, context) + * auth {string} One of: 'public' | 'user' | 'admin' + * + * Auth levels: + * 'public' Anyone can call this route. context.session is undefined. + * 'user' Requires a valid session. context.session.user is set. + * 'admin' Requires a valid session with role === 'admin'. + */ + +const VALID_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); +const VALID_AUTH = new Set(['public', 'user', 'admin']); + +/** + * Validate and return a frozen route definition array. + * + * @param {Array<{path: string, method: string, handler: Function, auth: string}>} routes + * @returns {ReadonlyArray} + * @throws {TypeError} If any route definition is invalid + */ +export function defineApiRoutes(routes) { + if (!Array.isArray(routes)) { + throw new TypeError('defineApiRoutes: argument must be an array'); + } + + for (let i = 0; i < routes.length; i++) { + const route = routes[i]; + const at = `defineApiRoutes: route[${i}]`; + + if (!route || typeof route !== 'object') { + throw new TypeError(`${at} must be an object`); + } + if (typeof route.path !== 'string' || !route.path.startsWith('/')) { + throw new TypeError( + `${at} — "path" must be a string starting with "/", got: ${JSON.stringify(route.path)}` + ); + } + if (!VALID_METHODS.has(route.method)) { + throw new TypeError( + `${at} (${route.path}) — "method" must be one of ${[...VALID_METHODS].join(' | ')}, got: ${JSON.stringify(route.method)}` + ); + } + if (typeof route.handler !== 'function') { + throw new TypeError( + `${at} (${route.method} ${route.path}) — "handler" must be a function` + ); + } + if (!VALID_AUTH.has(route.auth)) { + throw new TypeError( + `${at} (${route.method} ${route.path}) — "auth" must be one of "public" | "user" | "admin", got: ${JSON.stringify(route.auth)}` + ); + } + } + + // Freeze to prevent accidental mutation of route definitions at runtime. + return Object.freeze(routes.map(r => Object.freeze({ ...r }))); +} diff --git a/src/core/api/handlers/storage.js b/src/core/api/handlers/storage.js deleted file mode 100644 index 21b4a31..0000000 --- a/src/core/api/handlers/storage.js +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Storage API Handlers - * Handles secure file access - */ - -import { validateSession } from '../../../features/auth/lib/session.js'; -import { cookies } from 'next/headers'; -import { getSessionCookieName } from '../../../shared/lib/appConfig.js'; -import { getAllStoragePublicPrefixes } from '@zen/core/modules/storage'; -import { getFile } from '@zen/core/storage'; -import { fail } from '../../../shared/lib/logger.js'; - -// Get cookie name from environment or use default -const COOKIE_NAME = getSessionCookieName(); - -/** - * Serve a file from storage with security validation - * @param {Request} request - The request object - * @param {string} fileKey - The file key/path in storage - * @returns {Promise} File response or error object - */ -export async function handleGetFile(_request, fileKey) { - try { - // Reject any path that contains traversal sequences, empty segments, or - // absolute path indicators before splitting or passing to the storage backend. - // Next.js decodes URL percent-encoding before populating [...path], so - // '..' and '.' arrive as literal segment values here. - const rawSegments = fileKey.split('/'); - if ( - rawSegments.some(seg => seg === '..' || seg === '.' || seg === '') || - fileKey.includes('\0') - ) { - return { error: 'Bad Request', message: 'Invalid file path' }; - } - - const pathParts = rawSegments; - - // Public prefixes: declared by each module via defineModule() storagePublicPrefixes. - // Files whose path starts with a declared prefix are served without authentication. - // The path must have at least two segments beyond the prefix ({...prefix}/{id}/{filename}) - // to prevent unintentional exposure of files at the root of the prefix. - const publicPrefixes = getAllStoragePublicPrefixes(); - const matchedPrefix = publicPrefixes.find(prefix => fileKey.startsWith(prefix + '/')); - if (matchedPrefix) { - const prefixDepth = matchedPrefix.split('/').length; - if (pathParts.length < prefixDepth + 2) { - return { error: 'Bad Request', message: 'Invalid file path' }; - } - const result = await getFile(fileKey); - if (!result.success) { - if (result.error.includes('NoSuchKey') || result.error.includes('not found')) { - return { error: 'Not Found', message: 'File not found' }; - } - // Never forward raw storage error messages to the client. - return { error: 'Internal Server Error', message: 'Failed to retrieve file' }; - } - return { - success: true, - file: { - body: result.data.body, - contentType: result.data.contentType, - contentLength: result.data.contentLength, - lastModified: result.data.lastModified - } - }; - } - - // Require auth for other paths - const cookieStore = await cookies(); - const sessionToken = cookieStore.get(COOKIE_NAME)?.value; - - if (!sessionToken) { - return { - error: 'Unauthorized', - message: 'Authentication required to access files' - }; - } - - const session = await validateSession(sessionToken); - - if (!session) { - return { - error: 'Unauthorized', - message: 'Invalid or expired session' - }; - } - - // Security validation based on file path - if (pathParts[0] === 'users') { - // User files: users/{userId}/{category}/{filename} - const userId = pathParts[1]; - - // Users can only access their own files, unless they're admin - if (session.user.id !== userId && session.user.role !== 'admin') { - return { - error: 'Forbidden', - message: 'You do not have permission to access this file' - }; - } - } else if (pathParts[0] === 'organizations') { - // Organization files: organizations/{orgId}/{category}/{filename} - // Only admins can access organization files - if (session.user.role !== 'admin') { - return { - error: 'Forbidden', - message: 'Admin access required for organization files' - }; - } - } else if (pathParts[0] === 'posts') { - // Post files: posts/{type}/{id}/{filename} - // Private types (not in storagePublicPrefixes) require admin access - if (session.user.role !== 'admin') { - return { - error: 'Forbidden', - message: 'Admin access required for this file' - }; - } - } else { - // Unknown file path pattern — deny by default - return { - error: 'Forbidden', - message: 'Invalid file path' - }; - } - - // Get file from storage - const result = await getFile(fileKey); - - if (!result.success) { - if (result.error.includes('NoSuchKey') || result.error.includes('not found')) { - return { - error: 'Not Found', - message: 'File not found' - }; - } - // Never forward raw storage errors (which may contain bucket names/keys) to clients. - return { - error: 'Internal Server Error', - message: 'Failed to retrieve file' - }; - } - - // Return the file data with proper headers - return { - success: true, - file: { - body: result.data.body, - contentType: result.data.contentType, - contentLength: result.data.contentLength, - lastModified: result.data.lastModified - } - }; - } catch (error) { - // Log full error server-side; never surface internal details to the client. - fail(`Storage: error serving file: ${error.message}`); - return { - error: 'Internal Server Error', - message: 'Failed to retrieve file' - }; - } -} - diff --git a/src/core/api/handlers/users.js b/src/core/api/handlers/users.js deleted file mode 100644 index 05e2e47..0000000 --- a/src/core/api/handlers/users.js +++ /dev/null @@ -1,602 +0,0 @@ -/** - * Users API Handlers - * Handles user-related API endpoints - */ - -import { validateSession } from '../../../features/auth/lib/session.js'; -import { cookies } from 'next/headers'; -import { query, updateById } from '@zen/core/database'; -import { getSessionCookieName } from '../../../shared/lib/appConfig.js'; -import { updateUser } from '../../../features/auth/lib/auth.js'; -import { uploadImage, deleteFile, generateUniqueFilename, generateUserFilePath, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage'; -import { fail, info } from '../../../shared/lib/logger.js'; - -// Get cookie name from environment or use default -const COOKIE_NAME = getSessionCookieName(); - -/** Maximum number of users returned per paginated request */ -const MAX_PAGE_LIMIT = 100; - -/** - * Server-side whitelist of MIME types accepted for profile picture uploads. - * The client-supplied file.type is NEVER trusted; this set is the authoritative - * list. Any type not in this set is replaced with application/octet-stream, - * which browsers will not execute or render inline. - */ -const ALLOWED_IMAGE_MIME_TYPES = new Set([ - 'image/jpeg', - 'image/png', - 'image/webp', - 'image/gif', -]); - -/** - * Return a generic, opaque error message suitable for client consumption. - * Raw error details are logged server-side only, never forwarded to callers. - * @param {unknown} error - The caught exception - * @param {string} fallback - The safe message to surface to the client - */ -function logAndObscureError(error, fallback) { - fail(`Internal handler error: ${error.message}`); - return fallback; -} - -/** - * Get current user information - */ -export async function handleGetCurrentUser(request) { - // Get session token from cookies - const cookieStore = await cookies(); - const sessionToken = cookieStore.get(COOKIE_NAME)?.value; - - if (!sessionToken) { - return { - error: 'Unauthorized', - message: 'No session token provided' - }; - } - - // Validate session - const session = await validateSession(sessionToken); - - if (!session) { - return { - error: 'Unauthorized', - message: 'Invalid or expired session' - }; - } - - // Return user data (without sensitive information) - return { - user: { - id: session.user.id, - email: session.user.email, - name: session.user.name, - role: session.user.role, - image: session.user.image, - emailVerified: session.user.email_verified, - createdAt: session.user.created_at - } - }; -} - -/** - * Get user by ID (admin only) - */ -export async function handleGetUserById(request, userId) { - // Get session token from cookies - const cookieStore = await cookies(); - const sessionToken = cookieStore.get(COOKIE_NAME)?.value; - - if (!sessionToken) { - return { - error: 'Unauthorized', - message: 'No session token provided' - }; - } - - // Validate session - const session = await validateSession(sessionToken); - - if (!session) { - return { - error: 'Unauthorized', - message: 'Invalid or expired session' - }; - } - - // Check if user is admin - if (session.user.role !== 'admin') { - return { - error: 'Forbidden', - message: 'Admin access required' - }; - } - - // Get user from database - const result = await query( - 'SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users WHERE id = $1', - [userId] - ); - - if (result.rows.length === 0) { - return { - error: 'Not Found', - message: 'User not found' - }; - } - - const response = { user: result.rows[0] }; - - return response; -} - -/** - * Update user by ID (admin only) - */ -export async function handleUpdateUserById(request, userId) { - const cookieStore = await cookies(); - const sessionToken = cookieStore.get(COOKIE_NAME)?.value; - - if (!sessionToken) { - return { success: false, error: 'Unauthorized', message: 'No session token provided' }; - } - - const session = await validateSession(sessionToken); - if (!session) { - return { success: false, error: 'Unauthorized', message: 'Invalid or expired session' }; - } - if (session.user.role !== 'admin') { - return { success: false, error: 'Forbidden', message: 'Admin access required' }; - } - - try { - const body = await request.json(); - const allowedFields = ['name', 'role', 'email_verified']; - const updateData = { updated_at: new Date() }; - - for (const field of allowedFields) { - if (body[field] !== undefined) { - if (field === 'email_verified') { - updateData[field] = Boolean(body[field]); - } else if (field === 'role') { - const role = String(body[field]).toLowerCase(); - if (['admin', 'user'].includes(role)) { - updateData[field] = role; - } - } else if (field === 'name' && body[field] != null) { - updateData[field] = String(body[field]).trim() || null; - } - } - } - - const updated = await updateById('zen_auth_users', userId, updateData); - if (!updated) { - return { success: false, error: 'Not Found', message: 'User not found' }; - } - - const result = await query( - 'SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users WHERE id = $1', - [userId] - ); - return { - success: true, - user: result.rows[0], - message: 'User updated successfully' - }; - } catch (error) { - logAndObscureError(error, 'Failed to update user'); - return { - success: false, - error: 'Internal Server Error', - message: 'Failed to update user' - }; - } -} - -/** - * List all users (admin only) - */ -export async function handleListUsers(request) { - // Get session token from cookies - const cookieStore = await cookies(); - const sessionToken = cookieStore.get(COOKIE_NAME)?.value; - - if (!sessionToken) { - return { - error: 'Unauthorized', - message: 'No session token provided' - }; - } - - // Validate session - const session = await validateSession(sessionToken); - - if (!session) { - return { - error: 'Unauthorized', - message: 'Invalid or expired session' - }; - } - - // Check if user is admin - if (session.user.role !== 'admin') { - return { - error: 'Forbidden', - message: 'Admin access required' - }; - } - - // Get URL params for pagination and sorting. - // Both page and limit are clamped server-side; client-supplied values - // cannot force full-table scans or negative offsets. - const url = new URL(request.url); - const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10) || 1); - const limit = Math.min( - Math.max(1, parseInt(url.searchParams.get('limit') || '10', 10) || 10), - MAX_PAGE_LIMIT - ); - const offset = (page - 1) * limit; - - // Get sorting parameters - const sortBy = url.searchParams.get('sortBy') || 'created_at'; - const sortOrder = url.searchParams.get('sortOrder') || 'desc'; - - // Whitelist allowed sort columns to prevent SQL injection - const allowedSortColumns = ['id', 'email', 'name', 'role', 'email_verified', 'created_at']; - const sortColumn = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at'; - - // Validate sort order - const order = sortOrder.toLowerCase() === 'asc' ? 'ASC' : 'DESC'; - - // Wrap the whitelisted column name in double-quotes to enforce identifier - // boundaries, preventing any future whitelist entry or edge case from - // being interpreted as SQL syntax. - const quotedSortColumn = `"${sortColumn}"`; - - // Get users from database with dynamic sorting - const result = await query( - `SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users ORDER BY ${quotedSortColumn} ${order} LIMIT $1 OFFSET $2`, - [limit, offset] - ); - - // Get total count - const countResult = await query('SELECT COUNT(*) FROM zen_auth_users'); - const total = parseInt(countResult.rows[0].count); - - return { - users: result.rows, - pagination: { - page, - limit, - total, - totalPages: Math.ceil(total / limit) - } - }; -} - -/** - * Update current user profile - */ -export async function handleUpdateProfile(request) { - // Get session token from cookies - const cookieStore = await cookies(); - const sessionToken = cookieStore.get(COOKIE_NAME)?.value; - - if (!sessionToken) { - return { - success: false, - error: 'Unauthorized', - message: 'No session token provided' - }; - } - - // Validate session - const session = await validateSession(sessionToken); - - if (!session) { - return { - success: false, - error: 'Unauthorized', - message: 'Invalid or expired session' - }; - } - - try { - // Get update data from request body - const body = await request.json(); - const { name } = body; - - // Validate input - if (!name || !name.trim()) { - return { - success: false, - error: 'Bad Request', - message: 'Name is required' - }; - } - - // Prepare update data - const updateData = { - name: name.trim() - }; - - // Update user profile - const updatedUser = await updateUser(session.user.id, updateData); - - // Return updated user data (without sensitive information) - return { - success: true, - user: { - id: updatedUser.id, - email: updatedUser.email, - name: updatedUser.name, - role: updatedUser.role, - image: updatedUser.image, - email_verified: updatedUser.email_verified, - created_at: updatedUser.created_at - }, - message: 'Profile updated successfully' - }; - } catch (error) { - logAndObscureError(error, 'Failed to update profile'); - return { - success: false, - error: 'Internal Server Error', - message: 'Failed to update profile' - }; - } -} - -/** - * Upload profile picture - */ -export async function handleUploadProfilePicture(request) { - // Get session token from cookies - const cookieStore = await cookies(); - const sessionToken = cookieStore.get(COOKIE_NAME)?.value; - - if (!sessionToken) { - return { - success: false, - error: 'Unauthorized', - message: 'No session token provided' - }; - } - - // Validate session - const session = await validateSession(sessionToken); - - if (!session) { - return { - success: false, - error: 'Unauthorized', - message: 'Invalid or expired session' - }; - } - - try { - // Get form data from request - const formData = await request.formData(); - const file = formData.get('file'); - - if (!file) { - return { - success: false, - error: 'Bad Request', - message: 'No file provided' - }; - } - - // Read file buffer once — used for both content inspection and upload. - const buffer = Buffer.from(await file.arrayBuffer()); - - // Validate file — pass buffer for magic-byte / content-pattern inspection. - const validation = validateUpload({ - filename: file.name, - size: file.size, - allowedTypes: FILE_TYPE_PRESETS.IMAGES, - maxSize: FILE_SIZE_LIMITS.AVATAR, - buffer, - }); - - if (!validation.valid) { - return { - success: false, - error: 'Bad Request', - message: validation.errors.join(', ') - }; - } - - // Get current user to check for existing profile picture - const currentUser = await query( - 'SELECT image FROM zen_auth_users WHERE id = $1', - [session.user.id] - ); - - let oldImageKey = null; - if (currentUser.rows.length > 0 && currentUser.rows[0].image) { - // The image field now contains the storage key directly - oldImageKey = currentUser.rows[0].image; - } - - // Generate unique filename - const uniqueFilename = generateUniqueFilename(file.name, 'avatar'); - - // Generate storage path - const key = generateUserFilePath(session.user.id, 'profile', uniqueFilename); - - // Derive the authoritative content-type from the *validated file extension*, - // never from file.type which is fully attacker-controlled. The extension - // has already been verified against the allowedTypes whitelist above, so - // mapping it deterministically here eliminates any client influence over - // the MIME type stored in R2 and subsequently served to other users. - const EXTENSION_TO_MIME = { - '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', - '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', - }; - const ext = getFileExtension(file.name).toLowerCase(); - const contentType = EXTENSION_TO_MIME[ext] ?? 'application/octet-stream'; - - // Upload to storage - const uploadResult = await uploadImage({ - key, - body: buffer, - contentType, - metadata: { - userId: session.user.id, - originalName: file.name - } - }); - - if (!uploadResult.success) { - return { - success: false, - error: 'Upload Failed', - message: uploadResult.error || 'Failed to upload image' - }; - } - - // Update user profile with storage key (not URL). - // The DB write is committed first. Only if it succeeds do we delete the old - // object; this ensures the database never references a key that no longer - // exists in storage. An orphaned old object is preferable to a broken DB ref. - let updatedUser; - try { - updatedUser = await updateUser(session.user.id, { image: key }); - } catch (dbError) { - // DB write failed — roll back the uploaded object so storage stays clean. - try { - await deleteFile(key); - } catch (rollbackError) { - fail(`Rollback delete of newly uploaded object failed: ${rollbackError.message}`); - } - throw dbError; - } - - // DB write succeeded. Now it is safe to remove the old object. - if (oldImageKey) { - try { - await deleteFile(oldImageKey); - info(`Deleted old profile picture: ${oldImageKey}`); - } catch (deleteError) { - // Non-fatal: log for operator cleanup; the DB is consistent. - fail(`Failed to delete old profile picture (orphaned object): ${deleteError.message}`); - } - } - - // Return updated user data - return { - success: true, - user: { - id: updatedUser.id, - email: updatedUser.email, - name: updatedUser.name, - role: updatedUser.role, - image: updatedUser.image, - email_verified: updatedUser.email_verified, - created_at: updatedUser.created_at - }, - message: 'Profile picture uploaded successfully' - }; - } catch (error) { - logAndObscureError(error, 'Failed to upload profile picture'); - return { - success: false, - error: 'Internal Server Error', - message: 'Failed to upload profile picture' - }; - } -} - -/** - * Delete profile picture - */ -export async function handleDeleteProfilePicture(request) { - // Get session token from cookies - const cookieStore = await cookies(); - const sessionToken = cookieStore.get(COOKIE_NAME)?.value; - - if (!sessionToken) { - return { - success: false, - error: 'Unauthorized', - message: 'No session token provided' - }; - } - - // Validate session - const session = await validateSession(sessionToken); - - if (!session) { - return { - success: false, - error: 'Unauthorized', - message: 'Invalid or expired session' - }; - } - - try { - // Get current user to check for existing profile picture - const currentUser = await query( - 'SELECT image FROM zen_auth_users WHERE id = $1', - [session.user.id] - ); - - if (currentUser.rows.length === 0) { - return { - success: false, - error: 'Not Found', - message: 'User not found' - }; - } - - const imageKey = currentUser.rows[0].image; - if (!imageKey) { - return { - success: false, - error: 'Bad Request', - message: 'No profile picture to delete' - }; - } - - // Update user profile to remove image URL - const updatedUser = await updateUser(session.user.id, { - image: null - }); - - // Delete image from storage - if (imageKey) { - try { - await deleteFile(imageKey); - info(`Deleted profile picture: ${imageKey}`); - } catch (deleteError) { - // Log error but don't fail the update - fail(`Failed to delete profile picture from storage: ${deleteError.message}`); - } - } - - // Return updated user data - return { - success: true, - user: { - id: updatedUser.id, - email: updatedUser.email, - name: updatedUser.name, - role: updatedUser.role, - image: updatedUser.image, - email_verified: updatedUser.email_verified, - created_at: updatedUser.created_at - }, - message: 'Profile picture deleted successfully' - }; - } catch (error) { - logAndObscureError(error, 'Failed to delete profile picture'); - return { - success: false, - error: 'Internal Server Error', - message: 'Failed to delete profile picture' - }; - } -} - diff --git a/src/core/api/handlers/health.js b/src/core/api/health.js similarity index 53% rename from src/core/api/handlers/health.js rename to src/core/api/health.js index a3ca279..7e65e9c 100644 --- a/src/core/api/handlers/health.js +++ b/src/core/api/health.js @@ -1,14 +1,20 @@ /** * Health Check Handler - * Returns the status of the API and basic system information */ -export async function handleHealth() { +import { defineApiRoutes } from './define.js'; +import { apiSuccess } from './respond.js'; + +async function handleHealth() { // Return only a liveness signal. Process uptime and version strings are // operational fingerprinting data; exposing them unauthenticated aids // attackers in timing restarts and targeting known-vulnerable versions. - return { + return apiSuccess({ status: 'ok', timestamp: new Date().toISOString() - }; + }); } + +export const routes = defineApiRoutes([ + { path: '/health', method: 'GET', handler: handleHealth, auth: 'public' } +]); diff --git a/src/core/api/index.js b/src/core/api/index.js index 1fe52f2..d4f87b9 100644 --- a/src/core/api/index.js +++ b/src/core/api/index.js @@ -1,20 +1,15 @@ /** - * Zen API Module - * - * This module exports API utilities for custom handlers - * For route setup, import from '@zen/core/zen/api' + * Zen API — Public Surface + * + * Exports the router entry point, auth helpers, response utilities, + * and the route definition helper for use across the application. */ -// Export router utilities (for custom handlers) +// Router export { routeRequest, getStatusCode, requireAuth, requireAdmin } from './router.js'; -// Export individual handlers (for custom usage) -export { handleHealth } from './handlers/health.js'; -export { - handleGetCurrentUser, - handleGetUserById, - handleListUsers -} from './handlers/users.js'; +// Response utilities — use in all handlers (core and modules) +export { apiSuccess, apiError } from './respond.js'; -// Module API handlers are now self-contained in their respective modules -// e.g., invoice handlers are in @zen/core/modules/invoice/api +// Route definition helper — use in handler files and module api.js files +export { defineApiRoutes } from './define.js'; diff --git a/src/core/api/respond.js b/src/core/api/respond.js new file mode 100644 index 0000000..f5a14d6 --- /dev/null +++ b/src/core/api/respond.js @@ -0,0 +1,51 @@ +/** + * API Response Utilities + * + * Consistent, named response creation for all API handlers — core and modules alike. + * Using these functions makes intent explicit at every return site. + * + * Usage: + * import { apiSuccess, apiError } from '../respond.js'; + * + * return apiSuccess({ user }); + * return apiSuccess({ users }, { page, total, totalPages }); + * return apiError('Not Found', 'User not found'); + */ + +/** + * Create a successful API response payload. + * + * Returns the payload as-is — no envelope wrapping — so existing frontend + * consumers remain compatible. The function exists to make success paths + * explicit and searchable alongside apiError() calls. + * + * @param {Object} payload - The data to return to the client + * @returns {Object} + */ +export function apiSuccess(payload) { + return payload; +} + +/** + * Create an error API response payload. + * + * The `code` field is read by getStatusCode() in router.js to derive the + * HTTP status. Always use one of the recognised codes below — any other + * value maps to 500. + * + * Valid codes → HTTP status: + * 'Unauthorized' → 401 + * 'Admin access required' → 403 + * 'Forbidden' → 403 + * 'Not Found' → 404 + * 'Bad Request' → 400 + * 'Too Many Requests' → 429 + * 'Internal Server Error' → 500 + * + * @param {string} code - Machine-readable error code + * @param {string} message - User-facing message, safe for client exposure + * @returns {{ error: string, message: string }} + */ +export function apiError(code, message) { + return { error: code, message }; +} diff --git a/src/core/api/nx-route.js b/src/core/api/route-handler.js similarity index 100% rename from src/core/api/nx-route.js rename to src/core/api/route-handler.js diff --git a/src/core/api/router.js b/src/core/api/router.js index 7966408..0ed0377 100644 --- a/src/core/api/router.js +++ b/src/core/api/router.js @@ -1,10 +1,17 @@ /** * API Router - * Routes incoming requests to appropriate handlers - * - * This router supports both: - * - Core routes (health, version, users, storage) - * - Module routes (imported directly from module api.js files) + * + * Generic request router — has no knowledge of specific features. + * Core handlers and modules self-register their routes; this file + * only orchestrates rate limiting, CSRF, auth enforcement, and dispatch. + * + * Request lifecycle: + * route-handler.js → routeRequest() + * → rate limit check (health GET exempt) + * → CSRF origin validation (state-mutating methods only) + * → unified route match (core routes first, then module routes) + * → auth enforcement from route definition + * → handler(request, params, context) */ import { validateSession } from '../../features/auth/lib/session.js'; @@ -13,31 +20,59 @@ import { getSessionCookieName } from '../../shared/lib/appConfig.js'; import { getAllApiRoutes } from '../modules/index.js'; import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '../../features/auth/lib/rateLimit.js'; import { fail } from '../../shared/lib/logger.js'; +import { getCoreRoutes } from './core-routes.js'; +import { apiError } from './respond.js'; -// Core handlers -import { handleHealth } from './handlers/health.js'; -import { - handleGetCurrentUser, - handleGetUserById, - handleListUsers, - handleUpdateProfile, - handleUpdateUserById, - handleUploadProfilePicture, - handleDeleteProfilePicture -} from './handlers/users.js'; -import { handleGetFile } from './handlers/storage.js'; - -// Get cookie name from environment or use default const COOKIE_NAME = getSessionCookieName(); +// --------------------------------------------------------------------------- +// Auth helpers +// --------------------------------------------------------------------------- + +/** + * Require a valid session. Throws if the request carries no valid cookie. + * @param {Request} request + * @returns {Promise} session + */ +export async function requireAuth(_request) { + const cookieStore = await cookies(); + const sessionToken = cookieStore.get(COOKIE_NAME)?.value; + + if (!sessionToken) { + throw new Error('Unauthorized'); + } + + const session = await validateSession(sessionToken); + + if (!session || !session.user) { + throw new Error('Unauthorized'); + } + + return session; +} + +/** + * Require a valid admin session. Throws if not authenticated or not admin. + * @param {Request} request + * @returns {Promise} session + */ +export async function requireAdmin(_request) { + const session = await requireAuth(_request); + + if (session.user.role !== 'admin') { + throw new Error('Admin access required'); + } + + return session; +} + +// --------------------------------------------------------------------------- +// CSRF +// --------------------------------------------------------------------------- + /** * Resolve the canonical application URL from environment variables. - * - * Priority: - * 1. NEXT_PUBLIC_URL_DEV — used when NODE_ENV=development - * 2. NEXT_PUBLIC_URL — used in production - * - * @returns {string|null} + * Priority: NEXT_PUBLIC_URL_DEV (development) → NEXT_PUBLIC_URL (production). */ function resolveAppUrl() { if (process.env.NODE_ENV === 'development' && process.env.NEXT_PUBLIC_URL_DEV) { @@ -47,17 +82,10 @@ function resolveAppUrl() { } /** - * Verify that state-mutating requests (POST/PUT/PATCH/DELETE) originate from - * the expected application origin, blocking cross-site request forgery. - * - * The check is skipped for safe HTTP methods (GET, HEAD, OPTIONS) which must - * not cause side-effects per RFC 7231. - * - * The expected origin is resolved from environment variables (see resolveAppUrl). - * If no URL is configured the check denies the request and logs an error. - * + * Verify that state-mutating requests originate from the expected application + * origin. GET, HEAD, and OPTIONS are exempt per RFC 7231. * @param {Request} request - * @returns {boolean} true if the request passes CSRF validation + * @returns {boolean} */ function passesCsrfCheck(request) { const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS']); @@ -82,7 +110,7 @@ function passesCsrfCheck(request) { return origin === expectedOrigin; } - // No Origin header: fall back to Referer (e.g., some older browsers). + // No Origin header: fall back to Referer (some older browsers). const referer = request.headers.get('referer'); if (referer) { try { @@ -92,282 +120,184 @@ function passesCsrfCheck(request) { } } - // Neither Origin nor Referer present — deny to be safe. + // Neither Origin nor Referer — deny to be safe. return false; } -/** - * Get all module routes from the dynamic module registry - * @returns {Array} Array of route definitions - */ -function getModuleRoutes() { - // Use the dynamic module registry to get all routes - return getAllApiRoutes(); -} +// --------------------------------------------------------------------------- +// Route matching +// --------------------------------------------------------------------------- /** - * Check if user is authenticated - * @param {Request} request - The request object - * @returns {Promise} Session object if authenticated - * @throws {Error} If not authenticated - */ -async function requireAuth(request) { - const cookieStore = await cookies(); - const sessionToken = cookieStore.get(COOKIE_NAME)?.value; - - if (!sessionToken) { - throw new Error('Unauthorized'); - } - - const session = await validateSession(sessionToken); - - if (!session || !session.user) { - throw new Error('Unauthorized'); - } - - return session; -} - -/** - * Check if user is authenticated and is admin - * @param {Request} request - The request object - * @returns {Promise} Session object if authenticated and admin - * @throws {Error} If not authenticated or not admin - */ -async function requireAdmin(request) { - const session = await requireAuth(request); - - if (session.user.role !== 'admin') { - throw new Error('Admin access required'); - } - - return session; -} - -/** - * Route an API request to the appropriate handler - * @param {Request} request - The incoming request - * @param {string[]} path - The path segments after /zen/api/ - * @returns {Promise} - The response data - */ -export async function routeRequest(request, path) { - const method = request.method; - - // Global IP-based rate limit for all API calls (health is exempt) - const isExempt = path[0] === 'health' && method === 'GET'; - if (!isExempt) { - const ip = getIpFromRequest(request); - const rl = checkRateLimit(ip, 'api'); - if (!rl.allowed) { - return { - error: 'Too Many Requests', - message: `Trop de requêtes. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` - }; - } - } - - // CSRF origin validation for state-mutating requests. - if (!passesCsrfCheck(request)) { - return { error: 'Forbidden', message: 'CSRF validation failed' }; - } - - // Try core routes first - const coreResult = await routeCoreRequest(request, path, method); - if (coreResult !== null) { - return coreResult; - } - - // Try module routes (dynamically discovered) - const moduleResult = await routeModuleRequest(request, path, method); - if (moduleResult !== null) { - return moduleResult; - } - - // No matching route — return a generic message without reflecting the - // requested method or path back to the caller (prevents route enumeration). - return { - error: 'Not Found', - message: 'The requested resource does not exist' - }; -} - -/** - * Route core (non-module) requests - * @param {Request} request - The incoming request - * @param {string[]} path - The path segments - * @param {string} method - HTTP method - * @returns {Promise} Response or null if no match - */ -async function routeCoreRequest(request, path, method) { - // Health check endpoint - if (path[0] === 'health' && method === 'GET') { - return await handleHealth(); - } - - // Storage endpoint - serve files securely - if (path[0] === 'storage' && method === 'GET') { - const fileKey = path.slice(1).join('/'); - if (!fileKey) { - return { - error: 'Bad Request', - message: 'File path is required' - }; - } - return await handleGetFile(request, fileKey); - } - - // User endpoints - if (path[0] === 'users') { - // GET /zen/api/users - List all users (admin only) - if (path.length === 1 && method === 'GET') { - return await handleListUsers(request); - } - - // GET /zen/api/users/me - Get current user - if (path[1] === 'me' && method === 'GET') { - return await handleGetCurrentUser(request); - } - - // PUT /zen/api/users/profile - Update current user profile - if (path[1] === 'profile' && method === 'PUT') { - return await handleUpdateProfile(request); - } - - // POST /zen/api/users/profile/picture - Upload profile picture - if (path[1] === 'profile' && path[2] === 'picture' && method === 'POST') { - return await handleUploadProfilePicture(request); - } - - // DELETE /zen/api/users/profile/picture - Delete profile picture - if (path[1] === 'profile' && path[2] === 'picture' && method === 'DELETE') { - return await handleDeleteProfilePicture(request); - } - - // GET /zen/api/users/:id - Get user by ID (admin only) - if (path[1] && path[1] !== 'me' && path[1] !== 'profile' && method === 'GET') { - return await handleGetUserById(request, path[1]); - } - - // PUT /zen/api/users/:id - Update user by ID (admin only) - if (path[1] && path[1] !== 'me' && path[1] !== 'profile' && method === 'PUT') { - return await handleUpdateUserById(request, path[1]); - } - } - - return null; -} - -/** - * Route module requests - * @param {Request} request - The incoming request - * @param {string[]} path - The path segments - * @param {string} method - HTTP method - * @returns {Promise} Response or null if no match - */ -async function routeModuleRequest(request, path, method) { - // Get routes from enabled modules - const routes = getModuleRoutes(); - - // Convert path array to path string - const pathString = '/' + path.join('/'); - - // Find matching route - for (const route of routes) { - if (matchRoute(route.path, pathString) && route.method === method) { - // Check authentication - try { - if (route.auth === 'admin') { - await requireAdmin(request); - } else if (route.auth === 'user' || route.auth === 'auth') { - await requireAuth(request); - } - // 'public' or undefined means no auth required - - // Call the handler - if (typeof route.handler === 'function') { - // Extract path parameters - const params = extractPathParams(route.path, pathString); - return await route.handler(request, params); - } - } catch (err) { - // Only the two known auth-error strings are safe to surface verbatim. - // Any other exception (database errors, upstream API errors, etc.) must - // never reach the client raw — they can contain credentials, table names, - // or internal hostnames. Log the full error server-side only. - const SAFE_AUTH_MESSAGES = new Set(['Unauthorized', 'Admin access required']); - if (!SAFE_AUTH_MESSAGES.has(err.message)) { - fail(`Module route handler error: ${err.message}`); - } - return { - success: false, - error: SAFE_AUTH_MESSAGES.has(err.message) ? err.message : 'Internal Server Error' - }; - } - } - } - - return null; -} - -/** - * Match a route pattern against a path - * Supports path parameters like :id - * @param {string} pattern - Route pattern (e.g., '/admin/invoices/:id') - * @param {string} path - Actual path (e.g., '/admin/invoices/123') - * @returns {boolean} True if matches + * Match a route pattern against a request path. + * + * Supports: + * - Exact segments: '/health' + * - Named params: '/users/:id' + * - Greedy wildcard (end only): '/storage/**' + * + * @param {string} pattern - Route pattern + * @param {string} path - Actual request path (e.g. '/users/42') + * @returns {boolean} */ function matchRoute(pattern, path) { const patternParts = pattern.split('/').filter(Boolean); const pathParts = path.split('/').filter(Boolean); - - if (patternParts.length !== pathParts.length) { - return false; + + const hasWildcard = patternParts[patternParts.length - 1] === '**'; + + if (hasWildcard) { + const fixedParts = patternParts.slice(0, -1); + if (pathParts.length < fixedParts.length) return false; + for (let i = 0; i < fixedParts.length; i++) { + if (fixedParts[i].startsWith(':')) continue; + if (fixedParts[i] !== pathParts[i]) return false; + } + return true; } - + + if (patternParts.length !== pathParts.length) return false; + for (let i = 0; i < patternParts.length; i++) { - const patternPart = patternParts[i]; - const pathPart = pathParts[i]; - - // Skip parameter parts (they match anything) - if (patternPart.startsWith(':')) { - continue; - } - - if (patternPart !== pathPart) { - return false; - } + if (patternParts[i].startsWith(':')) continue; + if (patternParts[i] !== pathParts[i]) return false; } - + return true; } /** - * Extract path parameters from a path - * @param {string} pattern - Route pattern (e.g., '/admin/invoices/:id') - * @param {string} path - Actual path (e.g., '/admin/invoices/123') - * @returns {Object} Path parameters + * Extract named path parameters (and wildcard) from a matched route. + * + * @param {string} pattern - Route pattern (e.g. '/users/:id') + * @param {string} path - Actual path (e.g. '/users/42') + * @returns {Object} params — named params + optional `wildcard` string */ function extractPathParams(pattern, path) { const params = {}; const patternParts = pattern.split('/').filter(Boolean); const pathParts = path.split('/').filter(Boolean); - + for (let i = 0; i < patternParts.length; i++) { - const patternPart = patternParts[i]; - - if (patternPart.startsWith(':')) { - const paramName = patternPart.slice(1); - params[paramName] = pathParts[i]; + const part = patternParts[i]; + if (part === '**') { + params.wildcard = pathParts.slice(i).join('/'); + break; + } + if (part.startsWith(':')) { + params[part.slice(1)] = pathParts[i]; } } - + return params; } +// --------------------------------------------------------------------------- +// Main router +// --------------------------------------------------------------------------- + +// Messages safe to surface to clients verbatim. +const SAFE_AUTH_MESSAGES = new Set(['Unauthorized', 'Admin access required']); + +// Emitted at most once per process lifetime to avoid log flooding while still +// alerting operators that per-IP rate limiting is inactive. +let _rateLimitUnavailableWarned = false; + /** - * Get the HTTP status code based on the response - * @param {Object} response - The response object - * @returns {number} - HTTP status code + * Route an API request to the appropriate handler. + * + * @param {Request} request - Incoming Next.js request + * @param {string[]} path - Path segments after /zen/api/ + * @returns {Promise} Response payload (serialised to JSON by route-handler.js) + */ +export async function routeRequest(request, path) { + const method = request.method; + const pathString = '/' + path.join('/'); + + // IP-based rate limit for all API calls. The health endpoint is exempt so + // that monitoring probes do not consume quota. + const isHealthCheck = path[0] === 'health' && method === 'GET'; + if (!isHealthCheck) { + const ip = getIpFromRequest(request); + if (ip === 'unknown') { + // Client IP cannot be resolved — applying rate limiting against the + // shared 'unknown' key would collapse every user's traffic into one + // bucket, allowing a single attacker to exhaust it and deny service to + // all other users (global DoS). Rate limiting is therefore suspended + // until a trusted reverse proxy is configured. + // Operators must set ZEN_TRUST_PROXY=true once a verified proxy + // (Nginx, Cloudflare, AWS ALB, …) strips and rewrites forwarding headers. + if (!_rateLimitUnavailableWarned) { + _rateLimitUnavailableWarned = true; + fail( + 'Rate limiting inactive: client IP cannot be determined. ' + + 'Set ZEN_TRUST_PROXY=true behind a verified reverse proxy to enable per-IP rate limiting.' + ); + } + } else { + const rl = checkRateLimit(ip, 'api'); + if (!rl.allowed) { + return apiError( + 'Too Many Requests', + `Trop de requêtes. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` + ); + } + } + } + + // CSRF origin validation for state-mutating requests. + if (!passesCsrfCheck(request)) { + return apiError('Forbidden', 'CSRF validation failed'); + } + + // Merge all routes — core first so built-ins take precedence over modules. + const allRoutes = [...getCoreRoutes(), ...getAllApiRoutes()]; + + for (const route of allRoutes) { + if (!matchRoute(route.path, pathString) || route.method !== method) { + continue; + } + + // Enforce auth from the route definition before calling the handler. + const context = {}; + try { + if (route.auth === 'admin') { + context.session = await requireAdmin(request); + } else if (route.auth === 'user') { + context.session = await requireAuth(request); + } + // 'public' — context.session remains undefined + } catch (err) { + const code = SAFE_AUTH_MESSAGES.has(err.message) ? err.message : 'Internal Server Error'; + if (!SAFE_AUTH_MESSAGES.has(err.message)) { + fail(`Auth error: ${err.message}`); + } + return apiError(code, code); + } + + const params = extractPathParams(route.path, pathString); + + try { + return await route.handler(request, params, context); + } catch (err) { + fail(`Route handler error [${method} ${route.path}]: ${err.message}`); + return apiError('Internal Server Error', 'An unexpected error occurred. Please try again later.'); + } + } + + // No route matched — return a generic message without reflecting the method + // or path back to the caller to avoid route enumeration. + return apiError('Not Found', 'The requested resource does not exist'); +} + +// --------------------------------------------------------------------------- +// HTTP status mapping +// --------------------------------------------------------------------------- + +/** + * Derive an HTTP status code from the response payload. + * @param {Object} response + * @returns {number} */ export function getStatusCode(response) { if (response.error) { @@ -389,6 +319,3 @@ export function getStatusCode(response) { } return 200; } - -// Export auth helpers for use in module handlers -export { requireAuth, requireAdmin }; diff --git a/src/core/storage/api.js b/src/core/storage/api.js new file mode 100644 index 0000000..fdad9e8 --- /dev/null +++ b/src/core/storage/api.js @@ -0,0 +1,144 @@ +/** + * Storage Feature — API Routes + * + * Serves files from storage with path-based access control. + * + * Auth note: this route is declared as auth: 'public' in the route definition + * because access policy depends on the file path, not a single role. + * The handler enforces its own rules: + * - Public prefix paths → no session required + * - User files → session required; users can only access their own files + * - Organisation files → admin session required + * - Post files (private) → admin session required + * - Unknown paths → denied + */ + +import { validateSession } from '../../features/auth/lib/session.js'; +import { cookies } from 'next/headers'; +import { getSessionCookieName } from '../../shared/lib/appConfig.js'; +import { getAllStoragePublicPrefixes } from '@zen/core/modules/storage'; +import { getFile } from '@zen/core/storage'; +import { fail } from '../../shared/lib/logger.js'; +import { defineApiRoutes } from '../api/define.js'; +import { apiError } from '../api/respond.js'; + +const COOKIE_NAME = getSessionCookieName(); + +/** + * Serve a file from storage with path-based security validation. + * + * @param {Request} request + * @param {{ wildcard: string }} params - wildcard contains the full file key + * @returns {Promise<{ success: true, file: Object }|{ error: string, message: string }>} + */ +async function handleGetFile(request, { wildcard: fileKey }) { + try { + if (!fileKey) { + return apiError('Bad Request', 'File path is required'); + } + + // Reject path traversal sequences, empty segments, and null bytes before + // passing the key to the storage backend. Next.js decodes percent-encoding + // before populating [...path], so '..' and '.' arrive as literal values. + const rawSegments = fileKey.split('/'); + if ( + rawSegments.some(seg => seg === '..' || seg === '.' || seg === '') || + fileKey.includes('\0') + ) { + return apiError('Bad Request', 'Invalid file path'); + } + + const pathParts = rawSegments; + + // Public prefixes: declared by each module via defineModule() storagePublicPrefixes. + // Files whose path starts with a declared prefix are served without authentication. + // The path must have at least two segments beyond the prefix + // ({...prefix}/{id}/{filename}) to prevent unintentional root-level exposure. + const publicPrefixes = getAllStoragePublicPrefixes(); + const matchedPrefix = publicPrefixes.find(prefix => fileKey.startsWith(prefix + '/')); + if (matchedPrefix) { + const prefixDepth = matchedPrefix.split('/').length; + if (pathParts.length < prefixDepth + 2) { + return apiError('Bad Request', 'Invalid file path'); + } + return await fetchFile(fileKey); + } + + // Require authentication for all other paths. + const cookieStore = await cookies(); + const sessionToken = cookieStore.get(COOKIE_NAME)?.value; + + if (!sessionToken) { + return apiError('Unauthorized', 'Authentication required to access files'); + } + + const session = await validateSession(sessionToken); + + if (!session) { + return apiError('Unauthorized', 'Invalid or expired session'); + } + + // Path-based access control for authenticated users. + if (pathParts[0] === 'users') { + // User files: users/{userId}/{category}/{filename} + // Users can only access their own files, unless they are admin. + const userId = pathParts[1]; + if (session.user.id !== userId && session.user.role !== 'admin') { + return apiError('Forbidden', 'You do not have permission to access this file'); + } + } else if (pathParts[0] === 'organizations') { + // Organisation files: admin only. + if (session.user.role !== 'admin') { + return apiError('Forbidden', 'Admin access required for organisation files'); + } + } else if (pathParts[0] === 'posts') { + // Post files not covered by a public prefix: admin only. + if (session.user.role !== 'admin') { + return apiError('Forbidden', 'Admin access required for this file'); + } + } else { + // Unknown path pattern — deny by default. + return apiError('Forbidden', 'Invalid file path'); + } + + return await fetchFile(fileKey); + } catch (error) { + // Log full error server-side; never surface internal details to the client. + fail(`Storage: error serving file: ${error.message}`); + return apiError('Internal Server Error', 'Failed to retrieve file'); + } +} + +/** + * Retrieve a file from the storage backend and return the response envelope. + * @param {string} fileKey + */ +async function fetchFile(fileKey) { + const result = await getFile(fileKey); + + if (!result.success) { + if (result.error.includes('NoSuchKey') || result.error.includes('not found')) { + return apiError('Not Found', 'File not found'); + } + // Never forward raw storage error messages (which may contain bucket names + // or internal keys) to the client. + return apiError('Internal Server Error', 'Failed to retrieve file'); + } + + return { + success: true, + file: { + body: result.data.body, + contentType: result.data.contentType, + contentLength: result.data.contentLength, + lastModified: result.data.lastModified + } + }; +} + +// auth: 'public' — the handler enforces path-based access control internally. +// The router calls it without a session; the handler reads cookies itself for +// non-public paths. +export const routes = defineApiRoutes([ + { path: '/storage/**', method: 'GET', handler: handleGetFile, auth: 'public' } +]); diff --git a/src/features/auth/api.js b/src/features/auth/api.js new file mode 100644 index 0000000..c63c34e --- /dev/null +++ b/src/features/auth/api.js @@ -0,0 +1,356 @@ +/** + * Auth Feature — API Routes + * + * User management endpoints (profile, admin CRUD). + * Auth is enforced by the router before any handler is called — no manual + * session validation is needed here. The validated session is injected via + * the context argument: (request, params, { session }). + */ + +import { query, updateById } from '@zen/core/database'; +import { updateUser } from './lib/auth.js'; +import { uploadImage, deleteFile, generateUniqueFilename, generateUserFilePath, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage'; +import { fail, info } from '../../shared/lib/logger.js'; +import { defineApiRoutes } from '../../core/api/define.js'; +import { apiSuccess, apiError } from '../../core/api/respond.js'; + +/** Maximum number of users returned per paginated request */ +const MAX_PAGE_LIMIT = 100; + +/** + * Extension → MIME type map derived from the validated file extension. + * The client-supplied file.type is NEVER trusted. + */ +const EXTENSION_TO_MIME = { + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', +}; + +/** + * Log the raw error server-side and return an opaque fallback. + * Never forward internal error details to the client. + */ +function logAndObscureError(error, fallback) { + fail(`Internal handler error: ${error.message}`); + return fallback; +} + +// --------------------------------------------------------------------------- +// GET /zen/api/users/me +// --------------------------------------------------------------------------- + +async function handleGetCurrentUser(_request, _params, { session }) { + return apiSuccess({ + user: { + id: session.user.id, + email: session.user.email, + name: session.user.name, + role: session.user.role, + image: session.user.image, + emailVerified: session.user.email_verified, + createdAt: session.user.created_at + } + }); +} + +// --------------------------------------------------------------------------- +// GET /zen/api/users/:id (admin only) +// --------------------------------------------------------------------------- + +async function handleGetUserById(_request, { id: userId }) { + const result = await query( + 'SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users WHERE id = $1', + [userId] + ); + + if (result.rows.length === 0) { + return apiError('Not Found', 'User not found'); + } + + return apiSuccess({ user: result.rows[0] }); +} + +// --------------------------------------------------------------------------- +// PUT /zen/api/users/:id (admin only) +// --------------------------------------------------------------------------- + +async function handleUpdateUserById(request, { id: userId }) { + try { + const body = await request.json(); + const allowedFields = ['name', 'role', 'email_verified']; + const updateData = { updated_at: new Date() }; + + for (const field of allowedFields) { + if (body[field] !== undefined) { + if (field === 'email_verified') { + updateData[field] = Boolean(body[field]); + } else if (field === 'role') { + const role = String(body[field]).toLowerCase(); + if (['admin', 'user'].includes(role)) { + updateData[field] = role; + } + } else if (field === 'name' && body[field] != null) { + updateData[field] = String(body[field]).trim() || null; + } + } + } + + const updated = await updateById('zen_auth_users', userId, updateData); + if (!updated) { + return apiError('Not Found', 'User not found'); + } + + const result = await query( + 'SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users WHERE id = $1', + [userId] + ); + return apiSuccess({ + success: true, + user: result.rows[0], + message: 'User updated successfully' + }); + } catch (error) { + logAndObscureError(error, null); + return apiError('Internal Server Error', 'Failed to update user'); + } +} + +// --------------------------------------------------------------------------- +// GET /zen/api/users (admin only) +// --------------------------------------------------------------------------- + +async function handleListUsers(request) { + // Both page and limit are clamped server-side; client-supplied values + // cannot force full-table scans or negative offsets. + const url = new URL(request.url); + const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10) || 1); + const limit = Math.min( + Math.max(1, parseInt(url.searchParams.get('limit') || '10', 10) || 10), + MAX_PAGE_LIMIT + ); + const offset = (page - 1) * limit; + + const sortBy = url.searchParams.get('sortBy') || 'created_at'; + const sortOrder = url.searchParams.get('sortOrder') || 'desc'; + + // Whitelist allowed sort columns to prevent SQL injection via identifier injection. + const allowedSortColumns = ['id', 'email', 'name', 'role', 'email_verified', 'created_at']; + const sortColumn = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at'; + const order = sortOrder.toLowerCase() === 'asc' ? 'ASC' : 'DESC'; + + // Wrap the whitelisted column in double-quotes to enforce identifier boundaries. + const quotedSortColumn = `"${sortColumn}"`; + + const result = await query( + `SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users ORDER BY ${quotedSortColumn} ${order} LIMIT $1 OFFSET $2`, + [limit, offset] + ); + + const countResult = await query('SELECT COUNT(*) FROM zen_auth_users'); + const total = parseInt(countResult.rows[0].count); + + return apiSuccess({ + users: result.rows, + pagination: { page, limit, total, totalPages: Math.ceil(total / limit) } + }); +} + +// --------------------------------------------------------------------------- +// PUT /zen/api/users/profile +// --------------------------------------------------------------------------- + +async function handleUpdateProfile(request, _params, { session }) { + try { + const body = await request.json(); + const { name } = body; + + if (!name || !name.trim()) { + return apiError('Bad Request', 'Name is required'); + } + + const updatedUser = await updateUser(session.user.id, { name: name.trim() }); + + return apiSuccess({ + success: true, + user: { + id: updatedUser.id, + email: updatedUser.email, + name: updatedUser.name, + role: updatedUser.role, + image: updatedUser.image, + email_verified: updatedUser.email_verified, + created_at: updatedUser.created_at + }, + message: 'Profile updated successfully' + }); + } catch (error) { + logAndObscureError(error, null); + return apiError('Internal Server Error', 'Failed to update profile'); + } +} + +// --------------------------------------------------------------------------- +// POST /zen/api/users/profile/picture +// --------------------------------------------------------------------------- + +async function handleUploadProfilePicture(request, _params, { session }) { + try { + const formData = await request.formData(); + const file = formData.get('file'); + + if (!file) { + return apiError('Bad Request', 'No file provided'); + } + + // Read the buffer once — used for both content inspection and the upload. + const buffer = Buffer.from(await file.arrayBuffer()); + + // Validate file — passes buffer for magic-byte / content-pattern inspection. + const validation = validateUpload({ + filename: file.name, + size: file.size, + allowedTypes: FILE_TYPE_PRESETS.IMAGES, + maxSize: FILE_SIZE_LIMITS.AVATAR, + buffer, + }); + + if (!validation.valid) { + return apiError('Bad Request', validation.errors.join(', ')); + } + + // Check for an existing profile picture to replace later. + const currentUser = await query( + 'SELECT image FROM zen_auth_users WHERE id = $1', + [session.user.id] + ); + const oldImageKey = currentUser.rows[0]?.image ?? null; + + // Generate storage path. + const uniqueFilename = generateUniqueFilename(file.name, 'avatar'); + const key = generateUserFilePath(session.user.id, 'profile', uniqueFilename); + + // Derive content-type from the validated extension — never from file.type, + // which is fully attacker-controlled. + const ext = getFileExtension(file.name).toLowerCase(); + const contentType = EXTENSION_TO_MIME[ext] ?? 'application/octet-stream'; + + const uploadResult = await uploadImage({ + key, + body: buffer, + contentType, + metadata: { userId: session.user.id, originalName: file.name } + }); + + if (!uploadResult.success) { + return apiError('Internal Server Error', 'Failed to upload image'); + } + + // Commit the DB write first. Only on success do we remove the old object — + // an orphaned old object is preferable to a broken DB reference. + let updatedUser; + try { + updatedUser = await updateUser(session.user.id, { image: key }); + } catch (dbError) { + // Roll back the newly uploaded object so storage stays clean. + try { + await deleteFile(key); + } catch (rollbackError) { + fail(`Rollback delete of newly uploaded object failed: ${rollbackError.message}`); + } + throw dbError; + } + + if (oldImageKey) { + try { + await deleteFile(oldImageKey); + info(`Deleted old profile picture: ${oldImageKey}`); + } catch (deleteError) { + // Non-fatal: log for operator cleanup; the DB reference is consistent. + fail(`Failed to delete old profile picture (orphaned object): ${deleteError.message}`); + } + } + + return apiSuccess({ + success: true, + user: { + id: updatedUser.id, + email: updatedUser.email, + name: updatedUser.name, + role: updatedUser.role, + image: updatedUser.image, + email_verified: updatedUser.email_verified, + created_at: updatedUser.created_at + }, + message: 'Profile picture uploaded successfully' + }); + } catch (error) { + logAndObscureError(error, null); + return apiError('Internal Server Error', 'Failed to upload profile picture'); + } +} + +// --------------------------------------------------------------------------- +// DELETE /zen/api/users/profile/picture +// --------------------------------------------------------------------------- + +async function handleDeleteProfilePicture(_request, _params, { session }) { + try { + const currentUser = await query( + 'SELECT image FROM zen_auth_users WHERE id = $1', + [session.user.id] + ); + + if (currentUser.rows.length === 0) { + return apiError('Not Found', 'User not found'); + } + + const imageKey = currentUser.rows[0].image; + if (!imageKey) { + return apiError('Bad Request', 'No profile picture to delete'); + } + + const updatedUser = await updateUser(session.user.id, { image: null }); + + try { + await deleteFile(imageKey); + info(`Deleted profile picture: ${imageKey}`); + } catch (deleteError) { + // Non-fatal: the DB is already updated; log for operator cleanup. + fail(`Failed to delete profile picture from storage: ${deleteError.message}`); + } + + return apiSuccess({ + success: true, + user: { + id: updatedUser.id, + email: updatedUser.email, + name: updatedUser.name, + role: updatedUser.role, + image: updatedUser.image, + email_verified: updatedUser.email_verified, + created_at: updatedUser.created_at + }, + message: 'Profile picture deleted successfully' + }); + } catch (error) { + logAndObscureError(error, null); + return apiError('Internal Server Error', 'Failed to delete profile picture'); + } +} + +// --------------------------------------------------------------------------- +// Route definitions +// --------------------------------------------------------------------------- +// +// Order matters: specific paths (/users/me, /users/profile) must come before +// parameterised paths (/users/:id) so they match first. + +export const routes = defineApiRoutes([ + { path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' }, + { path: '/users/me', method: 'GET', handler: handleGetCurrentUser, auth: 'user' }, + { path: '/users/profile', method: 'PUT', handler: handleUpdateProfile, auth: 'user' }, + { path: '/users/profile/picture', method: 'POST', handler: handleUploadProfilePicture, auth: 'user' }, + { path: '/users/profile/picture', method: 'DELETE', handler: handleDeleteProfilePicture, auth: 'user' }, + { path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin' }, + { path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin' }, +]); diff --git a/src/modules/posts/admin/PostCreatePage.js b/src/modules/posts/admin/PostCreatePage.js index 720ab0f..1f1dc65 100644 --- a/src/modules/posts/admin/PostCreatePage.js +++ b/src/modules/posts/admin/PostCreatePage.js @@ -122,7 +122,7 @@ const PostCreatePage = () => { setFormData(prev => ({ ...prev, [fieldName]: data.key })); toast.success('Image téléchargée'); } else { - toast.error(data.error || 'Échec du téléchargement'); + toast.error(data.message || data.error || 'Échec du téléchargement'); } } catch (error) { console.error('Error uploading image:', error); @@ -178,7 +178,7 @@ const PostCreatePage = () => { toast.success('Post créé avec succès'); router.push(`/admin/posts/${postType}/list`); } else { - toast.error(data.error || data.message || 'Échec de la création'); + toast.error(data.message || data.error || 'Échec de la création'); } } catch (error) { console.error('Error creating post:', error); diff --git a/src/modules/posts/admin/PostEditPage.js b/src/modules/posts/admin/PostEditPage.js index f9a8ae6..63e575f 100644 --- a/src/modules/posts/admin/PostEditPage.js +++ b/src/modules/posts/admin/PostEditPage.js @@ -149,7 +149,7 @@ const PostEditPage = () => { setFormData(prev => ({ ...prev, [fieldName]: data.key })); toast.success('Image téléchargée'); } else { - toast.error(data.error || 'Échec du téléchargement'); + toast.error(data.message || data.error || 'Échec du téléchargement'); } } catch (error) { console.error('Error uploading image:', error); @@ -204,7 +204,7 @@ const PostEditPage = () => { toast.success('Post mis à jour avec succès'); router.push(`/admin/posts/${postType}/list`); } else { - toast.error(data.error || data.message || 'Échec de la mise à jour'); + toast.error(data.message || data.error || 'Échec de la mise à jour'); } } catch (error) { console.error('Error updating post:', error); diff --git a/src/modules/posts/admin/PostsListPage.js b/src/modules/posts/admin/PostsListPage.js index a5a0f0d..9e693bd 100644 --- a/src/modules/posts/admin/PostsListPage.js +++ b/src/modules/posts/admin/PostsListPage.js @@ -75,7 +75,7 @@ const PostsListPage = () => { page: data.page || 1 })); } else { - toast.error(data.error || 'Échec du chargement des posts'); + toast.error(data.message || data.error || 'Échec du chargement des posts'); } } catch (error) { console.error('Error loading posts:', error); @@ -101,7 +101,7 @@ const PostsListPage = () => { toast.success('Post supprimé avec succès'); loadPosts(); } else { - toast.error(data.error || 'Échec de la suppression'); + toast.error(data.message || data.error || 'Échec de la suppression'); } } catch (error) { console.error('Error deleting post:', error); diff --git a/src/modules/posts/api.js b/src/modules/posts/api.js index 0c389d8..0a81e27 100644 --- a/src/modules/posts/api.js +++ b/src/modules/posts/api.js @@ -27,12 +27,24 @@ import { generatePostFilePath, generateUniqueFilename, validateUpload, + getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS } from '@zen/core/storage'; +/** + * Extension → MIME type map derived from the validated file extension. + * The client-supplied file.type is NEVER trusted — it is an attacker-controlled + * multipart field with no server-side enforcement. + */ +const EXTENSION_TO_MIME = { + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', +}; + import { getPostsConfig, getPostType } from './config.js'; import { fail, warn } from '../../shared/lib/logger.js'; +import { defineApiRoutes, apiSuccess, apiError } from '@zen/core/api'; // ============================================================================ // Config @@ -41,9 +53,10 @@ import { fail, warn } from '../../shared/lib/logger.js'; async function handleGetConfig() { try { const config = getPostsConfig(); - return { success: true, config }; + return apiSuccess({ success: true, config }); } catch (error) { - return { success: false, error: error.message || 'Failed to get config' }; + fail(`Posts: error getting config: ${error.message}`); + return apiError('Internal Server Error', 'Failed to get config'); } } @@ -55,14 +68,14 @@ async function handleGetPosts(request) { try { const url = new URL(request.url); const postType = url.searchParams.get('type'); - if (!postType) return { success: false, error: 'Post type is required' }; - if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` }; + if (!postType) return apiError('Bad Request', 'Post type is required'); + if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`); const id = url.searchParams.get('id'); if (id) { const post = await getPostById(postType, parseInt(id)); - if (!post) return { success: false, error: 'Post not found' }; - return { success: true, post }; + if (!post) return apiError('Not Found', 'Post not found'); + return apiSuccess({ success: true, post }); } const page = parseInt(url.searchParams.get('page')) || 1; @@ -83,17 +96,17 @@ async function handleGetPosts(request) { withRelations }); - return { + return apiSuccess({ success: true, posts: result.posts, total: result.pagination.total, totalPages: result.pagination.totalPages, page: result.pagination.page, limit: result.pagination.limit - }; + }); } catch (error) { fail(`Posts: error GET posts: ${error.message}`); - return { success: false, error: error.message || 'Failed to fetch posts' }; + return apiError('Internal Server Error', 'Failed to fetch posts'); } } @@ -101,19 +114,19 @@ async function handleCreatePost(request) { try { const url = new URL(request.url); const postType = url.searchParams.get('type'); - if (!postType) return { success: false, error: 'Post type is required' }; + if (!postType) return apiError('Bad Request', 'Post type is required'); const body = await request.json(); const postData = body.post || body; if (!postData || Object.keys(postData).length === 0) { - return { success: false, error: 'Post data is required' }; + return apiError('Bad Request', 'Post data is required'); } const post = await createPost(postType, postData); - return { success: true, post, message: 'Post created successfully' }; + return apiSuccess({ success: true, post, message: 'Post created successfully' }); } catch (error) { fail(`Posts: error creating post: ${error.message}`); - return { success: false, error: error.message || 'Failed to create post' }; + return apiError('Internal Server Error', 'Failed to create post'); } } @@ -124,19 +137,18 @@ async function handleUpdatePost(request) { const postType = url.searchParams.get('type'); const id = url.searchParams.get('id') || body.id; - if (!postType) return { success: false, error: 'Post type is required' }; - if (!id) return { success: false, error: 'Post ID is required' }; + if (!postType) return apiError('Bad Request', 'Post type is required'); + if (!id) return apiError('Bad Request', 'Post ID is required'); const updates = body.post || (({ id: _i, ...rest }) => rest)(body); if (!updates || Object.keys(updates).length === 0) { - return { success: false, error: 'Update data is required' }; + return apiError('Bad Request', 'Update data is required'); } const typeConfig = getPostType(postType); - // Handle old image cleanup const existing = await getPostById(postType, parseInt(id)); - if (!existing) return { success: false, error: 'Post not found' }; + if (!existing) return apiError('Not Found', 'Post not found'); const post = await updatePost(postType, parseInt(id), updates); @@ -155,10 +167,10 @@ async function handleUpdatePost(request) { } } - return { success: true, post, message: 'Post updated successfully' }; + return apiSuccess({ success: true, post, message: 'Post updated successfully' }); } catch (error) { fail(`Posts: error updating post: ${error.message}`); - return { success: false, error: error.message || 'Failed to update post' }; + return apiError('Internal Server Error', 'Failed to update post'); } } @@ -168,15 +180,15 @@ async function handleDeletePost(request) { const postType = url.searchParams.get('type'); const id = url.searchParams.get('id'); - if (!postType) return { success: false, error: 'Post type is required' }; - if (!id) return { success: false, error: 'Post ID is required' }; + if (!postType) return apiError('Bad Request', 'Post type is required'); + if (!id) return apiError('Bad Request', 'Post ID is required'); const deleted = await deletePost(postType, parseInt(id)); - if (!deleted) return { success: false, error: 'Post not found' }; - return { success: true, message: 'Post deleted successfully' }; + if (!deleted) return apiError('Not Found', 'Post not found'); + return apiSuccess({ success: true, message: 'Post deleted successfully' }); } catch (error) { fail(`Posts: error deleting post: ${error.message}`); - return { success: false, error: 'Failed to delete post' }; + return apiError('Internal Server Error', 'Failed to delete post'); } } @@ -190,40 +202,50 @@ async function handleUploadImage(request) { const file = formData.get('file'); const postType = formData.get('type'); - if (!file) return { success: false, error: 'No file provided' }; - if (!postType) return { success: false, error: 'Post type is required' }; - if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` }; + if (!file) return apiError('Bad Request', 'No file provided'); + if (!postType) return apiError('Bad Request', 'Post type is required'); + if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`); + + // Read the buffer before validation so both magic-byte assertion and + // dangerous-pattern inspection (HTML/SVG/XML denylist) are executed + // against actual file content, not merely the client-supplied metadata. + const buffer = Buffer.from(await file.arrayBuffer()); const validation = validateUpload({ filename: file.name, size: file.size, allowedTypes: FILE_TYPE_PRESETS.IMAGES, - maxSize: FILE_SIZE_LIMITS.IMAGE + maxSize: FILE_SIZE_LIMITS.IMAGE, + buffer, }); if (!validation.valid) { - return { success: false, error: validation.errors.join(', ') }; + return apiError('Bad Request', validation.errors.join(', ')); } const uniqueFilename = generateUniqueFilename(file.name); const key = generatePostFilePath(postType, Date.now(), uniqueFilename); - const buffer = Buffer.from(await file.arrayBuffer()); + + // Derive content-type from the validated extension — never from file.type, + // which is fully attacker-controlled. + const ext = getFileExtension(file.name).toLowerCase(); + const contentType = EXTENSION_TO_MIME[ext] ?? 'application/octet-stream'; const uploadResult = await uploadImage({ key, body: buffer, - contentType: file.type, + contentType, metadata: { originalName: file.name } }); if (!uploadResult.success) { - return { success: false, error: uploadResult.error || 'Upload failed' }; + return apiError('Internal Server Error', 'Upload failed'); } - return { success: true, key: uploadResult.data.key }; + return apiSuccess({ success: true, key: uploadResult.data.key }); } catch (error) { fail(`Posts: error uploading image: ${error.message}`); - return { success: false, error: error.message || 'Upload failed' }; + return apiError('Internal Server Error', 'Upload failed'); } } @@ -235,13 +257,13 @@ async function handleGetCategories(request) { try { const url = new URL(request.url); const postType = url.searchParams.get('type'); - if (!postType) return { success: false, error: 'Post type is required' }; + if (!postType) return apiError('Bad Request', 'Post type is required'); const id = url.searchParams.get('id'); if (id) { const category = await getCategoryById(postType, parseInt(id)); - if (!category) return { success: false, error: 'Category not found' }; - return { success: true, category }; + if (!category) return apiError('Not Found', 'Category not found'); + return apiSuccess({ success: true, category }); } const page = parseInt(url.searchParams.get('page')) || 1; @@ -260,17 +282,17 @@ async function handleGetCategories(request) { sortOrder }); - return { + return apiSuccess({ success: true, categories: result.categories, total: result.pagination.total, totalPages: result.pagination.totalPages, page: result.pagination.page, limit: result.pagination.limit - }; + }); } catch (error) { fail(`Posts: error GET categories: ${error.message}`); - return { success: false, error: error.message || 'Failed to fetch categories' }; + return apiError('Internal Server Error', 'Failed to fetch categories'); } } @@ -278,19 +300,19 @@ async function handleCreateCategory(request) { try { const url = new URL(request.url); const postType = url.searchParams.get('type'); - if (!postType) return { success: false, error: 'Post type is required' }; + if (!postType) return apiError('Bad Request', 'Post type is required'); const body = await request.json(); const categoryData = body.category || body; if (!categoryData || Object.keys(categoryData).length === 0) { - return { success: false, error: 'Category data is required' }; + return apiError('Bad Request', 'Category data is required'); } const category = await createCategory(postType, categoryData); - return { success: true, category, message: 'Category created successfully' }; + return apiSuccess({ success: true, category, message: 'Category created successfully' }); } catch (error) { fail(`Posts: error creating category: ${error.message}`); - return { success: false, error: error.message || 'Failed to create category' }; + return apiError('Internal Server Error', 'Failed to create category'); } } @@ -301,22 +323,22 @@ async function handleUpdateCategory(request) { const postType = url.searchParams.get('type'); const id = url.searchParams.get('id') || body.id; - if (!postType) return { success: false, error: 'Post type is required' }; - if (!id) return { success: false, error: 'Category ID is required' }; + if (!postType) return apiError('Bad Request', 'Post type is required'); + if (!id) return apiError('Bad Request', 'Category ID is required'); const updates = body.category || (({ id: _i, ...rest }) => rest)(body); if (!updates || Object.keys(updates).length === 0) { - return { success: false, error: 'Update data is required' }; + return apiError('Bad Request', 'Update data is required'); } const existing = await getCategoryById(postType, parseInt(id)); - if (!existing) return { success: false, error: 'Category not found' }; + if (!existing) return apiError('Not Found', 'Category not found'); const category = await updateCategory(postType, parseInt(id), updates); - return { success: true, category, message: 'Category updated successfully' }; + return apiSuccess({ success: true, category, message: 'Category updated successfully' }); } catch (error) { fail(`Posts: error updating category: ${error.message}`); - return { success: false, error: error.message || 'Failed to update category' }; + return apiError('Internal Server Error', 'Failed to update category'); } } @@ -326,17 +348,17 @@ async function handleDeleteCategory(request) { const postType = url.searchParams.get('type'); const id = url.searchParams.get('id'); - if (!postType) return { success: false, error: 'Post type is required' }; - if (!id) return { success: false, error: 'Category ID is required' }; + if (!postType) return apiError('Bad Request', 'Post type is required'); + if (!id) return apiError('Bad Request', 'Category ID is required'); await deleteCategory(postType, parseInt(id)); - return { success: true, message: 'Category deleted successfully' }; + return apiSuccess({ success: true, message: 'Category deleted successfully' }); } catch (error) { fail(`Posts: error deleting category: ${error.message}`); if (error.message.includes('Cannot delete')) { - return { success: false, error: error.message }; + return apiError('Bad Request', error.message); } - return { success: false, error: 'Failed to delete category' }; + return apiError('Internal Server Error', 'Failed to delete category'); } } @@ -348,17 +370,17 @@ async function handleSearchPosts(request) { try { const url = new URL(request.url); const postType = url.searchParams.get('type'); - if (!postType) return { success: false, error: 'Post type is required' }; - if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` }; + if (!postType) return apiError('Bad Request', 'Post type is required'); + if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`); const q = url.searchParams.get('q') || ''; const limit = parseInt(url.searchParams.get('limit')) || 20; const posts = await searchPosts(postType, q, limit); - return { success: true, posts }; + return apiSuccess({ success: true, posts }); } catch (error) { fail(`Posts: error searching posts: ${error.message}`); - return { success: false, error: error.message || 'Failed to search posts' }; + return apiError('Internal Server Error', 'Failed to search posts'); } } @@ -369,17 +391,18 @@ async function handleSearchPosts(request) { async function handlePublicGetConfig() { try { const config = getPostsConfig(); - return { success: true, config }; + return apiSuccess({ success: true, config }); } catch (error) { - return { success: false, error: error.message || 'Failed to get config' }; + fail(`Posts: error getting public config: ${error.message}`); + return apiError('Internal Server Error', 'Failed to get config'); } } async function handlePublicGetPosts(request, params) { try { const postType = params?.type; - if (!postType) return { success: false, error: 'Post type is required' }; - if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` }; + if (!postType) return apiError('Bad Request', 'Post type is required'); + if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`); const url = new URL(request.url); const page = parseInt(url.searchParams.get('page')) || 1; @@ -398,17 +421,17 @@ async function handlePublicGetPosts(request, params) { withRelations }); - return { + return apiSuccess({ success: true, posts: result.posts, total: result.pagination.total, totalPages: result.pagination.totalPages, page: result.pagination.page, limit: result.pagination.limit - }; + }); } catch (error) { fail(`Posts: error public GET posts: ${error.message}`); - return { success: false, error: error.message || 'Failed to fetch posts' }; + return apiError('Internal Server Error', 'Failed to fetch posts'); } } @@ -416,27 +439,27 @@ async function handlePublicGetPostBySlug(request, params) { try { const postType = params?.type; const slug = params?.slug; - if (!postType || !slug) return { success: false, error: 'Post type and slug are required' }; + if (!postType || !slug) return apiError('Bad Request', 'Post type and slug are required'); const post = await getPostBySlug(postType, slug); - if (!post) return { success: false, error: 'Post not found' }; - return { success: true, post }; + if (!post) return apiError('Not Found', 'Post not found'); + return apiSuccess({ success: true, post }); } catch (error) { fail(`Posts: error public GET post by slug: ${error.message}`); - return { success: false, error: error.message || 'Failed to fetch post' }; + return apiError('Internal Server Error', 'Failed to fetch post'); } } async function handlePublicGetCategories(request, params) { try { const postType = params?.type; - if (!postType) return { success: false, error: 'Post type is required' }; + if (!postType) return apiError('Bad Request', 'Post type is required'); const categories = await getActiveCategories(postType); - return { success: true, categories }; + return apiSuccess({ success: true, categories }); } catch (error) { fail(`Posts: error public GET categories: ${error.message}`); - return { success: false, error: error.message || 'Failed to fetch categories' }; + return apiError('Internal Server Error', 'Failed to fetch categories'); } } @@ -445,15 +468,15 @@ async function handlePublicGetCategories(request, params) { // ============================================================================ export default { - routes: [ + routes: defineApiRoutes([ // Admin config { path: '/admin/posts/config', method: 'GET', handler: handleGetConfig, auth: 'admin' }, // Admin posts - { path: '/admin/posts/posts', method: 'GET', handler: handleGetPosts, auth: 'admin' }, - { path: '/admin/posts/posts', method: 'POST', handler: handleCreatePost, auth: 'admin' }, - { path: '/admin/posts/posts', method: 'PUT', handler: handleUpdatePost, auth: 'admin' }, - { path: '/admin/posts/posts', method: 'DELETE', handler: handleDeletePost, auth: 'admin' }, + { path: '/admin/posts/posts', method: 'GET', handler: handleGetPosts, auth: 'admin' }, + { path: '/admin/posts/posts', method: 'POST', handler: handleCreatePost, auth: 'admin' }, + { path: '/admin/posts/posts', method: 'PUT', handler: handleUpdatePost, auth: 'admin' }, + { path: '/admin/posts/posts', method: 'DELETE', handler: handleDeletePost, auth: 'admin' }, // Admin image upload { path: '/admin/posts/upload-image', method: 'POST', handler: handleUploadImage, auth: 'admin' }, @@ -462,15 +485,15 @@ export default { { path: '/admin/posts/search', method: 'GET', handler: handleSearchPosts, auth: 'admin' }, // Admin categories - { path: '/admin/posts/categories', method: 'GET', handler: handleGetCategories, auth: 'admin' }, - { path: '/admin/posts/categories', method: 'POST', handler: handleCreateCategory, auth: 'admin' }, - { path: '/admin/posts/categories', method: 'PUT', handler: handleUpdateCategory, auth: 'admin' }, - { path: '/admin/posts/categories', method: 'DELETE', handler: handleDeleteCategory, auth: 'admin' }, + { path: '/admin/posts/categories', method: 'GET', handler: handleGetCategories, auth: 'admin' }, + { path: '/admin/posts/categories', method: 'POST', handler: handleCreateCategory, auth: 'admin' }, + { path: '/admin/posts/categories', method: 'PUT', handler: handleUpdateCategory, auth: 'admin' }, + { path: '/admin/posts/categories', method: 'DELETE', handler: handleDeleteCategory, auth: 'admin' }, // Public - { path: '/posts/config', method: 'GET', handler: handlePublicGetConfig, auth: 'public' }, - { path: '/posts/:type', method: 'GET', handler: handlePublicGetPosts, auth: 'public' }, - { path: '/posts/:type/:slug', method: 'GET', handler: handlePublicGetPostBySlug, auth: 'public' }, + { path: '/posts/config', method: 'GET', handler: handlePublicGetConfig, auth: 'public' }, + { path: '/posts/:type', method: 'GET', handler: handlePublicGetPosts, auth: 'public' }, + { path: '/posts/:type/:slug', method: 'GET', handler: handlePublicGetPostBySlug, auth: 'public' }, { path: '/posts/:type/categories', method: 'GET', handler: handlePublicGetCategories, auth: 'public' }, - ] + ]) }; diff --git a/src/modules/posts/categories/admin/CategoriesListPage.js b/src/modules/posts/categories/admin/CategoriesListPage.js index 32f1712..27bacbe 100644 --- a/src/modules/posts/categories/admin/CategoriesListPage.js +++ b/src/modules/posts/categories/admin/CategoriesListPage.js @@ -116,7 +116,7 @@ const CategoriesListPage = () => { page: data.page || 1 })); } else { - toast.error(data.error || 'Échec du chargement des catégories'); + toast.error(data.message || data.error || 'Échec du chargement des catégories'); } } catch (error) { console.error('Error loading categories:', error); @@ -139,7 +139,7 @@ const CategoriesListPage = () => { toast.success('Catégorie supprimée avec succès'); loadCategories(); } else { - toast.error(data.error || 'Échec de la suppression de la catégorie'); + toast.error(data.message || data.error || 'Échec de la suppression de la catégorie'); } } catch (error) { console.error('Error deleting category:', error); diff --git a/src/modules/posts/categories/admin/CategoryCreatePage.js b/src/modules/posts/categories/admin/CategoryCreatePage.js index a21a512..1cd6cbe 100644 --- a/src/modules/posts/categories/admin/CategoryCreatePage.js +++ b/src/modules/posts/categories/admin/CategoryCreatePage.js @@ -52,7 +52,7 @@ const CategoryCreatePage = () => { toast.success('Catégorie créée avec succès'); router.push(`/admin/posts/${postType}/categories`); } else { - toast.error(data.error || 'Échec de la création'); + toast.error(data.message || data.error || 'Échec de la création'); } } catch (error) { console.error('Error creating category:', error); diff --git a/src/modules/posts/categories/admin/CategoryEditPage.js b/src/modules/posts/categories/admin/CategoryEditPage.js index aea5cec..5c8a3e6 100644 --- a/src/modules/posts/categories/admin/CategoryEditPage.js +++ b/src/modules/posts/categories/admin/CategoryEditPage.js @@ -82,7 +82,7 @@ const CategoryEditPage = () => { toast.success('Catégorie mise à jour avec succès'); router.push(`/admin/posts/${postType}/categories`); } else { - toast.error(data.error || 'Échec de la mise à jour'); + toast.error(data.message || data.error || 'Échec de la mise à jour'); } } catch (error) { console.error('Error updating category:', error); diff --git a/tsup.config.js b/tsup.config.js index ca69b6f..66132a7 100644 --- a/tsup.config.js +++ b/tsup.config.js @@ -14,7 +14,7 @@ export default defineConfig([ 'src/features/admin/pages.js', 'src/features/admin/components/index.js', 'src/core/api/index.js', - 'src/core/api/nx-route.js', + 'src/core/api/route-handler.js', 'src/core/database/index.js', 'src/cli/database.js', 'src/core/email/index.js',