Files
twotalesanimation 4c8b57391d
Deploy / deploy (push) Successful in 2m52s
Image url update
2026-07-29 01:33:40 +02:00

434 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
ListObjectsV2Command,
HeadObjectCommand,
} 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";
import { db } from "@/lib/db";
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";
}
/**
* Sanitize a user-supplied filename before it becomes (part of) an S3 object key.
*
* Reserved/special URL characters in a key — especially `#` (fragment) and
* `?`/`%` (query / percent-encoding) — break S3 presigned-URL signatures:
* the signature is computed over the raw key, but browsers/HTTP clients
* strip or re-encode those characters differently when the URL is actually
* fetched, producing a signature mismatch. That surfaces to users as a 400
* when the image is later loaded. Common real-world filenames like
* "Take #3.png" or "Ref #12 (final).jpg" are common enough in VFX pipelines
* to hit this regularly, so every key built from a filename must go through
* this sanitizer.
*/
export function sanitizeFileName(fileName: string): string {
const ext = path.extname(fileName);
const base = path.basename(fileName, ext);
const safeBase = base
.normalize("NFKD")
.replace(/[^\w.-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
const safeExt = ext.replace(/[^\w.-]+/g, "");
return `${safeBase || "file"}${safeExt}`;
}
// ── 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()}-${sanitizeFileName(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}`;
}
// ── Hetzner Object Storage (dedicated high-res bucket) ───────────────────────
async function getHetznerConfig(): Promise<{
endpoint: string;
accessKey: string;
secretKey: string;
bucket: string;
}> {
const rows = await db.systemConfig.findMany({
where: { key: { in: ["hetzner_endpoint", "hetzner_access_key", "hetzner_secret_key", "hetzner_bucket_name"] } },
});
const map = Object.fromEntries(rows.map((r) => [r.key, r.value]));
return {
endpoint: map["hetzner_endpoint"] ?? process.env.HETZNER_ENDPOINT ?? "",
accessKey: map["hetzner_access_key"] ?? process.env.HETZNER_ACCESS_KEY ?? "",
secretKey: map["hetzner_secret_key"] ?? process.env.HETZNER_SECRET_KEY ?? "",
bucket: map["hetzner_bucket_name"] ?? process.env.HETZNER_BUCKET_NAME ?? "vfx-review",
};
}
async function buildHetznerClient(): Promise<{ client: S3Client; bucket: string }> {
const cfg = await getHetznerConfig();
const client = new S3Client({
region: "auto",
endpoint: cfg.endpoint,
credentials: {
accessKeyId: cfg.accessKey,
secretAccessKey: cfg.secretKey,
},
});
return { client, bucket: cfg.bucket };
}
/**
* Upload a buffer directly to the Hetzner bucket (always uses Hetzner credentials,
* regardless of the global STORAGE_PROVIDER setting).
*/
export async function uploadToHetzner(
buffer: Buffer,
fileName: string,
contentType: string,
folder: string = "highres"
): Promise<{ key: string }> {
const key = `${folder}/${randomUUID()}-${sanitizeFileName(fileName)}`;
const { client, bucket } = await buildHetznerClient();
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: buffer,
ContentType: contentType,
})
);
return { key };
}
/**
* Generate a presigned PUT URL for direct browser-to-Hetzner uploads.
* The caller must use the returned `key` to commit the upload via the API.
* Expires in 1 hour by default.
*/
export async function generateHetznerPresignedUploadUrl(
key: string,
contentType: string,
expiresIn: number = 3600
): Promise<string> {
const { client, bucket } = await buildHetznerClient();
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: contentType,
});
return getSignedUrl(client, command, { expiresIn });
}
/**
* In-process cache for Hetzner key existence checks.
* Positive hits (key exists) are cached indefinitely for the process lifetime
* since uploaded files are immutable UUID-named objects that never disappear.
* Negative hits are not cached so newly-migrated files are picked up immediately.
*/
const hetznerExistsCache = new Map<string, true>();
/**
* Returns true if the given key exists in the Hetzner bucket.
* Results are cached in process memory after the first check.
*/
export async function hetznerKeyExists(key: string): Promise<boolean> {
if (hetznerExistsCache.has(key)) return true;
try {
const { client, bucket } = await buildHetznerClient();
await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
hetznerExistsCache.set(key, true);
return true;
} catch {
return false;
}
}
/**
* List all object keys currently stored in the Hetzner bucket.
* Paginates automatically. Used by the migration tool.
*/
export async function listHetznerKeys(): Promise<Set<string>> {
const { client, bucket } = await buildHetznerClient();
const keys = new Set<string>();
let continuationToken: string | undefined;
do {
const resp = await client.send(
new ListObjectsV2Command({ Bucket: bucket, ContinuationToken: continuationToken })
);
for (const obj of resp.Contents ?? []) {
if (obj.Key) keys.add(obj.Key);
}
continuationToken = resp.NextContinuationToken;
} while (continuationToken);
return keys;
}
/**
* Upload a stream directly to Hetzner under an exact key (no UUID prefix).
* Used by the migration tool to preserve existing file paths.
*/
export async function uploadToHetznerWithKey(
body: NodeJS.ReadableStream,
key: string,
contentType: string,
contentLength: number
): Promise<void> {
const { client, bucket } = await buildHetznerClient();
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body as unknown as Blob,
ContentType: contentType,
ContentLength: contentLength,
})
);
}
/**
* Generate a presigned URL for streaming a Hetzner object directly in the browser.
* No Content-Disposition header — suitable for <video src="..."> playback.
*/
export async function generateHetznerStreamUrl(
key: string,
expiresIn: number = 3600
): Promise<string> {
const { client, bucket } = await buildHetznerClient();
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
return getSignedUrl(client, command, { expiresIn });
}
/**
* Generate a presigned download URL from Hetzner that forces a file download
* with the original filename preserved via Content-Disposition.
*/
export async function generateHetznerDownloadUrl(
key: string,
originalFilename: string,
expiresIn: number = 3600
): Promise<string> {
const { client, bucket } = await buildHetznerClient();
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
ResponseContentDisposition: `attachment; filename="${originalFilename}"`,
});
return getSignedUrl(client, command, { expiresIn });
}
/**
* Delete a file from the Hetzner bucket by key.
*/
export async function deleteFromHetzner(key: string): Promise<void> {
const { client, bucket } = await buildHetznerClient();
await client.send(
new DeleteObjectCommand({ Bucket: bucket, Key: key })
);
}