6b3bb6a4ee
- 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
132 lines
5.1 KiB
JavaScript
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' }
|
|
]);
|