Files
OpenFrame/lib/r2-video-finalize.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

182 lines
5.2 KiB
TypeScript

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