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
@@ -3,6 +3,7 @@ import { db } from '@/lib/db';
import { auth, checkProjectAccess } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit';
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
import { deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { logError } from '@/lib/logger';
@@ -148,8 +149,17 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
}
});
const bunnyCleanupResult = await cleanupBunnyStreamVideosBestEffort([bunnyRef]);
const cleanupInput = { bunny: bunnyCleanupResult };
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
cleanupBunnyStreamVideosBestEffort([bunnyRef]),
result.version.providerId === 'r2'
? deleteMediaFilesBestEffort(
[result.version.originalUrl, result.version.thumbnailUrl].filter((url): url is string =>
Boolean(url)
)
)
: Promise.resolve({ attempted: 0, failed: 0, failedKeys: [] }),
]);
const cleanupInput = { bunny: bunnyCleanupResult, r2: r2CleanupResult };
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
if (cleanupWarnings) {
logCleanupWarnings({ entityType: 'video-version', entityId: versionId }, cleanupInput);
@@ -1,11 +1,13 @@
import { NextRequest } from 'next/server';
import { db } from '@/lib/db';
import { auth, checkProjectAccess } from '@/lib/auth';
import { validateUrl, validateOptionalUrl } from '@/lib/validation';
import { validateUrl, validateOptionalUrlOrAppPath } from '@/lib/validation';
import { toJsonSafe } from '@/lib/json-serialize';
import { rateLimit } from '@/lib/rate-limit';
import { notifyProjectOwner } from '@/lib/notifications';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
import { logError } from '@/lib/logger';
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
@@ -88,6 +90,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
duration,
setActive,
uploadToken,
objectKey,
} = body;
if (!videoUrl) {
@@ -103,13 +106,23 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}
}
// Validate URLs use safe schemes (http/https only)
const videoUrlError = validateUrl(videoUrl, 'Video URL');
if (videoUrlError) {
return apiErrors.badRequest(videoUrlError);
const normalizedProviderIdEarly =
typeof providerId === 'string' && providerId.trim()
? providerId.trim().toLowerCase()
: 'youtube';
if (normalizedProviderIdEarly === 'r2') {
if (!videoUrl.startsWith('/api/upload/video/')) {
return apiErrors.badRequest('Video URL must be a valid upload path');
}
} else {
const videoUrlError = validateUrl(videoUrl, 'Video URL');
if (videoUrlError) {
return apiErrors.badRequest(videoUrlError);
}
}
const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Thumbnail URL');
const thumbnailUrlError = validateOptionalUrlOrAppPath(thumbnailUrl, 'Thumbnail URL');
if (thumbnailUrlError) {
return apiErrors.badRequest(thumbnailUrlError);
}
@@ -122,6 +135,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
typeof providerVideoId === 'string' ? providerVideoId.trim() : '';
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
let versionSizeBytes = BigInt(0);
let persistedProviderVideoId = normalizedProviderVideoId;
let finalizedR2Session: {
sessionId: string;
reservationId: string | null;
billedUserId: string;
thumbnailProxyUrl: string;
} | null = null;
if (normalizedProviderId === 'bunny') {
if (!normalizedProviderVideoId || !normalizedUploadToken) {
return apiErrors.badRequest('Bunny uploads must include providerVideoId and uploadToken');
@@ -135,6 +157,34 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
} else if (normalizedProviderId === 'r2') {
const normalizedObjectKey = typeof objectKey === 'string' ? objectKey.trim() : '';
if (!normalizedObjectKey || !normalizedUploadToken) {
return apiErrors.badRequest('R2 uploads must include objectKey and uploadToken');
}
const finalizeResult = await finalizeR2VideoUpload({
userId: session.user.id,
projectId,
videoUrl,
objectKey: normalizedObjectKey,
uploadToken: normalizedUploadToken,
});
if (!finalizeResult.ok) {
if (finalizeResult.status === 403) {
return apiErrors.forbidden(finalizeResult.error);
}
return apiErrors.badRequest(finalizeResult.error);
}
versionSizeBytes = finalizeResult.sizeBytes;
persistedProviderVideoId = normalizedObjectKey;
finalizedR2Session = {
sessionId: finalizeResult.sessionId,
reservationId: finalizeResult.reservationId,
billedUserId: finalizeResult.billedUserId,
thumbnailProxyUrl: finalizeResult.thumbnailProxyUrl,
};
}
const nextVersionNumber = (video.versions[0]?.versionNumber || 0) + 1;
@@ -150,16 +200,47 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
});
}
if (finalizedR2Session) {
const consumed = await tx.videoUploadSession.updateMany({
where: {
id: finalizedR2Session.sessionId,
status: 'INITIATED',
userId: session.user.id,
projectId,
objectKey: persistedProviderVideoId,
},
data: {
status: 'FINALIZED',
consumedAt: new Date(),
},
});
if (consumed.count !== 1) {
throw new Error('Upload session already consumed');
}
if (finalizedR2Session.reservationId) {
await tx.uploadReservation.deleteMany({
where: {
id: finalizedR2Session.reservationId,
billedUserId: finalizedR2Session.billedUserId,
},
});
}
}
return tx.videoVersion.create({
data: {
versionNumber: nextVersionNumber,
versionLabel: versionLabel?.trim() || null,
providerId: normalizedProviderId,
videoId: normalizedProviderVideoId,
videoId: persistedProviderVideoId,
originalUrl: videoUrl,
title: versionLabel?.trim() || `Version ${nextVersionNumber}`,
thumbnailUrl: thumbnailUrl || null,
thumbnailUrl:
normalizedProviderId === 'r2'
? (finalizedR2Session?.thumbnailProxyUrl ?? '/placeholder-video-thumbnail.png')
: thumbnailUrl || null,
duration: duration || null,
sizeBytes: versionSizeBytes,
isActive: setActive ?? false,
videoParentId: videoId,
},
@@ -183,7 +264,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}).catch((err) => logError('Notification failed:', err));
}
const response = successResponse(version, 201);
const response = successResponse(toJsonSafe(version), 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error creating version:', error);
@@ -6,7 +6,7 @@ import { rateLimit } from '@/lib/rate-limit';
import crypto from 'crypto';
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
import { createBunnyUploadToken, verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags';
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
import { logError } from '@/lib/logger';
import { enforceStorageQuota } from '@/lib/storage-quota';
@@ -60,8 +60,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('Title is required');
}
if (!isBunnyUploadsFeatureEnabled()) {
return apiErrors.badRequest('Direct uploads are disabled by this host');
if (!isBunnyUploadsEnabled()) {
return apiErrors.badRequest('Bunny direct uploads are disabled by this host');
}
const quotaError = await enforceStorageQuota(project.workspace.ownerId, BigInt(0));
@@ -0,0 +1,298 @@
import { NextRequest } from 'next/server';
import { randomUUID } from 'crypto';
import { db } from '@/lib/db';
import { auth, checkProjectAccess } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { rateLimit } from '@/lib/rate-limit';
import {
createR2UploadToken,
parseR2UploadToken,
verifyR2UploadToken,
} from '@/lib/r2-upload-token';
import {
createPresignedImagePutUrl,
createPresignedVideoPutUrl,
deleteR2Object,
deleteVideoObject,
} from '@/lib/r2';
import { getMaxVideoUploadBytes, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
import {
buildVideoObjectKey,
getVideoExtensionFromMime,
resolveVideoContentType,
videoProxyPathFromFilename,
} from '@/lib/video-upload-validation';
import { logError } from '@/lib/logger';
import {
enforceStorageQuota,
releaseStorageReservation,
reserveStorageQuota,
} from '@/lib/storage-quota';
import { createR2UploadSession } from '@/lib/r2-upload-session';
type RouteParams = { params: Promise<{ projectId: string }> };
const VIDEO_RESERVATION_TTL_MS = 2 * 60 * 60 * 1000;
const THUMBNAIL_RESERVE_BYTES = BigInt(512 * 1024);
async function getProjectWithEditAccess(projectId: string, userId: string) {
const project = await db.project.findUnique({
where: { id: projectId },
select: {
id: true,
name: true,
ownerId: true,
workspaceId: true,
visibility: true,
workspace: { select: { ownerId: true } },
},
});
if (!project) return null;
const access = await checkProjectAccess(project, userId, { intent: 'manage' });
if (!access.canEdit) return null;
return project;
}
// POST /api/projects/[projectId]/videos/r2-init
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
if (!isS3VideoUploadsEnabled()) {
return apiErrors.badRequest('S3 video uploads are disabled by this host');
}
const project = await getProjectWithEditAccess(projectId, session.user.id);
if (!project) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json().catch(() => null);
const fileName = typeof body?.fileName === 'string' ? body.fileName.trim() : '';
const contentTypeInput = typeof body?.contentType === 'string' ? body.contentType.trim() : '';
const sizeBytesRaw = body?.sizeBytes;
if (!fileName) {
return apiErrors.badRequest('fileName is required');
}
let sizeBytes: bigint;
try {
sizeBytes = BigInt(sizeBytesRaw);
if (sizeBytes <= BigInt(0)) {
return apiErrors.badRequest('sizeBytes must be a positive integer');
}
} catch {
return apiErrors.badRequest('sizeBytes must be a positive integer');
}
const maxBytes = getMaxVideoUploadBytes();
if (sizeBytes > maxBytes) {
return apiErrors.badRequest('Video file exceeds the maximum allowed upload size');
}
const contentType = resolveVideoContentType(fileName, contentTypeInput);
if (!contentType) {
return apiErrors.badRequest('Unsupported video format');
}
const ext = getVideoExtensionFromMime(contentType);
if (!ext) {
return apiErrors.badRequest('Unsupported video format');
}
const quotaError = await enforceStorageQuota(
project.workspace.ownerId,
sizeBytes + THUMBNAIL_RESERVE_BYTES
);
if (quotaError) return quotaError;
const reserveResult = await reserveStorageQuota(
project.workspace.ownerId,
sizeBytes + THUMBNAIL_RESERVE_BYTES,
VIDEO_RESERVATION_TTL_MS
);
if ('error' in reserveResult) return reserveResult.error;
const fileId = randomUUID();
const filename = `${fileId}.${ext}`;
const objectKey = buildVideoObjectKey(filename);
const proxyUrl = videoProxyPathFromFilename(filename);
const thumbnailFilename = `${fileId}.jpg`;
const thumbnailObjectKey = `images/${thumbnailFilename}`;
const thumbnailProxyUrl = `/api/upload/image/${thumbnailFilename}`;
let presignedPutUrl: string;
let thumbnailPresignedPutUrl: string;
try {
[presignedPutUrl, thumbnailPresignedPutUrl] = await Promise.all([
createPresignedVideoPutUrl(objectKey, contentType, sizeBytes),
createPresignedImagePutUrl(thumbnailObjectKey, 'image/jpeg'),
]);
} catch (error) {
await releaseStorageReservation(reserveResult.reservationId, project.workspace.ownerId);
logError('Failed to create presigned video upload URL:', error);
return apiErrors.internalError('Failed to initialize video upload');
}
const uploadJti = randomUUID();
const expiresAt = new Date(Date.now() + VIDEO_RESERVATION_TTL_MS);
const uploadSession = await createR2UploadSession({
userId: session.user.id,
projectId,
billedUserId: project.workspace.ownerId,
objectKey,
thumbnailObjectKey,
declaredSizeBytes: sizeBytes,
contentType,
reservationId: reserveResult.reservationId,
uploadJti,
expiresAt,
});
const uploadToken = createR2UploadToken({
userId: session.user.id,
projectId,
objectKey,
sessionId: uploadSession.id,
tokenId: uploadJti,
thumbnailObjectKey,
});
const response = successResponse({
presignedPutUrl,
objectKey,
proxyUrl,
uploadToken,
reservationId: reserveResult.reservationId,
contentType,
thumbnailPresignedPutUrl,
thumbnailObjectKey,
thumbnailProxyUrl,
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error initializing R2 video upload:', error);
return apiErrors.internalError('Failed to initialize upload');
}
}
// DELETE /api/projects/[projectId]/videos/r2-init
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
if (!isS3VideoUploadsEnabled()) {
return apiErrors.badRequest('S3 video uploads are disabled by this host');
}
const project = await getProjectWithEditAccess(projectId, session.user.id);
if (!project) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json().catch(() => null);
const objectKey = typeof body?.objectKey === 'string' ? body.objectKey.trim() : '';
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
const thumbnailObjectKey =
typeof body?.thumbnailObjectKey === 'string' ? body.thumbnailObjectKey.trim() : '';
if (!objectKey || !uploadToken) {
return apiErrors.badRequest('objectKey and uploadToken are required');
}
const tokenPayload = parseR2UploadToken(uploadToken);
if (!tokenPayload) {
return apiErrors.forbidden('Invalid upload token');
}
const isValidUploadToken = verifyR2UploadToken(uploadToken, {
userId: session.user.id,
projectId,
objectKey,
sessionId: tokenPayload.sid,
tokenId: tokenPayload.jti,
});
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid upload token');
}
const uploadSession = await db.videoUploadSession.findFirst({
where: {
id: tokenPayload.sid,
status: 'INITIATED',
userId: session.user.id,
projectId,
objectKey,
uploadJti: tokenPayload.jti,
expiresAt: { gt: new Date() },
},
select: {
id: true,
reservationId: true,
billedUserId: true,
thumbnailObjectKey: true,
},
});
if (!uploadSession) {
return apiErrors.forbidden('Invalid upload token');
}
if (thumbnailObjectKey && thumbnailObjectKey !== uploadSession.thumbnailObjectKey) {
return apiErrors.badRequest('Invalid thumbnail object key');
}
const cancelled = await db.videoUploadSession.updateMany({
where: {
id: uploadSession.id,
status: 'INITIATED',
},
data: {
status: 'CANCELLED',
consumedAt: new Date(),
},
});
if (cancelled.count !== 1) {
return apiErrors.forbidden('Invalid upload token');
}
try {
await Promise.all([
deleteVideoObject(objectKey),
uploadSession.thumbnailObjectKey.startsWith('images/')
? deleteR2Object(uploadSession.thumbnailObjectKey)
: Promise.resolve(),
]);
} catch (error) {
logError('Failed to delete pending R2 video object:', error);
}
await releaseStorageReservation(uploadSession.reservationId, uploadSession.billedUserId);
const response = successResponse({ message: 'Pending upload cleaned up' });
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error cleaning up pending R2 video upload:', error);
return apiErrors.internalError('Failed to cleanup pending upload');
}
}
+116 -28
View File
@@ -1,11 +1,13 @@
import { NextRequest } from 'next/server';
import { db } from '@/lib/db';
import { auth, checkProjectAccess } from '@/lib/auth';
import { validateUrl, validateOptionalUrl } from '@/lib/validation';
import { validateUrl, validateOptionalUrlOrAppPath } from '@/lib/validation';
import { toJsonSafe } from '@/lib/json-serialize';
import { rateLimit } from '@/lib/rate-limit';
import { notifyProjectOwner } from '@/lib/notifications';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
import { logError } from '@/lib/logger';
type RouteParams = { params: Promise<{ projectId: string }> };
@@ -97,19 +99,30 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
thumbnailUrl,
duration,
uploadToken,
objectKey,
} = body;
if (!title || !videoUrl) {
return apiErrors.badRequest('Title and video URL are required');
}
// Validate URLs use safe schemes (http/https only)
const videoUrlError = validateUrl(videoUrl, 'Video URL');
if (videoUrlError) {
return apiErrors.badRequest(videoUrlError);
const normalizedProviderIdEarly =
typeof providerId === 'string' && providerId.trim()
? providerId.trim().toLowerCase()
: 'youtube';
if (normalizedProviderIdEarly === 'r2') {
if (!videoUrl.startsWith('/api/upload/video/')) {
return apiErrors.badRequest('Video URL must be a valid upload path');
}
} else {
const videoUrlError = validateUrl(videoUrl, 'Video URL');
if (videoUrlError) {
return apiErrors.badRequest(videoUrlError);
}
}
const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Thumbnail URL');
const thumbnailUrlError = validateOptionalUrlOrAppPath(thumbnailUrl, 'Thumbnail URL');
if (thumbnailUrlError) {
return apiErrors.badRequest(thumbnailUrlError);
}
@@ -121,6 +134,14 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const normalizedVideoId = typeof videoId === 'string' ? videoId.trim() : '';
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
let versionSizeBytes = BigInt(0);
let finalizedR2Session: {
sessionId: string;
reservationId: string | null;
billedUserId: string;
thumbnailProxyUrl: string;
} | null = null;
if (normalizedProviderId === 'bunny') {
if (!normalizedVideoId || !normalizedUploadToken) {
return apiErrors.badRequest('Bunny uploads must include videoId and uploadToken');
@@ -134,8 +155,42 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
} else if (normalizedProviderId === 'r2') {
const normalizedObjectKey = typeof objectKey === 'string' ? objectKey.trim() : '';
if (!normalizedObjectKey || !normalizedUploadToken) {
return apiErrors.badRequest('R2 uploads must include objectKey and uploadToken');
}
const finalizeResult = await finalizeR2VideoUpload({
userId: session.user.id,
projectId,
videoUrl,
objectKey: normalizedObjectKey,
uploadToken: normalizedUploadToken,
});
if (!finalizeResult.ok) {
if (finalizeResult.status === 403) {
return apiErrors.forbidden(finalizeResult.error);
}
return apiErrors.badRequest(finalizeResult.error);
}
versionSizeBytes = finalizeResult.sizeBytes;
finalizedR2Session = {
sessionId: finalizeResult.sessionId,
reservationId: finalizeResult.reservationId,
billedUserId: finalizeResult.billedUserId,
thumbnailProxyUrl: finalizeResult.thumbnailProxyUrl,
};
}
const persistedVideoId =
normalizedProviderId === 'r2'
? typeof objectKey === 'string'
? objectKey.trim()
: ''
: normalizedVideoId;
// Get the next position
const lastVideo = await db.video.findFirst({
where: { projectId },
@@ -144,29 +199,62 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const nextPosition = (lastVideo?.position ?? -1) + 1;
// Create video with initial version
const video = await db.video.create({
data: {
title: title.trim(),
description: description?.trim() || null,
position: nextPosition,
projectId,
versions: {
create: {
versionNumber: 1,
providerId: normalizedProviderId,
videoId: normalizedVideoId,
originalUrl: videoUrl,
title: title.trim(),
thumbnailUrl: thumbnailUrl || null,
duration: duration || null,
isActive: true,
const video = await db.$transaction(async (tx) => {
if (finalizedR2Session) {
const consumed = await tx.videoUploadSession.updateMany({
where: {
id: finalizedR2Session.sessionId,
status: 'INITIATED',
userId: session.user.id,
projectId,
objectKey: persistedVideoId,
},
data: {
status: 'FINALIZED',
consumedAt: new Date(),
},
});
if (consumed.count !== 1) {
throw new Error('Upload session already consumed');
}
if (finalizedR2Session.reservationId) {
await tx.uploadReservation.deleteMany({
where: {
id: finalizedR2Session.reservationId,
billedUserId: finalizedR2Session.billedUserId,
},
});
}
}
return tx.video.create({
data: {
title: title.trim(),
description: description?.trim() || null,
position: nextPosition,
projectId,
versions: {
create: {
versionNumber: 1,
providerId: normalizedProviderId,
videoId: persistedVideoId,
originalUrl: videoUrl,
title: title.trim(),
thumbnailUrl:
normalizedProviderId === 'r2'
? (finalizedR2Session?.thumbnailProxyUrl ?? '/placeholder-video-thumbnail.png')
: thumbnailUrl || null,
duration: duration || null,
sizeBytes: versionSizeBytes,
isActive: true,
},
},
},
},
include: {
versions: true,
_count: { select: { versions: true } },
},
include: {
versions: true,
_count: { select: { versions: true } },
},
});
});
// Notify project owner (fire-and-forget, skip if they added it themselves)
@@ -181,7 +269,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}).catch((err) => logError('Notification failed:', err));
}
const response = successResponse(video, 201);
const response = successResponse(toJsonSafe(video), 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error creating video:', error);
+5 -1
View File
@@ -37,6 +37,9 @@ const MIME_ALIASES: Record<string, string> = {
'audio/x-pn-wav': 'audio/wav',
'audio/mp3': 'audio/mpeg',
'audio/x-mpeg': 'audio/mpeg',
// Some browsers report MediaRecorder audio-only blobs as video/* containers.
'video/webm': 'audio/webm',
'video/mp4': 'audio/mp4',
};
// Map canonical MIME to fallback file extension
@@ -231,7 +234,8 @@ export async function POST(request: NextRequest) {
await releaseStorageReservation(reservationId);
return apiErrors.badRequest('File content does not match an audio format');
}
if (!hasValidAudioMagicBytes(buffer.slice(0, 16), contentType)) {
const hasValidMagicBytes = hasValidAudioMagicBytes(buffer.slice(0, 16), contentType);
if (!hasValidMagicBytes) {
await releaseStorageReservation(reservationId);
return apiErrors.badRequest('File content does not match the declared audio format');
}
+24 -4
View File
@@ -48,23 +48,43 @@ export async function GET(
projectId: true,
project: { select: projectSelect },
} as const;
const [comment, videoAsset, session] = await Promise.all([
db.comment.findFirst({
const [comments, videoAssets, videoVersions, session] = await Promise.all([
db.comment.findMany({
where: { imageUrl },
take: 2,
select: {
version: {
select: { video: { select: videoSelect } },
},
},
}),
db.videoAsset.findFirst({
db.videoAsset.findMany({
where: { sourceUrl: imageUrl },
take: 2,
select: { video: { select: videoSelect } },
}),
db.videoVersion.findMany({
where: { thumbnailUrl: imageUrl },
take: 2,
select: { video: { select: videoSelect } },
}),
auth(),
]);
const video = comment?.version?.video ?? videoAsset?.video ?? null;
const uniqueVideos = new Map<string, (typeof videoAssets)[number]['video']>();
comments.forEach((comment) => {
if (comment.version?.video) uniqueVideos.set(comment.version.video.id, comment.version.video);
});
videoAssets.forEach((videoAsset) => uniqueVideos.set(videoAsset.video.id, videoAsset.video));
videoVersions.forEach((videoVersion) =>
uniqueVideos.set(videoVersion.video.id, videoVersion.video)
);
if (uniqueVideos.size > 1) {
return apiErrors.forbidden('Access denied');
}
const video = uniqueVideos.values().next().value ?? null;
if (!video) {
return apiErrors.forbidden('Access denied');
}
+112
View File
@@ -0,0 +1,112 @@
import { NextRequest } from 'next/server';
import { auth, checkProjectAccess } from '@/lib/auth';
import { db } from '@/lib/db';
import { validateShareLinkAccess } from '@/lib/share-links';
import { getShareSessionFromRequest } from '@/lib/share-session';
import { apiErrors } from '@/lib/api-response';
import { proxyR2MediaObject } from '@/lib/r2-media-proxy';
import { buildVideoObjectKey, SAFE_VIDEO_BASENAME } from '@/lib/video-upload-validation';
import { logError } from '@/lib/logger';
const VIDEO_CONTENT_TYPE_MAP: 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',
};
function getVideoContentType(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || '';
return VIDEO_CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ filename: string }> }
) {
try {
const { filename } = await params;
if (!SAFE_VIDEO_BASENAME.test(filename)) {
return apiErrors.badRequest('Invalid filename');
}
const originalUrl = `/api/upload/video/${filename}`;
const projectSelect = {
id: true,
ownerId: true,
workspaceId: true,
visibility: true,
} as const;
const videoSelect = {
id: true,
projectId: true,
project: { select: projectSelect },
} as const;
const [versions, session] = await Promise.all([
db.videoVersion.findMany({
where: { originalUrl },
take: 2,
select: {
id: true,
video: { select: videoSelect },
},
}),
auth(),
]);
const uniqueVideos = new Map<string, (typeof versions)[number]['video']>();
for (const version of versions) {
uniqueVideos.set(version.video.id, version.video);
}
if (uniqueVideos.size > 1) {
return apiErrors.forbidden('Access denied');
}
const video = uniqueVideos.values().next().value ?? null;
if (!video) {
return apiErrors.forbidden('Access denied');
}
const access = await checkProjectAccess(video.project, session?.user?.id);
if (!access.hasAccess) {
const shareSession = getShareSessionFromRequest(request, video.id);
const shareAccess = shareSession
? await validateShareLinkAccess({
token: shareSession.token,
projectId: video.projectId,
videoId: video.id,
requiredPermission: 'VIEW',
passwordVerified: shareSession.passwordVerified,
})
: {
hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: false,
};
if (!shareAccess.hasAccess) {
return apiErrors.forbidden('Access denied');
}
}
const key = buildVideoObjectKey(filename);
return proxyR2MediaObject({
request,
key,
fallbackContentType: getVideoContentType(filename),
cacheControl: 'private, max-age=3600',
internalErrorMessage: 'Failed to load video',
});
} catch (error) {
logError('Error serving video upload:', error);
return apiErrors.internalError('Failed to load video');
}
}