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:
@@ -8,6 +8,8 @@ Pour l'architecture partagée (modules, composants, icônes) : [ARCHITECTURE.md]
|
||||
|
||||
Pour la procédure de publication du package : [PUBLICATION.md](./dev/PUBLICATION.md).
|
||||
|
||||
Pour les conventions de commit : [COMMITS.md](./dev/COMMITS.md).
|
||||
|
||||
> **Contexte projet** : les utilisateurs finaux créent leur projet via `npx @zen/start`, qui génère automatiquement une structure Next.js avec le CMS déjà intégré. Lors d'une assistance ou d'une revue, partez du principe que le projet cible est déjà structuré de cette façon.
|
||||
|
||||
---
|
||||
|
||||
@@ -31,4 +31,4 @@ Ces modules existent pour éviter la duplication. Avant d'écrire du code utilit
|
||||
|
||||
**Tâches planifiées** — Utiliser `src/core/cron` pour créer des tâches cron.
|
||||
|
||||
**API** — Utiliser `src/core/api` pour l'API admin et publique. Toujours définir l'API privée/admin en premier, puis exposer seulement ce qui doit l'être. Toujours vérifier l'authentification sur les routes qui l'exigent.
|
||||
**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.
|
||||
@@ -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,22 +16,6 @@ Tout ce qui est **visible par l'utilisateur** est en **français** :
|
||||
- Slugs et noms de dossiers qui correspondent à des routes URL
|
||||
- Documentations, README.md
|
||||
|
||||
## Messages de commit Git
|
||||
|
||||
Tous les messages de commit doivent être rédigés en **anglais**, en suivant le format conventional commits :
|
||||
|
||||
```
|
||||
<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
|
||||
|
||||
Se référer à `REDACTION.md` avant de rédiger tout contenu textuel.
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@
|
||||
"import": "./dist/core/api/index.js"
|
||||
},
|
||||
"./zen/api": {
|
||||
"import": "./dist/core/api/nx-route.js"
|
||||
"import": "./dist/core/api/route-handler.js"
|
||||
},
|
||||
"./database": {
|
||||
"import": "./dist/core/database/index.js"
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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 })));
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* Returns the status of the API and basic system information
|
||||
*/
|
||||
|
||||
export async function handleHealth() {
|
||||
import { defineApiRoutes } from './define.js';
|
||||
import { apiSuccess } from './respond.js';
|
||||
|
||||
async function handleHealth() {
|
||||
// Return only a liveness signal. Process uptime and version strings are
|
||||
// operational fingerprinting data; exposing them unauthenticated aids
|
||||
// attackers in timing restarts and targeting known-vulnerable versions.
|
||||
return {
|
||||
return apiSuccess({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const routes = defineApiRoutes([
|
||||
{ path: '/health', method: 'GET', handler: handleHealth, auth: 'public' }
|
||||
]);
|
||||
+8
-13
@@ -1,20 +1,15 @@
|
||||
/**
|
||||
* Zen API Module
|
||||
* Zen API — Public Surface
|
||||
*
|
||||
* This module exports API utilities for custom handlers
|
||||
* For route setup, import from '@zen/core/zen/api'
|
||||
* Exports the router entry point, auth helpers, response utilities,
|
||||
* and the route definition helper for use across the application.
|
||||
*/
|
||||
|
||||
// Export router utilities (for custom handlers)
|
||||
// Router
|
||||
export { routeRequest, getStatusCode, requireAuth, requireAdmin } from './router.js';
|
||||
|
||||
// Export individual handlers (for custom usage)
|
||||
export { handleHealth } from './handlers/health.js';
|
||||
export {
|
||||
handleGetCurrentUser,
|
||||
handleGetUserById,
|
||||
handleListUsers
|
||||
} from './handlers/users.js';
|
||||
// Response utilities — use in all handlers (core and modules)
|
||||
export { apiSuccess, apiError } from './respond.js';
|
||||
|
||||
// Module API handlers are now self-contained in their respective modules
|
||||
// e.g., invoice handlers are in @zen/core/modules/invoice/api
|
||||
// Route definition helper — use in handler files and module api.js files
|
||||
export { defineApiRoutes } from './define.js';
|
||||
|
||||
@@ -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
@@ -1,10 +1,17 @@
|
||||
/**
|
||||
* API Router
|
||||
* Routes incoming requests to appropriate handlers
|
||||
*
|
||||
* This router supports both:
|
||||
* - Core routes (health, version, users, storage)
|
||||
* - Module routes (imported directly from module api.js files)
|
||||
* Generic request router — has no knowledge of specific features.
|
||||
* Core handlers and modules self-register their routes; this file
|
||||
* only orchestrates rate limiting, CSRF, auth enforcement, and dispatch.
|
||||
*
|
||||
* Request lifecycle:
|
||||
* route-handler.js → routeRequest()
|
||||
* → rate limit check (health GET exempt)
|
||||
* → CSRF origin validation (state-mutating methods only)
|
||||
* → unified route match (core routes first, then module routes)
|
||||
* → auth enforcement from route definition
|
||||
* → handler(request, params, context)
|
||||
*/
|
||||
|
||||
import { validateSession } from '../../features/auth/lib/session.js';
|
||||
@@ -13,31 +20,59 @@ import { getSessionCookieName } from '../../shared/lib/appConfig.js';
|
||||
import { getAllApiRoutes } from '../modules/index.js';
|
||||
import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '../../features/auth/lib/rateLimit.js';
|
||||
import { fail } from '../../shared/lib/logger.js';
|
||||
import { getCoreRoutes } from './core-routes.js';
|
||||
import { apiError } from './respond.js';
|
||||
|
||||
// Core handlers
|
||||
import { handleHealth } from './handlers/health.js';
|
||||
import {
|
||||
handleGetCurrentUser,
|
||||
handleGetUserById,
|
||||
handleListUsers,
|
||||
handleUpdateProfile,
|
||||
handleUpdateUserById,
|
||||
handleUploadProfilePicture,
|
||||
handleDeleteProfilePicture
|
||||
} from './handlers/users.js';
|
||||
import { handleGetFile } from './handlers/storage.js';
|
||||
|
||||
// Get cookie name from environment or use default
|
||||
const COOKIE_NAME = getSessionCookieName();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Require a valid session. Throws if the request carries no valid cookie.
|
||||
* @param {Request} request
|
||||
* @returns {Promise<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.
|
||||
*
|
||||
* Priority:
|
||||
* 1. NEXT_PUBLIC_URL_DEV — used when NODE_ENV=development
|
||||
* 2. NEXT_PUBLIC_URL — used in production
|
||||
*
|
||||
* @returns {string|null}
|
||||
* Priority: NEXT_PUBLIC_URL_DEV (development) → NEXT_PUBLIC_URL (production).
|
||||
*/
|
||||
function resolveAppUrl() {
|
||||
if (process.env.NODE_ENV === 'development' && process.env.NEXT_PUBLIC_URL_DEV) {
|
||||
@@ -47,17 +82,10 @@ function resolveAppUrl() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that state-mutating requests (POST/PUT/PATCH/DELETE) originate from
|
||||
* the expected application origin, blocking cross-site request forgery.
|
||||
*
|
||||
* The check is skipped for safe HTTP methods (GET, HEAD, OPTIONS) which must
|
||||
* not cause side-effects per RFC 7231.
|
||||
*
|
||||
* The expected origin is resolved from environment variables (see resolveAppUrl).
|
||||
* If no URL is configured the check denies the request and logs an error.
|
||||
*
|
||||
* Verify that state-mutating requests originate from the expected application
|
||||
* origin. GET, HEAD, and OPTIONS are exempt per RFC 7231.
|
||||
* @param {Request} request
|
||||
* @returns {boolean} true if the request passes CSRF validation
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function passesCsrfCheck(request) {
|
||||
const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
@@ -82,7 +110,7 @@ function passesCsrfCheck(request) {
|
||||
return origin === expectedOrigin;
|
||||
}
|
||||
|
||||
// No Origin header: fall back to Referer (e.g., some older browsers).
|
||||
// No Origin header: fall back to Referer (some older browsers).
|
||||
const referer = request.headers.get('referer');
|
||||
if (referer) {
|
||||
try {
|
||||
@@ -92,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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all module routes from the dynamic module registry
|
||||
* @returns {Array} Array of route definitions
|
||||
*/
|
||||
function getModuleRoutes() {
|
||||
// Use the dynamic module registry to get all routes
|
||||
return getAllApiRoutes();
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route matching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
* @param {Request} request - The request object
|
||||
* @returns {Promise<Object>} Session object if authenticated
|
||||
* @throws {Error} If not authenticated
|
||||
*/
|
||||
async function requireAuth(request) {
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const session = await validateSession(sessionToken);
|
||||
|
||||
if (!session || !session.user) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated and is admin
|
||||
* @param {Request} request - The request object
|
||||
* @returns {Promise<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
|
||||
* Match a route pattern against a request path.
|
||||
*
|
||||
* Supports:
|
||||
* - Exact segments: '/health'
|
||||
* - Named params: '/users/:id'
|
||||
* - Greedy wildcard (end only): '/storage/**'
|
||||
*
|
||||
* @param {string} pattern - Route pattern
|
||||
* @param {string} path - Actual request path (e.g. '/users/42')
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function matchRoute(pattern, path) {
|
||||
const patternParts = pattern.split('/').filter(Boolean);
|
||||
const pathParts = path.split('/').filter(Boolean);
|
||||
|
||||
if (patternParts.length !== pathParts.length) {
|
||||
return false;
|
||||
const hasWildcard = patternParts[patternParts.length - 1] === '**';
|
||||
|
||||
if (hasWildcard) {
|
||||
const fixedParts = patternParts.slice(0, -1);
|
||||
if (pathParts.length < fixedParts.length) return false;
|
||||
for (let i = 0; i < fixedParts.length; i++) {
|
||||
if (fixedParts[i].startsWith(':')) continue;
|
||||
if (fixedParts[i] !== pathParts[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (patternParts.length !== pathParts.length) return false;
|
||||
|
||||
for (let i = 0; i < patternParts.length; i++) {
|
||||
const patternPart = patternParts[i];
|
||||
const pathPart = pathParts[i];
|
||||
|
||||
// Skip parameter parts (they match anything)
|
||||
if (patternPart.startsWith(':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (patternPart !== pathPart) {
|
||||
return false;
|
||||
}
|
||||
if (patternParts[i].startsWith(':')) continue;
|
||||
if (patternParts[i] !== pathParts[i]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path parameters from a path
|
||||
* @param {string} pattern - Route pattern (e.g., '/admin/invoices/:id')
|
||||
* @param {string} path - Actual path (e.g., '/admin/invoices/123')
|
||||
* @returns {Object} Path parameters
|
||||
* Extract named path parameters (and wildcard) from a matched route.
|
||||
*
|
||||
* @param {string} pattern - Route pattern (e.g. '/users/:id')
|
||||
* @param {string} path - Actual path (e.g. '/users/42')
|
||||
* @returns {Object} params — named params + optional `wildcard` string
|
||||
*/
|
||||
function extractPathParams(pattern, path) {
|
||||
const params = {};
|
||||
@@ -353,21 +179,125 @@ function extractPathParams(pattern, path) {
|
||||
const pathParts = path.split('/').filter(Boolean);
|
||||
|
||||
for (let i = 0; i < patternParts.length; i++) {
|
||||
const patternPart = patternParts[i];
|
||||
|
||||
if (patternPart.startsWith(':')) {
|
||||
const paramName = patternPart.slice(1);
|
||||
params[paramName] = pathParts[i];
|
||||
const part = patternParts[i];
|
||||
if (part === '**') {
|
||||
params.wildcard = pathParts.slice(i).join('/');
|
||||
break;
|
||||
}
|
||||
if (part.startsWith(':')) {
|
||||
params[part.slice(1)] = pathParts[i];
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main router
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Messages safe to surface to clients verbatim.
|
||||
const SAFE_AUTH_MESSAGES = new Set(['Unauthorized', 'Admin access required']);
|
||||
|
||||
// Emitted at most once per process lifetime to avoid log flooding while still
|
||||
// alerting operators that per-IP rate limiting is inactive.
|
||||
let _rateLimitUnavailableWarned = false;
|
||||
|
||||
/**
|
||||
* Get the HTTP status code based on the response
|
||||
* @param {Object} response - The response object
|
||||
* @returns {number} - HTTP status code
|
||||
* Route an API request to the appropriate handler.
|
||||
*
|
||||
* @param {Request} request - Incoming Next.js request
|
||||
* @param {string[]} path - Path segments after /zen/api/
|
||||
* @returns {Promise<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) {
|
||||
if (response.error) {
|
||||
@@ -389,6 +319,3 @@ export function getStatusCode(response) {
|
||||
}
|
||||
return 200;
|
||||
}
|
||||
|
||||
// Export auth helpers for use in module handlers
|
||||
export { requireAuth, requireAdmin };
|
||||
|
||||
@@ -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' }
|
||||
]);
|
||||
@@ -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' },
|
||||
]);
|
||||
@@ -122,7 +122,7 @@ const PostCreatePage = () => {
|
||||
setFormData(prev => ({ ...prev, [fieldName]: data.key }));
|
||||
toast.success('Image téléchargée');
|
||||
} else {
|
||||
toast.error(data.error || 'Échec du téléchargement');
|
||||
toast.error(data.message || data.error || 'Échec du téléchargement');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
@@ -178,7 +178,7 @@ const PostCreatePage = () => {
|
||||
toast.success('Post créé avec succès');
|
||||
router.push(`/admin/posts/${postType}/list`);
|
||||
} else {
|
||||
toast.error(data.error || data.message || 'Échec de la création');
|
||||
toast.error(data.message || data.error || 'Échec de la création');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating post:', error);
|
||||
|
||||
@@ -149,7 +149,7 @@ const PostEditPage = () => {
|
||||
setFormData(prev => ({ ...prev, [fieldName]: data.key }));
|
||||
toast.success('Image téléchargée');
|
||||
} else {
|
||||
toast.error(data.error || 'Échec du téléchargement');
|
||||
toast.error(data.message || data.error || 'Échec du téléchargement');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
@@ -204,7 +204,7 @@ const PostEditPage = () => {
|
||||
toast.success('Post mis à jour avec succès');
|
||||
router.push(`/admin/posts/${postType}/list`);
|
||||
} else {
|
||||
toast.error(data.error || data.message || 'Échec de la mise à jour');
|
||||
toast.error(data.message || data.error || 'Échec de la mise à jour');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating post:', error);
|
||||
|
||||
@@ -75,7 +75,7 @@ const PostsListPage = () => {
|
||||
page: data.page || 1
|
||||
}));
|
||||
} else {
|
||||
toast.error(data.error || 'Échec du chargement des posts');
|
||||
toast.error(data.message || data.error || 'Échec du chargement des posts');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading posts:', error);
|
||||
@@ -101,7 +101,7 @@ const PostsListPage = () => {
|
||||
toast.success('Post supprimé avec succès');
|
||||
loadPosts();
|
||||
} else {
|
||||
toast.error(data.error || 'Échec de la suppression');
|
||||
toast.error(data.message || data.error || 'Échec de la suppression');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting post:', error);
|
||||
|
||||
+99
-76
@@ -27,12 +27,24 @@ import {
|
||||
generatePostFilePath,
|
||||
generateUniqueFilename,
|
||||
validateUpload,
|
||||
getFileExtension,
|
||||
FILE_TYPE_PRESETS,
|
||||
FILE_SIZE_LIMITS
|
||||
} from '@zen/core/storage';
|
||||
|
||||
/**
|
||||
* Extension → MIME type map derived from the validated file extension.
|
||||
* The client-supplied file.type is NEVER trusted — it is an attacker-controlled
|
||||
* multipart field with no server-side enforcement.
|
||||
*/
|
||||
const EXTENSION_TO_MIME = {
|
||||
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp',
|
||||
};
|
||||
|
||||
import { getPostsConfig, getPostType } from './config.js';
|
||||
import { fail, warn } from '../../shared/lib/logger.js';
|
||||
import { defineApiRoutes, apiSuccess, apiError } from '@zen/core/api';
|
||||
|
||||
// ============================================================================
|
||||
// Config
|
||||
@@ -41,9 +53,10 @@ import { fail, warn } from '../../shared/lib/logger.js';
|
||||
async function handleGetConfig() {
|
||||
try {
|
||||
const config = getPostsConfig();
|
||||
return { success: true, config };
|
||||
return apiSuccess({ success: true, config });
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message || 'Failed to get config' };
|
||||
fail(`Posts: error getting config: ${error.message}`);
|
||||
return apiError('Internal Server Error', 'Failed to get config');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,14 +68,14 @@ async function handleGetPosts(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const postType = url.searchParams.get('type');
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` };
|
||||
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||
if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`);
|
||||
|
||||
const id = url.searchParams.get('id');
|
||||
if (id) {
|
||||
const post = await getPostById(postType, parseInt(id));
|
||||
if (!post) return { success: false, error: 'Post not found' };
|
||||
return { success: true, post };
|
||||
if (!post) return apiError('Not Found', 'Post not found');
|
||||
return apiSuccess({ success: true, post });
|
||||
}
|
||||
|
||||
const page = parseInt(url.searchParams.get('page')) || 1;
|
||||
@@ -83,17 +96,17 @@ async function handleGetPosts(request) {
|
||||
withRelations
|
||||
});
|
||||
|
||||
return {
|
||||
return apiSuccess({
|
||||
success: true,
|
||||
posts: result.posts,
|
||||
total: result.pagination.total,
|
||||
totalPages: result.pagination.totalPages,
|
||||
page: result.pagination.page,
|
||||
limit: result.pagination.limit
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
fail(`Posts: error GET posts: ${error.message}`);
|
||||
return { success: false, error: error.message || 'Failed to fetch posts' };
|
||||
return apiError('Internal Server Error', 'Failed to fetch posts');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,19 +114,19 @@ async function handleCreatePost(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const postType = url.searchParams.get('type');
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||
|
||||
const body = await request.json();
|
||||
const postData = body.post || body;
|
||||
if (!postData || Object.keys(postData).length === 0) {
|
||||
return { success: false, error: 'Post data is required' };
|
||||
return apiError('Bad Request', 'Post data is required');
|
||||
}
|
||||
|
||||
const post = await createPost(postType, postData);
|
||||
return { success: true, post, message: 'Post created successfully' };
|
||||
return apiSuccess({ success: true, post, message: 'Post created successfully' });
|
||||
} catch (error) {
|
||||
fail(`Posts: error creating post: ${error.message}`);
|
||||
return { success: false, error: error.message || 'Failed to create post' };
|
||||
return apiError('Internal Server Error', 'Failed to create post');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,19 +137,18 @@ async function handleUpdatePost(request) {
|
||||
const postType = url.searchParams.get('type');
|
||||
const id = url.searchParams.get('id') || body.id;
|
||||
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
if (!id) return { success: false, error: 'Post ID is required' };
|
||||
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||
if (!id) return apiError('Bad Request', 'Post ID is required');
|
||||
|
||||
const updates = body.post || (({ id: _i, ...rest }) => rest)(body);
|
||||
if (!updates || Object.keys(updates).length === 0) {
|
||||
return { success: false, error: 'Update data is required' };
|
||||
return apiError('Bad Request', 'Update data is required');
|
||||
}
|
||||
|
||||
const typeConfig = getPostType(postType);
|
||||
|
||||
// Handle old image cleanup
|
||||
const existing = await getPostById(postType, parseInt(id));
|
||||
if (!existing) return { success: false, error: 'Post not found' };
|
||||
if (!existing) return apiError('Not Found', 'Post not found');
|
||||
|
||||
const post = await updatePost(postType, parseInt(id), updates);
|
||||
|
||||
@@ -155,10 +167,10 @@ async function handleUpdatePost(request) {
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, post, message: 'Post updated successfully' };
|
||||
return apiSuccess({ success: true, post, message: 'Post updated successfully' });
|
||||
} catch (error) {
|
||||
fail(`Posts: error updating post: ${error.message}`);
|
||||
return { success: false, error: error.message || 'Failed to update post' };
|
||||
return apiError('Internal Server Error', 'Failed to update post');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,15 +180,15 @@ async function handleDeletePost(request) {
|
||||
const postType = url.searchParams.get('type');
|
||||
const id = url.searchParams.get('id');
|
||||
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
if (!id) return { success: false, error: 'Post ID is required' };
|
||||
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||
if (!id) return apiError('Bad Request', 'Post ID is required');
|
||||
|
||||
const deleted = await deletePost(postType, parseInt(id));
|
||||
if (!deleted) return { success: false, error: 'Post not found' };
|
||||
return { success: true, message: 'Post deleted successfully' };
|
||||
if (!deleted) return apiError('Not Found', 'Post not found');
|
||||
return apiSuccess({ success: true, message: 'Post deleted successfully' });
|
||||
} catch (error) {
|
||||
fail(`Posts: error deleting post: ${error.message}`);
|
||||
return { success: false, error: 'Failed to delete post' };
|
||||
return apiError('Internal Server Error', 'Failed to delete post');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,40 +202,50 @@ async function handleUploadImage(request) {
|
||||
const file = formData.get('file');
|
||||
const postType = formData.get('type');
|
||||
|
||||
if (!file) return { success: false, error: 'No file provided' };
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` };
|
||||
if (!file) return apiError('Bad Request', 'No file provided');
|
||||
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||
if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`);
|
||||
|
||||
// Read the buffer before validation so both magic-byte assertion and
|
||||
// dangerous-pattern inspection (HTML/SVG/XML denylist) are executed
|
||||
// against actual file content, not merely the client-supplied metadata.
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const validation = validateUpload({
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
allowedTypes: FILE_TYPE_PRESETS.IMAGES,
|
||||
maxSize: FILE_SIZE_LIMITS.IMAGE
|
||||
maxSize: FILE_SIZE_LIMITS.IMAGE,
|
||||
buffer,
|
||||
});
|
||||
|
||||
if (!validation.valid) {
|
||||
return { success: false, error: validation.errors.join(', ') };
|
||||
return apiError('Bad Request', validation.errors.join(', '));
|
||||
}
|
||||
|
||||
const uniqueFilename = generateUniqueFilename(file.name);
|
||||
const key = generatePostFilePath(postType, Date.now(), uniqueFilename);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
// Derive content-type from the validated extension — never from file.type,
|
||||
// which is fully attacker-controlled.
|
||||
const ext = getFileExtension(file.name).toLowerCase();
|
||||
const contentType = EXTENSION_TO_MIME[ext] ?? 'application/octet-stream';
|
||||
|
||||
const uploadResult = await uploadImage({
|
||||
key,
|
||||
body: buffer,
|
||||
contentType: file.type,
|
||||
contentType,
|
||||
metadata: { originalName: file.name }
|
||||
});
|
||||
|
||||
if (!uploadResult.success) {
|
||||
return { success: false, error: uploadResult.error || 'Upload failed' };
|
||||
return apiError('Internal Server Error', 'Upload failed');
|
||||
}
|
||||
|
||||
return { success: true, key: uploadResult.data.key };
|
||||
return apiSuccess({ success: true, key: uploadResult.data.key });
|
||||
} catch (error) {
|
||||
fail(`Posts: error uploading image: ${error.message}`);
|
||||
return { success: false, error: error.message || 'Upload failed' };
|
||||
return apiError('Internal Server Error', 'Upload failed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,13 +257,13 @@ async function handleGetCategories(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const postType = url.searchParams.get('type');
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||
|
||||
const id = url.searchParams.get('id');
|
||||
if (id) {
|
||||
const category = await getCategoryById(postType, parseInt(id));
|
||||
if (!category) return { success: false, error: 'Category not found' };
|
||||
return { success: true, category };
|
||||
if (!category) return apiError('Not Found', 'Category not found');
|
||||
return apiSuccess({ success: true, category });
|
||||
}
|
||||
|
||||
const page = parseInt(url.searchParams.get('page')) || 1;
|
||||
@@ -260,17 +282,17 @@ async function handleGetCategories(request) {
|
||||
sortOrder
|
||||
});
|
||||
|
||||
return {
|
||||
return apiSuccess({
|
||||
success: true,
|
||||
categories: result.categories,
|
||||
total: result.pagination.total,
|
||||
totalPages: result.pagination.totalPages,
|
||||
page: result.pagination.page,
|
||||
limit: result.pagination.limit
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
fail(`Posts: error GET categories: ${error.message}`);
|
||||
return { success: false, error: error.message || 'Failed to fetch categories' };
|
||||
return apiError('Internal Server Error', 'Failed to fetch categories');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,19 +300,19 @@ async function handleCreateCategory(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const postType = url.searchParams.get('type');
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||
|
||||
const body = await request.json();
|
||||
const categoryData = body.category || body;
|
||||
if (!categoryData || Object.keys(categoryData).length === 0) {
|
||||
return { success: false, error: 'Category data is required' };
|
||||
return apiError('Bad Request', 'Category data is required');
|
||||
}
|
||||
|
||||
const category = await createCategory(postType, categoryData);
|
||||
return { success: true, category, message: 'Category created successfully' };
|
||||
return apiSuccess({ success: true, category, message: 'Category created successfully' });
|
||||
} catch (error) {
|
||||
fail(`Posts: error creating category: ${error.message}`);
|
||||
return { success: false, error: error.message || 'Failed to create category' };
|
||||
return apiError('Internal Server Error', 'Failed to create category');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,22 +323,22 @@ async function handleUpdateCategory(request) {
|
||||
const postType = url.searchParams.get('type');
|
||||
const id = url.searchParams.get('id') || body.id;
|
||||
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
if (!id) return { success: false, error: 'Category ID is required' };
|
||||
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||
if (!id) return apiError('Bad Request', 'Category ID is required');
|
||||
|
||||
const updates = body.category || (({ id: _i, ...rest }) => rest)(body);
|
||||
if (!updates || Object.keys(updates).length === 0) {
|
||||
return { success: false, error: 'Update data is required' };
|
||||
return apiError('Bad Request', 'Update data is required');
|
||||
}
|
||||
|
||||
const existing = await getCategoryById(postType, parseInt(id));
|
||||
if (!existing) return { success: false, error: 'Category not found' };
|
||||
if (!existing) return apiError('Not Found', 'Category not found');
|
||||
|
||||
const category = await updateCategory(postType, parseInt(id), updates);
|
||||
return { success: true, category, message: 'Category updated successfully' };
|
||||
return apiSuccess({ success: true, category, message: 'Category updated successfully' });
|
||||
} catch (error) {
|
||||
fail(`Posts: error updating category: ${error.message}`);
|
||||
return { success: false, error: error.message || 'Failed to update category' };
|
||||
return apiError('Internal Server Error', 'Failed to update category');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,17 +348,17 @@ async function handleDeleteCategory(request) {
|
||||
const postType = url.searchParams.get('type');
|
||||
const id = url.searchParams.get('id');
|
||||
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
if (!id) return { success: false, error: 'Category ID is required' };
|
||||
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||
if (!id) return apiError('Bad Request', 'Category ID is required');
|
||||
|
||||
await deleteCategory(postType, parseInt(id));
|
||||
return { success: true, message: 'Category deleted successfully' };
|
||||
return apiSuccess({ success: true, message: 'Category deleted successfully' });
|
||||
} catch (error) {
|
||||
fail(`Posts: error deleting category: ${error.message}`);
|
||||
if (error.message.includes('Cannot delete')) {
|
||||
return { success: false, error: error.message };
|
||||
return apiError('Bad Request', error.message);
|
||||
}
|
||||
return { success: false, error: 'Failed to delete category' };
|
||||
return apiError('Internal Server Error', 'Failed to delete category');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,17 +370,17 @@ async function handleSearchPosts(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const postType = url.searchParams.get('type');
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` };
|
||||
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||
if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`);
|
||||
|
||||
const q = url.searchParams.get('q') || '';
|
||||
const limit = parseInt(url.searchParams.get('limit')) || 20;
|
||||
|
||||
const posts = await searchPosts(postType, q, limit);
|
||||
return { success: true, posts };
|
||||
return apiSuccess({ success: true, posts });
|
||||
} catch (error) {
|
||||
fail(`Posts: error searching posts: ${error.message}`);
|
||||
return { success: false, error: error.message || 'Failed to search posts' };
|
||||
return apiError('Internal Server Error', 'Failed to search posts');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,17 +391,18 @@ async function handleSearchPosts(request) {
|
||||
async function handlePublicGetConfig() {
|
||||
try {
|
||||
const config = getPostsConfig();
|
||||
return { success: true, config };
|
||||
return apiSuccess({ success: true, config });
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message || 'Failed to get config' };
|
||||
fail(`Posts: error getting public config: ${error.message}`);
|
||||
return apiError('Internal Server Error', 'Failed to get config');
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublicGetPosts(request, params) {
|
||||
try {
|
||||
const postType = params?.type;
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` };
|
||||
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||
if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const page = parseInt(url.searchParams.get('page')) || 1;
|
||||
@@ -398,17 +421,17 @@ async function handlePublicGetPosts(request, params) {
|
||||
withRelations
|
||||
});
|
||||
|
||||
return {
|
||||
return apiSuccess({
|
||||
success: true,
|
||||
posts: result.posts,
|
||||
total: result.pagination.total,
|
||||
totalPages: result.pagination.totalPages,
|
||||
page: result.pagination.page,
|
||||
limit: result.pagination.limit
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
fail(`Posts: error public GET posts: ${error.message}`);
|
||||
return { success: false, error: error.message || 'Failed to fetch posts' };
|
||||
return apiError('Internal Server Error', 'Failed to fetch posts');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,27 +439,27 @@ async function handlePublicGetPostBySlug(request, params) {
|
||||
try {
|
||||
const postType = params?.type;
|
||||
const slug = params?.slug;
|
||||
if (!postType || !slug) return { success: false, error: 'Post type and slug are required' };
|
||||
if (!postType || !slug) return apiError('Bad Request', 'Post type and slug are required');
|
||||
|
||||
const post = await getPostBySlug(postType, slug);
|
||||
if (!post) return { success: false, error: 'Post not found' };
|
||||
return { success: true, post };
|
||||
if (!post) return apiError('Not Found', 'Post not found');
|
||||
return apiSuccess({ success: true, post });
|
||||
} catch (error) {
|
||||
fail(`Posts: error public GET post by slug: ${error.message}`);
|
||||
return { success: false, error: error.message || 'Failed to fetch post' };
|
||||
return apiError('Internal Server Error', 'Failed to fetch post');
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublicGetCategories(request, params) {
|
||||
try {
|
||||
const postType = params?.type;
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||
|
||||
const categories = await getActiveCategories(postType);
|
||||
return { success: true, categories };
|
||||
return apiSuccess({ success: true, categories });
|
||||
} catch (error) {
|
||||
fail(`Posts: error public GET categories: ${error.message}`);
|
||||
return { success: false, error: error.message || 'Failed to fetch categories' };
|
||||
return apiError('Internal Server Error', 'Failed to fetch categories');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,7 +468,7 @@ async function handlePublicGetCategories(request, params) {
|
||||
// ============================================================================
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
routes: defineApiRoutes([
|
||||
// Admin config
|
||||
{ path: '/admin/posts/config', method: 'GET', handler: handleGetConfig, auth: 'admin' },
|
||||
|
||||
@@ -472,5 +495,5 @@ export default {
|
||||
{ path: '/posts/:type', method: 'GET', handler: handlePublicGetPosts, auth: 'public' },
|
||||
{ path: '/posts/:type/:slug', method: 'GET', handler: handlePublicGetPostBySlug, auth: 'public' },
|
||||
{ path: '/posts/:type/categories', method: 'GET', handler: handlePublicGetCategories, auth: 'public' },
|
||||
]
|
||||
])
|
||||
};
|
||||
|
||||
@@ -116,7 +116,7 @@ const CategoriesListPage = () => {
|
||||
page: data.page || 1
|
||||
}));
|
||||
} else {
|
||||
toast.error(data.error || 'Échec du chargement des catégories');
|
||||
toast.error(data.message || data.error || 'Échec du chargement des catégories');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading categories:', error);
|
||||
@@ -139,7 +139,7 @@ const CategoriesListPage = () => {
|
||||
toast.success('Catégorie supprimée avec succès');
|
||||
loadCategories();
|
||||
} else {
|
||||
toast.error(data.error || 'Échec de la suppression de la catégorie');
|
||||
toast.error(data.message || data.error || 'Échec de la suppression de la catégorie');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting category:', error);
|
||||
|
||||
@@ -52,7 +52,7 @@ const CategoryCreatePage = () => {
|
||||
toast.success('Catégorie créée avec succès');
|
||||
router.push(`/admin/posts/${postType}/categories`);
|
||||
} else {
|
||||
toast.error(data.error || 'Échec de la création');
|
||||
toast.error(data.message || data.error || 'Échec de la création');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating category:', error);
|
||||
|
||||
@@ -82,7 +82,7 @@ const CategoryEditPage = () => {
|
||||
toast.success('Catégorie mise à jour avec succès');
|
||||
router.push(`/admin/posts/${postType}/categories`);
|
||||
} else {
|
||||
toast.error(data.error || 'Échec de la mise à jour');
|
||||
toast.error(data.message || data.error || 'Échec de la mise à jour');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating category:', error);
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ export default defineConfig([
|
||||
'src/features/admin/pages.js',
|
||||
'src/features/admin/components/index.js',
|
||||
'src/core/api/index.js',
|
||||
'src/core/api/nx-route.js',
|
||||
'src/core/api/route-handler.js',
|
||||
'src/core/database/index.js',
|
||||
'src/cli/database.js',
|
||||
'src/core/email/index.js',
|
||||
|
||||
Reference in New Issue
Block a user