Initial commit
This commit is contained in:
+221
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user