Add guest upload tokens and share-session aware permissions

This commit is contained in:
Yusuf İpek
2026-02-23 18:17:22 +03:00
parent 9058317247
commit fe7235052e
21 changed files with 1117 additions and 150 deletions
@@ -17,6 +17,7 @@ interface ShareLinkData {
id: string; id: string;
token: string; token: string;
allowGuests: boolean; allowGuests: boolean;
allowDownloads: boolean;
hasPassword: boolean; hasPassword: boolean;
} }
@@ -38,6 +39,7 @@ export default function VideoSharePage({ params }: VideoSharePageProps) {
const [shareUrl, setShareUrl] = useState<string | null>(null); const [shareUrl, setShareUrl] = useState<string | null>(null);
const [hasPassword, setHasPassword] = useState(false); const [hasPassword, setHasPassword] = useState(false);
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [allowDownloads, setAllowDownloads] = useState(false);
useEffect(() => { useEffect(() => {
params.then(({ projectId: nextProjectId, videoId: nextVideoId }) => { params.then(({ projectId: nextProjectId, videoId: nextVideoId }) => {
@@ -65,10 +67,12 @@ export default function VideoSharePage({ params }: VideoSharePageProps) {
setShareUrl(payload.data.shareUrl); setShareUrl(payload.data.shareUrl);
setHasPassword(!!payload.data.link?.hasPassword); setHasPassword(!!payload.data.link?.hasPassword);
setAllowDownloads(!!payload.data.link?.allowDownloads);
} catch { } catch {
setError('Failed to load share link'); setError('Failed to load share link');
setShareUrl(null); setShareUrl(null);
setHasPassword(false); setHasPassword(false);
setAllowDownloads(false);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -94,7 +98,7 @@ export default function VideoSharePage({ params }: VideoSharePageProps) {
const response = await fetch(`/api/projects/${projectId}/videos/${videoId}/share`, { const response = await fetch(`/api/projects/${projectId}/videos/${videoId}/share`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ allowGuests: true }), body: JSON.stringify({ allowGuests: true, allowDownloads }),
}); });
const payload = (await response.json()) as ShareResponse; const payload = (await response.json()) as ShareResponse;
@@ -105,6 +109,7 @@ export default function VideoSharePage({ params }: VideoSharePageProps) {
setShareUrl(payload.data.shareUrl); setShareUrl(payload.data.shareUrl);
setHasPassword(!!payload.data.link?.hasPassword); setHasPassword(!!payload.data.link?.hasPassword);
setAllowDownloads(!!payload.data.link?.allowDownloads);
setPassword(''); setPassword('');
} catch { } catch {
setError('Failed to create share link'); setError('Failed to create share link');
@@ -132,6 +137,7 @@ export default function VideoSharePage({ params }: VideoSharePageProps) {
setShareUrl(null); setShareUrl(null);
setHasPassword(false); setHasPassword(false);
setAllowDownloads(false);
setPassword(''); setPassword('');
} catch { } catch {
setError('Failed to revoke share link'); setError('Failed to revoke share link');
@@ -165,6 +171,7 @@ export default function VideoSharePage({ params }: VideoSharePageProps) {
const data = (payload as ShareResponse).data; const data = (payload as ShareResponse).data;
setShareUrl(data.shareUrl); setShareUrl(data.shareUrl);
setHasPassword(!!data.link?.hasPassword); setHasPassword(!!data.link?.hasPassword);
setAllowDownloads(!!data.link?.allowDownloads);
setPassword(''); setPassword('');
} catch { } catch {
setError('Failed to update link security'); 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 ( return (
<div className="min-h-[calc(100vh-4rem)] flex items-start justify-center py-12 px-4"> <div className="min-h-[calc(100vh-4rem)] flex items-start justify-center py-12 px-4">
<div className="w-full max-w-xl space-y-6"> <div className="w-full max-w-xl space-y-6">
@@ -220,6 +255,29 @@ export default function VideoSharePage({ params }: VideoSharePageProps) {
Revoke Link Revoke Link
</Button> </Button>
</div> </div>
<div className="rounded-lg border p-3 space-y-2">
<div>
<p className="text-sm font-medium">Video download</p>
<p className="text-xs text-muted-foreground">Allow viewers with this link to download</p>
</div>
<div className="flex gap-2">
<Button
variant={allowDownloads ? 'default' : 'outline'}
disabled={submitting || allowDownloads}
onClick={() => updateDownloadSetting(true)}
>
Allow Download
</Button>
<Button
variant={!allowDownloads ? 'default' : 'outline'}
disabled={submitting || !allowDownloads}
onClick={() => updateDownloadSetting(false)}
>
Block Download
</Button>
</div>
</div>
<div className="rounded-lg border p-3 space-y-2"> <div className="rounded-lg border p-3 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium"> <div className="flex items-center gap-2 text-sm font-medium">
{hasPassword ? <ShieldCheck className="h-4 w-4 text-green-600" /> : <Lock className="h-4 w-4" />} {hasPassword ? <ShieldCheck className="h-4 w-4 text-green-600" /> : <Lock className="h-4 w-4" />}
+96 -24
View File
@@ -4,7 +4,10 @@ import { auth } from '@/lib/auth';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { DeleteObjectCommand } from '@aws-sdk/client-s3'; import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { rateLimit } from '@/lib/rate-limit'; 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 { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
type RouteParams = { params: Promise<{ commentId: string }> }; type RouteParams = { params: Promise<{ commentId: string }> };
@@ -125,10 +128,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
const session = await auth(); const session = await auth();
const { commentId } = await params; const { commentId } = await params;
const body = await request.json();
if (!session?.user?.id) { const { content, isResolved, tagId, annotationData } = body;
return apiErrors.unauthorized();
}
const comment = await db.comment.findUnique({ const comment = await db.comment.findUnique({
where: { id: commentId }, where: { id: commentId },
@@ -138,9 +139,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
video: { video: {
include: { include: {
project: { project: {
include: { include: { members: true },
members: { where: { userId: session.user.id } },
},
}, },
}, },
}, },
@@ -154,18 +153,25 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
} }
const project = comment.version.video.project; const project = comment.version.video.project;
const isOwner = project.ownerId === session.user.id; const userId = session?.user?.id ?? null;
const isAuthor = comment.authorId === session.user.id; const isOwner = userId === project.ownerId;
const isMember = project.members.length > 0; 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 // Check workspace membership for resolve permissions
let isWorkspaceMember = false; let isWorkspaceMember = false;
if (!isOwner && !isMember && session.user.id) { if (!isOwner && !isMember && userId) {
const wsMember = await db.workspaceMember.findUnique({ const wsMember = await db.workspaceMember.findUnique({
where: { where: {
workspaceId_userId: { workspaceId_userId: {
workspaceId: project.workspaceId, workspaceId: project.workspaceId,
userId: session.user.id, userId,
}, },
}, },
}); });
@@ -173,19 +179,33 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
where: { id: project.workspaceId }, where: { id: project.workspaceId },
select: { ownerId: true }, select: { ownerId: true },
}); });
isWorkspaceMember = !!wsMember || wsOwner?.ownerId === session.user.id; isWorkspaceMember = !!wsMember || wsOwner?.ownerId === userId;
} }
const body = await request.json(); if (!userId && !isGuestAuthor) {
const { content, isResolved, tagId, annotationData } = body; 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 // 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'); return apiErrors.forbidden('Only the author can edit comment content');
} }
// Owner, author, members, or workspace members can resolve/unresolve // 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'); 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'); return withCacheControl(response, 'private, no-store');
} catch (error) { } catch (error) {
console.error('Error updating comment:', error); console.error('Error updating comment:', error);
@@ -230,13 +269,18 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
const session = await auth(); const session = await auth();
const { commentId } = await params; const { commentId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const comment = await db.comment.findUnique({ const comment = await db.comment.findUnique({
where: { id: commentId }, where: { id: commentId },
include: { include: {
version: {
include: {
video: {
include: {
project: true,
},
},
},
},
replies: { select: { voiceUrl: true, imageUrl: true } }, replies: { select: { voiceUrl: true, imageUrl: true } },
}, },
}); });
@@ -245,9 +289,37 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Comment'); 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'); return apiErrors.forbidden('You can only delete your own comments');
} }
+44 -31
View File
@@ -2,19 +2,12 @@ import { NextRequest } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { validateShareLinkAccess } from '@/lib/share-links';
import { getShareSessionFromRequest } from '@/lib/share-session';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
type RouteParams = { params: Promise<{ projectId: string }> }; type RouteParams = { params: Promise<{ projectId: string }> };
// 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 // Helper to check project access
async function checkProjectAccess(projectId: string, userId: string) { async function checkProjectAccess(projectId: string, userId: string) {
const project = await db.project.findUnique({ const project = await db.project.findUnique({
@@ -51,36 +44,56 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
try { try {
const session = await auth(); const session = await auth();
const { projectId } = await params; const { projectId } = await params;
const videoId = request.nextUrl.searchParams.get('videoId');
if (!session?.user?.id) { const project = await db.project.findUnique({
return apiErrors.unauthorized(); where: { id: projectId },
} select: { id: true, visibility: true },
});
if (!project) return apiErrors.notFound('Project');
const { project } = await checkProjectAccess(projectId, session.user.id); if (session?.user?.id) {
if (!project) { const { project: accessibleProject } = await checkProjectAccess(projectId, session.user.id);
if (!accessibleProject) {
return apiErrors.notFound('Project'); return apiErrors.notFound('Project');
} }
} else {
let tags = await db.commentTag.findMany({ let hasGuestAccess = project.visibility === 'PUBLIC';
where: { projectId }, if (!hasGuestAccess && videoId) {
orderBy: { position: 'asc' }, const video = await db.video.findFirst({
}); where: { id: videoId, projectId },
select: { id: true },
// 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' },
}); });
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 tags = await db.commentTag.findMany({
where: { projectId },
orderBy: { position: 'asc' },
});
const response = successResponse(tags); 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) { } catch (error) {
console.error('Error fetching tags:', error); console.error('Error fetching tags:', error);
return apiErrors.internalError('Failed to fetch tags'); return apiErrors.internalError('Failed to fetch tags');
@@ -106,6 +106,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
isAuthenticated: !!session?.user?.id, isAuthenticated: !!session?.user?.id,
currentUserId: session?.user?.id || null, currentUserId: session?.user?.id || null,
currentUserName: session?.user?.name || null, currentUserName: session?.user?.name || null,
canDownload: access.hasAccess,
canManageTags: access.canEdit,
}); });
return withCacheControl(response, 'private, no-cache'); return withCacheControl(response, 'private, no-cache');
@@ -30,8 +30,27 @@ async function requireShareManagementAccess(projectId: string, videoId: string,
return { error: null, video }; 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 { 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); url.searchParams.set('shareToken', token);
return url.toString(); return url.toString();
} }
@@ -44,6 +63,7 @@ function serializeShareLink(
token: string; token: string;
permission: string; permission: string;
allowGuests: boolean; allowGuests: boolean;
allowDownloads: boolean;
expiresAt: Date | null; expiresAt: Date | null;
createdAt: Date; createdAt: Date;
passwordHash: string | null; passwordHash: string | null;
@@ -59,6 +79,7 @@ function serializeShareLink(
token: link.token, token: link.token,
permission: link.permission, permission: link.permission,
allowGuests: link.allowGuests, allowGuests: link.allowGuests,
allowDownloads: link.allowDownloads,
expiresAt: link.expiresAt, expiresAt: link.expiresAt,
createdAt: link.createdAt, createdAt: link.createdAt,
hasPassword: !!link.passwordHash, hasPassword: !!link.passwordHash,
@@ -91,6 +112,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
token: true, token: true,
permission: true, permission: true,
allowGuests: true, allowGuests: true,
allowDownloads: true,
expiresAt: true, expiresAt: true,
createdAt: true, createdAt: true,
passwordHash: true, passwordHash: true,
@@ -123,6 +145,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const allowGuests = typeof body?.allowGuests === 'boolean' ? body.allowGuests : true; 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() : ''; const password = typeof body?.password === 'string' ? body.password.trim() : '';
if (password.length > MAX_SHARE_PASSWORD_LENGTH) { if (password.length > MAX_SHARE_PASSWORD_LENGTH) {
return apiErrors.badRequest(`Password must be ${MAX_SHARE_PASSWORD_LENGTH} characters or fewer`); 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; token: string;
permission: string; permission: string;
allowGuests: boolean; allowGuests: boolean;
allowDownloads: boolean;
expiresAt: Date | null; expiresAt: Date | null;
createdAt: Date; createdAt: Date;
passwordHash: string | null; passwordHash: string | null;
@@ -158,6 +182,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
data: { data: {
token, token,
allowGuests, allowGuests,
allowDownloads,
passwordHash, passwordHash,
expiresAt: null, expiresAt: null,
}, },
@@ -166,6 +191,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
token: true, token: true,
permission: true, permission: true,
allowGuests: true, allowGuests: true,
allowDownloads: true,
expiresAt: true, expiresAt: true,
createdAt: true, createdAt: true,
passwordHash: true, passwordHash: true,
@@ -180,6 +206,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
videoId, videoId,
permission: 'COMMENT', permission: 'COMMENT',
allowGuests, allowGuests,
allowDownloads,
passwordHash, passwordHash,
}, },
select: { select: {
@@ -187,6 +214,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
token: true, token: true,
permission: true, permission: true,
allowGuests: true, allowGuests: true,
allowDownloads: true,
expiresAt: true, expiresAt: true,
createdAt: true, createdAt: true,
passwordHash: true, passwordHash: true,
@@ -232,6 +260,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const allowGuests = typeof body?.allowGuests === 'boolean' ? body.allowGuests : undefined; 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 rawPassword = typeof body?.password === 'string' ? body.password : undefined;
const clearPassword = body?.clearPassword === true; const clearPassword = body?.clearPassword === true;
if (rawPassword !== undefined && rawPassword.length > MAX_SHARE_PASSWORD_LENGTH) { 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 }, where: { id: existing.id },
data: { data: {
...(allowGuests !== undefined ? { allowGuests } : {}), ...(allowGuests !== undefined ? { allowGuests } : {}),
...(allowDownloads !== undefined ? { allowDownloads } : {}),
...(passwordHashUpdate !== undefined ? { passwordHash: passwordHashUpdate } : {}), ...(passwordHashUpdate !== undefined ? { passwordHash: passwordHashUpdate } : {}),
...(shouldRotateToken ? { token: randomBytes(24).toString('base64url') } : {}), ...(shouldRotateToken ? { token: randomBytes(24).toString('base64url') } : {}),
}, },
@@ -274,6 +304,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
token: true, token: true,
permission: true, permission: true,
allowGuests: true, allowGuests: true,
allowDownloads: true,
expiresAt: true, expiresAt: true,
createdAt: true, createdAt: true,
passwordHash: true, passwordHash: true,
+14 -1
View File
@@ -4,6 +4,7 @@ import { auth } from '@/lib/auth';
import { ProjectVisibility } from '@prisma/client'; import { ProjectVisibility } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; 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 // GET /api/projects - List all projects for the authenticated user
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -137,7 +138,8 @@ export async function POST(request: NextRequest) {
return apiErrors.forbidden('Only workspace owners and admins can create projects'); return apiErrors.forbidden('Only workspace owners and admins can create projects');
} }
const project = await db.project.create({ const project = await db.$transaction(async (tx) => {
const createdProject = await tx.project.create({
data: { data: {
name: name.trim(), name: name.trim(),
description: description?.trim() || null, description: description?.trim() || null,
@@ -152,6 +154,17 @@ export async function POST(request: NextRequest) {
}, },
}); });
await tx.commentTag.createMany({
data: DEFAULT_COMMENT_TAGS.map((tag) => ({
...tag,
projectId: createdProject.id,
})),
skipDuplicates: true,
});
return createdProject;
});
const response = successResponse(project, 201); const response = successResponse(project, 201);
return withCacheControl(response, 'private, no-store'); return withCacheControl(response, 'private, no-store');
} catch (error) { } catch (error) {
+22 -2
View File
@@ -1,7 +1,8 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; 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 { apiErrors } from '@/lib/api-response';
import { db } from '@/lib/db';
// Only allow UUID filenames with safe extensions // 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 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<string, string> = {
ogg: 'audio/ogg', ogg: 'audio/ogg',
wav: 'audio/wav', wav: 'audio/wav',
}; };
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
function getContentType(filename: string): string { function getContentType(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || ''; const ext = filename.split('.').pop()?.toLowerCase() || '';
@@ -34,6 +36,7 @@ export async function GET(
} }
const key = `voice/${filename}`; const key = `voice/${filename}`;
const mediaUrl = `/api/upload/audio/${filename}`;
// Get file metadata to determine content type // Get file metadata to determine content type
const headResponse = await r2Client.send( 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 // Use the stored content-type or infer from filename extension
const contentType = headResponse.ContentType || getContentType(filename); const contentType = headResponse.ContentType || getContentType(filename);
@@ -78,7 +98,7 @@ export async function GET(
status: 200, status: 200,
headers: { headers: {
'Content-Type': contentType, 'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable', 'Cache-Control': 'private, no-store',
'Accept-Ranges': 'bytes', 'Accept-Ranges': 'bytes',
}, },
}); });
+66 -6
View File
@@ -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 { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { PutObjectCommand } from '@aws-sdk/client-s3'; import { PutObjectCommand } from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { rateLimit } from '@/lib/rate-limit'; 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 { 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 MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'audio/wav']; 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 { try {
// Check Content-Length header BEFORE loading the file // Check Content-Length header BEFORE loading the file
const contentLength = request.headers.get('content-length'); const contentLength = request.headers.get('content-length');
@@ -23,18 +32,69 @@ export async function POST(request: Request) {
const limited = await rateLimit(request, 'voice-upload'); const limited = await rateLimit(request, 'voice-upload');
if (limited) return limited; if (limited) return limited;
// Require authentication
const session = await auth(); const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const formData = await request.formData(); const formData = await request.formData();
const file = formData.get('audio') as File | null; const file = formData.get('audio') as File | null;
const videoId = formData.get('videoId');
const uploadToken = formData.get('uploadToken');
if (!file) { if (!file) {
return apiErrors.badRequest('No audio file provided'); 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) // Double-check file size (defense in depth - Content-Length can be spoofed)
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
+22 -3
View File
@@ -1,7 +1,8 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; 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 { apiErrors } from '@/lib/api-response';
import { db } from '@/lib/db';
// Only allow UUID filenames with safe extensions // 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 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<string, string> = {
gif: 'image/gif', gif: 'image/gif',
svg: 'image/svg+xml', svg: 'image/svg+xml',
}; };
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
function getContentType(filename: string): string { function getContentType(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || ''; const ext = filename.split('.').pop()?.toLowerCase() || '';
@@ -33,6 +35,7 @@ export async function GET(
} }
const key = `images/${filename}`; const key = `images/${filename}`;
const mediaUrl = `/api/upload/image/${filename}`;
// Get file metadata to determine content type // Get file metadata to determine content type
const headResponse = await r2Client.send( 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 contentType = headResponse.ContentType || getContentType(filename);
const objectResponse = await r2Client.send( const objectResponse = await r2Client.send(
@@ -72,7 +92,7 @@ export async function GET(
status: 200, status: 200,
headers: { headers: {
'Content-Type': contentType, 'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable', 'Cache-Control': 'private, no-store',
'Accept-Ranges': 'bytes', 'Accept-Ranges': 'bytes',
}, },
}); });
@@ -85,4 +105,3 @@ export async function GET(
return apiErrors.internalError('Failed to retrieve image'); return apiErrors.internalError('Failed to retrieve image');
} }
} }
+67 -8
View File
@@ -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 { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { PutObjectCommand } from '@aws-sdk/client-s3'; import { PutObjectCommand } from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { rateLimit } from '@/lib/rate-limit'; 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 { 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 MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = [ const ALLOWED_TYPES = [
@@ -11,10 +20,9 @@ const ALLOWED_TYPES = [
'image/png', 'image/png',
'image/webp', 'image/webp',
'image/gif', 'image/gif',
'image/svg+xml'
]; ];
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
// Check Content-Length header BEFORE loading the file // Check Content-Length header BEFORE loading the file
const contentLength = request.headers.get('content-length'); const contentLength = request.headers.get('content-length');
@@ -29,18 +37,69 @@ export async function POST(request: Request) {
const limited = await rateLimit(request, 'image-upload'); const limited = await rateLimit(request, 'image-upload');
if (limited) return limited; if (limited) return limited;
// Require authentication
const session = await auth(); const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const formData = await request.formData(); const formData = await request.formData();
const file = formData.get('image') as File | null; const file = formData.get('image') as File | null;
const videoId = formData.get('videoId');
const uploadToken = formData.get('uploadToken');
if (!file) { if (!file) {
return apiErrors.badRequest('No image file provided'); 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) // Double-check file size (defense in depth - Content-Length can be spoofed)
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
@@ -54,7 +113,7 @@ export async function POST(request: Request) {
} }
// Generate unique filename // 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 filename = `${randomUUID()}.${ext}`;
const key = `images/${filename}`; const key = `images/${filename}`;
+54 -2
View File
@@ -6,10 +6,35 @@ import { notifyProjectOwner } from '@/lib/notifications';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { validateShareLinkAccess } from '@/lib/share-links'; import { validateShareLinkAccess } from '@/lib/share-links';
import { getShareSessionFromRequest } from '@/lib/share-session'; 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 }> }; 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_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 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<boolean> {
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 // GET /api/versions/[versionId]/comments
export async function GET(request: NextRequest, { params }: RouteParams) { export async function GET(request: NextRequest, { params }: RouteParams) {
@@ -200,7 +225,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
requiredPermission: 'COMMENT', requiredPermission: 'COMMENT',
passwordVerified: shareSession.passwordVerified, 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 // Check if user can comment
const canComment = isOwner || isMember || isPublic || isWorkspaceMember || shareAccess.canComment; 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)) { if (voiceUrl && !SAFE_AUDIO_PATH.test(voiceUrl)) {
return apiErrors.badRequest('Voice URL must reference an uploaded audio file'); 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)) { if (imageUrl && !SAFE_IMAGE_PATH.test(imageUrl)) {
return apiErrors.badRequest('Image URL must reference an uploaded image file'); 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({ const comment = await db.comment.create({
data: { data: {
@@ -265,6 +298,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
authorId: session?.user?.id || null, authorId: session?.user?.id || null,
guestName: isGuest ? guestName : null, guestName: isGuest ? guestName : null,
guestEmail: isGuest ? guestEmail : null, guestEmail: isGuest ? guestEmail : null,
guestIdentityId: isGuest ? guestIdentity?.identityId ?? null : null,
tagId: tagId || null, tagId: tagId || null,
versionId, 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'); return withCacheControl(response, 'private, no-store');
} catch (error) { } catch (error) {
console.error('Error creating comment:', error); console.error('Error creating comment:', error);
+16 -2
View File
@@ -2,6 +2,9 @@ import { db } from '@/lib/db';
import { auth, checkProjectAccess } from '@/lib/auth'; import { auth, checkProjectAccess } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { rateLimit } from '@/lib/rate-limit'; 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'; import { DownloadEgressSource } from '@prisma/client';
type RouteParams = { params: Promise<{ versionId: string }> }; type RouteParams = { params: Promise<{ versionId: string }> };
@@ -319,7 +322,7 @@ function parseEstimatedBytes(contentLengthHeader: string | null): bigint {
} }
// GET /api/versions/[versionId]/download // GET /api/versions/[versionId]/download
export async function GET(request: Request, { params }: RouteParams) { export async function GET(request: NextRequest, { params }: RouteParams) {
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const isPrepareOnly = searchParams.get('prepare') === '1'; 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); 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'); return apiErrors.forbidden('Access denied');
} }
+61 -1
View File
@@ -5,6 +5,7 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
import { validateShareLinkAccess } from '@/lib/share-links'; import { validateShareLinkAccess } from '@/lib/share-links';
import { getShareSessionFromRequest } from '@/lib/share-session'; import { getShareSessionFromRequest } from '@/lib/share-session';
import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
type RouteParams = { params: Promise<{ videoId: string }> }; type RouteParams = { params: Promise<{ videoId: string }> };
@@ -48,6 +49,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
annotationData: true, annotationData: true,
parentId: true, parentId: true,
authorId: true, authorId: true,
guestIdentityId: true,
tagId: true, tagId: true,
versionId: true, versionId: true,
guestName: true, guestName: true,
@@ -70,6 +72,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
annotationData: true, annotationData: true,
parentId: true, parentId: true,
authorId: true, authorId: true,
guestIdentityId: true,
tagId: true, tagId: true,
versionId: true, versionId: true,
guestName: true, guestName: true,
@@ -109,7 +112,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
requiredPermission: 'VIEW', requiredPermission: 'VIEW',
passwordVerified: shareSession.passwordVerified, 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) { if (!access.hasAccess && !shareAccess.hasAccess) {
return apiErrors.forbidden('Access denied'); 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 // Include auth context so the client knows if the viewer is a guest
const { project, ...videoData } = video; 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 canCommentWithMembership = access.hasAccess;
const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests); const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
const canDownloadWithMembership = access.hasAccess;
const canDownloadWithShareLink = shareAccess.hasAccess && shareAccess.canDownload;
const response = successResponse({ const response = successResponse({
...videoData, ...videoData,
versions,
projectId: video.projectId, projectId: video.projectId,
project: { project: {
name: project.name, name: project.name,
@@ -131,6 +189,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
currentUserId: session?.user?.id || null, currentUserId: session?.user?.id || null,
currentUserName: session?.user?.name || null, currentUserName: session?.user?.name || null,
canComment: canCommentWithMembership || canCommentWithShareLink, canComment: canCommentWithMembership || canCommentWithShareLink,
canDownload: canDownloadWithMembership || canDownloadWithShareLink,
canManageTags: access.canEdit,
}); });
return withCacheControl(response, 'private, no-cache'); return withCacheControl(response, 'private, no-cache');
@@ -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');
}
}
+86 -19
View File
@@ -130,6 +130,8 @@ interface Comment {
createdAt: string; createdAt: string;
author: { id: string; name: string | null; image: string | null } | null; author: { id: string; name: string | null; image: string | null } | null;
guestName: string | null; guestName: string | null;
canEdit?: boolean;
canDelete?: boolean;
tag: CommentTag | null; tag: CommentTag | null;
replies: { replies: {
id: string; id: string;
@@ -141,6 +143,8 @@ interface Comment {
createdAt: string; createdAt: string;
author: { id: string; name: string | null; image: string | null } | null; author: { id: string; name: string | null; image: string | null } | null;
guestName: string | null; guestName: string | null;
canEdit?: boolean;
canDelete?: boolean;
tag: CommentTag | null; tag: CommentTag | null;
}[]; }[];
} }
@@ -161,6 +165,8 @@ interface VideoData {
currentUserId: string | null; currentUserId: string | null;
currentUserName: string | null; currentUserName: string | null;
canComment?: boolean; canComment?: boolean;
canDownload?: boolean;
canManageTags?: boolean;
} }
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
@@ -388,6 +394,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const isGuest = video ? !video.isAuthenticated : false; const isGuest = video ? !video.isAuthenticated : false;
const canInitializePlayer = mode !== 'watch' || !isGuest || guestNameConfirmed; const canInitializePlayer = mode !== 'watch' || !isGuest || guestNameConfirmed;
const normalizedGuestName = guestName.trim();
const [showVersionDialog, setShowVersionDialog] = useState(false); const [showVersionDialog, setShowVersionDialog] = useState(false);
const [newVersionUrl, setNewVersionUrl] = useState(''); const [newVersionUrl, setNewVersionUrl] = useState('');
@@ -549,14 +556,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const isDownloadingVideo = activeDownloadTarget !== null; const isDownloadingVideo = activeDownloadTarget !== null;
const isVideoDownloadAvailable = useMemo(() => { const isVideoDownloadAvailable = useMemo(() => {
if (!activeVersion) return false; if (!activeVersion || !video?.canDownload) return false;
if (activeVersion.providerId === 'bunny') return true; if (activeVersion.providerId === 'bunny') return true;
if (activeVersion.providerId !== 'direct') return false; if (activeVersion.providerId !== 'direct') return false;
return !!getSafeDirectDownloadUrl(activeVersion.originalUrl); return !!getSafeDirectDownloadUrl(activeVersion.originalUrl);
}, [activeVersion]); }, [activeVersion, video?.canDownload]);
const handleDownloadVideo = useCallback(async (preference: BunnyDownloadPreference = 'compressed') => { const handleDownloadVideo = useCallback(async (preference: BunnyDownloadPreference = 'compressed') => {
if (!activeVersion || !video || isDownloadingVideo) return; 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') { if (activeVersion.providerId !== 'bunny' && activeVersion.providerId !== 'direct') {
toast.error('This video source does not support direct download'); toast.error('This video source does not support direct download');
return; return;
@@ -619,6 +630,24 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
} }
}, [activeVersion, isDownloadingVideo, video]); }, [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 // Memoize comments array
const comments = useMemo(() => { const comments = useMemo(() => {
return activeVersion?.comments || []; return activeVersion?.comments || [];
@@ -671,7 +700,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
if (!projectId) return; if (!projectId) return;
async function fetchTags() { async function fetchTags() {
try { 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) { if (res.ok) {
const data = await res.json(); const data = await res.json();
const tags = data.data || []; const tags = data.data || [];
@@ -684,7 +714,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
} }
} }
fetchTags(); fetchTags();
}, [projectId, selectedTagId]); }, [projectId, selectedTagId, videoId]);
// Load YouTube API immediately on component mount (async, non-blocking) // Load YouTube API immediately on component mount (async, non-blocking)
useEffect(() => { useEffect(() => {
@@ -1542,7 +1572,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
isResolved: false, isResolved: false,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null }, author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
guestName: isGuest ? guestName : null, guestName: isGuest ? normalizedGuestName : null,
canEdit: true,
canDelete: true,
tag: availableTags.find(t => t.id === selectedTagId) || null, tag: availableTags.find(t => t.id === selectedTagId) || null,
replies: [], replies: [],
}; };
@@ -1578,6 +1610,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setIsUploadingImage(true); setIsUploadingImage(true);
const imageFormData = new FormData(); const imageFormData = new FormData();
imageFormData.append('image', imageBlob); 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', { const imageRes = await fetch('/api/upload/image', {
method: 'POST', method: 'POST',
@@ -1597,7 +1632,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
timestamp: selectedTimestamp ?? currentTime, timestamp: selectedTimestamp ?? currentTime,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
...(imageData && { imageUrl: imageData.url }), ...(imageData && { imageUrl: imageData.url }),
...(isGuest && guestName && { guestName }), ...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }),
...(selectedTagId && { tagId: selectedTagId }), ...(selectedTagId && { tagId: selectedTagId }),
...(serializedAnnotation && { annotationData: serializedAnnotation }), ...(serializedAnnotation && { annotationData: serializedAnnotation }),
}), }),
@@ -1649,7 +1684,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setIsUploadingImage(false); setIsUploadingImage(false);
isMutatingRef.current = 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<HTMLInputElement>, isReply: boolean = false) => { const handleImageSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>, isReply: boolean = false) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -1758,6 +1793,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm'); 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', { const uploadRes = await fetch('/api/upload/audio', {
method: 'POST', method: 'POST',
@@ -1779,7 +1817,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
} finally { } finally {
setIsUploadingAudio(false); setIsUploadingAudio(false);
} }
}, [audioBlob, activeVersion, recordingTime, handleAddComment]); }, [audioBlob, activeVersion, recordingTime, handleAddComment, videoId, getGuestUploadToken]);
const stopVoiceTracking = useCallback(() => { const stopVoiceTracking = useCallback(() => {
if (voiceRafRef.current) { if (voiceRafRef.current) {
@@ -1895,6 +1933,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
if (audioBlob) { if (audioBlob) {
const formData = new FormData(); const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm'); 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 }); const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
if (!uploadRes.ok) throw new Error('Failed to upload audio'); if (!uploadRes.ok) throw new Error('Failed to upload audio');
const uploadData = await uploadRes.json(); const uploadData = await uploadRes.json();
@@ -1914,7 +1955,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setIsUploadingAudio(false); setIsUploadingAudio(false);
setIsUploadingImage(false); setIsUploadingImage(false);
} }
}, [audioBlob, imageBlob, activeVersion, recordingTime, commentText, submitVoiceComment, handleAddComment]); }, [audioBlob, imageBlob, activeVersion, recordingTime, commentText, submitVoiceComment, handleAddComment, videoId, getGuestUploadToken]);
const handleResolveComment = useCallback( const handleResolveComment = useCallback(
async (commentId: string, currentlyResolved: boolean) => { async (commentId: string, currentlyResolved: boolean) => {
@@ -2002,7 +2043,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
annotationData: null, annotationData: null,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null }, author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
guestName: isGuest ? guestName : null, guestName: isGuest ? normalizedGuestName : null,
canEdit: true,
canDelete: true,
tag: null, tag: null,
}; };
@@ -2041,6 +2084,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setIsUploadingReplyImage(true); setIsUploadingReplyImage(true);
const imageFormData = new FormData(); const imageFormData = new FormData();
imageFormData.append('image', replyImageBlob); 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', { const imageRes = await fetch('/api/upload/image', {
method: 'POST', method: 'POST',
@@ -2061,7 +2107,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
parentId, parentId,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
...(submittedImageData && { imageUrl: submittedImageData.url }), ...(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); setIsUploadingReplyImage(false);
isMutatingRef.current = 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 () => { const startReplyRecording = useCallback(async () => {
try { try {
@@ -2189,6 +2235,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('audio', replyAudioBlob, 'recording.webm'); 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 }); const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
if (!uploadRes.ok) throw new Error('Failed to upload audio'); if (!uploadRes.ok) throw new Error('Failed to upload audio');
const uploadData = await uploadRes.json(); const uploadData = await uploadRes.json();
@@ -2199,7 +2248,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
} finally { } finally {
setIsUploadingReplyAudio(false); setIsUploadingReplyAudio(false);
} }
}, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment]); }, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment, videoId, getGuestUploadToken]);
const submitReplyWithMedia = useCallback(async (parentId: string) => { const submitReplyWithMedia = useCallback(async (parentId: string) => {
if (!activeVersion) return; if (!activeVersion) return;
@@ -2218,6 +2267,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
if (replyAudioBlob) { if (replyAudioBlob) {
const formData = new FormData(); const formData = new FormData();
formData.append('audio', replyAudioBlob, 'recording.webm'); 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 }); const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
if (!uploadRes.ok) throw new Error('Failed to upload audio reply'); if (!uploadRes.ok) throw new Error('Failed to upload audio reply');
const uploadData = await uploadRes.json(); const uploadData = await uploadRes.json();
@@ -2237,7 +2289,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setIsUploadingReplyAudio(false); setIsUploadingReplyAudio(false);
setIsUploadingReplyImage(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) => { const handleEditComment = useCallback(async (commentId: string) => {
if (!editText.trim() && !editAnnotationData) return; if (!editText.trim() && !editAnnotationData) return;
@@ -2257,6 +2309,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const body: Record<string, unknown> = { content: editText }; const body: Record<string, unknown> = { content: editText };
if (editTagId !== undefined) body.tagId = editTagId; if (editTagId !== undefined) body.tagId = editTagId;
if (finalAnnotationData !== undefined) body.annotationData = finalAnnotationData; if (finalAnnotationData !== undefined) body.annotationData = finalAnnotationData;
if (isGuest && normalizedGuestName) body.guestName = normalizedGuestName;
const res = await fetch(`/api/comments/${commentId}`, { const res = await fetch(`/api/comments/${commentId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -2306,7 +2359,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setIsSubmittingEdit(false); setIsSubmittingEdit(false);
isMutatingRef.current = false; isMutatingRef.current = false;
} }
}, [editText, editTagId, editAnnotationData, isEditingAnnotation, activeVersionId, availableTags]); }, [editText, editTagId, editAnnotationData, isEditingAnnotation, activeVersionId, availableTags, isGuest, normalizedGuestName]);
const handleDeleteComment = useCallback(async (commentId: string) => { const handleDeleteComment = useCallback(async (commentId: string) => {
setDeletingCommentId(commentId); setDeletingCommentId(commentId);
@@ -3520,6 +3573,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
comment.author?.name || comment.guestName || 'Anonymous'; comment.author?.name || comment.guestName || 'Anonymous';
const isEditing = editingCommentId === comment.id; const isEditing = editingCommentId === comment.id;
const isReplying = replyingTo === 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 ( return (
<div <div
key={comment.id} key={comment.id}
@@ -3563,7 +3619,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
<Circle className="h-4 w-4" /> <Circle className="h-4 w-4" />
)} )}
</Button> </Button>
{(comment.author?.id === currentUserId || video.project.ownerId === currentUserId) && ( {canManageComment && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -3582,7 +3638,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
<Reply className="h-4 w-4 mr-2" /> <Reply className="h-4 w-4 mr-2" />
Reply Reply
</DropdownMenuItem> </DropdownMenuItem>
{comment.author?.id === currentUserId && ( {canEditComment && (
<DropdownMenuItem onClick={() => { <DropdownMenuItem onClick={() => {
setEditingCommentId(comment.id); setEditingCommentId(comment.id);
setEditText(comment.content || ''); setEditText(comment.content || '');
@@ -3592,6 +3648,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{canDeleteComment && (
<DropdownMenuItem <DropdownMenuItem
className="text-destructive" className="text-destructive"
onClick={() => handleDeleteComment(comment.id)} onClick={() => handleDeleteComment(comment.id)}
@@ -3599,6 +3656,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
@@ -3774,6 +3832,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const replyAuthor = const replyAuthor =
reply.author?.name || reply.guestName || 'Anonymous'; reply.author?.name || reply.guestName || 'Anonymous';
const isEditingReply = editingCommentId === reply.id; const isEditingReply = editingCommentId === reply.id;
const canEditReply = reply.canEdit ?? (reply.author?.id === currentUserId);
const canDeleteReply = reply.canDelete ?? (reply.author?.id === currentUserId || video.project.ownerId === currentUserId);
const canManageReply = canEditReply || canDeleteReply;
return ( return (
<div key={reply.id} className="group/reply text-sm"> <div key={reply.id} className="group/reply text-sm">
<div className="flex items-center justify-between gap-2 mb-1"> <div className="flex items-center justify-between gap-2 mb-1">
@@ -3788,7 +3849,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
{new Date(reply.createdAt).toLocaleDateString()} {new Date(reply.createdAt).toLocaleDateString()}
</span> </span>
</div> </div>
{(reply.author?.id === currentUserId || video.project.ownerId === currentUserId) && ( {canManageReply && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -3800,7 +3861,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{reply.author?.id === currentUserId && ( {canEditReply && (
<DropdownMenuItem onClick={() => { <DropdownMenuItem onClick={() => {
setEditingCommentId(reply.id); setEditingCommentId(reply.id);
setEditText(reply.content || ''); setEditText(reply.content || '');
@@ -3809,6 +3870,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{canDeleteReply && (
<DropdownMenuItem <DropdownMenuItem
className="text-destructive" className="text-destructive"
onClick={() => handleDeleteComment(reply.id)} onClick={() => handleDeleteComment(reply.id)}
@@ -3816,6 +3878,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
@@ -4345,6 +4408,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
{selectedTagId === tag.id && <span className="ml-auto"></span>} {selectedTagId === tag.id && <span className="ml-auto"></span>}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
{video.canManageTags && (
<>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/projects/${projectId}/settings#comment-tags`} className="gap-2 text-muted-foreground"> <Link href={`/projects/${projectId}/settings#comment-tags`} className="gap-2 text-muted-foreground">
@@ -4352,6 +4417,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
Manage Tags Manage Tags
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
+37 -9
View File
@@ -202,35 +202,63 @@ export const getCachedUserMediaStorage = unstable_cache(
const userStorage: Record<string, { total: number, voice: number, image: number }> = {}; const userStorage: Record<string, { total: number, voice: number, image: number }> = {};
try { try {
const fileSizes = await listAllR2FileSizes(); const fileSizes = await listAllR2FileSizes();
const seenKeys = new Set<string>();
const mediaComments = await db.comment.findMany({ const mediaComments = await db.comment.findMany({
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }], authorId: { not: null } }, where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] },
select: { authorId: true, voiceUrl: true, imageUrl: true } select: {
voiceUrl: true,
imageUrl: true,
version: {
select: {
video: {
select: {
project: {
select: {
workspace: {
select: { ownerId: true },
},
},
},
},
},
},
},
},
}); });
for (const comment of mediaComments) { for (const comment of mediaComments) {
if (!comment.authorId) continue; const billedUserId = comment.version.video.project.workspace.ownerId;
if (!billedUserId) continue;
if (!userStorage[comment.authorId]) { if (!userStorage[billedUserId]) {
userStorage[comment.authorId] = { total: 0, voice: 0, image: 0 }; userStorage[billedUserId] = { total: 0, voice: 0, image: 0 };
} }
if (comment.voiceUrl) { if (comment.voiceUrl) {
const keyParts = comment.voiceUrl.split('/'); const keyParts = comment.voiceUrl.split('/');
const filename = keyParts[keyParts.length - 1]; const filename = keyParts[keyParts.length - 1];
const r2Key = `voice/${filename}`; const r2Key = `voice/${filename}`;
const dedupeKey = `${billedUserId}:${r2Key}`;
if (!seenKeys.has(dedupeKey)) {
seenKeys.add(dedupeKey);
const size = fileSizes.get(r2Key) || 0; const size = fileSizes.get(r2Key) || 0;
userStorage[comment.authorId].voice += size; userStorage[billedUserId].voice += size;
userStorage[comment.authorId].total += size; userStorage[billedUserId].total += size;
}
} }
if (comment.imageUrl) { if (comment.imageUrl) {
const keyParts = comment.imageUrl.split('/'); const keyParts = comment.imageUrl.split('/');
const filename = keyParts[keyParts.length - 1]; const filename = keyParts[keyParts.length - 1];
const r2Key = `images/${filename}`; const r2Key = `images/${filename}`;
const dedupeKey = `${billedUserId}:${r2Key}`;
if (!seenKeys.has(dedupeKey)) {
seenKeys.add(dedupeKey);
const size = fileSizes.get(r2Key) || 0; const size = fileSizes.get(r2Key) || 0;
userStorage[comment.authorId].image += size; userStorage[billedUserId].image += size;
userStorage[comment.authorId].total += size; userStorage[billedUserId].total += size;
}
} }
} }
} catch (err) { } catch (err) {
+13
View File
@@ -0,0 +1,13 @@
export interface DefaultCommentTag {
name: string;
color: string;
position: number;
}
export const DEFAULT_COMMENT_TAGS: DefaultCommentTag[] = [
{ 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 },
];
+91
View File
@@ -0,0 +1,91 @@
import { createHmac, randomUUID, timingSafeEqual } from 'crypto';
import type { NextRequest, NextResponse } from 'next/server';
const GUEST_IDENTITY_COOKIE_NAME = 'openframe_guest_identity';
const GUEST_IDENTITY_TTL_SECONDS = 60 * 60 * 24 * 180; // 180 days
interface GuestIdentityPayload {
gid: string;
exp: number;
}
function getGuestIdentitySecret(): string {
const secret = process.env.GUEST_IDENTITY_SECRET ?? process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET;
if (!secret) {
throw new Error('Missing GUEST_IDENTITY_SECRET, AUTH_SECRET, or NEXTAUTH_SECRET.');
}
return secret;
}
function sign(value: string): string {
return createHmac('sha256', getGuestIdentitySecret()).update(value).digest('base64url');
}
function createSignedValue(payload: GuestIdentityPayload): string {
const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
const signature = sign(encodedPayload);
return `${encodedPayload}.${signature}`;
}
function parseSignedValue(value: string): GuestIdentityPayload | null {
const [encodedPayload, signature] = value.split('.');
if (!encodedPayload || !signature) return null;
const expectedSignature = sign(encodedPayload);
const actualBytes = Buffer.from(signature, 'utf8');
const expectedBytes = Buffer.from(expectedSignature, 'utf8');
if (actualBytes.length !== expectedBytes.length) return null;
if (!timingSafeEqual(actualBytes, expectedBytes)) return null;
try {
const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')) as Partial<GuestIdentityPayload>;
if (!payload.gid || typeof payload.gid !== 'string') return null;
if (!payload.exp || typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)) return null;
if (payload.exp <= Math.floor(Date.now() / 1000)) return null;
return { gid: payload.gid, exp: payload.exp };
} catch {
return null;
}
}
function createGuestIdentityValue(identityId: string): string {
return createSignedValue({
gid: identityId,
exp: Math.floor(Date.now() / 1000) + GUEST_IDENTITY_TTL_SECONDS,
});
}
function cookieOptions(maxAge: number) {
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge,
};
}
export function getGuestIdentityFromRequest(request: NextRequest): string | null {
const raw = request.cookies.get(GUEST_IDENTITY_COOKIE_NAME)?.value;
if (!raw) return null;
const payload = parseSignedValue(raw);
return payload?.gid ?? null;
}
export function ensureGuestIdentityFromRequest(request: NextRequest): { identityId: string; shouldSetCookie: boolean } {
const existingIdentity = getGuestIdentityFromRequest(request);
if (existingIdentity) {
return { identityId: existingIdentity, shouldSetCookie: false };
}
return { identityId: randomUUID(), shouldSetCookie: true };
}
export function setGuestIdentityCookie(response: NextResponse, identityId: string): void {
response.cookies.set(
GUEST_IDENTITY_COOKIE_NAME,
createGuestIdentityValue(identityId),
cookieOptions(GUEST_IDENTITY_TTL_SECONDS)
);
}
+189
View File
@@ -0,0 +1,189 @@
import { createHash, createHmac, timingSafeEqual } from 'crypto';
import { NextResponse } from 'next/server';
import { checkRateLimit, getClientIp, rateLimitHeaders } from '@/lib/rate-limit';
const GUEST_UPLOAD_TOKEN_TYPE = 'guest-upload';
const DEFAULT_GUEST_UPLOAD_TOKEN_TTL_SECONDS = 60 * 3;
const GUEST_UPLOAD_VIDEO_WINDOW_MS = 15 * 60 * 1000;
const GUEST_UPLOAD_VIDEO_MAX_REQUESTS = 12;
const GUEST_UPLOAD_SESSION_WINDOW_MS = 15 * 60 * 1000;
const GUEST_UPLOAD_SESSION_MAX_REQUESTS = 8;
export type GuestUploadIntent = 'audio' | 'image';
interface GuestUploadTokenPayload {
typ: typeof GUEST_UPLOAD_TOKEN_TYPE;
pid: string;
vid: string;
iat: number;
exp: number;
intent: GuestUploadIntent;
ctx: string;
}
interface GuestUploadTokenSubject {
projectId: string;
videoId: string;
intent: GuestUploadIntent;
context: string;
}
const TRUSTED_IP_PATTERN = /^[\da-fA-F.:]+$/;
function getGuestUploadTokenSecret(): string {
const secret = process.env.GUEST_UPLOAD_TOKEN_SECRET ?? process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET;
if (!secret) {
throw new Error('Missing GUEST_UPLOAD_TOKEN_SECRET, AUTH_SECRET, or NEXTAUTH_SECRET.');
}
return secret;
}
function signPayload(encodedPayload: string): string {
return createHmac('sha256', getGuestUploadTokenSecret()).update(encodedPayload).digest('base64url');
}
function getCloudflareClientIp(request: Request): string | null {
const cfIp = request.headers.get('cf-connecting-ip')?.trim();
if (!cfIp) return null;
if (cfIp.length > 45 || !TRUSTED_IP_PATTERN.test(cfIp)) return null;
return cfIp;
}
function resolveTrustedClientIp(request: Request): string | null {
const cfIp = getCloudflareClientIp(request);
if (cfIp) return cfIp;
// In production, require Cloudflare-provided client IP to avoid spoofable header fallbacks.
if (process.env.NODE_ENV === 'production') {
return null;
}
return getClientIp(request);
}
function isValidPayload(value: unknown): value is GuestUploadTokenPayload {
if (!value || typeof value !== 'object') return false;
const payload = value as Partial<GuestUploadTokenPayload>;
return payload.typ === GUEST_UPLOAD_TOKEN_TYPE
&& typeof payload.pid === 'string'
&& typeof payload.vid === 'string'
&& typeof payload.iat === 'number'
&& Number.isFinite(payload.iat)
&& typeof payload.exp === 'number'
&& Number.isFinite(payload.exp)
&& (payload.intent === 'audio' || payload.intent === 'image')
&& typeof payload.ctx === 'string';
}
export function deriveGuestUploadContext(request: Request, shareToken: string | null): string | null {
const ip = resolveTrustedClientIp(request);
if (!ip) return null;
const shareFingerprint = shareToken
? createHash('sha256').update(shareToken).digest('hex').slice(0, 24)
: 'public';
return `${ip}:${shareFingerprint}`;
}
export function createGuestUploadToken(
subject: GuestUploadTokenSubject,
ttlSeconds = DEFAULT_GUEST_UPLOAD_TOKEN_TTL_SECONDS
): string {
const now = Math.floor(Date.now() / 1000);
const payload: GuestUploadTokenPayload = {
typ: GUEST_UPLOAD_TOKEN_TYPE,
pid: subject.projectId,
vid: subject.videoId,
iat: now,
exp: now + ttlSeconds,
intent: subject.intent,
ctx: subject.context,
};
const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
const signature = signPayload(encodedPayload);
return `${encodedPayload}.${signature}`;
}
export function verifyGuestUploadToken(token: string, subject: GuestUploadTokenSubject): boolean {
try {
const parts = token.split('.');
if (parts.length !== 2) return false;
const [encodedPayload, providedSignature] = parts;
if (!encodedPayload || !providedSignature) return false;
const expectedSignature = signPayload(encodedPayload);
const providedBuffer = Buffer.from(providedSignature, 'utf8');
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
if (providedBuffer.length !== expectedBuffer.length) return false;
if (!timingSafeEqual(providedBuffer, expectedBuffer)) return false;
const payloadRaw = Buffer.from(encodedPayload, 'base64url').toString('utf8');
const payloadUnknown: unknown = JSON.parse(payloadRaw);
if (!isValidPayload(payloadUnknown)) return false;
const payload = payloadUnknown;
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return false;
return payload.pid === subject.projectId
&& payload.vid === subject.videoId
&& payload.intent === subject.intent
&& payload.ctx === subject.context;
} catch {
return false;
}
}
export async function enforceGuestUploadQuota(
request: Request,
videoId: string,
intent: GuestUploadIntent,
shareToken: string | null
): Promise<NextResponse | null> {
const ip = resolveTrustedClientIp(request);
if (!ip) {
return NextResponse.json(
{ error: 'Missing trusted client IP header' },
{ status: 403 }
);
}
const videoScoped = await checkRateLimit(
`${ip}:guest-upload:${intent}:video:${videoId}`,
`guest-upload-${intent}-video`,
{ windowMs: GUEST_UPLOAD_VIDEO_WINDOW_MS, maxRequests: GUEST_UPLOAD_VIDEO_MAX_REQUESTS }
);
if (!videoScoped.allowed) {
return NextResponse.json(
{ error: 'Too many uploads for this video. Please wait before uploading again.' },
{
status: 429,
headers: rateLimitHeaders(videoScoped, GUEST_UPLOAD_VIDEO_MAX_REQUESTS),
}
);
}
if (!shareToken) return null;
const shareFingerprint = createHash('sha256').update(shareToken).digest('hex').slice(0, 24);
const sessionScoped = await checkRateLimit(
`${shareFingerprint}:guest-upload:${intent}`,
`guest-upload-${intent}-session`,
{ windowMs: GUEST_UPLOAD_SESSION_WINDOW_MS, maxRequests: GUEST_UPLOAD_SESSION_MAX_REQUESTS }
);
if (!sessionScoped.allowed) {
return NextResponse.json(
{ error: 'Too many uploads for this share session. Please wait before uploading again.' },
{
status: 429,
headers: rateLimitHeaders(sessionScoped, GUEST_UPLOAD_SESSION_MAX_REQUESTS),
}
);
}
return null;
}
export const guestUploadTokenTtlSeconds = DEFAULT_GUEST_UPLOAD_TOKEN_TTL_SECONDS;
+7 -5
View File
@@ -16,6 +16,7 @@ interface ValidateShareLinkParams {
export interface ShareLinkAccessResult { export interface ShareLinkAccessResult {
hasAccess: boolean; hasAccess: boolean;
canComment: boolean; canComment: boolean;
canDownload: boolean;
allowGuests: boolean; allowGuests: boolean;
requiresPassword: boolean; requiresPassword: boolean;
link: ShareLink | null; link: ShareLink | null;
@@ -47,7 +48,7 @@ export async function validateShareLinkAccess({
}); });
if (!link) { if (!link) {
return { hasAccess: false, canComment: false, allowGuests: false, requiresPassword: false, link: null }; return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false, link: null };
} }
const projectMatches = link.projectId === projectId; const projectMatches = link.projectId === projectId;
@@ -56,27 +57,28 @@ export async function validateShareLinkAccess({
const permissionMatches = hasRequiredPermission(link.permission, requiredPermission); const permissionMatches = hasRequiredPermission(link.permission, requiredPermission);
if (!projectMatches || !videoMatches || !permissionMatches || isLinkExpired(link)) { if (!projectMatches || !videoMatches || !permissionMatches || isLinkExpired(link)) {
return { hasAccess: false, canComment: false, allowGuests: false, requiresPassword: false, link }; return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false, link };
} }
if (link.passwordHash && !passwordVerified) { if (link.passwordHash && !passwordVerified) {
if (!presentedPassword) { if (!presentedPassword) {
return { hasAccess: false, canComment: false, allowGuests: false, requiresPassword: true, link }; return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: true, link };
} }
if (presentedPassword.length > MAX_SHARE_PASSWORD_LENGTH) { if (presentedPassword.length > MAX_SHARE_PASSWORD_LENGTH) {
return { hasAccess: false, canComment: false, allowGuests: false, requiresPassword: true, link }; return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: true, link };
} }
const isPasswordValid = await bcrypt.compare(presentedPassword, link.passwordHash); const isPasswordValid = await bcrypt.compare(presentedPassword, link.passwordHash);
if (!isPasswordValid) { if (!isPasswordValid) {
return { hasAccess: false, canComment: false, allowGuests: false, requiresPassword: true, link }; return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: true, link };
} }
} }
return { return {
hasAccess: true, hasAccess: true,
canComment: link.permission === 'COMMENT', canComment: link.permission === 'COMMENT',
canDownload: link.allowDownloads,
allowGuests: link.allowGuests, allowGuests: link.allowGuests,
requiresPassword: false, requiresPassword: false,
link, link,
+3
View File
@@ -310,6 +310,7 @@ model Comment {
// Guest author info (when authorId is null) // Guest author info (when authorId is null)
guestName String? guestName String?
guestEmail String? guestEmail String?
guestIdentityId String?
// Video version relation // Video version relation
versionId String versionId String
@@ -326,6 +327,7 @@ model Comment {
@@index([versionId]) @@index([versionId])
@@index([parentId]) @@index([parentId])
@@index([authorId]) @@index([authorId])
@@index([guestIdentityId])
@@index([timestamp]) @@index([timestamp])
@@index([tagId]) @@index([tagId])
@@index([versionId, isResolved, timestamp]) @@index([versionId, isResolved, timestamp])
@@ -376,6 +378,7 @@ model ShareLink {
// Settings // Settings
allowGuests Boolean @default(true) // Allow comments without account allowGuests Boolean @default(true) // Allow comments without account
allowDownloads Boolean @default(false) // Allow downloading video via share link
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())