diff --git a/app/(dashboard)/projects/[projectId]/videos/[videoId]/share/page.tsx b/app/(dashboard)/projects/[projectId]/videos/[videoId]/share/page.tsx index f65f16c..3e6990a 100644 --- a/app/(dashboard)/projects/[projectId]/videos/[videoId]/share/page.tsx +++ b/app/(dashboard)/projects/[projectId]/videos/[videoId]/share/page.tsx @@ -17,6 +17,7 @@ interface ShareLinkData { id: string; token: string; allowGuests: boolean; + allowDownloads: boolean; hasPassword: boolean; } @@ -38,6 +39,7 @@ export default function VideoSharePage({ params }: VideoSharePageProps) { const [shareUrl, setShareUrl] = useState(null); const [hasPassword, setHasPassword] = useState(false); const [password, setPassword] = useState(''); + const [allowDownloads, setAllowDownloads] = useState(false); useEffect(() => { params.then(({ projectId: nextProjectId, videoId: nextVideoId }) => { @@ -65,10 +67,12 @@ export default function VideoSharePage({ params }: VideoSharePageProps) { setShareUrl(payload.data.shareUrl); setHasPassword(!!payload.data.link?.hasPassword); + setAllowDownloads(!!payload.data.link?.allowDownloads); } catch { setError('Failed to load share link'); setShareUrl(null); setHasPassword(false); + setAllowDownloads(false); } finally { setLoading(false); } @@ -94,7 +98,7 @@ export default function VideoSharePage({ params }: VideoSharePageProps) { const response = await fetch(`/api/projects/${projectId}/videos/${videoId}/share`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ allowGuests: true }), + body: JSON.stringify({ allowGuests: true, allowDownloads }), }); const payload = (await response.json()) as ShareResponse; @@ -105,6 +109,7 @@ export default function VideoSharePage({ params }: VideoSharePageProps) { setShareUrl(payload.data.shareUrl); setHasPassword(!!payload.data.link?.hasPassword); + setAllowDownloads(!!payload.data.link?.allowDownloads); setPassword(''); } catch { setError('Failed to create share link'); @@ -132,6 +137,7 @@ export default function VideoSharePage({ params }: VideoSharePageProps) { setShareUrl(null); setHasPassword(false); + setAllowDownloads(false); setPassword(''); } catch { setError('Failed to revoke share link'); @@ -165,6 +171,7 @@ export default function VideoSharePage({ params }: VideoSharePageProps) { const data = (payload as ShareResponse).data; setShareUrl(data.shareUrl); setHasPassword(!!data.link?.hasPassword); + setAllowDownloads(!!data.link?.allowDownloads); setPassword(''); } catch { setError('Failed to update link security'); @@ -173,6 +180,34 @@ export default function VideoSharePage({ params }: VideoSharePageProps) { } }; + const updateDownloadSetting = async (nextAllowDownloads: boolean) => { + if (!projectId || !videoId || !shareUrl) return; + + setSubmitting(true); + setError(''); + + try { + const response = await fetch(`/api/projects/${projectId}/videos/${videoId}/share`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ allowDownloads: nextAllowDownloads }), + }); + const payload = (await response.json().catch(() => null)) as ShareResponse | { error?: string } | null; + if (!response.ok || ('error' in (payload || {}) && payload?.error)) { + setError((payload as { error?: string } | null)?.error || 'Failed to update download setting'); + return; + } + const data = (payload as ShareResponse).data; + setShareUrl(data.shareUrl); + setAllowDownloads(!!data.link?.allowDownloads); + setHasPassword(!!data.link?.hasPassword); + } catch { + setError('Failed to update download setting'); + } finally { + setSubmitting(false); + } + }; + return (
@@ -220,6 +255,29 @@ export default function VideoSharePage({ params }: VideoSharePageProps) { Revoke Link
+
+
+

Video download

+

Allow viewers with this link to download

+
+
+ + +
+
+
{hasPassword ? : } diff --git a/app/api/comments/[commentId]/route.ts b/app/api/comments/[commentId]/route.ts index 85fa5d3..605f5c7 100644 --- a/app/api/comments/[commentId]/route.ts +++ b/app/api/comments/[commentId]/route.ts @@ -4,7 +4,10 @@ import { auth } from '@/lib/auth'; import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; import { DeleteObjectCommand } from '@aws-sdk/client-s3'; import { rateLimit } from '@/lib/rate-limit'; +import { validateShareLinkAccess } from '@/lib/share-links'; +import { getShareSessionFromRequest } from '@/lib/share-session'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; +import { getGuestIdentityFromRequest } from '@/lib/guest-identity'; type RouteParams = { params: Promise<{ commentId: string }> }; @@ -125,10 +128,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const session = await auth(); const { commentId } = await params; - - if (!session?.user?.id) { - return apiErrors.unauthorized(); - } + const body = await request.json(); + const { content, isResolved, tagId, annotationData } = body; const comment = await db.comment.findUnique({ where: { id: commentId }, @@ -138,9 +139,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { video: { include: { project: { - include: { - members: { where: { userId: session.user.id } }, - }, + include: { members: true }, }, }, }, @@ -154,18 +153,25 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { } const project = comment.version.video.project; - const isOwner = project.ownerId === session.user.id; - const isAuthor = comment.authorId === session.user.id; - const isMember = project.members.length > 0; + const userId = session?.user?.id ?? null; + const isOwner = userId === project.ownerId; + const isAuthor = !!userId && comment.authorId === userId; + const isMember = !!userId && project.members.some((member) => member.userId === userId); + const guestIdentityId = !userId ? getGuestIdentityFromRequest(request) : null; + const isGuestAuthor = !userId + && !comment.authorId + && !!comment.guestIdentityId + && guestIdentityId === comment.guestIdentityId; + const canEditOwnContent = isAuthor || isGuestAuthor; // Check workspace membership for resolve permissions let isWorkspaceMember = false; - if (!isOwner && !isMember && session.user.id) { + if (!isOwner && !isMember && userId) { const wsMember = await db.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId: project.workspaceId, - userId: session.user.id, + userId, }, }, }); @@ -173,19 +179,33 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { where: { id: project.workspaceId }, select: { ownerId: true }, }); - isWorkspaceMember = !!wsMember || wsOwner?.ownerId === session.user.id; + isWorkspaceMember = !!wsMember || wsOwner?.ownerId === userId; } - const body = await request.json(); - const { content, isResolved, tagId, annotationData } = body; + if (!userId && !isGuestAuthor) { + const shareSession = getShareSessionFromRequest(request, comment.version.video.id); + const shareAccess = shareSession + ? await validateShareLinkAccess({ + token: shareSession.token, + projectId: project.id, + videoId: comment.version.video.id, + requiredPermission: 'COMMENT', + passwordVerified: shareSession.passwordVerified, + }) + : { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false }; + const hasGuestAccess = project.visibility === 'PUBLIC' || (shareAccess.canComment && shareAccess.allowGuests); + if (!hasGuestAccess) { + return apiErrors.forbidden('Access denied'); + } + } // Only author can edit content or tag - if ((content !== undefined || tagId !== undefined || annotationData !== undefined) && !isAuthor) { + if ((content !== undefined || tagId !== undefined || annotationData !== undefined) && !canEditOwnContent) { return apiErrors.forbidden('Only the author can edit comment content'); } // Owner, author, members, or workspace members can resolve/unresolve - if (isResolved !== undefined && !isOwner && !isAuthor && !isMember && !isWorkspaceMember) { + if (isResolved !== undefined && !isOwner && !canEditOwnContent && !isMember && !isWorkspaceMember) { return apiErrors.forbidden('Access denied'); } @@ -213,7 +233,26 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { }, }); - const response = successResponse(updatedComment); + const { guestIdentityId: _updatedGuestIdentityId, ...updatedCommentData } = updatedComment; + const response = successResponse({ + ...updatedCommentData, + canEdit: canEditOwnContent, + canDelete: canEditOwnContent || isOwner, + replies: updatedComment.replies.map((reply) => { + const canEditReply = !!userId + ? reply.authorId === userId + : !!guestIdentityId + && !reply.authorId + && !!reply.guestIdentityId + && reply.guestIdentityId === guestIdentityId; + const { guestIdentityId: _replyGuestIdentityId, ...replyData } = reply; + return { + ...replyData, + canEdit: canEditReply, + canDelete: canEditReply || isOwner, + }; + }), + }); return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error updating comment:', error); @@ -230,13 +269,18 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { const session = await auth(); const { commentId } = await params; - if (!session?.user?.id) { - return apiErrors.unauthorized(); - } - const comment = await db.comment.findUnique({ where: { id: commentId }, include: { + version: { + include: { + video: { + include: { + project: true, + }, + }, + }, + }, replies: { select: { voiceUrl: true, imageUrl: true } }, }, }); @@ -245,9 +289,37 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { return apiErrors.notFound('Comment'); } - const isAuthor = comment.authorId === session.user.id; + const userId = session?.user?.id ?? null; + const isAuthor = !!userId && comment.authorId === userId; - if (!isAuthor) { + let canDeleteOwnComment = isAuthor; + if (!userId) { + const guestIdentityId = getGuestIdentityFromRequest(request); + const isGuestAuthor = !comment.authorId + && !!comment.guestIdentityId + && guestIdentityId === comment.guestIdentityId; + + if (isGuestAuthor) { + const project = comment.version.video.project; + const shareSession = getShareSessionFromRequest(request, comment.version.video.id); + const shareAccess = shareSession + ? await validateShareLinkAccess({ + token: shareSession.token, + projectId: project.id, + videoId: comment.version.video.id, + requiredPermission: 'COMMENT', + passwordVerified: shareSession.passwordVerified, + }) + : { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false }; + const hasGuestAccess = project.visibility === 'PUBLIC' || (shareAccess.canComment && shareAccess.allowGuests); + if (!hasGuestAccess) { + return apiErrors.forbidden('Access denied'); + } + canDeleteOwnComment = true; + } + } + + if (!canDeleteOwnComment) { return apiErrors.forbidden('You can only delete your own comments'); } diff --git a/app/api/projects/[projectId]/tags/route.ts b/app/api/projects/[projectId]/tags/route.ts index ca4eec1..e27be53 100644 --- a/app/api/projects/[projectId]/tags/route.ts +++ b/app/api/projects/[projectId]/tags/route.ts @@ -2,19 +2,12 @@ import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { rateLimit } from '@/lib/rate-limit'; +import { validateShareLinkAccess } from '@/lib/share-links'; +import { getShareSessionFromRequest } from '@/lib/share-session'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ projectId: string }> }; -// Default tags to create for new projects -const DEFAULT_TAGS = [ - { name: 'Feedback', color: '#3B82F6', position: 0 }, - { name: 'Technical', color: '#EF4444', position: 1 }, - { name: 'Creative', color: '#8B5CF6', position: 2 }, - { name: 'Approved', color: '#22C55E', position: 3 }, - { name: 'Urgent', color: '#F59E0B', position: 4 }, -]; - // Helper to check project access async function checkProjectAccess(projectId: string, userId: string) { const project = await db.project.findUnique({ @@ -51,36 +44,56 @@ export async function GET(request: NextRequest, { params }: RouteParams) { try { const session = await auth(); const { projectId } = await params; + const videoId = request.nextUrl.searchParams.get('videoId'); - if (!session?.user?.id) { - return apiErrors.unauthorized(); + const project = await db.project.findUnique({ + where: { id: projectId }, + select: { id: true, visibility: true }, + }); + if (!project) return apiErrors.notFound('Project'); + + if (session?.user?.id) { + const { project: accessibleProject } = await checkProjectAccess(projectId, session.user.id); + if (!accessibleProject) { + return apiErrors.notFound('Project'); + } + } else { + let hasGuestAccess = project.visibility === 'PUBLIC'; + if (!hasGuestAccess && videoId) { + const video = await db.video.findFirst({ + where: { id: videoId, projectId }, + select: { id: true }, + }); + if (video) { + const shareSession = getShareSessionFromRequest(request, video.id); + const shareAccess = shareSession + ? await validateShareLinkAccess({ + token: shareSession.token, + projectId, + videoId: video.id, + requiredPermission: 'COMMENT', + passwordVerified: shareSession.passwordVerified, + }) + : { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false }; + hasGuestAccess = shareAccess.canComment && shareAccess.allowGuests; + } + } + + if (!hasGuestAccess) { + return apiErrors.forbidden('Access denied'); + } } - const { project } = await checkProjectAccess(projectId, session.user.id); - if (!project) { - return apiErrors.notFound('Project'); - } - - let tags = await db.commentTag.findMany({ + const tags = await db.commentTag.findMany({ where: { projectId }, orderBy: { position: 'asc' }, }); - // Auto-create default tags if none exist (idempotent with skipDuplicates - // to handle race conditions from concurrent requests) - if (tags.length === 0) { - await db.commentTag.createMany({ - data: DEFAULT_TAGS.map((tag) => ({ ...tag, projectId })), - skipDuplicates: true, - }); - tags = await db.commentTag.findMany({ - where: { projectId }, - orderBy: { position: 'asc' }, - }); - } - const response = successResponse(tags); - return withCacheControl(response, 'private, max-age=120, stale-while-revalidate=300'); + const cacheControl = session?.user?.id + ? 'private, max-age=120, stale-while-revalidate=300' + : 'private, no-cache'; + return withCacheControl(response, cacheControl); } catch (error) { console.error('Error fetching tags:', error); return apiErrors.internalError('Failed to fetch tags'); diff --git a/app/api/projects/[projectId]/videos/[videoId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/route.ts index c1d194b..a543769 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/route.ts @@ -106,6 +106,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) { isAuthenticated: !!session?.user?.id, currentUserId: session?.user?.id || null, currentUserName: session?.user?.name || null, + canDownload: access.hasAccess, + canManageTags: access.canEdit, }); return withCacheControl(response, 'private, no-cache'); diff --git a/app/api/projects/[projectId]/videos/[videoId]/share/route.ts b/app/api/projects/[projectId]/videos/[videoId]/share/route.ts index 4fb7120..480ab51 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/share/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/share/route.ts @@ -30,8 +30,27 @@ async function requireShareManagementAccess(projectId: string, videoId: string, return { error: null, video }; } +function resolveShareBaseUrl(request: NextRequest): string { + const configuredBaseUrl = process.env.NEXTAUTH_URL ?? process.env.NEXT_PUBLIC_APP_URL; + const normalizedConfiguredBaseUrl = configuredBaseUrl?.trim(); + + if (normalizedConfiguredBaseUrl) { + const withProtocol = /^https?:\/\//i.test(normalizedConfiguredBaseUrl) + ? normalizedConfiguredBaseUrl + : `https://${normalizedConfiguredBaseUrl}`; + + try { + return new URL(withProtocol).origin; + } catch { + // Fallback to request origin when env configuration is invalid. + } + } + + return request.nextUrl.origin; +} + function buildWatchUrl(request: NextRequest, videoId: string, token: string): string { - const url = new URL(`/watch/${videoId}`, request.nextUrl.origin); + const url = new URL(`/watch/${videoId}`, resolveShareBaseUrl(request)); url.searchParams.set('shareToken', token); return url.toString(); } @@ -44,6 +63,7 @@ function serializeShareLink( token: string; permission: string; allowGuests: boolean; + allowDownloads: boolean; expiresAt: Date | null; createdAt: Date; passwordHash: string | null; @@ -59,6 +79,7 @@ function serializeShareLink( token: link.token, permission: link.permission, allowGuests: link.allowGuests, + allowDownloads: link.allowDownloads, expiresAt: link.expiresAt, createdAt: link.createdAt, hasPassword: !!link.passwordHash, @@ -91,6 +112,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { token: true, permission: true, allowGuests: true, + allowDownloads: true, expiresAt: true, createdAt: true, passwordHash: true, @@ -123,6 +145,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const body = await request.json().catch(() => ({})); const allowGuests = typeof body?.allowGuests === 'boolean' ? body.allowGuests : true; + const allowDownloads = typeof body?.allowDownloads === 'boolean' ? body.allowDownloads : false; const password = typeof body?.password === 'string' ? body.password.trim() : ''; if (password.length > MAX_SHARE_PASSWORD_LENGTH) { return apiErrors.badRequest(`Password must be ${MAX_SHARE_PASSWORD_LENGTH} characters or fewer`); @@ -135,6 +158,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { token: string; permission: string; allowGuests: boolean; + allowDownloads: boolean; expiresAt: Date | null; createdAt: Date; passwordHash: string | null; @@ -158,6 +182,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { data: { token, allowGuests, + allowDownloads, passwordHash, expiresAt: null, }, @@ -166,6 +191,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { token: true, permission: true, allowGuests: true, + allowDownloads: true, expiresAt: true, createdAt: true, passwordHash: true, @@ -180,6 +206,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { videoId, permission: 'COMMENT', allowGuests, + allowDownloads, passwordHash, }, select: { @@ -187,6 +214,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { token: true, permission: true, allowGuests: true, + allowDownloads: true, expiresAt: true, createdAt: true, passwordHash: true, @@ -232,6 +260,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const body = await request.json().catch(() => ({})); const allowGuests = typeof body?.allowGuests === 'boolean' ? body.allowGuests : undefined; + const allowDownloads = typeof body?.allowDownloads === 'boolean' ? body.allowDownloads : undefined; const rawPassword = typeof body?.password === 'string' ? body.password : undefined; const clearPassword = body?.clearPassword === true; if (rawPassword !== undefined && rawPassword.length > MAX_SHARE_PASSWORD_LENGTH) { @@ -266,6 +295,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { where: { id: existing.id }, data: { ...(allowGuests !== undefined ? { allowGuests } : {}), + ...(allowDownloads !== undefined ? { allowDownloads } : {}), ...(passwordHashUpdate !== undefined ? { passwordHash: passwordHashUpdate } : {}), ...(shouldRotateToken ? { token: randomBytes(24).toString('base64url') } : {}), }, @@ -274,6 +304,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { token: true, permission: true, allowGuests: true, + allowDownloads: true, expiresAt: true, createdAt: true, passwordHash: true, diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index ee91f44..dd3375d 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -4,6 +4,7 @@ import { auth } from '@/lib/auth'; import { ProjectVisibility } from '@prisma/client'; import { rateLimit } from '@/lib/rate-limit'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; +import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags'; // GET /api/projects - List all projects for the authenticated user export async function GET(request: NextRequest) { @@ -137,19 +138,31 @@ export async function POST(request: NextRequest) { return apiErrors.forbidden('Only workspace owners and admins can create projects'); } - const project = await db.project.create({ - data: { - name: name.trim(), - description: description?.trim() || null, - slug, - visibility: visibility || ProjectVisibility.PRIVATE, - ownerId: session.user.id, - workspaceId, - }, - include: { - owner: { select: { id: true, name: true, image: true } }, - _count: { select: { videos: true, members: true } }, - }, + const project = await db.$transaction(async (tx) => { + const createdProject = await tx.project.create({ + data: { + name: name.trim(), + description: description?.trim() || null, + slug, + visibility: visibility || ProjectVisibility.PRIVATE, + ownerId: session.user.id, + workspaceId, + }, + include: { + owner: { select: { id: true, name: true, image: true } }, + _count: { select: { videos: true, members: true } }, + }, + }); + + await tx.commentTag.createMany({ + data: DEFAULT_COMMENT_TAGS.map((tag) => ({ + ...tag, + projectId: createdProject.id, + })), + skipDuplicates: true, + }); + + return createdProject; }); const response = successResponse(project, 201); diff --git a/app/api/upload/audio/[filename]/route.ts b/app/api/upload/audio/[filename]/route.ts index b532012..074906c 100644 --- a/app/api/upload/audio/[filename]/route.ts +++ b/app/api/upload/audio/[filename]/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server'; import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; -import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; +import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; import { apiErrors } from '@/lib/api-response'; +import { db } from '@/lib/db'; // 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; @@ -15,6 +16,7 @@ const CONTENT_TYPE_MAP: Record = { ogg: 'audio/ogg', wav: 'audio/wav', }; +const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000; function getContentType(filename: string): string { const ext = filename.split('.').pop()?.toLowerCase() || ''; @@ -34,6 +36,7 @@ export async function GET( } const key = `voice/${filename}`; + const mediaUrl = `/api/upload/audio/${filename}`; // Get file metadata to determine content type const headResponse = await r2Client.send( @@ -43,6 +46,23 @@ export async function GET( }) ); + const lastModified = headResponse.LastModified; + if (lastModified && Date.now() - lastModified.getTime() > UNATTACHED_UPLOAD_TTL_MS) { + const referenced = await db.comment.findFirst({ + where: { voiceUrl: mediaUrl }, + select: { id: true }, + }); + if (!referenced) { + await r2Client.send( + new DeleteObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: key, + }) + ).catch(() => undefined); + return apiErrors.notFound('File'); + } + } + // Use the stored content-type or infer from filename extension const contentType = headResponse.ContentType || getContentType(filename); @@ -78,7 +98,7 @@ export async function GET( status: 200, headers: { 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=31536000, immutable', + 'Cache-Control': 'private, no-store', 'Accept-Ranges': 'bytes', }, }); diff --git a/app/api/upload/audio/route.ts b/app/api/upload/audio/route.ts index 7c2cffd..a8975dc 100644 --- a/app/api/upload/audio/route.ts +++ b/app/api/upload/audio/route.ts @@ -1,14 +1,23 @@ -import { auth } from '@/lib/auth'; +import { NextRequest } from 'next/server'; +import { auth, checkProjectAccess } from '@/lib/auth'; +import { db } from '@/lib/db'; 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 { validateShareLinkAccess } from '@/lib/share-links'; +import { getShareSessionFromRequest } from '@/lib/share-session'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; +import { + deriveGuestUploadContext, + enforceGuestUploadQuota, + verifyGuestUploadToken, +} from '@/lib/guest-upload-token'; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const ALLOWED_TYPES = ['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'audio/wav']; -export async function POST(request: Request) { +export async function POST(request: NextRequest) { try { // Check Content-Length header BEFORE loading the file const contentLength = request.headers.get('content-length'); @@ -23,18 +32,69 @@ export async function POST(request: Request) { const limited = await rateLimit(request, 'voice-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('audio') as File | null; + const videoId = formData.get('videoId'); + const uploadToken = formData.get('uploadToken'); if (!file) { return apiErrors.badRequest('No audio file provided'); } + if (typeof videoId !== 'string' || !videoId.trim()) { + return apiErrors.badRequest('videoId is required'); + } + + const safeVideoId = videoId.trim(); + const video = await db.video.findUnique({ + where: { id: safeVideoId }, + include: { project: true }, + }); + if (!video) { + return apiErrors.notFound('Video'); + } + + const access = await checkProjectAccess(video.project, session?.user?.id); + const shareSession = getShareSessionFromRequest(request, safeVideoId); + const shareAccess = shareSession + ? await validateShareLinkAccess({ + token: shareSession.token, + projectId: video.projectId, + videoId: safeVideoId, + requiredPermission: 'COMMENT', + passwordVerified: shareSession.passwordVerified, + }) + : { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false }; + const canCommentWithMembership = !!session?.user?.id && access.hasAccess; + const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests); + if (!canCommentWithMembership && !canCommentWithShareLink) { + return apiErrors.forbidden('Access denied'); + } + + if (!session?.user?.id) { + if (typeof uploadToken !== 'string' || !uploadToken.trim()) { + return apiErrors.badRequest('uploadToken is required for guest uploads'); + } + + const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null); + if (!expectedContext) { + return apiErrors.forbidden('Missing trusted client IP header'); + } + + const isValidUploadToken = verifyGuestUploadToken(uploadToken.trim(), { + projectId: video.projectId, + videoId: safeVideoId, + intent: 'audio', + context: expectedContext, + }); + if (!isValidUploadToken) { + return apiErrors.forbidden('Invalid upload token'); + } + + const quotaError = await enforceGuestUploadQuota(request, safeVideoId, 'audio', shareSession?.token ?? null); + if (quotaError) return quotaError; + } // Double-check file size (defense in depth - Content-Length can be spoofed) if (file.size > MAX_FILE_SIZE) { diff --git a/app/api/upload/image/[filename]/route.ts b/app/api/upload/image/[filename]/route.ts index 947b699..993be54 100644 --- a/app/api/upload/image/[filename]/route.ts +++ b/app/api/upload/image/[filename]/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server'; import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; -import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; +import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; import { apiErrors } from '@/lib/api-response'; +import { db } from '@/lib/db'; // 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; @@ -14,6 +15,7 @@ const CONTENT_TYPE_MAP: Record = { gif: 'image/gif', svg: 'image/svg+xml', }; +const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000; function getContentType(filename: string): string { const ext = filename.split('.').pop()?.toLowerCase() || ''; @@ -33,6 +35,7 @@ export async function GET( } const key = `images/${filename}`; + const mediaUrl = `/api/upload/image/${filename}`; // Get file metadata to determine content type const headResponse = await r2Client.send( @@ -42,6 +45,23 @@ export async function GET( }) ); + const lastModified = headResponse.LastModified; + if (lastModified && Date.now() - lastModified.getTime() > UNATTACHED_UPLOAD_TTL_MS) { + const referenced = await db.comment.findFirst({ + where: { imageUrl: mediaUrl }, + select: { id: true }, + }); + if (!referenced) { + await r2Client.send( + new DeleteObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: key, + }) + ).catch(() => undefined); + return apiErrors.notFound('File'); + } + } + const contentType = headResponse.ContentType || getContentType(filename); const objectResponse = await r2Client.send( @@ -72,7 +92,7 @@ export async function GET( status: 200, headers: { 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=31536000, immutable', + 'Cache-Control': 'private, no-store', 'Accept-Ranges': 'bytes', }, }); @@ -85,4 +105,3 @@ export async function GET( return apiErrors.internalError('Failed to retrieve image'); } } - diff --git a/app/api/upload/image/route.ts b/app/api/upload/image/route.ts index 0d08798..c6f8931 100644 --- a/app/api/upload/image/route.ts +++ b/app/api/upload/image/route.ts @@ -1,9 +1,18 @@ -import { auth } from '@/lib/auth'; +import { NextRequest } from 'next/server'; +import { auth, checkProjectAccess } from '@/lib/auth'; +import { db } from '@/lib/db'; 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 { validateShareLinkAccess } from '@/lib/share-links'; +import { getShareSessionFromRequest } from '@/lib/share-session'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; +import { + deriveGuestUploadContext, + enforceGuestUploadQuota, + verifyGuestUploadToken, +} from '@/lib/guest-upload-token'; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const ALLOWED_TYPES = [ @@ -11,10 +20,9 @@ const ALLOWED_TYPES = [ 'image/png', 'image/webp', 'image/gif', - 'image/svg+xml' ]; -export async function POST(request: Request) { +export async function POST(request: NextRequest) { try { // Check Content-Length header BEFORE loading the file const contentLength = request.headers.get('content-length'); @@ -29,18 +37,69 @@ export async function POST(request: Request) { 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; + const videoId = formData.get('videoId'); + const uploadToken = formData.get('uploadToken'); if (!file) { return apiErrors.badRequest('No image file provided'); } + if (typeof videoId !== 'string' || !videoId.trim()) { + return apiErrors.badRequest('videoId is required'); + } + + const safeVideoId = videoId.trim(); + const video = await db.video.findUnique({ + where: { id: safeVideoId }, + include: { project: true }, + }); + if (!video) { + return apiErrors.notFound('Video'); + } + + const access = await checkProjectAccess(video.project, session?.user?.id); + const shareSession = getShareSessionFromRequest(request, safeVideoId); + const shareAccess = shareSession + ? await validateShareLinkAccess({ + token: shareSession.token, + projectId: video.projectId, + videoId: safeVideoId, + requiredPermission: 'COMMENT', + passwordVerified: shareSession.passwordVerified, + }) + : { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false }; + const canCommentWithMembership = !!session?.user?.id && access.hasAccess; + const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests); + if (!canCommentWithMembership && !canCommentWithShareLink) { + return apiErrors.forbidden('Access denied'); + } + + if (!session?.user?.id) { + if (typeof uploadToken !== 'string' || !uploadToken.trim()) { + return apiErrors.badRequest('uploadToken is required for guest uploads'); + } + + const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null); + if (!expectedContext) { + return apiErrors.forbidden('Missing trusted client IP header'); + } + + const isValidUploadToken = verifyGuestUploadToken(uploadToken.trim(), { + projectId: video.projectId, + videoId: safeVideoId, + intent: 'image', + context: expectedContext, + }); + if (!isValidUploadToken) { + return apiErrors.forbidden('Invalid upload token'); + } + + const quotaError = await enforceGuestUploadQuota(request, safeVideoId, 'image', shareSession?.token ?? null); + if (quotaError) return quotaError; + } // Double-check file size (defense in depth - Content-Length can be spoofed) if (file.size > MAX_FILE_SIZE) { @@ -54,7 +113,7 @@ export async function POST(request: Request) { } // Generate unique filename - const ext = contentType.split('/')[1] === 'svg+xml' ? 'svg' : contentType.split('/')[1] || 'jpeg'; + const ext = contentType.split('/')[1] || 'jpeg'; const filename = `${randomUUID()}.${ext}`; const key = `images/${filename}`; diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts index c9b6044..2b1c7be 100644 --- a/app/api/versions/[versionId]/comments/route.ts +++ b/app/api/versions/[versionId]/comments/route.ts @@ -6,10 +6,35 @@ import { notifyProjectOwner } from '@/lib/notifications'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { validateShareLinkAccess } from '@/lib/share-links'; import { getShareSessionFromRequest } from '@/lib/share-session'; +import { HeadObjectCommand } from '@aws-sdk/client-s3'; +import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; +import { ensureGuestIdentityFromRequest, getGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity'; type RouteParams = { params: Promise<{ versionId: string }> }; const SAFE_IMAGE_PATH = /^\/api\/upload\/image\/[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 SAFE_AUDIO_PATH = /^\/api\/upload\/audio\/[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 UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000; + +async function isFreshAttachment(url: string, kind: 'audio' | 'image'): Promise { + const prefix = kind === 'audio' ? '/api/upload/audio/' : '/api/upload/image/'; + if (!url.startsWith(prefix)) return false; + + const filename = url.slice(prefix.length); + const key = kind === 'audio' ? `voice/${filename}` : `images/${filename}`; + + try { + const head = await r2Client.send( + new HeadObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: key, + }) + ); + if (!head.LastModified) return false; + return Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS; + } catch { + return false; + } +} // GET /api/versions/[versionId]/comments export async function GET(request: NextRequest, { params }: RouteParams) { @@ -200,7 +225,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { requiredPermission: 'COMMENT', passwordVerified: shareSession.passwordVerified, }) - : { hasAccess: false, canComment: false, allowGuests: false, requiresPassword: false }; + : { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false }; // Check if user can comment const canComment = isOwner || isMember || isPublic || isWorkspaceMember || shareAccess.canComment; @@ -247,10 +272,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) { if (voiceUrl && !SAFE_AUDIO_PATH.test(voiceUrl)) { return apiErrors.badRequest('Voice URL must reference an uploaded audio file'); } + if (voiceUrl && !(await isFreshAttachment(voiceUrl, 'audio'))) { + return apiErrors.badRequest('Voice upload expired. Please upload again.'); + } if (imageUrl && !SAFE_IMAGE_PATH.test(imageUrl)) { return apiErrors.badRequest('Image URL must reference an uploaded image file'); } + if (imageUrl && !(await isFreshAttachment(imageUrl, 'image'))) { + return apiErrors.badRequest('Image upload expired. Please upload again.'); + } + + const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null; const comment = await db.comment.create({ data: { @@ -265,6 +298,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { authorId: session?.user?.id || null, guestName: isGuest ? guestName : null, guestEmail: isGuest ? guestEmail : null, + guestIdentityId: isGuest ? guestIdentity?.identityId ?? null : null, tagId: tagId || null, versionId, }, @@ -319,7 +353,25 @@ export async function POST(request: NextRequest, { params }: RouteParams) { } } - const response = successResponse(comment, 201); + const viewerUserId = session?.user?.id ?? null; + const viewerGuestIdentityId = viewerUserId + ? null + : guestIdentity?.identityId ?? getGuestIdentityFromRequest(request); + const canEditComment = viewerUserId + ? comment.authorId === viewerUserId + : !!viewerGuestIdentityId + && !!comment.guestIdentityId + && comment.guestIdentityId === viewerGuestIdentityId; + const { guestIdentityId: _guestIdentityId, ...commentData } = comment; + + const response = successResponse({ + ...commentData, + canEdit: canEditComment, + canDelete: canEditComment || viewerUserId === project.ownerId, + }, 201); + if (isGuest && guestIdentity?.shouldSetCookie) { + setGuestIdentityCookie(response, guestIdentity.identityId); + } return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error creating comment:', error); diff --git a/app/api/versions/[versionId]/download/route.ts b/app/api/versions/[versionId]/download/route.ts index 852778b..c478d37 100644 --- a/app/api/versions/[versionId]/download/route.ts +++ b/app/api/versions/[versionId]/download/route.ts @@ -2,6 +2,9 @@ import { db } from '@/lib/db'; import { auth, checkProjectAccess } from '@/lib/auth'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { rateLimit } from '@/lib/rate-limit'; +import { validateShareLinkAccess } from '@/lib/share-links'; +import { getShareSessionFromRequest } from '@/lib/share-session'; +import { NextRequest } from 'next/server'; import { DownloadEgressSource } from '@prisma/client'; type RouteParams = { params: Promise<{ versionId: string }> }; @@ -319,7 +322,7 @@ function parseEstimatedBytes(contentLengthHeader: string | null): bigint { } // GET /api/versions/[versionId]/download -export async function GET(request: Request, { params }: RouteParams) { +export async function GET(request: NextRequest, { params }: RouteParams) { try { const { searchParams } = new URL(request.url); const isPrepareOnly = searchParams.get('prepare') === '1'; @@ -362,7 +365,18 @@ export async function GET(request: Request, { params }: RouteParams) { } const access = await checkProjectAccess(version.video.project, session?.user?.id); - if (!access.hasAccess) { + const shareSession = getShareSessionFromRequest(request, version.video.id); + const shareAccess = shareSession + ? await validateShareLinkAccess({ + token: shareSession.token, + projectId: version.video.projectId, + videoId: version.video.id, + requiredPermission: 'VIEW', + passwordVerified: shareSession.passwordVerified, + }) + : { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false }; + const canDownloadViaShareLink = shareAccess.hasAccess && shareAccess.canDownload; + if (!access.hasAccess && !canDownloadViaShareLink) { return apiErrors.forbidden('Access denied'); } diff --git a/app/api/watch/[videoId]/route.ts b/app/api/watch/[videoId]/route.ts index a499d8e..690f8b4 100644 --- a/app/api/watch/[videoId]/route.ts +++ b/app/api/watch/[videoId]/route.ts @@ -5,6 +5,7 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response import { rateLimit } from '@/lib/rate-limit'; import { validateShareLinkAccess } from '@/lib/share-links'; import { getShareSessionFromRequest } from '@/lib/share-session'; +import { getGuestIdentityFromRequest } from '@/lib/guest-identity'; type RouteParams = { params: Promise<{ videoId: string }> }; @@ -48,6 +49,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { annotationData: true, parentId: true, authorId: true, + guestIdentityId: true, tagId: true, versionId: true, guestName: true, @@ -70,6 +72,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { annotationData: true, parentId: true, authorId: true, + guestIdentityId: true, tagId: true, versionId: true, guestName: true, @@ -109,7 +112,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { requiredPermission: 'VIEW', passwordVerified: shareSession.passwordVerified, }) - : { hasAccess: false, canComment: false, allowGuests: false, requiresPassword: false }; + : { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false }; if (!access.hasAccess && !shareAccess.hasAccess) { return apiErrors.forbidden('Access denied'); @@ -117,10 +120,65 @@ export async function GET(request: NextRequest, { params }: RouteParams) { // Include auth context so the client knows if the viewer is a guest const { project, ...videoData } = video; + const viewerUserId = session?.user?.id ?? null; + const viewerGuestIdentityId = viewerUserId ? null : getGuestIdentityFromRequest(request); + const isProjectOwner = viewerUserId === project.ownerId; + + const versions = videoData.versions.map((version) => { + if (!('comments' in version)) { + return version; + } + + return { + ...version, + comments: version.comments.map((comment) => { + const canEditComment = viewerUserId + ? comment.authorId === viewerUserId + : !!viewerGuestIdentityId + && !!comment.guestIdentityId + && comment.guestIdentityId === viewerGuestIdentityId; + const canDeleteComment = canEditComment || isProjectOwner; + const { + authorId: _commentAuthorId, + guestIdentityId: _commentGuestIdentityId, + replies, + ...commentData + } = comment; + + return { + ...commentData, + canEdit: canEditComment, + canDelete: canDeleteComment, + replies: replies.map((reply) => { + const canEditReply = viewerUserId + ? reply.authorId === viewerUserId + : !!viewerGuestIdentityId + && !!reply.guestIdentityId + && reply.guestIdentityId === viewerGuestIdentityId; + const canDeleteReply = canEditReply || isProjectOwner; + const { + authorId: _replyAuthorId, + guestIdentityId: _replyGuestIdentityId, + ...replyData + } = reply; + return { + ...replyData, + canEdit: canEditReply, + canDelete: canDeleteReply, + }; + }), + }; + }), + }; + }); + const canCommentWithMembership = access.hasAccess; const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests); + const canDownloadWithMembership = access.hasAccess; + const canDownloadWithShareLink = shareAccess.hasAccess && shareAccess.canDownload; const response = successResponse({ ...videoData, + versions, projectId: video.projectId, project: { name: project.name, @@ -131,6 +189,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) { currentUserId: session?.user?.id || null, currentUserName: session?.user?.name || null, canComment: canCommentWithMembership || canCommentWithShareLink, + canDownload: canDownloadWithMembership || canDownloadWithShareLink, + canManageTags: access.canEdit, }); return withCacheControl(response, 'private, no-cache'); diff --git a/app/api/watch/[videoId]/upload-token/route.ts b/app/api/watch/[videoId]/upload-token/route.ts new file mode 100644 index 0000000..9a12bfc --- /dev/null +++ b/app/api/watch/[videoId]/upload-token/route.ts @@ -0,0 +1,101 @@ +import { NextRequest } from 'next/server'; +import { auth, checkProjectAccess } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { rateLimit } from '@/lib/rate-limit'; +import { validateShareLinkAccess } from '@/lib/share-links'; +import { getShareSessionFromRequest } from '@/lib/share-session'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; +import { + createGuestUploadToken, + deriveGuestUploadContext, + guestUploadTokenTtlSeconds, + type GuestUploadIntent, +} from '@/lib/guest-upload-token'; + +type RouteParams = { params: Promise<{ videoId: string }> }; + +function validateSameOriginRequest(request: NextRequest): Response | null { + const origin = request.headers.get('origin'); + if (!origin) { + return apiErrors.forbidden('Missing Origin header'); + } + + if (origin !== request.nextUrl.origin) { + return apiErrors.forbidden('Cross-origin requests are not allowed'); + } + + return null; +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const originError = validateSameOriginRequest(request); + if (originError) return originError; + + const limited = await rateLimit(request, 'guest-upload-token', { + windowMs: 60 * 1000, + maxRequests: 20, + }); + if (limited) return limited; + + const session = await auth(); + if (session?.user?.id) { + return apiErrors.badRequest('Upload token is only required for guest uploads'); + } + + const { videoId } = await params; + const body = await request.json().catch(() => ({})); + const intent = body?.intent; + if (intent !== 'audio' && intent !== 'image') { + return apiErrors.badRequest('intent must be "audio" or "image"'); + } + + const video = await db.video.findUnique({ + where: { id: videoId }, + include: { project: true }, + }); + if (!video) { + return apiErrors.notFound('Video'); + } + + const access = await checkProjectAccess(video.project, session?.user?.id); + const shareSession = getShareSessionFromRequest(request, video.id); + const shareAccess = shareSession + ? await validateShareLinkAccess({ + token: shareSession.token, + projectId: video.projectId, + videoId: video.id, + requiredPermission: 'COMMENT', + passwordVerified: shareSession.passwordVerified, + }) + : { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false }; + + const canCommentWithMembership = !!session?.user?.id && access.hasAccess; + const canCommentWithShareLink = shareAccess.canComment && shareAccess.allowGuests; + if (!canCommentWithMembership && !canCommentWithShareLink) { + return apiErrors.forbidden('Access denied'); + } + + const context = deriveGuestUploadContext(request, shareSession?.token ?? null); + if (!context) { + return apiErrors.forbidden('Missing trusted client IP header'); + } + + const token = createGuestUploadToken({ + projectId: video.projectId, + videoId: video.id, + intent: intent as GuestUploadIntent, + context, + }); + + const response = successResponse({ + token, + intent, + expiresInSeconds: guestUploadTokenTtlSeconds, + }); + return withCacheControl(response, 'private, no-store'); + } catch (error) { + console.error('Error issuing guest upload token:', error); + return apiErrors.internalError('Failed to issue upload token'); + } +} diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index dc215f2..5fbb2cf 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -130,6 +130,8 @@ interface Comment { createdAt: string; author: { id: string; name: string | null; image: string | null } | null; guestName: string | null; + canEdit?: boolean; + canDelete?: boolean; tag: CommentTag | null; replies: { id: string; @@ -141,6 +143,8 @@ interface Comment { createdAt: string; author: { id: string; name: string | null; image: string | null } | null; guestName: string | null; + canEdit?: boolean; + canDelete?: boolean; tag: CommentTag | null; }[]; } @@ -161,6 +165,8 @@ interface VideoData { currentUserId: string | null; currentUserName: string | null; canComment?: boolean; + canDownload?: boolean; + canManageTags?: boolean; } function formatTime(seconds: number): string { @@ -388,6 +394,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const isGuest = video ? !video.isAuthenticated : false; const canInitializePlayer = mode !== 'watch' || !isGuest || guestNameConfirmed; + const normalizedGuestName = guestName.trim(); const [showVersionDialog, setShowVersionDialog] = useState(false); const [newVersionUrl, setNewVersionUrl] = useState(''); @@ -549,14 +556,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const isDownloadingVideo = activeDownloadTarget !== null; const isVideoDownloadAvailable = useMemo(() => { - if (!activeVersion) return false; + if (!activeVersion || !video?.canDownload) return false; if (activeVersion.providerId === 'bunny') return true; if (activeVersion.providerId !== 'direct') return false; return !!getSafeDirectDownloadUrl(activeVersion.originalUrl); - }, [activeVersion]); + }, [activeVersion, video?.canDownload]); const handleDownloadVideo = useCallback(async (preference: BunnyDownloadPreference = 'compressed') => { if (!activeVersion || !video || isDownloadingVideo) return; + if (!video.canDownload) { + toast.error('Download is disabled for this shared link'); + return; + } if (activeVersion.providerId !== 'bunny' && activeVersion.providerId !== 'direct') { toast.error('This video source does not support direct download'); return; @@ -619,6 +630,24 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi } }, [activeVersion, isDownloadingVideo, video]); + const getGuestUploadToken = useCallback(async (intent: 'audio' | 'image') => { + if (!isGuest) return null; + + const response = await fetch(`/api/watch/${videoId}/upload-token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ intent }), + }); + const payload = (await response.json().catch(() => null)) as + | { data?: { token?: string }; error?: string } + | null; + const token = payload?.data?.token; + if (!response.ok || !token) { + throw new Error(payload?.error || 'Failed to prepare upload'); + } + return token; + }, [isGuest, videoId]); + // Memoize comments array const comments = useMemo(() => { return activeVersion?.comments || []; @@ -671,7 +700,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi if (!projectId) return; async function fetchTags() { try { - const res = await fetch(`/api/projects/${projectId}/tags`); + const query = videoId ? `?videoId=${encodeURIComponent(videoId)}` : ''; + const res = await fetch(`/api/projects/${projectId}/tags${query}`); if (res.ok) { const data = await res.json(); const tags = data.data || []; @@ -684,7 +714,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi } } fetchTags(); - }, [projectId, selectedTagId]); + }, [projectId, selectedTagId, videoId]); // Load YouTube API immediately on component mount (async, non-blocking) useEffect(() => { @@ -1542,7 +1572,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi isResolved: false, createdAt: new Date().toISOString(), author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null }, - guestName: isGuest ? guestName : null, + guestName: isGuest ? normalizedGuestName : null, + canEdit: true, + canDelete: true, tag: availableTags.find(t => t.id === selectedTagId) || null, replies: [], }; @@ -1578,6 +1610,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi setIsUploadingImage(true); const imageFormData = new FormData(); imageFormData.append('image', imageBlob); + imageFormData.append('videoId', videoId); + const uploadToken = await getGuestUploadToken('image'); + if (uploadToken) imageFormData.append('uploadToken', uploadToken); const imageRes = await fetch('/api/upload/image', { method: 'POST', @@ -1597,7 +1632,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi timestamp: selectedTimestamp ?? currentTime, ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), ...(imageData && { imageUrl: imageData.url }), - ...(isGuest && guestName && { guestName }), + ...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }), ...(selectedTagId && { tagId: selectedTagId }), ...(serializedAnnotation && { annotationData: serializedAnnotation }), }), @@ -1649,7 +1684,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi setIsUploadingImage(false); isMutatingRef.current = false; } - }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, currentUserName, selectedTagId, availableTags, imageBlob, annotationStrokes, isAnnotating]); + }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, normalizedGuestName, currentUserName, selectedTagId, availableTags, imageBlob, annotationStrokes, isAnnotating, videoId, getGuestUploadToken]); const handleImageSelect = useCallback((e: React.ChangeEvent, isReply: boolean = false) => { const file = e.target.files?.[0]; @@ -1758,6 +1793,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi try { const formData = new FormData(); formData.append('audio', audioBlob, 'recording.webm'); + formData.append('videoId', videoId); + const uploadToken = await getGuestUploadToken('audio'); + if (uploadToken) formData.append('uploadToken', uploadToken); const uploadRes = await fetch('/api/upload/audio', { method: 'POST', @@ -1779,7 +1817,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi } finally { setIsUploadingAudio(false); } - }, [audioBlob, activeVersion, recordingTime, handleAddComment]); + }, [audioBlob, activeVersion, recordingTime, handleAddComment, videoId, getGuestUploadToken]); const stopVoiceTracking = useCallback(() => { if (voiceRafRef.current) { @@ -1895,6 +1933,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi if (audioBlob) { const formData = new FormData(); formData.append('audio', audioBlob, 'recording.webm'); + formData.append('videoId', videoId); + const uploadToken = await getGuestUploadToken('audio'); + if (uploadToken) formData.append('uploadToken', uploadToken); 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(); @@ -1914,7 +1955,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi setIsUploadingAudio(false); setIsUploadingImage(false); } - }, [audioBlob, imageBlob, activeVersion, recordingTime, commentText, submitVoiceComment, handleAddComment]); + }, [audioBlob, imageBlob, activeVersion, recordingTime, commentText, submitVoiceComment, handleAddComment, videoId, getGuestUploadToken]); const handleResolveComment = useCallback( async (commentId: string, currentlyResolved: boolean) => { @@ -2002,7 +2043,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi annotationData: null, createdAt: new Date().toISOString(), author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null }, - guestName: isGuest ? guestName : null, + guestName: isGuest ? normalizedGuestName : null, + canEdit: true, + canDelete: true, tag: null, }; @@ -2041,6 +2084,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi setIsUploadingReplyImage(true); const imageFormData = new FormData(); imageFormData.append('image', replyImageBlob); + imageFormData.append('videoId', videoId); + const uploadToken = await getGuestUploadToken('image'); + if (uploadToken) imageFormData.append('uploadToken', uploadToken); const imageRes = await fetch('/api/upload/image', { method: 'POST', @@ -2061,7 +2107,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi parentId, ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), ...(submittedImageData && { imageUrl: submittedImageData.url }), - ...(isGuest && guestName && { guestName }), + ...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }), }), }); @@ -2132,7 +2178,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi setIsUploadingReplyImage(false); isMutatingRef.current = false; } - }, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName, currentUserName, replyImageBlob]); + }, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, normalizedGuestName, currentUserName, replyImageBlob, videoId, getGuestUploadToken]); const startReplyRecording = useCallback(async () => { try { @@ -2189,6 +2235,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi try { const formData = new FormData(); formData.append('audio', replyAudioBlob, 'recording.webm'); + formData.append('videoId', videoId); + const uploadToken = await getGuestUploadToken('audio'); + if (uploadToken) formData.append('uploadToken', uploadToken); 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(); @@ -2199,7 +2248,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi } finally { setIsUploadingReplyAudio(false); } - }, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment]); + }, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment, videoId, getGuestUploadToken]); const submitReplyWithMedia = useCallback(async (parentId: string) => { if (!activeVersion) return; @@ -2218,6 +2267,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi if (replyAudioBlob) { const formData = new FormData(); formData.append('audio', replyAudioBlob, 'recording.webm'); + formData.append('videoId', videoId); + const uploadToken = await getGuestUploadToken('audio'); + if (uploadToken) formData.append('uploadToken', uploadToken); 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(); @@ -2237,7 +2289,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi setIsUploadingReplyAudio(false); setIsUploadingReplyImage(false); } - }, [replyAudioBlob, replyImageBlob, activeVersion, replyRecordingTime, replyText, submitVoiceReply, handleReplyComment]); + }, [replyAudioBlob, replyImageBlob, activeVersion, replyRecordingTime, replyText, submitVoiceReply, handleReplyComment, videoId, getGuestUploadToken]); const handleEditComment = useCallback(async (commentId: string) => { if (!editText.trim() && !editAnnotationData) return; @@ -2257,6 +2309,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const body: Record = { content: editText }; if (editTagId !== undefined) body.tagId = editTagId; if (finalAnnotationData !== undefined) body.annotationData = finalAnnotationData; + if (isGuest && normalizedGuestName) body.guestName = normalizedGuestName; const res = await fetch(`/api/comments/${commentId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -2306,7 +2359,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi setIsSubmittingEdit(false); isMutatingRef.current = false; } - }, [editText, editTagId, editAnnotationData, isEditingAnnotation, activeVersionId, availableTags]); + }, [editText, editTagId, editAnnotationData, isEditingAnnotation, activeVersionId, availableTags, isGuest, normalizedGuestName]); const handleDeleteComment = useCallback(async (commentId: string) => { setDeletingCommentId(commentId); @@ -3520,6 +3573,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi comment.author?.name || comment.guestName || 'Anonymous'; const isEditing = editingCommentId === comment.id; const isReplying = replyingTo === comment.id; + const canEditComment = comment.canEdit ?? (comment.author?.id === currentUserId); + const canDeleteComment = comment.canDelete ?? (comment.author?.id === currentUserId || video.project.ownerId === currentUserId); + const canManageComment = canEditComment || canDeleteComment; return (
)} - {(comment.author?.id === currentUserId || video.project.ownerId === currentUserId) && ( + {canManageComment && (