All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 2m9s
152 lines
5.3 KiB
TypeScript
152 lines
5.3 KiB
TypeScript
// 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<string> {
|
|
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<Buffer> {
|
|
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<Buffer> {
|
|
const localDestination = getPayrollStoragePath(relKey);
|
|
if (!localDestination) return storageGetBuffer(relKey);
|
|
return readFile(localDestination);
|
|
}
|