mike/backend/src/lib/storage.ts
2026-05-08 20:45:16 +08:00

187 lines
5.6 KiB
TypeScript

/**
* Cloudflare R2 storage utilities for Mike document management.
* R2 is S3-compatible — uses @aws-sdk/client-s3.
*
* Required env vars:
* R2_ENDPOINT_URL — https://<account-id>.r2.cloudflarestorage.com
* R2_ACCESS_KEY_ID — R2 API token (Access Key ID)
* R2_SECRET_ACCESS_KEY — R2 API token (Secret Access Key)
* R2_BUCKET_NAME — bucket name (default: "mike")
*/
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl as awsGetSignedUrl } from "@aws-sdk/s3-request-presigner";
function getClient(): S3Client {
return new S3Client({
region: "auto",
endpoint: process.env.R2_ENDPOINT_URL!,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
}
const BUCKET = process.env.R2_BUCKET_NAME ?? "mike";
export const storageEnabled = Boolean(
process.env.R2_ENDPOINT_URL &&
process.env.R2_ACCESS_KEY_ID &&
process.env.R2_SECRET_ACCESS_KEY,
);
// ---------------------------------------------------------------------------
// Upload
// ---------------------------------------------------------------------------
export async function uploadFile(
key: string,
content: ArrayBuffer,
contentType: string,
): Promise<void> {
const client = getClient();
await client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: Buffer.from(content),
ContentType: contentType,
}),
);
}
// ---------------------------------------------------------------------------
// Download
// ---------------------------------------------------------------------------
export async function downloadFile(key: string): Promise<ArrayBuffer | null> {
if (!storageEnabled) return null;
try {
const client = getClient();
const response = await client.send(
new GetObjectCommand({ Bucket: BUCKET, Key: key }),
);
if (!response.Body) return null;
const bytes = await response.Body.transformToByteArray();
return bytes.buffer as ArrayBuffer;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Delete
// ---------------------------------------------------------------------------
export async function deleteFile(key: string): Promise<void> {
if (!storageEnabled) return;
const client = getClient();
await client.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key }));
}
// ---------------------------------------------------------------------------
// Signed URL (pre-signed for temporary direct access)
// ---------------------------------------------------------------------------
export async function getSignedUrl(
key: string,
expiresIn = 3600,
downloadFilename?: string,
): Promise<string | null> {
if (!storageEnabled) return null;
try {
const client = getClient();
// Override the response Content-Disposition so the browser uses this
// filename on download, instead of the last path segment of the R2 key
// (which includes the document UUID). The `download` attribute on <a>
// is ignored for cross-origin URLs, so we have to set it server-side.
const responseContentDisposition = downloadFilename
? buildContentDisposition("attachment", downloadFilename)
: undefined;
const command = new GetObjectCommand({
Bucket: BUCKET,
Key: key,
ResponseContentDisposition: responseContentDisposition,
});
return await awsGetSignedUrl(client, command, { expiresIn });
} catch {
return null;
}
}
export function normalizeDownloadFilename(name: string): string {
const trimmed = name.trim();
const base = trimmed || "download";
return base.replace(/[\x00-\x1F\x7F]/g, "_").replace(/[\\/]/g, "_");
}
export function sanitizeDispositionFilename(name: string): string {
return normalizeDownloadFilename(name)
.replace(/["\\]/g, "_")
.replace(/[^\x20-\x7E]/g, "_");
}
export function encodeRFC5987(str: string): string {
return encodeURIComponent(str).replace(
/['()*]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
}
export function buildContentDisposition(
kind: "inline" | "attachment",
filename: string,
): string {
const normalized = normalizeDownloadFilename(filename);
return `${kind}; filename="${sanitizeDispositionFilename(normalized)}"; filename*=UTF-8''${encodeRFC5987(normalized)}`;
}
// ---------------------------------------------------------------------------
// Storage key helpers
// ---------------------------------------------------------------------------
export function storageKey(
userId: string,
docId: string,
filename: string,
): string {
return `documents/${userId}/${docId}/source${storageExtension(filename, ".bin")}`;
}
export function pdfStorageKey(
userId: string,
docId: string,
stem: string,
): string {
return `documents/${userId}/${docId}/${stem}.pdf`;
}
export function generatedDocKey(
userId: string,
docId: string,
filename: string,
): string {
return `generated/${userId}/${docId}/generated${storageExtension(filename, ".docx")}`;
}
export function versionStorageKey(
userId: string,
docId: string,
versionSlug: string,
filename: string,
): string {
return `documents/${userId}/${docId}/versions/${versionSlug}${storageExtension(filename, ".bin")}`;
}
function storageExtension(filename: string, fallback: string): string {
const lastDot = filename.lastIndexOf(".");
if (lastDot < 0) return fallback;
const ext = filename.slice(lastDot).toLowerCase();
return /^\.[a-z0-9]{1,16}$/.test(ext) ? ext : fallback;
}