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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user