mirror of
https://github.com/willchen96/mike.git
synced 2026-07-12 22:42:11 +02:00
Add local repo contents
This commit is contained in:
parent
65739ef1ce
commit
d9690965b5
176 changed files with 68998 additions and 0 deletions
185
backend/src/lib/storage.ts
Normal file
185
backend/src/lib/storage.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
/**
|
||||
* 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, "_");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue