feat(storage): replace hardcoded blog prefix with module-declared public prefixes

Refactor storage access control to use dynamic public prefixes
sourced from `getAllStoragePublicPrefixes()` instead of a hardcoded
`blog` check. Each module can now declare its own public storage
prefixes via `defineModule()` storagePublicPrefixes, making the
system extensible without modifying the core handler.

Also adds a `posts` path handler requiring admin access for private
post types, removes the deprecated `version` API endpoint and its
rate-limit exemption, and minor whitespace/comment cleanup.
This commit is contained in:
2026-04-12 20:41:37 -04:00
parent c65d028a20
commit d9ba777028
15 changed files with 126 additions and 65 deletions
+22 -9
View File
@@ -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'
-34
View File
@@ -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'
};
}
+1 -2
View File
@@ -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
+3 -9
View File
@@ -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('/');
+3
View File
@@ -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,
+1 -1
View File
@@ -646,7 +646,7 @@ export {
formatFileSize,
generateUserFilePath,
generateOrgFilePath,
generateBlogFilePath,
generatePostFilePath,
sanitizeFilename,
validateImageDimensions,
validateUpload,
+6 -5
View File
@@ -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}`;
}
/**
+53
View File
@@ -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];
}
+8 -1
View File
@@ -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
#################################
+10
View File
@@ -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
+5 -2
View File
@@ -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({
+2
View File
@@ -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,
};
}
+1 -1
View File
@@ -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 |
+2
View File
@@ -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
+9 -1
View File
@@ -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,