// Preconfigured storage helpers for Manus WebDev templates // Uploads via Forge Server presigned URL to S3 (PUT direct). // Downloads return /manus-storage/{key} paths served via 307 redirect. import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, relative, resolve } from "node:path"; import { ENV } from "./_core/env"; function getForgeConfig() { const forgeUrl = ENV.forgeApiUrl; const forgeKey = ENV.forgeApiKey; if (!forgeUrl || !forgeKey) { throw new Error( "Storage config missing: set BUILT_IN_FORGE_API_URL and BUILT_IN_FORGE_API_KEY", ); } return { forgeUrl: forgeUrl.replace(/\/+$/, ""), forgeKey }; } function normalizeKey(relKey: string): string { return relKey.replace(/^\/+/, ""); } function appendHashSuffix(relKey: string): string { const hash = crypto.randomUUID().replace(/-/g, "").slice(0, 8); const lastDot = relKey.lastIndexOf("."); if (lastDot === -1) return `${relKey}_${hash}`; return `${relKey.slice(0, lastDot)}_${hash}${relKey.slice(lastDot)}`; } export async function storagePut( relKey: string, data: Buffer | Uint8Array | string, contentType = "application/octet-stream", ): Promise<{ key: string; url: string }> { const { forgeUrl, forgeKey } = getForgeConfig(); const key = appendHashSuffix(normalizeKey(relKey)); // 1. Get presigned PUT URL from Forge const presignUrl = new URL("v1/storage/presign/put", forgeUrl + "/"); presignUrl.searchParams.set("path", key); const presignResp = await fetch(presignUrl, { headers: { Authorization: `Bearer ${forgeKey}` }, }); if (!presignResp.ok) { const msg = await presignResp.text().catch(() => presignResp.statusText); throw new Error(`Storage presign failed (${presignResp.status}): ${msg}`); } const { url: s3Url } = (await presignResp.json()) as { url: string }; if (!s3Url) throw new Error("Forge returned empty presign URL"); // 2. PUT file directly to S3 const blob = typeof data === "string" ? new Blob([data], { type: contentType }) : new Blob([data as any], { type: contentType }); const uploadResp = await fetch(s3Url, { method: "PUT", headers: { "Content-Type": contentType }, body: blob, }); if (!uploadResp.ok) { throw new Error(`Storage upload to S3 failed (${uploadResp.status})`); } return { key, url: `/manus-storage/${key}` }; } export async function storageGet(relKey: string): Promise<{ key: string; url: string }> { const key = normalizeKey(relKey); return { key, url: `/manus-storage/${key}` }; } export async function storageGetSignedUrl(relKey: string): Promise { const { forgeUrl, forgeKey } = getForgeConfig(); const key = normalizeKey(relKey); const getUrl = new URL("v1/storage/presign/get", forgeUrl + "/"); getUrl.searchParams.set("path", key); const resp = await fetch(getUrl, { headers: { Authorization: `Bearer ${forgeKey}` }, }); if (!resp.ok) { const msg = await resp.text().catch(() => resp.statusText); throw new Error(`Storage signed URL failed (${resp.status}): ${msg}`); } const { url } = (await resp.json()) as { url: string }; return url; } /** * Télécharge un objet uniquement depuis le serveur. Cette primitive est utilisée * par la consultation de bulletins afin de ne jamais communiquer de lien de * stockage ni de document chiffré au navigateur. */ export async function storageGetBuffer(relKey: string): Promise { const signedUrl = await storageGetSignedUrl(relKey); const response = await fetch(signedUrl, { redirect: "follow" }); if (!response.ok) throw new Error(`Storage download failed (${response.status})`); return Buffer.from(await response.arrayBuffer()); } /** * Répertoire persistant optionnel réservé aux PDF de paie déjà chiffrés. * Il n'est utilisé que sur les environnements sans configuration Forge, où le * volume Docker hôte survit aux reconstructions de l'image applicative. */ function getPayrollStoragePath(relKey: string): string | null { const configuredDirectory = process.env.PAYROLL_STORAGE_DIR?.trim(); if (!configuredDirectory) return null; const root = resolve(configuredDirectory); const destination = resolve(root, normalizeKey(relKey)); const pathFromRoot = relative(root, destination); if (pathFromRoot.startsWith("..") || pathFromRoot === "") { throw new Error("Invalid payroll storage key"); } return destination; } export async function payrollStoragePut( relKey: string, data: Buffer | Uint8Array | string, contentType = "application/octet-stream", ): Promise<{ key: string; url: string }> { const localKey = appendHashSuffix(normalizeKey(relKey)); const localDestination = getPayrollStoragePath(localKey); if (!localDestination) return storagePut(relKey, data, contentType); await mkdir(dirname(localDestination), { recursive: true, mode: 0o700 }); await writeFile(localDestination, data, { mode: 0o600, flag: "wx" }); // Les appels paie n'exposent jamais cette URL : la lecture passe exclusivement // par la route administrateur qui déchiffre côté serveur. return { key: localKey, url: `/manus-storage/${localKey}` }; } export async function payrollStorageGetBuffer(relKey: string): Promise { const localDestination = getPayrollStoragePath(relKey); if (!localDestination) return storageGetBuffer(relKey); return readFile(localDestination); }