diff --git a/src/core/api/handlers/storage.js b/src/core/api/handlers/storage.js index 99e0b13..1af4279 100644 --- a/src/core/api/handlers/storage.js +++ b/src/core/api/handlers/storage.js @@ -6,6 +6,7 @@ import { validateSession } from '../../../features/auth/lib/session.js'; import { cookies } from 'next/headers'; import { getSessionCookieName } from '../../../shared/lib/appConfig.js'; +import { getAllStoragePublicPrefixes } from '../../../../modules/modules.storage.js'; import { getFile } from '@zen/core/storage'; // Get cookie name from environment or use default @@ -33,12 +34,15 @@ export async function handleGetFile(request, fileKey) { const pathParts = rawSegments; - // Blog images: public read (no auth) for site integration. - // Only static blog assets are served publicly. The path must be exactly - // two segments deep (blog/{post-id-or-slug}/{filename}) to prevent - // unintentional exposure of files at the root of the blog prefix. - if (pathParts[0] === 'blog') { - if (pathParts.length < 3) { + // 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); @@ -84,7 +88,7 @@ export async function handleGetFile(request, fileKey) { 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 { @@ -94,15 +98,24 @@ export async function handleGetFile(request, fileKey) { } } else if (pathParts[0] === 'organizations') { // Organization files: organizations/{orgId}/{category}/{filename} - // For now, only admins can access organization files + // 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 + // Unknown file path pattern — deny by default return { error: 'Forbidden', message: 'Invalid file path' diff --git a/src/core/api/handlers/version.js b/src/core/api/handlers/version.js deleted file mode 100644 index 4284482..0000000 --- a/src/core/api/handlers/version.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Version Handler - * Returns version information about the ZEN API. - * Requires a valid authenticated session to prevent application fingerprinting. - */ - -import { getAppName } from '../../../shared/lib/appConfig.js'; -import { validateSession } from '../../../features/auth/lib/session.js'; -import { cookies } from 'next/headers'; -import { getSessionCookieName } from '../../../shared/lib/appConfig.js'; - -const COOKIE_NAME = getSessionCookieName(); - -export async function handleVersion(request) { - const cookieStore = await cookies(); - const sessionToken = cookieStore.get(COOKIE_NAME)?.value; - - if (!sessionToken) { - return { error: 'Unauthorized', message: 'Authentication required' }; - } - - const session = await validateSession(sessionToken); - if (!session) { - return { error: 'Unauthorized', message: 'Invalid or expired session' }; - } - - return { - name: 'ZEN API', - appName: getAppName(), - version: '0.1.0', - apiVersion: '1.0', - description: 'ZEN API - Complete modular web platform' - }; -} diff --git a/src/core/api/index.js b/src/core/api/index.js index 30551b4..1fe52f2 100644 --- a/src/core/api/index.js +++ b/src/core/api/index.js @@ -10,8 +10,7 @@ export { routeRequest, getStatusCode, requireAuth, requireAdmin } from './router // Export individual handlers (for custom usage) export { handleHealth } from './handlers/health.js'; -export { handleVersion } from './handlers/version.js'; -export { +export { handleGetCurrentUser, handleGetUserById, handleListUsers diff --git a/src/core/api/router.js b/src/core/api/router.js index d6bb5fb..0bb6556 100644 --- a/src/core/api/router.js +++ b/src/core/api/router.js @@ -15,8 +15,7 @@ import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '../../featur // Core handlers import { handleHealth } from './handlers/health.js'; -import { handleVersion } from './handlers/version.js'; -import { +import { handleGetCurrentUser, handleGetUserById, handleListUsers, @@ -153,8 +152,8 @@ async function requireAdmin(request) { export async function routeRequest(request, path) { const method = request.method; - // Global IP-based rate limit for all API calls (health/version are exempt) - const isExempt = (path[0] === 'health' || path[0] === 'version') && method === 'GET'; + // 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'); @@ -204,11 +203,6 @@ async function routeCoreRequest(request, path, method) { return await handleHealth(); } - // Version endpoint — authentication required; see handlers/version.js - if (path[0] === 'version' && method === 'GET') { - return await handleVersion(request); - } - // Storage endpoint - serve files securely if (path[0] === 'storage' && method === 'GET') { const fileKey = path.slice(1).join('/'); diff --git a/src/core/modules/defineModule.js b/src/core/modules/defineModule.js index 66359f1..8a76e63 100644 --- a/src/core/modules/defineModule.js +++ b/src/core/modules/defineModule.js @@ -41,6 +41,9 @@ export function defineModule(config) { // SEO metadata generators metadata: {}, + // Storage prefixes served publicly (no auth). Format: 'posts/blogue', 'assets/docs' + storagePublicPrefixes: [], + // Database (optional) — { createTables, dropTables } db: null, diff --git a/src/core/storage/index.js b/src/core/storage/index.js index 7f5f06e..1bc2721 100644 --- a/src/core/storage/index.js +++ b/src/core/storage/index.js @@ -646,7 +646,7 @@ export { formatFileSize, generateUserFilePath, generateOrgFilePath, - generateBlogFilePath, + generatePostFilePath, sanitizeFilename, validateImageDimensions, validateUpload, diff --git a/src/core/storage/utils.js b/src/core/storage/utils.js index ec3196e..c43dece 100644 --- a/src/core/storage/utils.js +++ b/src/core/storage/utils.js @@ -158,13 +158,14 @@ export function generateOrgFilePath(orgId, category, filename) { } /** - * Generate a storage path for blog post images - * @param {string|number} postIdOrSlug - Post ID or slug (e.g. for temp uploads use timestamp) + * Generate a storage path for a post image, scoped by post type. + * @param {string} typeKey - Post type key (e.g. 'blogue', 'cve') + * @param {string|number} postIdOrSlug - Post ID or slug (use timestamp for pre-creation uploads) * @param {string} filename - Filename - * @returns {string} Storage path (e.g., 'blog/123/filename.jpg') + * @returns {string} Storage path (e.g., 'posts/blogue/123/filename.jpg') */ -export function generateBlogFilePath(postIdOrSlug, filename) { - return `blog/${postIdOrSlug}/${filename}`; +export function generatePostFilePath(typeKey, postIdOrSlug, filename) { + return `posts/${typeKey}/${postIdOrSlug}/${filename}`; } /** diff --git a/src/modules/modules.storage.js b/src/modules/modules.storage.js new file mode 100644 index 0000000..69bf95e --- /dev/null +++ b/src/modules/modules.storage.js @@ -0,0 +1,53 @@ +/** + * Module Storage Registry (Server-Side) + * + * Aggregates storage public prefixes declared by each module via defineModule(). + * A prefix listed here is served without authentication by the storage handler. + * + * Internal modules declare `storagePublicPrefixes` in their defineModule() config. + * External modules registered at runtime are also included automatically. + * + * Usage: + * import { getAllStoragePublicPrefixes } from '@zen/core/modules/storage'; + */ + +import { getModule, getEnabledModules } from '@zen/core/core/modules'; +import { getPostsConfig } from './posts/config.js'; + +/** + * Compute public storage prefixes for the posts module from its type config. + * Avoids importing module.config.js (which contains React lazy() calls). + * @returns {string[]} + */ +function getPostsPublicPrefixes() { + if (process.env.ZEN_MODULE_POSTS !== 'true') return []; + const config = getPostsConfig(); + return Object.values(config.types) + .filter(t => t.public) + .map(t => `posts/${t.key}`); +} + +/** + * Get all storage public prefixes from every enabled module (internal + external). + * @returns {string[]} Deduplicated list of public storage prefixes + */ +export function getAllStoragePublicPrefixes() { + const prefixes = new Set(); + + // Internal modules — call server-only config helpers directly to avoid + // importing module.config.js files that contain React lazy() references. + for (const prefix of getPostsPublicPrefixes()) { + prefixes.add(prefix); + } + + // External modules — runtime registry + for (const mod of getEnabledModules()) { + if (!mod.external) continue; + const runtimeConfig = getModule(mod.name); + for (const prefix of runtimeConfig?.storagePublicPrefixes ?? []) { + prefixes.add(prefix); + } + } + + return [...prefixes]; +} diff --git a/src/modules/posts/.env.example b/src/modules/posts/.env.example index 6a5893e..ff84001 100644 --- a/src/modules/posts/.env.example +++ b/src/modules/posts/.env.example @@ -7,10 +7,17 @@ ZEN_MODULE_POSTS=true ZEN_MODULE_ZEN_MODULE_POSTS_TYPES=blogue:Blogue|cve:CVE|emploi:Emplois # Fields for each type: name:type|name:type|... -# Supported field types: title, slug, text, markdown, date, category, image +# Supported field types: title, slug, text, markdown, date, datetime, color, category, image # Relation field: name:relation:target_post_type (e.g. keywords:relation:mots-cle) ZEN_MODULE_POSTS_TYPE_BLOGUE=title:title|slug:slug|category:category|date:date|resume:text|content:markdown|image:image ZEN_MODULE_POSTS_TYPE_CVE=title:title|slug:slug|cve_id:text|severity:text|description:markdown|date:date|keywords:relation:mots-cle ZEN_MODULE_POSTS_TYPE_EMPLOI=title:title|slug:slug|date:date|description:markdown ZEN_MODULE_POSTS_TYPE_MOTS-CLE=title:title|slug:slug + +# Public storage access per type (optional, default: false) +# When true, images of that type are served without authentication. +# Files are stored at posts/{type}/{id}/{filename} and accessible via /zen/api/storage/posts/{type}/... +ZEN_MODULE_POSTS_TYPE_BLOGUE_PUBLIC=true +# ZEN_MODULE_POSTS_TYPE_CVE_PUBLIC=false +# ZEN_MODULE_POSTS_TYPE_EMPLOI_PUBLIC=false ################################# diff --git a/src/modules/posts/README.md b/src/modules/posts/README.md index 7dbeb8f..6d7501b 100644 --- a/src/modules/posts/README.md +++ b/src/modules/posts/README.md @@ -29,6 +29,16 @@ Chaque type doit avoir au moins un champ `title` et un champ `slug`. Si un type utilise le champ `image`, configurer le stockage Zen dans le `.env` principal : `ZEN_STORAGE_REGION`, `ZEN_STORAGE_ACCESS_KEY`, `ZEN_STORAGE_SECRET_KEY`, `ZEN_STORAGE_BUCKET`. +### Accès public aux images + +Par défaut, les images d'un type nécessitent une session authentifiée. Pour les rendre accessibles publiquement (ex. images de blogue affichées sur le site) : + +```env +ZEN_MODULE_POSTS_TYPE_BLOGUE_PUBLIC=true +``` + +Les images sont stockées sous `posts/{type}/{id}/{filename}` et servies via `/zen/api/storage/posts/{type}/...`. L'accès public est déclaré dans le module. Aucune variable d'environnement globale n'est nécessaire. + --- ## Base de données diff --git a/src/modules/posts/api.js b/src/modules/posts/api.js index 3d025f1..a9e6d0d 100644 --- a/src/modules/posts/api.js +++ b/src/modules/posts/api.js @@ -24,7 +24,7 @@ import { import { uploadImage, deleteFile, - generateBlogFilePath, + generatePostFilePath, generateUniqueFilename, validateUpload, FILE_TYPE_PRESETS, @@ -187,8 +187,11 @@ async function handleUploadImage(request) { try { const formData = await request.formData(); const file = formData.get('file'); + const postType = formData.get('type'); if (!file) return { success: false, error: 'No file provided' }; + if (!postType) return { success: false, error: 'Post type is required' }; + if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` }; const validation = validateUpload({ filename: file.name, @@ -202,7 +205,7 @@ async function handleUploadImage(request) { } const uniqueFilename = generateUniqueFilename(file.name); - const key = generateBlogFilePath(Date.now(), uniqueFilename); + const key = generatePostFilePath(postType, Date.now(), uniqueFilename); const buffer = Buffer.from(await file.arrayBuffer()); const uploadResult = await uploadImage({ diff --git a/src/modules/posts/config.js b/src/modules/posts/config.js index 47e4acb..4c78f53 100644 --- a/src/modules/posts/config.js +++ b/src/modules/posts/config.js @@ -82,6 +82,7 @@ function buildConfig() { const hasCategory = fields.some(f => f.type === 'category'); const hasRelations = fields.some(f => f.type === 'relation'); const label = customLabel || (key.charAt(0).toUpperCase() + key.slice(1)); + const isPublic = process.env[`ZEN_MODULE_POSTS_TYPE_${key.toUpperCase()}_PUBLIC`] === 'true'; types[key] = { key, @@ -91,6 +92,7 @@ function buildConfig() { hasRelations, titleField, slugField, + public: isPublic, }; } diff --git a/src/modules/posts/docs/admin-api.md b/src/modules/posts/docs/admin-api.md index 78eda05..7f9f263 100644 --- a/src/modules/posts/docs/admin-api.md +++ b/src/modules/posts/docs/admin-api.md @@ -12,7 +12,7 @@ Authentification requise. | `POST` | `/zen/api/admin/posts/posts?type={type}` | Créer un post | | `PUT` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Modifier un post | | `DELETE` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Supprimer un post | -| `POST` | `/zen/api/admin/posts/upload-image` | Upload d'image | +| `POST` | `/zen/api/admin/posts/upload-image` | Upload d'image (`multipart/form-data` : `file`, `type`) | | `GET` | `/zen/api/admin/posts/categories?type={type}` | Liste des catégories | | `POST` | `/zen/api/admin/posts/categories?type={type}` | Créer une catégorie | | `PUT` | `/zen/api/admin/posts/categories?type={type}&id={id}` | Modifier une catégorie | diff --git a/src/modules/posts/docs/integration.md b/src/modules/posts/docs/integration.md index 5def351..fa80459 100644 --- a/src/modules/posts/docs/integration.md +++ b/src/modules/posts/docs/integration.md @@ -1,5 +1,7 @@ # Intégration Next.js — Module Posts +> **Images** — Les images sont stockées sous `posts/{type}/{id}/{filename}` et servies via `/zen/api/storage/posts/{type}/...`. Pour qu'elles soient accessibles sans authentification (nécessaire pour l'affichage public), activer `ZEN_MODULE_POSTS_TYPE_{TYPE}_PUBLIC=true` dans le `.env`. + ## Liste de posts ```js diff --git a/src/modules/posts/module.config.js b/src/modules/posts/module.config.js index b65ab61..f821573 100644 --- a/src/modules/posts/module.config.js +++ b/src/modules/posts/module.config.js @@ -17,11 +17,17 @@ const CategoryEditPage = lazy(() => import('./categories/admin/CategoryEditPa const postsConfig = getPostsConfig(); -// Build adminPages and navigation dynamically from configured post types +// Build adminPages, navigation and public storage prefixes dynamically from configured post types const adminPages = {}; const navigationSections = []; +const storagePublicPrefixes = []; for (const type of Object.values(postsConfig.types)) { + // Register public storage prefix for this type if marked public + if (type.public) { + storagePublicPrefixes.push(`posts/${type.key}`); + } + // Register routes for this post type adminPages[`/admin/posts/${type.key}/list`] = PostsListPage; adminPages[`/admin/posts/${type.key}/new`] = PostCreatePage; @@ -84,6 +90,8 @@ export default defineModule({ envVars: ['ZEN_MODULE_POSTS_TYPES'], + storagePublicPrefixes, + // Array of sections — one per post type (server-side, env vars available) navigation: navigationSections,