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
+87
View File
@@ -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<number>(0);
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null);
const [showResolved, setShowResolved] = useState(false);
const [isExportingCsv, setIsExportingCsv] = useState(false);
const [isExportingPdf, setIsExportingPdf] = useState(false);
// Watch progress state
const [savedProgress, setSavedProgress] = useState<number | null>(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
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleToggleShowResolved(); }}>
{showResolved ? 'Hide' : 'Show'} Resolved
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={!activeVersion || isExportingCsv || isExportingPdf}
onClick={(e) => {
e.stopPropagation();
handleExportComments('csv');
}}
title="Download comments as CSV"
>
{isExportingCsv ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={!activeVersion || isExportingCsv || isExportingPdf}
onClick={(e) => {
e.stopPropagation();
handleExportComments('pdf');
}}
title="Download comments as PDF"
>
{isExportingPdf ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileText className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 lg:hidden" onClick={() => setIsMobileCommentsOpen(false)}>
<X className="h-4 w-4" />
</Button>