From 72d14f4a5d2261782baa23bb4fba2176a62dc35d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Sun, 22 Feb 2026 13:56:02 +0300 Subject: [PATCH] feat(comments): add secure CSV/PDF export for version comments with auth, rate limits, and CSV injection hardening --- app/admin/page.tsx | 2 +- app/admin/users/page.tsx | 2 +- .../[versionId]/comments/export/route.ts | 188 ++++++++++ components/video-page-content.tsx | 87 +++++ lib/comment-export.ts | 327 ++++++++++++++++++ 5 files changed, 604 insertions(+), 2 deletions(-) create mode 100644 app/api/versions/[versionId]/comments/export/route.ts create mode 100644 lib/comment-export.ts diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 8d3d41d..e1cb39a 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -14,7 +14,7 @@ export const metadata: Metadata = { function formatBytes(bytes: number, decimals = 2) { if (bytes < 0) return 'Error Fetching'; if (!+bytes) return '0 Bytes'; - const k = 1024; + const k = 1000; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index d4849b1..3b388e2 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -28,7 +28,7 @@ export const metadata: Metadata = { function formatBytes(bytes: number, decimals = 2) { if (bytes < 0) return 'Error Fetching'; if (!+bytes) return '0 Bytes'; - const k = 1024; + const k = 1000; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); diff --git a/app/api/versions/[versionId]/comments/export/route.ts b/app/api/versions/[versionId]/comments/export/route.ts new file mode 100644 index 0000000..4e4c074 --- /dev/null +++ b/app/api/versions/[versionId]/comments/export/route.ts @@ -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'); + } +} diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index ddc9ca7..682ebc6 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -41,6 +41,7 @@ import { Minimize, Image as ImageIcon, Download, + FileText, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -253,6 +254,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const voiceKnownDurationRef = useRef(0); const [selectedTimestamp, setSelectedTimestamp] = useState(null); const [showResolved, setShowResolved] = useState(false); + const [isExportingCsv, setIsExportingCsv] = useState(false); + const [isExportingPdf, setIsExportingPdf] = useState(false); // Watch progress state const [savedProgress, setSavedProgress] = useState(null); @@ -367,6 +370,64 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi setShowResolved(prev => !prev); }, []); + const handleExportComments = useCallback( + async (format: 'csv' | 'pdf') => { + if (!activeVersionId) return; + + if (format === 'csv') { + setIsExportingCsv(true); + } else { + setIsExportingPdf(true); + } + + try { + const response = await fetch( + `/api/versions/${activeVersionId}/comments/export?format=${format}&includeResolved=${showResolved}` + ); + + if (!response.ok) { + let message = 'Failed to export comments'; + try { + const data = await response.json(); + if (typeof data?.error === 'string') { + message = data.error; + } + } catch { + // Keep fallback message when response is not JSON. + } + throw new Error(message); + } + + const blob = await response.blob(); + const disposition = response.headers.get('content-disposition'); + const fallbackName = `comments.${format}`; + const matched = disposition?.match(/filename="?([^"]+)"?/i); + const filename = matched?.[1] || fallbackName; + + const downloadUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(downloadUrl); + + toast.success(`Comments exported as ${format.toUpperCase()}`); + } catch (error) { + console.error('Failed to export comments:', error); + toast.error(error instanceof Error ? error.message : 'Failed to export comments'); + } finally { + if (format === 'csv') { + setIsExportingCsv(false); + } else { + setIsExportingPdf(false); + } + } + }, + [activeVersionId, showResolved] + ); + const handleVideoMouseMove = useCallback(() => { setCursorIdle(false); if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current); @@ -3164,6 +3225,32 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi + + diff --git a/lib/comment-export.ts b/lib/comment-export.ts new file mode 100644 index 0000000..d0815e2 --- /dev/null +++ b/lib/comment-export.ts @@ -0,0 +1,327 @@ +interface ExportAuthor { + name: string | null; +} + +interface ExportTag { + name: string; +} + +interface ExportComment { + id: string; + parentId: string | null; + content: string | null; + timestamp: number; + timestampEnd: number | null; + isResolved: boolean; + voiceUrl: string | null; + voiceDuration: number | null; + imageUrl: string | null; + annotationData: string | null; + createdAt: Date; + author: ExportAuthor | null; + guestName: string | null; + tag: ExportTag | null; + replies: Omit[]; +} + +export interface ExportCommentRow { + commentId: string; + parentCommentId: string | null; + level: 0 | 1; + authorName: string; + authorType: 'user' | 'guest'; + content: string; + timestamp: number; + timestampEnd: number | null; + tag: string; + isResolved: boolean; + hasVoiceNote: boolean; + voiceDuration: number | null; + hasImageAttachment: boolean; + hasAnnotation: boolean; + createdAtIso: string; +} + +function csvCell(value: string | number | boolean | null): string { + const raw = value === null ? '' : String(value); + const neutralized = /^[\s]*[=+\-@]/.test(raw) ? `'${raw}` : raw; + return `"${neutralized.replace(/"/g, '""')}"`; +} + +function formatTimestamp(seconds: number): string { + const totalSeconds = Math.floor(seconds); + const hrs = Math.floor(totalSeconds / 3600); + const mins = Math.floor((totalSeconds % 3600) / 60); + const secs = totalSeconds % 60; + + if (hrs > 0) { + return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + +function toPdfSafeAscii(value: string): string { + return value.replace(/[^\x20-\x7E]/g, '?'); +} + +function escapePdfText(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); +} + +function wrapLine(value: string, maxChars: number): string[] { + const text = value.trim(); + if (!text) return ['']; + + const words = text.split(/\s+/); + const lines: string[] = []; + let current = ''; + + for (const word of words) { + const next = current ? `${current} ${word}` : word; + if (next.length <= maxChars) { + current = next; + continue; + } + + if (current) lines.push(current); + + if (word.length <= maxChars) { + current = word; + continue; + } + + let chunk = word; + while (chunk.length > maxChars) { + lines.push(chunk.slice(0, maxChars)); + chunk = chunk.slice(maxChars); + } + current = chunk; + } + + if (current) lines.push(current); + return lines; +} + +function sanitizeFileSegment(input: string): string { + const cleaned = input + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + + return cleaned || 'comments'; +} + +export function buildExportFileBaseName(videoTitle: string, versionNumber: number): string { + return `${sanitizeFileSegment(videoTitle)}-v${versionNumber}-comments`; +} + +export function flattenCommentsForExport(comments: ExportComment[]): ExportCommentRow[] { + const rows: ExportCommentRow[] = []; + + for (const comment of comments) { + rows.push({ + commentId: comment.id, + parentCommentId: null, + level: 0, + authorName: comment.author?.name || comment.guestName || 'Anonymous', + authorType: comment.author ? 'user' : 'guest', + content: comment.content || '', + timestamp: comment.timestamp, + timestampEnd: comment.timestampEnd, + tag: comment.tag?.name || '', + isResolved: comment.isResolved, + hasVoiceNote: !!comment.voiceUrl, + voiceDuration: comment.voiceDuration, + hasImageAttachment: !!comment.imageUrl, + hasAnnotation: !!comment.annotationData, + createdAtIso: comment.createdAt.toISOString(), + }); + + for (const reply of comment.replies) { + rows.push({ + commentId: reply.id, + parentCommentId: comment.id, + level: 1, + authorName: reply.author?.name || reply.guestName || 'Anonymous', + authorType: reply.author ? 'user' : 'guest', + content: reply.content || '', + timestamp: reply.timestamp, + timestampEnd: reply.timestampEnd, + tag: reply.tag?.name || '', + isResolved: reply.isResolved, + hasVoiceNote: !!reply.voiceUrl, + voiceDuration: reply.voiceDuration, + hasImageAttachment: !!reply.imageUrl, + hasAnnotation: !!reply.annotationData, + createdAtIso: reply.createdAt.toISOString(), + }); + } + } + + return rows; +} + +export function buildCommentsCsv( + rows: ExportCommentRow[], + meta: { videoTitle: string; versionNumber: number; versionLabel: string | null } +): string { + const header = [ + 'video_title', + 'version_number', + 'version_label', + 'comment_id', + 'parent_comment_id', + 'thread_level', + 'author_name', + 'author_type', + 'timestamp_seconds', + 'timestamp_hhmmss', + 'timestamp_end_seconds', + 'is_resolved', + 'tag', + 'content', + 'has_voice_note', + 'voice_duration_seconds', + 'has_image_attachment', + 'has_annotation', + 'created_at_iso', + ]; + + const lines = [header.map(csvCell).join(',')]; + for (const row of rows) { + lines.push( + [ + meta.videoTitle, + meta.versionNumber, + meta.versionLabel || '', + row.commentId, + row.parentCommentId, + row.level, + row.authorName, + row.authorType, + row.timestamp.toFixed(3), + formatTimestamp(row.timestamp), + row.timestampEnd === null ? '' : row.timestampEnd.toFixed(3), + row.isResolved, + row.tag, + row.content, + row.hasVoiceNote, + row.voiceDuration === null ? '' : row.voiceDuration.toFixed(3), + row.hasImageAttachment, + row.hasAnnotation, + row.createdAtIso, + ].map(csvCell).join(',') + ); + } + + return lines.join('\n'); +} + +export function buildCommentsPdf( + rows: ExportCommentRow[], + meta: { videoTitle: string; versionNumber: number; versionLabel: string | null } +): Buffer { + const lines: string[] = []; + const versionTitle = meta.versionLabel + ? `v${meta.versionNumber} (${meta.versionLabel})` + : `v${meta.versionNumber}`; + + lines.push(`OpenFrame Comments Export`); + lines.push(`Video: ${meta.videoTitle}`); + lines.push(`Version: ${versionTitle}`); + lines.push(`Generated At: ${new Date().toISOString()}`); + lines.push(`Total Entries: ${rows.length}`); + lines.push(''); + + rows.forEach((row, index) => { + const prefix = row.level === 1 ? ' Reply' : 'Comment'; + const base = `${index + 1}. ${prefix} ${formatTimestamp(row.timestamp)} by ${row.authorName}`; + const details = [ + `resolved=${row.isResolved ? 'yes' : 'no'}`, + `voice=${row.hasVoiceNote ? 'yes' : 'no'}`, + `image=${row.hasImageAttachment ? 'yes' : 'no'}`, + `annotation=${row.hasAnnotation ? 'yes' : 'no'}`, + row.tag ? `tag=${row.tag}` : null, + ].filter((item): item is string => item !== null).join(', '); + + lines.push(base); + lines.push(` ${details}`); + if (row.content) { + lines.push(...wrapLine(` ${row.content}`, 96)); + } + lines.push(` created_at=${row.createdAtIso}`); + lines.push(''); + }); + + return buildSimplePdf(lines); +} + +function buildSimplePdf(lines: string[]): Buffer { + const pageWidth = 612; + const pageHeight = 792; + const margin = 40; + const lineHeight = 14; + const maxLinesPerPage = Math.floor((pageHeight - margin * 2) / lineHeight); + + const pages: string[][] = []; + let currentPage: string[] = []; + + for (const line of lines) { + if (currentPage.length >= maxLinesPerPage) { + pages.push(currentPage); + currentPage = []; + } + currentPage.push(line); + } + if (currentPage.length > 0) pages.push(currentPage); + if (pages.length === 0) pages.push(['No comments']); + + const objects: string[] = []; + const pageRefs: string[] = []; + + objects[0] = '<< /Type /Catalog /Pages 2 0 R >>'; + objects[1] = ''; + objects[2] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'; + + for (const pageLines of pages) { + const contentLines = ['BT', '/F1 11 Tf']; + pageLines.forEach((line, lineIndex) => { + const y = pageHeight - margin - lineIndex * lineHeight; + const safeLine = escapePdfText(toPdfSafeAscii(line)); + contentLines.push(`1 0 0 1 ${margin} ${y} Tm (${safeLine}) Tj`); + }); + contentLines.push('ET'); + + const stream = contentLines.join('\n'); + const contentObject = `<< /Length ${Buffer.byteLength(stream, 'utf8')} >>\nstream\n${stream}\nendstream`; + const contentObjNumber = objects.length + 1; + objects.push(contentObject); + + const pageObjNumber = objects.length + 1; + objects.push( + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentObjNumber} 0 R >>` + ); + pageRefs.push(`${pageObjNumber} 0 R`); + } + + objects[1] = `<< /Type /Pages /Kids [${pageRefs.join(' ')}] /Count ${pageRefs.length} >>`; + + let pdf = '%PDF-1.4\n'; + const offsets: number[] = [0]; + + objects.forEach((obj, index) => { + offsets.push(Buffer.byteLength(pdf, 'utf8')); + const objNum = index + 1; + pdf += `${objNum} 0 obj\n${obj}\nendobj\n`; + }); + + const xrefOffset = Buffer.byteLength(pdf, 'utf8'); + pdf += `xref\n0 ${objects.length + 1}\n`; + pdf += '0000000000 65535 f \n'; + for (let i = 1; i < offsets.length; i += 1) { + pdf += `${offsets[i].toString().padStart(10, '0')} 00000 n \n`; + } + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`; + + return Buffer.from(pdf, 'utf8'); +}