refactor(api): refactor API module with route definitions and response utilities

Restructure the core API module to improve clarity, consistency, and
maintainability:

- Introduce `defineApiRoutes()` helper for declarative route definitions
  with built-in config validation at startup
- Add `apiSuccess()` / `apiError()` response utilities; enforce their
  use across all handlers (core and modules)
- Move auth enforcement to route definitions (`auth: 'public' | 'user' |
  'admin'`), removing manual auth checks from handlers
- Extract core routes into `core-routes.js`; router now has no knowledge
  of specific features
- Rename `nx-route.js` to `route-handler.js` and update package.json
  export accordingly
- Update ARCHITECTURE.md to reflect new API conventions and point to
  `src/core/api/README.md` for details
This commit is contained in:
2026-04-13 15:13:03 -04:00
parent 89741d4460
commit 4ddf834990
25 changed files with 1261 additions and 1185 deletions
+2
View File
@@ -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 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. > **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.
--- ---
+1 -1
View File
@@ -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. **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. **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.
+56
View File
@@ -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/) :
```
<type>(<scope>): <description courte>
```
---
## 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.
```
-16
View File
@@ -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 - Slugs et noms de dossiers qui correspondent à des routes URL
- Documentations, README.md - 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 :
```
<type>(<scope>): <description courte>
```
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 ## Guide de rédaction
Se référer à `REDACTION.md` avant de rédiger tout contenu textuel. Se référer à `REDACTION.md` avant de rédiger tout contenu textuel.
+1 -1
View File
@@ -83,7 +83,7 @@
"import": "./dist/core/api/index.js" "import": "./dist/core/api/index.js"
}, },
"./zen/api": { "./zen/api": {
"import": "./dist/core/api/nx-route.js" "import": "./dist/core/api/route-handler.js"
}, },
"./database": { "./database": {
"import": "./dist/core/database/index.js" "import": "./dist/core/database/index.js"
+194
View File
@@ -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<Object>` |
| `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.
+29
View File
@@ -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,
];
}
+73
View File
@@ -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 })));
}
-162
View File
@@ -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<Response|Object>} 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'
};
}
}
-602
View File
@@ -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'
};
}
}
@@ -1,14 +1,20 @@
/** /**
* Health Check Handler * 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 // Return only a liveness signal. Process uptime and version strings are
// operational fingerprinting data; exposing them unauthenticated aids // operational fingerprinting data; exposing them unauthenticated aids
// attackers in timing restarts and targeting known-vulnerable versions. // attackers in timing restarts and targeting known-vulnerable versions.
return { return apiSuccess({
status: 'ok', status: 'ok',
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}; });
} }
export const routes = defineApiRoutes([
{ path: '/health', method: 'GET', handler: handleHealth, auth: 'public' }
]);
+8 -13
View File
@@ -1,20 +1,15 @@
/** /**
* Zen API Module * Zen API — Public Surface
* *
* This module exports API utilities for custom handlers * Exports the router entry point, auth helpers, response utilities,
* For route setup, import from '@zen/core/zen/api' * 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 { routeRequest, getStatusCode, requireAuth, requireAdmin } from './router.js';
// Export individual handlers (for custom usage) // Response utilities — use in all handlers (core and modules)
export { handleHealth } from './handlers/health.js'; export { apiSuccess, apiError } from './respond.js';
export {
handleGetCurrentUser,
handleGetUserById,
handleListUsers
} from './handlers/users.js';
// Module API handlers are now self-contained in their respective modules // Route definition helper — use in handler files and module api.js files
// e.g., invoice handlers are in @zen/core/modules/invoice/api export { defineApiRoutes } from './define.js';
+51
View File
@@ -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 };
}
+208 -281
View File
@@ -1,10 +1,17 @@
/** /**
* API Router * API Router
* Routes incoming requests to appropriate handlers
* *
* This router supports both: * Generic request router — has no knowledge of specific features.
* - Core routes (health, version, users, storage) * Core handlers and modules self-register their routes; this file
* - Module routes (imported directly from module api.js files) * 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'; 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 { getAllApiRoutes } from '../modules/index.js';
import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '../../features/auth/lib/rateLimit.js'; import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '../../features/auth/lib/rateLimit.js';
import { fail } from '../../shared/lib/logger.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(); const COOKIE_NAME = getSessionCookieName();
// ---------------------------------------------------------------------------
// Auth helpers
// ---------------------------------------------------------------------------
/**
* Require a valid session. Throws if the request carries no valid cookie.
* @param {Request} request
* @returns {Promise<Object>} 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<Object>} 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. * Resolve the canonical application URL from environment variables.
* * Priority: NEXT_PUBLIC_URL_DEV (development) → NEXT_PUBLIC_URL (production).
* Priority:
* 1. NEXT_PUBLIC_URL_DEV — used when NODE_ENV=development
* 2. NEXT_PUBLIC_URL — used in production
*
* @returns {string|null}
*/ */
function resolveAppUrl() { function resolveAppUrl() {
if (process.env.NODE_ENV === 'development' && process.env.NEXT_PUBLIC_URL_DEV) { 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 * Verify that state-mutating requests originate from the expected application
* the expected application origin, blocking cross-site request forgery. * origin. GET, HEAD, and OPTIONS are exempt per RFC 7231.
*
* 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.
*
* @param {Request} request * @param {Request} request
* @returns {boolean} true if the request passes CSRF validation * @returns {boolean}
*/ */
function passesCsrfCheck(request) { function passesCsrfCheck(request) {
const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS']); const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS']);
@@ -82,7 +110,7 @@ function passesCsrfCheck(request) {
return origin === expectedOrigin; 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'); const referer = request.headers.get('referer');
if (referer) { if (referer) {
try { try {
@@ -92,260 +120,58 @@ function passesCsrfCheck(request) {
} }
} }
// Neither Origin nor Referer present — deny to be safe. // Neither Origin nor Referer — deny to be safe.
return false; return false;
} }
/** // ---------------------------------------------------------------------------
* Get all module routes from the dynamic module registry // Route matching
* @returns {Array} Array of route definitions // ---------------------------------------------------------------------------
*/
function getModuleRoutes() {
// Use the dynamic module registry to get all routes
return getAllApiRoutes();
}
/** /**
* Check if user is authenticated * Match a route pattern against a request path.
* @param {Request} request - The request object *
* @returns {Promise<Object>} Session object if authenticated * Supports:
* @throws {Error} If not authenticated * - Exact segments: '/health'
*/ * - Named params: '/users/:id'
async function requireAuth(request) { * - Greedy wildcard (end only): '/storage/**'
const cookieStore = await cookies(); *
const sessionToken = cookieStore.get(COOKIE_NAME)?.value; * @param {string} pattern - Route pattern
* @param {string} path - Actual request path (e.g. '/users/42')
if (!sessionToken) { * @returns {boolean}
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<Object>} 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<Object>} - 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<Object|null>} 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<Object|null>} 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
*/ */
function matchRoute(pattern, path) { function matchRoute(pattern, path) {
const patternParts = pattern.split('/').filter(Boolean); const patternParts = pattern.split('/').filter(Boolean);
const pathParts = path.split('/').filter(Boolean); const pathParts = path.split('/').filter(Boolean);
if (patternParts.length !== pathParts.length) { const hasWildcard = patternParts[patternParts.length - 1] === '**';
return false;
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++) { for (let i = 0; i < patternParts.length; i++) {
const patternPart = patternParts[i]; if (patternParts[i].startsWith(':')) continue;
const pathPart = pathParts[i]; if (patternParts[i] !== pathParts[i]) return false;
// Skip parameter parts (they match anything)
if (patternPart.startsWith(':')) {
continue;
}
if (patternPart !== pathPart) {
return false;
}
} }
return true; return true;
} }
/** /**
* Extract path parameters from a path * Extract named path parameters (and wildcard) from a matched route.
* @param {string} pattern - Route pattern (e.g., '/admin/invoices/:id') *
* @param {string} path - Actual path (e.g., '/admin/invoices/123') * @param {string} pattern - Route pattern (e.g. '/users/:id')
* @returns {Object} Path parameters * @param {string} path - Actual path (e.g. '/users/42')
* @returns {Object} params — named params + optional `wildcard` string
*/ */
function extractPathParams(pattern, path) { function extractPathParams(pattern, path) {
const params = {}; const params = {};
@@ -353,21 +179,125 @@ function extractPathParams(pattern, path) {
const pathParts = path.split('/').filter(Boolean); const pathParts = path.split('/').filter(Boolean);
for (let i = 0; i < patternParts.length; i++) { for (let i = 0; i < patternParts.length; i++) {
const patternPart = patternParts[i]; const part = patternParts[i];
if (part === '**') {
if (patternPart.startsWith(':')) { params.wildcard = pathParts.slice(i).join('/');
const paramName = patternPart.slice(1); break;
params[paramName] = pathParts[i]; }
if (part.startsWith(':')) {
params[part.slice(1)] = pathParts[i];
} }
} }
return params; 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 * Route an API request to the appropriate handler.
* @param {Object} response - The response object *
* @returns {number} - HTTP status code * @param {Request} request - Incoming Next.js request
* @param {string[]} path - Path segments after /zen/api/
* @returns {Promise<Object>} 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) { export function getStatusCode(response) {
if (response.error) { if (response.error) {
@@ -389,6 +319,3 @@ export function getStatusCode(response) {
} }
return 200; return 200;
} }
// Export auth helpers for use in module handlers
export { requireAuth, requireAdmin };
+144
View File
@@ -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' }
]);
+356
View File
@@ -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' },
]);
+2 -2
View File
@@ -122,7 +122,7 @@ const PostCreatePage = () => {
setFormData(prev => ({ ...prev, [fieldName]: data.key })); setFormData(prev => ({ ...prev, [fieldName]: data.key }));
toast.success('Image téléchargée'); toast.success('Image téléchargée');
} else { } else {
toast.error(data.error || 'Échec du téléchargement'); toast.error(data.message || data.error || 'Échec du téléchargement');
} }
} catch (error) { } catch (error) {
console.error('Error uploading image:', error); console.error('Error uploading image:', error);
@@ -178,7 +178,7 @@ const PostCreatePage = () => {
toast.success('Post créé avec succès'); toast.success('Post créé avec succès');
router.push(`/admin/posts/${postType}/list`); router.push(`/admin/posts/${postType}/list`);
} else { } 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) { } catch (error) {
console.error('Error creating post:', error); console.error('Error creating post:', error);
+2 -2
View File
@@ -149,7 +149,7 @@ const PostEditPage = () => {
setFormData(prev => ({ ...prev, [fieldName]: data.key })); setFormData(prev => ({ ...prev, [fieldName]: data.key }));
toast.success('Image téléchargée'); toast.success('Image téléchargée');
} else { } else {
toast.error(data.error || 'Échec du téléchargement'); toast.error(data.message || data.error || 'Échec du téléchargement');
} }
} catch (error) { } catch (error) {
console.error('Error uploading image:', error); console.error('Error uploading image:', error);
@@ -204,7 +204,7 @@ const PostEditPage = () => {
toast.success('Post mis à jour avec succès'); toast.success('Post mis à jour avec succès');
router.push(`/admin/posts/${postType}/list`); router.push(`/admin/posts/${postType}/list`);
} else { } 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) { } catch (error) {
console.error('Error updating post:', error); console.error('Error updating post:', error);
+2 -2
View File
@@ -75,7 +75,7 @@ const PostsListPage = () => {
page: data.page || 1 page: data.page || 1
})); }));
} else { } else {
toast.error(data.error || 'Échec du chargement des posts'); toast.error(data.message || data.error || 'Échec du chargement des posts');
} }
} catch (error) { } catch (error) {
console.error('Error loading posts:', error); console.error('Error loading posts:', error);
@@ -101,7 +101,7 @@ const PostsListPage = () => {
toast.success('Post supprimé avec succès'); toast.success('Post supprimé avec succès');
loadPosts(); loadPosts();
} else { } else {
toast.error(data.error || 'Échec de la suppression'); toast.error(data.message || data.error || 'Échec de la suppression');
} }
} catch (error) { } catch (error) {
console.error('Error deleting post:', error); console.error('Error deleting post:', error);
+110 -87
View File
@@ -27,12 +27,24 @@ import {
generatePostFilePath, generatePostFilePath,
generateUniqueFilename, generateUniqueFilename,
validateUpload, validateUpload,
getFileExtension,
FILE_TYPE_PRESETS, FILE_TYPE_PRESETS,
FILE_SIZE_LIMITS FILE_SIZE_LIMITS
} from '@zen/core/storage'; } 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 { getPostsConfig, getPostType } from './config.js';
import { fail, warn } from '../../shared/lib/logger.js'; import { fail, warn } from '../../shared/lib/logger.js';
import { defineApiRoutes, apiSuccess, apiError } from '@zen/core/api';
// ============================================================================ // ============================================================================
// Config // Config
@@ -41,9 +53,10 @@ import { fail, warn } from '../../shared/lib/logger.js';
async function handleGetConfig() { async function handleGetConfig() {
try { try {
const config = getPostsConfig(); const config = getPostsConfig();
return { success: true, config }; return apiSuccess({ success: true, config });
} catch (error) { } 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 { try {
const url = new URL(request.url); const url = new URL(request.url);
const postType = url.searchParams.get('type'); 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');
if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` }; if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`);
const id = url.searchParams.get('id'); const id = url.searchParams.get('id');
if (id) { if (id) {
const post = await getPostById(postType, parseInt(id)); const post = await getPostById(postType, parseInt(id));
if (!post) return { success: false, error: 'Post not found' }; if (!post) return apiError('Not Found', 'Post not found');
return { success: true, post }; return apiSuccess({ success: true, post });
} }
const page = parseInt(url.searchParams.get('page')) || 1; const page = parseInt(url.searchParams.get('page')) || 1;
@@ -83,17 +96,17 @@ async function handleGetPosts(request) {
withRelations withRelations
}); });
return { return apiSuccess({
success: true, success: true,
posts: result.posts, posts: result.posts,
total: result.pagination.total, total: result.pagination.total,
totalPages: result.pagination.totalPages, totalPages: result.pagination.totalPages,
page: result.pagination.page, page: result.pagination.page,
limit: result.pagination.limit limit: result.pagination.limit
}; });
} catch (error) { } catch (error) {
fail(`Posts: error GET posts: ${error.message}`); 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 { try {
const url = new URL(request.url); const url = new URL(request.url);
const postType = url.searchParams.get('type'); 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 body = await request.json();
const postData = body.post || body; const postData = body.post || body;
if (!postData || Object.keys(postData).length === 0) { 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); 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) { } catch (error) {
fail(`Posts: error creating post: ${error.message}`); 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 postType = url.searchParams.get('type');
const id = url.searchParams.get('id') || body.id; const id = url.searchParams.get('id') || body.id;
if (!postType) return { success: false, error: 'Post type is required' }; if (!postType) return apiError('Bad Request', 'Post type is required');
if (!id) return { success: false, error: 'Post ID is required' }; if (!id) return apiError('Bad Request', 'Post ID is required');
const updates = body.post || (({ id: _i, ...rest }) => rest)(body); const updates = body.post || (({ id: _i, ...rest }) => rest)(body);
if (!updates || Object.keys(updates).length === 0) { 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); const typeConfig = getPostType(postType);
// Handle old image cleanup
const existing = await getPostById(postType, parseInt(id)); 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); 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) { } catch (error) {
fail(`Posts: error updating post: ${error.message}`); 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 postType = url.searchParams.get('type');
const id = url.searchParams.get('id'); const id = url.searchParams.get('id');
if (!postType) return { success: false, error: 'Post type is required' }; if (!postType) return apiError('Bad Request', 'Post type is required');
if (!id) return { success: false, error: 'Post ID is required' }; if (!id) return apiError('Bad Request', 'Post ID is required');
const deleted = await deletePost(postType, parseInt(id)); const deleted = await deletePost(postType, parseInt(id));
if (!deleted) return { success: false, error: 'Post not found' }; if (!deleted) return apiError('Not Found', 'Post not found');
return { success: true, message: 'Post deleted successfully' }; return apiSuccess({ success: true, message: 'Post deleted successfully' });
} catch (error) { } catch (error) {
fail(`Posts: error deleting post: ${error.message}`); 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 file = formData.get('file');
const postType = formData.get('type'); const postType = formData.get('type');
if (!file) return { success: false, error: 'No file provided' }; if (!file) return apiError('Bad Request', 'No file provided');
if (!postType) return { success: false, error: 'Post type is required' }; if (!postType) return apiError('Bad Request', 'Post type is required');
if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` }; 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({ const validation = validateUpload({
filename: file.name, filename: file.name,
size: file.size, size: file.size,
allowedTypes: FILE_TYPE_PRESETS.IMAGES, allowedTypes: FILE_TYPE_PRESETS.IMAGES,
maxSize: FILE_SIZE_LIMITS.IMAGE maxSize: FILE_SIZE_LIMITS.IMAGE,
buffer,
}); });
if (!validation.valid) { if (!validation.valid) {
return { success: false, error: validation.errors.join(', ') }; return apiError('Bad Request', validation.errors.join(', '));
} }
const uniqueFilename = generateUniqueFilename(file.name); const uniqueFilename = generateUniqueFilename(file.name);
const key = generatePostFilePath(postType, Date.now(), uniqueFilename); 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({ const uploadResult = await uploadImage({
key, key,
body: buffer, body: buffer,
contentType: file.type, contentType,
metadata: { originalName: file.name } metadata: { originalName: file.name }
}); });
if (!uploadResult.success) { 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) { } catch (error) {
fail(`Posts: error uploading image: ${error.message}`); 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 { try {
const url = new URL(request.url); const url = new URL(request.url);
const postType = url.searchParams.get('type'); 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'); const id = url.searchParams.get('id');
if (id) { if (id) {
const category = await getCategoryById(postType, parseInt(id)); const category = await getCategoryById(postType, parseInt(id));
if (!category) return { success: false, error: 'Category not found' }; if (!category) return apiError('Not Found', 'Category not found');
return { success: true, category }; return apiSuccess({ success: true, category });
} }
const page = parseInt(url.searchParams.get('page')) || 1; const page = parseInt(url.searchParams.get('page')) || 1;
@@ -260,17 +282,17 @@ async function handleGetCategories(request) {
sortOrder sortOrder
}); });
return { return apiSuccess({
success: true, success: true,
categories: result.categories, categories: result.categories,
total: result.pagination.total, total: result.pagination.total,
totalPages: result.pagination.totalPages, totalPages: result.pagination.totalPages,
page: result.pagination.page, page: result.pagination.page,
limit: result.pagination.limit limit: result.pagination.limit
}; });
} catch (error) { } catch (error) {
fail(`Posts: error GET categories: ${error.message}`); 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 { try {
const url = new URL(request.url); const url = new URL(request.url);
const postType = url.searchParams.get('type'); 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 body = await request.json();
const categoryData = body.category || body; const categoryData = body.category || body;
if (!categoryData || Object.keys(categoryData).length === 0) { 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); 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) { } catch (error) {
fail(`Posts: error creating category: ${error.message}`); 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 postType = url.searchParams.get('type');
const id = url.searchParams.get('id') || body.id; const id = url.searchParams.get('id') || body.id;
if (!postType) return { success: false, error: 'Post type is required' }; if (!postType) return apiError('Bad Request', 'Post type is required');
if (!id) return { success: false, error: 'Category ID is required' }; if (!id) return apiError('Bad Request', 'Category ID is required');
const updates = body.category || (({ id: _i, ...rest }) => rest)(body); const updates = body.category || (({ id: _i, ...rest }) => rest)(body);
if (!updates || Object.keys(updates).length === 0) { 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)); 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); 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) { } catch (error) {
fail(`Posts: error updating category: ${error.message}`); 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 postType = url.searchParams.get('type');
const id = url.searchParams.get('id'); const id = url.searchParams.get('id');
if (!postType) return { success: false, error: 'Post type is required' }; if (!postType) return apiError('Bad Request', 'Post type is required');
if (!id) return { success: false, error: 'Category ID is required' }; if (!id) return apiError('Bad Request', 'Category ID is required');
await deleteCategory(postType, parseInt(id)); await deleteCategory(postType, parseInt(id));
return { success: true, message: 'Category deleted successfully' }; return apiSuccess({ success: true, message: 'Category deleted successfully' });
} catch (error) { } catch (error) {
fail(`Posts: error deleting category: ${error.message}`); fail(`Posts: error deleting category: ${error.message}`);
if (error.message.includes('Cannot delete')) { 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 { try {
const url = new URL(request.url); const url = new URL(request.url);
const postType = url.searchParams.get('type'); 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');
if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` }; if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`);
const q = url.searchParams.get('q') || ''; const q = url.searchParams.get('q') || '';
const limit = parseInt(url.searchParams.get('limit')) || 20; const limit = parseInt(url.searchParams.get('limit')) || 20;
const posts = await searchPosts(postType, q, limit); const posts = await searchPosts(postType, q, limit);
return { success: true, posts }; return apiSuccess({ success: true, posts });
} catch (error) { } catch (error) {
fail(`Posts: error searching posts: ${error.message}`); 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() { async function handlePublicGetConfig() {
try { try {
const config = getPostsConfig(); const config = getPostsConfig();
return { success: true, config }; return apiSuccess({ success: true, config });
} catch (error) { } 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) { async function handlePublicGetPosts(request, params) {
try { try {
const postType = params?.type; const postType = params?.type;
if (!postType) return { success: false, error: 'Post type is required' }; if (!postType) return apiError('Bad Request', 'Post type is required');
if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` }; if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`);
const url = new URL(request.url); const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page')) || 1; const page = parseInt(url.searchParams.get('page')) || 1;
@@ -398,17 +421,17 @@ async function handlePublicGetPosts(request, params) {
withRelations withRelations
}); });
return { return apiSuccess({
success: true, success: true,
posts: result.posts, posts: result.posts,
total: result.pagination.total, total: result.pagination.total,
totalPages: result.pagination.totalPages, totalPages: result.pagination.totalPages,
page: result.pagination.page, page: result.pagination.page,
limit: result.pagination.limit limit: result.pagination.limit
}; });
} catch (error) { } catch (error) {
fail(`Posts: error public GET posts: ${error.message}`); 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 { try {
const postType = params?.type; const postType = params?.type;
const slug = params?.slug; 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); const post = await getPostBySlug(postType, slug);
if (!post) return { success: false, error: 'Post not found' }; if (!post) return apiError('Not Found', 'Post not found');
return { success: true, post }; return apiSuccess({ success: true, post });
} catch (error) { } catch (error) {
fail(`Posts: error public GET post by slug: ${error.message}`); 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) { async function handlePublicGetCategories(request, params) {
try { try {
const postType = params?.type; 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); const categories = await getActiveCategories(postType);
return { success: true, categories }; return apiSuccess({ success: true, categories });
} catch (error) { } catch (error) {
fail(`Posts: error public GET categories: ${error.message}`); 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 { export default {
routes: [ routes: defineApiRoutes([
// Admin config // Admin config
{ path: '/admin/posts/config', method: 'GET', handler: handleGetConfig, auth: 'admin' }, { path: '/admin/posts/config', method: 'GET', handler: handleGetConfig, auth: 'admin' },
// Admin posts // Admin posts
{ path: '/admin/posts/posts', method: 'GET', handler: handleGetPosts, 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: 'POST', handler: handleCreatePost, auth: 'admin' },
{ path: '/admin/posts/posts', method: 'PUT', handler: handleUpdatePost, 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: 'DELETE', handler: handleDeletePost, auth: 'admin' },
// Admin image upload // Admin image upload
{ path: '/admin/posts/upload-image', method: 'POST', handler: handleUploadImage, auth: 'admin' }, { 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' }, { path: '/admin/posts/search', method: 'GET', handler: handleSearchPosts, auth: 'admin' },
// Admin categories // Admin categories
{ path: '/admin/posts/categories', method: 'GET', handler: handleGetCategories, 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: 'POST', handler: handleCreateCategory, auth: 'admin' },
{ path: '/admin/posts/categories', method: 'PUT', handler: handleUpdateCategory, 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: 'DELETE', handler: handleDeleteCategory, auth: 'admin' },
// Public // Public
{ path: '/posts/config', method: 'GET', handler: handlePublicGetConfig, auth: 'public' }, { path: '/posts/config', method: 'GET', handler: handlePublicGetConfig, auth: 'public' },
{ path: '/posts/:type', method: 'GET', handler: handlePublicGetPosts, auth: 'public' }, { path: '/posts/:type', method: 'GET', handler: handlePublicGetPosts, auth: 'public' },
{ path: '/posts/:type/:slug', method: 'GET', handler: handlePublicGetPostBySlug, auth: 'public' }, { path: '/posts/:type/:slug', method: 'GET', handler: handlePublicGetPostBySlug, auth: 'public' },
{ path: '/posts/:type/categories', method: 'GET', handler: handlePublicGetCategories, auth: 'public' }, { path: '/posts/:type/categories', method: 'GET', handler: handlePublicGetCategories, auth: 'public' },
] ])
}; };
@@ -116,7 +116,7 @@ const CategoriesListPage = () => {
page: data.page || 1 page: data.page || 1
})); }));
} else { } 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) { } catch (error) {
console.error('Error loading categories:', error); console.error('Error loading categories:', error);
@@ -139,7 +139,7 @@ const CategoriesListPage = () => {
toast.success('Catégorie supprimée avec succès'); toast.success('Catégorie supprimée avec succès');
loadCategories(); loadCategories();
} else { } 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) { } catch (error) {
console.error('Error deleting category:', error); console.error('Error deleting category:', error);
@@ -52,7 +52,7 @@ const CategoryCreatePage = () => {
toast.success('Catégorie créée avec succès'); toast.success('Catégorie créée avec succès');
router.push(`/admin/posts/${postType}/categories`); router.push(`/admin/posts/${postType}/categories`);
} else { } else {
toast.error(data.error || 'Échec de la création'); toast.error(data.message || data.error || 'Échec de la création');
} }
} catch (error) { } catch (error) {
console.error('Error creating category:', error); console.error('Error creating category:', error);
@@ -82,7 +82,7 @@ const CategoryEditPage = () => {
toast.success('Catégorie mise à jour avec succès'); toast.success('Catégorie mise à jour avec succès');
router.push(`/admin/posts/${postType}/categories`); router.push(`/admin/posts/${postType}/categories`);
} else { } else {
toast.error(data.error || 'Échec de la mise à jour'); toast.error(data.message || data.error || 'Échec de la mise à jour');
} }
} catch (error) { } catch (error) {
console.error('Error updating category:', error); console.error('Error updating category:', error);
+1 -1
View File
@@ -14,7 +14,7 @@ export default defineConfig([
'src/features/admin/pages.js', 'src/features/admin/pages.js',
'src/features/admin/components/index.js', 'src/features/admin/components/index.js',
'src/core/api/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/core/database/index.js',
'src/cli/database.js', 'src/cli/database.js',
'src/core/email/index.js', 'src/core/email/index.js',