feat(comments): add secure CSV/PDF export for version comments with auth, rate limits, and CSV injection hardening

This commit is contained in:
Yusuf İpek
2026-02-22 13:56:02 +03:00
parent 47f12b38fa
commit 72d14f4a5d
5 changed files with 604 additions and 2 deletions
@@ -0,0 +1,188 @@
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import {
buildCommentsCsv,
buildCommentsPdf,
buildExportFileBaseName,
flattenCommentsForExport,
} from '@/lib/comment-export';
import { apiErrors, withCacheControl } from '@/lib/api-response';
import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ versionId: string }> };
const MAX_EXPORT_COMMENTS = 5000;
const EXPORT_RATE_LIMIT = { windowMs: 60 * 1000, maxRequests: 10 };
// GET /api/versions/[versionId]/comments/export?format=csv|pdf&includeResolved=true|false
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'comment-export', EXPORT_RATE_LIMIT);
if (limited) return limited;
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized('Authentication required for exports');
}
const { versionId } = await params;
const { searchParams } = new URL(request.url);
const format = (searchParams.get('format') || 'csv').toLowerCase();
if (format !== 'csv' && format !== 'pdf') {
return apiErrors.badRequest('Invalid format. Use "csv" or "pdf"');
}
const includeResolved = searchParams.get('includeResolved') !== 'false';
const version = await db.videoVersion.findUnique({
where: { id: versionId },
select: {
id: true,
versionNumber: true,
versionLabel: true,
video: {
select: {
title: true,
project: {
select: {
id: true,
ownerId: true,
workspaceId: true,
visibility: true,
members: {
where: { userId: session.user.id },
select: { id: true },
},
},
},
},
},
},
});
if (!version) {
return apiErrors.notFound('Version');
}
const project = version.video.project;
const isOwner = session.user.id === project.ownerId;
const isMember = project.members.length > 0;
let isWorkspaceMember = false;
if (!isOwner && !isMember) {
const [workspaceMember, workspace] = await Promise.all([
db.workspaceMember.findUnique({
where: {
workspaceId_userId: {
workspaceId: project.workspaceId,
userId: session.user.id,
},
},
select: { id: true },
}),
db.workspace.findUnique({
where: { id: project.workspaceId },
select: { ownerId: true },
}),
]);
isWorkspaceMember = !!workspaceMember || workspace?.ownerId === session.user.id;
}
if (!isOwner && !isMember && !isWorkspaceMember) {
return apiErrors.notFound('Version');
}
const totalComments = await db.comment.count({
where: {
versionId,
...(includeResolved ? {} : { isResolved: false }),
},
});
if (totalComments > MAX_EXPORT_COMMENTS) {
return apiErrors.badRequest(
`Too many comments to export (${totalComments}). Maximum allowed is ${MAX_EXPORT_COMMENTS}.`
);
}
const comments = await db.comment.findMany({
where: {
versionId,
parentId: null,
...(includeResolved ? {} : { isResolved: false }),
},
orderBy: { timestamp: 'asc' },
select: {
id: true,
parentId: true,
content: true,
timestamp: true,
timestampEnd: true,
isResolved: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
annotationData: true,
createdAt: true,
author: { select: { name: true } },
guestName: true,
tag: { select: { name: true } },
replies: {
orderBy: { createdAt: 'asc' },
select: {
id: true,
parentId: true,
content: true,
timestamp: true,
timestampEnd: true,
isResolved: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
annotationData: true,
createdAt: true,
author: { select: { name: true } },
guestName: true,
tag: { select: { name: true } },
},
},
},
});
const rows = flattenCommentsForExport(comments);
const fileBaseName = buildExportFileBaseName(version.video.title, version.versionNumber);
const versionMeta = {
videoTitle: version.video.title,
versionNumber: version.versionNumber,
versionLabel: version.versionLabel,
};
if (format === 'csv') {
const csv = buildCommentsCsv(rows, versionMeta);
const response = new Response(csv, {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${fileBaseName}.csv"`,
},
});
return withCacheControl(response, 'private, no-store');
}
const pdf = buildCommentsPdf(rows, versionMeta);
const pdfBytes = Uint8Array.from(pdf);
const response = new Response(pdfBytes, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${fileBaseName}.pdf"`,
},
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error exporting comments:', error);
return apiErrors.internalError('Failed to export comments');
}
}