Files
core/src/core/storage/api.js
T
hykocx 6b3bb6a4ee fix(storage): make next/headers import lazy in api.js to avoid module resolution failure
- replace top-level `import { cookies } from 'next/headers'` with lazy `await import('next/headers')` inside handler
- document the constraint in PROJECT.md: no top-level next/headers or next/navigation imports reachable from module register() chains
2026-04-25 13:08:14 -04:00

132 lines
5.1 KiB
JavaScript

/**
* Storage Feature — API Routes
*
* Serves files from storage with path-based access control.
*
* Auth note: this route is declared as auth: 'public' in the route definition
* because access policy depends on the file path, not a single role.
* The handler enforces its own rules:
* - Public prefix paths → no session required
* - All other paths → session required; access governed by registered policies
* - Unknown paths → denied
*
* Call registerStoragePolicies() and registerStoragePublicPrefixes() during
* initializeZen before the first request, following the same pattern as
* registerFeatureRoutes in core/api/runtime.js.
*/
import { getSessionCookieName } from '@zen/core/shared/config';
import { getSessionResolver } from '../api/router.js';
import { getFile } from './index.js';
import { fail } from '@zen/core/shared/logger';
import { defineApiRoutes } from '../api/define.js';
import { apiError } from '../api/respond.js';
import { getStoragePublicPrefixes, getStorageAccessPolicies } from './storage-config.js';
const COOKIE_NAME = getSessionCookieName();
// ─── Handlers ─────────────────────────────────────────────────────────────────
async function handleGetFile(_request, { wildcard: fileKey }) {
try {
if (!fileKey) {
return apiError('Bad Request', 'File path is required');
}
// Reject path traversal sequences, empty segments, and null bytes before
// passing the key to the storage backend. Next.js decodes percent-encoding
// before populating [...path], so '..' and '.' arrive as literal values.
const pathParts = fileKey.split('/');
if (
pathParts.some(seg => seg === '..' || seg === '.' || seg === '') ||
fileKey.includes('\0')
) {
return apiError('Bad Request', 'Invalid file path');
}
// Public prefixes: declared by each module via defineModule() storagePublicPrefixes.
// Files whose path starts with a declared prefix are served without authentication.
// The path must have at least two segments beyond the prefix
// ({...prefix}/{id}/{filename}) to prevent unintentional root-level exposure.
const publicPrefixes = getStoragePublicPrefixes();
const matchedPrefix = publicPrefixes.find(prefix => fileKey.startsWith(prefix + '/'));
if (matchedPrefix) {
const prefixDepth = matchedPrefix.split('/').length;
if (pathParts.length < prefixDepth + 2) {
return apiError('Bad Request', 'Invalid file path');
}
return await fetchFile(fileKey);
}
// Require authentication for all other paths.
const { cookies } = await import('next/headers');
const cookieStore = await cookies();
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
if (!sessionToken) {
return apiError('Unauthorized', 'Authentication required to access files');
}
const session = await getSessionResolver()(sessionToken);
if (!session) {
return apiError('Unauthorized', 'Invalid or expired session');
}
// Path-based access control driven by policies declared in each module.
const policies = getStorageAccessPolicies();
const policy = policies.find(p => pathParts[0] === p.prefix);
if (!policy) {
return apiError('Forbidden', 'Invalid file path');
}
if (policy.type === 'owner') {
// Owner-scoped: pathParts[1] is the resource owner ID (e.g. users/{userId}/...)
if (session.user.id !== pathParts[1] && session.user.role !== 'admin') {
return apiError('Forbidden', 'You do not have permission to access this file');
}
} else if (policy.type === 'admin') {
if (session.user.role !== 'admin') {
return apiError('Forbidden', 'Admin access required for this file');
}
}
return await fetchFile(fileKey);
} catch (error) {
// Log full error server-side; never surface internal details to the client.
fail(`Storage: error serving file: ${error.message}`);
return apiError('Internal Server Error', 'Failed to retrieve file');
}
}
async function fetchFile(fileKey) {
const result = await getFile(fileKey);
if (!result.success) {
if (result.error.includes('NoSuchKey') || result.error.includes('not found')) {
return apiError('Not Found', 'File not found');
}
// Never forward raw storage error messages (which may contain bucket names
// or internal keys) to the client.
return apiError('Internal Server Error', 'Failed to retrieve file');
}
return {
success: true,
file: {
body: result.data.body,
contentType: result.data.contentType,
contentLength: result.data.contentLength,
lastModified: result.data.lastModified,
},
};
}
// auth: 'public' — the handler enforces path-based access control internally.
// The router calls it without a session; the handler reads cookies itself for
// non-public paths.
export const routes = defineApiRoutes([
{ path: '/storage/**', method: 'GET', handler: handleGetFile, auth: 'public' }
]);