From 936d21fdecca0a6f39a9df509f37427f8e7a502d Mon Sep 17 00:00:00 2001 From: Hyko Date: Tue, 14 Apr 2026 17:23:43 -0400 Subject: [PATCH] docs/feat: add storage policies to discovery and refactor utils - Add `storagePublicPrefixes` and `storageAccessPolicies` fields to both internal and external module config loading in discovery.js - Add a module-level `MIME_TYPES` constant in storage/utils.js to avoid recreating the object on every `getMimeType` call - Remove unused `validateImageDimensions` export from storage/index.js - Remove dead `isFinite` check after `Math.min/max` in `getPresignedUrl` (result is always finite at that point) - Remove unused `warn` import from storage/utils.js - Add documentation rule in DEV.md: comments must always reflect the actual behavior of the code they describe --- docs/DEV.md | 2 + src/core/modules/discovery.js | 4 + src/core/storage/index.js | 4 - src/core/storage/utils.js | 131 +++++++++++------------------ src/modules/modules.storage.js | 49 +++-------- src/modules/posts/module.config.js | 1 + 6 files changed, 66 insertions(+), 125 deletions(-) diff --git a/docs/DEV.md b/docs/DEV.md index b5b8363..57847b9 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -32,6 +32,8 @@ Pour les conventions de commit : [COMMITS.md](./dev/COMMITS.md). **Les variables d'environnement et la documentation se mettent à jour avec le code.** Toute variable ajoutée, renommée ou supprimée doit être reflétée dans `.env.example`. Toute décision architecturale ou convention nouvelle doit être documentée dans le fichier `docs/` concerné. Le code et sa documentation vieillissent ensemble. +**Les commentaires reflètent toujours le comportement réel du code.** Un commentaire obsolète est pire qu'un commentaire absent — il induit en erreur. Quand on modifie une fonction, on met à jour son commentaire. Un commentaire qui contredit le code est un bug de documentation. + --- ## Build et configuration tsup diff --git a/src/core/modules/discovery.js b/src/core/modules/discovery.js index 2a93a80..0c6e87d 100644 --- a/src/core/modules/discovery.js +++ b/src/core/modules/discovery.js @@ -108,6 +108,8 @@ async function loadModuleConfig(moduleName) { public: moduleConfig.publicRoutes?.length ? { routes: moduleConfig.publicRoutes } : undefined, + storagePublicPrefixes: moduleConfig.storagePublicPrefixes || [], + storageAccessPolicies: moduleConfig.storageAccessPolicies || [], }; } catch (error) { // No module.config.js — use defaults silently @@ -208,6 +210,8 @@ export async function registerExternalModules(modules = []) { : undefined, cron: moduleConfig.cron || undefined, api: moduleConfig.api || undefined, + storagePublicPrefixes: moduleConfig.storagePublicPrefixes || [], + storageAccessPolicies: moduleConfig.storageAccessPolicies || [], enabled: true, external: true, }; diff --git a/src/core/storage/index.js b/src/core/storage/index.js index 0a9cc58..e304572 100644 --- a/src/core/storage/index.js +++ b/src/core/storage/index.js @@ -537,9 +537,6 @@ async function getPresignedUrl({ key, expiresIn = 3600, operation = 'get' }) { // R2/S3 max presigned URL lifetime is 7 days (604800 seconds) const validExpiresIn = Math.min(Math.max(Math.floor(Number(expiresIn)), 1), 604800); - if (!Number.isFinite(validExpiresIn)) { - throw new Error('expiresIn must be a finite positive number'); - } const url = buildPresignedUrl({ method, host: config.host, path, expiresIn: validExpiresIn, config, date }); @@ -646,7 +643,6 @@ export { validateFileSize, formatFileSize, sanitizeFilename, - validateImageDimensions, validateUpload, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, diff --git a/src/core/storage/utils.js b/src/core/storage/utils.js index 642ceb2..4ac3481 100644 --- a/src/core/storage/utils.js +++ b/src/core/storage/utils.js @@ -4,7 +4,6 @@ */ import crypto from 'crypto'; -import { warn } from '../../shared/lib/logger.js'; /** * Generate a unique filename with timestamp and random hash @@ -31,6 +30,51 @@ export function getFileExtension(filename) { return lastDot === -1 ? '' : filename.substring(lastDot).toLowerCase(); } +const MIME_TYPES = { + // Images + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.bmp': 'image/bmp', + + // Documents + '.pdf': 'application/pdf', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.ppt': 'application/vnd.ms-powerpoint', + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + '.txt': 'text/plain', + '.csv': 'text/csv', + + // Archives + '.zip': 'application/zip', + '.rar': 'application/x-rar-compressed', + '.7z': 'application/x-7z-compressed', + '.tar': 'application/x-tar', + '.gz': 'application/gzip', + + // Media + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mov': 'video/quicktime', + '.wmv': 'video/x-ms-wmv', + + // Code + '.js': 'application/javascript', + '.json': 'application/json', + '.xml': 'application/xml', + '.html': 'text/html', + '.css': 'text/css', +}; + /** * Get MIME type from file extension * @param {string} filename - Filename or extension @@ -38,53 +82,7 @@ export function getFileExtension(filename) { */ export function getMimeType(filename) { const ext = getFileExtension(filename).toLowerCase(); - - const mimeTypes = { - // Images - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.svg': 'image/svg+xml', - '.ico': 'image/x-icon', - '.bmp': 'image/bmp', - - // Documents - '.pdf': 'application/pdf', - '.doc': 'application/msword', - '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - '.xls': 'application/vnd.ms-excel', - '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - '.ppt': 'application/vnd.ms-powerpoint', - '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - '.txt': 'text/plain', - '.csv': 'text/csv', - - // Archives - '.zip': 'application/zip', - '.rar': 'application/x-rar-compressed', - '.7z': 'application/x-7z-compressed', - '.tar': 'application/x-tar', - '.gz': 'application/gzip', - - // Media - '.mp3': 'audio/mpeg', - '.wav': 'audio/wav', - '.mp4': 'video/mp4', - '.avi': 'video/x-msvideo', - '.mov': 'video/quicktime', - '.wmv': 'video/x-ms-wmv', - - // Code - '.js': 'application/javascript', - '.json': 'application/json', - '.xml': 'application/xml', - '.html': 'text/html', - '.css': 'text/css', - }; - - return mimeTypes[ext] || 'application/octet-stream'; + return MIME_TYPES[ext] || 'application/octet-stream'; } /** @@ -154,36 +152,6 @@ export function sanitizeFilename(filename) { return sanitized + ext; } -/** - * Validate image dimensions from buffer - * Note: This is a basic implementation. For production, consider using a library like 'sharp' - * @param {Buffer} buffer - Image buffer - * @param {Object} constraints - Dimension constraints - * @param {number} constraints.maxWidth - Maximum width - * @param {number} constraints.maxHeight - Maximum height - * @param {number} constraints.minWidth - Minimum width - * @param {number} constraints.minHeight - Minimum height - * @returns {Promise} Validation result with dimensions - */ -export async function validateImageDimensions(buffer, constraints = {}) { - // SECURITY: This function previously returned { valid: true } unconditionally, - // silently bypassing all dimension constraints. That behaviour is unsafe — - // callers that invoke this function expect enforcement, not a no-op. - // - // Returning valid=false with a clear diagnostic forces callers to either - // install 'sharp' (the recommended path) or explicitly handle the - // unvalidated case themselves. Never silently approve what cannot be checked. - warn('Storage: validateImageDimensions — dimension enforcement unavailable. Install "sharp" to enable pixel-level validation.'); - return { - valid: false, - width: null, - height: null, - message: - 'Image dimension validation is not configured. ' + - 'Install "sharp" and implement validateImageDimensions before enforcing size constraints.', - }; -} - /** * Common file type presets */ @@ -303,11 +271,8 @@ export function validateUpload({ filename, size, allowedTypes, maxSize, buffer } } // Explicitly reject SVG regardless of allowedTypes — SVG can carry JavaScript. - if (filename) { - const ext = filename.split('.').pop()?.toLowerCase(); - if (ext === 'svg') { - errors.push('SVG files are not permitted due to script execution risk'); - } + if (filename && getFileExtension(filename) === '.svg') { + errors.push('SVG files are not permitted due to script execution risk'); } if (allowedTypes && !validateFileType(filename, allowedTypes)) { diff --git a/src/modules/modules.storage.js b/src/modules/modules.storage.js index 8f2ca2e..95c3892 100644 --- a/src/modules/modules.storage.js +++ b/src/modules/modules.storage.js @@ -11,40 +11,17 @@ * import { getAllStoragePublicPrefixes, getAllStorageAccessPolicies } from '@zen/core/modules/storage'; */ -import { getModule, getEnabledModules } from '@zen/core/core/modules'; -import { getPostsConfig } from './posts/config.js'; +import { getEnabledModules } from '@zen/core/core/modules'; /** - * 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). + * Get all storage public prefixes from every enabled module. * @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 ?? []) { + for (const prefix of mod.storagePublicPrefixes ?? []) { prefixes.add(prefix); } } @@ -55,8 +32,12 @@ export function getAllStoragePublicPrefixes() { /** * Get all storage access policies from every enabled module. * - * Policies for built-in features (auth, posts) are included directly. - * External modules contribute via their `storageAccessPolicies` defineModule field. + * Built-in: user files at users/{id}/... are always owner-scoped. + * The auth feature is an always-on core feature with no module registration; + * its policy is declared here as the single built-in entry. + * + * Additional policies are contributed by enabled modules via their + * `storageAccessPolicies` defineModule field. * * Policy shape: { prefix: string, type: 'owner' | 'admin' } * 'owner' — pathParts[1] must match session.user.id, or role is 'admin' @@ -66,20 +47,12 @@ export function getAllStoragePublicPrefixes() { */ export function getAllStorageAccessPolicies() { const policies = [ - // Built-in auth feature — user files are owner-scoped + // Built-in: user files are owner-scoped (auth feature, always enabled) { prefix: 'users', type: 'owner' }, ]; - // Posts module — non-public post paths require admin - if (process.env.ZEN_MODULE_POSTS === 'true') { - policies.push({ prefix: 'posts', type: 'admin' }); - } - - // External modules for (const mod of getEnabledModules()) { - if (!mod.external) continue; - const runtimeConfig = getModule(mod.name); - for (const policy of runtimeConfig?.storageAccessPolicies ?? []) { + for (const policy of mod.storageAccessPolicies ?? []) { policies.push(policy); } } diff --git a/src/modules/posts/module.config.js b/src/modules/posts/module.config.js index f821573..bf4fe33 100644 --- a/src/modules/posts/module.config.js +++ b/src/modules/posts/module.config.js @@ -91,6 +91,7 @@ export default defineModule({ envVars: ['ZEN_MODULE_POSTS_TYPES'], storagePublicPrefixes, + storageAccessPolicies: [{ prefix: 'posts', type: 'admin' }], // Array of sections — one per post type (server-side, env vars available) navigation: navigationSections,