Files
OpenFrame/lib/r2.ts
T
yusufipk 4bf6e821af feat: enable S3 video uploads and update related configurations
- 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
2026-05-27 17:04:39 +02:00

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 };