Initial commit

This commit is contained in:
twotalesanimation
2026-05-19 22:20:29 +02:00
commit 0fbe856dce
173 changed files with 38316 additions and 0 deletions
+221
View File
@@ -0,0 +1,221 @@
/**
* S3-compatible storage abstraction.
*
* STORAGE_PROVIDER env controls which backend is used:
* local local filesystem (dev only, no CDN)
* uploadthing UploadThing managed (default for quick start)
* s3 AWS S3
* r2 Cloudflare R2
* b2 Backblaze B2 (S3-compatible endpoint)
* minio Self-hosted MinIO
*/
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import fs from "fs";
import path from "path";
import { randomUUID } from "crypto";
export type StorageProvider = "local" | "uploadthing" | "s3" | "r2" | "b2" | "minio";
function getProvider(): StorageProvider {
return (process.env.STORAGE_PROVIDER as StorageProvider) ?? "uploadthing";
}
// ── S3 Client factory ────────────────────────────────────────────────────────
function buildS3Client(): S3Client {
const provider = getProvider();
if (provider === "r2") {
return new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID!}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
}
if (provider === "b2") {
return new S3Client({
region: "auto",
endpoint: process.env.B2_ENDPOINT!,
credentials: {
accessKeyId: process.env.B2_APPLICATION_KEY_ID!,
secretAccessKey: process.env.B2_APPLICATION_KEY!,
},
});
}
if (provider === "minio") {
return new S3Client({
region: "us-east-1",
endpoint: process.env.MINIO_ENDPOINT ?? "http://localhost:9000",
forcePathStyle: true,
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY!,
secretAccessKey: process.env.MINIO_SECRET_KEY!,
},
});
}
// Default: AWS S3
return new S3Client({
region: process.env.AWS_REGION ?? "us-east-1",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
}
function getBucketName(): string {
const provider = getProvider();
const map: Record<string, string | undefined> = {
s3: process.env.AWS_BUCKET_NAME,
r2: process.env.R2_BUCKET_NAME,
b2: process.env.B2_BUCKET_NAME,
minio: process.env.MINIO_BUCKET_NAME,
};
return map[provider] ?? "vfx-review";
}
// ── Public API ───────────────────────────────────────────────────────────────
export interface UploadResult {
url: string;
key: string;
provider: StorageProvider;
}
/**
* Upload a file buffer to the configured storage backend.
* Returns the public URL and storage key.
*/
export async function uploadFile(
buffer: Buffer,
fileName: string,
contentType: string,
folder: string = "uploads"
): Promise<UploadResult> {
const provider = getProvider();
const key = `${folder}/${randomUUID()}-${fileName}`;
if (provider === "local") {
return uploadLocal(buffer, key);
}
// All S3-compatible providers share the same logic
const client = buildS3Client();
const bucket = getBucketName();
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: buffer,
ContentType: contentType,
})
);
const url = getPublicUrl(key, provider);
return { url, key, provider };
}
/**
* Generate a presigned URL for direct browser-to-storage upload.
* Expires in 1 hour by default.
*/
export async function generatePresignedUploadUrl(
key: string,
contentType: string,
expiresIn: number = 3600
): Promise<string> {
const provider = getProvider();
if (provider === "local" || provider === "uploadthing") {
// Not applicable for local / uploadthing (use their own flow)
throw new Error(`Presigned URLs not supported for provider: ${provider}`);
}
const client = buildS3Client();
const command = new PutObjectCommand({
Bucket: getBucketName(),
Key: key,
ContentType: contentType,
});
return getSignedUrl(client, command, { expiresIn });
}
/**
* Generate a presigned download URL for a private object.
*/
export async function generatePresignedDownloadUrl(
key: string,
expiresIn: number = 3600
): Promise<string> {
const provider = getProvider();
if (provider === "local") {
return `/api/files/${encodeURIComponent(key)}`;
}
const client = buildS3Client();
const command = new GetObjectCommand({
Bucket: getBucketName(),
Key: key,
});
return getSignedUrl(client, command, { expiresIn });
}
/**
* Delete a file from storage by key.
*/
export async function deleteFile(key: string): Promise<void> {
const provider = getProvider();
if (provider === "local") {
const filePath = path.join(process.env.LOCAL_UPLOAD_DIR ?? "./uploads", key);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
return;
}
const client = buildS3Client();
await client.send(
new DeleteObjectCommand({ Bucket: getBucketName(), Key: key })
);
}
// ── Private helpers ──────────────────────────────────────────────────────────
function uploadLocal(buffer: Buffer, key: string): UploadResult {
const uploadDir = process.env.LOCAL_UPLOAD_DIR ?? "./uploads";
const filePath = path.join(uploadDir, key);
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, buffer);
const url = `/api/files/${encodeURIComponent(key)}`;
return { url, key, provider: "local" };
}
function getPublicUrl(key: string, provider: StorageProvider): string {
if (provider === "r2" && process.env.R2_PUBLIC_URL) {
return `${process.env.R2_PUBLIC_URL}/${key}`;
}
if (provider === "s3") {
return `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION ?? "us-east-1"}.amazonaws.com/${key}`;
}
if (provider === "minio") {
return `${process.env.MINIO_ENDPOINT}/${getBucketName()}/${key}`;
}
// Fallback
return `/${key}`;
}