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:
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user