diff --git a/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts
index d065f7b..9683386 100644
--- a/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts
+++ b/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts
@@ -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);
diff --git a/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts b/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts
index bac0479..44d60ea 100644
--- a/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts
+++ b/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts
@@ -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);
diff --git a/app/api/projects/[projectId]/videos/bunny-init/route.ts b/app/api/projects/[projectId]/videos/bunny-init/route.ts
index 6b60c12..638d006 100644
--- a/app/api/projects/[projectId]/videos/bunny-init/route.ts
+++ b/app/api/projects/[projectId]/videos/bunny-init/route.ts
@@ -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));
diff --git a/app/api/projects/[projectId]/videos/r2-init/route.ts b/app/api/projects/[projectId]/videos/r2-init/route.ts
new file mode 100644
index 0000000..8944b15
--- /dev/null
+++ b/app/api/projects/[projectId]/videos/r2-init/route.ts
@@ -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');
+ }
+}
diff --git a/app/api/projects/[projectId]/videos/route.ts b/app/api/projects/[projectId]/videos/route.ts
index 0047b93..66d9838 100644
--- a/app/api/projects/[projectId]/videos/route.ts
+++ b/app/api/projects/[projectId]/videos/route.ts
@@ -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);
diff --git a/app/api/upload/audio/route.ts b/app/api/upload/audio/route.ts
index 9d3b942..2e3fcf6 100644
--- a/app/api/upload/audio/route.ts
+++ b/app/api/upload/audio/route.ts
@@ -37,6 +37,9 @@ const MIME_ALIASES: Record
= {
'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');
}
diff --git a/app/api/upload/image/[filename]/route.ts b/app/api/upload/image/[filename]/route.ts
index 771bae6..ebee3bc 100644
--- a/app/api/upload/image/[filename]/route.ts
+++ b/app/api/upload/image/[filename]/route.ts
@@ -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();
+ 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');
}
diff --git a/app/api/upload/video/[filename]/route.ts b/app/api/upload/video/[filename]/route.ts
new file mode 100644
index 0000000..bacab5b
--- /dev/null
+++ b/app/api/upload/video/[filename]/route.ts
@@ -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 = {
+ 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();
+ 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');
+ }
+}
diff --git a/bun.lock b/bun.lock
index 04f3de5..88894c8 100644
--- a/bun.lock
+++ b/bun.lock
@@ -6,7 +6,8 @@
"name": "openframe",
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
- "@aws-sdk/client-s3": "^3.1029.0",
+ "@aws-sdk/client-s3": "3.1054.0",
+ "@aws-sdk/s3-request-presigner": "3.1054.0",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
"bcryptjs": "^3.0.3",
@@ -16,7 +17,7 @@
"gsap": "^3.14.2",
"hls.js": "^1.6.15",
"lucide-react": "^0.563.0",
- "next": "16.2.3",
+ "next": "16.2.6",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"nodemailer": "^8.0.5",
@@ -82,69 +83,53 @@
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
- "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1029.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.27", "@aws-sdk/credential-provider-node": "^3.972.30", "@aws-sdk/middleware-bucket-endpoint": "^3.972.9", "@aws-sdk/middleware-expect-continue": "^3.972.9", "@aws-sdk/middleware-flexible-checksums": "^3.974.7", "@aws-sdk/middleware-host-header": "^3.972.9", "@aws-sdk/middleware-location-constraint": "^3.972.9", "@aws-sdk/middleware-logger": "^3.972.9", "@aws-sdk/middleware-recursion-detection": "^3.972.10", "@aws-sdk/middleware-sdk-s3": "^3.972.28", "@aws-sdk/middleware-ssec": "^3.972.9", "@aws-sdk/middleware-user-agent": "^3.972.29", "@aws-sdk/region-config-resolver": "^3.972.11", "@aws-sdk/signature-v4-multi-region": "^3.996.16", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-endpoints": "^3.996.6", "@aws-sdk/util-user-agent-browser": "^3.972.9", "@aws-sdk/util-user-agent-node": "^3.973.15", "@smithy/config-resolver": "^4.4.14", "@smithy/core": "^3.23.14", "@smithy/eventstream-serde-browser": "^4.2.13", "@smithy/eventstream-serde-config-resolver": "^4.3.13", "@smithy/eventstream-serde-node": "^4.2.13", "@smithy/fetch-http-handler": "^5.3.16", "@smithy/hash-blob-browser": "^4.2.14", "@smithy/hash-node": "^4.2.13", "@smithy/hash-stream-node": "^4.2.13", "@smithy/invalid-dependency": "^4.2.13", "@smithy/md5-js": "^4.2.13", "@smithy/middleware-content-length": "^4.2.13", "@smithy/middleware-endpoint": "^4.4.29", "@smithy/middleware-retry": "^4.5.0", "@smithy/middleware-serde": "^4.2.17", "@smithy/middleware-stack": "^4.2.13", "@smithy/node-config-provider": "^4.3.13", "@smithy/node-http-handler": "^4.5.2", "@smithy/protocol-http": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.45", "@smithy/util-defaults-mode-node": "^4.2.49", "@smithy/util-endpoints": "^3.3.4", "@smithy/util-middleware": "^4.2.13", "@smithy/util-retry": "^4.3.0", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.15", "tslib": "^2.6.2" } }, "sha512-OuA8RZTxsAaHDcI25j2NGLMaYFI2WpJdDzK3uLmVBmaHwjQKQZOUDVVBcln8pNo3IgkY+HRSJhRR4/xlM//UyQ=="],
+ "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1054.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.14", "@aws-sdk/credential-provider-node": "^3.972.45", "@aws-sdk/middleware-bucket-endpoint": "^3.972.16", "@aws-sdk/middleware-expect-continue": "^3.972.13", "@aws-sdk/middleware-flexible-checksums": "^3.974.22", "@aws-sdk/middleware-location-constraint": "^3.972.11", "@aws-sdk/middleware-sdk-s3": "^3.972.43", "@aws-sdk/middleware-ssec": "^3.972.11", "@aws-sdk/signature-v4-multi-region": "^3.996.29", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-2ue7uVqaHYX4rytkcrLySYU/m/ZlRbL8KojWefbR24B0/TcFkqN2IovpBFrnmla/dtZAn9eVSlhHeEddOghZ5w=="],
- "@aws-sdk/core": ["@aws-sdk/core@3.973.27", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@aws-sdk/xml-builder": "^3.972.17", "@smithy/core": "^3.23.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/property-provider": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/signature-v4": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A=="],
+ "@aws-sdk/core": ["@aws-sdk/core@3.974.14", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@aws-sdk/xml-builder": "^3.972.26", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.3", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-ppamm04uoj3hhNO5IlQSs5D6rWX1fWkzcn6a4pZrojk8Y6ObY9wzLDdT/Eq3gv6O9hOebi9tYTNB8b8fQj9XJw=="],
- "@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.6", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-NMbiqKdruhwwgI6nzBVe2jWMkXjaoQz2YOs3rFX+2F3gGyrJDkDPwMpV/RsTFeq2vAQ055wZNtOXFK4NYSkM8g=="],
+ "@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-P+QGozmXn2mZZI7sDgk+aUm+RTI61MPSFB+Ir2vjEjEbEsE4e7hYtzrDvAUxZy9ko81h53e11+F/GYlvwDkaOQ=="],
- "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.25", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-6QfI0wv4jpG5CrdO/AO0JfZ2ux+tKwJPrUwmvxXF50vI5KIypKVGNF6b4vlkYEnKumDTI1NX2zUBi8JoU5QU3A=="],
+ "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.40", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-jjT0p0Y7KZtcvExYiPCLJnqM9lkXDV1KBEg/13OE2DXv/9batzlyJHVKUEnRNJccY0O2Sul17E1su38CgdBhGQ=="],
- "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.27", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@smithy/fetch-http-handler": "^5.3.16", "@smithy/node-http-handler": "^4.5.2", "@smithy/property-provider": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-stream": "^4.5.22", "tslib": "^2.6.2" } }, "sha512-3V3Usj9Gs93h865DqN4M2NWJhC5kXU9BvZskfN3+69omuYlE3TZxOEcVQtBGLOloJB7BVfJKXVLqeNhOzHqSlQ=="],
+ "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-+3fsKtWybe5BjKEUA3/07oh7Ayfd82IED2+gyyaVfS/4PU78E3TaOQxSGOJ1t7Imefoidw/ne9QA7apX8wEnJg=="],
- "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/credential-provider-env": "^3.972.25", "@aws-sdk/credential-provider-http": "^3.972.27", "@aws-sdk/credential-provider-login": "^3.972.29", "@aws-sdk/credential-provider-process": "^3.972.25", "@aws-sdk/credential-provider-sso": "^3.972.29", "@aws-sdk/credential-provider-web-identity": "^3.972.29", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/credential-provider-imds": "^4.2.13", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-SiBuAnXecCbT/OpAf3vqyI/AVE3mTaYr9ShXLybxZiPLBiPCCOIWSGAtYYGQWMRvobBTiqOewaB+wcgMMZI2Aw=="],
+ "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/credential-provider-env": "^3.972.40", "@aws-sdk/credential-provider-http": "^3.972.42", "@aws-sdk/credential-provider-login": "^3.972.44", "@aws-sdk/credential-provider-process": "^3.972.40", "@aws-sdk/credential-provider-sso": "^3.972.44", "@aws-sdk/credential-provider-web-identity": "^3.972.44", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-gZFw5wBefCIPg9vpT+gV5FdhfNKhYTVDZa1IsZCcn3SRoYUOJ/E05vwIogkJoonqBL0ttBGi5vhthX7xceekRg=="],
- "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-OGOslTbOlxXexKMqhxCEbBQbUIfuhGxU5UXw3Fm56ypXHvrXH4aTt/xb5Y884LOoteP1QST1lVZzHfcTnWhiPQ=="],
+ "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-QqEGHfQeZgUDqh7zpqHufrZ8T644ELEWvB+4gUdewLyRw4IRF+6CJqeQuRWqucZdQzoQeMh7fNAD9BWxFAdNig=="],
- "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.30", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.25", "@aws-sdk/credential-provider-http": "^3.972.27", "@aws-sdk/credential-provider-ini": "^3.972.29", "@aws-sdk/credential-provider-process": "^3.972.25", "@aws-sdk/credential-provider-sso": "^3.972.29", "@aws-sdk/credential-provider-web-identity": "^3.972.29", "@aws-sdk/types": "^3.973.7", "@smithy/credential-provider-imds": "^4.2.13", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-FMnAnWxc8PG+ZrZ2OBKzY4luCUJhe9CG0B9YwYr4pzrYGLXBS2rl+UoUvjGbAwiptxRL6hyA3lFn03Bv1TLqTw=="],
+ "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.45", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.40", "@aws-sdk/credential-provider-http": "^3.972.42", "@aws-sdk/credential-provider-ini": "^3.972.44", "@aws-sdk/credential-provider-process": "^3.972.40", "@aws-sdk/credential-provider-sso": "^3.972.44", "@aws-sdk/credential-provider-web-identity": "^3.972.44", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-3YCv52ExXIRz3LAVNysevd+s7akSpg9dl39v9LJ7dOQH+s5rHi3jMZYQyxwMmglxQGMuzYRfQ0o1VSP2UOlIRw=="],
- "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.25", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-HR7ynNRdNhNsdVCOCegy1HsfsRzozCOPtD3RzzT1JouuaHobWyRfJzCBue/3jP7gECHt+kQyZUvwg/cYLWurNQ=="],
+ "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.40", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-cXaozlgJCOwmE6D7x4npcPdyk7kiFZdrGjN3D6tXXtItJJMNGPafDfAJn4YQmciMooG/X+b0Y6RTqdVVMx26jg=="],
- "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/token-providers": "3.1026.0", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-HWv4SEq3jZDYPlwryZVef97+U8CxxRos5mK8sgGO1dQaFZpV5giZLzqGE5hkDmh2csYcBO2uf5XHjPTpZcJlig=="],
+ "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/token-providers": "3.1054.0", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-YePoj5kQuPmE0MHnyftXCfsO8ZSBd2kDr50XEIUrdejSbGFlayYvUuCohdb8drhGhPm6b65o7H1eC26EZhwUvA=="],
- "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-PdMBza1WEKEUPFEmMGCfnU2RYCz9MskU2e8JxjyUOsMKku7j9YaDKvbDi2dzC0ihFoM6ods2SbhfAAro+Gwlew=="],
+ "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Ys/JJe++8Z2Y5meR1taMBaVcrGBA0/XsVTQR+qOKZbdNyg+8Jlv5rYZSwh8SqEHY00goSOZy7PHzZ2rLNQxDLg=="],
- "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-COToYKgquDyligbcAep7ygs48RK+mwe/IYprq4+TSrVFzNOYmzWvHf6werpnKV5VYpRiwdn+Wa5ZXkPqLVwcTg=="],
+ "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.16", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-FhasMTBDBmMN7EEa1hUeHwo5p5Mv3Dm8w0VEbdXX/6ola/uyhRuJt8zGkH09mLTmab20USTzEpPqyqEoe1MqNg=="],
- "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-V/FNCjFxnh4VGu+HdSiW4Yg5GELihA1MIDSAdsEPvuayXBVmr0Jaa6jdLAZLH38KYXl/vVjri9DQJWnTAujHEA=="],
+ "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.13", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-sHiqIFg8o2ipT7t40B89Vj0ubSUtY6OSt/+Ee/OXhHch5K4+81zP2+QX8Lkc/nJ2QSmCySxOke7TEbmX69fe2g=="],
- "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.27", "@aws-sdk/crc64-nvme": "^3.972.6", "@aws-sdk/types": "^3.973.7", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-middleware": "^4.2.13", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-uU4/ch2CLHB8Phu1oTKnnQ4e8Ujqi49zEnQYBhWYT53zfFvtJCdGsaOoypBr8Fm/pmCBssRmGoIQ4sixgdLP9w=="],
+ "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.22", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.14", "@aws-sdk/crc64-nvme": "^3.972.9", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-ot1kZ1JGHUxcXPOARhej/n/+Odfx9VPt60pNrUq8Lf/U2blIF3+uj5v56gw76VD70dZvrfeLNo9jKz6pQJfOlA=="],
- "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-je5vRdNw4SkuTnmRbFZLdye4sQ0faLt8kwka5wnnSU30q1mHO4X+idGEJOOE+Tn1ME7Oryn05xxkDvIb3UaLaQ=="],
+ "@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hkfspNUP4criAH6ton6BGKgnm5dZx+7bUOy1YqlTfejDeUPAM23D81q/IX+hdlS3KUsfwGz5ADTqZWKBEUpf4A=="],
- "@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-TyfOi2XNdOZpNKeTJwRUsVAGa+14nkyMb2VVGG+eDgcWG/ed6+NUo72N3hT6QJioxym80NSinErD+LBRF0Ir1w=="],
+ "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/signature-v4-multi-region": "^3.996.29", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-CBmixMY36JdAdt9ALgm7yVlvOXGUCHt9Z2kn5p9XVO5StO6HCH+cayV7YYV1CDLsXvVyebaXgBmif9wHoxCeNA=="],
- "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-HsVgDrruhqI28RkaXALm8grJ7Agc1wF6Et0xh6pom8NdO2VdO/SD9U/tPwUjewwK/pVoka+EShBxyCvgsPCtog=="],
+ "@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-7PQvGNhtveKlvVqNahqWx5yrwxP7ecwAoB1dYBf8eKwfo2tzzCbNnW+q2nO3N066ktQaB4iBQbDRWtizm+amoQ=="],
- "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-RVQQbq5orQ/GHUnXvqEOj2HHPBJm+mM+ySwZKS5UaLBwra5ugRtiH09PLUoOZRl7a1YzaOzXSuGbn9iD5j60WQ=="],
+ "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.12", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.14", "@aws-sdk/signature-v4-multi-region": "^3.996.29", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Js2VYaCM269feB0cs0cGmlIhdOgT9aMqzdBx68lCy6kVCYfzr0T36ovUFDvfUmatkuBeyBJhCwaLBh7P8meH5Q=="],
- "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.28", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/signature-v4": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-qJHcJQH9UNPUrnPlRtCozKjtqAaypQ5IgQxTNoPsVYIQeuwNIA8Rwt3NvGij1vCDYDfCmZaPLpnJEHlZXeFqmg=="],
+ "@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.1054.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/signature-v4-multi-region": "^3.996.29", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-rgSDc0LwzM1yUL/UouFLR72HV5lhgdQRQlp8WWWot+q3nhBYrj+mklreWh28q9bGkgrNGWrcpMRp4Y3rC4sxVw=="],
- "@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-wSA2BR7L0CyBNDJeSrleIIzC+DzL93YNTdfU0KPGLiocK6YsRv1nPAzPF+BFSdcs0Qa5ku5Kcf4KvQcWwKGenQ=="],
+ "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.29", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Few9FoQqOt/0KSvZYP+qdW0dfOhfQ9N+gl2UUDvCPW6mkPKHli9LMbKxWj+wZ5zKPaOoqxuR3Hhy3OTpndkfSw=="],
- "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-endpoints": "^3.996.6", "@smithy/core": "^3.23.14", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-retry": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-f/sIRzuTfEjg6NsbMYvye2VsmnQoNgntntleQyx5uGacUYzszbfIlO3GcI6G6daWUmTm0IDZc11qMHWwF0o0mQ=="],
+ "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1054.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hG9YKApmZOw+drJ9Nuoaf/OvC8e5W1+3eoLeN5p2uVCZRWsv27teIS0b4kiH6Sfv3WMmamqYJxmE2WMwyp/L/A=="],
- "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.19", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.27", "@aws-sdk/middleware-host-header": "^3.972.9", "@aws-sdk/middleware-logger": "^3.972.9", "@aws-sdk/middleware-recursion-detection": "^3.972.10", "@aws-sdk/middleware-user-agent": "^3.972.29", "@aws-sdk/region-config-resolver": "^3.972.11", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-endpoints": "^3.996.6", "@aws-sdk/util-user-agent-browser": "^3.972.9", "@aws-sdk/util-user-agent-node": "^3.973.15", "@smithy/config-resolver": "^4.4.14", "@smithy/core": "^3.23.14", "@smithy/fetch-http-handler": "^5.3.16", "@smithy/hash-node": "^4.2.13", "@smithy/invalid-dependency": "^4.2.13", "@smithy/middleware-content-length": "^4.2.13", "@smithy/middleware-endpoint": "^4.4.29", "@smithy/middleware-retry": "^4.5.0", "@smithy/middleware-serde": "^4.2.17", "@smithy/middleware-stack": "^4.2.13", "@smithy/node-config-provider": "^4.3.13", "@smithy/node-http-handler": "^4.5.2", "@smithy/protocol-http": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.45", "@smithy/util-defaults-mode-node": "^4.2.49", "@smithy/util-endpoints": "^3.3.4", "@smithy/util-middleware": "^4.2.13", "@smithy/util-retry": "^4.3.0", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-uFkmCDXvmQYLanlYdOFS0+MQWkrj9wPMt/ZCc/0J0fjPim6F5jBVBmEomvGY/j77ILW6GTPwN22Jc174Mhkw6Q=="],
-
- "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/config-resolver": "^4.4.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-6Q8B1dcx6BBqUTY1Mc/eROKA0FImEEY5VPSd6AGPEUf0ErjExz4snVqa9kNJSoVDV1rKaNf3qrWojgcKW+SdDg=="],
-
- "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.16", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.28", "@aws-sdk/types": "^3.973.7", "@smithy/protocol-http": "^5.3.13", "@smithy/signature-v4": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-EMdXYB4r/k5RWq86fugjRhid5JA+Z6MpS7n4sij4u5/C+STrkvuf9aFu41rJA9MjUzxCLzv8U2XL8cH2GSRYpQ=="],
-
- "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1026.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-Ieq/HiRrbEtrYP387Nes0XlR7H1pJiJOZKv+QyQzMYpvTiDs0VKy2ZB3E2Zf+aFovWmeE7lRE4lXyF7dYM6GgA=="],
-
- "@aws-sdk/types": ["@aws-sdk/types@3.973.7", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg=="],
-
- "@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
-
- "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-endpoints": "^3.3.4", "tslib": "^2.6.2" } }, "sha512-2nUQ+2ih7CShuKHpGSIYvvAIOHy52dOZguYG36zptBukhw6iFwcvGfG0tes0oZFWQqEWvgZe9HLWaNlvXGdOrg=="],
+ "@aws-sdk/types": ["@aws-sdk/types@3.973.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="],
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.4", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog=="],
- "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-sn/LMzTbGjYqCCF24390WxPd6hkpoSptiUn5DzVp4cD71yqw+yGEGm1YCxyEoPXyc8qciM8UzLJcZBFslxo5Uw=="],
-
- "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.15", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.29", "@aws-sdk/types": "^3.973.7", "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-fYn3s9PtKdgQkczGZCFMgkNEe8aq1JCVbnRqjqN9RSVW43xn2RV9xdcZ3z01a48Jpkuh/xCmBKJxdLOo4Ozg7w=="],
-
- "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.17", "", { "dependencies": { "@smithy/types": "^4.14.0", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-Ra7hjqAZf1OXRRMueB13qex7mFJRDK/pgCvdSFemXBT8KCGnQDPoKzHY1SjN+TjJVmnpSF14W5tJ1vDamFu+Gg=="],
+ "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.26", "", { "dependencies": { "@smithy/types": "^4.14.2", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g=="],
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="],
@@ -380,25 +365,25 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
- "@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="],
+ "@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="],
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.1.6", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ=="],
- "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="],
+ "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="],
- "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="],
+ "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="],
- "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="],
+ "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="],
- "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="],
+ "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="],
- "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="],
+ "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="],
- "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="],
+ "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="],
- "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="],
+ "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="],
- "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="],
+ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="],
"@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
@@ -406,6 +391,8 @@
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
+ "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
+
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -574,105 +561,23 @@
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
- "@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="],
+ "@smithy/core": ["@smithy/core@3.24.4", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw=="],
- "@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.3", "", { "dependencies": { "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw=="],
+ "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.4", "", { "dependencies": { "@smithy/core": "^3.24.4", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA=="],
- "@smithy/config-resolver": ["@smithy/config-resolver@4.4.14", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.4", "@smithy/util-middleware": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-N55f8mPEccpzKetUagdvmAy8oohf0J5cuj9jLI1TaSceRlq0pJsIZepY3kmAXAhyxqXPV6hDerDQhqQPKWgAoQ=="],
+ "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.4", "", { "dependencies": { "@smithy/core": "^3.24.4", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-qM7AUKI4G6d7lNgaZD3lA1tWSolh5r6gcixfTZAPstVURfjIbvreVTPz+994M0yC3HbX4YYhDRgr31Xy3XwWOQ=="],
- "@smithy/core": ["@smithy/core@3.23.14", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg=="],
+ "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
- "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.13", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.13", "@smithy/property-provider": "^4.2.13", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-wboCPijzf6RJKLOvnjDAiBxGSmSnGXj35o5ZAWKDaHa/cvQ5U3ZJ13D4tMCE8JG4dxVAZFy/P0x/V9CwwdfULQ=="],
+ "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.4", "", { "dependencies": { "@smithy/core": "^3.24.4", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ=="],
- "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.13", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ=="],
+ "@smithy/signature-v4": ["@smithy/signature-v4@5.4.4", "", { "dependencies": { "@smithy/core": "^3.24.4", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q=="],
- "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.13", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-wwybfcOX0tLqCcBP378TIU9IqrDuZq/tDV48LlZNydMpCnqnYr+hWBAYbRE+rFFf/p7IkDJySM3bgiMKP2ihPg=="],
+ "@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="],
- "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-ied1lO559PtAsMJzg2TKRlctLnEi1PfkNeMMpdwXDImk1zV9uvS/Oxoy/vcy9uv1GKZAjDAB5xT6ziE9fzm5wA=="],
+ "@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
- "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.13", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-hFyK+ORJrxAN3RYoaD6+gsGDQjeix8HOEkosoajvXYZ4VeqonM3G4jd9IIRm/sWGXUKmudkY9KdYjzosUqdM8A=="],
-
- "@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.13", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-kRrq4EKLGeOxhC2CBEhRNcu1KSzNJzYY7RK3S7CxMPgB5dRrv55WqQOtRwQxQLC04xqORFLUgnDlc6xrNUULaA=="],
-
- "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.16", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/querystring-builder": "^4.2.13", "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-nYDRUIvNd4mFmuXraRWt6w5UsZTNqtj4hXJA/iiOD4tuseIdLP9Lq38teH/SZTcIFCa2f+27o7hYpIsWktJKEQ=="],
-
- "@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.14", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-rtQ5es8r/5v4rav7q5QTsfx9CtCyzrz/g7ZZZBH2xtMmd6G/KQrLOWfSHTvFOUPlVy59RQvxeBYJaLRoybMEyA=="],
-
- "@smithy/hash-node": ["@smithy/hash-node@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-4/oy9h0jjmY80a2gOIo75iLl8TOPhmtx4E2Hz+PfMjvx/vLtGY4TMU/35WRyH2JHPfT5CVB38u4JRow7gnmzJA=="],
-
- "@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-WdQ7HwUjINXETeh6dqUeob1UHIYx8kAn9PSp1HhM2WWegiZBYVy2WXIs1lB07SZLan/udys9SBnQGt9MQbDpdg=="],
-
- "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-jvC0RB/8BLj2SMIkY0Npl425IdnxZJxInpZJbu563zIRnVjpDMXevU3VMCRSabaLB0kf/eFIOusdGstrLJ8IDg=="],
-
- "@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="],
-
- "@smithy/md5-js": ["@smithy/md5-js@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-cNm7I9NXolFxtS20ojROddOEpSAeI1Obq6pd1Kj5HtHws3s9Fkk8DdHDfQSs5KuxCewZuVK6UqrJnfJmiMzDuQ=="],
-
- "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.13", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-IPMLm/LE4AZwu6qiE8Rr8vJsWhs9AtOdySRXrOM7xnvclp77Tyh7hMs/FRrMf26kgIe67vFJXXOSmVxS7oKeig=="],
-
- "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.29", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/middleware-serde": "^4.2.17", "@smithy/node-config-provider": "^4.3.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-middleware": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-R9Q/58U+qBiSARGWbAbFLczECg/RmysRksX6Q8BaQEpt75I7LI6WGDZnjuC9GXSGKljEbA7N118LhGaMbfrTXw=="],
-
- "@smithy/middleware-retry": ["@smithy/middleware-retry@4.5.1", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/service-error-classification": "^4.2.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-middleware": "^4.2.13", "@smithy/util-retry": "^4.3.1", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-/zY+Gp7Qj2D2hVm3irkCyONER7E9MiX3cUUm/k2ZmhkzZkrPgwVS4aJ5NriZUEN/M0D1hhjrgjUmX04HhRwdWA=="],
-
- "@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.17", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-0T2mcaM6v9W1xku86Dk0bEW7aEseG6KenFkPK98XNw0ZhOqOiD1MrMsdnQw9QsL3/Oa85T53iSMlm0SZdSuIEQ=="],
-
- "@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw=="],
-
- "@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.13", "", { "dependencies": { "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-iGxQ04DsKXLckbgnX4ipElrOTk+IHgTyu0q0WssZfYhDm9CQWHmu6cOeI5wmWRxpXbBDhIIfXMWz5tPEtcVqbw=="],
-
- "@smithy/node-http-handler": ["@smithy/node-http-handler@4.5.2", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/querystring-builder": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-/oD7u8M0oj2ZTFw7GkuuHWpIxtWdLlnyNkbrWcyVYhd5RJNDuczdkb0wfnQICyNFrVPlr8YHOhamjNy3zidhmA=="],
-
- "@smithy/property-provider": ["@smithy/property-provider@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ=="],
-
- "@smithy/protocol-http": ["@smithy/protocol-http@5.3.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg=="],
-
- "@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-tG4aOYFCZdPMjbgfhnIQ322H//ojujldp1SrHPHpBSb3NqgUp3dwiUGRJzie87hS1DYwWGqDuPaowoDF+rYCbQ=="],
-
- "@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA=="],
-
- "@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0" } }, "sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw=="],
-
- "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.8", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw=="],
-
- "@smithy/signature-v4": ["@smithy/signature-v4@5.3.13", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-YpYSyM0vMDwKbHD/JA7bVOF6kToVRpa+FM5ateEVRpsTNu564g1muBlkTubXhSKKYXInhpADF46FPyrZcTLpXg=="],
-
- "@smithy/smithy-client": ["@smithy/smithy-client@4.12.9", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/middleware-endpoint": "^4.4.29", "@smithy/middleware-stack": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-stream": "^4.5.22", "tslib": "^2.6.2" } }, "sha512-ovaLEcTU5olSeHcRXcxV6viaKtpkHZumn6Ps0yn7dRf2rRSfy794vpjOtrWDO0d1auDSvAqxO+lyhERSXQ03EQ=="],
-
- "@smithy/types": ["@smithy/types@4.14.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ=="],
-
- "@smithy/url-parser": ["@smithy/url-parser@4.2.13", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw=="],
-
- "@smithy/util-base64": ["@smithy/util-base64@4.3.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ=="],
-
- "@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ=="],
-
- "@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g=="],
-
- "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
-
- "@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="],
-
- "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.45", "", { "dependencies": { "@smithy/property-provider": "^4.2.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-ag9sWc6/nWZAuK3Wm9KlFJUnRkXLrXn33RFjIAmCTFThqLHY+7wCst10BGq56FxslsDrjhSie46c8OULS+BiIw=="],
-
- "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.49", "", { "dependencies": { "@smithy/config-resolver": "^4.4.14", "@smithy/credential-provider-imds": "^4.2.13", "@smithy/node-config-provider": "^4.3.13", "@smithy/property-provider": "^4.2.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-jlN6vHwE8gY5AfiFBavtD3QtCX2f7lM3BKkz7nFKSNfFR5nXLXLg6sqXTJEEyDwtxbztIDBQCfjsGVXlIru2lQ=="],
-
- "@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.4", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-BKoR/ubPp9KNKFxPpg1J28N1+bgu8NGAtJblBP7yHy8yQPBWhIAv9+l92SlQLpolGm71CVO+btB60gTgzT0wog=="],
-
- "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="],
-
- "@smithy/util-middleware": ["@smithy/util-middleware@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow=="],
-
- "@smithy/util-retry": ["@smithy/util-retry@4.3.1", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-FwmicpgWOkP5kZUjN3y+3JIom8NLGqSAJBeoIgK0rIToI817TEBHCrd0A2qGeKQlgDeP+Jzn4i0H/NLAXGy9uQ=="],
-
- "@smithy/util-stream": ["@smithy/util-stream@4.5.22", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.16", "@smithy/node-http-handler": "^4.5.2", "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-3H8iq/0BfQjUs2/4fbHZ9aG9yNzcuZs24LPkcX1Q7Z+qpqaGM8+qbGmE8zo9m2nCRgamyvS98cHdcWvR6YUsew=="],
-
- "@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="],
-
- "@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
-
- "@smithy/util-waiter": ["@smithy/util-waiter@4.2.15", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-oUt9o7n8hBv3BL56sLSneL0XeigZSuem0Hr78JaoK33D9oKieyCvVP8eTSe3j7g2mm/S1DvzxKieG7JEWNJUNg=="],
-
- "@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="],
+ "@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -1126,9 +1031,9 @@
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
- "fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
+ "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="],
- "fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
+ "fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
@@ -1536,7 +1441,7 @@
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
- "next": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="],
+ "next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="],
"next-auth": ["next-auth@5.0.0-beta.30", "", { "dependencies": { "@auth/core": "0.41.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", "nodemailer": "^7.0.7", "react": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg=="],
@@ -1978,6 +1883,8 @@
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
+ "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
+
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
@@ -2010,18 +1917,12 @@
"@aws-crypto/sha1-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
- "@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
-
"@aws-crypto/sha256-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
- "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
-
"@aws-crypto/sha256-js/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
"@aws-crypto/util/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
- "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
-
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -2178,18 +2079,12 @@
"@aws-crypto/sha1-browser/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
- "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
-
"@aws-crypto/sha256-browser/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
- "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
-
"@aws-crypto/sha256-js/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
"@aws-crypto/util/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
- "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
-
"@commitlint/config-validator/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"@commitlint/top-level/find-up/locate-path": ["locate-path@7.2.0", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA=="],
@@ -2248,12 +2143,6 @@
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
- "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
-
- "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
-
- "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
-
"@commitlint/top-level/find-up/locate-path/p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="],
"@dotenvx/dotenvx/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
diff --git a/components/video-drag-drop-uploader.tsx b/components/video-drag-drop-uploader.tsx
index 1dcb40e..e18bfad 100644
--- a/components/video-drag-drop-uploader.tsx
+++ b/components/video-drag-drop-uploader.tsx
@@ -22,6 +22,8 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
+import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
+import type { DirectUploadProvider } from '@/components/video-page/types';
type ProjectOption = {
id: string;
@@ -35,6 +37,7 @@ interface VideoDragDropUploaderProps {
workspaceId?: string;
projectOptions?: ProjectOption[];
canUpload?: boolean;
+ directUploadProvider?: DirectUploadProvider;
}
const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv'];
@@ -68,6 +71,7 @@ export function VideoDragDropUploader({
workspaceId,
projectOptions,
canUpload = false,
+ directUploadProvider = 'bunny',
}: VideoDragDropUploaderProps) {
const router = useRouter();
@@ -86,11 +90,23 @@ export function VideoDragDropUploader({
);
const activeTusUploadRef = useRef(null);
- const pendingUploadRef = useRef<{
- projectId: string;
- videoId: string;
- uploadToken: string;
- } | null>(null);
+ const pendingUploadRef = useRef<
+ | {
+ type: 'bunny';
+ projectId: string;
+ videoId: string;
+ uploadToken: string;
+ }
+ | {
+ type: 'r2';
+ projectId: string;
+ objectKey: string;
+ uploadToken: string;
+ reservationId: string | null;
+ thumbnailObjectKey?: string;
+ }
+ | null
+ >(null);
const cancelRequestedRef = useRef(false);
const dragDepthRef = useRef(0);
const hasLoadedProjectsRef = useRef(false);
@@ -205,11 +221,20 @@ export function VideoDragDropUploader({
if (pending) {
try {
- await fetch(`/api/projects/${pending.projectId}/videos/bunny-init`, {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ videoId: pending.videoId, uploadToken: pending.uploadToken }),
- });
+ if (pending.type === 'bunny') {
+ await fetch(`/api/projects/${pending.projectId}/videos/bunny-init`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ videoId: pending.videoId, uploadToken: pending.uploadToken }),
+ });
+ } else {
+ await cleanupPendingR2VideoUpload(pending.projectId, {
+ objectKey: pending.objectKey,
+ uploadToken: pending.uploadToken,
+ reservationId: pending.reservationId,
+ thumbnailObjectKey: pending.thumbnailObjectKey,
+ });
+ }
} catch (error) {
console.error('Failed to cleanup cancelled upload:', error);
}
@@ -232,12 +257,80 @@ export function VideoDragDropUploader({
setSelectedProjectId(projectId);
setSelectedProjectName(projectName ?? projectsById.get(projectId) ?? null);
- let createdVideoId: string | null = null;
- let uploadToken: string | null = null;
+ let pendingCleanup:
+ | { type: 'bunny'; videoId: string; uploadToken: string }
+ | {
+ type: 'r2';
+ objectKey: string;
+ uploadToken: string;
+ reservationId: string | null;
+ thumbnailObjectKey?: string;
+ }
+ | null = null;
try {
const title = getDefaultTitleFromFile(file);
+ if (directUploadProvider === 'r2') {
+ const uploaded = await uploadVideoToR2(projectId, file, {
+ onProgress: (progress) => {
+ setUploadProgress(progress);
+ setUploadStatus(`Uploading... ${progress}%`);
+ },
+ });
+ pendingCleanup = {
+ type: 'r2',
+ objectKey: uploaded.objectKey,
+ uploadToken: uploaded.uploadToken,
+ reservationId: uploaded.reservationId,
+ thumbnailObjectKey: uploaded.thumbnailObjectKey,
+ };
+ pendingUploadRef.current = {
+ type: 'r2',
+ projectId,
+ objectKey: uploaded.objectKey,
+ uploadToken: uploaded.uploadToken,
+ reservationId: uploaded.reservationId,
+ thumbnailObjectKey: uploaded.thumbnailObjectKey,
+ };
+
+ setUploadStatus('Saving video...');
+ const createResponse = await fetch(`/api/projects/${projectId}/videos`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title,
+ description: null,
+ videoUrl: uploaded.proxyUrl,
+ providerId: 'r2',
+ videoId: uploaded.objectKey,
+ thumbnailUrl: uploaded.thumbnailUrl || '/placeholder-video-thumbnail.png',
+ duration: uploaded.duration,
+ uploadToken: uploaded.uploadToken,
+ objectKey: uploaded.objectKey,
+ reservationId: uploaded.reservationId,
+ }),
+ });
+
+ const createPayload = (await createResponse.json().catch(() => null)) as {
+ error?: string;
+ } | null;
+
+ if (!createResponse.ok) {
+ throw new Error(createPayload?.error || 'Failed to create video');
+ }
+
+ toast.success(
+ `Video uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}`
+ );
+ setDialogOpen(false);
+ setDroppedFile(null);
+ cleanupUploadState();
+ router.push(`/projects/${projectId}`);
+ router.refresh();
+ return;
+ }
+
const initResponse = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -259,9 +352,11 @@ export function VideoDragDropUploader({
throw new Error(initPayload?.error || 'Failed to initialize upload');
}
- createdVideoId = initPayload.data.videoId;
- uploadToken = initPayload.data.uploadToken;
+ const createdVideoId = initPayload.data.videoId;
+ const uploadToken = initPayload.data.uploadToken;
+ pendingCleanup = { type: 'bunny', videoId: createdVideoId, uploadToken };
pendingUploadRef.current = {
+ type: 'bunny',
projectId,
videoId: createdVideoId,
uploadToken,
@@ -346,13 +441,20 @@ export function VideoDragDropUploader({
return;
}
- if (createdVideoId && uploadToken) {
+ if (pendingCleanup) {
try {
- await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ videoId: createdVideoId, uploadToken }),
- });
+ if (pendingCleanup.type === 'bunny') {
+ await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ videoId: pendingCleanup.videoId,
+ uploadToken: pendingCleanup.uploadToken,
+ }),
+ });
+ } else {
+ await cleanupPendingR2VideoUpload(projectId, pendingCleanup);
+ }
} catch (cleanupError) {
console.error('Failed to cleanup pending upload:', cleanupError);
}
@@ -364,7 +466,7 @@ export function VideoDragDropUploader({
toast.error(error instanceof Error ? error.message : 'Failed to upload video');
}
},
- [bunnyCdnHostname, cleanupUploadState, projectsById, router]
+ [bunnyCdnHostname, cleanupUploadState, directUploadProvider, projectsById, router]
);
const handleDropFile = useCallback(
diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx
index 8ebd8c2..cdd438c 100644
--- a/components/video-page-content.tsx
+++ b/components/video-page-content.tsx
@@ -71,14 +71,16 @@ interface VideoPageContentProps {
mode: VideoPageMode;
videoId: string;
projectId?: string;
- bunnyUploadsEnabled?: boolean;
+ directUploadsEnabled?: boolean;
+ directUploadProvider?: import('@/components/video-page/types').DirectUploadProvider;
}
export function VideoPageContent({
mode,
videoId,
projectId: propProjectId,
- bunnyUploadsEnabled = true,
+ directUploadsEnabled = false,
+ directUploadProvider = 'bunny',
}: VideoPageContentProps) {
const iframeRef = useRef(null);
const videoRef = useRef(null);
@@ -200,7 +202,8 @@ export function VideoPageContent({
} = useVersionActions({
projectId: propProjectId,
videoId,
- bunnyUploadsEnabled,
+ directUploadsEnabled,
+ directUploadProvider,
setVideo,
activeVersionId,
setActiveVersionId,
@@ -282,6 +285,20 @@ export function VideoPageContent({
if (!bunnyCdnHostname) return '';
return `https://${bunnyCdnHostname}/${activeVersion.videoId}/playlist.m3u8`;
}
+ if (activeVersion.providerId === 'r2') {
+ if (activeVersion.originalUrl.startsWith('/api/upload/video/')) {
+ return activeVersion.originalUrl;
+ }
+ if (activeVersion.originalUrl.startsWith('videos/')) {
+ const filename = activeVersion.originalUrl.slice('videos/'.length);
+ return `/api/upload/video/${filename}`;
+ }
+ if (activeVersion.videoId.startsWith('videos/')) {
+ const filename = activeVersion.videoId.slice('videos/'.length);
+ return `/api/upload/video/${filename}`;
+ }
+ return activeVersion.originalUrl;
+ }
try {
const url = new URL(activeVersion.originalUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
@@ -551,7 +568,8 @@ export function VideoPageContent({
const isBunnyVersion = activeVersion?.providerId === 'bunny';
const showBunnyProcessingOverlay =
isBunnyVersion && bunnyPlaybackState === 'processing' && !isReady;
- const showBunnyErrorOverlay = isBunnyVersion && bunnyPlaybackState === 'error';
+ const isR2Version = activeVersion?.providerId === 'r2';
+ const showBunnyErrorOverlay = (isBunnyVersion || isR2Version) && bunnyPlaybackState === 'error';
const confirmGuestName = useCallback(() => {
if (!guestName.trim()) return;
@@ -732,7 +750,7 @@ export function VideoPageContent({
onDownload={headerActions.onDownload}
projectId={projectId}
videoId={videoId}
- bunnyUploadsEnabled={bunnyUploadsEnabled}
+ directUploadsEnabled={directUploadsEnabled}
showVersionDialog={showVersionDialog}
setShowVersionDialog={setShowVersionDialog}
newVersionMode={newVersionMode}
diff --git a/components/video-page/download-controls.tsx b/components/video-page/download-controls.tsx
index 03d5033..64a8997 100644
--- a/components/video-page/download-controls.tsx
+++ b/components/video-page/download-controls.tsx
@@ -45,7 +45,9 @@ export const DownloadControls = memo(function DownloadControls({
const isVideoDownloadAvailable =
videoCanDownload &&
- (activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
+ (activeVersion.providerId === 'bunny' ||
+ activeVersion.providerId === 'direct' ||
+ activeVersion.providerId === 'r2');
if (activeVersion.providerId === 'bunny') {
return (
@@ -180,7 +182,9 @@ export const DownloadMenuItems = memo(function DownloadMenuItems({
const isVideoDownloadAvailable =
videoCanDownload &&
- (activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
+ (activeVersion.providerId === 'bunny' ||
+ activeVersion.providerId === 'direct' ||
+ activeVersion.providerId === 'r2');
if (activeVersion.providerId === 'bunny') {
return (
diff --git a/components/video-page/hooks/use-comment-actions.ts b/components/video-page/hooks/use-comment-actions.ts
index 9bd1698..959db55 100644
--- a/components/video-page/hooks/use-comment-actions.ts
+++ b/components/video-page/hooks/use-comment-actions.ts
@@ -51,6 +51,15 @@ interface UseCommentActionsParams extends CommentActionsConfig {
fetchAssets: () => Promise;
}
+function getAudioUploadFilename(blob: Blob): string {
+ const mime = blob.type.split(';')[0].trim().toLowerCase();
+ if (mime === 'audio/mp4') return 'recording.m4a';
+ if (mime === 'audio/ogg' || mime === 'audio/opus') return 'recording.ogg';
+ if (mime === 'audio/mpeg') return 'recording.mp3';
+ if (mime === 'audio/wav') return 'recording.wav';
+ return 'recording.webm';
+}
+
export function useCommentActions({
videoId,
setVideo,
@@ -430,7 +439,8 @@ export function useCommentActions({
};
mediaRecorder.onstop = () => {
- const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
+ const recordedMime = mediaRecorder.mimeType || 'audio/webm';
+ const blob = new Blob(audioChunksRef.current, { type: recordedMime });
setAudioBlob(blob);
stream.getTracks().forEach((track) => track.stop());
if (recordingTimerRef.current) {
@@ -472,7 +482,8 @@ export function useCommentActions({
try {
const formData = new FormData();
- formData.append('audio', audioBlob, 'recording.webm');
+ const uploadFilename = getAudioUploadFilename(audioBlob);
+ formData.append('audio', audioBlob, uploadFilename);
formData.append('videoId', videoId);
const uploadToken = await getGuestUploadToken('audio');
if (uploadToken) formData.append('uploadToken', uploadToken);
@@ -514,7 +525,7 @@ export function useCommentActions({
let voiceData: { url: string; duration: number } | undefined;
if (audioBlob) {
const formData = new FormData();
- formData.append('audio', audioBlob, 'recording.webm');
+ formData.append('audio', audioBlob, getAudioUploadFilename(audioBlob));
formData.append('videoId', videoId);
const uploadToken = await getGuestUploadToken('audio');
if (uploadToken) formData.append('uploadToken', uploadToken);
@@ -829,7 +840,8 @@ export function useCommentActions({
if (e.data.size > 0) replyAudioChunksRef.current.push(e.data);
};
mediaRecorder.onstop = () => {
- const blob = new Blob(replyAudioChunksRef.current, { type: 'audio/webm' });
+ const recordedMime = mediaRecorder.mimeType || 'audio/webm';
+ const blob = new Blob(replyAudioChunksRef.current, { type: recordedMime });
setReplyAudioBlob(blob);
stream.getTracks().forEach((track) => track.stop());
if (replyRecordingTimerRef.current) {
@@ -870,7 +882,7 @@ export function useCommentActions({
setIsUploadingReplyAudio(true);
try {
const formData = new FormData();
- formData.append('audio', replyAudioBlob, 'recording.webm');
+ formData.append('audio', replyAudioBlob, getAudioUploadFilename(replyAudioBlob));
formData.append('videoId', videoId);
const uploadToken = await getGuestUploadToken('audio');
if (uploadToken) formData.append('uploadToken', uploadToken);
@@ -912,7 +924,7 @@ export function useCommentActions({
if (replyAudioBlob) {
const formData = new FormData();
- formData.append('audio', replyAudioBlob, 'recording.webm');
+ formData.append('audio', replyAudioBlob, getAudioUploadFilename(replyAudioBlob));
formData.append('videoId', videoId);
const uploadToken = await getGuestUploadToken('audio');
if (uploadToken) formData.append('uploadToken', uploadToken);
diff --git a/components/video-page/hooks/use-download-actions.ts b/components/video-page/hooks/use-download-actions.ts
index ae52aaf..fa175d4 100644
--- a/components/video-page/hooks/use-download-actions.ts
+++ b/components/video-page/hooks/use-download-actions.ts
@@ -67,7 +67,11 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
toast.error('Download is disabled for this shared link');
return;
}
- if (activeVersion.providerId !== 'bunny' && activeVersion.providerId !== 'direct') {
+ if (
+ activeVersion.providerId !== 'bunny' &&
+ activeVersion.providerId !== 'direct' &&
+ activeVersion.providerId !== 'r2'
+ ) {
toast.error('This video source does not support direct download');
return;
}
@@ -97,6 +101,11 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
}
downloadUrl = `/api/versions/${activeVersion.id}/download?source=${preference}`;
+ } else if (activeVersion.providerId === 'r2') {
+ if (!activeVersion.originalUrl.startsWith('/api/upload/video/')) {
+ throw new Error('Direct download URL is not allowed');
+ }
+ downloadUrl = activeVersion.originalUrl;
} else {
downloadUrl = getSafeDirectDownloadUrl(activeVersion.originalUrl);
if (!downloadUrl) {
@@ -113,8 +122,9 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
const baseName = sanitizeDownloadFileName(`${video.title} ${versionLabel}`) || 'video';
const a = document.createElement('a');
a.href = downloadUrl;
- if (activeVersion.providerId === 'direct') {
- a.download = `${baseName}.mp4`;
+ if (activeVersion.providerId === 'direct' || activeVersion.providerId === 'r2') {
+ const ext = activeVersion.originalUrl.split('.').pop()?.toLowerCase() || 'mp4';
+ a.download = `${baseName}.${ext}`;
}
document.body.appendChild(a);
a.click();
diff --git a/components/video-page/hooks/use-version-actions.ts b/components/video-page/hooks/use-version-actions.ts
index a43f7f4..cdf6967 100644
--- a/components/video-page/hooks/use-version-actions.ts
+++ b/components/video-page/hooks/use-version-actions.ts
@@ -11,6 +11,7 @@ import {
} from '@/lib/video-providers';
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
+import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
interface UseVersionActionsParams extends VersionActionsConfig {
setVideo: Dispatch>;
@@ -21,7 +22,8 @@ interface UseVersionActionsParams extends VersionActionsConfig {
export function useVersionActions({
projectId,
videoId,
- bunnyUploadsEnabled = true,
+ directUploadsEnabled = false,
+ directUploadProvider = 'bunny',
setVideo,
activeVersionId,
setActiveVersionId,
@@ -58,13 +60,111 @@ export function useVersionActions({
}
};
+ const uploadNewVersionFile = async (file: File, title: string) => {
+ if (!projectId) throw new Error('Missing project');
+
+ if (directUploadProvider === 'r2') {
+ setNewVersionUploadStatus('Initializing upload...');
+ const uploaded = await uploadVideoToR2(projectId, file, {
+ onProgress: (progress) => {
+ setNewVersionUploadProgress(progress);
+ setNewVersionUploadStatus(`Uploading... ${progress}%`);
+ },
+ });
+
+ return {
+ finalVideoUrl: uploaded.proxyUrl,
+ finalProviderId: 'r2',
+ finalProviderVideoId: uploaded.objectKey,
+ finalThumbnailUrl: uploaded.thumbnailUrl || '/placeholder-video-thumbnail.png',
+ finalDuration: uploaded.duration,
+ uploadToken: uploaded.uploadToken,
+ objectKey: uploaded.objectKey,
+ reservationId: uploaded.reservationId,
+ pendingCleanup: {
+ objectKey: uploaded.objectKey,
+ uploadToken: uploaded.uploadToken,
+ reservationId: uploaded.reservationId,
+ thumbnailObjectKey: uploaded.thumbnailObjectKey,
+ },
+ };
+ }
+
+ setNewVersionUploadStatus('Initializing upload...');
+ const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ title }),
+ });
+
+ if (!initRes.ok) throw new Error('Failed to initialize upload');
+ const {
+ data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken },
+ } = await initRes.json();
+
+ await new Promise((resolve, reject) => {
+ setNewVersionUploadStatus('Uploading video...');
+ const upload = new tus.Upload(file, {
+ endpoint: 'https://video.bunnycdn.com/tusupload',
+ retryDelays: [0, 3000, 5000, 10000, 20000],
+ headers: {
+ AuthorizationSignature: signature,
+ AuthorizationExpire: expirationTime.toString(),
+ VideoId: bunnyVideoId,
+ LibraryId: libraryId,
+ },
+ metadata: {
+ filetype: file.type,
+ title,
+ },
+ onError: (error) => reject(new Error(`Upload failed: ${error.message}`)),
+ onProgress: (bytesUploaded, bytesTotal) => {
+ const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1);
+ setNewVersionUploadProgress(Number(percentage));
+ setNewVersionUploadStatus(`Uploading... ${percentage}%`);
+ },
+ onSuccess: () => {
+ setNewVersionUploadStatus('Processing video...');
+ resolve(true);
+ },
+ });
+ upload.start();
+ });
+
+ return {
+ finalVideoUrl: `https://iframe.mediadelivery.net/embed/${libraryId}/${bunnyVideoId}`,
+ finalProviderId: 'bunny',
+ finalProviderVideoId: bunnyVideoId,
+ finalThumbnailUrl: bunnyCdnHostname
+ ? `https://${bunnyCdnHostname}/${bunnyVideoId}/thumbnail.jpg`
+ : null,
+ finalDuration: null as number | null,
+ uploadToken,
+ objectKey: null as string | null,
+ reservationId: null as string | null,
+ pendingCleanup: {
+ bunnyVideoId,
+ uploadToken,
+ },
+ };
+ };
+
const handleCreateVersion = async () => {
if (!projectId) return;
setIsCreatingVersion(true);
setNewVersionUploadStatus('');
setNewVersionUploadProgress(0);
- let uploadedBunnyVideoId: string | null = null;
- let uploadedBunnyUploadToken: string | null = null;
+ let pendingCleanup:
+ | {
+ objectKey: string;
+ uploadToken: string;
+ reservationId: string | null;
+ }
+ | {
+ bunnyVideoId: string;
+ uploadToken: string;
+ }
+ | null = null;
try {
let finalVideoUrl = '';
@@ -72,6 +172,9 @@ export function useVersionActions({
let finalProviderVideoId = '';
let finalThumbnailUrl: string | null = null;
let finalDuration: number | null = null;
+ let uploadToken: string | null = null;
+ let objectKey: string | null = null;
+ let reservationId: string | null = null;
if (newVersionMode === 'url') {
if (!newVersionSource) throw new Error('Invalid URL');
@@ -82,7 +185,7 @@ export function useVersionActions({
finalThumbnailUrl = getThumbnailUrl(newVersionSource, 'large');
finalDuration = meta?.duration || null;
} else {
- if (!bunnyUploadsEnabled) throw new Error('Direct uploads are disabled by this host');
+ if (!directUploadsEnabled) throw new Error('Direct uploads are disabled by this host');
if (!newVersionFile) throw new Error('No file selected');
let title = newVersionFile.name;
if (newVersionLabel.trim()) {
@@ -91,55 +194,16 @@ export function useVersionActions({
title = title.replace(/\.[^/.]+$/, '');
}
- setNewVersionUploadStatus('Initializing upload...');
- const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ title }),
- });
-
- if (!initRes.ok) throw new Error('Failed to initialize upload');
- const {
- data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken },
- } = await initRes.json();
- uploadedBunnyVideoId = bunnyVideoId;
- uploadedBunnyUploadToken = uploadToken;
-
- await new Promise((resolve, reject) => {
- setNewVersionUploadStatus('Uploading video...');
- const upload = new tus.Upload(newVersionFile, {
- endpoint: 'https://video.bunnycdn.com/tusupload',
- retryDelays: [0, 3000, 5000, 10000, 20000],
- headers: {
- AuthorizationSignature: signature,
- AuthorizationExpire: expirationTime.toString(),
- VideoId: bunnyVideoId,
- LibraryId: libraryId,
- },
- metadata: {
- filetype: newVersionFile.type,
- title,
- },
- onError: (error) => reject(new Error(`Upload failed: ${error.message}`)),
- onProgress: (bytesUploaded, bytesTotal) => {
- const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1);
- setNewVersionUploadProgress(Number(percentage));
- setNewVersionUploadStatus(`Uploading... ${percentage}%`);
- },
- onSuccess: () => {
- setNewVersionUploadStatus('Processing video...');
- resolve(true);
- },
- });
- upload.start();
- });
-
- finalVideoUrl = `https://iframe.mediadelivery.net/embed/${libraryId}/${bunnyVideoId}`;
- finalProviderId = 'bunny';
- finalProviderVideoId = bunnyVideoId;
- finalThumbnailUrl = bunnyCdnHostname
- ? `https://${bunnyCdnHostname}/${bunnyVideoId}/thumbnail.jpg`
- : null;
+ const uploaded = await uploadNewVersionFile(newVersionFile, title);
+ finalVideoUrl = uploaded.finalVideoUrl;
+ finalProviderId = uploaded.finalProviderId;
+ finalProviderVideoId = uploaded.finalProviderVideoId;
+ finalThumbnailUrl = uploaded.finalThumbnailUrl;
+ finalDuration = uploaded.finalDuration;
+ uploadToken = uploaded.uploadToken;
+ objectKey = uploaded.objectKey;
+ reservationId = uploaded.reservationId;
+ pendingCleanup = uploaded.pendingCleanup;
}
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}/versions`, {
@@ -149,7 +213,9 @@ export function useVersionActions({
videoUrl: finalVideoUrl,
providerId: finalProviderId,
providerVideoId: finalProviderVideoId,
- uploadToken: uploadedBunnyUploadToken,
+ uploadToken,
+ objectKey,
+ reservationId,
versionLabel: newVersionLabel.trim() || null,
thumbnailUrl: finalThumbnailUrl,
duration: finalDuration,
@@ -180,24 +246,30 @@ export function useVersionActions({
setNewVersionSource(null);
setNewVersionFile(null);
setNewVersionUploadStatus('');
+ pendingCleanup = null;
} catch (err) {
const errorObj = err as Error;
- if (uploadedBunnyVideoId && uploadedBunnyUploadToken) {
- await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- videoId: uploadedBunnyVideoId,
- uploadToken: uploadedBunnyUploadToken,
- }),
- }).catch((cleanupError) => {
- console.error('Failed to cleanup pending Bunny version upload:', cleanupError);
- });
+ if (pendingCleanup && projectId) {
+ if ('objectKey' in pendingCleanup) {
+ await cleanupPendingR2VideoUpload(projectId, pendingCleanup);
+ } else {
+ await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ videoId: pendingCleanup.bunnyVideoId,
+ uploadToken: pendingCleanup.uploadToken,
+ }),
+ }).catch((cleanupError) => {
+ console.error('Failed to cleanup pending Bunny version upload:', cleanupError);
+ });
+ }
}
console.error('Failed to create version:', errorObj);
toast.error(errorObj.message || 'Failed to create version');
} finally {
setIsCreatingVersion(false);
+ setNewVersionUploadProgress(0);
}
};
@@ -209,34 +281,28 @@ export function useVersionActions({
`/api/projects/${projectId}/videos/${videoId}/versions/${versionToDelete}`,
{ method: 'DELETE' }
);
- if (res.ok) {
- setVideo((prev) => {
- if (!prev) return prev;
- const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
- return { ...prev, versions: remaining };
- });
-
- if (activeVersionId === versionToDelete) {
- setVideo((prev) => {
- if (!prev) return prev;
- const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
- if (remaining.length > 0) {
- setActiveVersionId(remaining[0].id);
- } else {
- setActiveVersionId(null);
- }
- return prev;
- });
- }
-
- setShowDeleteVersionDialog(false);
- setVersionToDelete(null);
- } else {
- const data = await res.json();
- toast.error(data.error || 'Failed to delete version');
+ if (!res.ok) {
+ const data = await res.json().catch(() => null);
+ throw new Error(data?.error || 'Failed to delete version');
}
- } catch {
- toast.error('Failed to delete version');
+
+ setVideo((prev) => {
+ if (!prev) return prev;
+ const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
+ if (activeVersionId === versionToDelete && remaining.length > 0) {
+ const nextActive = remaining.find((v) => v.isActive) ?? remaining[0];
+ setActiveVersionId(nextActive.id);
+ }
+ return { ...prev, versions: remaining };
+ });
+
+ setShowDeleteVersionDialog(false);
+ setVersionToDelete(null);
+ toast.success('Version deleted');
+ } catch (err) {
+ const errorObj = err as Error;
+ console.error('Failed to delete version:', errorObj);
+ toast.error(errorObj.message || 'Failed to delete version');
} finally {
setIsDeletingVersion(false);
}
@@ -259,10 +325,8 @@ export function useVersionActions({
newVersionUploadStatus,
handleNewVersionUrlChange,
handleCreateVersion,
-
showDeleteVersionDialog,
setShowDeleteVersionDialog,
- versionToDelete,
setVersionToDelete,
isDeletingVersion,
handleDeleteVersion,
diff --git a/components/video-page/hooks/use-video-player.ts b/components/video-page/hooks/use-video-player.ts
index 8d2fa46..f18b15f 100644
--- a/components/video-page/hooks/use-video-player.ts
+++ b/components/video-page/hooks/use-video-player.ts
@@ -204,9 +204,10 @@ export function useVideoPlayer({
if (!activeProviderId) return;
const isYoutube = activeProviderId === 'youtube';
const isBunny = activeProviderId === 'bunny';
+ const isR2 = activeProviderId === 'r2';
if (isYoutube && !isApiLoaded) return;
- if (!isYoutube && !isBunny) return;
+ if (!isYoutube && !isBunny && !isR2) return;
const currentVersionKey = `${activeProviderId ?? 'none'}:${activeVersionId ?? 'none'}`;
const versionChanged = previousVersionKeyRef.current !== currentVersionKey;
@@ -623,6 +624,132 @@ export function useVideoPlayer({
videoEl.load();
},
};
+ } else if (isR2) {
+ const videoEl = videoRef.current;
+ if (!videoEl) return;
+
+ let cachedDuration = 0;
+ let destroyed = false;
+
+ const syncDuration = () => {
+ if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
+ cachedDuration = videoEl.duration;
+ setVideoDuration(videoEl.duration);
+ }
+ };
+
+ const saveProgress = () => {
+ const current = videoEl.currentTime || 0;
+ const duration =
+ Number.isFinite(videoEl.duration) && videoEl.duration > 0
+ ? videoEl.duration
+ : cachedDuration;
+ scheduleWatchProgressSaveRef.current({
+ progress: current,
+ duration,
+ immediate: true,
+ force: true,
+ });
+ };
+
+ const onLoadedMetadata = () => {
+ if (destroyed) return;
+ setBunnyPlaybackState('none');
+ if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
+ setIsBunnyPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
+ }
+ setIsReady(true);
+ syncDuration();
+ if (!videoEl.paused) {
+ startBunnyFrameTracking();
+ }
+ };
+
+ const onPlay = () => {
+ setIsPlaying(true);
+ setBunnyPlaybackState('none');
+ syncDuration();
+ startBunnyFrameTracking();
+ };
+
+ const onPause = () => {
+ setIsPlaying(false);
+ stopBunnyFrameTracking();
+ saveProgress();
+ };
+
+ const onEnded = () => {
+ setIsPlaying(false);
+ stopBunnyFrameTracking();
+ saveProgress();
+ };
+
+ const onTimeUpdate = () => {
+ if (!isDraggingRef.current) {
+ setCurrentTime(videoEl.currentTime || 0);
+ }
+ syncDuration();
+ };
+
+ const onVideoError = () => {
+ if (destroyed) return;
+ setBunnyPlaybackState('error');
+ };
+
+ videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
+ videoEl.addEventListener('play', onPlay);
+ videoEl.addEventListener('pause', onPause);
+ videoEl.addEventListener('ended', onEnded);
+ videoEl.addEventListener('timeupdate', onTimeUpdate);
+ videoEl.addEventListener('error', onVideoError);
+
+ const playbackSrc =
+ embedUrl.startsWith('/') && typeof window !== 'undefined'
+ ? `${window.location.origin}${embedUrl}`
+ : embedUrl;
+ videoEl.src = playbackSrc;
+ videoEl.load();
+
+ playerRef.current = {
+ playVideo: () => {
+ videoEl.play().catch((err) => console.error('Error playing video:', err));
+ },
+ pauseVideo: () => videoEl.pause(),
+ seekTo: (time: number) => {
+ videoEl.currentTime = time;
+ },
+ mute: () => {
+ videoEl.muted = true;
+ },
+ unMute: () => {
+ videoEl.muted = false;
+ },
+ isMuted: () => videoEl.muted,
+ getCurrentTime: () => videoEl.currentTime || 0,
+ getDuration: () => {
+ if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) return videoEl.duration;
+ return cachedDuration;
+ },
+ getPlayerState: () =>
+ videoEl.paused
+ ? (window.YT?.PlayerState?.PAUSED ?? 2)
+ : (window.YT?.PlayerState?.PLAYING ?? 1),
+ setPlaybackRate: (rate: number) => {
+ videoEl.playbackRate = rate;
+ },
+ destroy: () => {
+ destroyed = true;
+ stopBunnyFrameTracking();
+ videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
+ videoEl.removeEventListener('play', onPlay);
+ videoEl.removeEventListener('pause', onPause);
+ videoEl.removeEventListener('ended', onEnded);
+ videoEl.removeEventListener('timeupdate', onTimeUpdate);
+ videoEl.removeEventListener('error', onVideoError);
+ videoEl.removeAttribute('src');
+ videoEl.load();
+ },
+ };
}
};
@@ -633,7 +760,7 @@ export function useVideoPlayer({
} else {
window.onYouTubeIframeAPIReady = initPlayer;
}
- } else if (isBunny) {
+ } else if (isBunny || isR2) {
initPlayer();
}
}, 100);
diff --git a/components/video-page/player-core.tsx b/components/video-page/player-core.tsx
index 94dbd08..0e94fb2 100644
--- a/components/video-page/player-core.tsx
+++ b/components/video-page/player-core.tsx
@@ -170,7 +170,7 @@ export const PlayerCore = memo(function PlayerCore({
onMouseLeave={handleVideoMouseLeave}
>
- {activeProviderId === 'bunny' ? (
+ {activeProviderId === 'bunny' || activeProviderId === 'r2' ? (
- The Bunny stream is unavailable right now. Please refresh this page in a moment.
+ {activeProviderId === 'r2'
+ ? 'This video file could not be loaded. Try refreshing the page or re-uploading the version.'
+ : 'The Bunny stream is unavailable right now. Please refresh this page in a moment.'}
diff --git a/components/video-page/types.ts b/components/video-page/types.ts
index 3f8b08e..e9c229c 100644
--- a/components/video-page/types.ts
+++ b/components/video-page/types.ts
@@ -185,10 +185,13 @@ export interface CommentActionsConfig {
videoId: string;
}
+export type DirectUploadProvider = 'bunny' | 'r2';
+
export interface VersionActionsConfig {
projectId?: string;
videoId: string;
- bunnyUploadsEnabled?: boolean;
+ directUploadsEnabled?: boolean;
+ directUploadProvider?: DirectUploadProvider;
}
export interface VideoPageHeaderActions {
diff --git a/components/video-page/version-actions-dialog.tsx b/components/video-page/version-actions-dialog.tsx
index 6477981..cff5c42 100644
--- a/components/video-page/version-actions-dialog.tsx
+++ b/components/video-page/version-actions-dialog.tsx
@@ -28,7 +28,7 @@ import type { VideoSource } from '@/lib/video-providers';
interface VersionActionsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
- bunnyUploadsEnabled: boolean;
+ directUploadsEnabled: boolean;
newVersionMode: 'url' | 'file';
onNewVersionModeChange: (mode: 'url' | 'file') => void;
newVersionUrl: string;
@@ -49,7 +49,7 @@ interface VersionActionsDialogProps {
export const VersionActionsDialog = memo(function VersionActionsDialog({
open,
onOpenChange,
- bunnyUploadsEnabled,
+ directUploadsEnabled,
newVersionMode,
onNewVersionModeChange,
newVersionUrl,
@@ -88,10 +88,10 @@ export const VersionActionsDialog = memo(function VersionActionsDialog({
className="mb-2"
>
Link URL
- {bunnyUploadsEnabled ? Upload File : null}
+ {directUploadsEnabled ? Upload File : null}
diff --git a/components/video-page/video-page-header.tsx b/components/video-page/video-page-header.tsx
index 46f8c81..564d422 100644
--- a/components/video-page/video-page-header.tsx
+++ b/components/video-page/video-page-header.tsx
@@ -57,7 +57,7 @@ interface VideoPageHeaderProps {
onDownload: (preference?: BunnyDownloadPreference) => void;
projectId?: string;
videoId: string;
- bunnyUploadsEnabled: boolean;
+ directUploadsEnabled: boolean;
showVersionDialog: boolean;
setShowVersionDialog: (open: boolean) => void;
newVersionMode: 'url' | 'file';
@@ -105,7 +105,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
onDownload,
projectId,
videoId,
- bunnyUploadsEnabled,
+ directUploadsEnabled,
showVersionDialog,
setShowVersionDialog,
newVersionMode,
@@ -261,7 +261,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
void;
+
+function uploadBytesWithProgress(
+ url: string,
+ body: Blob | File,
+ contentType: string,
+ onProgress?: UploadProgressHandler
+): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 };
+}
diff --git a/lib/client/video-thumbnail.ts b/lib/client/video-thumbnail.ts
new file mode 100644
index 0000000..178125c
--- /dev/null
+++ b/lib/client/video-thumbnail.ts
@@ -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 {
+ 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;
+ });
+}
diff --git a/lib/feature-flags.ts b/lib/feature-flags.ts
index 335704e..48fed40 100644
--- a/lib/feature-flags.ts
+++ b/lib/feature-flags.ts
@@ -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);
}
diff --git a/lib/json-serialize.ts b/lib/json-serialize.ts
new file mode 100644
index 0000000..39cc85a
--- /dev/null
+++ b/lib/json-serialize.ts
@@ -0,0 +1,8 @@
+/**
+ * Converts values for JSON responses (e.g. Prisma BigInt fields).
+ */
+export function toJsonSafe(value: T): T {
+ return JSON.parse(
+ JSON.stringify(value, (_key, val) => (typeof val === 'bigint' ? val.toString() : val))
+ ) as T;
+}
diff --git a/lib/r2-cleanup.ts b/lib/r2-cleanup.ts
index a85a566..0acf2be 100644
--- a/lib/r2-cleanup.ts
+++ b/lib/r2-cleanup.ts
@@ -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 {
- 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
},
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
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
* Collect all media URLs from comments under all videos in a project.
*/
export async function collectProjectMediaUrls(projectId: string): Promise {
- 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 {
@@ -135,6 +149,10 @@ export async function collectProjectMediaUrls(projectId: string): Promise {
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 {
- 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 {
@@ -166,6 +188,10 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise {
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;
}
diff --git a/lib/r2-media-proxy.ts b/lib/r2-media-proxy.ts
index 5a8cc1e..8b9b3c0 100644
--- a/lib/r2-media-proxy.ts
+++ b/lib/r2-media-proxy.ts
@@ -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)) {
diff --git a/lib/r2-upload-session.ts b/lib/r2-upload-session.ts
new file mode 100644
index 0000000..5a6df06
--- /dev/null
+++ b/lib/r2-upload-session.ts
@@ -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(),
+ },
+ });
+}
diff --git a/lib/r2-upload-token.ts b/lib/r2-upload-token.ts
new file mode 100644
index 0000000..d59a1b8
--- /dev/null
+++ b/lib/r2-upload-token.ts
@@ -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;
+ 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;
+ }
+}
diff --git a/lib/r2-video-finalize.ts b/lib/r2-video-finalize.ts
new file mode 100644
index 0000000..2b6bc5a
--- /dev/null
+++ b/lib/r2-video-finalize.ts
@@ -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 {
+ 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 => {
+ 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}`,
+ };
+}
diff --git a/lib/r2.ts b/lib/r2.ts
index 1dd9d4a..9054b64 100644
--- a/lib/r2.ts
+++ b/lib/r2.ts
@@ -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();
+
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 };
+ 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 {
+ if (!key.startsWith(VIDEO_OBJECT_KEY_PREFIX)) {
+ throw new Error('Invalid video object key');
+ }
+
+ await deleteR2Object(key);
+}
+
+export async function deleteR2Object(key: string): Promise {
+ assertAllowedObjectKey(key);
+
+ await r2Client.send(
+ new DeleteObjectCommand({
+ Bucket: R2_BUCKET_NAME,
+ Key: key,
+ })
+ );
+}
+
export { R2_BUCKET_NAME };
diff --git a/lib/storage-quota.ts b/lib/storage-quota.ts
index fcfcd53..fdb3df7 100644
--- a/lib/storage-quota.ts
+++ b/lib/storage-quota.ts
@@ -20,13 +20,22 @@ class QuotaExceededError extends Error {}
* every upload.
*/
export async function getUserTotalStorageBytes(userId: string): Promise {
- 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
`,
]);
- 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 {
+export async function releaseStorageReservation(
+ reservationId: string | null,
+ billedUserId?: string | null
+): Promise {
if (!reservationId) return;
- await db.uploadReservation.deleteMany({ where: { id: reservationId } });
+ await db.uploadReservation.deleteMany({
+ where: {
+ id: reservationId,
+ ...(billedUserId ? { billedUserId } : {}),
+ },
+ });
}
diff --git a/lib/validation.ts b/lib/validation.ts
index 6f71d40..91d5d9e 100644
--- a/lib/validation.ts
+++ b/lib/validation.ts
@@ -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);
+}
diff --git a/lib/video-providers/index.ts b/lib/video-providers/index.ts
index 30b2d77..9f91069 100644
--- a/lib/video-providers/index.ts
+++ b/lib/video-providers/index.ts
@@ -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(providers.map((p) => [p.id, p]));
diff --git a/lib/video-providers/r2.ts b/lib/video-providers/r2.ts
new file mode 100644
index 0000000..eed0be3
--- /dev/null
+++ b/lib/video-providers/r2.ts
@@ -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 {
+ const filename = videoId.split('/').pop() || 'Video';
+ const nameWithoutExt = filename.replace(/\.[^/.]+$/, '');
+
+ return {
+ title: nameWithoutExt,
+ thumbnailUrl: this.getThumbnailUrl(videoId),
+ };
+ },
+};
diff --git a/lib/video-providers/types.ts b/lib/video-providers/types.ts
index c38d3c3..4d36b39 100644
--- a/lib/video-providers/types.ts
+++ b/lib/video-providers/types.ts
@@ -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 {
diff --git a/lib/video-upload-validation.ts b/lib/video-upload-validation.ts
new file mode 100644
index 0000000..031fae5
--- /dev/null
+++ b/lib/video-upload-validation.ts
@@ -0,0 +1,87 @@
+const VIDEO_MIME_TO_EXT: Record = {
+ '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 = {
+ 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 };
diff --git a/next.config.ts b/next.config.ts
index 9decc66..4f5cf82 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -15,6 +15,41 @@ function resolveBunnyCdnHostname(): string | null {
const bunnyCdnHostname = resolveBunnyCdnHostname();
const isDev = process.env.NODE_ENV === 'development';
+function resolveR2ConnectOrigins(): string[] {
+ const origins = new Set();
+
+ for (const raw of [
+ process.env.R2_ENDPOINT?.trim(),
+ process.env.R2_PRESIGN_ENDPOINT?.trim(),
+ process.env.R2_PUBLIC_BASE_URL?.trim(),
+ ]) {
+ if (!raw) continue;
+ try {
+ origins.add(new URL(raw).origin);
+ } catch {
+ // Ignore invalid custom endpoints in CSP generation.
+ }
+ }
+
+ const accountId = process.env.R2_ACCOUNT_ID?.trim();
+ const bucket = process.env.R2_BUCKET_NAME?.trim();
+ if (accountId) {
+ origins.add(`https://${accountId}.r2.cloudflarestorage.com`);
+ if (bucket) {
+ origins.add(`https://${bucket}.${accountId}.r2.cloudflarestorage.com`);
+ }
+ origins.add('https://*.r2.cloudflarestorage.com');
+ }
+
+ // Docker/MinIO self-hosted defaults. Keep unconditional because CSP is
+ // compiled at build time and build env may not include runtime self-hosted
+ // variables.
+ origins.add('http://localhost:9000');
+ origins.add('http://127.0.0.1:9000');
+
+ return [...origins];
+}
+
// Build Content-Security-Policy from resolved config
const cdnOrigin = bunnyCdnHostname ? `https://${bunnyCdnHostname}` : '';
@@ -23,6 +58,7 @@ const connectSrcParts = [
'https://video.bunnycdn.com',
'https://www.youtube.com',
cdnOrigin,
+ ...resolveR2ConnectOrigins(),
// Allow Next.js HMR websocket in development
...(isDev ? ['ws://localhost:* wss://localhost:*'] : []),
].filter(Boolean);
diff --git a/package.json b/package.json
index 38b508d..a36a140 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"db:seed": "prisma db seed",
"db:setup": "bun run db:generate && bun run db:migrate",
"self-host:bootstrap": "bun run scripts/self-host-bootstrap.ts",
+ "r2:configure-cors": "bun run scripts/configure-r2-cors.ts",
"r2:cleanup-orphans:dry": "bun run scripts/r2-orphan-cleanup.ts --dry-run",
"r2:cleanup-orphans": "bun run scripts/r2-orphan-cleanup.ts",
"bunny:cleanup-orphans:dry": "bun run scripts/bunny-orphan-cleanup.ts --dry-run",
@@ -28,7 +29,8 @@
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
- "@aws-sdk/client-s3": "^3.1029.0",
+ "@aws-sdk/client-s3": "3.1054.0",
+ "@aws-sdk/s3-request-presigner": "3.1054.0",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
"bcryptjs": "^3.0.3",
diff --git a/prisma/migrations/20260527120000_add_size_bytes_to_video_versions/migration.sql b/prisma/migrations/20260527120000_add_size_bytes_to_video_versions/migration.sql
new file mode 100644
index 0000000..e1fd12a
--- /dev/null
+++ b/prisma/migrations/20260527120000_add_size_bytes_to_video_versions/migration.sql
@@ -0,0 +1 @@
+ALTER TABLE "video_versions" ADD COLUMN "size_bytes" BIGINT NOT NULL DEFAULT 0;
diff --git a/prisma/migrations/20260527154000_add_video_upload_sessions/migration.sql b/prisma/migrations/20260527154000_add_video_upload_sessions/migration.sql
new file mode 100644
index 0000000..bfaaddf
--- /dev/null
+++ b/prisma/migrations/20260527154000_add_video_upload_sessions/migration.sql
@@ -0,0 +1,41 @@
+-- CreateEnum
+CREATE TYPE "UploadSessionStatus" AS ENUM ('INITIATED', 'FINALIZED', 'CANCELLED', 'EXPIRED');
+
+-- CreateTable
+CREATE TABLE "video_upload_sessions" (
+ "id" TEXT NOT NULL,
+ "upload_jti" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "projectId" TEXT NOT NULL,
+ "billed_user_id" TEXT NOT NULL,
+ "object_key" TEXT NOT NULL,
+ "thumbnail_object_key" TEXT NOT NULL,
+ "declared_size_bytes" BIGINT NOT NULL,
+ "content_type" TEXT NOT NULL,
+ "reservation_id" TEXT,
+ "expires_at" TIMESTAMP(3) NOT NULL,
+ "status" "UploadSessionStatus" NOT NULL DEFAULT 'INITIATED',
+ "consumed_at" TIMESTAMP(3),
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "video_upload_sessions_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "video_upload_sessions_upload_jti_key" ON "video_upload_sessions"("upload_jti");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "video_upload_sessions_object_key_key" ON "video_upload_sessions"("object_key");
+
+-- CreateIndex
+CREATE INDEX "video_upload_sessions_projectId_status_idx" ON "video_upload_sessions"("projectId", "status");
+
+-- CreateIndex
+CREATE INDEX "video_upload_sessions_userId_status_idx" ON "video_upload_sessions"("userId", "status");
+
+-- CreateIndex
+CREATE INDEX "video_upload_sessions_billed_user_id_idx" ON "video_upload_sessions"("billed_user_id");
+
+-- CreateIndex
+CREATE INDEX "video_upload_sessions_expires_at_idx" ON "video_upload_sessions"("expires_at");
diff --git a/prisma/migrations/20260527155000_add_r2_video_uniqueness_indexes/migration.sql b/prisma/migrations/20260527155000_add_r2_video_uniqueness_indexes/migration.sql
new file mode 100644
index 0000000..6b6f854
--- /dev/null
+++ b/prisma/migrations/20260527155000_add_r2_video_uniqueness_indexes/migration.sql
@@ -0,0 +1,12 @@
+-- Ensure a finalized R2 object key and proxy URL can only be claimed once.
+CREATE UNIQUE INDEX "video_versions_r2_videoid_unique"
+ON "video_versions" ("videoId")
+WHERE "providerId" = 'r2';
+
+CREATE UNIQUE INDEX "video_versions_r2_originalurl_unique"
+ON "video_versions" ("originalUrl")
+WHERE "providerId" = 'r2' AND "originalUrl" LIKE '/api/upload/video/%';
+
+CREATE UNIQUE INDEX "video_versions_r2_thumbnail_unique"
+ON "video_versions" ("thumbnailUrl")
+WHERE "providerId" = 'r2' AND "thumbnailUrl" LIKE '/api/upload/image/%';
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 1764939..9c1114f 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -361,6 +361,7 @@ model VideoVersion {
title String?
thumbnailUrl String?
duration Int? // Duration in seconds
+ sizeBytes BigInt @default(0) @map("size_bytes") // R2-hosted video file size
// Status
isActive Boolean @default(true) // Currently displayed version
@@ -701,6 +702,37 @@ model UploadReservation {
@@map("upload_reservations")
}
+enum UploadSessionStatus {
+ INITIATED
+ FINALIZED
+ CANCELLED
+ EXPIRED
+}
+
+model VideoUploadSession {
+ id String @id @default(cuid())
+ uploadJti String @unique @map("upload_jti")
+ userId String
+ projectId String
+ billedUserId String @map("billed_user_id")
+ objectKey String @unique @map("object_key")
+ thumbnailObjectKey String @map("thumbnail_object_key")
+ declaredSizeBytes BigInt @map("declared_size_bytes")
+ contentType String @map("content_type")
+ reservationId String? @map("reservation_id")
+ expiresAt DateTime @map("expires_at")
+ status UploadSessionStatus @default(INITIATED)
+ consumedAt DateTime? @map("consumed_at")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ @@index([projectId, status])
+ @@index([userId, status])
+ @@index([billedUserId])
+ @@index([expiresAt])
+ @@map("video_upload_sessions")
+}
+
// Rate limiting table (created as UNLOGGED via raw SQL migration)
// Defined here so `prisma db push` doesn't drop it
model RateLimit {
diff --git a/scripts/configure-r2-cors.ts b/scripts/configure-r2-cors.ts
new file mode 100644
index 0000000..dd53a88
--- /dev/null
+++ b/scripts/configure-r2-cors.ts
@@ -0,0 +1,20 @@
+import 'dotenv/config';
+import { ensureR2UploadCors, R2_BUCKET_NAME, r2Client } from '@/lib/r2';
+import { logError } from '@/lib/logger';
+
+async function main() {
+ const origins = await ensureR2UploadCors();
+ console.log(`Configured upload CORS on bucket "${R2_BUCKET_NAME}" for origins:`);
+ for (const origin of origins) {
+ console.log(` - ${origin}`);
+ }
+}
+
+main()
+ .catch((error) => {
+ logError('Failed to configure R2 upload CORS:', error);
+ process.exitCode = 1;
+ })
+ .finally(() => {
+ r2Client.destroy();
+ });
diff --git a/scripts/docker-db-bootstrap.ts b/scripts/docker-db-bootstrap.ts
index 47c2d06..65846a5 100644
--- a/scripts/docker-db-bootstrap.ts
+++ b/scripts/docker-db-bootstrap.ts
@@ -82,6 +82,16 @@ async function getPublicTables(client: Client) {
return result.rows.map((row) => row.table_name);
}
+async function ensureRateLimitCleanupFunction(client: Client) {
+ await client.query(`
+ CREATE OR REPLACE FUNCTION cleanup_rate_limits() RETURNS void AS $$
+ BEGIN
+ DELETE FROM rate_limits WHERE window_start < NOW() - INTERVAL '1 hour';
+ END;
+ $$ LANGUAGE plpgsql;
+ `);
+}
+
async function main() {
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is required');
@@ -132,6 +142,7 @@ async function main() {
await runPrisma(['migrate', 'resolve', '--applied', migrationName]);
}
+ await ensureRateLimitCleanupFunction(client);
console.log('Fresh database bootstrap complete');
return;
}
diff --git a/scripts/r2-orphan-cleanup.ts b/scripts/r2-orphan-cleanup.ts
index bf3c3ad..7b2adaf 100644
--- a/scripts/r2-orphan-cleanup.ts
+++ b/scripts/r2-orphan-cleanup.ts
@@ -10,7 +10,7 @@ import { logError } from '@/lib/logger';
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
const CHUNK_SIZE = 500;
-const PREFIXES = ['images/', 'voice/'] as const;
+const PREFIXES = ['images/', 'voice/', 'videos/'] as const;
type CleanupCandidate = {
key: string;
@@ -33,6 +33,10 @@ function keyToProxyUrl(key: string): string | null {
const filename = key.slice('voice/'.length);
return filename ? `/api/upload/audio/${filename}` : null;
}
+ if (key.startsWith('videos/')) {
+ const filename = key.slice('videos/'.length);
+ return filename ? `/api/upload/video/${filename}` : null;
+ }
return null;
}
@@ -93,31 +97,38 @@ async function findReferencedUrls(urls: string[]): Promise> {
).userFeedbackScreenshot;
for (const group of chunk(urls, CHUNK_SIZE)) {
- const [commentRows, feedbackRows, feedbackAttachmentRows, assetRows] = await Promise.all([
- db.comment.findMany({
- where: {
- OR: [{ voiceUrl: { in: group } }, { imageUrl: { in: group } }],
- },
- select: {
- voiceUrl: true,
- imageUrl: true,
- },
- }),
- db.userFeedback.findMany({
- where: { screenshotUrl: { in: group } },
- select: { screenshotUrl: true },
- }),
- userFeedbackScreenshotDelegate
- ? userFeedbackScreenshotDelegate.findMany({
- where: { url: { in: group } },
- select: { url: true },
- })
- : Promise.resolve([] as Array<{ url: string }>),
- db.videoAsset.findMany({
- where: { sourceUrl: { in: group } },
- select: { sourceUrl: true },
- }),
- ]);
+ const [commentRows, feedbackRows, feedbackAttachmentRows, assetRows, versionRows] =
+ await Promise.all([
+ db.comment.findMany({
+ where: {
+ OR: [{ voiceUrl: { in: group } }, { imageUrl: { in: group } }],
+ },
+ select: {
+ voiceUrl: true,
+ imageUrl: true,
+ },
+ }),
+ db.userFeedback.findMany({
+ where: { screenshotUrl: { in: group } },
+ select: { screenshotUrl: true },
+ }),
+ userFeedbackScreenshotDelegate
+ ? userFeedbackScreenshotDelegate.findMany({
+ where: { url: { in: group } },
+ select: { url: true },
+ })
+ : Promise.resolve([] as Array<{ url: string }>),
+ db.videoAsset.findMany({
+ where: { sourceUrl: { in: group } },
+ select: { sourceUrl: true },
+ }),
+ db.videoVersion.findMany({
+ where: {
+ OR: [{ originalUrl: { in: group } }, { thumbnailUrl: { in: group } }],
+ },
+ select: { originalUrl: true, thumbnailUrl: true },
+ }),
+ ]);
for (const row of commentRows) {
if (row.voiceUrl) referenced.add(row.voiceUrl);
@@ -132,6 +143,10 @@ async function findReferencedUrls(urls: string[]): Promise> {
for (const row of assetRows) {
if (row.sourceUrl) referenced.add(row.sourceUrl);
}
+ for (const row of versionRows) {
+ if (row.originalUrl) referenced.add(row.originalUrl);
+ if (row.thumbnailUrl) referenced.add(row.thumbnailUrl);
+ }
}
return referenced;
diff --git a/scripts/self-host-bootstrap.ts b/scripts/self-host-bootstrap.ts
index acd5a18..ffc5954 100644
--- a/scripts/self-host-bootstrap.ts
+++ b/scripts/self-host-bootstrap.ts
@@ -1,5 +1,6 @@
import 'dotenv/config';
-import { ensureR2BucketExists, R2_BUCKET_NAME } from '@/lib/r2';
+import { ensureR2BucketExists, ensureR2UploadCors, R2_BUCKET_NAME, r2Client } from '@/lib/r2';
+import { isS3VideoUploadsEnabled } from '@/lib/feature-flags';
import { logError } from '@/lib/logger';
const shouldCreateBucket = /^(1|true|yes|on)$/i.test(
@@ -15,9 +16,26 @@ async function main() {
console.log(`Ensuring object storage bucket exists: ${R2_BUCKET_NAME}`);
await ensureR2BucketExists();
console.log(`Bucket is ready: ${R2_BUCKET_NAME}`);
+
+ if (isS3VideoUploadsEnabled()) {
+ try {
+ const origins = await ensureR2UploadCors();
+ console.log(`Configured upload CORS for origins: ${origins.join(', ')}`);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.warn(
+ `Skipping automatic bucket CORS setup (${message}). ` +
+ 'If direct S3 uploads fail in the browser, configure bucket CORS manually.'
+ );
+ }
+ }
}
-main().catch((error) => {
- logError('Self-host bootstrap failed:', error);
- process.exit(1);
-});
+main()
+ .catch((error) => {
+ logError('Self-host bootstrap failed:', error);
+ process.exit(1);
+ })
+ .finally(() => {
+ r2Client.destroy();
+ });