fix: address security vulnerabilities and add image attachments

- Fix type confusion vulnerability in comment content updates
- Validate pagination offsets to prevent negative values
- Validate timestamp is a valid number before parsing
- Exclude guestEmail from comment API responses for privacy
- Fix TypeScript error in audio upload route
- Add image attachment support for comments with upload API
- Update admin dashboard to track image attachments
- Rename cleanup functions to handle both voice and image media
This commit is contained in:
Yusuf İpek
2026-02-21 16:40:58 +03:00
parent cd9b89c971
commit e32196c430
15 changed files with 837 additions and 119 deletions
+14 -1
View File
@@ -4,7 +4,7 @@ import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getCachedTotalStorage } from '@/lib/admin-stats'; import { getCachedTotalStorage } from '@/lib/admin-stats';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Users, Folder, Video, MessageSquare, Mic, HardDrive } from 'lucide-react'; import { Users, Folder, Video, MessageSquare, Mic, HardDrive, Image as ImageIcon } from 'lucide-react';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Admin Dashboard | OpenFrame', title: 'Admin Dashboard | OpenFrame',
@@ -34,6 +34,7 @@ export default async function AdminDashboardPage() {
totalVideos, totalVideos,
totalComments, totalComments,
totalVoiceComments, totalVoiceComments,
totalImageComments,
] = await Promise.all([ ] = await Promise.all([
db.user.count(), db.user.count(),
db.project.count(), db.project.count(),
@@ -42,6 +43,9 @@ export default async function AdminDashboardPage() {
db.comment.count({ db.comment.count({
where: { voiceUrl: { not: null } }, where: { voiceUrl: { not: null } },
}), }),
db.comment.count({
where: { imageUrl: { not: null } },
}),
]); ]);
// 2. Storage Stats (Cached) // 2. Storage Stats (Cached)
@@ -101,6 +105,15 @@ export default async function AdminDashboardPage() {
<div className="text-2xl font-bold">{totalVoiceComments}</div> <div className="text-2xl font-bold">{totalVoiceComments}</div>
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Image Attachments</CardTitle>
<ImageIcon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalImageComments}</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Cloudflare R2 Storage</CardTitle> <CardTitle className="text-sm font-medium">Cloudflare R2 Storage</CardTitle>
+16 -6
View File
@@ -2,7 +2,8 @@ import { Metadata } from 'next';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getCachedUserVoiceStorage } from '@/lib/admin-stats'; import { getCachedUserMediaStorage } from '@/lib/admin-stats';
import { HardDrive } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -69,8 +70,8 @@ export default async function AdminUsersPage({
const totalPages = Math.ceil(totalUsers / pageSize); const totalPages = Math.ceil(totalUsers / pageSize);
// Determine voice storage per user (Cached) // Determine media storage per user (Cached)
const userStorage = await getCachedUserVoiceStorage(); const userStorage = await getCachedUserMediaStorage();
return ( return (
<div className="flex-1 space-y-4 px-4 md:px-8"> <div className="flex-1 space-y-4 px-4 md:px-8">
@@ -95,7 +96,7 @@ export default async function AdminUsersPage({
<TableHead className="text-center">Workspaces Owned</TableHead> <TableHead className="text-center">Workspaces Owned</TableHead>
<TableHead className="text-center">Projects Owned</TableHead> <TableHead className="text-center">Projects Owned</TableHead>
<TableHead className="text-center">Total Comments</TableHead> <TableHead className="text-center">Total Comments</TableHead>
<TableHead className="text-right">Voice Storage</TableHead> <TableHead className="text-right">Media Storage</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -120,8 +121,17 @@ export default async function AdminUsersPage({
<TableCell className="text-center">{user._count.ownedWorkspaces}</TableCell> <TableCell className="text-center">{user._count.ownedWorkspaces}</TableCell>
<TableCell className="text-center">{user._count.projects}</TableCell> <TableCell className="text-center">{user._count.projects}</TableCell>
<TableCell className="text-center">{user._count.comments}</TableCell> <TableCell className="text-center">{user._count.comments}</TableCell>
<TableCell className="text-right text-muted-foreground text-sm"> <TableCell className="text-right text-sm">
{formatBytes(userStorage[user.id] || 0)} <div className="flex flex-col items-end">
<span className="font-medium text-foreground">{formatBytes(userStorage[user.id]?.total || 0)}</span>
{(userStorage[user.id]?.voice > 0 || userStorage[user.id]?.image > 0) && (
<span className="text-xs text-muted-foreground mt-0.5 whitespace-nowrap space-x-1">
{userStorage[user.id]?.voice > 0 && <span>🎤 {formatBytes(userStorage[user.id]?.voice)}</span>}
{userStorage[user.id]?.voice > 0 && userStorage[user.id]?.image > 0 && <span></span>}
{userStorage[user.id]?.image > 0 && <span>🖼 {formatBytes(userStorage[user.id]?.image)}</span>}
</span>
)}
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
+9
View File
@@ -17,6 +17,7 @@ export async function GET() {
totalVideos, totalVideos,
totalComments, totalComments,
totalVoiceComments, totalVoiceComments,
totalImageComments,
] = await Promise.all([ ] = await Promise.all([
db.user.count(), db.user.count(),
db.project.count(), db.project.count(),
@@ -28,6 +29,13 @@ export async function GET() {
not: null, not: null,
} }
} }
}),
db.comment.count({
where: {
imageUrl: {
not: null,
}
}
}) })
]); ]);
@@ -40,6 +48,7 @@ export async function GET() {
totalVideos, totalVideos,
totalComments, totalComments,
totalVoiceComments, totalVoiceComments,
totalImageComments,
totalStorageBytes, totalStorageBytes,
}); });
} catch (error) { } catch (error) {
+58 -14
View File
@@ -16,12 +16,46 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const comment = await db.comment.findUnique({ const comment = await db.comment.findUnique({
where: { id: commentId }, where: { id: commentId },
include: { select: {
id: true,
content: true,
timestamp: true,
timestampEnd: true,
createdAt: true,
updatedAt: true,
isResolved: true,
resolvedAt: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
parentId: true,
authorId: true,
tagId: true,
versionId: true,
guestName: true,
author: { select: { id: true, name: true, image: true } }, author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
replies: { replies: {
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
include: { select: {
id: true,
content: true,
timestamp: true,
timestampEnd: true,
createdAt: true,
updatedAt: true,
isResolved: true,
resolvedAt: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
parentId: true,
authorId: true,
tagId: true,
versionId: true,
guestName: true,
author: { select: { id: true, name: true, image: true } }, author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
}, },
}, },
version: { version: {
@@ -155,7 +189,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
} }
const updateData: Record<string, unknown> = {}; const updateData: Record<string, unknown> = {};
if (content !== undefined) updateData.content = content.trim(); if (content !== undefined && typeof content === 'string') updateData.content = content.trim();
if (tagId !== undefined) updateData.tagId = tagId; if (tagId !== undefined) updateData.tagId = tagId;
if (isResolved !== undefined) { if (isResolved !== undefined) {
updateData.isResolved = isResolved; updateData.isResolved = isResolved;
@@ -201,7 +235,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
const comment = await db.comment.findUnique({ const comment = await db.comment.findUnique({
where: { id: commentId }, where: { id: commentId },
include: { include: {
replies: { select: { voiceUrl: true } }, replies: { select: { voiceUrl: true, imageUrl: true } },
}, },
}); });
@@ -215,27 +249,37 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.forbidden('You can only delete your own comments'); return apiErrors.forbidden('You can only delete your own comments');
} }
// Collect all voice URLs to delete from R2 (comment + its replies) // Collect all media URLs to delete from R2 (comment + its replies)
const voiceUrls: string[] = []; const mediaUrls: string[] = [];
if (comment.voiceUrl) voiceUrls.push(comment.voiceUrl); if (comment.voiceUrl) mediaUrls.push(comment.voiceUrl);
if (comment.imageUrl) mediaUrls.push(comment.imageUrl);
for (const reply of comment.replies) { for (const reply of comment.replies) {
if (reply.voiceUrl) voiceUrls.push(reply.voiceUrl); if (reply.voiceUrl) mediaUrls.push(reply.voiceUrl);
if (reply.imageUrl) mediaUrls.push(reply.imageUrl);
} }
await db.comment.delete({ where: { id: commentId } }); await db.comment.delete({ where: { id: commentId } });
// Clean up audio files from R2 (best-effort, don't block on failure) // Clean up media files from R2 (best-effort, don't block on failure)
const AUDIO_PREFIX = '/api/upload/audio/'; const AUDIO_PREFIX = '/api/upload/audio/';
for (const url of voiceUrls) { const IMAGE_PREFIX = '/api/upload/image/';
for (const url of mediaUrls) {
try { try {
// Extract filename using string parsing (safe against ReDoS) // Extract filename using string parsing (safe against ReDoS)
const idx = url.indexOf(AUDIO_PREFIX); let key: string | null = null;
const filename = idx !== -1 ? url.slice(idx + AUDIO_PREFIX.length) : null; if (url.includes(AUDIO_PREFIX)) {
if (filename) { const filename = url.slice(url.indexOf(AUDIO_PREFIX) + AUDIO_PREFIX.length);
if (filename) key = `voice/${filename}`;
} else if (url.includes(IMAGE_PREFIX)) {
const filename = url.slice(url.indexOf(IMAGE_PREFIX) + IMAGE_PREFIX.length);
if (filename) key = `images/${filename}`;
}
if (key) {
await r2Client.send( await r2Client.send(
new DeleteObjectCommand({ new DeleteObjectCommand({
Bucket: R2_BUCKET_NAME, Bucket: R2_BUCKET_NAME,
Key: `voice/${filename}`, Key: key,
}) })
); );
} }
+3 -3
View File
@@ -3,7 +3,7 @@ import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { ProjectMemberRole, ProjectVisibility } from '@prisma/client'; import { ProjectMemberRole, ProjectVisibility } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { cleanupProjectVoiceFiles } from '@/lib/r2-cleanup'; import { cleanupProjectMediaFiles } from '@/lib/r2-cleanup';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
type RouteParams = { params: Promise<{ projectId: string }> }; type RouteParams = { params: Promise<{ projectId: string }> };
@@ -56,7 +56,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// Parse pagination params // Parse pagination params
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100); const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
const offset = parseInt(searchParams.get('offset') || '0'); const offset = Math.max(0, parseInt(searchParams.get('offset') || '0'));
const project = await db.project.findUnique({ const project = await db.project.findUnique({
where: { id: projectId }, where: { id: projectId },
@@ -197,7 +197,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
} }
// Clean up voice files from R2 before cascade delete removes comment rows // Clean up voice files from R2 before cascade delete removes comment rows
await cleanupProjectVoiceFiles(projectId); await cleanupProjectMediaFiles(projectId);
await db.project.delete({ where: { id: projectId } }); await db.project.delete({ where: { id: projectId } });
@@ -4,7 +4,7 @@ import { db } from '@/lib/db';
import { auth, checkProjectAccess } from '@/lib/auth'; import { auth, checkProjectAccess } from '@/lib/auth';
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client'; import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { cleanupVideoVoiceFiles } from '@/lib/r2-cleanup'; import { cleanupVideoMediaFiles } from '@/lib/r2-cleanup';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> }; type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
@@ -18,7 +18,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// Parse query params for pagination and options // Parse query params for pagination and options
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const commentLimit = Math.min(parseInt(searchParams.get('commentLimit') || '50'), 100); const commentLimit = Math.min(parseInt(searchParams.get('commentLimit') || '50'), 100);
const commentOffset = parseInt(searchParams.get('commentOffset') || '0'); const commentOffset = Math.max(0, parseInt(searchParams.get('commentOffset') || '0'));
const includeReplies = searchParams.get('includeReplies') === 'true'; const includeReplies = searchParams.get('includeReplies') === 'true';
const video = await db.video.findFirst({ const video = await db.video.findFirst({
@@ -32,20 +32,54 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
orderBy: { timestamp: 'asc' }, orderBy: { timestamp: 'asc' },
skip: commentOffset, skip: commentOffset,
take: commentLimit, take: commentLimit,
include: { select: {
id: true,
content: true,
timestamp: true,
timestampEnd: true,
createdAt: true,
updatedAt: true,
isResolved: true,
resolvedAt: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
parentId: true,
authorId: true,
tagId: true,
versionId: true,
guestName: true,
// guestEmail excluded for privacy
author: { select: { id: true, name: true, image: true } }, author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } }, tag: { select: { id: true, name: true, color: true } },
...(includeReplies ? { ...(includeReplies ? {
replies: { replies: {
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
include: { select: {
id: true,
content: true,
timestamp: true,
timestampEnd: true,
createdAt: true,
updatedAt: true,
isResolved: true,
resolvedAt: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
parentId: true,
authorId: true,
tagId: true,
versionId: true,
guestName: true,
// guestEmail excluded for privacy
author: { select: { id: true, name: true, image: true } }, author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } }, tag: { select: { id: true, name: true, color: true } },
}, },
}, },
} : {}), } : {}),
}, },
where: { parentId: null }, // Only top-level comments where: { parentId: null },
}, },
_count: { select: { comments: true } }, _count: { select: { comments: true } },
}, },
@@ -194,7 +228,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
} }
// Clean up voice files from R2 before cascade delete removes comment rows // Clean up voice files from R2 before cascade delete removes comment rows
await cleanupVideoVoiceFiles(videoId); await cleanupVideoMediaFiles(videoId);
await db.video.delete({ where: { id: videoId } }); await db.video.delete({ where: { id: videoId } });
+2 -2
View File
@@ -62,8 +62,8 @@ export async function GET(
// Convert stream to Uint8Array // Convert stream to Uint8Array
const chunks: Uint8Array[] = []; const chunks: Uint8Array[] = [];
// @ts-expect-error - body is an iterable const asyncIterable = body as AsyncIterable<Uint8Array>;
for await (const chunk of body) { for await (const chunk of asyncIterable) {
chunks.push(chunk); chunks.push(chunk);
} }
const uint8Array = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0)); const uint8Array = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
+88
View File
@@ -0,0 +1,88 @@
import { NextResponse } from 'next/server';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { apiErrors } from '@/lib/api-response';
// Only allow UUID filenames with safe extensions
const SAFE_FILENAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
const CONTENT_TYPE_MAP: Record<string, string> = {
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
svg: 'image/svg+xml',
};
function getContentType(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || '';
return CONTENT_TYPE_MAP[ext] || 'image/jpeg';
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ filename: string }> }
) {
try {
const { filename } = await params;
// Validate filename to prevent path traversal
if (!SAFE_FILENAME.test(filename)) {
return apiErrors.badRequest('Invalid filename');
}
const key = `images/${filename}`;
// Get file metadata to determine content type
const headResponse = await r2Client.send(
new HeadObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
})
);
const contentType = headResponse.ContentType || getContentType(filename);
const objectResponse = await r2Client.send(
new GetObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
})
);
const body = objectResponse.Body;
if (!body) {
return apiErrors.internalError('Empty file');
}
const chunks: Uint8Array[] = [];
// @ts-expect-error - body is an iterable
for await (const chunk of body) {
chunks.push(chunk);
}
const uint8Array = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
let offset = 0;
for (const chunk of chunks) {
uint8Array.set(chunk, offset);
offset += chunk.length;
}
return new NextResponse(uint8Array, {
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
'Accept-Ranges': 'bytes',
},
});
} catch (error: unknown) {
const errorName = error instanceof Error ? error.name : '';
if (errorName === 'NoSuchKey') {
return apiErrors.notFound('File');
}
console.error('Error serving image:', error);
return apiErrors.internalError('Failed to retrieve image');
}
}
+84
View File
@@ -0,0 +1,84 @@
import { auth } from '@/lib/auth';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto';
import { rateLimit } from '@/lib/rate-limit';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'image/svg+xml'
];
export async function POST(request: Request) {
try {
// Check Content-Length header BEFORE loading the file
const contentLength = request.headers.get('content-length');
if (contentLength) {
const fileSize = parseInt(contentLength, 10);
if (isNaN(fileSize) || fileSize > MAX_FILE_SIZE) {
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
}
}
// Rate limit
const limited = await rateLimit(request, 'image-upload');
if (limited) return limited;
// Require authentication
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const formData = await request.formData();
const file = formData.get('image') as File | null;
if (!file) {
return apiErrors.badRequest('No image file provided');
}
// Double-check file size (defense in depth - Content-Length can be spoofed)
if (file.size > MAX_FILE_SIZE) {
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
}
// Check content type
const contentType = file.type;
if (!ALLOWED_TYPES.includes(contentType)) {
return apiErrors.badRequest(`Unsupported image format: ${contentType}`);
}
// Generate unique filename
const ext = contentType.split('/')[1] === 'svg+xml' ? 'svg' : contentType.split('/')[1] || 'jpeg';
const filename = `${randomUUID()}.${ext}`;
const key = `images/${filename}`;
// Convert to buffer
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Upload to R2
await r2Client.send(
new PutObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
Body: buffer,
ContentType: contentType,
})
);
// Return the URL through our proxy endpoint
const imageUrl = `/api/upload/image/${filename}`;
const response = successResponse({ url: imageUrl }, 201);
return withCacheControl(response, 'public, max-age=31536000, immutable');
} catch (error) {
console.error('Error uploading image:', error);
return apiErrors.internalError('Failed to upload image');
}
}
+53 -8
View File
@@ -71,12 +71,44 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
...(includeResolved ? {} : { isResolved: false }), ...(includeResolved ? {} : { isResolved: false }),
}, },
orderBy: { timestamp: 'asc' }, orderBy: { timestamp: 'asc' },
include: { select: {
id: true,
content: true,
timestamp: true,
timestampEnd: true,
createdAt: true,
updatedAt: true,
isResolved: true,
resolvedAt: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
parentId: true,
authorId: true,
tagId: true,
versionId: true,
guestName: true,
author: { select: { id: true, name: true, image: true } }, author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } }, tag: { select: { id: true, name: true, color: true } },
replies: { replies: {
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
include: { select: {
id: true,
content: true,
timestamp: true,
timestampEnd: true,
createdAt: true,
updatedAt: true,
isResolved: true,
resolvedAt: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
parentId: true,
authorId: true,
tagId: true,
versionId: true,
guestName: true,
author: { select: { id: true, name: true, image: true } }, author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } }, tag: { select: { id: true, name: true, color: true } },
}, },
@@ -152,15 +184,20 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
} }
const body = await request.json(); const body = await request.json();
const { content, timestamp, timestampEnd, parentId, voiceUrl, voiceDuration, guestName, guestEmail, tagId } = body; const { content, timestamp, timestampEnd, parentId, voiceUrl, voiceDuration, guestName, guestEmail, tagId, imageUrl } = body;
// Validate required fields // Validate required fields
if (timestamp === undefined || timestamp === null) { if (timestamp === undefined || timestamp === null) {
return apiErrors.badRequest('Timestamp is required'); return apiErrors.badRequest('Timestamp is required');
} }
if (!content && !voiceUrl) { const parsedTimestamp = parseFloat(timestamp);
return apiErrors.badRequest('Either content or voice recording is required'); if (isNaN(parsedTimestamp)) {
return apiErrors.badRequest('Timestamp must be a valid number');
}
if (!content && !voiceUrl && !imageUrl) {
return apiErrors.badRequest('Either content, a voice recording, or an image attachment is required');
} }
// If replying, verify parent exists in same version // If replying, verify parent exists in same version
@@ -187,14 +224,22 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
} }
} }
if (imageUrl && !imageUrl.startsWith('/api/')) {
const imageUrlError = validateOptionalUrl(imageUrl, 'Image URL');
if (imageUrlError) {
return apiErrors.badRequest(imageUrlError);
}
}
const comment = await db.comment.create({ const comment = await db.comment.create({
data: { data: {
content: content?.trim() || null, content: content?.trim() || null,
timestamp: parseFloat(timestamp), timestamp: parsedTimestamp,
timestampEnd: timestampEnd ? parseFloat(timestampEnd) : null, timestampEnd: timestampEnd ? parseFloat(timestampEnd) : null,
parentId: parentId || null, parentId: parentId || null,
voiceUrl: voiceUrl || null, voiceUrl: voiceUrl || null,
voiceDuration: voiceDuration || null, voiceDuration: voiceDuration || null,
imageUrl: imageUrl || null,
authorId: session?.user?.id || null, authorId: session?.user?.id || null,
guestName: isGuest ? guestName : null, guestName: isGuest ? guestName : null,
guestEmail: isGuest ? guestEmail : null, guestEmail: isGuest ? guestEmail : null,
@@ -234,7 +279,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
projectName: project.name, projectName: project.name,
videoTitle, videoTitle,
replyAuthor: commentAuthorName, replyAuthor: commentAuthorName,
replyText: content?.trim() || '(voice note)', replyText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'),
parentAuthor: parentComment?.author?.name || parentComment?.guestName || 'Someone', parentAuthor: parentComment?.author?.name || parentComment?.guestName || 'Someone',
timestamp: ts, timestamp: ts,
url: `${baseUrl}/watch/${version.video.id}`, url: `${baseUrl}/watch/${version.video.id}`,
@@ -245,7 +290,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
projectName: project.name, projectName: project.name,
videoTitle, videoTitle,
commentAuthor: commentAuthorName, commentAuthor: commentAuthorName,
commentText: content?.trim() || '(voice note)', commentText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'),
timestamp: ts, timestamp: ts,
url: `${baseUrl}/watch/${version.video.id}`, url: `${baseUrl}/watch/${version.video.id}`,
}).catch((err) => console.error('Notification failed:', err)); }).catch((err) => console.error('Notification failed:', err));
+2 -2
View File
@@ -2,7 +2,7 @@ import { NextRequest } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { cleanupWorkspaceVoiceFiles } from '@/lib/r2-cleanup'; import { cleanupWorkspaceMediaFiles } from '@/lib/r2-cleanup';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
type RouteParams = { params: Promise<{ workspaceId: string }> }; type RouteParams = { params: Promise<{ workspaceId: string }> };
@@ -145,7 +145,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
} }
// Clean up voice files from R2 before cascade delete removes comment rows // Clean up voice files from R2 before cascade delete removes comment rows
await cleanupWorkspaceVoiceFiles(workspaceId); await cleanupWorkspaceMediaFiles(workspaceId);
await db.workspace.delete({ where: { id: workspaceId } }); await db.workspace.delete({ where: { id: workspaceId } });
+376 -25
View File
@@ -38,6 +38,8 @@ import {
User, User,
Maximize, Maximize,
Minimize, Minimize,
Image as ImageIcon,
Download,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -102,6 +104,7 @@ interface Comment {
timestamp: number; timestamp: number;
voiceUrl: string | null; voiceUrl: string | null;
voiceDuration: number | null; voiceDuration: number | null;
imageUrl: string | null;
isResolved: boolean; isResolved: boolean;
createdAt: string; createdAt: string;
author: { id: string; name: string | null; image: string | null } | null; author: { id: string; name: string | null; image: string | null } | null;
@@ -112,6 +115,7 @@ interface Comment {
content: string | null; content: string | null;
voiceUrl: string | null; voiceUrl: string | null;
voiceDuration: number | null; voiceDuration: number | null;
imageUrl: string | null;
createdAt: string; createdAt: string;
author: { id: string; name: string | null; image: string | null } | null; author: { id: string; name: string | null; image: string | null } | null;
guestName: string | null; guestName: string | null;
@@ -185,6 +189,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const [recordingTime, setRecordingTime] = useState(0); const [recordingTime, setRecordingTime] = useState(0);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null); const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [isUploadingAudio, setIsUploadingAudio] = useState(false); const [isUploadingAudio, setIsUploadingAudio] = useState(false);
const [imageBlob, setImageBlob] = useState<File | null>(null);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const imageInputRef = useRef<HTMLInputElement>(null);
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null); const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
const [voiceProgress, setVoiceProgress] = useState(0); const [voiceProgress, setVoiceProgress] = useState(0);
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0); const [voiceCurrentTime, setVoiceCurrentTime] = useState(0);
@@ -220,6 +227,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const [replyRecordingTime, setReplyRecordingTime] = useState(0); const [replyRecordingTime, setReplyRecordingTime] = useState(0);
const [replyAudioBlob, setReplyAudioBlob] = useState<Blob | null>(null); const [replyAudioBlob, setReplyAudioBlob] = useState<Blob | null>(null);
const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false); const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false);
const [replyImageBlob, setReplyImageBlob] = useState<File | null>(null);
const [isUploadingReplyImage, setIsUploadingReplyImage] = useState(false);
const replyImageInputRef = useRef<HTMLInputElement>(null);
const replyMediaRecorderRef = useRef<MediaRecorder | null>(null); const replyMediaRecorderRef = useRef<MediaRecorder | null>(null);
const replyAudioChunksRef = useRef<Blob[]>([]); const replyAudioChunksRef = useRef<Blob[]>([]);
const replyRecordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null); const replyRecordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -229,6 +239,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false); const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
const [deletingCommentId, setDeletingCommentId] = useState<string | null>(null); const [deletingCommentId, setDeletingCommentId] = useState<string | null>(null);
const isMutatingRef = useRef(false); const isMutatingRef = useRef(false);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [guestName, setGuestName] = useState(''); const [guestName, setGuestName] = useState('');
const [guestNameConfirmed, setGuestNameConfirmed] = useState(mode === 'dashboard'); const [guestNameConfirmed, setGuestNameConfirmed] = useState(mode === 'dashboard');
@@ -893,17 +904,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
} }
}, [isDragging, currentTime, handleSeekToTimestamp]); }, [isDragging, currentTime, handleSeekToTimestamp]);
const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => { const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }, imageData?: { url: string }) => {
if (!voiceData && !commentText.trim()) return; if (!voiceData && !imageBlob && !commentText.trim()) return;
if (!activeVersion) return; if (!activeVersion) return;
const tempId = `temp-${Date.now()}`; const tempId = `temp-${Date.now()}`;
const optimisticComment: Comment = { const optimisticComment: Comment = {
id: tempId, id: tempId,
content: voiceData ? commentText.trim() || null : commentText, content: (voiceData || imageBlob) ? commentText.trim() || null : commentText,
timestamp: selectedTimestamp ?? currentTime, timestamp: selectedTimestamp ?? currentTime,
voiceUrl: voiceData?.url ?? null, voiceUrl: voiceData?.url ?? null,
voiceDuration: voiceData?.duration ?? null, voiceDuration: voiceData?.duration ?? null,
imageUrl: imageBlob ? URL.createObjectURL(imageBlob) : null,
isResolved: false, isResolved: false,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null }, author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
@@ -928,18 +940,37 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setSelectedTimestamp(null); setSelectedTimestamp(null);
setSelectedTagId(availableTags.length > 0 ? availableTags[0].id : null); setSelectedTagId(availableTags.length > 0 ? availableTags[0].id : null);
setAudioBlob(null); setAudioBlob(null);
setImageBlob(null);
setIsSubmittingComment(true); setIsSubmittingComment(true);
isMutatingRef.current = true; isMutatingRef.current = true;
try { try {
let imageData: { url: string } | undefined;
if (imageBlob) {
setIsUploadingImage(true);
const imageFormData = new FormData();
imageFormData.append('image', imageBlob);
const imageRes = await fetch('/api/upload/image', {
method: 'POST',
body: imageFormData,
});
if (!imageRes.ok) throw new Error('Failed to upload image');
const imageDataResponse = await imageRes.json();
imageData = { url: imageDataResponse.data.url };
}
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, { const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
content: voiceData ? commentText.trim() || null : commentText, content: (voiceData || imageBlob) ? commentText.trim() || null : commentText,
timestamp: selectedTimestamp ?? currentTime, timestamp: selectedTimestamp ?? currentTime,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
...(imageData && { imageUrl: imageData.url }),
...(isGuest && guestName && { guestName }), ...(isGuest && guestName && { guestName }),
...(selectedTagId && { tagId: selectedTagId }), ...(selectedTagId && { tagId: selectedTagId }),
}), }),
@@ -988,9 +1019,55 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
toast.error('Failed to add comment'); toast.error('Failed to add comment');
} finally { } finally {
setIsSubmittingComment(false); setIsSubmittingComment(false);
setIsUploadingImage(false);
isMutatingRef.current = false; isMutatingRef.current = false;
} }
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags]); }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags, imageBlob]);
const handleImageSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>, isReply: boolean = false) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file');
return;
}
if (file.size > 10 * 1024 * 1024) {
toast.error('Image must be less than 10MB');
return;
}
if (isReply) {
setReplyImageBlob(file);
} else {
setImageBlob(file);
}
}, []);
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>, isReply: boolean = false) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const file = items[i].getAsFile();
if (file) {
if (file.size > 10 * 1024 * 1024) {
toast.error('Image must be less than 10MB');
return;
}
if (isReply) {
setReplyImageBlob(file);
} else {
setImageBlob(file);
}
e.preventDefault();
break;
}
}
}
}, []);
const startRecording = useCallback(async () => { const startRecording = useCallback(async () => {
try { try {
@@ -1174,6 +1251,46 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}; };
}, []); }, []);
const submitCommentWithMedia = useCallback(async () => {
if (!activeVersion) return;
// If we only have audio, handle it via submitVoiceComment for backwards compatibility conceptually
if (audioBlob && !imageBlob && !commentText.trim()) {
submitVoiceComment();
return;
}
if (audioBlob) setIsUploadingAudio(true);
if (imageBlob) setIsUploadingImage(true);
try {
let voiceData: { url: string; duration: number } | undefined;
let imageData: { url: string } | undefined;
if (audioBlob) {
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
if (!uploadRes.ok) throw new Error('Failed to upload audio');
const uploadData = await uploadRes.json();
voiceData = { url: uploadData.data.url, duration: recordingTime };
}
await handleAddComment(voiceData, imageData); // Image is uploaded inside handleAddComment for both text/image cases
setAudioBlob(null);
setRecordingTime(0);
setImageBlob(null);
if (imageInputRef.current) imageInputRef.current.value = '';
} catch (err) {
console.error('Failed to submit comment with media:', err);
toast.error('Failed to upload media');
} finally {
setIsUploadingAudio(false);
setIsUploadingImage(false);
}
}, [audioBlob, imageBlob, activeVersion, recordingTime, commentText, submitVoiceComment, handleAddComment]);
const handleResolveComment = useCallback( const handleResolveComment = useCallback(
async (commentId: string, currentlyResolved: boolean) => { async (commentId: string, currentlyResolved: boolean) => {
isMutatingRef.current = true; isMutatingRef.current = true;
@@ -1245,17 +1362,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
[activeVersionId] [activeVersionId]
); );
const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => { const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }, imageData?: { url: string }) => {
if (!voiceData && !replyText.trim()) return; if (!voiceData && !replyImageBlob && !replyText.trim()) return;
if (!activeVersion) return; if (!activeVersion) return;
const tempId = `temp-reply-${Date.now()}`; const tempId = `temp-reply-${Date.now()}`;
const parentComment = comments.find((c) => c.id === parentId); const parentComment = comments.find((c) => c.id === parentId);
const optimisticReply = { const optimisticReply = {
id: tempId, id: tempId,
content: voiceData ? replyText.trim() || null : replyText, content: (voiceData || replyImageBlob) ? replyText.trim() || null : replyText,
voiceUrl: voiceData?.url ?? null, voiceUrl: voiceData?.url ?? null,
voiceDuration: voiceData?.duration ?? null, voiceDuration: voiceData?.duration ?? null,
imageUrl: replyImageBlob ? URL.createObjectURL(replyImageBlob) : null,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null }, author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
guestName: isGuest ? guestName : null, guestName: isGuest ? guestName : null,
@@ -1285,19 +1403,38 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setReplyingTo(null); setReplyingTo(null);
setReplyAudioBlob(null); setReplyAudioBlob(null);
setReplyRecordingTime(0); setReplyRecordingTime(0);
setReplyImageBlob(null);
setIsSubmittingReply(true); setIsSubmittingReply(true);
isMutatingRef.current = true; isMutatingRef.current = true;
try { try {
let submittedImageData: { url: string } | undefined = imageData;
if (replyImageBlob && !imageData) {
setIsUploadingReplyImage(true);
const imageFormData = new FormData();
imageFormData.append('image', replyImageBlob);
const imageRes = await fetch('/api/upload/image', {
method: 'POST',
body: imageFormData,
});
if (!imageRes.ok) throw new Error('Failed to upload image reply');
const imageDataResponse = await imageRes.json();
submittedImageData = { url: imageDataResponse.data.url };
}
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, { const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
content: voiceData ? replyText.trim() || null : replyText, content: (voiceData || submittedImageData) ? replyText.trim() || null : replyText,
timestamp: parentComment?.timestamp ?? currentTime, timestamp: parentComment?.timestamp ?? currentTime,
parentId, parentId,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
...(submittedImageData && { imageUrl: submittedImageData.url }),
...(isGuest && guestName && { guestName }), ...(isGuest && guestName && { guestName }),
}), }),
}); });
@@ -1366,9 +1503,10 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
toast.error('Failed to add reply'); toast.error('Failed to add reply');
} finally { } finally {
setIsSubmittingReply(false); setIsSubmittingReply(false);
setIsUploadingReplyImage(false);
isMutatingRef.current = false; isMutatingRef.current = false;
} }
}, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName]); }, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName, replyImageBlob]);
const startReplyRecording = useCallback(async () => { const startReplyRecording = useCallback(async () => {
try { try {
@@ -1437,6 +1575,44 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
} }
}, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment]); }, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment]);
const submitReplyWithMedia = useCallback(async (parentId: string) => {
if (!activeVersion) return;
if (replyAudioBlob && !replyImageBlob && !replyText.trim()) {
submitVoiceReply(parentId);
return;
}
if (replyAudioBlob) setIsUploadingReplyAudio(true);
if (replyImageBlob) setIsUploadingReplyImage(true);
try {
let voiceData: { url: string; duration: number } | undefined;
if (replyAudioBlob) {
const formData = new FormData();
formData.append('audio', replyAudioBlob, 'recording.webm');
const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
if (!uploadRes.ok) throw new Error('Failed to upload audio reply');
const uploadData = await uploadRes.json();
voiceData = { url: uploadData.data.url, duration: replyRecordingTime };
}
await handleReplyComment(parentId, voiceData);
setReplyAudioBlob(null);
setReplyRecordingTime(0);
setReplyImageBlob(null);
if (replyImageInputRef.current) replyImageInputRef.current.value = '';
} catch (err) {
console.error('Failed to submit reply with media:', err);
toast.error('Failed to upload media');
} finally {
setIsUploadingReplyAudio(false);
setIsUploadingReplyImage(false);
}
}, [replyAudioBlob, replyImageBlob, activeVersion, replyRecordingTime, replyText, submitVoiceReply, handleReplyComment]);
const handleEditComment = useCallback(async (commentId: string) => { const handleEditComment = useCallback(async (commentId: string) => {
if (!editText.trim()) return; if (!editText.trim()) return;
setIsSubmittingEdit(true); setIsSubmittingEdit(true);
@@ -1502,7 +1678,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
.filter((c) => c.id !== commentId) .filter((c) => c.id !== commentId)
.map((c) => ({ .map((c) => ({
...c, ...c,
replies: c.replies.filter((r) => r.id !== commentId), replies: c.replies?.filter((r) => r.id !== commentId) || [],
})), })),
} }
: v : v
@@ -2381,7 +2557,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
</div> </div>
</div> </div>
) : ( ) : (
comment.content && <p className="text-sm mb-2"><Linkify>{comment.content}</Linkify></p> <div className="mb-2">
{comment.content && <p className="text-sm mb-2"><Linkify>{comment.content}</Linkify></p>}
{comment.imageUrl && (
<div
className="rounded-md overflow-hidden bg-muted mb-2 max-h-60 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => setPreviewImage(comment.imageUrl)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={comment.imageUrl} alt="Attachment" className="max-h-60 w-auto object-contain" />
</div>
)}
</div>
)} )}
{comment.voiceUrl && ( {comment.voiceUrl && (
@@ -2524,7 +2711,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
</div> </div>
</div> </div>
) : ( ) : (
reply.content && <p className="text-sm"><Linkify>{reply.content}</Linkify></p> <div className="mb-1">
{reply.content && <p className="text-sm"><Linkify>{reply.content}</Linkify></p>}
{reply.imageUrl && (
<div
className="rounded-md overflow-hidden bg-muted mt-2 max-h-40 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => setPreviewImage(reply.imageUrl)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={reply.imageUrl} alt="Attachment" className="max-h-40 w-auto object-contain" />
</div>
)}
</div>
)} )}
{reply.voiceUrl && ( {reply.voiceUrl && (
<div className="flex items-center gap-2 p-1.5 bg-muted rounded mt-1"> <div className="flex items-center gap-2 p-1.5 bg-muted rounded mt-1">
@@ -2620,6 +2818,22 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
</div> </div>
{replyImageBlob && (
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center h-20 mb-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={URL.createObjectURL(replyImageBlob)} alt="Preview" className="h-full object-contain" />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Button size="icon" variant="destructive" className="h-6 w-6" onClick={() => {
setReplyImageBlob(null);
if (replyImageInputRef.current) replyImageInputRef.current.value = '';
}}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
)}
<Textarea <Textarea
value={replyText} value={replyText}
onChange={(e) => setReplyText(e.target.value)} onChange={(e) => setReplyText(e.target.value)}
@@ -2627,20 +2841,34 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
rows={1} rows={1}
className="resize-none text-sm" className="resize-none text-sm"
/> />
<div className="flex gap-1"> <div className="flex gap-1 mt-2">
<Button <Button
size="sm" size="sm"
onClick={() => submitVoiceReply(comment.id)} onClick={() => submitReplyWithMedia(comment.id)}
disabled={isUploadingReplyAudio} disabled={isUploadingReplyAudio || isUploadingReplyImage}
className="h-7 text-xs" className="h-7 text-xs"
> >
{isUploadingReplyAudio ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Send Voice Reply'} {isUploadingReplyAudio || isUploadingReplyImage ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Send Reply'}
</Button> </Button>
<Button size="sm" variant="ghost" onClick={cancelReplyRecording} className="h-7 text-xs">Cancel</Button> <Button size="sm" variant="ghost" onClick={cancelReplyRecording} className="h-7 text-xs">Cancel</Button>
</div> </div>
</div> </div>
) : ( ) : (
<> <>
{replyImageBlob && (
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center h-20 mb-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={URL.createObjectURL(replyImageBlob)} alt="Preview" className="h-full object-contain" />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Button size="icon" variant="destructive" className="h-6 w-6" onClick={() => {
setReplyImageBlob(null);
if (replyImageInputRef.current) replyImageInputRef.current.value = '';
}}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
)}
<div className="flex gap-1"> <div className="flex gap-1">
<Textarea <Textarea
value={replyText} value={replyText}
@@ -2658,6 +2886,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setReplyText(''); setReplyText('');
} }
}} }}
onPaste={(e) => handlePaste(e, true)}
/> />
<Button <Button
size="icon" size="icon"
@@ -2668,15 +2897,31 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
> >
<Mic className="h-3 w-3" /> <Mic className="h-3 w-3" />
</Button> </Button>
<Button
size="icon"
variant="outline"
onClick={() => replyImageInputRef.current?.click()}
title="Attach Image"
className="h-8 w-8 shrink-0 self-end"
>
<ImageIcon className="h-3 w-3" />
</Button>
<input
type="file"
accept="image/*"
className="hidden"
ref={replyImageInputRef}
onChange={(e) => handleImageSelect(e, true)}
/>
</div> </div>
<div className="flex gap-1 mt-1"> <div className="flex gap-1 mt-1">
<Button <Button
size="sm" size="sm"
onClick={() => handleReplyComment(comment.id)} onClick={() => handleReplyComment(comment.id)}
disabled={!replyText.trim() || isSubmittingReply} disabled={(!replyText.trim() && !replyImageBlob) || isSubmittingReply || isUploadingReplyImage}
className="h-7 text-xs" className="h-7 text-xs"
> >
{isSubmittingReply ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Reply'} {isSubmittingReply || isUploadingReplyImage ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Reply'}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@@ -2789,6 +3034,22 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
{imageBlob && (
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center max-h-40 mb-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={URL.createObjectURL(imageBlob)} alt="Preview" className="max-h-40 w-auto object-contain" />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Button size="icon" variant="destructive" onClick={() => {
setImageBlob(null);
if (imageInputRef.current) imageInputRef.current.value = '';
}}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
)}
<Textarea <Textarea
placeholder="Add a note to your voice comment (optional)..." placeholder="Add a note to your voice comment (optional)..."
value={commentText} value={commentText}
@@ -2798,14 +3059,14 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
/> />
<Button <Button
size="sm" size="sm"
onClick={submitVoiceComment} onClick={submitCommentWithMedia}
disabled={isUploadingAudio} disabled={isUploadingAudio || isUploadingImage}
className="w-full" className="w-full"
> >
{isUploadingAudio ? ( {isUploadingAudio || isUploadingImage ? (
<> <>
<Loader2 className="h-4 w-4 animate-spin mr-2" /> <Loader2 className="h-4 w-4 animate-spin mr-2" />
Uploading... Uploading Media...
</> </>
) : ( ) : (
<> <>
@@ -2817,6 +3078,20 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
</div> </div>
) : ( ) : (
<> <>
{imageBlob && (
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center max-h-40 mb-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={URL.createObjectURL(imageBlob)} alt="Preview" className="max-h-40 w-auto object-contain" />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Button size="icon" variant="destructive" onClick={() => {
setImageBlob(null);
if (imageInputRef.current) imageInputRef.current.value = '';
}}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
)}
<div className="flex gap-2"> <div className="flex gap-2">
<Textarea <Textarea
placeholder="Add a comment..." placeholder="Add a comment..."
@@ -2829,14 +3104,15 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
handleAddComment(); handleAddComment();
} }
}} }}
onPaste={(e) => handlePaste(e, false)}
/> />
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Button <Button
size="icon" size="icon"
onClick={() => handleAddComment()} onClick={() => handleAddComment()}
disabled={!commentText.trim() || isSubmittingComment} disabled={(!commentText.trim() && !imageBlob) || isSubmittingComment || isUploadingImage}
> >
{isSubmittingComment ? ( {isSubmittingComment || isUploadingImage ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
@@ -2850,6 +3126,21 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
> >
<Mic className="h-4 w-4" /> <Mic className="h-4 w-4" />
</Button> </Button>
<Button
size="icon"
variant="outline"
onClick={() => imageInputRef.current?.click()}
title="Attach Image"
>
<ImageIcon className="h-4 w-4" />
</Button>
<input
type="file"
accept="image/*"
className="hidden"
ref={imageInputRef}
onChange={handleImageSelect}
/>
{availableTags.length > 0 && ( {availableTags.length > 0 && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -2897,6 +3188,66 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
</div> </div>
</div> </div>
</div> </div>
<Dialog open={!!previewImage} onOpenChange={(open) => !open && setPreviewImage(null)}>
<DialogContent
showCloseButton={false}
className="max-w-none sm:max-w-none w-screen h-screen max-h-screen p-0 overflow-hidden bg-black/90 border-none shadow-none flex flex-col items-center justify-center rounded-none"
>
<DialogTitle className="sr-only">Image Preview</DialogTitle>
<div className="absolute top-4 right-4 flex gap-3 z-50">
<Button
variant="outline"
size="icon"
className="rounded-full bg-black/40 hover:bg-black/80 border-white/20 text-white h-10 w-10 backdrop-blur-md transition-all shrink-0"
onClick={async (e) => {
e.stopPropagation();
try {
const response = await fetch(previewImage!);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = previewImage!.split('/').pop() || 'attachment.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download image:', error);
toast.error('Failed to download image');
}
}}
>
<Download className="h-5 w-5" />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full bg-black/40 hover:bg-black/80 border-white/20 text-white h-10 w-10 backdrop-blur-md transition-all shrink-0"
onClick={(e) => {
e.stopPropagation();
setPreviewImage(null);
}}
>
<X className="h-5 w-5" />
</Button>
</div>
<div
className="relative w-full h-full flex items-center justify-center p-4 cursor-zoom-out"
onClick={() => setPreviewImage(null)}
>
{previewImage && (
<img
src={previewImage}
alt="Preview"
className="max-w-[95vw] max-h-[90vh] object-contain rounded-md select-none cursor-default"
onClick={(e) => e.stopPropagation()}
/>
)}
</div>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
+25 -9
View File
@@ -34,10 +34,10 @@ export const getCachedTotalStorage = unstable_cache(
{ revalidate: 600 } { revalidate: 600 }
); );
export const getCachedUserVoiceStorage = unstable_cache( export const getCachedUserMediaStorage = unstable_cache(
async () => { async () => {
// Return a plain object so it maps cleanly out of unstable_cache across requests // Return a plain object so it maps cleanly out of unstable_cache across requests
const userStorage: Record<string, number> = {}; const userStorage: Record<string, { total: number, voice: number, image: number }> = {};
try { try {
const fileSizes = new Map<string, number>(); const fileSizes = new Map<string, number>();
let isTruncated = true; let isTruncated = true;
@@ -57,25 +57,41 @@ export const getCachedUserVoiceStorage = unstable_cache(
continuationToken = data.NextContinuationToken; continuationToken = data.NextContinuationToken;
} }
const voiceComments = await db.comment.findMany({ const mediaComments = await db.comment.findMany({
where: { voiceUrl: { not: null }, authorId: { not: null } }, where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }], authorId: { not: null } },
select: { authorId: true, voiceUrl: true } select: { authorId: true, voiceUrl: true, imageUrl: true }
}); });
for (const comment of voiceComments) { for (const comment of mediaComments) {
if (!comment.authorId || !comment.voiceUrl) continue; if (!comment.authorId) continue;
if (!userStorage[comment.authorId]) {
userStorage[comment.authorId] = { total: 0, voice: 0, image: 0 };
}
if (comment.voiceUrl) {
const keyParts = comment.voiceUrl.split('/'); const keyParts = comment.voiceUrl.split('/');
const filename = keyParts[keyParts.length - 1]; const filename = keyParts[keyParts.length - 1];
const r2Key = `voice/${filename}`; const r2Key = `voice/${filename}`;
const size = fileSizes.get(r2Key) || 0; const size = fileSizes.get(r2Key) || 0;
userStorage[comment.authorId].voice += size;
userStorage[comment.authorId].total += size;
}
userStorage[comment.authorId] = (userStorage[comment.authorId] || 0) + size; if (comment.imageUrl) {
const keyParts = comment.imageUrl.split('/');
const filename = keyParts[keyParts.length - 1];
const r2Key = `images/${filename}`;
const size = fileSizes.get(r2Key) || 0;
userStorage[comment.authorId].image += size;
userStorage[comment.authorId].total += size;
}
} }
} catch (err) { } catch (err) {
console.error('Failed to parse user storage:', err); console.error('Failed to parse user storage:', err);
} }
return userStorage; return userStorage;
}, },
['admin-user-voice-storage'], ['admin-user-media-storage'],
{ revalidate: 600 } { revalidate: 600 }
); );
+58 -37
View File
@@ -2,103 +2,124 @@ import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
/** The path prefix for images served by the upload API. */
const IMAGE_PATH_PREFIX = '/api/upload/image/';
/** The path prefix for audio URLs served by the upload API. */ /** The path prefix for audio URLs served by the upload API. */
const AUDIO_PATH_PREFIX = '/api/upload/audio/'; const AUDIO_PATH_PREFIX = '/api/upload/audio/';
/** /**
* Extract the R2 object key from a voice URL like /api/upload/audio/filename.webm. * Extract the R2 object key from a media URL.
* Uses string parsing instead of regex to avoid ReDoS risk on untrusted input. * Uses string parsing instead of regex to avoid ReDoS risk on untrusted input.
*/ */
function voiceUrlToKey(url: string): string | null { function mediaUrlToKey(url: string): string | null {
const idx = url.indexOf(AUDIO_PATH_PREFIX); if (url.includes(AUDIO_PATH_PREFIX)) {
if (idx === -1) return null; const filename = url.slice(url.indexOf(AUDIO_PATH_PREFIX) + AUDIO_PATH_PREFIX.length);
const filename = url.slice(idx + AUDIO_PATH_PREFIX.length);
return filename ? `voice/${filename}` : null; return filename ? `voice/${filename}` : null;
} else if (url.includes(IMAGE_PATH_PREFIX)) {
const filename = url.slice(url.indexOf(IMAGE_PATH_PREFIX) + IMAGE_PATH_PREFIX.length);
return filename ? `images/${filename}` : null;
}
return null;
} }
/** /**
* Delete a list of voice files from R2 (best-effort, logs failures). * Delete a list of media files from R2 (best-effort, logs failures).
*/ */
async function deleteVoiceFiles(voiceUrls: string[]) { async function deleteMediaFiles(mediaUrls: string[]) {
for (const url of voiceUrls) { for (const url of mediaUrls) {
try { try {
const key = voiceUrlToKey(url); const key = mediaUrlToKey(url);
if (key) { if (key) {
await r2Client.send( await r2Client.send(
new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: key }) new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: key })
); );
} }
} catch (err) { } catch (err) {
console.error('Failed to delete audio from R2:', err); console.error('Failed to delete media from R2:', err);
} }
} }
} }
/** /**
* Collect all voice URLs from comments under a given video (all versions). * Collect all media URLs from comments under a given video (all versions).
*/ */
export async function collectVideoVoiceUrls(videoId: string): Promise<string[]> { export async function collectVideoMediaUrls(videoId: string): Promise<string[]> {
const comments = await db.comment.findMany({ const comments = await db.comment.findMany({
where: { where: {
voiceUrl: { not: null }, OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { videoParentId: videoId }, version: { videoParentId: videoId },
}, },
select: { voiceUrl: true }, select: { voiceUrl: true, imageUrl: true },
}); });
return comments.map((c: { voiceUrl: string | null }) => c.voiceUrl).filter(Boolean) as string[]; const urls: string[] = [];
comments.forEach(c => {
if (c.voiceUrl) urls.push(c.voiceUrl);
if (c.imageUrl) urls.push(c.imageUrl);
});
return urls;
} }
/** /**
* Collect all voice URLs from comments under all videos in a project. * Collect all media URLs from comments under all videos in a project.
*/ */
export async function collectProjectVoiceUrls(projectId: string): Promise<string[]> { export async function collectProjectMediaUrls(projectId: string): Promise<string[]> {
const comments = await db.comment.findMany({ const comments = await db.comment.findMany({
where: { where: {
voiceUrl: { not: null }, OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { video: { projectId } }, version: { video: { projectId } },
}, },
select: { voiceUrl: true }, select: { voiceUrl: true, imageUrl: true },
}); });
return comments.map((c: { voiceUrl: string | null }) => c.voiceUrl).filter(Boolean) as string[]; const urls: string[] = [];
comments.forEach(c => {
if (c.voiceUrl) urls.push(c.voiceUrl);
if (c.imageUrl) urls.push(c.imageUrl);
});
return urls;
} }
/** /**
* Collect all voice URLs from comments under all projects in a workspace. * Collect all media URLs from comments under all projects in a workspace.
*/ */
export async function collectWorkspaceVoiceUrls(workspaceId: string): Promise<string[]> { export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<string[]> {
const comments = await db.comment.findMany({ const comments = await db.comment.findMany({
where: { where: {
voiceUrl: { not: null }, OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { video: { project: { workspaceId } } }, version: { video: { project: { workspaceId } } },
}, },
select: { voiceUrl: true }, select: { voiceUrl: true, imageUrl: true },
}); });
return comments.map((c: { voiceUrl: string | null }) => c.voiceUrl).filter(Boolean) as string[]; const urls: string[] = [];
comments.forEach(c => {
if (c.voiceUrl) urls.push(c.voiceUrl);
if (c.imageUrl) urls.push(c.imageUrl);
});
return urls;
} }
/** /**
* Delete all voice files for a video from R2. * Delete all media files for a video from R2.
* Call BEFORE deleting the video from the database (cascade would remove comment rows). * Call BEFORE deleting the video from the database (cascade would remove comment rows).
*/ */
export async function cleanupVideoVoiceFiles(videoId: string) { export async function cleanupVideoMediaFiles(videoId: string) {
const urls = await collectVideoVoiceUrls(videoId); const urls = await collectVideoMediaUrls(videoId);
await deleteVoiceFiles(urls); await deleteMediaFiles(urls);
} }
/** /**
* Delete all voice files for a project from R2. * Delete all media files for a project from R2.
* Call BEFORE deleting the project from the database. * Call BEFORE deleting the project from the database.
*/ */
export async function cleanupProjectVoiceFiles(projectId: string) { export async function cleanupProjectMediaFiles(projectId: string) {
const urls = await collectProjectVoiceUrls(projectId); const urls = await collectProjectMediaUrls(projectId);
await deleteVoiceFiles(urls); await deleteMediaFiles(urls);
} }
/** /**
* Delete all voice files for a workspace from R2. * Delete all media files for a workspace from R2.
* Call BEFORE deleting the workspace from the database. * Call BEFORE deleting the workspace from the database.
*/ */
export async function cleanupWorkspaceVoiceFiles(workspaceId: string) { export async function cleanupWorkspaceMediaFiles(workspaceId: string) {
const urls = await collectWorkspaceVoiceUrls(workspaceId); const urls = await collectWorkspaceMediaUrls(workspaceId);
await deleteVoiceFiles(urls); await deleteMediaFiles(urls);
} }
+3
View File
@@ -263,6 +263,9 @@ model Comment {
voiceUrl String? // URL to voice recording file voiceUrl String? // URL to voice recording file
voiceDuration Float? // Duration of voice recording in seconds voiceDuration Float? // Duration of voice recording in seconds
// Image attachment (optional)
imageUrl String? // URL to uploaded image file
// Threading // Threading
parentId String? parentId String?
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade) parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)