feat(storage): refactor storage config and remove module registry
Introduce a dedicated `storage-config.js` for registering public prefixes and access policies via `configureStorageApi()`, replacing the previous `getAllStoragePublicPrefixes` / `getAllStorageAccessPolicies` imports from the module registry. Remove `getAllApiRoutes()` from the router so module-level routes are no longer auto-collected; feature routes must now be registered explicitly via `registerFeatureRoutes()` during `initializeZen()`. Update `.env.example` to document separate `ZEN_STORAGE_PROVIDER`, `ZEN_STORAGE_B2_*` variables for Backblaze B2 alongside the existing Cloudflare R2 variables, making provider selection explicit. Clean up admin navigation and page components to drop module-injected nav entries, keeping only core and system sections.
This commit is contained in:
+14
-3
@@ -15,15 +15,26 @@ ZEN_DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/postgres
|
||||
ZEN_DATABASE_URL_DEV=postgres://USER:PASSWORD@HOST:PORT/postgres_dev
|
||||
ZEN_DB_SSL_DISABLED=false
|
||||
|
||||
# STORAGE (S3-compatible — Cloudflare R2 ou Backblaze B2)
|
||||
# R2 : ZEN_STORAGE_ENDPOINT=<accountId>.r2.cloudflarestorage.com ZEN_STORAGE_REGION=auto
|
||||
# B2 : ZEN_STORAGE_ENDPOINT=s3.<region>.backblazeb2.com ZEN_STORAGE_REGION=<region>
|
||||
# STORAGE
|
||||
# Fournisseur : 'r2' (défaut) ou 'backblaze'
|
||||
ZEN_STORAGE_PROVIDER=r2
|
||||
|
||||
# Cloudflare R2 (ZEN_STORAGE_PROVIDER=r2)
|
||||
# Endpoint format : <accountId>.r2.cloudflarestorage.com
|
||||
ZEN_STORAGE_ENDPOINT=
|
||||
ZEN_STORAGE_REGION=auto
|
||||
ZEN_STORAGE_BUCKET=
|
||||
ZEN_STORAGE_ACCESS_KEY=
|
||||
ZEN_STORAGE_SECRET_KEY=
|
||||
|
||||
# Backblaze B2 (ZEN_STORAGE_PROVIDER=backblaze)
|
||||
# Endpoint format : s3.<region>.backblazeb2.com
|
||||
ZEN_STORAGE_B2_ENDPOINT=
|
||||
ZEN_STORAGE_B2_REGION=
|
||||
ZEN_STORAGE_B2_BUCKET=
|
||||
ZEN_STORAGE_B2_ACCESS_KEY=
|
||||
ZEN_STORAGE_B2_SECRET_KEY=
|
||||
|
||||
# EMAIL
|
||||
ZEN_EMAIL_RESEND_APIKEY=
|
||||
ZEN_EMAIL_FROM_NAME="EXEMPLE"
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
* src/core/ and have no feature-level dependencies.
|
||||
*
|
||||
* Feature routes (e.g. /users/*) are registered separately by each feature
|
||||
* during initializeZen() via registerFeatureRoutes(). Module routes are
|
||||
* collected via getAllApiRoutes() from the module registry.
|
||||
* during initializeZen() via registerFeatureRoutes().
|
||||
*
|
||||
* To add a new core infrastructure handler:
|
||||
* 1. Create the handler in its natural location under src/core/
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { getSessionCookieName } from '../../shared/lib/appConfig.js';
|
||||
import { getAllApiRoutes } from '../modules/index.js';
|
||||
import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '../../shared/lib/rateLimit.js';
|
||||
import { fail } from '../../shared/lib/logger.js';
|
||||
import { getCoreRoutes } from './core-routes.js';
|
||||
@@ -220,7 +219,7 @@ export async function routeRequest(request, path) {
|
||||
// Fusion de toutes les routes — core en premier pour que les built-ins aient priorité.
|
||||
// Le rate limit est différé après le matching pour pouvoir honorer skipRateLimit
|
||||
// sans hardcoder de chemins dans le router.
|
||||
const allRoutes = [...getCoreRoutes(), ...getFeatureRoutes(), ...getAllApiRoutes()];
|
||||
const allRoutes = [...getCoreRoutes(), ...getFeatureRoutes()];
|
||||
|
||||
const matchedRoute = allRoutes.find(
|
||||
route => matchRoute(route.path, pathString) && route.method === method
|
||||
|
||||
+13
-20
@@ -7,30 +7,27 @@
|
||||
* because access policy depends on the file path, not a single role.
|
||||
* The handler enforces its own rules:
|
||||
* - Public prefix paths → no session required
|
||||
* - User files → session required; users can only access their own files
|
||||
* - Organisation files → admin session required
|
||||
* - Post files (private) → admin session required
|
||||
* - All other paths → session required; access governed by registered policies
|
||||
* - Unknown paths → denied
|
||||
*
|
||||
* Call configureStorageApi({ getPublicPrefixes, getAccessPolicies }) during
|
||||
* initializeZen before the first request, following the same pattern as
|
||||
* configureRouter in core/api/runtime.js.
|
||||
*/
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { getSessionCookieName } from '../../shared/lib/appConfig.js';
|
||||
import { getSessionResolver } from '../api/router.js';
|
||||
import { getAllStoragePublicPrefixes, getAllStorageAccessPolicies } from '@zen/core/modules/storage';
|
||||
import { getFile } from '@zen/core/storage';
|
||||
import { getFile } from './index.js';
|
||||
import { fail } from '../../shared/lib/logger.js';
|
||||
import { defineApiRoutes } from '../api/define.js';
|
||||
import { apiError } from '../api/respond.js';
|
||||
import { getStoragePublicPrefixes, getStorageAccessPolicies } from './storage-config.js';
|
||||
|
||||
const COOKIE_NAME = getSessionCookieName();
|
||||
|
||||
/**
|
||||
* Serve a file from storage with path-based security validation.
|
||||
*
|
||||
* @param {Request} request
|
||||
* @param {{ wildcard: string }} params - wildcard contains the full file key
|
||||
* @returns {Promise<{ success: true, file: Object }|{ error: string, message: string }>}
|
||||
*/
|
||||
// ─── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleGetFile(_request, { wildcard: fileKey }) {
|
||||
try {
|
||||
if (!fileKey) {
|
||||
@@ -52,7 +49,7 @@ async function handleGetFile(_request, { wildcard: fileKey }) {
|
||||
// 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 = getAllStoragePublicPrefixes();
|
||||
const publicPrefixes = getStoragePublicPrefixes();
|
||||
const matchedPrefix = publicPrefixes.find(prefix => fileKey.startsWith(prefix + '/'));
|
||||
if (matchedPrefix) {
|
||||
const prefixDepth = matchedPrefix.split('/').length;
|
||||
@@ -77,7 +74,7 @@ async function handleGetFile(_request, { wildcard: fileKey }) {
|
||||
}
|
||||
|
||||
// Path-based access control driven by policies declared in each module.
|
||||
const policies = getAllStorageAccessPolicies();
|
||||
const policies = getStorageAccessPolicies();
|
||||
const policy = policies.find(p => pathParts[0] === p.prefix);
|
||||
|
||||
if (!policy) {
|
||||
@@ -103,10 +100,6 @@ async function handleGetFile(_request, { wildcard: fileKey }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a file from the storage backend and return the response envelope.
|
||||
* @param {string} fileKey
|
||||
*/
|
||||
async function fetchFile(fileKey) {
|
||||
const result = await getFile(fileKey);
|
||||
|
||||
@@ -125,8 +118,8 @@ async function fetchFile(fileKey) {
|
||||
body: result.data.body,
|
||||
contentType: result.data.contentType,
|
||||
contentLength: result.data.contentLength,
|
||||
lastModified: result.data.lastModified
|
||||
}
|
||||
lastModified: result.data.lastModified,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Backblaze B2 provider config (S3-compatible API).
|
||||
* Reads ZEN_STORAGE_B2_* environment variables.
|
||||
*
|
||||
* Endpoint format: s3.<region>.backblazeb2.com (e.g. s3.us-west-004.backblazeb2.com)
|
||||
*/
|
||||
|
||||
export function getConfig() {
|
||||
const host = process.env.ZEN_STORAGE_B2_ENDPOINT;
|
||||
const region = process.env.ZEN_STORAGE_B2_REGION;
|
||||
const accessKeyId = process.env.ZEN_STORAGE_B2_ACCESS_KEY;
|
||||
const secretAccessKey = process.env.ZEN_STORAGE_B2_SECRET_KEY;
|
||||
const bucket = process.env.ZEN_STORAGE_B2_BUCKET;
|
||||
|
||||
if (!host || !accessKeyId || !secretAccessKey) {
|
||||
throw new Error(
|
||||
'Backblaze B2 credentials are not configured. Please set ZEN_STORAGE_B2_ENDPOINT, ZEN_STORAGE_B2_ACCESS_KEY, and ZEN_STORAGE_B2_SECRET_KEY.'
|
||||
);
|
||||
}
|
||||
if (!bucket) {
|
||||
throw new Error('ZEN_STORAGE_B2_BUCKET environment variable is not set.');
|
||||
}
|
||||
if (!region) {
|
||||
throw new Error('ZEN_STORAGE_B2_REGION environment variable is not set.');
|
||||
}
|
||||
|
||||
return { accessKeyId, secretAccessKey, bucket, host, region };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Cloudflare R2 provider config.
|
||||
* Reads ZEN_STORAGE_* environment variables.
|
||||
*/
|
||||
|
||||
export function getConfig() {
|
||||
const host = process.env.ZEN_STORAGE_ENDPOINT;
|
||||
const region = process.env.ZEN_STORAGE_REGION ?? 'auto';
|
||||
const accessKeyId = process.env.ZEN_STORAGE_ACCESS_KEY;
|
||||
const secretAccessKey = process.env.ZEN_STORAGE_SECRET_KEY;
|
||||
const bucket = process.env.ZEN_STORAGE_BUCKET;
|
||||
|
||||
if (!host || !accessKeyId || !secretAccessKey) {
|
||||
throw new Error(
|
||||
'Storage credentials are not configured. Please set ZEN_STORAGE_ENDPOINT, ZEN_STORAGE_ACCESS_KEY, and ZEN_STORAGE_SECRET_KEY.'
|
||||
);
|
||||
}
|
||||
if (!bucket) {
|
||||
throw new Error('ZEN_STORAGE_BUCKET environment variable is not set.');
|
||||
}
|
||||
|
||||
return { accessKeyId, secretAccessKey, bucket, host, region };
|
||||
}
|
||||
+35
-347
@@ -1,259 +1,33 @@
|
||||
/**
|
||||
* Zen Storage Module - Cloudflare R2
|
||||
* Provides file upload, download, deletion, and management functionality
|
||||
* Uses native fetch + crypto (AWS Signature V4) — no external dependencies
|
||||
*/
|
||||
|
||||
import { createHmac, createHash } from 'crypto';
|
||||
import { createHash } from 'crypto';
|
||||
import { fail, warn, info } from '../../shared/lib/logger.js';
|
||||
import {
|
||||
signRequest,
|
||||
buildPresignedUrl,
|
||||
toBuffer,
|
||||
xmlFirst,
|
||||
xmlAll,
|
||||
sanitizeHeaderValue,
|
||||
escapeXml,
|
||||
metaToHeaders,
|
||||
headersToMeta,
|
||||
encodePath,
|
||||
} from './signing.js';
|
||||
|
||||
// ─── AWS Signature V4 ────────────────────────────────────────────────────────
|
||||
// ─── Provider selection ───────────────────────────────────────────────────────
|
||||
|
||||
function sha256hex(data) {
|
||||
return createHash('sha256').update(data).digest('hex');
|
||||
async function getConfig() {
|
||||
const provider = process.env.ZEN_STORAGE_PROVIDER ?? 'r2';
|
||||
const { getConfig: providerGetConfig } = provider === 'backblaze'
|
||||
? await import('./backblaze.js')
|
||||
: await import('./cloudflare-r2.js');
|
||||
return providerGetConfig();
|
||||
}
|
||||
|
||||
function hmac(key, data) {
|
||||
return createHmac('sha256', Buffer.isBuffer(key) ? key : Buffer.from(key)).update(data).digest();
|
||||
}
|
||||
// ─── Storage functions ────────────────────────────────────────────────────────
|
||||
|
||||
function hmacHex(key, data) {
|
||||
return createHmac('sha256', Buffer.isBuffer(key) ? key : Buffer.from(key)).update(data).digest('hex');
|
||||
}
|
||||
|
||||
function amzDate(date) {
|
||||
return date.toISOString().replace(/[:\-]|\.\d{3}/g, '');
|
||||
}
|
||||
|
||||
function dateStamp(date) {
|
||||
return date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a string per AWS Signature V4 spec (encodes everything except A-Z a-z 0-9 - _ . ~)
|
||||
*/
|
||||
function encodeS3(str) {
|
||||
return encodeURIComponent(str)
|
||||
.replace(/!/g, '%21')
|
||||
.replace(/'/g, '%27')
|
||||
.replace(/\(/g, '%28')
|
||||
.replace(/\)/g, '%29')
|
||||
.replace(/\*/g, '%2A');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a URI path, encoding each segment individually (preserving slashes)
|
||||
*/
|
||||
function encodePath(path) {
|
||||
return path
|
||||
.split('/')
|
||||
.map(segment => (segment ? encodeS3(segment) : ''))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
function signingKey(secret, ds, region, service) {
|
||||
const kDate = hmac('AWS4' + secret, ds);
|
||||
const kRegion = hmac(kDate, region);
|
||||
const kService = hmac(kRegion, service);
|
||||
return hmac(kService, 'aws4_request');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign an S3 request using AWS Signature V4.
|
||||
* Returns the full URL and the headers object to pass to fetch.
|
||||
*/
|
||||
function signRequest({ method, host, path, query = {}, extraHeaders = {}, bodyBuffer, config, date }) {
|
||||
const { accessKeyId, secretAccessKey, region } = config;
|
||||
const service = 's3';
|
||||
|
||||
const ts = amzDate(date);
|
||||
const ds = dateStamp(date);
|
||||
const bodyHash = sha256hex(bodyBuffer ?? Buffer.alloc(0));
|
||||
|
||||
const headers = {
|
||||
host,
|
||||
'x-amz-date': ts,
|
||||
'x-amz-content-sha256': bodyHash,
|
||||
...extraHeaders,
|
||||
};
|
||||
|
||||
const sortedHeaderKeys = Object.keys(headers).sort();
|
||||
const canonicalHeaders = sortedHeaderKeys.map(k => `${k}:${headers[k]}\n`).join('');
|
||||
const signedHeaders = sortedHeaderKeys.join(';');
|
||||
|
||||
const canonicalQueryString = Object.keys(query)
|
||||
.sort()
|
||||
.map(k => `${encodeS3(k)}=${encodeS3(query[k])}`)
|
||||
.join('&');
|
||||
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
encodePath(path),
|
||||
canonicalQueryString,
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
bodyHash,
|
||||
].join('\n');
|
||||
|
||||
const scope = `${ds}/${region}/${service}/aws4_request`;
|
||||
const stringToSign = ['AWS4-HMAC-SHA256', ts, scope, sha256hex(canonicalRequest)].join('\n');
|
||||
|
||||
const sig = hmacHex(signingKey(secretAccessKey, ds, region, service), stringToSign);
|
||||
const auth = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${sig}`;
|
||||
|
||||
const requestHeaders = { ...headers, Authorization: auth };
|
||||
delete requestHeaders.host;
|
||||
|
||||
const url = canonicalQueryString
|
||||
? `https://${host}${path}?${canonicalQueryString}`
|
||||
: `https://${host}${path}`;
|
||||
|
||||
return { url, headers: requestHeaders };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a presigned URL (signature embedded in query string, no Authorization header).
|
||||
* The payload is marked UNSIGNED-PAYLOAD so no body hash is needed at signing time.
|
||||
*/
|
||||
function buildPresignedUrl({ method, host, path, expiresIn, config, date }) {
|
||||
const { accessKeyId, secretAccessKey, region } = config;
|
||||
const service = 's3';
|
||||
|
||||
const ts = amzDate(date);
|
||||
const ds = dateStamp(date);
|
||||
const scope = `${ds}/${region}/${service}/aws4_request`;
|
||||
|
||||
const query = {
|
||||
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
|
||||
'X-Amz-Credential': `${accessKeyId}/${scope}`,
|
||||
'X-Amz-Date': ts,
|
||||
'X-Amz-Expires': String(expiresIn),
|
||||
'X-Amz-SignedHeaders': 'host',
|
||||
};
|
||||
|
||||
const canonicalQueryString = Object.keys(query)
|
||||
.sort()
|
||||
.map(k => `${encodeS3(k)}=${encodeS3(query[k])}`)
|
||||
.join('&');
|
||||
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
encodePath(path),
|
||||
canonicalQueryString,
|
||||
`host:${host}\n`,
|
||||
'host',
|
||||
'UNSIGNED-PAYLOAD',
|
||||
].join('\n');
|
||||
|
||||
const stringToSign = ['AWS4-HMAC-SHA256', ts, scope, sha256hex(canonicalRequest)].join('\n');
|
||||
const sig = hmacHex(signingKey(secretAccessKey, ds, region, service), stringToSign);
|
||||
|
||||
return `https://${host}${path}?${canonicalQueryString}&X-Amz-Signature=${sig}`;
|
||||
}
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function getConfig() {
|
||||
const host = process.env.ZEN_STORAGE_ENDPOINT;
|
||||
const region = process.env.ZEN_STORAGE_REGION ?? 'auto';
|
||||
const accessKeyId = process.env.ZEN_STORAGE_ACCESS_KEY;
|
||||
const secretAccessKey = process.env.ZEN_STORAGE_SECRET_KEY;
|
||||
const bucket = process.env.ZEN_STORAGE_BUCKET;
|
||||
|
||||
if (!host || !accessKeyId || !secretAccessKey) {
|
||||
throw new Error(
|
||||
'Storage credentials are not configured. Please set ZEN_STORAGE_ENDPOINT, ZEN_STORAGE_ACCESS_KEY, and ZEN_STORAGE_SECRET_KEY.'
|
||||
);
|
||||
}
|
||||
if (!bucket) {
|
||||
throw new Error('ZEN_STORAGE_BUCKET environment variable is not set');
|
||||
}
|
||||
|
||||
return { accessKeyId, secretAccessKey, bucket, host, region };
|
||||
}
|
||||
|
||||
// ─── Minimal XML helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function xmlFirst(xml, tag) {
|
||||
const m = xml.match(new RegExp(`<${tag}[^>]*>(.*?)</${tag}>`, 's'));
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function xmlAll(xml, tag) {
|
||||
const re = new RegExp(`<${tag}[^>]*>(.*?)</${tag}>`, 'gs');
|
||||
const results = [];
|
||||
let m;
|
||||
while ((m = re.exec(xml)) !== null) results.push(m[1]);
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Body normalizer ─────────────────────────────────────────────────────────
|
||||
|
||||
async function toBuffer(body) {
|
||||
if (Buffer.isBuffer(body)) return body;
|
||||
if (body instanceof Uint8Array) return Buffer.from(body);
|
||||
if (typeof body === 'string') return Buffer.from(body, 'utf8');
|
||||
if (body instanceof Blob) return Buffer.from(await body.arrayBuffer());
|
||||
return Buffer.from(body);
|
||||
}
|
||||
|
||||
// ─── Sanitization helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Strip HTTP header injection characters (\r, \n, \0) from a header value.
|
||||
* A value containing these characters would break the canonical request format
|
||||
* and could allow an attacker to inject arbitrary signed headers.
|
||||
*/
|
||||
function sanitizeHeaderValue(value) {
|
||||
return String(value).replace(/[\r\n\0]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape XML special characters to prevent injection into the DeleteObjects payload.
|
||||
*/
|
||||
function escapeXml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ─── Metadata header helpers ─────────────────────────────────────────────────
|
||||
|
||||
function metaToHeaders(metadata) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(metadata).map(([k, v]) => [
|
||||
`x-amz-meta-${sanitizeHeaderValue(k).toLowerCase()}`,
|
||||
sanitizeHeaderValue(v),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function headersToMeta(headers) {
|
||||
return Object.fromEntries(
|
||||
[...headers.entries()]
|
||||
.filter(([k]) => k.startsWith('x-amz-meta-'))
|
||||
.map(([k, v]) => [k.replace('x-amz-meta-', ''), v])
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Storage functions ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upload a file to storage
|
||||
* @param {Object} options
|
||||
* @param {string} options.key - File path/key in the bucket
|
||||
* @param {Buffer|string|Uint8Array|Blob} options.body - File content
|
||||
* @param {string} options.contentType - MIME type
|
||||
* @param {Object} options.metadata - Optional metadata key-value pairs
|
||||
* @param {string} options.cacheControl - Optional cache control header
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function uploadFile({ key, body, contentType, metadata = {}, cacheControl }) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const config = await getConfig();
|
||||
const path = `/${config.bucket}/${key}`;
|
||||
const date = new Date();
|
||||
const bodyBuffer = await toBuffer(body);
|
||||
@@ -264,16 +38,7 @@ async function uploadFile({ key, body, contentType, metadata = {}, cacheControl
|
||||
...(cacheControl && { 'cache-control': sanitizeHeaderValue(cacheControl) }),
|
||||
};
|
||||
|
||||
const { url, headers } = signRequest({
|
||||
method: 'PUT',
|
||||
host: config.host,
|
||||
path,
|
||||
extraHeaders,
|
||||
bodyBuffer,
|
||||
config,
|
||||
date,
|
||||
});
|
||||
|
||||
const { url, headers } = signRequest({ method: 'PUT', host: config.host, path, extraHeaders, bodyBuffer, config, date });
|
||||
const response = await fetch(url, { method: 'PUT', headers, body: bodyBuffer });
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -288,27 +53,13 @@ async function uploadFile({ key, body, contentType, metadata = {}, cacheControl
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an image with optimized settings
|
||||
* @param {Object} options
|
||||
* @param {string} options.key - File path/key in the bucket
|
||||
* @param {Buffer|Blob} options.body - Image content
|
||||
* @param {string} options.contentType - Image MIME type
|
||||
* @param {Object} options.metadata - Optional metadata
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function uploadImage({ key, body, contentType, metadata = {} }) {
|
||||
return uploadFile({ key, body, contentType, metadata, cacheControl: 'public, max-age=31536000' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from storage
|
||||
* @param {string} key - File path/key to delete
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function deleteFile(key) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const config = await getConfig();
|
||||
const path = `/${config.bucket}/${key}`;
|
||||
const date = new Date();
|
||||
|
||||
@@ -327,14 +78,9 @@ async function deleteFile(key) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple files from storage
|
||||
* @param {string[]} keys - Array of file paths/keys to delete
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function deleteFiles(keys) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const config = await getConfig();
|
||||
const path = `/${config.bucket}`;
|
||||
const date = new Date();
|
||||
|
||||
@@ -378,14 +124,9 @@ async function deleteFiles(keys) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a file from storage
|
||||
* @param {string} key - File path/key to retrieve
|
||||
* @returns {Promise<Object>} File data with metadata
|
||||
*/
|
||||
async function getFile(key) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const config = await getConfig();
|
||||
const path = `/${config.bucket}/${key}`;
|
||||
const date = new Date();
|
||||
|
||||
@@ -419,14 +160,9 @@ async function getFile(key) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file metadata without downloading the file
|
||||
* @param {string} key - File path/key
|
||||
* @returns {Promise<Object>} File metadata
|
||||
*/
|
||||
async function getFileMetadata(key) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const config = await getConfig();
|
||||
const path = `/${config.bucket}/${key}`;
|
||||
const date = new Date();
|
||||
|
||||
@@ -457,27 +193,14 @@ async function getFileMetadata(key) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists in storage
|
||||
* @param {string} key - File path/key to check
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function fileExists(key) {
|
||||
const result = await getFileMetadata(key);
|
||||
return result.success;
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a directory/prefix
|
||||
* @param {Object} options
|
||||
* @param {string} options.prefix - Directory prefix (e.g., 'users/123/')
|
||||
* @param {number} options.maxKeys - Maximum number of keys to return (default: 1000)
|
||||
* @param {string} options.continuationToken - Token for pagination
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function listFiles({ prefix = '', maxKeys = 1000, continuationToken } = {}) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const config = await getConfig();
|
||||
const path = `/${config.bucket}`;
|
||||
const date = new Date();
|
||||
|
||||
@@ -509,35 +232,22 @@ async function listFiles({ prefix = '', maxKeys = 1000, continuationToken } = {}
|
||||
etag: (xmlFirst(block, 'ETag') || '').replace(/"/g, ''),
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { files, isTruncated, nextContinuationToken, count: files.length },
|
||||
error: null,
|
||||
};
|
||||
return { success: true, data: { files, isTruncated, nextContinuationToken, count: files.length }, error: null };
|
||||
} catch (error) {
|
||||
fail(`Storage list files failed: ${error.message}`);
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a presigned URL for temporary access to a file
|
||||
* @param {Object} options
|
||||
* @param {string} options.key - File path/key
|
||||
* @param {number} options.expiresIn - URL expiration time in seconds (default: 3600)
|
||||
* @param {string} options.operation - 'get' or 'put' (default: 'get')
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function getPresignedUrl({ key, expiresIn = 3600, operation = 'get' }) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const config = await getConfig();
|
||||
const path = `/${config.bucket}/${key}`;
|
||||
const date = new Date();
|
||||
const method = operation === 'put' ? 'PUT' : 'GET';
|
||||
|
||||
// R2/S3 max presigned URL lifetime is 7 days (604800 seconds)
|
||||
const validExpiresIn = Math.min(Math.max(Math.floor(Number(expiresIn)), 1), 604800);
|
||||
|
||||
const url = buildPresignedUrl({ method, host: config.host, path, expiresIn: validExpiresIn, config, date });
|
||||
|
||||
return { success: true, data: { key, url, expiresIn: validExpiresIn, operation }, error: null };
|
||||
@@ -547,17 +257,9 @@ async function getPresignedUrl({ key, expiresIn = 3600, operation = 'get' }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a file within the same bucket using the native S3 CopyObject API.
|
||||
* No data is transferred through the server — the provider copies server-side.
|
||||
* @param {Object} options
|
||||
* @param {string} options.sourceKey - Source file path/key
|
||||
* @param {string} options.destinationKey - Destination file path/key
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function copyFile({ sourceKey, destinationKey }) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const config = await getConfig();
|
||||
const path = `/${config.bucket}/${destinationKey}`;
|
||||
const date = new Date();
|
||||
|
||||
@@ -585,15 +287,6 @@ async function copyFile({ sourceKey, destinationKey }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy a file from storage, returning a handler-ready response object.
|
||||
* Use this instead of presigned URLs to avoid exposing storage URLs to clients.
|
||||
* The returned object is consumed directly by the API router to stream the file.
|
||||
* @param {string} key - File path/key to retrieve
|
||||
* @param {Object} options
|
||||
* @param {string} [options.filename] - Optional download filename (Content-Disposition)
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function proxyFile(key, { filename } = {}) {
|
||||
const result = await getFile(key);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
@@ -608,13 +301,6 @@ async function proxyFile(key, { filename } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a file (copy + delete source)
|
||||
* @param {Object} options
|
||||
* @param {string} options.sourceKey - Source file path/key
|
||||
* @param {string} options.destinationKey - Destination file path/key
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function moveFile({ sourceKey, destinationKey }) {
|
||||
try {
|
||||
const copyResult = await copyFile({ sourceKey, destinationKey });
|
||||
@@ -634,7 +320,8 @@ async function moveFile({ sourceKey, destinationKey }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Export utility functions
|
||||
// ─── Exports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export {
|
||||
generateUniqueFilename,
|
||||
getFileExtension,
|
||||
@@ -648,7 +335,6 @@ export {
|
||||
FILE_SIZE_LIMITS,
|
||||
} from './utils.js';
|
||||
|
||||
// Export storage functions
|
||||
export {
|
||||
uploadFile,
|
||||
uploadImage,
|
||||
@@ -663,3 +349,5 @@ export {
|
||||
copyFile,
|
||||
moveFile,
|
||||
};
|
||||
|
||||
export { configureStorageApi } from './storage-config.js';
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* AWS Signature V4 — internal helpers for S3-compatible storage providers.
|
||||
* Not exported from package.json; only imported by provider implementations.
|
||||
*/
|
||||
|
||||
import { createHmac, createHash } from 'crypto';
|
||||
|
||||
// ─── Crypto primitives ───────────────────────────────────────────────────────
|
||||
|
||||
export function sha256hex(data) {
|
||||
return createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* HMAC-SHA256. Pass encoding='hex' for a hex string; omit for a Buffer.
|
||||
*/
|
||||
export function hmac(key, data, encoding) {
|
||||
return createHmac('sha256', Buffer.isBuffer(key) ? key : Buffer.from(key))
|
||||
.update(data)
|
||||
.digest(encoding);
|
||||
}
|
||||
|
||||
export function amzDate(date) {
|
||||
return date.toISOString().replace(/[:\-]|\.\d{3}/g, '');
|
||||
}
|
||||
|
||||
export function dateStamp(date) {
|
||||
return date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
}
|
||||
|
||||
// ─── URI encoding ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Encode a string per AWS Signature V4 spec (encodes everything except A-Z a-z 0-9 - _ . ~)
|
||||
*/
|
||||
export function encodeS3(str) {
|
||||
return encodeURIComponent(str)
|
||||
.replace(/!/g, '%21')
|
||||
.replace(/'/g, '%27')
|
||||
.replace(/\(/g, '%28')
|
||||
.replace(/\)/g, '%29')
|
||||
.replace(/\*/g, '%2A');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a URI path, encoding each segment individually (preserving slashes)
|
||||
*/
|
||||
export function encodePath(path) {
|
||||
return path.split('/').map(seg => (seg ? encodeS3(seg) : '')).join('/');
|
||||
}
|
||||
|
||||
// ─── Signing key ─────────────────────────────────────────────────────────────
|
||||
|
||||
function signingKey(secret, ds, region, service) {
|
||||
const kDate = hmac('AWS4' + secret, ds);
|
||||
const kRegion = hmac(kDate, region);
|
||||
const kService = hmac(kRegion, service);
|
||||
return hmac(kService, 'aws4_request');
|
||||
}
|
||||
|
||||
// ─── Request signing ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sign an S3 request using AWS Signature V4.
|
||||
* Returns the full URL and the headers object to pass to fetch.
|
||||
*/
|
||||
export function signRequest({ method, host, path, query = {}, extraHeaders = {}, bodyBuffer, config, date }) {
|
||||
const { accessKeyId, secretAccessKey, region } = config;
|
||||
const service = 's3';
|
||||
|
||||
const ts = amzDate(date);
|
||||
const ds = dateStamp(date);
|
||||
const bodyHash = sha256hex(bodyBuffer ?? Buffer.alloc(0));
|
||||
|
||||
const headers = { host, 'x-amz-date': ts, 'x-amz-content-sha256': bodyHash, ...extraHeaders };
|
||||
|
||||
const sortedKeys = Object.keys(headers).sort();
|
||||
const canonicalHeaders = sortedKeys.map(k => `${k}:${headers[k]}\n`).join('');
|
||||
const signedHeaders = sortedKeys.join(';');
|
||||
|
||||
const canonicalQueryString = Object.keys(query)
|
||||
.sort()
|
||||
.map(k => `${encodeS3(k)}=${encodeS3(query[k])}`)
|
||||
.join('&');
|
||||
|
||||
const canonicalRequest = [method, encodePath(path), canonicalQueryString, canonicalHeaders, signedHeaders, bodyHash].join('\n');
|
||||
|
||||
const scope = `${ds}/${region}/${service}/aws4_request`;
|
||||
const stringToSign = ['AWS4-HMAC-SHA256', ts, scope, sha256hex(canonicalRequest)].join('\n');
|
||||
const sig = hmac(signingKey(secretAccessKey, ds, region, service), stringToSign, 'hex');
|
||||
const auth = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${sig}`;
|
||||
|
||||
const requestHeaders = { ...headers, Authorization: auth };
|
||||
delete requestHeaders.host;
|
||||
|
||||
const url = canonicalQueryString
|
||||
? `https://${host}${path}?${canonicalQueryString}`
|
||||
: `https://${host}${path}`;
|
||||
|
||||
return { url, headers: requestHeaders };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a presigned URL (signature embedded in query string, no Authorization header).
|
||||
* The payload is marked UNSIGNED-PAYLOAD so no body hash is needed at signing time.
|
||||
*/
|
||||
export function buildPresignedUrl({ method, host, path, expiresIn, config, date }) {
|
||||
const { accessKeyId, secretAccessKey, region } = config;
|
||||
const service = 's3';
|
||||
|
||||
const ts = amzDate(date);
|
||||
const ds = dateStamp(date);
|
||||
const scope = `${ds}/${region}/${service}/aws4_request`;
|
||||
|
||||
const query = {
|
||||
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
|
||||
'X-Amz-Credential': `${accessKeyId}/${scope}`,
|
||||
'X-Amz-Date': ts,
|
||||
'X-Amz-Expires': String(expiresIn),
|
||||
'X-Amz-SignedHeaders': 'host',
|
||||
};
|
||||
|
||||
const canonicalQueryString = Object.keys(query)
|
||||
.sort()
|
||||
.map(k => `${encodeS3(k)}=${encodeS3(query[k])}`)
|
||||
.join('&');
|
||||
|
||||
const canonicalRequest = [method, encodePath(path), canonicalQueryString, `host:${host}\n`, 'host', 'UNSIGNED-PAYLOAD'].join('\n');
|
||||
const stringToSign = ['AWS4-HMAC-SHA256', ts, scope, sha256hex(canonicalRequest)].join('\n');
|
||||
const sig = hmac(signingKey(secretAccessKey, ds, region, service), stringToSign, 'hex');
|
||||
|
||||
return `https://${host}${path}?${canonicalQueryString}&X-Amz-Signature=${sig}`;
|
||||
}
|
||||
|
||||
// ─── XML helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function xmlFirst(xml, tag) {
|
||||
const m = xml.match(new RegExp(`<${tag}[^>]*>(.*?)</${tag}>`, 's'));
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
export function xmlAll(xml, tag) {
|
||||
const re = new RegExp(`<${tag}[^>]*>(.*?)</${tag}>`, 'gs');
|
||||
const results = [];
|
||||
let m;
|
||||
while ((m = re.exec(xml)) !== null) results.push(m[1]);
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Body normalizer ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function toBuffer(body) {
|
||||
if (Buffer.isBuffer(body)) return body;
|
||||
if (body instanceof Uint8Array) return Buffer.from(body);
|
||||
if (typeof body === 'string') return Buffer.from(body, 'utf8');
|
||||
if (body instanceof Blob) return Buffer.from(await body.arrayBuffer());
|
||||
return Buffer.from(body);
|
||||
}
|
||||
|
||||
// ─── Security helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Strip HTTP header injection characters (\r, \n, \0) from a header value.
|
||||
*/
|
||||
export function sanitizeHeaderValue(value) {
|
||||
return String(value).replace(/[\r\n\0]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape XML special characters to prevent injection into the DeleteObjects payload.
|
||||
*/
|
||||
export function escapeXml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ─── Metadata helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
export function metaToHeaders(metadata) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(metadata).map(([k, v]) => [
|
||||
`x-amz-meta-${sanitizeHeaderValue(k).toLowerCase()}`,
|
||||
sanitizeHeaderValue(v),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
export function headersToMeta(headers) {
|
||||
return Object.fromEntries(
|
||||
[...headers.entries()]
|
||||
.filter(([k]) => k.startsWith('x-amz-meta-'))
|
||||
.map(([k, v]) => [k.replace('x-amz-meta-', ''), v])
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Storage API runtime configuration.
|
||||
* Holds injected prefix/policy resolvers — same pattern as core/api/runtime.js.
|
||||
* Imported by both api.js (reads) and index.js (exports configureStorageApi).
|
||||
*/
|
||||
|
||||
let _getPublicPrefixes = () => [];
|
||||
let _getAccessPolicies = () => [];
|
||||
|
||||
export function configureStorageApi({ getPublicPrefixes, getAccessPolicies }) {
|
||||
_getPublicPrefixes = getPublicPrefixes;
|
||||
_getAccessPolicies = getAccessPolicies;
|
||||
}
|
||||
|
||||
export function getStoragePublicPrefixes() {
|
||||
return _getPublicPrefixes();
|
||||
}
|
||||
|
||||
export function getStorageAccessPolicies() {
|
||||
return _getAccessPolicies();
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* Admin Server Actions
|
||||
*
|
||||
* These are exported separately from admin/index.js to avoid bundling
|
||||
* Exported separately from admin/index.js to avoid bundling
|
||||
* server-side code (which includes database imports) into client components.
|
||||
*
|
||||
* Usage:
|
||||
* import { getDashboardStats, getModuleDashboardStats } from '@zen/core/admin/actions';
|
||||
* Usage: import { getDashboardStats } from '@zen/core/admin/actions';
|
||||
*/
|
||||
|
||||
export { getDashboardStats } from './actions/statsActions.js';
|
||||
export { getAllModuleDashboardStats as getModuleDashboardStats } from '@zen/core/modules/actions';
|
||||
|
||||
@@ -15,20 +15,12 @@
|
||||
* Icons are passed as string names and resolved on the client.
|
||||
*/
|
||||
|
||||
// Import from the main package to use the same registry as discovery
|
||||
import { moduleSystem } from '@zen/core';
|
||||
const { getAllAdminNavigation } = moduleSystem;
|
||||
|
||||
/**
|
||||
* Build complete navigation sections including modules
|
||||
* This should ONLY be called on the server (in page.js)
|
||||
* Build complete navigation sections
|
||||
* @param {string} pathname - Current pathname
|
||||
* @param {Object} enabledModules - Object with module names as keys (for compatibility)
|
||||
* @returns {Array} Complete navigation sections (serializable, icons as strings)
|
||||
*/
|
||||
export function buildNavigationSections(pathname, enabledModules = null) {
|
||||
// Core navigation sections (always available)
|
||||
// Use icon NAMES (strings) for serialization across server/client boundary
|
||||
export function buildNavigationSections(pathname) {
|
||||
const coreNavigation = [
|
||||
{
|
||||
id: 'Dashboard',
|
||||
@@ -45,10 +37,6 @@ export function buildNavigationSections(pathname, enabledModules = null) {
|
||||
}
|
||||
];
|
||||
|
||||
// Get module navigation from registry (only works on server)
|
||||
const moduleNavigation = getAllAdminNavigation(pathname);
|
||||
|
||||
// System navigation (always at the end)
|
||||
const systemNavigation = [
|
||||
{
|
||||
id: 'users',
|
||||
@@ -65,5 +53,5 @@ export function buildNavigationSections(pathname, enabledModules = null) {
|
||||
}
|
||||
];
|
||||
|
||||
return [...coreNavigation, ...moduleNavigation, ...systemNavigation];
|
||||
return [...coreNavigation, ...systemNavigation];
|
||||
}
|
||||
|
||||
+10
-69
@@ -1,134 +1,75 @@
|
||||
/**
|
||||
* Admin Page - Server Component Wrapper for Next.js App Router
|
||||
*
|
||||
* This is a complete server component that handles all admin routes.
|
||||
* Users can simply re-export this in their app/admin/[...admin]/page.js:
|
||||
*
|
||||
* ```javascript
|
||||
* Re-export this in your app/admin/[...admin]/page.js:
|
||||
* export { default } from '@zen/core/admin/page';
|
||||
* ```
|
||||
*
|
||||
* This eliminates the need to manually import and pass all actions and props.
|
||||
*/
|
||||
|
||||
import { AdminPagesLayout, AdminPagesClient } from '@zen/core/admin/pages';
|
||||
import { protectAdmin } from '@zen/core/admin';
|
||||
import { buildNavigationSections } from '@zen/core/admin/navigation';
|
||||
import { getDashboardStats, getModuleDashboardStats } from '@zen/core/admin/actions';
|
||||
import { getDashboardStats } from '@zen/core/admin/actions';
|
||||
import { logoutAction } from '@zen/core/auth/actions';
|
||||
import { getAppName, getModulesConfig, getAppConfig, moduleSystem } from '@zen/core';
|
||||
import { getAppName } from '@zen/core';
|
||||
|
||||
const { getAdminPage } = moduleSystem;
|
||||
|
||||
/**
|
||||
* Parse admin route params and build the module path
|
||||
* Handles nested paths like /admin/invoice/clients/edit/123
|
||||
*
|
||||
* @param {Object} params - Next.js route params
|
||||
* @returns {Object} Parsed info with path, action, and id
|
||||
*/
|
||||
function parseAdminRoute(params) {
|
||||
const parts = params?.admin || [];
|
||||
|
||||
if (parts.length === 0) {
|
||||
return { path: '/admin/dashboard', action: null, id: null, isCorePage: true };
|
||||
return { path: '/admin/dashboard', action: null, id: null };
|
||||
}
|
||||
|
||||
// Check for core pages first
|
||||
const corePages = ['dashboard', 'users', 'profile'];
|
||||
if (corePages.includes(parts[0])) {
|
||||
// Users: support /admin/users/edit/:id
|
||||
if (parts[0] === 'users' && parts[1] === 'edit' && parts[2]) {
|
||||
return { path: '/admin/users', action: 'edit', id: parts[2], isCorePage: true };
|
||||
return { path: '/admin/users', action: 'edit', id: parts[2] };
|
||||
}
|
||||
return { path: `/admin/${parts[0]}`, action: null, id: null, isCorePage: true };
|
||||
return { path: `/admin/${parts[0]}`, action: null, id: null };
|
||||
}
|
||||
|
||||
// Build module path
|
||||
// Look for 'new', 'create', or 'edit' to determine action
|
||||
const actionKeywords = ['new', 'create', 'edit'];
|
||||
let pathParts = [];
|
||||
let action = null;
|
||||
let id = null;
|
||||
const actionKeywords = ['new', 'create', 'edit'];
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
|
||||
if (actionKeywords.includes(part)) {
|
||||
action = part === 'create' ? 'new' : part;
|
||||
// If it's 'edit', the next part is the ID
|
||||
if (action === 'edit' && i + 1 < parts.length) {
|
||||
id = parts[i + 1];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
pathParts.push(part);
|
||||
}
|
||||
|
||||
// Build the full path
|
||||
let fullPath = '/admin/' + pathParts.join('/');
|
||||
if (action) {
|
||||
fullPath += '/' + action;
|
||||
}
|
||||
|
||||
return { path: fullPath, action, id, isCorePage: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is a module page
|
||||
* @param {string} fullPath - Full admin path
|
||||
* @returns {Object|null} Module info if it's a module page, null otherwise
|
||||
*/
|
||||
function getModulePageInfo(fullPath) {
|
||||
const modulePage = getAdminPage(fullPath);
|
||||
if (modulePage) {
|
||||
return {
|
||||
module: modulePage.module,
|
||||
path: fullPath
|
||||
};
|
||||
}
|
||||
return null;
|
||||
return { path: '/admin/' + pathParts.join('/') + (action ? '/' + action : ''), action, id };
|
||||
}
|
||||
|
||||
export default async function AdminPage({ params }) {
|
||||
const resolvedParams = await params;
|
||||
const session = await protectAdmin();
|
||||
const appName = getAppName();
|
||||
const enabledModules = getModulesConfig();
|
||||
const config = getAppConfig();
|
||||
|
||||
const statsResult = await getDashboardStats();
|
||||
const dashboardStats = statsResult.success ? statsResult.stats : null;
|
||||
|
||||
// Fetch module dashboard stats for widgets
|
||||
const moduleStats = await getModuleDashboardStats();
|
||||
|
||||
// Build navigation on server where module registry is available
|
||||
const navigationSections = buildNavigationSections('/', enabledModules);
|
||||
|
||||
// Parse route and build path
|
||||
const { path, action, id, isCorePage } = parseAdminRoute(resolvedParams);
|
||||
|
||||
// Check if this is a module page (just check existence, don't load)
|
||||
const modulePageInfo = isCorePage ? null : getModulePageInfo(path);
|
||||
const navigationSections = buildNavigationSections('/');
|
||||
const { path, action, id } = parseAdminRoute(resolvedParams);
|
||||
|
||||
return (
|
||||
<AdminPagesLayout
|
||||
user={session.user}
|
||||
onLogout={logoutAction}
|
||||
appName={appName}
|
||||
enabledModules={enabledModules}
|
||||
navigationSections={navigationSections}
|
||||
>
|
||||
<AdminPagesClient
|
||||
params={resolvedParams}
|
||||
user={session.user}
|
||||
dashboardStats={dashboardStats}
|
||||
moduleStats={moduleStats}
|
||||
modulePageInfo={modulePageInfo}
|
||||
routeInfo={{ path, action, id }}
|
||||
enabledModules={enabledModules}
|
||||
/>
|
||||
</AdminPagesLayout>
|
||||
);
|
||||
|
||||
+1
-10
@@ -23,20 +23,11 @@ export * as stripe from "./core/payments/stripe.js";
|
||||
// Export PDF utilities as namespace
|
||||
export * as pdf from "./core/pdf/index.js";
|
||||
|
||||
// Export module system as namespace
|
||||
export * as moduleSystem from "./core/modules/index.js";
|
||||
|
||||
// NOTE: Toast components are CLIENT ONLY - import from '@zen/core/toast'
|
||||
// Do not export here to avoid mixing client/server boundaries
|
||||
|
||||
// Export modules system as namespace (legacy, includes invoice module)
|
||||
export * as modules from "./modules/index.js";
|
||||
|
||||
// Export public pages (Zen routes)
|
||||
export { PublicPagesLayout, PublicPagesClient } from "./modules/pages.js";
|
||||
|
||||
// Export app configuration utilities
|
||||
export { getAppName, getAppConfig, getSessionCookieName, getModulesConfig, getPublicBaseUrl } from "./shared/lib/appConfig.js";
|
||||
export { getAppName, getAppConfig, getSessionCookieName, getPublicBaseUrl } from "./shared/lib/appConfig.js";
|
||||
|
||||
// Export initialization utilities
|
||||
export { initializeZen, resetZenInitialization } from "./shared/lib/init.js";
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Centralized configuration management for the entire package
|
||||
*/
|
||||
|
||||
import { getAvailableModules } from '../../modules/modules.registry.js';
|
||||
|
||||
/**
|
||||
* Get application name from environment variables
|
||||
* @returns {string} Application name
|
||||
@@ -36,28 +34,6 @@ export function getPublicBaseUrl() {
|
||||
return String(raw).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled modules configuration (server-side only)
|
||||
* This function dynamically reads from modules.registry.js and checks environment variables
|
||||
* Use this on the server and pass the result to client components as props
|
||||
*
|
||||
* To enable a module, set the environment variable: ZEN_MODULE_{NAME}=true
|
||||
* Example: ZEN_MODULE_INVOICE=true
|
||||
*
|
||||
* @returns {Object} Object with module names as keys and boolean values
|
||||
*/
|
||||
export function getModulesConfig() {
|
||||
const modules = {};
|
||||
const availableModules = getAvailableModules();
|
||||
|
||||
for (const moduleName of availableModules) {
|
||||
const envVar = `ZEN_MODULE_${moduleName.toUpperCase()}`;
|
||||
modules[moduleName] = process.env[envVar] === 'true';
|
||||
}
|
||||
|
||||
return modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get application configuration
|
||||
* @returns {Object} Application configuration object
|
||||
@@ -68,10 +44,7 @@ export function getAppConfig() {
|
||||
sessionCookieName: getSessionCookieName(),
|
||||
timezone: process.env.ZEN_TIMEZONE || 'America/Toronto',
|
||||
dateFormat: process.env.ZEN_DATE_FORMAT || 'YYYY-MM-DD',
|
||||
// Currency configuration (for currency module)
|
||||
defaultCurrency: process.env.ZEN_CURRENCY || 'CAD',
|
||||
currencySymbol: process.env.ZEN_CURRENCY_SYMBOL || '$',
|
||||
// Enabled modules
|
||||
modules: getModulesConfig(),
|
||||
};
|
||||
}
|
||||
|
||||
+12
-99
@@ -1,136 +1,49 @@
|
||||
/**
|
||||
* ZEN Initialization
|
||||
* Initialize all ZEN services and modules using dynamic module discovery
|
||||
*/
|
||||
|
||||
import { discoverModules, registerExternalModules, startModuleCronJobs, stopModuleCronJobs } from '../../core/modules/index.js';
|
||||
import { configureRouter, registerFeatureRoutes, clearFeatureRoutes, clearRouterConfig } from '../../core/api/index.js';
|
||||
import { validateSession } from '../../features/auth/lib/session.js';
|
||||
import { routes as authRoutes } from '../../features/auth/api.js';
|
||||
import { step, done, warn, fail } from './logger.js';
|
||||
|
||||
// Use globalThis to persist initialization flag across module reloads
|
||||
const ZEN_INIT_KEY = Symbol.for('__ZEN_INITIALIZED__');
|
||||
|
||||
/**
|
||||
* Initialize ZEN system
|
||||
* Discovers modules dynamically and starts cron jobs
|
||||
*
|
||||
* Recommended: Use instrumentation.js for automatic initialization
|
||||
* Alternative: Call this function manually in your root layout
|
||||
* Wires core feature dependencies into the API router.
|
||||
* This is the composition root — the only place that connects features to core.
|
||||
*
|
||||
* @example
|
||||
* // instrumentation.js (Recommended) — internal modules only
|
||||
* // instrumentation.js
|
||||
* export async function register() {
|
||||
* if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
* const { initializeZen } = await import('@zen/core');
|
||||
* await initializeZen();
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // instrumentation.js — with external modules from zen.config.js
|
||||
* import zenConfig from './zen.config.js';
|
||||
* export async function register() {
|
||||
* if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
* const { initializeZen } = await import('@zen/core');
|
||||
* await initializeZen(zenConfig);
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @param {Object} config - Configuration object
|
||||
* @param {Array} config.modules - External module configs (from zen.config.js)
|
||||
* @param {boolean} config.skipCron - Skip cron job initialization
|
||||
* @param {boolean} config.skipDb - Skip database initialization
|
||||
* @returns {Promise<Object>} Initialization result
|
||||
*/
|
||||
export async function initializeZen(config = {}) {
|
||||
const { modules: externalModules = [], skipCron = false, skipDb = true } = config;
|
||||
|
||||
// Only run on server-side
|
||||
import { configureRouter, registerFeatureRoutes, clearRouterConfig, clearFeatureRoutes } from '../../core/api/index.js';
|
||||
import { validateSession } from '../../features/auth/lib/session.js';
|
||||
import { routes as authRoutes } from '../../features/auth/api.js';
|
||||
import { done, warn } from './logger.js';
|
||||
|
||||
const ZEN_INIT_KEY = Symbol.for('__ZEN_INITIALIZED__');
|
||||
|
||||
export async function initializeZen() {
|
||||
if (typeof window !== 'undefined') {
|
||||
return { skipped: true, reason: 'client-side' };
|
||||
}
|
||||
|
||||
// Prevent multiple initializations using globalThis
|
||||
if (globalThis[ZEN_INIT_KEY]) {
|
||||
warn('ZEN: already initialized, skipping');
|
||||
return { skipped: true, reason: 'already-initialized' };
|
||||
}
|
||||
|
||||
globalThis[ZEN_INIT_KEY] = true;
|
||||
step('ZEN starting...');
|
||||
|
||||
const result = {
|
||||
discovery: null,
|
||||
cron: { started: [], errors: [] }
|
||||
};
|
||||
|
||||
try {
|
||||
// Step 1: Wire core feature dependencies into the API router.
|
||||
// init.js is the composition root — the only place that wires features
|
||||
// into core, keeping core/api/ free of static feature-level imports.
|
||||
configureRouter({ resolveSession: validateSession });
|
||||
registerFeatureRoutes(authRoutes);
|
||||
|
||||
// Step 2: Discover and register internal modules (from modules.registry.js)
|
||||
result.discovery = await discoverModules();
|
||||
|
||||
const enabledCount = result.discovery.enabled?.length || 0;
|
||||
const skippedCount = result.discovery.skipped?.length || 0;
|
||||
|
||||
if (enabledCount > 0) {
|
||||
done(`ZEN: ${enabledCount} module(s): ${result.discovery.enabled.join(', ')}`);
|
||||
}
|
||||
if (skippedCount > 0) {
|
||||
warn(`ZEN: skipped ${skippedCount} module(s): ${result.discovery.skipped.join(', ')}`);
|
||||
}
|
||||
|
||||
// Step 3: Register external modules from zen.config.js (if any)
|
||||
if (externalModules.length > 0) {
|
||||
result.external = await registerExternalModules(externalModules);
|
||||
|
||||
if (result.external.registered.length > 0) {
|
||||
done(`ZEN: ${result.external.registered.length} external module(s): ${result.external.registered.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Start cron jobs for all enabled modules (internal + external)
|
||||
if (!skipCron) {
|
||||
result.cron = await startModuleCronJobs();
|
||||
|
||||
if (result.cron.started.length > 0) {
|
||||
done(`ZEN: ${result.cron.started.length} cron job(s): ${result.cron.started.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
done('ZEN: ready');
|
||||
|
||||
} catch (error) {
|
||||
fail(`ZEN: init failed: ${error.message}`);
|
||||
result.error = error.message;
|
||||
return {};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset initialization flag (useful for testing or manual reinitialization)
|
||||
* @returns {void}
|
||||
*/
|
||||
export function resetZenInitialization() {
|
||||
globalThis[ZEN_INIT_KEY] = false;
|
||||
|
||||
// Stop all cron jobs using the module system
|
||||
try {
|
||||
stopModuleCronJobs();
|
||||
} catch (e) {
|
||||
// Cron system not available
|
||||
}
|
||||
|
||||
// Clear router config and feature routes so they are re-registered on next initializeZen()
|
||||
clearRouterConfig();
|
||||
clearFeatureRoutes();
|
||||
|
||||
warn('ZEN: initialization reset');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user