mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
- Added support for self-hosted S3 video uploads with new environment variables: OPENFRAME_ENABLE_S3_VIDEO_UPLOADS and OPENFRAME_MAX_VIDEO_UPLOAD_BYTES. - Updated .env.example and .env.docker.example to reflect new configuration options. - Enhanced Content Security Policy to include origins for S3-compatible storage. - Updated dependencies for AWS SDK to support new features. - Refactored upload logic to accommodate both Bunny and S3 upload providers. - Updated documentation to clarify the usage of direct uploads and S3 configurations. - Closes #11
400 lines
11 KiB
TypeScript
400 lines
11 KiB
TypeScript
import {
|
|
CreateBucketCommand,
|
|
DeleteObjectCommand,
|
|
GetObjectCommand,
|
|
GetBucketCorsCommand,
|
|
HeadBucketCommand,
|
|
HeadObjectCommand,
|
|
PutBucketCorsCommand,
|
|
PutObjectCommand,
|
|
S3Client,
|
|
} from '@aws-sdk/client-s3';
|
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
import { VIDEO_OBJECT_KEY_PREFIX } from '@/lib/video-upload-validation';
|
|
|
|
const IMAGE_OBJECT_KEY_PREFIX = 'images/';
|
|
|
|
const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID;
|
|
const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID;
|
|
const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY;
|
|
const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME ?? '';
|
|
const R2_ENDPOINT = process.env.R2_ENDPOINT;
|
|
const R2_PRESIGN_ENDPOINT = process.env.R2_PRESIGN_ENDPOINT;
|
|
const R2_PUBLIC_BASE_URL = process.env.R2_PUBLIC_BASE_URL;
|
|
|
|
let cachedR2Client: S3Client | null = null;
|
|
let cachedR2PresignClient: S3Client | null = null;
|
|
|
|
function trimTrailingSlashes(value: string): string {
|
|
return value.replace(/\/+$/, '');
|
|
}
|
|
|
|
function requireStorageValue(name: string, value: string | undefined): string {
|
|
if (!value) {
|
|
throw new Error(`Missing ${name} for S3-compatible storage`);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function getR2Endpoint(): string {
|
|
if (R2_ENDPOINT) {
|
|
return trimTrailingSlashes(R2_ENDPOINT);
|
|
}
|
|
|
|
if (!R2_ACCOUNT_ID) {
|
|
throw new Error('Missing R2_ENDPOINT or R2_ACCOUNT_ID for S3-compatible storage');
|
|
}
|
|
|
|
return `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
|
|
}
|
|
|
|
function getR2PresignEndpoint(): string {
|
|
if (R2_PRESIGN_ENDPOINT) {
|
|
return trimTrailingSlashes(R2_PRESIGN_ENDPOINT);
|
|
}
|
|
return getR2Endpoint();
|
|
}
|
|
|
|
function getOrCreateR2Client(): S3Client {
|
|
if (cachedR2Client) {
|
|
return cachedR2Client;
|
|
}
|
|
|
|
cachedR2Client = new S3Client({
|
|
region: 'auto',
|
|
endpoint: getR2Endpoint(),
|
|
forcePathStyle: Boolean(R2_ENDPOINT),
|
|
requestChecksumCalculation: 'WHEN_REQUIRED',
|
|
responseChecksumValidation: 'WHEN_REQUIRED',
|
|
credentials: {
|
|
accessKeyId: requireStorageValue('R2_ACCESS_KEY_ID', R2_ACCESS_KEY_ID),
|
|
secretAccessKey: requireStorageValue('R2_SECRET_ACCESS_KEY', R2_SECRET_ACCESS_KEY),
|
|
},
|
|
});
|
|
|
|
return cachedR2Client;
|
|
}
|
|
|
|
function getOrCreateR2PresignClient(): S3Client {
|
|
if (cachedR2PresignClient) {
|
|
return cachedR2PresignClient;
|
|
}
|
|
|
|
cachedR2PresignClient = new S3Client({
|
|
region: 'auto',
|
|
endpoint: getR2PresignEndpoint(),
|
|
forcePathStyle: Boolean(R2_PRESIGN_ENDPOINT || R2_ENDPOINT),
|
|
requestChecksumCalculation: 'WHEN_REQUIRED',
|
|
responseChecksumValidation: 'WHEN_REQUIRED',
|
|
credentials: {
|
|
accessKeyId: requireStorageValue('R2_ACCESS_KEY_ID', R2_ACCESS_KEY_ID),
|
|
secretAccessKey: requireStorageValue('R2_SECRET_ACCESS_KEY', R2_SECRET_ACCESS_KEY),
|
|
},
|
|
});
|
|
|
|
return cachedR2PresignClient;
|
|
}
|
|
|
|
export const r2Client = new Proxy({} as S3Client, {
|
|
get(_target, prop, receiver) {
|
|
if (prop === 'destroy') {
|
|
return () => {
|
|
if (!cachedR2Client) return;
|
|
cachedR2Client.destroy();
|
|
cachedR2Client = null;
|
|
if (!cachedR2PresignClient) return;
|
|
cachedR2PresignClient.destroy();
|
|
cachedR2PresignClient = null;
|
|
};
|
|
}
|
|
|
|
const client = getOrCreateR2Client();
|
|
const value = Reflect.get(client, prop, receiver);
|
|
return typeof value === 'function' ? value.bind(client) : value;
|
|
},
|
|
});
|
|
|
|
export function getR2PublicObjectUrl(key: string): string {
|
|
const sanitizedKey = key.replace(/^\/+/, '');
|
|
|
|
if (R2_PUBLIC_BASE_URL) {
|
|
return `${trimTrailingSlashes(R2_PUBLIC_BASE_URL)}/${sanitizedKey}`;
|
|
}
|
|
|
|
if (R2_ENDPOINT) {
|
|
return `${trimTrailingSlashes(R2_ENDPOINT)}/${R2_BUCKET_NAME}/${sanitizedKey}`;
|
|
}
|
|
|
|
if (!R2_ACCOUNT_ID) {
|
|
throw new Error('Missing R2_PUBLIC_BASE_URL or R2_ACCOUNT_ID for public object URLs');
|
|
}
|
|
|
|
return `https://${R2_BUCKET_NAME}.${R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${sanitizedKey}`;
|
|
}
|
|
|
|
export async function ensureR2BucketExists(): Promise<void> {
|
|
try {
|
|
await r2Client.send(new HeadBucketCommand({ Bucket: R2_BUCKET_NAME }));
|
|
return;
|
|
} catch (error) {
|
|
const statusCode = (error as { $metadata?: { httpStatusCode?: number } })?.$metadata
|
|
?.httpStatusCode;
|
|
if (statusCode && statusCode !== 404 && statusCode !== 301 && statusCode !== 403) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
await r2Client.send(new CreateBucketCommand({ Bucket: R2_BUCKET_NAME }));
|
|
}
|
|
|
|
export async function uploadAudio(
|
|
buffer: Buffer,
|
|
filename: string,
|
|
contentType: string = 'audio/webm'
|
|
): Promise<string> {
|
|
// Sanitize: strip any path components, use only the basename
|
|
const sanitized = filename.replace(/^.*[\\/]/, '').replace(/\.\.+/g, '');
|
|
if (!sanitized) throw new Error('Invalid filename');
|
|
const key = `voice/${sanitized}`;
|
|
|
|
await r2Client.send(
|
|
new PutObjectCommand({
|
|
Bucket: R2_BUCKET_NAME,
|
|
Key: key,
|
|
Body: buffer,
|
|
ContentType: contentType,
|
|
})
|
|
);
|
|
|
|
return getR2PublicObjectUrl(key);
|
|
}
|
|
|
|
const DEFAULT_PRESIGNED_PUT_TTL_SECONDS = 60 * 60;
|
|
|
|
export function getR2UploadCorsOrigins(extraOrigins: string[] = []): string[] {
|
|
const origins = new Set<string>();
|
|
|
|
for (const raw of [process.env.NEXTAUTH_URL, process.env.NEXT_PUBLIC_APP_URL, ...extraOrigins]) {
|
|
if (!raw?.trim()) continue;
|
|
try {
|
|
origins.add(new URL(trimTrailingSlashes(raw.trim())).origin);
|
|
} catch {
|
|
// Ignore invalid origin URLs.
|
|
}
|
|
}
|
|
|
|
if (process.env.NODE_ENV === 'development') {
|
|
origins.add('http://localhost:3000');
|
|
origins.add('http://127.0.0.1:3000');
|
|
}
|
|
|
|
return [...origins];
|
|
}
|
|
|
|
function corsRulesMatchOrigins(
|
|
existing:
|
|
| {
|
|
AllowedOrigins?: string[];
|
|
AllowedMethods?: string[];
|
|
}
|
|
| undefined,
|
|
requiredOrigins: string[]
|
|
): boolean {
|
|
if (!existing?.AllowedOrigins?.length || !existing.AllowedMethods?.length) {
|
|
return false;
|
|
}
|
|
|
|
const allowedOrigins = new Set(existing.AllowedOrigins);
|
|
const methods = new Set(existing.AllowedMethods.map((method) => method.toUpperCase()));
|
|
const hasRequiredOrigins = requiredOrigins.every((origin) => allowedOrigins.has(origin));
|
|
const hasPut = methods.has('PUT');
|
|
const hasGet = methods.has('GET') || methods.has('HEAD');
|
|
|
|
return hasRequiredOrigins && hasPut && hasGet;
|
|
}
|
|
|
|
export async function ensureR2UploadCors(extraOrigins: string[] = []): Promise<string[]> {
|
|
const allowedOrigins = getR2UploadCorsOrigins(extraOrigins);
|
|
if (allowedOrigins.length === 0) {
|
|
throw new Error(
|
|
'No origins configured for R2 upload CORS (set NEXTAUTH_URL or NEXT_PUBLIC_APP_URL)'
|
|
);
|
|
}
|
|
|
|
const managedRule = {
|
|
AllowedOrigins: allowedOrigins,
|
|
AllowedMethods: ['GET', 'PUT', 'HEAD'],
|
|
AllowedHeaders: ['*'],
|
|
ExposeHeaders: ['ETag'],
|
|
MaxAgeSeconds: 3600,
|
|
};
|
|
|
|
try {
|
|
const existing = await r2Client.send(
|
|
new GetBucketCorsCommand({
|
|
Bucket: R2_BUCKET_NAME,
|
|
})
|
|
);
|
|
const existingRules = existing.CORSRules ?? [];
|
|
if (existingRules.some((rule) => corsRulesMatchOrigins(rule, allowedOrigins))) {
|
|
return allowedOrigins;
|
|
}
|
|
|
|
await r2Client.send(
|
|
new PutBucketCorsCommand({
|
|
Bucket: R2_BUCKET_NAME,
|
|
CORSConfiguration: {
|
|
CORSRules: [...existingRules, managedRule],
|
|
},
|
|
})
|
|
);
|
|
return allowedOrigins;
|
|
} catch {
|
|
// No CORS config yet, or insufficient permissions to read — attempt to write.
|
|
}
|
|
|
|
await r2Client.send(
|
|
new PutBucketCorsCommand({
|
|
Bucket: R2_BUCKET_NAME,
|
|
CORSConfiguration: {
|
|
CORSRules: [managedRule],
|
|
},
|
|
})
|
|
);
|
|
|
|
return allowedOrigins;
|
|
}
|
|
|
|
export async function createPresignedVideoPutUrl(
|
|
key: string,
|
|
contentType: string,
|
|
contentLength: bigint,
|
|
expiresInSeconds = DEFAULT_PRESIGNED_PUT_TTL_SECONDS
|
|
): Promise<string> {
|
|
if (!key.startsWith(VIDEO_OBJECT_KEY_PREFIX)) {
|
|
throw new Error('Invalid video object key');
|
|
}
|
|
|
|
if (contentLength <= BigInt(0) || contentLength > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
throw new Error('Invalid video content length');
|
|
}
|
|
|
|
const command = new PutObjectCommand({
|
|
Bucket: R2_BUCKET_NAME,
|
|
Key: key,
|
|
ContentType: contentType,
|
|
ContentLength: Number(contentLength),
|
|
});
|
|
|
|
return getSignedUrl(getOrCreateR2PresignClient(), command, { expiresIn: expiresInSeconds });
|
|
}
|
|
|
|
export async function createPresignedImagePutUrl(
|
|
key: string,
|
|
contentType: string,
|
|
expiresInSeconds = DEFAULT_PRESIGNED_PUT_TTL_SECONDS
|
|
): Promise<string> {
|
|
if (!key.startsWith(IMAGE_OBJECT_KEY_PREFIX)) {
|
|
throw new Error('Invalid image object key');
|
|
}
|
|
|
|
const command = new PutObjectCommand({
|
|
Bucket: R2_BUCKET_NAME,
|
|
Key: key,
|
|
ContentType: contentType,
|
|
});
|
|
|
|
return getSignedUrl(getOrCreateR2PresignClient(), command, { expiresIn: expiresInSeconds });
|
|
}
|
|
|
|
export async function headVideoObject(key: string): Promise<{
|
|
contentLength: bigint;
|
|
contentType: string | undefined;
|
|
} | null> {
|
|
if (!key.startsWith(VIDEO_OBJECT_KEY_PREFIX)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const result = await r2Client.send(
|
|
new HeadObjectCommand({
|
|
Bucket: R2_BUCKET_NAME,
|
|
Key: key,
|
|
})
|
|
);
|
|
|
|
const contentLength =
|
|
typeof result.ContentLength === 'number' && result.ContentLength >= 0
|
|
? BigInt(result.ContentLength)
|
|
: BigInt(0);
|
|
|
|
return {
|
|
contentLength,
|
|
contentType: result.ContentType,
|
|
};
|
|
} catch (error) {
|
|
const statusCode = (error as { $metadata?: { httpStatusCode?: number } })?.$metadata
|
|
?.httpStatusCode;
|
|
if (statusCode === 404) return null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function readVideoObjectBytes(
|
|
key: string,
|
|
byteLength: number
|
|
): Promise<Uint8Array | null> {
|
|
if (!key.startsWith(VIDEO_OBJECT_KEY_PREFIX) || byteLength <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const rangeEnd = Math.max(0, byteLength - 1);
|
|
try {
|
|
const result = await r2Client.send(
|
|
new GetObjectCommand({
|
|
Bucket: R2_BUCKET_NAME,
|
|
Key: key,
|
|
Range: `bytes=0-${rangeEnd}`,
|
|
})
|
|
);
|
|
|
|
if (!result.Body) return null;
|
|
const body = result.Body as { transformToByteArray?: () => Promise<Uint8Array> };
|
|
if (typeof body.transformToByteArray !== 'function') return null;
|
|
return await body.transformToByteArray();
|
|
} catch (error) {
|
|
const statusCode = (error as { $metadata?: { httpStatusCode?: number } })?.$metadata
|
|
?.httpStatusCode;
|
|
if (statusCode === 404 || statusCode === 416) return null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function assertAllowedObjectKey(key: string): void {
|
|
if (!key.startsWith(VIDEO_OBJECT_KEY_PREFIX) && !key.startsWith(IMAGE_OBJECT_KEY_PREFIX)) {
|
|
throw new Error('Invalid object key');
|
|
}
|
|
}
|
|
|
|
export async function deleteVideoObject(key: string): Promise<void> {
|
|
if (!key.startsWith(VIDEO_OBJECT_KEY_PREFIX)) {
|
|
throw new Error('Invalid video object key');
|
|
}
|
|
|
|
await deleteR2Object(key);
|
|
}
|
|
|
|
export async function deleteR2Object(key: string): Promise<void> {
|
|
assertAllowedObjectKey(key);
|
|
|
|
await r2Client.send(
|
|
new DeleteObjectCommand({
|
|
Bucket: R2_BUCKET_NAME,
|
|
Key: key,
|
|
})
|
|
);
|
|
}
|
|
|
|
export { R2_BUCKET_NAME };
|