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 la procédure de publication du package : [PUBLICATION.md](./dev/PUBLICATION.md).
|
||||||
|
|
||||||
|
Pour les conventions de commit : [COMMITS.md](./dev/COMMITS.md).
|
||||||
|
|
||||||
> **Contexte projet** : les utilisateurs finaux créent leur projet via `npx @zen/start`, qui génère automatiquement une structure Next.js avec le CMS déjà intégré. Lors d'une assistance ou d'une revue, partez du principe que le projet cible est déjà structuré de cette façon.
|
> **Contexte projet** : les utilisateurs finaux créent leur projet via `npx @zen/start`, qui génère automatiquement une structure Next.js avec le CMS déjà intégré. Lors d'une assistance ou d'une revue, partez du principe que le projet cible est déjà structuré de cette façon.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -31,4 +31,4 @@ Ces modules existent pour éviter la duplication. Avant d'écrire du code utilit
|
|||||||
|
|
||||||
**Tâches planifiées** — Utiliser `src/core/cron` pour créer des tâches cron.
|
**Tâches planifiées** — Utiliser `src/core/cron` pour créer des tâches cron.
|
||||||
|
|
||||||
**API** — Utiliser `src/core/api` pour l'API admin et publique. Toujours définir l'API privée/admin en premier, puis exposer seulement ce qui doit l'être. Toujours vérifier l'authentification sur les routes qui l'exigent.
|
**API** — Utiliser `src/core/api` pour l'API admin et publique. Définir les routes avec `defineApiRoutes()` (valide la config au démarrage). L'authentification est déclarée dans la définition de route (`auth: 'public' | 'user' | 'admin'`) — ne jamais la vérifier manuellement dans un handler. Retourner `apiSuccess()` / `apiError()` dans tous les handlers. Voir `src/core/api/README.md` pour le détail.
|
||||||
@@ -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
|
- Slugs et noms de dossiers qui correspondent à des routes URL
|
||||||
- Documentations, README.md
|
- Documentations, README.md
|
||||||
|
|
||||||
## Messages de commit Git
|
|
||||||
|
|
||||||
Tous les messages de commit doivent être rédigés en **anglais**, en suivant le format conventional commits :
|
|
||||||
|
|
||||||
```
|
|
||||||
<type>(<scope>): <description courte>
|
|
||||||
```
|
|
||||||
|
|
||||||
Types courants : `feat`, `fix`, `refactor`, `style`, `docs`, `test`, `chore`
|
|
||||||
|
|
||||||
Exemples :
|
|
||||||
- `feat(auth): add OAuth2 login support`
|
|
||||||
- `fix(api): handle null response from payment gateway`
|
|
||||||
- `docs(guide): add git commit message conventions`
|
|
||||||
- `chore(deps): update dependencies`
|
|
||||||
|
|
||||||
## Guide de rédaction
|
## Guide de rédaction
|
||||||
|
|
||||||
Se référer à `REDACTION.md` avant de rédiger tout contenu textuel.
|
Se référer à `REDACTION.md` avant de rédiger tout contenu textuel.
|
||||||
|
|||||||
+1
-1
@@ -83,7 +83,7 @@
|
|||||||
"import": "./dist/core/api/index.js"
|
"import": "./dist/core/api/index.js"
|
||||||
},
|
},
|
||||||
"./zen/api": {
|
"./zen/api": {
|
||||||
"import": "./dist/core/api/nx-route.js"
|
"import": "./dist/core/api/route-handler.js"
|
||||||
},
|
},
|
||||||
"./database": {
|
"./database": {
|
||||||
"import": "./dist/core/database/index.js"
|
"import": "./dist/core/database/index.js"
|
||||||
|
|||||||
@@ -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
|
* Health Check Handler
|
||||||
* Returns the status of the API and basic system information
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export async function handleHealth() {
|
import { defineApiRoutes } from './define.js';
|
||||||
|
import { apiSuccess } from './respond.js';
|
||||||
|
|
||||||
|
async function handleHealth() {
|
||||||
// Return only a liveness signal. Process uptime and version strings are
|
// Return only a liveness signal. Process uptime and version strings are
|
||||||
// operational fingerprinting data; exposing them unauthenticated aids
|
// operational fingerprinting data; exposing them unauthenticated aids
|
||||||
// attackers in timing restarts and targeting known-vulnerable versions.
|
// attackers in timing restarts and targeting known-vulnerable versions.
|
||||||
return {
|
return apiSuccess({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const routes = defineApiRoutes([
|
||||||
|
{ path: '/health', method: 'GET', handler: handleHealth, auth: 'public' }
|
||||||
|
]);
|
||||||
+8
-13
@@ -1,20 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Zen API Module
|
* Zen API — Public Surface
|
||||||
*
|
*
|
||||||
* This module exports API utilities for custom handlers
|
* Exports the router entry point, auth helpers, response utilities,
|
||||||
* For route setup, import from '@zen/core/zen/api'
|
* and the route definition helper for use across the application.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Export router utilities (for custom handlers)
|
// Router
|
||||||
export { routeRequest, getStatusCode, requireAuth, requireAdmin } from './router.js';
|
export { routeRequest, getStatusCode, requireAuth, requireAdmin } from './router.js';
|
||||||
|
|
||||||
// Export individual handlers (for custom usage)
|
// Response utilities — use in all handlers (core and modules)
|
||||||
export { handleHealth } from './handlers/health.js';
|
export { apiSuccess, apiError } from './respond.js';
|
||||||
export {
|
|
||||||
handleGetCurrentUser,
|
|
||||||
handleGetUserById,
|
|
||||||
handleListUsers
|
|
||||||
} from './handlers/users.js';
|
|
||||||
|
|
||||||
// Module API handlers are now self-contained in their respective modules
|
// Route definition helper — use in handler files and module api.js files
|
||||||
// e.g., invoice handlers are in @zen/core/modules/invoice/api
|
export { defineApiRoutes } from './define.js';
|
||||||
|
|||||||
@@ -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
|
* API Router
|
||||||
* Routes incoming requests to appropriate handlers
|
|
||||||
*
|
*
|
||||||
* This router supports both:
|
* Generic request router — has no knowledge of specific features.
|
||||||
* - Core routes (health, version, users, storage)
|
* Core handlers and modules self-register their routes; this file
|
||||||
* - Module routes (imported directly from module api.js files)
|
* only orchestrates rate limiting, CSRF, auth enforcement, and dispatch.
|
||||||
|
*
|
||||||
|
* Request lifecycle:
|
||||||
|
* route-handler.js → routeRequest()
|
||||||
|
* → rate limit check (health GET exempt)
|
||||||
|
* → CSRF origin validation (state-mutating methods only)
|
||||||
|
* → unified route match (core routes first, then module routes)
|
||||||
|
* → auth enforcement from route definition
|
||||||
|
* → handler(request, params, context)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { validateSession } from '../../features/auth/lib/session.js';
|
import { validateSession } from '../../features/auth/lib/session.js';
|
||||||
@@ -13,31 +20,59 @@ import { getSessionCookieName } from '../../shared/lib/appConfig.js';
|
|||||||
import { getAllApiRoutes } from '../modules/index.js';
|
import { getAllApiRoutes } from '../modules/index.js';
|
||||||
import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '../../features/auth/lib/rateLimit.js';
|
import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '../../features/auth/lib/rateLimit.js';
|
||||||
import { fail } from '../../shared/lib/logger.js';
|
import { fail } from '../../shared/lib/logger.js';
|
||||||
|
import { getCoreRoutes } from './core-routes.js';
|
||||||
|
import { apiError } from './respond.js';
|
||||||
|
|
||||||
// Core handlers
|
|
||||||
import { handleHealth } from './handlers/health.js';
|
|
||||||
import {
|
|
||||||
handleGetCurrentUser,
|
|
||||||
handleGetUserById,
|
|
||||||
handleListUsers,
|
|
||||||
handleUpdateProfile,
|
|
||||||
handleUpdateUserById,
|
|
||||||
handleUploadProfilePicture,
|
|
||||||
handleDeleteProfilePicture
|
|
||||||
} from './handlers/users.js';
|
|
||||||
import { handleGetFile } from './handlers/storage.js';
|
|
||||||
|
|
||||||
// Get cookie name from environment or use default
|
|
||||||
const COOKIE_NAME = getSessionCookieName();
|
const COOKIE_NAME = getSessionCookieName();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require a valid session. Throws if the request carries no valid cookie.
|
||||||
|
* @param {Request} request
|
||||||
|
* @returns {Promise<Object>} session
|
||||||
|
*/
|
||||||
|
export async function requireAuth(_request) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||||
|
|
||||||
|
if (!sessionToken) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await validateSession(sessionToken);
|
||||||
|
|
||||||
|
if (!session || !session.user) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require a valid admin session. Throws if not authenticated or not admin.
|
||||||
|
* @param {Request} request
|
||||||
|
* @returns {Promise<Object>} session
|
||||||
|
*/
|
||||||
|
export async function requireAdmin(_request) {
|
||||||
|
const session = await requireAuth(_request);
|
||||||
|
|
||||||
|
if (session.user.role !== 'admin') {
|
||||||
|
throw new Error('Admin access required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CSRF
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the canonical application URL from environment variables.
|
* Resolve the canonical application URL from environment variables.
|
||||||
*
|
* Priority: NEXT_PUBLIC_URL_DEV (development) → NEXT_PUBLIC_URL (production).
|
||||||
* Priority:
|
|
||||||
* 1. NEXT_PUBLIC_URL_DEV — used when NODE_ENV=development
|
|
||||||
* 2. NEXT_PUBLIC_URL — used in production
|
|
||||||
*
|
|
||||||
* @returns {string|null}
|
|
||||||
*/
|
*/
|
||||||
function resolveAppUrl() {
|
function resolveAppUrl() {
|
||||||
if (process.env.NODE_ENV === 'development' && process.env.NEXT_PUBLIC_URL_DEV) {
|
if (process.env.NODE_ENV === 'development' && process.env.NEXT_PUBLIC_URL_DEV) {
|
||||||
@@ -47,17 +82,10 @@ function resolveAppUrl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify that state-mutating requests (POST/PUT/PATCH/DELETE) originate from
|
* Verify that state-mutating requests originate from the expected application
|
||||||
* the expected application origin, blocking cross-site request forgery.
|
* origin. GET, HEAD, and OPTIONS are exempt per RFC 7231.
|
||||||
*
|
|
||||||
* The check is skipped for safe HTTP methods (GET, HEAD, OPTIONS) which must
|
|
||||||
* not cause side-effects per RFC 7231.
|
|
||||||
*
|
|
||||||
* The expected origin is resolved from environment variables (see resolveAppUrl).
|
|
||||||
* If no URL is configured the check denies the request and logs an error.
|
|
||||||
*
|
|
||||||
* @param {Request} request
|
* @param {Request} request
|
||||||
* @returns {boolean} true if the request passes CSRF validation
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
function passesCsrfCheck(request) {
|
function passesCsrfCheck(request) {
|
||||||
const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS']);
|
const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||||
@@ -82,7 +110,7 @@ function passesCsrfCheck(request) {
|
|||||||
return origin === expectedOrigin;
|
return origin === expectedOrigin;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No Origin header: fall back to Referer (e.g., some older browsers).
|
// No Origin header: fall back to Referer (some older browsers).
|
||||||
const referer = request.headers.get('referer');
|
const referer = request.headers.get('referer');
|
||||||
if (referer) {
|
if (referer) {
|
||||||
try {
|
try {
|
||||||
@@ -92,260 +120,58 @@ function passesCsrfCheck(request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Neither Origin nor Referer present — deny to be safe.
|
// Neither Origin nor Referer — deny to be safe.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ---------------------------------------------------------------------------
|
||||||
* Get all module routes from the dynamic module registry
|
// Route matching
|
||||||
* @returns {Array} Array of route definitions
|
// ---------------------------------------------------------------------------
|
||||||
*/
|
|
||||||
function getModuleRoutes() {
|
|
||||||
// Use the dynamic module registry to get all routes
|
|
||||||
return getAllApiRoutes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user is authenticated
|
* Match a route pattern against a request path.
|
||||||
* @param {Request} request - The request object
|
*
|
||||||
* @returns {Promise<Object>} Session object if authenticated
|
* Supports:
|
||||||
* @throws {Error} If not authenticated
|
* - Exact segments: '/health'
|
||||||
*/
|
* - Named params: '/users/:id'
|
||||||
async function requireAuth(request) {
|
* - Greedy wildcard (end only): '/storage/**'
|
||||||
const cookieStore = await cookies();
|
*
|
||||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
* @param {string} pattern - Route pattern
|
||||||
|
* @param {string} path - Actual request path (e.g. '/users/42')
|
||||||
if (!sessionToken) {
|
* @returns {boolean}
|
||||||
throw new Error('Unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await validateSession(sessionToken);
|
|
||||||
|
|
||||||
if (!session || !session.user) {
|
|
||||||
throw new Error('Unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user is authenticated and is admin
|
|
||||||
* @param {Request} request - The request object
|
|
||||||
* @returns {Promise<Object>} Session object if authenticated and admin
|
|
||||||
* @throws {Error} If not authenticated or not admin
|
|
||||||
*/
|
|
||||||
async function requireAdmin(request) {
|
|
||||||
const session = await requireAuth(request);
|
|
||||||
|
|
||||||
if (session.user.role !== 'admin') {
|
|
||||||
throw new Error('Admin access required');
|
|
||||||
}
|
|
||||||
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Route an API request to the appropriate handler
|
|
||||||
* @param {Request} request - The incoming request
|
|
||||||
* @param {string[]} path - The path segments after /zen/api/
|
|
||||||
* @returns {Promise<Object>} - The response data
|
|
||||||
*/
|
|
||||||
export async function routeRequest(request, path) {
|
|
||||||
const method = request.method;
|
|
||||||
|
|
||||||
// Global IP-based rate limit for all API calls (health is exempt)
|
|
||||||
const isExempt = path[0] === 'health' && method === 'GET';
|
|
||||||
if (!isExempt) {
|
|
||||||
const ip = getIpFromRequest(request);
|
|
||||||
const rl = checkRateLimit(ip, 'api');
|
|
||||||
if (!rl.allowed) {
|
|
||||||
return {
|
|
||||||
error: 'Too Many Requests',
|
|
||||||
message: `Trop de requêtes. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSRF origin validation for state-mutating requests.
|
|
||||||
if (!passesCsrfCheck(request)) {
|
|
||||||
return { error: 'Forbidden', message: 'CSRF validation failed' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try core routes first
|
|
||||||
const coreResult = await routeCoreRequest(request, path, method);
|
|
||||||
if (coreResult !== null) {
|
|
||||||
return coreResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try module routes (dynamically discovered)
|
|
||||||
const moduleResult = await routeModuleRequest(request, path, method);
|
|
||||||
if (moduleResult !== null) {
|
|
||||||
return moduleResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No matching route — return a generic message without reflecting the
|
|
||||||
// requested method or path back to the caller (prevents route enumeration).
|
|
||||||
return {
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'The requested resource does not exist'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Route core (non-module) requests
|
|
||||||
* @param {Request} request - The incoming request
|
|
||||||
* @param {string[]} path - The path segments
|
|
||||||
* @param {string} method - HTTP method
|
|
||||||
* @returns {Promise<Object|null>} Response or null if no match
|
|
||||||
*/
|
|
||||||
async function routeCoreRequest(request, path, method) {
|
|
||||||
// Health check endpoint
|
|
||||||
if (path[0] === 'health' && method === 'GET') {
|
|
||||||
return await handleHealth();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Storage endpoint - serve files securely
|
|
||||||
if (path[0] === 'storage' && method === 'GET') {
|
|
||||||
const fileKey = path.slice(1).join('/');
|
|
||||||
if (!fileKey) {
|
|
||||||
return {
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'File path is required'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return await handleGetFile(request, fileKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
// User endpoints
|
|
||||||
if (path[0] === 'users') {
|
|
||||||
// GET /zen/api/users - List all users (admin only)
|
|
||||||
if (path.length === 1 && method === 'GET') {
|
|
||||||
return await handleListUsers(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /zen/api/users/me - Get current user
|
|
||||||
if (path[1] === 'me' && method === 'GET') {
|
|
||||||
return await handleGetCurrentUser(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
// PUT /zen/api/users/profile - Update current user profile
|
|
||||||
if (path[1] === 'profile' && method === 'PUT') {
|
|
||||||
return await handleUpdateProfile(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /zen/api/users/profile/picture - Upload profile picture
|
|
||||||
if (path[1] === 'profile' && path[2] === 'picture' && method === 'POST') {
|
|
||||||
return await handleUploadProfilePicture(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE /zen/api/users/profile/picture - Delete profile picture
|
|
||||||
if (path[1] === 'profile' && path[2] === 'picture' && method === 'DELETE') {
|
|
||||||
return await handleDeleteProfilePicture(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /zen/api/users/:id - Get user by ID (admin only)
|
|
||||||
if (path[1] && path[1] !== 'me' && path[1] !== 'profile' && method === 'GET') {
|
|
||||||
return await handleGetUserById(request, path[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// PUT /zen/api/users/:id - Update user by ID (admin only)
|
|
||||||
if (path[1] && path[1] !== 'me' && path[1] !== 'profile' && method === 'PUT') {
|
|
||||||
return await handleUpdateUserById(request, path[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Route module requests
|
|
||||||
* @param {Request} request - The incoming request
|
|
||||||
* @param {string[]} path - The path segments
|
|
||||||
* @param {string} method - HTTP method
|
|
||||||
* @returns {Promise<Object|null>} Response or null if no match
|
|
||||||
*/
|
|
||||||
async function routeModuleRequest(request, path, method) {
|
|
||||||
// Get routes from enabled modules
|
|
||||||
const routes = getModuleRoutes();
|
|
||||||
|
|
||||||
// Convert path array to path string
|
|
||||||
const pathString = '/' + path.join('/');
|
|
||||||
|
|
||||||
// Find matching route
|
|
||||||
for (const route of routes) {
|
|
||||||
if (matchRoute(route.path, pathString) && route.method === method) {
|
|
||||||
// Check authentication
|
|
||||||
try {
|
|
||||||
if (route.auth === 'admin') {
|
|
||||||
await requireAdmin(request);
|
|
||||||
} else if (route.auth === 'user' || route.auth === 'auth') {
|
|
||||||
await requireAuth(request);
|
|
||||||
}
|
|
||||||
// 'public' or undefined means no auth required
|
|
||||||
|
|
||||||
// Call the handler
|
|
||||||
if (typeof route.handler === 'function') {
|
|
||||||
// Extract path parameters
|
|
||||||
const params = extractPathParams(route.path, pathString);
|
|
||||||
return await route.handler(request, params);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Only the two known auth-error strings are safe to surface verbatim.
|
|
||||||
// Any other exception (database errors, upstream API errors, etc.) must
|
|
||||||
// never reach the client raw — they can contain credentials, table names,
|
|
||||||
// or internal hostnames. Log the full error server-side only.
|
|
||||||
const SAFE_AUTH_MESSAGES = new Set(['Unauthorized', 'Admin access required']);
|
|
||||||
if (!SAFE_AUTH_MESSAGES.has(err.message)) {
|
|
||||||
fail(`Module route handler error: ${err.message}`);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: SAFE_AUTH_MESSAGES.has(err.message) ? err.message : 'Internal Server Error'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Match a route pattern against a path
|
|
||||||
* Supports path parameters like :id
|
|
||||||
* @param {string} pattern - Route pattern (e.g., '/admin/invoices/:id')
|
|
||||||
* @param {string} path - Actual path (e.g., '/admin/invoices/123')
|
|
||||||
* @returns {boolean} True if matches
|
|
||||||
*/
|
*/
|
||||||
function matchRoute(pattern, path) {
|
function matchRoute(pattern, path) {
|
||||||
const patternParts = pattern.split('/').filter(Boolean);
|
const patternParts = pattern.split('/').filter(Boolean);
|
||||||
const pathParts = path.split('/').filter(Boolean);
|
const pathParts = path.split('/').filter(Boolean);
|
||||||
|
|
||||||
if (patternParts.length !== pathParts.length) {
|
const hasWildcard = patternParts[patternParts.length - 1] === '**';
|
||||||
return false;
|
|
||||||
|
if (hasWildcard) {
|
||||||
|
const fixedParts = patternParts.slice(0, -1);
|
||||||
|
if (pathParts.length < fixedParts.length) return false;
|
||||||
|
for (let i = 0; i < fixedParts.length; i++) {
|
||||||
|
if (fixedParts[i].startsWith(':')) continue;
|
||||||
|
if (fixedParts[i] !== pathParts[i]) return false;
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patternParts.length !== pathParts.length) return false;
|
||||||
|
|
||||||
for (let i = 0; i < patternParts.length; i++) {
|
for (let i = 0; i < patternParts.length; i++) {
|
||||||
const patternPart = patternParts[i];
|
if (patternParts[i].startsWith(':')) continue;
|
||||||
const pathPart = pathParts[i];
|
if (patternParts[i] !== pathParts[i]) return false;
|
||||||
|
|
||||||
// Skip parameter parts (they match anything)
|
|
||||||
if (patternPart.startsWith(':')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (patternPart !== pathPart) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract path parameters from a path
|
* Extract named path parameters (and wildcard) from a matched route.
|
||||||
* @param {string} pattern - Route pattern (e.g., '/admin/invoices/:id')
|
*
|
||||||
* @param {string} path - Actual path (e.g., '/admin/invoices/123')
|
* @param {string} pattern - Route pattern (e.g. '/users/:id')
|
||||||
* @returns {Object} Path parameters
|
* @param {string} path - Actual path (e.g. '/users/42')
|
||||||
|
* @returns {Object} params — named params + optional `wildcard` string
|
||||||
*/
|
*/
|
||||||
function extractPathParams(pattern, path) {
|
function extractPathParams(pattern, path) {
|
||||||
const params = {};
|
const params = {};
|
||||||
@@ -353,21 +179,125 @@ function extractPathParams(pattern, path) {
|
|||||||
const pathParts = path.split('/').filter(Boolean);
|
const pathParts = path.split('/').filter(Boolean);
|
||||||
|
|
||||||
for (let i = 0; i < patternParts.length; i++) {
|
for (let i = 0; i < patternParts.length; i++) {
|
||||||
const patternPart = patternParts[i];
|
const part = patternParts[i];
|
||||||
|
if (part === '**') {
|
||||||
if (patternPart.startsWith(':')) {
|
params.wildcard = pathParts.slice(i).join('/');
|
||||||
const paramName = patternPart.slice(1);
|
break;
|
||||||
params[paramName] = pathParts[i];
|
}
|
||||||
|
if (part.startsWith(':')) {
|
||||||
|
params[part.slice(1)] = pathParts[i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main router
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Messages safe to surface to clients verbatim.
|
||||||
|
const SAFE_AUTH_MESSAGES = new Set(['Unauthorized', 'Admin access required']);
|
||||||
|
|
||||||
|
// Emitted at most once per process lifetime to avoid log flooding while still
|
||||||
|
// alerting operators that per-IP rate limiting is inactive.
|
||||||
|
let _rateLimitUnavailableWarned = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the HTTP status code based on the response
|
* Route an API request to the appropriate handler.
|
||||||
* @param {Object} response - The response object
|
*
|
||||||
* @returns {number} - HTTP status code
|
* @param {Request} request - Incoming Next.js request
|
||||||
|
* @param {string[]} path - Path segments after /zen/api/
|
||||||
|
* @returns {Promise<Object>} Response payload (serialised to JSON by route-handler.js)
|
||||||
|
*/
|
||||||
|
export async function routeRequest(request, path) {
|
||||||
|
const method = request.method;
|
||||||
|
const pathString = '/' + path.join('/');
|
||||||
|
|
||||||
|
// IP-based rate limit for all API calls. The health endpoint is exempt so
|
||||||
|
// that monitoring probes do not consume quota.
|
||||||
|
const isHealthCheck = path[0] === 'health' && method === 'GET';
|
||||||
|
if (!isHealthCheck) {
|
||||||
|
const ip = getIpFromRequest(request);
|
||||||
|
if (ip === 'unknown') {
|
||||||
|
// Client IP cannot be resolved — applying rate limiting against the
|
||||||
|
// shared 'unknown' key would collapse every user's traffic into one
|
||||||
|
// bucket, allowing a single attacker to exhaust it and deny service to
|
||||||
|
// all other users (global DoS). Rate limiting is therefore suspended
|
||||||
|
// until a trusted reverse proxy is configured.
|
||||||
|
// Operators must set ZEN_TRUST_PROXY=true once a verified proxy
|
||||||
|
// (Nginx, Cloudflare, AWS ALB, …) strips and rewrites forwarding headers.
|
||||||
|
if (!_rateLimitUnavailableWarned) {
|
||||||
|
_rateLimitUnavailableWarned = true;
|
||||||
|
fail(
|
||||||
|
'Rate limiting inactive: client IP cannot be determined. ' +
|
||||||
|
'Set ZEN_TRUST_PROXY=true behind a verified reverse proxy to enable per-IP rate limiting.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const rl = checkRateLimit(ip, 'api');
|
||||||
|
if (!rl.allowed) {
|
||||||
|
return apiError(
|
||||||
|
'Too Many Requests',
|
||||||
|
`Trop de requêtes. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF origin validation for state-mutating requests.
|
||||||
|
if (!passesCsrfCheck(request)) {
|
||||||
|
return apiError('Forbidden', 'CSRF validation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge all routes — core first so built-ins take precedence over modules.
|
||||||
|
const allRoutes = [...getCoreRoutes(), ...getAllApiRoutes()];
|
||||||
|
|
||||||
|
for (const route of allRoutes) {
|
||||||
|
if (!matchRoute(route.path, pathString) || route.method !== method) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce auth from the route definition before calling the handler.
|
||||||
|
const context = {};
|
||||||
|
try {
|
||||||
|
if (route.auth === 'admin') {
|
||||||
|
context.session = await requireAdmin(request);
|
||||||
|
} else if (route.auth === 'user') {
|
||||||
|
context.session = await requireAuth(request);
|
||||||
|
}
|
||||||
|
// 'public' — context.session remains undefined
|
||||||
|
} catch (err) {
|
||||||
|
const code = SAFE_AUTH_MESSAGES.has(err.message) ? err.message : 'Internal Server Error';
|
||||||
|
if (!SAFE_AUTH_MESSAGES.has(err.message)) {
|
||||||
|
fail(`Auth error: ${err.message}`);
|
||||||
|
}
|
||||||
|
return apiError(code, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = extractPathParams(route.path, pathString);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await route.handler(request, params, context);
|
||||||
|
} catch (err) {
|
||||||
|
fail(`Route handler error [${method} ${route.path}]: ${err.message}`);
|
||||||
|
return apiError('Internal Server Error', 'An unexpected error occurred. Please try again later.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No route matched — return a generic message without reflecting the method
|
||||||
|
// or path back to the caller to avoid route enumeration.
|
||||||
|
return apiError('Not Found', 'The requested resource does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP status mapping
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive an HTTP status code from the response payload.
|
||||||
|
* @param {Object} response
|
||||||
|
* @returns {number}
|
||||||
*/
|
*/
|
||||||
export function getStatusCode(response) {
|
export function getStatusCode(response) {
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
@@ -389,6 +319,3 @@ export function getStatusCode(response) {
|
|||||||
}
|
}
|
||||||
return 200;
|
return 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export auth helpers for use in module handlers
|
|
||||||
export { requireAuth, requireAdmin };
|
|
||||||
|
|||||||
@@ -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 }));
|
setFormData(prev => ({ ...prev, [fieldName]: data.key }));
|
||||||
toast.success('Image téléchargée');
|
toast.success('Image téléchargée');
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || 'Échec du téléchargement');
|
toast.error(data.message || data.error || 'Échec du téléchargement');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading image:', error);
|
console.error('Error uploading image:', error);
|
||||||
@@ -178,7 +178,7 @@ const PostCreatePage = () => {
|
|||||||
toast.success('Post créé avec succès');
|
toast.success('Post créé avec succès');
|
||||||
router.push(`/admin/posts/${postType}/list`);
|
router.push(`/admin/posts/${postType}/list`);
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || data.message || 'Échec de la création');
|
toast.error(data.message || data.error || 'Échec de la création');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating post:', error);
|
console.error('Error creating post:', error);
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ const PostEditPage = () => {
|
|||||||
setFormData(prev => ({ ...prev, [fieldName]: data.key }));
|
setFormData(prev => ({ ...prev, [fieldName]: data.key }));
|
||||||
toast.success('Image téléchargée');
|
toast.success('Image téléchargée');
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || 'Échec du téléchargement');
|
toast.error(data.message || data.error || 'Échec du téléchargement');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading image:', error);
|
console.error('Error uploading image:', error);
|
||||||
@@ -204,7 +204,7 @@ const PostEditPage = () => {
|
|||||||
toast.success('Post mis à jour avec succès');
|
toast.success('Post mis à jour avec succès');
|
||||||
router.push(`/admin/posts/${postType}/list`);
|
router.push(`/admin/posts/${postType}/list`);
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || data.message || 'Échec de la mise à jour');
|
toast.error(data.message || data.error || 'Échec de la mise à jour');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating post:', error);
|
console.error('Error updating post:', error);
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const PostsListPage = () => {
|
|||||||
page: data.page || 1
|
page: data.page || 1
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || 'Échec du chargement des posts');
|
toast.error(data.message || data.error || 'Échec du chargement des posts');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading posts:', error);
|
console.error('Error loading posts:', error);
|
||||||
@@ -101,7 +101,7 @@ const PostsListPage = () => {
|
|||||||
toast.success('Post supprimé avec succès');
|
toast.success('Post supprimé avec succès');
|
||||||
loadPosts();
|
loadPosts();
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || 'Échec de la suppression');
|
toast.error(data.message || data.error || 'Échec de la suppression');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting post:', error);
|
console.error('Error deleting post:', error);
|
||||||
|
|||||||
+99
-76
@@ -27,12 +27,24 @@ import {
|
|||||||
generatePostFilePath,
|
generatePostFilePath,
|
||||||
generateUniqueFilename,
|
generateUniqueFilename,
|
||||||
validateUpload,
|
validateUpload,
|
||||||
|
getFileExtension,
|
||||||
FILE_TYPE_PRESETS,
|
FILE_TYPE_PRESETS,
|
||||||
FILE_SIZE_LIMITS
|
FILE_SIZE_LIMITS
|
||||||
} from '@zen/core/storage';
|
} from '@zen/core/storage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension → MIME type map derived from the validated file extension.
|
||||||
|
* The client-supplied file.type is NEVER trusted — it is an attacker-controlled
|
||||||
|
* multipart field with no server-side enforcement.
|
||||||
|
*/
|
||||||
|
const EXTENSION_TO_MIME = {
|
||||||
|
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp',
|
||||||
|
};
|
||||||
|
|
||||||
import { getPostsConfig, getPostType } from './config.js';
|
import { getPostsConfig, getPostType } from './config.js';
|
||||||
import { fail, warn } from '../../shared/lib/logger.js';
|
import { fail, warn } from '../../shared/lib/logger.js';
|
||||||
|
import { defineApiRoutes, apiSuccess, apiError } from '@zen/core/api';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Config
|
// Config
|
||||||
@@ -41,9 +53,10 @@ import { fail, warn } from '../../shared/lib/logger.js';
|
|||||||
async function handleGetConfig() {
|
async function handleGetConfig() {
|
||||||
try {
|
try {
|
||||||
const config = getPostsConfig();
|
const config = getPostsConfig();
|
||||||
return { success: true, config };
|
return apiSuccess({ success: true, config });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: error.message || 'Failed to get config' };
|
fail(`Posts: error getting config: ${error.message}`);
|
||||||
|
return apiError('Internal Server Error', 'Failed to get config');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,14 +68,14 @@ async function handleGetPosts(request) {
|
|||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const postType = url.searchParams.get('type');
|
const postType = url.searchParams.get('type');
|
||||||
if (!postType) return { success: false, error: 'Post type is required' };
|
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||||
if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` };
|
if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`);
|
||||||
|
|
||||||
const id = url.searchParams.get('id');
|
const id = url.searchParams.get('id');
|
||||||
if (id) {
|
if (id) {
|
||||||
const post = await getPostById(postType, parseInt(id));
|
const post = await getPostById(postType, parseInt(id));
|
||||||
if (!post) return { success: false, error: 'Post not found' };
|
if (!post) return apiError('Not Found', 'Post not found');
|
||||||
return { success: true, post };
|
return apiSuccess({ success: true, post });
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = parseInt(url.searchParams.get('page')) || 1;
|
const page = parseInt(url.searchParams.get('page')) || 1;
|
||||||
@@ -83,17 +96,17 @@ async function handleGetPosts(request) {
|
|||||||
withRelations
|
withRelations
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return apiSuccess({
|
||||||
success: true,
|
success: true,
|
||||||
posts: result.posts,
|
posts: result.posts,
|
||||||
total: result.pagination.total,
|
total: result.pagination.total,
|
||||||
totalPages: result.pagination.totalPages,
|
totalPages: result.pagination.totalPages,
|
||||||
page: result.pagination.page,
|
page: result.pagination.page,
|
||||||
limit: result.pagination.limit
|
limit: result.pagination.limit
|
||||||
};
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(`Posts: error GET posts: ${error.message}`);
|
fail(`Posts: error GET posts: ${error.message}`);
|
||||||
return { success: false, error: error.message || 'Failed to fetch posts' };
|
return apiError('Internal Server Error', 'Failed to fetch posts');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,19 +114,19 @@ async function handleCreatePost(request) {
|
|||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const postType = url.searchParams.get('type');
|
const postType = url.searchParams.get('type');
|
||||||
if (!postType) return { success: false, error: 'Post type is required' };
|
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const postData = body.post || body;
|
const postData = body.post || body;
|
||||||
if (!postData || Object.keys(postData).length === 0) {
|
if (!postData || Object.keys(postData).length === 0) {
|
||||||
return { success: false, error: 'Post data is required' };
|
return apiError('Bad Request', 'Post data is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const post = await createPost(postType, postData);
|
const post = await createPost(postType, postData);
|
||||||
return { success: true, post, message: 'Post created successfully' };
|
return apiSuccess({ success: true, post, message: 'Post created successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(`Posts: error creating post: ${error.message}`);
|
fail(`Posts: error creating post: ${error.message}`);
|
||||||
return { success: false, error: error.message || 'Failed to create post' };
|
return apiError('Internal Server Error', 'Failed to create post');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,19 +137,18 @@ async function handleUpdatePost(request) {
|
|||||||
const postType = url.searchParams.get('type');
|
const postType = url.searchParams.get('type');
|
||||||
const id = url.searchParams.get('id') || body.id;
|
const id = url.searchParams.get('id') || body.id;
|
||||||
|
|
||||||
if (!postType) return { success: false, error: 'Post type is required' };
|
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||||
if (!id) return { success: false, error: 'Post ID is required' };
|
if (!id) return apiError('Bad Request', 'Post ID is required');
|
||||||
|
|
||||||
const updates = body.post || (({ id: _i, ...rest }) => rest)(body);
|
const updates = body.post || (({ id: _i, ...rest }) => rest)(body);
|
||||||
if (!updates || Object.keys(updates).length === 0) {
|
if (!updates || Object.keys(updates).length === 0) {
|
||||||
return { success: false, error: 'Update data is required' };
|
return apiError('Bad Request', 'Update data is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeConfig = getPostType(postType);
|
const typeConfig = getPostType(postType);
|
||||||
|
|
||||||
// Handle old image cleanup
|
|
||||||
const existing = await getPostById(postType, parseInt(id));
|
const existing = await getPostById(postType, parseInt(id));
|
||||||
if (!existing) return { success: false, error: 'Post not found' };
|
if (!existing) return apiError('Not Found', 'Post not found');
|
||||||
|
|
||||||
const post = await updatePost(postType, parseInt(id), updates);
|
const post = await updatePost(postType, parseInt(id), updates);
|
||||||
|
|
||||||
@@ -155,10 +167,10 @@ async function handleUpdatePost(request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, post, message: 'Post updated successfully' };
|
return apiSuccess({ success: true, post, message: 'Post updated successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(`Posts: error updating post: ${error.message}`);
|
fail(`Posts: error updating post: ${error.message}`);
|
||||||
return { success: false, error: error.message || 'Failed to update post' };
|
return apiError('Internal Server Error', 'Failed to update post');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,15 +180,15 @@ async function handleDeletePost(request) {
|
|||||||
const postType = url.searchParams.get('type');
|
const postType = url.searchParams.get('type');
|
||||||
const id = url.searchParams.get('id');
|
const id = url.searchParams.get('id');
|
||||||
|
|
||||||
if (!postType) return { success: false, error: 'Post type is required' };
|
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||||
if (!id) return { success: false, error: 'Post ID is required' };
|
if (!id) return apiError('Bad Request', 'Post ID is required');
|
||||||
|
|
||||||
const deleted = await deletePost(postType, parseInt(id));
|
const deleted = await deletePost(postType, parseInt(id));
|
||||||
if (!deleted) return { success: false, error: 'Post not found' };
|
if (!deleted) return apiError('Not Found', 'Post not found');
|
||||||
return { success: true, message: 'Post deleted successfully' };
|
return apiSuccess({ success: true, message: 'Post deleted successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(`Posts: error deleting post: ${error.message}`);
|
fail(`Posts: error deleting post: ${error.message}`);
|
||||||
return { success: false, error: 'Failed to delete post' };
|
return apiError('Internal Server Error', 'Failed to delete post');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,40 +202,50 @@ async function handleUploadImage(request) {
|
|||||||
const file = formData.get('file');
|
const file = formData.get('file');
|
||||||
const postType = formData.get('type');
|
const postType = formData.get('type');
|
||||||
|
|
||||||
if (!file) return { success: false, error: 'No file provided' };
|
if (!file) return apiError('Bad Request', 'No file provided');
|
||||||
if (!postType) return { success: false, error: 'Post type is required' };
|
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||||
if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` };
|
if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`);
|
||||||
|
|
||||||
|
// Read the buffer before validation so both magic-byte assertion and
|
||||||
|
// dangerous-pattern inspection (HTML/SVG/XML denylist) are executed
|
||||||
|
// against actual file content, not merely the client-supplied metadata.
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
const validation = validateUpload({
|
const validation = validateUpload({
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
allowedTypes: FILE_TYPE_PRESETS.IMAGES,
|
allowedTypes: FILE_TYPE_PRESETS.IMAGES,
|
||||||
maxSize: FILE_SIZE_LIMITS.IMAGE
|
maxSize: FILE_SIZE_LIMITS.IMAGE,
|
||||||
|
buffer,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return { success: false, error: validation.errors.join(', ') };
|
return apiError('Bad Request', validation.errors.join(', '));
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueFilename = generateUniqueFilename(file.name);
|
const uniqueFilename = generateUniqueFilename(file.name);
|
||||||
const key = generatePostFilePath(postType, Date.now(), uniqueFilename);
|
const key = generatePostFilePath(postType, Date.now(), uniqueFilename);
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
|
||||||
|
// Derive content-type from the validated extension — never from file.type,
|
||||||
|
// which is fully attacker-controlled.
|
||||||
|
const ext = getFileExtension(file.name).toLowerCase();
|
||||||
|
const contentType = EXTENSION_TO_MIME[ext] ?? 'application/octet-stream';
|
||||||
|
|
||||||
const uploadResult = await uploadImage({
|
const uploadResult = await uploadImage({
|
||||||
key,
|
key,
|
||||||
body: buffer,
|
body: buffer,
|
||||||
contentType: file.type,
|
contentType,
|
||||||
metadata: { originalName: file.name }
|
metadata: { originalName: file.name }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!uploadResult.success) {
|
if (!uploadResult.success) {
|
||||||
return { success: false, error: uploadResult.error || 'Upload failed' };
|
return apiError('Internal Server Error', 'Upload failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, key: uploadResult.data.key };
|
return apiSuccess({ success: true, key: uploadResult.data.key });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(`Posts: error uploading image: ${error.message}`);
|
fail(`Posts: error uploading image: ${error.message}`);
|
||||||
return { success: false, error: error.message || 'Upload failed' };
|
return apiError('Internal Server Error', 'Upload failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,13 +257,13 @@ async function handleGetCategories(request) {
|
|||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const postType = url.searchParams.get('type');
|
const postType = url.searchParams.get('type');
|
||||||
if (!postType) return { success: false, error: 'Post type is required' };
|
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||||
|
|
||||||
const id = url.searchParams.get('id');
|
const id = url.searchParams.get('id');
|
||||||
if (id) {
|
if (id) {
|
||||||
const category = await getCategoryById(postType, parseInt(id));
|
const category = await getCategoryById(postType, parseInt(id));
|
||||||
if (!category) return { success: false, error: 'Category not found' };
|
if (!category) return apiError('Not Found', 'Category not found');
|
||||||
return { success: true, category };
|
return apiSuccess({ success: true, category });
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = parseInt(url.searchParams.get('page')) || 1;
|
const page = parseInt(url.searchParams.get('page')) || 1;
|
||||||
@@ -260,17 +282,17 @@ async function handleGetCategories(request) {
|
|||||||
sortOrder
|
sortOrder
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return apiSuccess({
|
||||||
success: true,
|
success: true,
|
||||||
categories: result.categories,
|
categories: result.categories,
|
||||||
total: result.pagination.total,
|
total: result.pagination.total,
|
||||||
totalPages: result.pagination.totalPages,
|
totalPages: result.pagination.totalPages,
|
||||||
page: result.pagination.page,
|
page: result.pagination.page,
|
||||||
limit: result.pagination.limit
|
limit: result.pagination.limit
|
||||||
};
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(`Posts: error GET categories: ${error.message}`);
|
fail(`Posts: error GET categories: ${error.message}`);
|
||||||
return { success: false, error: error.message || 'Failed to fetch categories' };
|
return apiError('Internal Server Error', 'Failed to fetch categories');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,19 +300,19 @@ async function handleCreateCategory(request) {
|
|||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const postType = url.searchParams.get('type');
|
const postType = url.searchParams.get('type');
|
||||||
if (!postType) return { success: false, error: 'Post type is required' };
|
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const categoryData = body.category || body;
|
const categoryData = body.category || body;
|
||||||
if (!categoryData || Object.keys(categoryData).length === 0) {
|
if (!categoryData || Object.keys(categoryData).length === 0) {
|
||||||
return { success: false, error: 'Category data is required' };
|
return apiError('Bad Request', 'Category data is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const category = await createCategory(postType, categoryData);
|
const category = await createCategory(postType, categoryData);
|
||||||
return { success: true, category, message: 'Category created successfully' };
|
return apiSuccess({ success: true, category, message: 'Category created successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(`Posts: error creating category: ${error.message}`);
|
fail(`Posts: error creating category: ${error.message}`);
|
||||||
return { success: false, error: error.message || 'Failed to create category' };
|
return apiError('Internal Server Error', 'Failed to create category');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,22 +323,22 @@ async function handleUpdateCategory(request) {
|
|||||||
const postType = url.searchParams.get('type');
|
const postType = url.searchParams.get('type');
|
||||||
const id = url.searchParams.get('id') || body.id;
|
const id = url.searchParams.get('id') || body.id;
|
||||||
|
|
||||||
if (!postType) return { success: false, error: 'Post type is required' };
|
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||||
if (!id) return { success: false, error: 'Category ID is required' };
|
if (!id) return apiError('Bad Request', 'Category ID is required');
|
||||||
|
|
||||||
const updates = body.category || (({ id: _i, ...rest }) => rest)(body);
|
const updates = body.category || (({ id: _i, ...rest }) => rest)(body);
|
||||||
if (!updates || Object.keys(updates).length === 0) {
|
if (!updates || Object.keys(updates).length === 0) {
|
||||||
return { success: false, error: 'Update data is required' };
|
return apiError('Bad Request', 'Update data is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await getCategoryById(postType, parseInt(id));
|
const existing = await getCategoryById(postType, parseInt(id));
|
||||||
if (!existing) return { success: false, error: 'Category not found' };
|
if (!existing) return apiError('Not Found', 'Category not found');
|
||||||
|
|
||||||
const category = await updateCategory(postType, parseInt(id), updates);
|
const category = await updateCategory(postType, parseInt(id), updates);
|
||||||
return { success: true, category, message: 'Category updated successfully' };
|
return apiSuccess({ success: true, category, message: 'Category updated successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(`Posts: error updating category: ${error.message}`);
|
fail(`Posts: error updating category: ${error.message}`);
|
||||||
return { success: false, error: error.message || 'Failed to update category' };
|
return apiError('Internal Server Error', 'Failed to update category');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,17 +348,17 @@ async function handleDeleteCategory(request) {
|
|||||||
const postType = url.searchParams.get('type');
|
const postType = url.searchParams.get('type');
|
||||||
const id = url.searchParams.get('id');
|
const id = url.searchParams.get('id');
|
||||||
|
|
||||||
if (!postType) return { success: false, error: 'Post type is required' };
|
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||||
if (!id) return { success: false, error: 'Category ID is required' };
|
if (!id) return apiError('Bad Request', 'Category ID is required');
|
||||||
|
|
||||||
await deleteCategory(postType, parseInt(id));
|
await deleteCategory(postType, parseInt(id));
|
||||||
return { success: true, message: 'Category deleted successfully' };
|
return apiSuccess({ success: true, message: 'Category deleted successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(`Posts: error deleting category: ${error.message}`);
|
fail(`Posts: error deleting category: ${error.message}`);
|
||||||
if (error.message.includes('Cannot delete')) {
|
if (error.message.includes('Cannot delete')) {
|
||||||
return { success: false, error: error.message };
|
return apiError('Bad Request', error.message);
|
||||||
}
|
}
|
||||||
return { success: false, error: 'Failed to delete category' };
|
return apiError('Internal Server Error', 'Failed to delete category');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,17 +370,17 @@ async function handleSearchPosts(request) {
|
|||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const postType = url.searchParams.get('type');
|
const postType = url.searchParams.get('type');
|
||||||
if (!postType) return { success: false, error: 'Post type is required' };
|
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||||
if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` };
|
if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`);
|
||||||
|
|
||||||
const q = url.searchParams.get('q') || '';
|
const q = url.searchParams.get('q') || '';
|
||||||
const limit = parseInt(url.searchParams.get('limit')) || 20;
|
const limit = parseInt(url.searchParams.get('limit')) || 20;
|
||||||
|
|
||||||
const posts = await searchPosts(postType, q, limit);
|
const posts = await searchPosts(postType, q, limit);
|
||||||
return { success: true, posts };
|
return apiSuccess({ success: true, posts });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(`Posts: error searching posts: ${error.message}`);
|
fail(`Posts: error searching posts: ${error.message}`);
|
||||||
return { success: false, error: error.message || 'Failed to search posts' };
|
return apiError('Internal Server Error', 'Failed to search posts');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,17 +391,18 @@ async function handleSearchPosts(request) {
|
|||||||
async function handlePublicGetConfig() {
|
async function handlePublicGetConfig() {
|
||||||
try {
|
try {
|
||||||
const config = getPostsConfig();
|
const config = getPostsConfig();
|
||||||
return { success: true, config };
|
return apiSuccess({ success: true, config });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: error.message || 'Failed to get config' };
|
fail(`Posts: error getting public config: ${error.message}`);
|
||||||
|
return apiError('Internal Server Error', 'Failed to get config');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePublicGetPosts(request, params) {
|
async function handlePublicGetPosts(request, params) {
|
||||||
try {
|
try {
|
||||||
const postType = params?.type;
|
const postType = params?.type;
|
||||||
if (!postType) return { success: false, error: 'Post type is required' };
|
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||||
if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` };
|
if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`);
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const page = parseInt(url.searchParams.get('page')) || 1;
|
const page = parseInt(url.searchParams.get('page')) || 1;
|
||||||
@@ -398,17 +421,17 @@ async function handlePublicGetPosts(request, params) {
|
|||||||
withRelations
|
withRelations
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return apiSuccess({
|
||||||
success: true,
|
success: true,
|
||||||
posts: result.posts,
|
posts: result.posts,
|
||||||
total: result.pagination.total,
|
total: result.pagination.total,
|
||||||
totalPages: result.pagination.totalPages,
|
totalPages: result.pagination.totalPages,
|
||||||
page: result.pagination.page,
|
page: result.pagination.page,
|
||||||
limit: result.pagination.limit
|
limit: result.pagination.limit
|
||||||
};
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(`Posts: error public GET posts: ${error.message}`);
|
fail(`Posts: error public GET posts: ${error.message}`);
|
||||||
return { success: false, error: error.message || 'Failed to fetch posts' };
|
return apiError('Internal Server Error', 'Failed to fetch posts');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,27 +439,27 @@ async function handlePublicGetPostBySlug(request, params) {
|
|||||||
try {
|
try {
|
||||||
const postType = params?.type;
|
const postType = params?.type;
|
||||||
const slug = params?.slug;
|
const slug = params?.slug;
|
||||||
if (!postType || !slug) return { success: false, error: 'Post type and slug are required' };
|
if (!postType || !slug) return apiError('Bad Request', 'Post type and slug are required');
|
||||||
|
|
||||||
const post = await getPostBySlug(postType, slug);
|
const post = await getPostBySlug(postType, slug);
|
||||||
if (!post) return { success: false, error: 'Post not found' };
|
if (!post) return apiError('Not Found', 'Post not found');
|
||||||
return { success: true, post };
|
return apiSuccess({ success: true, post });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(`Posts: error public GET post by slug: ${error.message}`);
|
fail(`Posts: error public GET post by slug: ${error.message}`);
|
||||||
return { success: false, error: error.message || 'Failed to fetch post' };
|
return apiError('Internal Server Error', 'Failed to fetch post');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePublicGetCategories(request, params) {
|
async function handlePublicGetCategories(request, params) {
|
||||||
try {
|
try {
|
||||||
const postType = params?.type;
|
const postType = params?.type;
|
||||||
if (!postType) return { success: false, error: 'Post type is required' };
|
if (!postType) return apiError('Bad Request', 'Post type is required');
|
||||||
|
|
||||||
const categories = await getActiveCategories(postType);
|
const categories = await getActiveCategories(postType);
|
||||||
return { success: true, categories };
|
return apiSuccess({ success: true, categories });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(`Posts: error public GET categories: ${error.message}`);
|
fail(`Posts: error public GET categories: ${error.message}`);
|
||||||
return { success: false, error: error.message || 'Failed to fetch categories' };
|
return apiError('Internal Server Error', 'Failed to fetch categories');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,7 +468,7 @@ async function handlePublicGetCategories(request, params) {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
routes: [
|
routes: defineApiRoutes([
|
||||||
// Admin config
|
// Admin config
|
||||||
{ path: '/admin/posts/config', method: 'GET', handler: handleGetConfig, auth: 'admin' },
|
{ path: '/admin/posts/config', method: 'GET', handler: handleGetConfig, auth: 'admin' },
|
||||||
|
|
||||||
@@ -472,5 +495,5 @@ export default {
|
|||||||
{ path: '/posts/:type', method: 'GET', handler: handlePublicGetPosts, auth: 'public' },
|
{ path: '/posts/:type', method: 'GET', handler: handlePublicGetPosts, auth: 'public' },
|
||||||
{ path: '/posts/:type/:slug', method: 'GET', handler: handlePublicGetPostBySlug, auth: 'public' },
|
{ path: '/posts/:type/:slug', method: 'GET', handler: handlePublicGetPostBySlug, auth: 'public' },
|
||||||
{ path: '/posts/:type/categories', method: 'GET', handler: handlePublicGetCategories, auth: 'public' },
|
{ path: '/posts/:type/categories', method: 'GET', handler: handlePublicGetCategories, auth: 'public' },
|
||||||
]
|
])
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ const CategoriesListPage = () => {
|
|||||||
page: data.page || 1
|
page: data.page || 1
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || 'Échec du chargement des catégories');
|
toast.error(data.message || data.error || 'Échec du chargement des catégories');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading categories:', error);
|
console.error('Error loading categories:', error);
|
||||||
@@ -139,7 +139,7 @@ const CategoriesListPage = () => {
|
|||||||
toast.success('Catégorie supprimée avec succès');
|
toast.success('Catégorie supprimée avec succès');
|
||||||
loadCategories();
|
loadCategories();
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || 'Échec de la suppression de la catégorie');
|
toast.error(data.message || data.error || 'Échec de la suppression de la catégorie');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting category:', error);
|
console.error('Error deleting category:', error);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const CategoryCreatePage = () => {
|
|||||||
toast.success('Catégorie créée avec succès');
|
toast.success('Catégorie créée avec succès');
|
||||||
router.push(`/admin/posts/${postType}/categories`);
|
router.push(`/admin/posts/${postType}/categories`);
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || 'Échec de la création');
|
toast.error(data.message || data.error || 'Échec de la création');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating category:', error);
|
console.error('Error creating category:', error);
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const CategoryEditPage = () => {
|
|||||||
toast.success('Catégorie mise à jour avec succès');
|
toast.success('Catégorie mise à jour avec succès');
|
||||||
router.push(`/admin/posts/${postType}/categories`);
|
router.push(`/admin/posts/${postType}/categories`);
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || 'Échec de la mise à jour');
|
toast.error(data.message || data.error || 'Échec de la mise à jour');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating category:', error);
|
console.error('Error updating category:', error);
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ export default defineConfig([
|
|||||||
'src/features/admin/pages.js',
|
'src/features/admin/pages.js',
|
||||||
'src/features/admin/components/index.js',
|
'src/features/admin/components/index.js',
|
||||||
'src/core/api/index.js',
|
'src/core/api/index.js',
|
||||||
'src/core/api/nx-route.js',
|
'src/core/api/route-handler.js',
|
||||||
'src/core/database/index.js',
|
'src/core/database/index.js',
|
||||||
'src/cli/database.js',
|
'src/cli/database.js',
|
||||||
'src/core/email/index.js',
|
'src/core/email/index.js',
|
||||||
|
|||||||
Reference in New Issue
Block a user