mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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
This commit is contained in:
@@ -1,18 +1,29 @@
|
||||
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(/\/+$/, '');
|
||||
@@ -38,6 +49,13 @@ function getR2Endpoint(): string {
|
||||
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;
|
||||
@@ -47,6 +65,8 @@ function getOrCreateR2Client(): 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),
|
||||
@@ -56,6 +76,26 @@ function getOrCreateR2Client(): S3Client {
|
||||
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') {
|
||||
@@ -63,6 +103,9 @@ export const r2Client = new Proxy({} as S3Client, {
|
||||
if (!cachedR2Client) return;
|
||||
cachedR2Client.destroy();
|
||||
cachedR2Client = null;
|
||||
if (!cachedR2PresignClient) return;
|
||||
cachedR2PresignClient.destroy();
|
||||
cachedR2PresignClient = null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,4 +170,230 @@ export async function uploadAudio(
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user