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
This commit is contained in:
2026-04-14 17:23:43 -04:00
parent 2e348a1608
commit 936d21fdec
6 changed files with 66 additions and 125 deletions
+2
View File
@@ -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 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 ## Build et configuration tsup
+4
View File
@@ -108,6 +108,8 @@ async function loadModuleConfig(moduleName) {
public: moduleConfig.publicRoutes?.length public: moduleConfig.publicRoutes?.length
? { routes: moduleConfig.publicRoutes } ? { routes: moduleConfig.publicRoutes }
: undefined, : undefined,
storagePublicPrefixes: moduleConfig.storagePublicPrefixes || [],
storageAccessPolicies: moduleConfig.storageAccessPolicies || [],
}; };
} catch (error) { } catch (error) {
// No module.config.js — use defaults silently // No module.config.js — use defaults silently
@@ -208,6 +210,8 @@ export async function registerExternalModules(modules = []) {
: undefined, : undefined,
cron: moduleConfig.cron || undefined, cron: moduleConfig.cron || undefined,
api: moduleConfig.api || undefined, api: moduleConfig.api || undefined,
storagePublicPrefixes: moduleConfig.storagePublicPrefixes || [],
storageAccessPolicies: moduleConfig.storageAccessPolicies || [],
enabled: true, enabled: true,
external: true, external: true,
}; };
-4
View File
@@ -537,9 +537,6 @@ async function getPresignedUrl({ key, expiresIn = 3600, operation = 'get' }) {
// R2/S3 max presigned URL lifetime is 7 days (604800 seconds) // R2/S3 max presigned URL lifetime is 7 days (604800 seconds)
const validExpiresIn = Math.min(Math.max(Math.floor(Number(expiresIn)), 1), 604800); 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 }); const url = buildPresignedUrl({ method, host: config.host, path, expiresIn: validExpiresIn, config, date });
@@ -646,7 +643,6 @@ export {
validateFileSize, validateFileSize,
formatFileSize, formatFileSize,
sanitizeFilename, sanitizeFilename,
validateImageDimensions,
validateUpload, validateUpload,
FILE_TYPE_PRESETS, FILE_TYPE_PRESETS,
FILE_SIZE_LIMITS, FILE_SIZE_LIMITS,
+48 -83
View File
@@ -4,7 +4,6 @@
*/ */
import crypto from 'crypto'; import crypto from 'crypto';
import { warn } from '../../shared/lib/logger.js';
/** /**
* Generate a unique filename with timestamp and random hash * Generate a unique filename with timestamp and random hash
@@ -31,6 +30,51 @@ export function getFileExtension(filename) {
return lastDot === -1 ? '' : filename.substring(lastDot).toLowerCase(); 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 * Get MIME type from file extension
* @param {string} filename - Filename or extension * @param {string} filename - Filename or extension
@@ -38,53 +82,7 @@ export function getFileExtension(filename) {
*/ */
export function getMimeType(filename) { export function getMimeType(filename) {
const ext = getFileExtension(filename).toLowerCase(); const ext = getFileExtension(filename).toLowerCase();
return MIME_TYPES[ext] || 'application/octet-stream';
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';
} }
/** /**
@@ -154,36 +152,6 @@ export function sanitizeFilename(filename) {
return sanitized + ext; 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<Object>} 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 * 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. // Explicitly reject SVG regardless of allowedTypes — SVG can carry JavaScript.
if (filename) { if (filename && getFileExtension(filename) === '.svg') {
const ext = filename.split('.').pop()?.toLowerCase(); errors.push('SVG files are not permitted due to script execution risk');
if (ext === 'svg') {
errors.push('SVG files are not permitted due to script execution risk');
}
} }
if (allowedTypes && !validateFileType(filename, allowedTypes)) { if (allowedTypes && !validateFileType(filename, allowedTypes)) {
+11 -38
View File
@@ -11,40 +11,17 @@
* import { getAllStoragePublicPrefixes, getAllStorageAccessPolicies } from '@zen/core/modules/storage'; * import { getAllStoragePublicPrefixes, getAllStorageAccessPolicies } from '@zen/core/modules/storage';
*/ */
import { getModule, getEnabledModules } from '@zen/core/core/modules'; import { getEnabledModules } from '@zen/core/core/modules';
import { getPostsConfig } from './posts/config.js';
/** /**
* Compute public storage prefixes for the posts module from its type config. * Get all storage public prefixes from every enabled module.
* 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 * @returns {string[]} Deduplicated list of public storage prefixes
*/ */
export function getAllStoragePublicPrefixes() { export function getAllStoragePublicPrefixes() {
const prefixes = new Set(); 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()) { for (const mod of getEnabledModules()) {
if (!mod.external) continue; for (const prefix of mod.storagePublicPrefixes ?? []) {
const runtimeConfig = getModule(mod.name);
for (const prefix of runtimeConfig?.storagePublicPrefixes ?? []) {
prefixes.add(prefix); prefixes.add(prefix);
} }
} }
@@ -55,8 +32,12 @@ export function getAllStoragePublicPrefixes() {
/** /**
* Get all storage access policies from every enabled module. * Get all storage access policies from every enabled module.
* *
* Policies for built-in features (auth, posts) are included directly. * Built-in: user files at users/{id}/... are always owner-scoped.
* External modules contribute via their `storageAccessPolicies` defineModule field. * 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' } * Policy shape: { prefix: string, type: 'owner' | 'admin' }
* 'owner' — pathParts[1] must match session.user.id, or role is 'admin' * 'owner' — pathParts[1] must match session.user.id, or role is 'admin'
@@ -66,20 +47,12 @@ export function getAllStoragePublicPrefixes() {
*/ */
export function getAllStorageAccessPolicies() { export function getAllStorageAccessPolicies() {
const policies = [ 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' }, { 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()) { for (const mod of getEnabledModules()) {
if (!mod.external) continue; for (const policy of mod.storageAccessPolicies ?? []) {
const runtimeConfig = getModule(mod.name);
for (const policy of runtimeConfig?.storageAccessPolicies ?? []) {
policies.push(policy); policies.push(policy);
} }
} }
+1
View File
@@ -91,6 +91,7 @@ export default defineModule({
envVars: ['ZEN_MODULE_POSTS_TYPES'], envVars: ['ZEN_MODULE_POSTS_TYPES'],
storagePublicPrefixes, storagePublicPrefixes,
storageAccessPolicies: [{ prefix: 'posts', type: 'admin' }],
// Array of sections — one per post type (server-side, env vars available) // Array of sections — one per post type (server-side, env vars available)
navigation: navigationSections, navigation: navigationSections,