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 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
+4
View File
@@ -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,
};
-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)
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,
+10 -45
View File
@@ -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,15 +30,7 @@ export function getFileExtension(filename) {
return lastDot === -1 ? '' : filename.substring(lastDot).toLowerCase();
}
/**
* Get MIME type from file extension
* @param {string} filename - Filename or extension
* @returns {string} MIME type
*/
export function getMimeType(filename) {
const ext = getFileExtension(filename).toLowerCase();
const mimeTypes = {
const MIME_TYPES = {
// Images
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
@@ -84,7 +75,14 @@ export function getMimeType(filename) {
'.css': 'text/css',
};
return mimeTypes[ext] || 'application/octet-stream';
/**
* Get MIME type from file extension
* @param {string} filename - Filename or extension
* @returns {string} MIME type
*/
export function getMimeType(filename) {
const ext = getFileExtension(filename).toLowerCase();
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<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
*/
@@ -303,12 +271,9 @@ 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') {
if (filename && getFileExtension(filename) === '.svg') {
errors.push('SVG files are not permitted due to script execution risk');
}
}
if (allowedTypes && !validateFileType(filename, allowedTypes)) {
const typesList = allowedTypes.join(', ');
+11 -38
View File
@@ -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);
}
}
+1
View File
@@ -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,