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