mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
A comment held one image, and the paste handler took the first item off the clipboard and dropped the rest. Reviewing a cut usually means several screenshots about the same moment, which meant one comment per screenshot or one screenshot and a paragraph describing the others. Editing a comment could not attach anything at all: the edit box had no paste handler, no file picker and no way to remove what was already there. A comment now carries up to five images, in the composer, in a reply and in the editor. One paste stages every image on the clipboard, the file picker takes a multiple selection, and a drop lands on whichever editor is open. Over the cap the extras are refused out loud rather than dropped quietly. A single image still fills the width; several tile into a grid, and either opens full screen on click. The images move into their own table. `comments.imageUrl` stays and follows the first of them, so a reader that has not been updated keeps working, and the migration copies the existing attachments across so the new table is complete from the first read. Every path that resolves a URL back to a comment now asks the new table: R2 cleanup, the orphan sweep, the storage accounting and the reference checks that decide whether an object can be deleted. Left on the old column they would have treated images two through five as unreferenced and swept them. Detaching an image while editing only breaks the link. The file stays in R2 and in the assets pane, which is where it is deleted from and where its bytes are already billed.
303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
import { NextRequest } from 'next/server';
|
|
import { revalidatePath } from 'next/cache';
|
|
import { db } from '@/lib/db';
|
|
import { auth, checkProjectAccess } from '@/lib/auth';
|
|
import { rateLimit } from '@/lib/rate-limit';
|
|
import { collectVideoMediaUrls, deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
|
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
|
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
|
import { logError } from '@/lib/logger';
|
|
import { canDownloadProjectMedia } from '@/lib/project-download';
|
|
|
|
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
|
|
|
|
// GET /api/projects/[projectId]/videos/[videoId]
|
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
|
try {
|
|
const session = await auth();
|
|
const { projectId, videoId } = await params;
|
|
|
|
// Parse query params for pagination and options
|
|
const searchParams = request.nextUrl.searchParams;
|
|
const includeComments = searchParams.get('includeComments') !== 'false';
|
|
const commentLimit = Math.min(parseInt(searchParams.get('commentLimit') || '50'), 100);
|
|
const commentOffset = Math.max(0, parseInt(searchParams.get('commentOffset') || '0'));
|
|
const includeReplies = searchParams.get('includeReplies') === 'true';
|
|
|
|
const video = await db.video.findFirst({
|
|
where: { id: videoId, projectId },
|
|
include: {
|
|
project: true,
|
|
versions: {
|
|
orderBy: { versionNumber: 'desc' },
|
|
...(includeComments
|
|
? {
|
|
include: {
|
|
comments: {
|
|
orderBy: { timestamp: 'asc' },
|
|
skip: commentOffset,
|
|
take: commentLimit,
|
|
select: {
|
|
id: true,
|
|
content: true,
|
|
timestamp: true,
|
|
timestampEnd: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
isResolved: true,
|
|
resolvedAt: true,
|
|
voiceUrl: true,
|
|
voiceDuration: true,
|
|
imageUrl: true,
|
|
images: { select: { id: true, url: true }, orderBy: { position: 'asc' } },
|
|
annotationData: true,
|
|
parentId: true,
|
|
authorId: true,
|
|
tagId: true,
|
|
versionId: true,
|
|
guestName: true,
|
|
// guestEmail excluded for privacy
|
|
author: { select: { id: true, name: true, image: true } },
|
|
tag: { select: { id: true, name: true, color: true } },
|
|
...(includeReplies
|
|
? {
|
|
replies: {
|
|
orderBy: { createdAt: 'asc' },
|
|
select: {
|
|
id: true,
|
|
content: true,
|
|
timestamp: true,
|
|
timestampEnd: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
isResolved: true,
|
|
resolvedAt: true,
|
|
voiceUrl: true,
|
|
voiceDuration: true,
|
|
imageUrl: true,
|
|
images: {
|
|
select: { id: true, url: true },
|
|
orderBy: { position: 'asc' },
|
|
},
|
|
annotationData: true,
|
|
parentId: true,
|
|
authorId: true,
|
|
tagId: true,
|
|
versionId: true,
|
|
guestName: true,
|
|
// guestEmail excluded for privacy
|
|
author: { select: { id: true, name: true, image: true } },
|
|
tag: { select: { id: true, name: true, color: true } },
|
|
},
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
where: { parentId: null },
|
|
},
|
|
_count: { select: { comments: true } },
|
|
},
|
|
}
|
|
: {
|
|
select: {
|
|
id: true,
|
|
thumbnailUrl: true,
|
|
duration: true,
|
|
versionNumber: true,
|
|
versionLabel: true,
|
|
providerId: true,
|
|
videoId: true,
|
|
originalUrl: true,
|
|
title: true,
|
|
isActive: true,
|
|
_count: { select: { comments: true } },
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!video) {
|
|
return apiErrors.notFound('Video');
|
|
}
|
|
|
|
// Check access including workspace membership
|
|
const access = await checkProjectAccess(video.project, session?.user?.id);
|
|
|
|
if (!access.hasAccess) {
|
|
return apiErrors.forbidden('Access denied');
|
|
}
|
|
|
|
const canDownload = canDownloadProjectMedia(video.project, access);
|
|
const response = successResponse({
|
|
...video,
|
|
isAuthenticated: !!session?.user?.id,
|
|
currentUserId: session?.user?.id || null,
|
|
currentUserName: session?.user?.name || null,
|
|
canDownload,
|
|
canManageTags: access.canEdit,
|
|
canResolveComments: access.canEdit,
|
|
canRequestApproval: access.canEdit,
|
|
canShareVideo: access.canEdit,
|
|
canUploadAssets: access.hasAccess,
|
|
canDownloadAssets: canDownload,
|
|
});
|
|
|
|
return withCacheControl(response, 'private, no-cache');
|
|
} catch (error) {
|
|
logError('Error fetching video:', error);
|
|
return apiErrors.internalError('Failed to fetch video');
|
|
}
|
|
}
|
|
|
|
// PATCH /api/projects/[projectId]/videos/[videoId]
|
|
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|
try {
|
|
const limited = await rateLimit(request, 'mutate');
|
|
if (limited) return limited;
|
|
|
|
const session = await auth();
|
|
const { projectId, videoId } = await params;
|
|
|
|
if (!session?.user?.id) {
|
|
return apiErrors.unauthorized();
|
|
}
|
|
|
|
const video = await db.video.findFirst({
|
|
where: { id: videoId, projectId },
|
|
include: {
|
|
project: true,
|
|
},
|
|
});
|
|
|
|
if (!video) {
|
|
return apiErrors.notFound('Video');
|
|
}
|
|
|
|
const access = await checkProjectAccess(video.project, session.user.id);
|
|
if (!access.canEdit) {
|
|
return apiErrors.forbidden('Access denied');
|
|
}
|
|
|
|
const body = await request.json();
|
|
const { title, description, position } = body;
|
|
|
|
// Validate types before using string methods to prevent type confusion attacks
|
|
if (
|
|
position !== undefined &&
|
|
(typeof position !== 'number' || !Number.isInteger(position) || position < 0)
|
|
) {
|
|
return apiErrors.badRequest('position must be a non-negative integer');
|
|
}
|
|
|
|
const updateData: Record<string, unknown> = {};
|
|
if (typeof title === 'string') updateData.title = title.trim();
|
|
if (typeof description === 'string') updateData.description = description.trim() || null;
|
|
if (position !== undefined) updateData.position = position;
|
|
|
|
// Keep the response to scalar video fields: including versions would pull
|
|
// in BigInt columns (sizeBytes) that JSON.stringify cannot serialize, and
|
|
// no caller consumes the success payload beyond these fields.
|
|
const updatedVideo = await db.video.update({
|
|
where: { id: videoId },
|
|
data: updateData,
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
description: true,
|
|
position: true,
|
|
projectId: true,
|
|
updatedAt: true,
|
|
},
|
|
});
|
|
|
|
const response = successResponse(updatedVideo);
|
|
return withCacheControl(response, 'private, no-store');
|
|
} catch (error) {
|
|
logError('Error updating video:', error);
|
|
return apiErrors.internalError('Failed to update video');
|
|
}
|
|
}
|
|
|
|
// DELETE /api/projects/[projectId]/videos/[videoId]
|
|
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, videoId } = await params;
|
|
|
|
if (!session?.user?.id) {
|
|
return apiErrors.unauthorized();
|
|
}
|
|
|
|
const video = await db.video.findFirst({
|
|
where: { id: videoId, projectId },
|
|
include: {
|
|
versions: {
|
|
select: {
|
|
providerId: true,
|
|
videoId: true,
|
|
},
|
|
},
|
|
assets: {
|
|
select: {
|
|
provider: true,
|
|
providerVideoId: true,
|
|
},
|
|
},
|
|
project: true,
|
|
},
|
|
});
|
|
|
|
if (!video) {
|
|
return apiErrors.notFound('Video');
|
|
}
|
|
|
|
const access = await checkProjectAccess(video.project, session.user.id);
|
|
if (!access.canEdit) {
|
|
return apiErrors.forbidden('Only project owner or admin can delete videos');
|
|
}
|
|
|
|
const bunnyRefs = [
|
|
...video.versions,
|
|
...video.assets
|
|
.filter((asset) => asset.provider === 'BUNNY' && !!asset.providerVideoId)
|
|
.map((asset) => ({
|
|
providerId: 'bunny',
|
|
videoId: asset.providerVideoId as string,
|
|
})),
|
|
];
|
|
|
|
const mediaUrls = await collectVideoMediaUrls(videoId);
|
|
|
|
await db.video.delete({ where: { id: videoId } });
|
|
|
|
revalidatePath(`/projects/${projectId}`);
|
|
|
|
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
|
|
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
|
|
deleteMediaFilesBestEffort(mediaUrls),
|
|
]);
|
|
const cleanupInput = {
|
|
bunny: bunnyCleanupResult,
|
|
r2: r2CleanupResult,
|
|
};
|
|
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
|
if (cleanupWarnings) {
|
|
logCleanupWarnings({ entityType: 'video', entityId: videoId }, cleanupInput);
|
|
}
|
|
|
|
const response = successResponse({
|
|
message: 'Video deleted',
|
|
...(cleanupWarnings ? { cleanupWarnings } : {}),
|
|
});
|
|
return withCacheControl(response, 'private, no-store');
|
|
} catch (error) {
|
|
logError('Error deleting video:', error);
|
|
return apiErrors.internalError('Failed to delete video');
|
|
}
|
|
}
|