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:
yusufipk
2026-05-27 17:04:39 +02:00
parent b6de3a29aa
commit 4bf6e821af
57 changed files with 2707 additions and 436 deletions
+181
View File
@@ -0,0 +1,181 @@
import { captureVideoThumbnail } from '@/lib/client/video-thumbnail';
export type R2VideoInitResponse = {
presignedPutUrl: string;
objectKey: string;
proxyUrl: string;
uploadToken: string;
reservationId: string | null;
contentType: string;
thumbnailPresignedPutUrl: string;
thumbnailObjectKey: string;
thumbnailProxyUrl: string;
};
export type R2VideoUploadResult = R2VideoInitResponse & {
duration: number | null;
thumbnailUrl: string | null;
};
type UploadProgressHandler = (progress: number) => void;
function uploadBytesWithProgress(
url: string,
body: Blob | File,
contentType: string,
onProgress?: UploadProgressHandler
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', url);
xhr.setRequestHeader('Content-Type', contentType);
xhr.upload.onprogress = (event) => {
if (!onProgress || !event.lengthComputable) return;
onProgress(Math.round((event.loaded / event.total) * 100));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
return;
}
reject(new Error(`Upload failed with status ${xhr.status}`));
};
xhr.onerror = () => {
reject(
new Error(
'Network error during upload. If you use direct S3/R2 uploads, configure bucket CORS to allow PUT from this site origin.'
)
);
};
xhr.onabort = () => reject(new Error('Upload aborted'));
xhr.send(body);
});
}
async function readVideoDuration(file: File): Promise<number | null> {
return new Promise((resolve) => {
const objectUrl = URL.createObjectURL(file);
const video = document.createElement('video');
video.preload = 'metadata';
const cleanup = () => {
video.removeAttribute('src');
video.load();
URL.revokeObjectURL(objectUrl);
};
video.onloadedmetadata = () => {
const duration =
Number.isFinite(video.duration) && video.duration > 0 ? Math.round(video.duration) : null;
cleanup();
resolve(duration);
};
video.onerror = () => {
cleanup();
resolve(null);
};
video.src = objectUrl;
});
}
export async function initR2VideoUpload(
projectId: string,
file: File
): Promise<R2VideoInitResponse> {
const initRes = await fetch(`/api/projects/${projectId}/videos/r2-init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
sizeBytes: file.size,
}),
});
const initPayload = (await initRes.json().catch(() => null)) as {
data?: R2VideoInitResponse;
error?: string;
} | null;
if (!initRes.ok || !initPayload?.data) {
throw new Error(initPayload?.error || 'Failed to initialize video upload');
}
return initPayload.data;
}
export async function cleanupPendingR2VideoUpload(
projectId: string,
input: {
objectKey: string;
uploadToken: string;
reservationId: string | null;
thumbnailObjectKey?: string | null;
},
keepalive = false
): Promise<void> {
try {
await fetch(`/api/projects/${projectId}/videos/r2-init`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
objectKey: input.objectKey,
uploadToken: input.uploadToken,
reservationId: input.reservationId,
thumbnailObjectKey: input.thumbnailObjectKey ?? undefined,
}),
keepalive,
});
} catch (error) {
console.error('Failed to cleanup pending R2 video upload:', error);
}
}
export async function uploadVideoToR2(
projectId: string,
file: File,
options?: { onProgress?: UploadProgressHandler }
): Promise<R2VideoUploadResult> {
const init = await initR2VideoUpload(projectId, file);
const cleanupInput = {
objectKey: init.objectKey,
uploadToken: init.uploadToken,
reservationId: init.reservationId,
thumbnailObjectKey: init.thumbnailObjectKey,
};
try {
await uploadBytesWithProgress(
init.presignedPutUrl,
file,
init.contentType,
options?.onProgress
);
} catch (error) {
await cleanupPendingR2VideoUpload(projectId, cleanupInput);
throw error;
}
const [duration, thumbnailBlob] = await Promise.all([
readVideoDuration(file),
captureVideoThumbnail(file),
]);
let thumbnailUrl: string | null = null;
if (thumbnailBlob) {
try {
await uploadBytesWithProgress(init.thumbnailPresignedPutUrl, thumbnailBlob, 'image/jpeg');
thumbnailUrl = init.thumbnailProxyUrl;
} catch (error) {
console.warn('Failed to upload video thumbnail:', error);
}
}
return { ...init, duration, thumbnailUrl };
}
+63
View File
@@ -0,0 +1,63 @@
const THUMBNAIL_MAX_WIDTH = 640;
const DEFAULT_SEEK_SECONDS = 1;
export async function captureVideoThumbnail(
file: File,
seekSeconds = DEFAULT_SEEK_SECONDS
): Promise<Blob | null> {
return new Promise((resolve) => {
const objectUrl = URL.createObjectURL(file);
const video = document.createElement('video');
video.preload = 'metadata';
video.muted = true;
video.playsInline = true;
let settled = false;
const finish = (blob: Blob | null) => {
if (settled) return;
settled = true;
video.removeAttribute('src');
video.load();
URL.revokeObjectURL(objectUrl);
resolve(blob);
};
video.onloadedmetadata = () => {
const duration = Number.isFinite(video.duration) ? video.duration : 0;
const target =
duration > 0 ? Math.min(Math.max(seekSeconds, 0), Math.max(0, duration - 0.1)) : 0;
video.currentTime = target;
};
video.onseeked = () => {
try {
const width = video.videoWidth;
const height = video.videoHeight;
if (width <= 0 || height <= 0) {
finish(null);
return;
}
const scale = Math.min(1, THUMBNAIL_MAX_WIDTH / width);
const canvas = document.createElement('canvas');
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
const context = canvas.getContext('2d');
if (!context) {
finish(null);
return;
}
context.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => finish(blob), 'image/jpeg', 0.85);
} catch {
finish(null);
}
};
video.onerror = () => finish(null);
video.src = objectUrl;
});
}
+58
View File
@@ -1,3 +1,5 @@
import { logError } from '@/lib/logger';
function readBooleanEnv(name: string, defaultValue: boolean): boolean {
const value = process.env[name];
if (!value) return defaultValue;
@@ -9,6 +11,20 @@ function readBooleanEnv(name: string, defaultValue: boolean): boolean {
return defaultValue;
}
let warnedAboutConflictingUploadFlags = false;
function warnIfConflictingDirectUploadFlags(): void {
if (warnedAboutConflictingUploadFlags) return;
if (!isS3VideoUploadsFeatureEnabled() || !isBunnyUploadsFeatureEnabled()) return;
if (!hasR2Config() || !hasBunnyUploadsConfig()) return;
warnedAboutConflictingUploadFlags = true;
logError(
'OPENFRAME_ENABLE_S3_VIDEO_UPLOADS and OPENFRAME_ENABLE_BUNNY_UPLOADS are both enabled with valid config. S3 video uploads take precedence; disable Bunny uploads for self-hosted deployments.',
new Error('Conflicting direct upload feature flags')
);
}
export function isStripeFeatureEnabled() {
return readBooleanEnv('OPENFRAME_ENABLE_STRIPE', true);
}
@@ -32,10 +48,52 @@ export function hasBunnyUploadsConfig() {
);
}
export function isS3VideoUploadsFeatureEnabled() {
return readBooleanEnv('OPENFRAME_ENABLE_S3_VIDEO_UPLOADS', false);
}
export function hasR2Config() {
return Boolean(
process.env.R2_ACCESS_KEY_ID &&
process.env.R2_SECRET_ACCESS_KEY &&
process.env.R2_BUCKET_NAME &&
(process.env.R2_ENDPOINT || process.env.R2_ACCOUNT_ID)
);
}
export function isS3VideoUploadsEnabled() {
warnIfConflictingDirectUploadFlags();
return isS3VideoUploadsFeatureEnabled() && hasR2Config();
}
export function isBunnyUploadsEnabled() {
if (isS3VideoUploadsEnabled()) {
return false;
}
return isBunnyUploadsFeatureEnabled() && hasBunnyUploadsConfig();
}
export function isDirectFileUploadEnabled() {
return isS3VideoUploadsEnabled() || isBunnyUploadsEnabled();
}
export function getMaxVideoUploadBytes(): bigint {
const raw = process.env.OPENFRAME_MAX_VIDEO_UPLOAD_BYTES?.trim();
if (!raw) {
return BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024);
}
try {
const parsed = BigInt(raw);
if (parsed <= BigInt(0)) {
return BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024);
}
return parsed;
} catch {
return BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024);
}
}
export function isInviteCodeRequired() {
return readBooleanEnv('OPENFRAME_REQUIRE_INVITE_CODE', true);
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Converts values for JSON responses (e.g. Prisma BigInt fields).
*/
export function toJsonSafe<T>(value: T): T {
return JSON.parse(
JSON.stringify(value, (_key, val) => (typeof val === 'bigint' ? val.toString() : val))
) as T;
}
+30 -4
View File
@@ -2,6 +2,7 @@ import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { db } from '@/lib/db';
import { runWithConcurrency } from '@/lib/async-pool';
import { videoProxyPathToObjectKey } from '@/lib/video-upload-validation';
import { logError } from '@/lib/logger';
/** The path prefix for images served by the upload API. */
@@ -32,7 +33,8 @@ export function mediaUrlToKey(url: string): string | null {
const filename = url.slice(IMAGE_PATH_PREFIX.length);
return filename ? `images/${filename}` : null;
}
return null;
return videoProxyPathToObjectKey(url);
}
/**
@@ -80,7 +82,7 @@ export async function deleteMediaFilesBestEffort(mediaUrls: string[]): Promise<R
* Collect all media URLs from comments under a given video (all versions).
*/
export async function collectVideoMediaUrls(videoId: string): Promise<string[]> {
const [comments, assets] = await Promise.all([
const [comments, assets, versions] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
@@ -95,6 +97,10 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
},
select: { sourceUrl: true },
}),
db.videoVersion.findMany({
where: { videoParentId: videoId, providerId: 'r2' },
select: { originalUrl: true, thumbnailUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach((c) => {
@@ -104,6 +110,10 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl);
});
versions.forEach((version) => {
if (version.originalUrl) urls.push(version.originalUrl);
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
});
return urls;
}
@@ -111,7 +121,7 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
* Collect all media URLs from comments under all videos in a project.
*/
export async function collectProjectMediaUrls(projectId: string): Promise<string[]> {
const [comments, assets] = await Promise.all([
const [comments, assets, versions] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
@@ -126,6 +136,10 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
},
select: { sourceUrl: true },
}),
db.videoVersion.findMany({
where: { providerId: 'r2', video: { projectId } },
select: { originalUrl: true, thumbnailUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach((c) => {
@@ -135,6 +149,10 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl);
});
versions.forEach((version) => {
if (version.originalUrl) urls.push(version.originalUrl);
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
});
return urls;
}
@@ -142,7 +160,7 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
* Collect all media URLs from comments under all projects in a workspace.
*/
export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<string[]> {
const [comments, assets] = await Promise.all([
const [comments, assets, versions] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
@@ -157,6 +175,10 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
},
select: { sourceUrl: true },
}),
db.videoVersion.findMany({
where: { providerId: 'r2', video: { project: { workspaceId } } },
select: { originalUrl: true, thumbnailUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach((c) => {
@@ -166,6 +188,10 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl);
});
versions.forEach((version) => {
if (version.originalUrl) urls.push(version.originalUrl);
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
});
return urls;
}
+7 -1
View File
@@ -168,13 +168,19 @@ export async function proxyR2MediaObject({
}
const headers = new Headers();
setIfPresent(headers, 'Content-Type', objectResponse.ContentType || fallbackContentType);
const resolvedContentType =
objectResponse.ContentType && objectResponse.ContentType !== 'application/octet-stream'
? objectResponse.ContentType
: fallbackContentType;
setIfPresent(headers, 'Content-Type', resolvedContentType);
setIfPresent(headers, 'Content-Length', objectResponse.ContentLength);
setIfPresent(headers, 'Content-Range', objectResponse.ContentRange);
setIfPresent(headers, 'ETag', objectResponse.ETag);
setIfPresent(headers, 'Last-Modified', objectResponse.LastModified?.toUTCString());
setIfPresent(headers, 'Accept-Ranges', objectResponse.AcceptRanges || 'bytes');
headers.set('Cache-Control', cacheControl);
headers.set('X-Content-Type-Options', 'nosniff');
headers.set('Content-Disposition', 'inline');
if (extraHeaders) {
for (const [name, value] of Object.entries(extraHeaders)) {
+45
View File
@@ -0,0 +1,45 @@
import { db } from '@/lib/db';
export type CreateR2UploadSessionInput = {
userId: string;
projectId: string;
billedUserId: string;
objectKey: string;
thumbnailObjectKey: string;
declaredSizeBytes: bigint;
contentType: string;
reservationId: string | null;
uploadJti: string;
expiresAt: Date;
};
export async function createR2UploadSession(input: CreateR2UploadSessionInput) {
return db.videoUploadSession.create({
data: {
userId: input.userId,
projectId: input.projectId,
billedUserId: input.billedUserId,
objectKey: input.objectKey,
thumbnailObjectKey: input.thumbnailObjectKey,
declaredSizeBytes: input.declaredSizeBytes,
contentType: input.contentType,
reservationId: input.reservationId,
uploadJti: input.uploadJti,
expiresAt: input.expiresAt,
},
});
}
export async function cancelR2UploadSession(sessionId: string) {
return db.videoUploadSession.updateMany({
where: {
id: sessionId,
status: 'INITIATED',
expiresAt: { gt: new Date() },
},
data: {
status: 'CANCELLED',
consumedAt: new Date(),
},
});
}
+129
View File
@@ -0,0 +1,129 @@
import crypto from 'crypto';
const R2_UPLOAD_TOKEN_TYPE = 'r2-upload';
const DEFAULT_TOKEN_TTL_SECONDS = 60 * 60;
interface R2UploadTokenPayload {
typ: typeof R2_UPLOAD_TOKEN_TYPE;
uid: string;
pid: string;
key: string;
sid: string;
jti: string;
tkey: string;
iat: number;
exp: number;
}
export interface R2UploadTokenSubject {
userId: string;
projectId: string;
objectKey: string;
sessionId?: string;
tokenId?: string;
thumbnailObjectKey?: string;
}
function getR2UploadTokenSecret(): string {
const secret = process.env.R2_UPLOAD_TOKEN_SECRET || process.env.NEXTAUTH_SECRET;
if (!secret) {
throw new Error('Missing R2_UPLOAD_TOKEN_SECRET or NEXTAUTH_SECRET.');
}
return secret;
}
function signPayload(payload: string, secret: string): string {
return crypto.createHmac('sha256', secret).update(payload).digest('base64url');
}
function isValidPayload(value: unknown): value is R2UploadTokenPayload {
if (!value || typeof value !== 'object') return false;
const payload = value as Partial<R2UploadTokenPayload>;
return (
payload.typ === R2_UPLOAD_TOKEN_TYPE &&
typeof payload.uid === 'string' &&
typeof payload.pid === 'string' &&
typeof payload.key === 'string' &&
typeof payload.sid === 'string' &&
typeof payload.jti === 'string' &&
typeof payload.tkey === 'string' &&
typeof payload.iat === 'number' &&
Number.isFinite(payload.iat) &&
typeof payload.exp === 'number' &&
Number.isFinite(payload.exp)
);
}
export function createR2UploadToken(
subject: R2UploadTokenSubject & {
sessionId: string;
tokenId: string;
thumbnailObjectKey: string;
},
ttlSeconds = DEFAULT_TOKEN_TTL_SECONDS
): string {
const now = Math.floor(Date.now() / 1000);
const payload: R2UploadTokenPayload = {
typ: R2_UPLOAD_TOKEN_TYPE,
uid: subject.userId,
pid: subject.projectId,
key: subject.objectKey,
sid: subject.sessionId,
jti: subject.tokenId,
tkey: subject.thumbnailObjectKey,
iat: now,
exp: now + ttlSeconds,
};
const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
const signature = signPayload(encodedPayload, getR2UploadTokenSecret());
return `${encodedPayload}.${signature}`;
}
export function verifyR2UploadToken(token: string, subject: R2UploadTokenSubject): boolean {
const payload = parseR2UploadToken(token);
if (!payload) return false;
if (
payload.uid !== subject.userId ||
payload.pid !== subject.projectId ||
payload.key !== subject.objectKey
) {
return false;
}
if (subject.sessionId && payload.sid !== subject.sessionId) return false;
if (subject.tokenId && payload.jti !== subject.tokenId) return false;
if (subject.thumbnailObjectKey && payload.tkey !== subject.thumbnailObjectKey) return false;
return true;
}
export function parseR2UploadToken(token: string): R2UploadTokenPayload | null {
try {
const parts = token.split('.');
if (parts.length !== 2) return null;
const [encodedPayload, providedSignature] = parts;
if (!encodedPayload || !providedSignature) return null;
const expectedSignature = signPayload(encodedPayload, getR2UploadTokenSecret());
const providedBuffer = Buffer.from(providedSignature, 'utf8');
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
if (providedBuffer.length !== expectedBuffer.length) return null;
if (!crypto.timingSafeEqual(providedBuffer, expectedBuffer)) return null;
const payloadJson = Buffer.from(encodedPayload, 'base64url').toString('utf8');
const payloadUnknown: unknown = JSON.parse(payloadJson);
if (!isValidPayload(payloadUnknown)) return null;
const payload = payloadUnknown;
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
return payload;
} catch {
return null;
}
}
+181
View File
@@ -0,0 +1,181 @@
import { db } from '@/lib/db';
import { getMaxVideoUploadBytes } from '@/lib/feature-flags';
import { deleteR2Object, deleteVideoObject, headVideoObject, readVideoObjectBytes } from '@/lib/r2';
import { parseR2UploadToken, verifyR2UploadToken } from '@/lib/r2-upload-token';
import {
objectKeyToVideoProxyPath,
videoProxyPathToObjectKey,
} from '@/lib/video-upload-validation';
export type R2VideoFinalizeInput = {
userId: string;
projectId: string;
videoUrl: string;
objectKey: string;
uploadToken: string;
};
export type R2VideoFinalizeResult =
| {
ok: true;
sizeBytes: bigint;
proxyUrl: string;
objectKey: string;
sessionId: string;
reservationId: string | null;
billedUserId: string;
thumbnailObjectKey: string;
thumbnailProxyUrl: string;
}
| { ok: false; error: string; status: 400 | 403 };
function hasKnownVideoMagicBytes(bytes: Uint8Array): boolean {
if (bytes.length >= 12) {
const box = String.fromCharCode(bytes[4] ?? 0, bytes[5] ?? 0, bytes[6] ?? 0, bytes[7] ?? 0);
if (box === 'ftyp') return true;
}
if (
bytes.length >= 4 &&
bytes[0] === 0x1a &&
bytes[1] === 0x45 &&
bytes[2] === 0xdf &&
bytes[3] === 0xa3
) {
return true;
}
if (
bytes.length >= 4 &&
bytes[0] === 0x4f &&
bytes[1] === 0x67 &&
bytes[2] === 0x67 &&
bytes[3] === 0x53
) {
return true;
}
if (
bytes.length >= 12 &&
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x41 &&
bytes[9] === 0x56 &&
bytes[10] === 0x49 &&
bytes[11] === 0x20
) {
return true;
}
return false;
}
export async function finalizeR2VideoUpload(
input: R2VideoFinalizeInput
): Promise<R2VideoFinalizeResult> {
const { userId, projectId, videoUrl, objectKey, uploadToken } = input;
if (!objectKey || !uploadToken) {
return { ok: false, error: 'R2 uploads must include objectKey and uploadToken', status: 400 };
}
const expectedProxyUrl = objectKeyToVideoProxyPath(objectKey);
if (!expectedProxyUrl) {
return { ok: false, error: 'Invalid object key', status: 400 };
}
if (videoUrl !== expectedProxyUrl) {
return { ok: false, error: 'Video URL does not match the uploaded object', status: 400 };
}
const keyFromUrl = videoProxyPathToObjectKey(videoUrl);
if (!keyFromUrl || keyFromUrl !== objectKey) {
return { ok: false, error: 'Video URL does not match the uploaded object', status: 400 };
}
const tokenPayload = parseR2UploadToken(uploadToken);
if (!tokenPayload) {
return { ok: false, error: 'Invalid upload token', status: 403 };
}
const isValidUploadToken = verifyR2UploadToken(uploadToken, {
userId,
projectId,
objectKey,
sessionId: tokenPayload.sid,
tokenId: tokenPayload.jti,
});
if (!isValidUploadToken) {
return { ok: false, error: 'Invalid upload token', status: 403 };
}
const uploadSession = await db.videoUploadSession.findFirst({
where: {
id: tokenPayload.sid,
uploadJti: tokenPayload.jti,
status: 'INITIATED',
userId,
projectId,
objectKey,
thumbnailObjectKey: tokenPayload.tkey,
expiresAt: { gt: new Date() },
},
select: {
id: true,
billedUserId: true,
reservationId: true,
declaredSizeBytes: true,
thumbnailObjectKey: true,
},
});
if (!uploadSession) {
return { ok: false, error: 'Invalid upload token', status: 403 };
}
const thumbnailFilename = uploadSession.thumbnailObjectKey.startsWith('images/')
? uploadSession.thumbnailObjectKey.slice('images/'.length)
: '';
if (!thumbnailFilename) {
return { ok: false, error: 'Invalid upload token', status: 403 };
}
const cancelPendingUpload = async (error: string): Promise<R2VideoFinalizeResult> => {
await db.videoUploadSession.updateMany({
where: { id: uploadSession.id, status: 'INITIATED' },
data: { status: 'CANCELLED', consumedAt: new Date() },
});
await Promise.all([
deleteVideoObject(objectKey).catch(() => undefined),
deleteR2Object(uploadSession.thumbnailObjectKey).catch(() => undefined),
]);
return { ok: false, error, status: 400 };
};
const head = await headVideoObject(objectKey);
if (!head || head.contentLength <= BigInt(0)) {
return cancelPendingUpload('Uploaded video was not found in storage');
}
if (head.contentLength > getMaxVideoUploadBytes()) {
return cancelPendingUpload('Uploaded video exceeds the maximum allowed upload size');
}
if (head.contentLength > uploadSession.declaredSizeBytes) {
return cancelPendingUpload('Uploaded video size does not match upload request');
}
const headerBytes = await readVideoObjectBytes(objectKey, 64);
if (!headerBytes || !hasKnownVideoMagicBytes(headerBytes)) {
return cancelPendingUpload('Uploaded file is not a valid video');
}
return {
ok: true,
sizeBytes: head.contentLength,
proxyUrl: expectedProxyUrl,
objectKey,
sessionId: uploadSession.id,
reservationId: uploadSession.reservationId,
billedUserId: uploadSession.billedUserId,
thumbnailObjectKey: uploadSession.thumbnailObjectKey,
thumbnailProxyUrl: `/api/upload/image/${thumbnailFilename}`,
};
}
+269
View File
@@ -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 };
+37 -9
View File
@@ -20,13 +20,22 @@ class QuotaExceededError extends Error {}
* every upload.
*/
export async function getUserTotalStorageBytes(userId: string): Promise<bigint> {
const [r2Rows, bunnyByUser, reservationRows] = await Promise.all([
const [r2AssetRows, r2VideoRows, bunnyByUser, reservationRows] = await Promise.all([
db.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
FROM video_assets
WHERE "billedUserId" = ${userId}
AND provider IN ('R2_IMAGE', 'R2_AUDIO')
`,
db.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(vv.size_bytes), 0)::bigint AS total
FROM video_versions vv
INNER JOIN videos v ON v.id = vv."videoParentId"
INNER JOIN projects p ON p.id = v."projectId"
INNER JOIN workspaces w ON w.id = p."workspaceId"
WHERE w."ownerId" = ${userId}
AND vv."providerId" = 'r2'
`,
getCachedUserBunnyStorage(),
db.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total
@@ -36,11 +45,12 @@ export async function getUserTotalStorageBytes(userId: string): Promise<bigint>
`,
]);
const r2Bytes = r2Rows[0]?.total ?? BigInt(0);
const r2AssetBytes = r2AssetRows[0]?.total ?? BigInt(0);
const r2VideoBytes = r2VideoRows[0]?.total ?? BigInt(0);
const bunnyBytes = BigInt(bunnyByUser[userId] ?? 0);
const reservedBytes = reservationRows[0]?.total ?? BigInt(0);
return r2Bytes + bunnyBytes + reservedBytes;
return r2AssetBytes + r2VideoBytes + bunnyBytes + reservedBytes;
}
/**
@@ -102,13 +112,14 @@ export async function enforceStorageQuota(
*/
export async function reserveStorageQuota(
userId: string,
incomingSizeBytes: bigint
incomingSizeBytes: bigint,
reservationTtlMs: number = RESERVATION_TTL_MS
): Promise<{ reservationId: string | null } | { error: NextResponse }> {
if (!isStripeFeatureEnabled()) {
return { reservationId: null };
}
const expiresAt = new Date(Date.now() + RESERVATION_TTL_MS);
const expiresAt = new Date(Date.now() + reservationTtlMs);
// Fetch Bunny storage BEFORE entering the transaction to avoid holding the
// advisory lock during a potentially slow/failing HTTP call on cache miss.
@@ -128,13 +139,22 @@ export async function reserveStorageQuota(
`;
// Read committed R2 storage under the lock
const [r2Row] = await tx.$queryRaw<[{ total: bigint }]>`
const [r2AssetRow] = await tx.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
FROM video_assets
WHERE "billedUserId" = ${userId}
AND provider IN ('R2_IMAGE', 'R2_AUDIO')
`;
const r2Bytes = r2Row?.total ?? BigInt(0);
const [r2VideoRow] = await tx.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(vv.size_bytes), 0)::bigint AS total
FROM video_versions vv
INNER JOIN videos v ON v.id = vv."videoParentId"
INNER JOIN projects p ON p.id = v."projectId"
INNER JOIN workspaces w ON w.id = p."workspaceId"
WHERE w."ownerId" = ${userId}
AND vv."providerId" = 'r2'
`;
const r2Bytes = (r2AssetRow?.total ?? BigInt(0)) + (r2VideoRow?.total ?? BigInt(0));
// Read active (non-expired) reservations under the same lock
const [resRow] = await tx.$queryRaw<[{ total: bigint }]>`
@@ -171,7 +191,15 @@ export async function reserveStorageQuota(
* Deletes an upload reservation created by `reserveStorageQuota`.
* Safe to call with `null` (no-op) for flows where billing is disabled.
*/
export async function releaseStorageReservation(reservationId: string | null): Promise<void> {
export async function releaseStorageReservation(
reservationId: string | null,
billedUserId?: string | null
): Promise<void> {
if (!reservationId) return;
await db.uploadReservation.deleteMany({ where: { id: reservationId } });
await db.uploadReservation.deleteMany({
where: {
id: reservationId,
...(billedUserId ? { billedUserId } : {}),
},
});
}
+29
View File
@@ -91,3 +91,32 @@ export function validateOptionalUrl(
return validateUrl(urlString, fieldName);
}
const SAFE_APP_RELATIVE_PATH =
/^\/(?:api\/upload\/(?:image|audio|video)\/[0-9a-f-]{36}\.[a-z0-9]+|placeholder-video-thumbnail\.png)$/i;
export function isSafeAppRelativePath(path: string): boolean {
if (!path.startsWith('/') || path.includes('..')) {
return false;
}
return SAFE_APP_RELATIVE_PATH.test(path);
}
/**
* Accepts optional absolute http(s) URLs or safe same-origin app paths (upload proxy, placeholders).
*/
export function validateOptionalUrlOrAppPath(
urlString: string | null | undefined,
fieldName: string = 'URL'
): string | null {
if (!urlString) {
return null;
}
if (isSafeAppRelativePath(urlString)) {
return null;
}
return validateOptionalUrl(urlString, fieldName);
}
+2 -1
View File
@@ -3,6 +3,7 @@
import { youtubeProvider } from './youtube';
import { directProvider } from './direct';
import { bunnyProvider } from './bunny';
import { r2Provider } from './r2';
import type { VideoProvider, VideoSource, VideoMetadata, VideoProviderType } from './types';
import { logError } from '@/lib/logger';
@@ -10,7 +11,7 @@ import { logError } from '@/lib/logger';
export * from './types';
// Registry of all available providers
const providers: VideoProvider[] = [youtubeProvider, directProvider, bunnyProvider];
const providers: VideoProvider[] = [youtubeProvider, directProvider, bunnyProvider, r2Provider];
// Provider lookup map for quick access
const providerMap = new Map<string, VideoProvider>(providers.map((p) => [p.id, p]));
+42
View File
@@ -0,0 +1,42 @@
import type { VideoProvider, VideoMetadata, EmbedOptions } from './types';
const R2_VIDEO_PROXY_PATH = /^\/api\/upload\/video\/[0-9a-f-]{36}\.[a-z0-9]+$/i;
export const r2Provider: VideoProvider = {
id: 'r2',
name: 'Self-hosted',
icon: 'Upload',
canHandle(url: string): boolean {
return R2_VIDEO_PROXY_PATH.test(url);
},
extractVideoId(url: string): string | null {
if (this.canHandle(url)) {
return url;
}
return null;
},
getEmbedUrl(videoId: string, options: EmbedOptions = {}): string {
const params = new URLSearchParams();
if (options.startTime) params.set('t', String(Math.floor(options.startTime)));
const queryString = params.toString();
return `${videoId}${queryString ? `?${queryString}` : ''}`;
},
getThumbnailUrl(_videoId: string): string {
void _videoId;
return '/placeholder-video-thumbnail.png';
},
async getMetadata(videoId: string): Promise<VideoMetadata> {
const filename = videoId.split('/').pop() || 'Video';
const nameWithoutExt = filename.replace(/\.[^/.]+$/, '');
return {
title: nameWithoutExt,
thumbnailUrl: this.getThumbnailUrl(videoId),
};
},
};
+1 -1
View File
@@ -38,7 +38,7 @@ export interface EmbedOptions {
export type ThumbnailSize = 'small' | 'medium' | 'large' | 'maxres';
// Supported provider types - extend as we add more
export type VideoProviderType = 'youtube' | 'direct' | 'bunny';
export type VideoProviderType = 'youtube' | 'direct' | 'bunny' | 'r2';
// Video source stored in database
export interface VideoSource {
+87
View File
@@ -0,0 +1,87 @@
const VIDEO_MIME_TO_EXT: Record<string, string> = {
'video/mp4': 'mp4',
'video/webm': 'webm',
'video/ogg': 'ogg',
'video/quicktime': 'mov',
'video/x-matroska': 'mkv',
'video/x-msvideo': 'avi',
};
const EXT_TO_MIME: Record<string, string> = {
mp4: 'video/mp4',
webm: 'video/webm',
ogg: 'video/ogg',
mov: 'video/quicktime',
m4v: 'video/mp4',
mkv: 'video/x-matroska',
avi: 'video/x-msvideo',
};
const ALLOWED_VIDEO_EXTENSIONS = new Set(Object.keys(EXT_TO_MIME));
export function normalizeVideoMime(mime: string | undefined): string | null {
if (!mime) return null;
const normalized = mime.split(';')[0]?.trim().toLowerCase() ?? '';
if (!normalized.startsWith('video/')) return null;
return normalized;
}
export function getVideoExtensionFromMime(mime: string): string | null {
return VIDEO_MIME_TO_EXT[mime] ?? null;
}
export function getVideoExtensionFromFileName(fileName: string): string | null {
const ext = fileName.split('.').pop()?.toLowerCase();
if (!ext || !ALLOWED_VIDEO_EXTENSIONS.has(ext)) return null;
return ext;
}
export function resolveVideoContentType(fileName: string, mime: string | undefined): string | null {
const normalizedMime = normalizeVideoMime(mime);
if (normalizedMime) {
const extFromMime = getVideoExtensionFromMime(normalizedMime);
const extFromName = getVideoExtensionFromFileName(fileName);
if (extFromMime && extFromName && extFromMime !== extFromName) {
return EXT_TO_MIME[extFromName] ?? normalizedMime;
}
return normalizedMime;
}
const ext = getVideoExtensionFromFileName(fileName);
if (!ext) return null;
return EXT_TO_MIME[ext] ?? null;
}
export function isAllowedVideoFile(fileName: string, mime: string | undefined): boolean {
return resolveVideoContentType(fileName, mime) !== null;
}
export const VIDEO_OBJECT_KEY_PREFIX = 'videos/';
const SAFE_VIDEO_BASENAME =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
export function buildVideoObjectKey(filename: string): string {
return `${VIDEO_OBJECT_KEY_PREFIX}${filename}`;
}
export function videoProxyPathFromFilename(filename: string): string {
return `/api/upload/video/${filename}`;
}
export function videoProxyPathToObjectKey(proxyPath: string): string | null {
const prefix = '/api/upload/video/';
if (!proxyPath.startsWith(prefix)) return null;
const filename = proxyPath.slice(prefix.length);
if (!SAFE_VIDEO_BASENAME.test(filename)) return null;
return buildVideoObjectKey(filename);
}
export function objectKeyToVideoProxyPath(objectKey: string): string | null {
if (!objectKey.startsWith(VIDEO_OBJECT_KEY_PREFIX)) return null;
const filename = objectKey.slice(VIDEO_OBJECT_KEY_PREFIX.length);
if (!SAFE_VIDEO_BASENAME.test(filename)) return null;
return videoProxyPathFromFilename(filename);
}
export { SAFE_VIDEO_BASENAME };