From e32196c430e8d7a5d61a112396231abb25415016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Sat, 21 Feb 2026 16:40:58 +0300 Subject: [PATCH] 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 --- app/admin/page.tsx | 15 +- app/admin/users/page.tsx | 22 +- app/api/admin/stats/route.ts | 9 + app/api/comments/[commentId]/route.ts | 72 +++- app/api/projects/[projectId]/route.ts | 8 +- .../[projectId]/videos/[videoId]/route.ts | 46 +- app/api/upload/audio/[filename]/route.ts | 4 +- app/api/upload/image/[filename]/route.ts | 88 ++++ app/api/upload/image/route.ts | 84 ++++ .../versions/[versionId]/comments/route.ts | 61 ++- app/api/workspaces/[workspaceId]/route.ts | 4 +- components/video-page-content.tsx | 401 ++++++++++++++++-- lib/admin-stats.ts | 42 +- lib/r2-cleanup.ts | 97 +++-- prisma/schema.prisma | 3 + 15 files changed, 837 insertions(+), 119 deletions(-) create mode 100644 app/api/upload/image/[filename]/route.ts create mode 100644 app/api/upload/image/route.ts diff --git a/app/admin/page.tsx b/app/admin/page.tsx index da936e8..a3ef4a9 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -4,7 +4,7 @@ import { auth } from '@/lib/auth'; import { redirect } from 'next/navigation'; import { getCachedTotalStorage } from '@/lib/admin-stats'; 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 = { title: 'Admin Dashboard | OpenFrame', @@ -34,6 +34,7 @@ export default async function AdminDashboardPage() { totalVideos, totalComments, totalVoiceComments, + totalImageComments, ] = await Promise.all([ db.user.count(), db.project.count(), @@ -42,6 +43,9 @@ export default async function AdminDashboardPage() { db.comment.count({ where: { voiceUrl: { not: null } }, }), + db.comment.count({ + where: { imageUrl: { not: null } }, + }), ]); // 2. Storage Stats (Cached) @@ -101,6 +105,15 @@ export default async function AdminDashboardPage() {
{totalVoiceComments}
+ + + Image Attachments + + + +
{totalImageComments}
+
+
Cloudflare R2 Storage diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index abc3889..bd4c018 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -2,7 +2,8 @@ import { Metadata } from 'next'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; 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 { Button } from '@/components/ui/button'; import { @@ -69,8 +70,8 @@ export default async function AdminUsersPage({ const totalPages = Math.ceil(totalUsers / pageSize); - // Determine voice storage per user (Cached) - const userStorage = await getCachedUserVoiceStorage(); + // Determine media storage per user (Cached) + const userStorage = await getCachedUserMediaStorage(); return (
@@ -95,7 +96,7 @@ export default async function AdminUsersPage({ Workspaces Owned Projects Owned Total Comments - Voice Storage + Media Storage @@ -120,8 +121,17 @@ export default async function AdminUsersPage({ {user._count.ownedWorkspaces} {user._count.projects} {user._count.comments} - - {formatBytes(userStorage[user.id] || 0)} + +
+ {formatBytes(userStorage[user.id]?.total || 0)} + {(userStorage[user.id]?.voice > 0 || userStorage[user.id]?.image > 0) && ( + + {userStorage[user.id]?.voice > 0 && 🎤 {formatBytes(userStorage[user.id]?.voice)}} + {userStorage[user.id]?.voice > 0 && userStorage[user.id]?.image > 0 && } + {userStorage[user.id]?.image > 0 && 🖼️ {formatBytes(userStorage[user.id]?.image)}} + + )} +
)) diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts index 654bc0c..580511b 100644 --- a/app/api/admin/stats/route.ts +++ b/app/api/admin/stats/route.ts @@ -17,6 +17,7 @@ export async function GET() { totalVideos, totalComments, totalVoiceComments, + totalImageComments, ] = await Promise.all([ db.user.count(), db.project.count(), @@ -28,6 +29,13 @@ export async function GET() { not: null, } } + }), + db.comment.count({ + where: { + imageUrl: { + not: null, + } + } }) ]); @@ -40,6 +48,7 @@ export async function GET() { totalVideos, totalComments, totalVoiceComments, + totalImageComments, totalStorageBytes, }); } catch (error) { diff --git a/app/api/comments/[commentId]/route.ts b/app/api/comments/[commentId]/route.ts index 91c2f0c..df27073 100644 --- a/app/api/comments/[commentId]/route.ts +++ b/app/api/comments/[commentId]/route.ts @@ -16,12 +16,46 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const comment = await db.comment.findUnique({ 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 } }, + tag: { select: { id: true, name: true, color: true } }, replies: { 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 } }, + tag: { select: { id: true, name: true, color: true } }, }, }, version: { @@ -155,7 +189,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { } const updateData: Record = {}; - if (content !== undefined) updateData.content = content.trim(); + if (content !== undefined && typeof content === 'string') updateData.content = content.trim(); if (tagId !== undefined) updateData.tagId = tagId; if (isResolved !== undefined) { updateData.isResolved = isResolved; @@ -201,7 +235,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { const comment = await db.comment.findUnique({ where: { id: commentId }, 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'); } - // Collect all voice URLs to delete from R2 (comment + its replies) - const voiceUrls: string[] = []; - if (comment.voiceUrl) voiceUrls.push(comment.voiceUrl); + // Collect all media URLs to delete from R2 (comment + its replies) + const mediaUrls: string[] = []; + if (comment.voiceUrl) mediaUrls.push(comment.voiceUrl); + if (comment.imageUrl) mediaUrls.push(comment.imageUrl); 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 } }); - // 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/'; - for (const url of voiceUrls) { + const IMAGE_PREFIX = '/api/upload/image/'; + for (const url of mediaUrls) { try { // Extract filename using string parsing (safe against ReDoS) - const idx = url.indexOf(AUDIO_PREFIX); - const filename = idx !== -1 ? url.slice(idx + AUDIO_PREFIX.length) : null; - if (filename) { + let key: string | null = null; + if (url.includes(AUDIO_PREFIX)) { + 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( new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, - Key: `voice/${filename}`, + Key: key, }) ); } diff --git a/app/api/projects/[projectId]/route.ts b/app/api/projects/[projectId]/route.ts index ee4857f..c595394 100644 --- a/app/api/projects/[projectId]/route.ts +++ b/app/api/projects/[projectId]/route.ts @@ -3,7 +3,7 @@ import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { ProjectMemberRole, ProjectVisibility } from '@prisma/client'; 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'; type RouteParams = { params: Promise<{ projectId: string }> }; @@ -52,11 +52,11 @@ export async function GET(request: NextRequest, { params }: RouteParams) { try { const session = await auth(); const { projectId } = await params; - + // Parse pagination params const searchParams = request.nextUrl.searchParams; 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({ 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 - await cleanupProjectVoiceFiles(projectId); + await cleanupProjectMediaFiles(projectId); await db.project.delete({ where: { id: projectId } }); diff --git a/app/api/projects/[projectId]/videos/[videoId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/route.ts index 050b010..d7ffa6e 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/route.ts @@ -4,7 +4,7 @@ import { db } from '@/lib/db'; import { auth, checkProjectAccess } from '@/lib/auth'; import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client'; 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'; 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 const searchParams = request.nextUrl.searchParams; 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 video = await db.video.findFirst({ @@ -32,20 +32,54 @@ export async function GET(request: NextRequest, { params }: RouteParams) { orderBy: { timestamp: 'asc' }, skip: commentOffset, 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 } }, tag: { select: { id: true, name: true, color: true } }, ...(includeReplies ? { replies: { 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 } }, tag: { select: { id: true, name: true, color: true } }, }, }, } : {}), }, - where: { parentId: null }, // Only top-level comments + where: { parentId: null }, }, _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 - await cleanupVideoVoiceFiles(videoId); + await cleanupVideoMediaFiles(videoId); await db.video.delete({ where: { id: videoId } }); diff --git a/app/api/upload/audio/[filename]/route.ts b/app/api/upload/audio/[filename]/route.ts index d46c147..b532012 100644 --- a/app/api/upload/audio/[filename]/route.ts +++ b/app/api/upload/audio/[filename]/route.ts @@ -62,8 +62,8 @@ export async function GET( // Convert stream to Uint8Array const chunks: Uint8Array[] = []; - // @ts-expect-error - body is an iterable - for await (const chunk of body) { + const asyncIterable = body as AsyncIterable; + for await (const chunk of asyncIterable) { chunks.push(chunk); } const uint8Array = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0)); diff --git a/app/api/upload/image/[filename]/route.ts b/app/api/upload/image/[filename]/route.ts new file mode 100644 index 0000000..947b699 --- /dev/null +++ b/app/api/upload/image/[filename]/route.ts @@ -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 = { + 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'); + } +} + diff --git a/app/api/upload/image/route.ts b/app/api/upload/image/route.ts new file mode 100644 index 0000000..0d08798 --- /dev/null +++ b/app/api/upload/image/route.ts @@ -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'); + } +} diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts index 63244f8..feb201f 100644 --- a/app/api/versions/[versionId]/comments/route.ts +++ b/app/api/versions/[versionId]/comments/route.ts @@ -71,12 +71,44 @@ export async function GET(request: NextRequest, { params }: RouteParams) { ...(includeResolved ? {} : { isResolved: false }), }, 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 } }, tag: { select: { id: true, name: true, color: true } }, replies: { 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 } }, 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 { 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 if (timestamp === undefined || timestamp === null) { return apiErrors.badRequest('Timestamp is required'); } - if (!content && !voiceUrl) { - return apiErrors.badRequest('Either content or voice recording is required'); + const parsedTimestamp = parseFloat(timestamp); + 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 @@ -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({ data: { content: content?.trim() || null, - timestamp: parseFloat(timestamp), + timestamp: parsedTimestamp, timestampEnd: timestampEnd ? parseFloat(timestampEnd) : null, parentId: parentId || null, voiceUrl: voiceUrl || null, voiceDuration: voiceDuration || null, + imageUrl: imageUrl || null, authorId: session?.user?.id || null, guestName: isGuest ? guestName : null, guestEmail: isGuest ? guestEmail : null, @@ -234,7 +279,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { projectName: project.name, videoTitle, replyAuthor: commentAuthorName, - replyText: content?.trim() || '(voice note)', + replyText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'), parentAuthor: parentComment?.author?.name || parentComment?.guestName || 'Someone', timestamp: ts, url: `${baseUrl}/watch/${version.video.id}`, @@ -245,7 +290,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { projectName: project.name, videoTitle, commentAuthor: commentAuthorName, - commentText: content?.trim() || '(voice note)', + commentText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'), timestamp: ts, url: `${baseUrl}/watch/${version.video.id}`, }).catch((err) => console.error('Notification failed:', err)); diff --git a/app/api/workspaces/[workspaceId]/route.ts b/app/api/workspaces/[workspaceId]/route.ts index 97630f4..6ab178a 100644 --- a/app/api/workspaces/[workspaceId]/route.ts +++ b/app/api/workspaces/[workspaceId]/route.ts @@ -2,7 +2,7 @@ import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; 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'; 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 - await cleanupWorkspaceVoiceFiles(workspaceId); + await cleanupWorkspaceMediaFiles(workspaceId); await db.workspace.delete({ where: { id: workspaceId } }); diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index 100e97a..cbd850d 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -38,6 +38,8 @@ import { User, Maximize, Minimize, + Image as ImageIcon, + Download, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -102,6 +104,7 @@ interface Comment { timestamp: number; voiceUrl: string | null; voiceDuration: number | null; + imageUrl: string | null; isResolved: boolean; createdAt: string; author: { id: string; name: string | null; image: string | null } | null; @@ -112,6 +115,7 @@ interface Comment { content: string | null; voiceUrl: string | null; voiceDuration: number | null; + imageUrl: string | null; createdAt: string; author: { id: string; name: string | null; image: string | null } | null; guestName: string | null; @@ -185,6 +189,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const [recordingTime, setRecordingTime] = useState(0); const [audioBlob, setAudioBlob] = useState(null); const [isUploadingAudio, setIsUploadingAudio] = useState(false); + const [imageBlob, setImageBlob] = useState(null); + const [isUploadingImage, setIsUploadingImage] = useState(false); + const imageInputRef = useRef(null); const [playingVoiceId, setPlayingVoiceId] = useState(null); const [voiceProgress, setVoiceProgress] = 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 [replyAudioBlob, setReplyAudioBlob] = useState(null); const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false); + const [replyImageBlob, setReplyImageBlob] = useState(null); + const [isUploadingReplyImage, setIsUploadingReplyImage] = useState(false); + const replyImageInputRef = useRef(null); const replyMediaRecorderRef = useRef(null); const replyAudioChunksRef = useRef([]); const replyRecordingTimerRef = useRef | null>(null); @@ -229,6 +239,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const [isSubmittingEdit, setIsSubmittingEdit] = useState(false); const [deletingCommentId, setDeletingCommentId] = useState(null); const isMutatingRef = useRef(false); + const [previewImage, setPreviewImage] = useState(null); const [guestName, setGuestName] = useState(''); const [guestNameConfirmed, setGuestNameConfirmed] = useState(mode === 'dashboard'); @@ -893,17 +904,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi } }, [isDragging, currentTime, handleSeekToTimestamp]); - const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => { - if (!voiceData && !commentText.trim()) return; + const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }, imageData?: { url: string }) => { + if (!voiceData && !imageBlob && !commentText.trim()) return; if (!activeVersion) return; const tempId = `temp-${Date.now()}`; const optimisticComment: Comment = { id: tempId, - content: voiceData ? commentText.trim() || null : commentText, + content: (voiceData || imageBlob) ? commentText.trim() || null : commentText, timestamp: selectedTimestamp ?? currentTime, voiceUrl: voiceData?.url ?? null, voiceDuration: voiceData?.duration ?? null, + imageUrl: imageBlob ? URL.createObjectURL(imageBlob) : null, isResolved: false, createdAt: new Date().toISOString(), author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null }, @@ -928,18 +940,37 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi setSelectedTimestamp(null); setSelectedTagId(availableTags.length > 0 ? availableTags[0].id : null); setAudioBlob(null); + setImageBlob(null); setIsSubmittingComment(true); isMutatingRef.current = true; 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`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - content: voiceData ? commentText.trim() || null : commentText, + content: (voiceData || imageBlob) ? commentText.trim() || null : commentText, timestamp: selectedTimestamp ?? currentTime, ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), + ...(imageData && { imageUrl: imageData.url }), ...(isGuest && guestName && { guestName }), ...(selectedTagId && { tagId: selectedTagId }), }), @@ -988,9 +1019,55 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi toast.error('Failed to add comment'); } finally { setIsSubmittingComment(false); + setIsUploadingImage(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, 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, 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 () => { 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( async (commentId: string, currentlyResolved: boolean) => { isMutatingRef.current = true; @@ -1245,17 +1362,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi [activeVersionId] ); - const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => { - if (!voiceData && !replyText.trim()) return; + const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }, imageData?: { url: string }) => { + if (!voiceData && !replyImageBlob && !replyText.trim()) return; if (!activeVersion) return; const tempId = `temp-reply-${Date.now()}`; const parentComment = comments.find((c) => c.id === parentId); const optimisticReply = { id: tempId, - content: voiceData ? replyText.trim() || null : replyText, + content: (voiceData || replyImageBlob) ? replyText.trim() || null : replyText, voiceUrl: voiceData?.url ?? null, voiceDuration: voiceData?.duration ?? null, + imageUrl: replyImageBlob ? URL.createObjectURL(replyImageBlob) : null, createdAt: new Date().toISOString(), author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null }, guestName: isGuest ? guestName : null, @@ -1285,19 +1403,38 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi setReplyingTo(null); setReplyAudioBlob(null); setReplyRecordingTime(0); + setReplyImageBlob(null); setIsSubmittingReply(true); isMutatingRef.current = true; 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`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - content: voiceData ? replyText.trim() || null : replyText, + content: (voiceData || submittedImageData) ? replyText.trim() || null : replyText, timestamp: parentComment?.timestamp ?? currentTime, parentId, ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), + ...(submittedImageData && { imageUrl: submittedImageData.url }), ...(isGuest && guestName && { guestName }), }), }); @@ -1366,9 +1503,10 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi toast.error('Failed to add reply'); } finally { setIsSubmittingReply(false); + setIsUploadingReplyImage(false); isMutatingRef.current = false; } - }, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName]); + }, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName, replyImageBlob]); const startReplyRecording = useCallback(async () => { try { @@ -1437,6 +1575,44 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi } }, [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) => { if (!editText.trim()) return; setIsSubmittingEdit(true); @@ -1502,7 +1678,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi .filter((c) => c.id !== commentId) .map((c) => ({ ...c, - replies: c.replies.filter((r) => r.id !== commentId), + replies: c.replies?.filter((r) => r.id !== commentId) || [], })), } : v @@ -2381,7 +2557,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
) : ( - comment.content &&

{comment.content}

+
+ {comment.content &&

{comment.content}

} + {comment.imageUrl && ( +
setPreviewImage(comment.imageUrl)} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + Attachment +
+ )} +
)} {comment.voiceUrl && ( @@ -2524,7 +2711,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi ) : ( - reply.content &&

{reply.content}

+
+ {reply.content &&

{reply.content}

} + {reply.imageUrl && ( +
setPreviewImage(reply.imageUrl)} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + Attachment +
+ )} +
)} {reply.voiceUrl && (
@@ -2620,6 +2818,22 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
+ + {replyImageBlob && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Preview +
+ +
+
+ )} +