mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Add guest upload tokens and share-session aware permissions
This commit is contained in:
@@ -17,6 +17,7 @@ interface ShareLinkData {
|
||||
id: string;
|
||||
token: string;
|
||||
allowGuests: boolean;
|
||||
allowDownloads: boolean;
|
||||
hasPassword: boolean;
|
||||
}
|
||||
|
||||
@@ -38,6 +39,7 @@ export default function VideoSharePage({ params }: VideoSharePageProps) {
|
||||
const [shareUrl, setShareUrl] = useState<string | null>(null);
|
||||
const [hasPassword, setHasPassword] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [allowDownloads, setAllowDownloads] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
params.then(({ projectId: nextProjectId, videoId: nextVideoId }) => {
|
||||
@@ -65,10 +67,12 @@ export default function VideoSharePage({ params }: VideoSharePageProps) {
|
||||
|
||||
setShareUrl(payload.data.shareUrl);
|
||||
setHasPassword(!!payload.data.link?.hasPassword);
|
||||
setAllowDownloads(!!payload.data.link?.allowDownloads);
|
||||
} catch {
|
||||
setError('Failed to load share link');
|
||||
setShareUrl(null);
|
||||
setHasPassword(false);
|
||||
setAllowDownloads(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -94,7 +98,7 @@ export default function VideoSharePage({ params }: VideoSharePageProps) {
|
||||
const response = await fetch(`/api/projects/${projectId}/videos/${videoId}/share`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ allowGuests: true }),
|
||||
body: JSON.stringify({ allowGuests: true, allowDownloads }),
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as ShareResponse;
|
||||
@@ -105,6 +109,7 @@ export default function VideoSharePage({ params }: VideoSharePageProps) {
|
||||
|
||||
setShareUrl(payload.data.shareUrl);
|
||||
setHasPassword(!!payload.data.link?.hasPassword);
|
||||
setAllowDownloads(!!payload.data.link?.allowDownloads);
|
||||
setPassword('');
|
||||
} catch {
|
||||
setError('Failed to create share link');
|
||||
@@ -132,6 +137,7 @@ export default function VideoSharePage({ params }: VideoSharePageProps) {
|
||||
|
||||
setShareUrl(null);
|
||||
setHasPassword(false);
|
||||
setAllowDownloads(false);
|
||||
setPassword('');
|
||||
} catch {
|
||||
setError('Failed to revoke share link');
|
||||
@@ -165,6 +171,7 @@ export default function VideoSharePage({ params }: VideoSharePageProps) {
|
||||
const data = (payload as ShareResponse).data;
|
||||
setShareUrl(data.shareUrl);
|
||||
setHasPassword(!!data.link?.hasPassword);
|
||||
setAllowDownloads(!!data.link?.allowDownloads);
|
||||
setPassword('');
|
||||
} catch {
|
||||
setError('Failed to update link security');
|
||||
@@ -173,6 +180,34 @@ export default function VideoSharePage({ params }: VideoSharePageProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const updateDownloadSetting = async (nextAllowDownloads: boolean) => {
|
||||
if (!projectId || !videoId || !shareUrl) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/videos/${videoId}/share`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ allowDownloads: nextAllowDownloads }),
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as ShareResponse | { error?: string } | null;
|
||||
if (!response.ok || ('error' in (payload || {}) && payload?.error)) {
|
||||
setError((payload as { error?: string } | null)?.error || 'Failed to update download setting');
|
||||
return;
|
||||
}
|
||||
const data = (payload as ShareResponse).data;
|
||||
setShareUrl(data.shareUrl);
|
||||
setAllowDownloads(!!data.link?.allowDownloads);
|
||||
setHasPassword(!!data.link?.hasPassword);
|
||||
} catch {
|
||||
setError('Failed to update download setting');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -220,6 +255,29 @@ export default function VideoSharePage({ params }: VideoSharePageProps) {
|
||||
Revoke Link
|
||||
</Button>
|
||||
</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="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" />}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { auth } from '@/lib/auth';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
|
||||
|
||||
type RouteParams = { params: Promise<{ commentId: string }> };
|
||||
|
||||
@@ -125,10 +128,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const session = await auth();
|
||||
const { commentId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
const body = await request.json();
|
||||
const { content, isResolved, tagId, annotationData } = body;
|
||||
|
||||
const comment = await db.comment.findUnique({
|
||||
where: { id: commentId },
|
||||
@@ -138,9 +139,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
video: {
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
members: { where: { userId: session.user.id } },
|
||||
},
|
||||
include: { members: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -154,18 +153,25 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
const project = comment.version.video.project;
|
||||
const isOwner = project.ownerId === session.user.id;
|
||||
const isAuthor = comment.authorId === session.user.id;
|
||||
const isMember = project.members.length > 0;
|
||||
const userId = session?.user?.id ?? null;
|
||||
const isOwner = userId === project.ownerId;
|
||||
const isAuthor = !!userId && comment.authorId === userId;
|
||||
const isMember = !!userId && project.members.some((member) => member.userId === userId);
|
||||
const guestIdentityId = !userId ? getGuestIdentityFromRequest(request) : null;
|
||||
const isGuestAuthor = !userId
|
||||
&& !comment.authorId
|
||||
&& !!comment.guestIdentityId
|
||||
&& guestIdentityId === comment.guestIdentityId;
|
||||
const canEditOwnContent = isAuthor || isGuestAuthor;
|
||||
|
||||
// Check workspace membership for resolve permissions
|
||||
let isWorkspaceMember = false;
|
||||
if (!isOwner && !isMember && session.user.id) {
|
||||
if (!isOwner && !isMember && userId) {
|
||||
const wsMember = await db.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: project.workspaceId,
|
||||
userId: session.user.id,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -173,19 +179,33 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
where: { id: project.workspaceId },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
isWorkspaceMember = !!wsMember || wsOwner?.ownerId === session.user.id;
|
||||
isWorkspaceMember = !!wsMember || wsOwner?.ownerId === userId;
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { content, isResolved, tagId, annotationData } = body;
|
||||
if (!userId && !isGuestAuthor) {
|
||||
const shareSession = getShareSessionFromRequest(request, comment.version.video.id);
|
||||
const shareAccess = shareSession
|
||||
? await validateShareLinkAccess({
|
||||
token: shareSession.token,
|
||||
projectId: project.id,
|
||||
videoId: comment.version.video.id,
|
||||
requiredPermission: 'COMMENT',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
||||
const hasGuestAccess = project.visibility === 'PUBLIC' || (shareAccess.canComment && shareAccess.allowGuests);
|
||||
if (!hasGuestAccess) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
}
|
||||
|
||||
// Only author can edit content or tag
|
||||
if ((content !== undefined || tagId !== undefined || annotationData !== undefined) && !isAuthor) {
|
||||
if ((content !== undefined || tagId !== undefined || annotationData !== undefined) && !canEditOwnContent) {
|
||||
return apiErrors.forbidden('Only the author can edit comment content');
|
||||
}
|
||||
|
||||
// Owner, author, members, or workspace members can resolve/unresolve
|
||||
if (isResolved !== undefined && !isOwner && !isAuthor && !isMember && !isWorkspaceMember) {
|
||||
if (isResolved !== undefined && !isOwner && !canEditOwnContent && !isMember && !isWorkspaceMember) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
@@ -213,7 +233,26 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
},
|
||||
});
|
||||
|
||||
const response = successResponse(updatedComment);
|
||||
const { guestIdentityId: _updatedGuestIdentityId, ...updatedCommentData } = updatedComment;
|
||||
const response = successResponse({
|
||||
...updatedCommentData,
|
||||
canEdit: canEditOwnContent,
|
||||
canDelete: canEditOwnContent || isOwner,
|
||||
replies: updatedComment.replies.map((reply) => {
|
||||
const canEditReply = !!userId
|
||||
? reply.authorId === userId
|
||||
: !!guestIdentityId
|
||||
&& !reply.authorId
|
||||
&& !!reply.guestIdentityId
|
||||
&& reply.guestIdentityId === guestIdentityId;
|
||||
const { guestIdentityId: _replyGuestIdentityId, ...replyData } = reply;
|
||||
return {
|
||||
...replyData,
|
||||
canEdit: canEditReply,
|
||||
canDelete: canEditReply || isOwner,
|
||||
};
|
||||
}),
|
||||
});
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
console.error('Error updating comment:', error);
|
||||
@@ -230,13 +269,18 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
const session = await auth();
|
||||
const { commentId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
const comment = await db.comment.findUnique({
|
||||
where: { id: commentId },
|
||||
include: {
|
||||
version: {
|
||||
include: {
|
||||
video: {
|
||||
include: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
replies: { select: { voiceUrl: true, imageUrl: true } },
|
||||
},
|
||||
});
|
||||
@@ -245,9 +289,37 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.notFound('Comment');
|
||||
}
|
||||
|
||||
const isAuthor = comment.authorId === session.user.id;
|
||||
const userId = session?.user?.id ?? null;
|
||||
const isAuthor = !!userId && comment.authorId === userId;
|
||||
|
||||
if (!isAuthor) {
|
||||
let canDeleteOwnComment = isAuthor;
|
||||
if (!userId) {
|
||||
const guestIdentityId = getGuestIdentityFromRequest(request);
|
||||
const isGuestAuthor = !comment.authorId
|
||||
&& !!comment.guestIdentityId
|
||||
&& guestIdentityId === comment.guestIdentityId;
|
||||
|
||||
if (isGuestAuthor) {
|
||||
const project = comment.version.video.project;
|
||||
const shareSession = getShareSessionFromRequest(request, comment.version.video.id);
|
||||
const shareAccess = shareSession
|
||||
? await validateShareLinkAccess({
|
||||
token: shareSession.token,
|
||||
projectId: project.id,
|
||||
videoId: comment.version.video.id,
|
||||
requiredPermission: 'COMMENT',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
||||
const hasGuestAccess = project.visibility === 'PUBLIC' || (shareAccess.canComment && shareAccess.allowGuests);
|
||||
if (!hasGuestAccess) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
canDeleteOwnComment = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!canDeleteOwnComment) {
|
||||
return apiErrors.forbidden('You can only delete your own comments');
|
||||
}
|
||||
|
||||
|
||||
@@ -2,19 +2,12 @@ import { NextRequest } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
|
||||
// Default tags to create for new projects
|
||||
const DEFAULT_TAGS = [
|
||||
{ name: 'Feedback', color: '#3B82F6', position: 0 },
|
||||
{ name: 'Technical', color: '#EF4444', position: 1 },
|
||||
{ name: 'Creative', color: '#8B5CF6', position: 2 },
|
||||
{ name: 'Approved', color: '#22C55E', position: 3 },
|
||||
{ name: 'Urgent', color: '#F59E0B', position: 4 },
|
||||
];
|
||||
|
||||
// Helper to check project access
|
||||
async function checkProjectAccess(projectId: string, userId: string) {
|
||||
const project = await db.project.findUnique({
|
||||
@@ -51,36 +44,56 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
const { projectId } = await params;
|
||||
const videoId = request.nextUrl.searchParams.get('videoId');
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true, visibility: true },
|
||||
});
|
||||
if (!project) return apiErrors.notFound('Project');
|
||||
|
||||
if (session?.user?.id) {
|
||||
const { project: accessibleProject } = await checkProjectAccess(projectId, session.user.id);
|
||||
if (!accessibleProject) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
} else {
|
||||
let hasGuestAccess = project.visibility === 'PUBLIC';
|
||||
if (!hasGuestAccess && videoId) {
|
||||
const video = await db.video.findFirst({
|
||||
where: { id: videoId, projectId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (video) {
|
||||
const shareSession = getShareSessionFromRequest(request, video.id);
|
||||
const shareAccess = shareSession
|
||||
? await validateShareLinkAccess({
|
||||
token: shareSession.token,
|
||||
projectId,
|
||||
videoId: video.id,
|
||||
requiredPermission: 'COMMENT',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
||||
hasGuestAccess = shareAccess.canComment && shareAccess.allowGuests;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasGuestAccess) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
}
|
||||
|
||||
const { project } = await checkProjectAccess(projectId, session.user.id);
|
||||
if (!project) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
|
||||
let tags = await db.commentTag.findMany({
|
||||
const tags = await db.commentTag.findMany({
|
||||
where: { projectId },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
|
||||
// Auto-create default tags if none exist (idempotent with skipDuplicates
|
||||
// to handle race conditions from concurrent requests)
|
||||
if (tags.length === 0) {
|
||||
await db.commentTag.createMany({
|
||||
data: DEFAULT_TAGS.map((tag) => ({ ...tag, projectId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
tags = await db.commentTag.findMany({
|
||||
where: { projectId },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
const response = successResponse(tags);
|
||||
return withCacheControl(response, 'private, max-age=120, stale-while-revalidate=300');
|
||||
const cacheControl = session?.user?.id
|
||||
? 'private, max-age=120, stale-while-revalidate=300'
|
||||
: 'private, no-cache';
|
||||
return withCacheControl(response, cacheControl);
|
||||
} catch (error) {
|
||||
console.error('Error fetching tags:', error);
|
||||
return apiErrors.internalError('Failed to fetch tags');
|
||||
|
||||
@@ -106,6 +106,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
isAuthenticated: !!session?.user?.id,
|
||||
currentUserId: session?.user?.id || null,
|
||||
currentUserName: session?.user?.name || null,
|
||||
canDownload: access.hasAccess,
|
||||
canManageTags: access.canEdit,
|
||||
});
|
||||
|
||||
return withCacheControl(response, 'private, no-cache');
|
||||
|
||||
@@ -30,8 +30,27 @@ async function requireShareManagementAccess(projectId: string, videoId: string,
|
||||
return { error: null, video };
|
||||
}
|
||||
|
||||
function resolveShareBaseUrl(request: NextRequest): string {
|
||||
const configuredBaseUrl = process.env.NEXTAUTH_URL ?? process.env.NEXT_PUBLIC_APP_URL;
|
||||
const normalizedConfiguredBaseUrl = configuredBaseUrl?.trim();
|
||||
|
||||
if (normalizedConfiguredBaseUrl) {
|
||||
const withProtocol = /^https?:\/\//i.test(normalizedConfiguredBaseUrl)
|
||||
? normalizedConfiguredBaseUrl
|
||||
: `https://${normalizedConfiguredBaseUrl}`;
|
||||
|
||||
try {
|
||||
return new URL(withProtocol).origin;
|
||||
} catch {
|
||||
// Fallback to request origin when env configuration is invalid.
|
||||
}
|
||||
}
|
||||
|
||||
return request.nextUrl.origin;
|
||||
}
|
||||
|
||||
function buildWatchUrl(request: NextRequest, videoId: string, token: string): string {
|
||||
const url = new URL(`/watch/${videoId}`, request.nextUrl.origin);
|
||||
const url = new URL(`/watch/${videoId}`, resolveShareBaseUrl(request));
|
||||
url.searchParams.set('shareToken', token);
|
||||
return url.toString();
|
||||
}
|
||||
@@ -44,6 +63,7 @@ function serializeShareLink(
|
||||
token: string;
|
||||
permission: string;
|
||||
allowGuests: boolean;
|
||||
allowDownloads: boolean;
|
||||
expiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
passwordHash: string | null;
|
||||
@@ -59,6 +79,7 @@ function serializeShareLink(
|
||||
token: link.token,
|
||||
permission: link.permission,
|
||||
allowGuests: link.allowGuests,
|
||||
allowDownloads: link.allowDownloads,
|
||||
expiresAt: link.expiresAt,
|
||||
createdAt: link.createdAt,
|
||||
hasPassword: !!link.passwordHash,
|
||||
@@ -91,6 +112,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
token: true,
|
||||
permission: true,
|
||||
allowGuests: true,
|
||||
allowDownloads: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
passwordHash: true,
|
||||
@@ -123,6 +145,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const allowGuests = typeof body?.allowGuests === 'boolean' ? body.allowGuests : true;
|
||||
const allowDownloads = typeof body?.allowDownloads === 'boolean' ? body.allowDownloads : false;
|
||||
const password = typeof body?.password === 'string' ? body.password.trim() : '';
|
||||
if (password.length > MAX_SHARE_PASSWORD_LENGTH) {
|
||||
return apiErrors.badRequest(`Password must be ${MAX_SHARE_PASSWORD_LENGTH} characters or fewer`);
|
||||
@@ -135,6 +158,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
token: string;
|
||||
permission: string;
|
||||
allowGuests: boolean;
|
||||
allowDownloads: boolean;
|
||||
expiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
passwordHash: string | null;
|
||||
@@ -158,6 +182,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
data: {
|
||||
token,
|
||||
allowGuests,
|
||||
allowDownloads,
|
||||
passwordHash,
|
||||
expiresAt: null,
|
||||
},
|
||||
@@ -166,6 +191,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
token: true,
|
||||
permission: true,
|
||||
allowGuests: true,
|
||||
allowDownloads: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
passwordHash: true,
|
||||
@@ -180,6 +206,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
videoId,
|
||||
permission: 'COMMENT',
|
||||
allowGuests,
|
||||
allowDownloads,
|
||||
passwordHash,
|
||||
},
|
||||
select: {
|
||||
@@ -187,6 +214,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
token: true,
|
||||
permission: true,
|
||||
allowGuests: true,
|
||||
allowDownloads: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
passwordHash: true,
|
||||
@@ -232,6 +260,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const allowGuests = typeof body?.allowGuests === 'boolean' ? body.allowGuests : undefined;
|
||||
const allowDownloads = typeof body?.allowDownloads === 'boolean' ? body.allowDownloads : undefined;
|
||||
const rawPassword = typeof body?.password === 'string' ? body.password : undefined;
|
||||
const clearPassword = body?.clearPassword === true;
|
||||
if (rawPassword !== undefined && rawPassword.length > MAX_SHARE_PASSWORD_LENGTH) {
|
||||
@@ -266,6 +295,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...(allowGuests !== undefined ? { allowGuests } : {}),
|
||||
...(allowDownloads !== undefined ? { allowDownloads } : {}),
|
||||
...(passwordHashUpdate !== undefined ? { passwordHash: passwordHashUpdate } : {}),
|
||||
...(shouldRotateToken ? { token: randomBytes(24).toString('base64url') } : {}),
|
||||
},
|
||||
@@ -274,6 +304,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
token: true,
|
||||
permission: true,
|
||||
allowGuests: true,
|
||||
allowDownloads: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
passwordHash: true,
|
||||
|
||||
+26
-13
@@ -4,6 +4,7 @@ import { auth } from '@/lib/auth';
|
||||
import { ProjectVisibility } from '@prisma/client';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags';
|
||||
|
||||
// GET /api/projects - List all projects for the authenticated user
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -137,19 +138,31 @@ export async function POST(request: NextRequest) {
|
||||
return apiErrors.forbidden('Only workspace owners and admins can create projects');
|
||||
}
|
||||
|
||||
const project = await db.project.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
description: description?.trim() || null,
|
||||
slug,
|
||||
visibility: visibility || ProjectVisibility.PRIVATE,
|
||||
ownerId: session.user.id,
|
||||
workspaceId,
|
||||
},
|
||||
include: {
|
||||
owner: { select: { id: true, name: true, image: true } },
|
||||
_count: { select: { videos: true, members: true } },
|
||||
},
|
||||
const project = await db.$transaction(async (tx) => {
|
||||
const createdProject = await tx.project.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
description: description?.trim() || null,
|
||||
slug,
|
||||
visibility: visibility || ProjectVisibility.PRIVATE,
|
||||
ownerId: session.user.id,
|
||||
workspaceId,
|
||||
},
|
||||
include: {
|
||||
owner: { select: { id: true, name: true, image: true } },
|
||||
_count: { select: { videos: true, members: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await tx.commentTag.createMany({
|
||||
data: DEFAULT_COMMENT_TAGS.map((tag) => ({
|
||||
...tag,
|
||||
projectId: createdProject.id,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
return createdProject;
|
||||
});
|
||||
|
||||
const response = successResponse(project, 201);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { apiErrors } from '@/lib/api-response';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// Only allow UUID filenames with safe extensions
|
||||
const SAFE_FILENAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
@@ -15,6 +16,7 @@ const CONTENT_TYPE_MAP: Record<string, string> = {
|
||||
ogg: 'audio/ogg',
|
||||
wav: 'audio/wav',
|
||||
};
|
||||
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
function getContentType(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
@@ -34,6 +36,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
const key = `voice/${filename}`;
|
||||
const mediaUrl = `/api/upload/audio/${filename}`;
|
||||
|
||||
// Get file metadata to determine content type
|
||||
const headResponse = await r2Client.send(
|
||||
@@ -43,6 +46,23 @@ export async function GET(
|
||||
})
|
||||
);
|
||||
|
||||
const lastModified = headResponse.LastModified;
|
||||
if (lastModified && Date.now() - lastModified.getTime() > UNATTACHED_UPLOAD_TTL_MS) {
|
||||
const referenced = await db.comment.findFirst({
|
||||
where: { voiceUrl: mediaUrl },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!referenced) {
|
||||
await r2Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
})
|
||||
).catch(() => undefined);
|
||||
return apiErrors.notFound('File');
|
||||
}
|
||||
}
|
||||
|
||||
// Use the stored content-type or infer from filename extension
|
||||
const contentType = headResponse.ContentType || getContentType(filename);
|
||||
|
||||
@@ -78,7 +98,7 @@ export async function GET(
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'Cache-Control': 'private, no-store',
|
||||
'Accept-Ranges': 'bytes',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import {
|
||||
deriveGuestUploadContext,
|
||||
enforceGuestUploadQuota,
|
||||
verifyGuestUploadToken,
|
||||
} from '@/lib/guest-upload-token';
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_TYPES = ['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'audio/wav'];
|
||||
|
||||
export async function POST(request: Request) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Check Content-Length header BEFORE loading the file
|
||||
const contentLength = request.headers.get('content-length');
|
||||
@@ -23,18 +32,69 @@ export async function POST(request: Request) {
|
||||
const limited = await rateLimit(request, 'voice-upload');
|
||||
if (limited) return limited;
|
||||
|
||||
// Require authentication
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('audio') as File | null;
|
||||
const videoId = formData.get('videoId');
|
||||
const uploadToken = formData.get('uploadToken');
|
||||
|
||||
if (!file) {
|
||||
return apiErrors.badRequest('No audio file provided');
|
||||
}
|
||||
if (typeof videoId !== 'string' || !videoId.trim()) {
|
||||
return apiErrors.badRequest('videoId is required');
|
||||
}
|
||||
|
||||
const safeVideoId = videoId.trim();
|
||||
const video = await db.video.findUnique({
|
||||
where: { id: safeVideoId },
|
||||
include: { project: true },
|
||||
});
|
||||
if (!video) {
|
||||
return apiErrors.notFound('Video');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
||||
const shareSession = getShareSessionFromRequest(request, safeVideoId);
|
||||
const shareAccess = shareSession
|
||||
? await validateShareLinkAccess({
|
||||
token: shareSession.token,
|
||||
projectId: video.projectId,
|
||||
videoId: safeVideoId,
|
||||
requiredPermission: 'COMMENT',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
||||
const canCommentWithMembership = !!session?.user?.id && access.hasAccess;
|
||||
const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
|
||||
if (!canCommentWithMembership && !canCommentWithShareLink) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
if (!session?.user?.id) {
|
||||
if (typeof uploadToken !== 'string' || !uploadToken.trim()) {
|
||||
return apiErrors.badRequest('uploadToken is required for guest uploads');
|
||||
}
|
||||
|
||||
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
|
||||
if (!expectedContext) {
|
||||
return apiErrors.forbidden('Missing trusted client IP header');
|
||||
}
|
||||
|
||||
const isValidUploadToken = verifyGuestUploadToken(uploadToken.trim(), {
|
||||
projectId: video.projectId,
|
||||
videoId: safeVideoId,
|
||||
intent: 'audio',
|
||||
context: expectedContext,
|
||||
});
|
||||
if (!isValidUploadToken) {
|
||||
return apiErrors.forbidden('Invalid upload token');
|
||||
}
|
||||
|
||||
const quotaError = await enforceGuestUploadQuota(request, safeVideoId, 'audio', shareSession?.token ?? null);
|
||||
if (quotaError) return quotaError;
|
||||
}
|
||||
|
||||
// Double-check file size (defense in depth - Content-Length can be spoofed)
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { apiErrors } from '@/lib/api-response';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// Only allow UUID filenames with safe extensions
|
||||
const SAFE_FILENAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
@@ -14,6 +15,7 @@ const CONTENT_TYPE_MAP: Record<string, string> = {
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
};
|
||||
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
function getContentType(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
@@ -33,6 +35,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
const key = `images/${filename}`;
|
||||
const mediaUrl = `/api/upload/image/${filename}`;
|
||||
|
||||
// Get file metadata to determine content type
|
||||
const headResponse = await r2Client.send(
|
||||
@@ -42,6 +45,23 @@ export async function GET(
|
||||
})
|
||||
);
|
||||
|
||||
const lastModified = headResponse.LastModified;
|
||||
if (lastModified && Date.now() - lastModified.getTime() > UNATTACHED_UPLOAD_TTL_MS) {
|
||||
const referenced = await db.comment.findFirst({
|
||||
where: { imageUrl: mediaUrl },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!referenced) {
|
||||
await r2Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
})
|
||||
).catch(() => undefined);
|
||||
return apiErrors.notFound('File');
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = headResponse.ContentType || getContentType(filename);
|
||||
|
||||
const objectResponse = await r2Client.send(
|
||||
@@ -72,7 +92,7 @@ export async function GET(
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'Cache-Control': 'private, no-store',
|
||||
'Accept-Ranges': 'bytes',
|
||||
},
|
||||
});
|
||||
@@ -85,4 +105,3 @@ export async function GET(
|
||||
return apiErrors.internalError('Failed to retrieve image');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import {
|
||||
deriveGuestUploadContext,
|
||||
enforceGuestUploadQuota,
|
||||
verifyGuestUploadToken,
|
||||
} from '@/lib/guest-upload-token';
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_TYPES = [
|
||||
@@ -11,10 +20,9 @@ const ALLOWED_TYPES = [
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'image/svg+xml'
|
||||
];
|
||||
|
||||
export async function POST(request: Request) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Check Content-Length header BEFORE loading the file
|
||||
const contentLength = request.headers.get('content-length');
|
||||
@@ -29,18 +37,69 @@ export async function POST(request: Request) {
|
||||
const limited = await rateLimit(request, 'image-upload');
|
||||
if (limited) return limited;
|
||||
|
||||
// Require authentication
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('image') as File | null;
|
||||
const videoId = formData.get('videoId');
|
||||
const uploadToken = formData.get('uploadToken');
|
||||
|
||||
if (!file) {
|
||||
return apiErrors.badRequest('No image file provided');
|
||||
}
|
||||
if (typeof videoId !== 'string' || !videoId.trim()) {
|
||||
return apiErrors.badRequest('videoId is required');
|
||||
}
|
||||
|
||||
const safeVideoId = videoId.trim();
|
||||
const video = await db.video.findUnique({
|
||||
where: { id: safeVideoId },
|
||||
include: { project: true },
|
||||
});
|
||||
if (!video) {
|
||||
return apiErrors.notFound('Video');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
||||
const shareSession = getShareSessionFromRequest(request, safeVideoId);
|
||||
const shareAccess = shareSession
|
||||
? await validateShareLinkAccess({
|
||||
token: shareSession.token,
|
||||
projectId: video.projectId,
|
||||
videoId: safeVideoId,
|
||||
requiredPermission: 'COMMENT',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
||||
const canCommentWithMembership = !!session?.user?.id && access.hasAccess;
|
||||
const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
|
||||
if (!canCommentWithMembership && !canCommentWithShareLink) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
if (!session?.user?.id) {
|
||||
if (typeof uploadToken !== 'string' || !uploadToken.trim()) {
|
||||
return apiErrors.badRequest('uploadToken is required for guest uploads');
|
||||
}
|
||||
|
||||
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
|
||||
if (!expectedContext) {
|
||||
return apiErrors.forbidden('Missing trusted client IP header');
|
||||
}
|
||||
|
||||
const isValidUploadToken = verifyGuestUploadToken(uploadToken.trim(), {
|
||||
projectId: video.projectId,
|
||||
videoId: safeVideoId,
|
||||
intent: 'image',
|
||||
context: expectedContext,
|
||||
});
|
||||
if (!isValidUploadToken) {
|
||||
return apiErrors.forbidden('Invalid upload token');
|
||||
}
|
||||
|
||||
const quotaError = await enforceGuestUploadQuota(request, safeVideoId, 'image', shareSession?.token ?? null);
|
||||
if (quotaError) return quotaError;
|
||||
}
|
||||
|
||||
// Double-check file size (defense in depth - Content-Length can be spoofed)
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
@@ -54,7 +113,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const ext = contentType.split('/')[1] === 'svg+xml' ? 'svg' : contentType.split('/')[1] || 'jpeg';
|
||||
const ext = contentType.split('/')[1] || 'jpeg';
|
||||
const filename = `${randomUUID()}.${ext}`;
|
||||
const key = `images/${filename}`;
|
||||
|
||||
|
||||
@@ -6,10 +6,35 @@ import { notifyProjectOwner } from '@/lib/notifications';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { ensureGuestIdentityFromRequest, getGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity';
|
||||
|
||||
type RouteParams = { params: Promise<{ versionId: string }> };
|
||||
const SAFE_IMAGE_PATH = /^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
const SAFE_AUDIO_PATH = /^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
async function isFreshAttachment(url: string, kind: 'audio' | 'image'): Promise<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
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
@@ -200,7 +225,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
requiredPermission: 'COMMENT',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: { hasAccess: false, canComment: false, allowGuests: false, requiresPassword: false };
|
||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
||||
|
||||
// Check if user can comment
|
||||
const canComment = isOwner || isMember || isPublic || isWorkspaceMember || shareAccess.canComment;
|
||||
@@ -247,10 +272,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
if (voiceUrl && !SAFE_AUDIO_PATH.test(voiceUrl)) {
|
||||
return apiErrors.badRequest('Voice URL must reference an uploaded audio file');
|
||||
}
|
||||
if (voiceUrl && !(await isFreshAttachment(voiceUrl, 'audio'))) {
|
||||
return apiErrors.badRequest('Voice upload expired. Please upload again.');
|
||||
}
|
||||
|
||||
if (imageUrl && !SAFE_IMAGE_PATH.test(imageUrl)) {
|
||||
return apiErrors.badRequest('Image URL must reference an uploaded image file');
|
||||
}
|
||||
if (imageUrl && !(await isFreshAttachment(imageUrl, 'image'))) {
|
||||
return apiErrors.badRequest('Image upload expired. Please upload again.');
|
||||
}
|
||||
|
||||
const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null;
|
||||
|
||||
const comment = await db.comment.create({
|
||||
data: {
|
||||
@@ -265,6 +298,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
authorId: session?.user?.id || null,
|
||||
guestName: isGuest ? guestName : null,
|
||||
guestEmail: isGuest ? guestEmail : null,
|
||||
guestIdentityId: isGuest ? guestIdentity?.identityId ?? null : null,
|
||||
tagId: tagId || null,
|
||||
versionId,
|
||||
},
|
||||
@@ -319,7 +353,25 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
}
|
||||
|
||||
const response = successResponse(comment, 201);
|
||||
const viewerUserId = session?.user?.id ?? null;
|
||||
const viewerGuestIdentityId = viewerUserId
|
||||
? null
|
||||
: guestIdentity?.identityId ?? getGuestIdentityFromRequest(request);
|
||||
const canEditComment = viewerUserId
|
||||
? comment.authorId === viewerUserId
|
||||
: !!viewerGuestIdentityId
|
||||
&& !!comment.guestIdentityId
|
||||
&& comment.guestIdentityId === viewerGuestIdentityId;
|
||||
const { guestIdentityId: _guestIdentityId, ...commentData } = comment;
|
||||
|
||||
const response = successResponse({
|
||||
...commentData,
|
||||
canEdit: canEditComment,
|
||||
canDelete: canEditComment || viewerUserId === project.ownerId,
|
||||
}, 201);
|
||||
if (isGuest && guestIdentity?.shouldSetCookie) {
|
||||
setGuestIdentityCookie(response, guestIdentity.identityId);
|
||||
}
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
console.error('Error creating comment:', error);
|
||||
|
||||
@@ -2,6 +2,9 @@ import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { DownloadEgressSource } from '@prisma/client';
|
||||
|
||||
type RouteParams = { params: Promise<{ versionId: string }> };
|
||||
@@ -319,7 +322,7 @@ function parseEstimatedBytes(contentLengthHeader: string | null): bigint {
|
||||
}
|
||||
|
||||
// GET /api/versions/[versionId]/download
|
||||
export async function GET(request: Request, { params }: RouteParams) {
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const isPrepareOnly = searchParams.get('prepare') === '1';
|
||||
@@ -362,7 +365,18 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(version.video.project, session?.user?.id);
|
||||
if (!access.hasAccess) {
|
||||
const shareSession = getShareSessionFromRequest(request, version.video.id);
|
||||
const shareAccess = shareSession
|
||||
? await validateShareLinkAccess({
|
||||
token: shareSession.token,
|
||||
projectId: version.video.projectId,
|
||||
videoId: version.video.id,
|
||||
requiredPermission: 'VIEW',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
||||
const canDownloadViaShareLink = shareAccess.hasAccess && shareAccess.canDownload;
|
||||
if (!access.hasAccess && !canDownloadViaShareLink) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
|
||||
|
||||
type RouteParams = { params: Promise<{ videoId: string }> };
|
||||
|
||||
@@ -48,6 +49,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
guestIdentityId: true,
|
||||
tagId: true,
|
||||
versionId: true,
|
||||
guestName: true,
|
||||
@@ -70,6 +72,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
guestIdentityId: true,
|
||||
tagId: true,
|
||||
versionId: true,
|
||||
guestName: true,
|
||||
@@ -109,7 +112,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
requiredPermission: 'VIEW',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: { hasAccess: false, canComment: false, allowGuests: false, requiresPassword: false };
|
||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
||||
|
||||
if (!access.hasAccess && !shareAccess.hasAccess) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
@@ -117,10 +120,65 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
// Include auth context so the client knows if the viewer is a guest
|
||||
const { project, ...videoData } = video;
|
||||
const viewerUserId = session?.user?.id ?? null;
|
||||
const viewerGuestIdentityId = viewerUserId ? null : getGuestIdentityFromRequest(request);
|
||||
const isProjectOwner = viewerUserId === project.ownerId;
|
||||
|
||||
const versions = videoData.versions.map((version) => {
|
||||
if (!('comments' in version)) {
|
||||
return version;
|
||||
}
|
||||
|
||||
return {
|
||||
...version,
|
||||
comments: version.comments.map((comment) => {
|
||||
const canEditComment = viewerUserId
|
||||
? comment.authorId === viewerUserId
|
||||
: !!viewerGuestIdentityId
|
||||
&& !!comment.guestIdentityId
|
||||
&& comment.guestIdentityId === viewerGuestIdentityId;
|
||||
const canDeleteComment = canEditComment || isProjectOwner;
|
||||
const {
|
||||
authorId: _commentAuthorId,
|
||||
guestIdentityId: _commentGuestIdentityId,
|
||||
replies,
|
||||
...commentData
|
||||
} = comment;
|
||||
|
||||
return {
|
||||
...commentData,
|
||||
canEdit: canEditComment,
|
||||
canDelete: canDeleteComment,
|
||||
replies: replies.map((reply) => {
|
||||
const canEditReply = viewerUserId
|
||||
? reply.authorId === viewerUserId
|
||||
: !!viewerGuestIdentityId
|
||||
&& !!reply.guestIdentityId
|
||||
&& reply.guestIdentityId === viewerGuestIdentityId;
|
||||
const canDeleteReply = canEditReply || isProjectOwner;
|
||||
const {
|
||||
authorId: _replyAuthorId,
|
||||
guestIdentityId: _replyGuestIdentityId,
|
||||
...replyData
|
||||
} = reply;
|
||||
return {
|
||||
...replyData,
|
||||
canEdit: canEditReply,
|
||||
canDelete: canDeleteReply,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const canCommentWithMembership = access.hasAccess;
|
||||
const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
|
||||
const canDownloadWithMembership = access.hasAccess;
|
||||
const canDownloadWithShareLink = shareAccess.hasAccess && shareAccess.canDownload;
|
||||
const response = successResponse({
|
||||
...videoData,
|
||||
versions,
|
||||
projectId: video.projectId,
|
||||
project: {
|
||||
name: project.name,
|
||||
@@ -131,6 +189,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
currentUserId: session?.user?.id || null,
|
||||
currentUserName: session?.user?.name || null,
|
||||
canComment: canCommentWithMembership || canCommentWithShareLink,
|
||||
canDownload: canDownloadWithMembership || canDownloadWithShareLink,
|
||||
canManageTags: access.canEdit,
|
||||
});
|
||||
|
||||
return withCacheControl(response, 'private, no-cache');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,8 @@ interface Comment {
|
||||
createdAt: string;
|
||||
author: { id: string; name: string | null; image: string | null } | null;
|
||||
guestName: string | null;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
tag: CommentTag | null;
|
||||
replies: {
|
||||
id: string;
|
||||
@@ -141,6 +143,8 @@ interface Comment {
|
||||
createdAt: string;
|
||||
author: { id: string; name: string | null; image: string | null } | null;
|
||||
guestName: string | null;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
tag: CommentTag | null;
|
||||
}[];
|
||||
}
|
||||
@@ -161,6 +165,8 @@ interface VideoData {
|
||||
currentUserId: string | null;
|
||||
currentUserName: string | null;
|
||||
canComment?: boolean;
|
||||
canDownload?: boolean;
|
||||
canManageTags?: boolean;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
@@ -388,6 +394,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
|
||||
const isGuest = video ? !video.isAuthenticated : false;
|
||||
const canInitializePlayer = mode !== 'watch' || !isGuest || guestNameConfirmed;
|
||||
const normalizedGuestName = guestName.trim();
|
||||
|
||||
const [showVersionDialog, setShowVersionDialog] = useState(false);
|
||||
const [newVersionUrl, setNewVersionUrl] = useState('');
|
||||
@@ -549,14 +556,18 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
const isDownloadingVideo = activeDownloadTarget !== null;
|
||||
|
||||
const isVideoDownloadAvailable = useMemo(() => {
|
||||
if (!activeVersion) return false;
|
||||
if (!activeVersion || !video?.canDownload) return false;
|
||||
if (activeVersion.providerId === 'bunny') return true;
|
||||
if (activeVersion.providerId !== 'direct') return false;
|
||||
return !!getSafeDirectDownloadUrl(activeVersion.originalUrl);
|
||||
}, [activeVersion]);
|
||||
}, [activeVersion, video?.canDownload]);
|
||||
|
||||
const handleDownloadVideo = useCallback(async (preference: BunnyDownloadPreference = 'compressed') => {
|
||||
if (!activeVersion || !video || isDownloadingVideo) return;
|
||||
if (!video.canDownload) {
|
||||
toast.error('Download is disabled for this shared link');
|
||||
return;
|
||||
}
|
||||
if (activeVersion.providerId !== 'bunny' && activeVersion.providerId !== 'direct') {
|
||||
toast.error('This video source does not support direct download');
|
||||
return;
|
||||
@@ -619,6 +630,24 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
}
|
||||
}, [activeVersion, isDownloadingVideo, video]);
|
||||
|
||||
const getGuestUploadToken = useCallback(async (intent: 'audio' | 'image') => {
|
||||
if (!isGuest) return null;
|
||||
|
||||
const response = await fetch(`/api/watch/${videoId}/upload-token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ intent }),
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { data?: { token?: string }; error?: string }
|
||||
| null;
|
||||
const token = payload?.data?.token;
|
||||
if (!response.ok || !token) {
|
||||
throw new Error(payload?.error || 'Failed to prepare upload');
|
||||
}
|
||||
return token;
|
||||
}, [isGuest, videoId]);
|
||||
|
||||
// Memoize comments array
|
||||
const comments = useMemo(() => {
|
||||
return activeVersion?.comments || [];
|
||||
@@ -671,7 +700,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
if (!projectId) return;
|
||||
async function fetchTags() {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/tags`);
|
||||
const query = videoId ? `?videoId=${encodeURIComponent(videoId)}` : '';
|
||||
const res = await fetch(`/api/projects/${projectId}/tags${query}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const tags = data.data || [];
|
||||
@@ -684,7 +714,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
}
|
||||
}
|
||||
fetchTags();
|
||||
}, [projectId, selectedTagId]);
|
||||
}, [projectId, selectedTagId, videoId]);
|
||||
|
||||
// Load YouTube API immediately on component mount (async, non-blocking)
|
||||
useEffect(() => {
|
||||
@@ -1542,7 +1572,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
isResolved: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
|
||||
guestName: isGuest ? guestName : null,
|
||||
guestName: isGuest ? normalizedGuestName : null,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
tag: availableTags.find(t => t.id === selectedTagId) || null,
|
||||
replies: [],
|
||||
};
|
||||
@@ -1578,6 +1610,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
setIsUploadingImage(true);
|
||||
const imageFormData = new FormData();
|
||||
imageFormData.append('image', imageBlob);
|
||||
imageFormData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('image');
|
||||
if (uploadToken) imageFormData.append('uploadToken', uploadToken);
|
||||
|
||||
const imageRes = await fetch('/api/upload/image', {
|
||||
method: 'POST',
|
||||
@@ -1597,7 +1632,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
timestamp: selectedTimestamp ?? currentTime,
|
||||
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
||||
...(imageData && { imageUrl: imageData.url }),
|
||||
...(isGuest && guestName && { guestName }),
|
||||
...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }),
|
||||
...(selectedTagId && { tagId: selectedTagId }),
|
||||
...(serializedAnnotation && { annotationData: serializedAnnotation }),
|
||||
}),
|
||||
@@ -1649,7 +1684,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
setIsUploadingImage(false);
|
||||
isMutatingRef.current = false;
|
||||
}
|
||||
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, currentUserName, selectedTagId, availableTags, imageBlob, annotationStrokes, isAnnotating]);
|
||||
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, normalizedGuestName, currentUserName, selectedTagId, availableTags, imageBlob, annotationStrokes, isAnnotating, videoId, getGuestUploadToken]);
|
||||
|
||||
const handleImageSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>, isReply: boolean = false) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -1758,6 +1793,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', audioBlob, 'recording.webm');
|
||||
formData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('audio');
|
||||
if (uploadToken) formData.append('uploadToken', uploadToken);
|
||||
|
||||
const uploadRes = await fetch('/api/upload/audio', {
|
||||
method: 'POST',
|
||||
@@ -1779,7 +1817,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
} finally {
|
||||
setIsUploadingAudio(false);
|
||||
}
|
||||
}, [audioBlob, activeVersion, recordingTime, handleAddComment]);
|
||||
}, [audioBlob, activeVersion, recordingTime, handleAddComment, videoId, getGuestUploadToken]);
|
||||
|
||||
const stopVoiceTracking = useCallback(() => {
|
||||
if (voiceRafRef.current) {
|
||||
@@ -1895,6 +1933,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
if (audioBlob) {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', audioBlob, 'recording.webm');
|
||||
formData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('audio');
|
||||
if (uploadToken) formData.append('uploadToken', uploadToken);
|
||||
const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
|
||||
if (!uploadRes.ok) throw new Error('Failed to upload audio');
|
||||
const uploadData = await uploadRes.json();
|
||||
@@ -1914,7 +1955,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
setIsUploadingAudio(false);
|
||||
setIsUploadingImage(false);
|
||||
}
|
||||
}, [audioBlob, imageBlob, activeVersion, recordingTime, commentText, submitVoiceComment, handleAddComment]);
|
||||
}, [audioBlob, imageBlob, activeVersion, recordingTime, commentText, submitVoiceComment, handleAddComment, videoId, getGuestUploadToken]);
|
||||
|
||||
const handleResolveComment = useCallback(
|
||||
async (commentId: string, currentlyResolved: boolean) => {
|
||||
@@ -2002,7 +2043,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
annotationData: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
|
||||
guestName: isGuest ? guestName : null,
|
||||
guestName: isGuest ? normalizedGuestName : null,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
tag: null,
|
||||
};
|
||||
|
||||
@@ -2041,6 +2084,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
setIsUploadingReplyImage(true);
|
||||
const imageFormData = new FormData();
|
||||
imageFormData.append('image', replyImageBlob);
|
||||
imageFormData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('image');
|
||||
if (uploadToken) imageFormData.append('uploadToken', uploadToken);
|
||||
|
||||
const imageRes = await fetch('/api/upload/image', {
|
||||
method: 'POST',
|
||||
@@ -2061,7 +2107,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
parentId,
|
||||
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
||||
...(submittedImageData && { imageUrl: submittedImageData.url }),
|
||||
...(isGuest && guestName && { guestName }),
|
||||
...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -2132,7 +2178,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
setIsUploadingReplyImage(false);
|
||||
isMutatingRef.current = false;
|
||||
}
|
||||
}, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName, currentUserName, replyImageBlob]);
|
||||
}, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, normalizedGuestName, currentUserName, replyImageBlob, videoId, getGuestUploadToken]);
|
||||
|
||||
const startReplyRecording = useCallback(async () => {
|
||||
try {
|
||||
@@ -2189,6 +2235,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', replyAudioBlob, 'recording.webm');
|
||||
formData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('audio');
|
||||
if (uploadToken) formData.append('uploadToken', uploadToken);
|
||||
const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
|
||||
if (!uploadRes.ok) throw new Error('Failed to upload audio');
|
||||
const uploadData = await uploadRes.json();
|
||||
@@ -2199,7 +2248,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
} finally {
|
||||
setIsUploadingReplyAudio(false);
|
||||
}
|
||||
}, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment]);
|
||||
}, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment, videoId, getGuestUploadToken]);
|
||||
|
||||
const submitReplyWithMedia = useCallback(async (parentId: string) => {
|
||||
if (!activeVersion) return;
|
||||
@@ -2218,6 +2267,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
if (replyAudioBlob) {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', replyAudioBlob, 'recording.webm');
|
||||
formData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('audio');
|
||||
if (uploadToken) formData.append('uploadToken', uploadToken);
|
||||
const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
|
||||
if (!uploadRes.ok) throw new Error('Failed to upload audio reply');
|
||||
const uploadData = await uploadRes.json();
|
||||
@@ -2237,7 +2289,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
setIsUploadingReplyAudio(false);
|
||||
setIsUploadingReplyImage(false);
|
||||
}
|
||||
}, [replyAudioBlob, replyImageBlob, activeVersion, replyRecordingTime, replyText, submitVoiceReply, handleReplyComment]);
|
||||
}, [replyAudioBlob, replyImageBlob, activeVersion, replyRecordingTime, replyText, submitVoiceReply, handleReplyComment, videoId, getGuestUploadToken]);
|
||||
|
||||
const handleEditComment = useCallback(async (commentId: string) => {
|
||||
if (!editText.trim() && !editAnnotationData) return;
|
||||
@@ -2257,6 +2309,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
const body: Record<string, unknown> = { content: editText };
|
||||
if (editTagId !== undefined) body.tagId = editTagId;
|
||||
if (finalAnnotationData !== undefined) body.annotationData = finalAnnotationData;
|
||||
if (isGuest && normalizedGuestName) body.guestName = normalizedGuestName;
|
||||
const res = await fetch(`/api/comments/${commentId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -2306,7 +2359,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
setIsSubmittingEdit(false);
|
||||
isMutatingRef.current = false;
|
||||
}
|
||||
}, [editText, editTagId, editAnnotationData, isEditingAnnotation, activeVersionId, availableTags]);
|
||||
}, [editText, editTagId, editAnnotationData, isEditingAnnotation, activeVersionId, availableTags, isGuest, normalizedGuestName]);
|
||||
|
||||
const handleDeleteComment = useCallback(async (commentId: string) => {
|
||||
setDeletingCommentId(commentId);
|
||||
@@ -3520,6 +3573,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
comment.author?.name || comment.guestName || 'Anonymous';
|
||||
const isEditing = editingCommentId === comment.id;
|
||||
const isReplying = replyingTo === comment.id;
|
||||
const canEditComment = comment.canEdit ?? (comment.author?.id === currentUserId);
|
||||
const canDeleteComment = comment.canDelete ?? (comment.author?.id === currentUserId || video.project.ownerId === currentUserId);
|
||||
const canManageComment = canEditComment || canDeleteComment;
|
||||
return (
|
||||
<div
|
||||
key={comment.id}
|
||||
@@ -3563,7 +3619,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
<Circle className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
{(comment.author?.id === currentUserId || video.project.ownerId === currentUserId) && (
|
||||
{canManageComment && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -3582,7 +3638,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
<Reply className="h-4 w-4 mr-2" />
|
||||
Reply
|
||||
</DropdownMenuItem>
|
||||
{comment.author?.id === currentUserId && (
|
||||
{canEditComment && (
|
||||
<DropdownMenuItem onClick={() => {
|
||||
setEditingCommentId(comment.id);
|
||||
setEditText(comment.content || '');
|
||||
@@ -3592,13 +3648,15 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteComment(comment.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
{canDeleteComment && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteComment(comment.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
@@ -3774,6 +3832,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
const replyAuthor =
|
||||
reply.author?.name || reply.guestName || 'Anonymous';
|
||||
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 (
|
||||
<div key={reply.id} className="group/reply text-sm">
|
||||
<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()}
|
||||
</span>
|
||||
</div>
|
||||
{(reply.author?.id === currentUserId || video.project.ownerId === currentUserId) && (
|
||||
{canManageReply && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -3800,7 +3861,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{reply.author?.id === currentUserId && (
|
||||
{canEditReply && (
|
||||
<DropdownMenuItem onClick={() => {
|
||||
setEditingCommentId(reply.id);
|
||||
setEditText(reply.content || '');
|
||||
@@ -3809,13 +3870,15 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteComment(reply.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
{canDeleteReply && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteComment(reply.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
@@ -4345,13 +4408,17 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
{selectedTagId === tag.id && <span className="ml-auto">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/projects/${projectId}/settings#comment-tags`} className="gap-2 text-muted-foreground">
|
||||
<Tag className="h-3 w-3" />
|
||||
Manage Tags
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{video.canManageTags && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/projects/${projectId}/settings#comment-tags`} className="gap-2 text-muted-foreground">
|
||||
<Tag className="h-3 w-3" />
|
||||
Manage Tags
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
+39
-11
@@ -202,35 +202,63 @@ export const getCachedUserMediaStorage = unstable_cache(
|
||||
const userStorage: Record<string, { total: number, voice: number, image: number }> = {};
|
||||
try {
|
||||
const fileSizes = await listAllR2FileSizes();
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
const mediaComments = await db.comment.findMany({
|
||||
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }], authorId: { not: null } },
|
||||
select: { authorId: true, voiceUrl: true, imageUrl: true }
|
||||
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] },
|
||||
select: {
|
||||
voiceUrl: true,
|
||||
imageUrl: true,
|
||||
version: {
|
||||
select: {
|
||||
video: {
|
||||
select: {
|
||||
project: {
|
||||
select: {
|
||||
workspace: {
|
||||
select: { ownerId: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const comment of mediaComments) {
|
||||
if (!comment.authorId) continue;
|
||||
const billedUserId = comment.version.video.project.workspace.ownerId;
|
||||
if (!billedUserId) continue;
|
||||
|
||||
if (!userStorage[comment.authorId]) {
|
||||
userStorage[comment.authorId] = { total: 0, voice: 0, image: 0 };
|
||||
if (!userStorage[billedUserId]) {
|
||||
userStorage[billedUserId] = { total: 0, voice: 0, image: 0 };
|
||||
}
|
||||
|
||||
if (comment.voiceUrl) {
|
||||
const keyParts = comment.voiceUrl.split('/');
|
||||
const filename = keyParts[keyParts.length - 1];
|
||||
const r2Key = `voice/${filename}`;
|
||||
const size = fileSizes.get(r2Key) || 0;
|
||||
userStorage[comment.authorId].voice += size;
|
||||
userStorage[comment.authorId].total += size;
|
||||
const dedupeKey = `${billedUserId}:${r2Key}`;
|
||||
if (!seenKeys.has(dedupeKey)) {
|
||||
seenKeys.add(dedupeKey);
|
||||
const size = fileSizes.get(r2Key) || 0;
|
||||
userStorage[billedUserId].voice += size;
|
||||
userStorage[billedUserId].total += size;
|
||||
}
|
||||
}
|
||||
|
||||
if (comment.imageUrl) {
|
||||
const keyParts = comment.imageUrl.split('/');
|
||||
const filename = keyParts[keyParts.length - 1];
|
||||
const r2Key = `images/${filename}`;
|
||||
const size = fileSizes.get(r2Key) || 0;
|
||||
userStorage[comment.authorId].image += size;
|
||||
userStorage[comment.authorId].total += size;
|
||||
const dedupeKey = `${billedUserId}:${r2Key}`;
|
||||
if (!seenKeys.has(dedupeKey)) {
|
||||
seenKeys.add(dedupeKey);
|
||||
const size = fileSizes.get(r2Key) || 0;
|
||||
userStorage[billedUserId].image += size;
|
||||
userStorage[billedUserId].total += size;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -16,6 +16,7 @@ interface ValidateShareLinkParams {
|
||||
export interface ShareLinkAccessResult {
|
||||
hasAccess: boolean;
|
||||
canComment: boolean;
|
||||
canDownload: boolean;
|
||||
allowGuests: boolean;
|
||||
requiresPassword: boolean;
|
||||
link: ShareLink | null;
|
||||
@@ -47,7 +48,7 @@ export async function validateShareLinkAccess({
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -56,27 +57,28 @@ export async function validateShareLinkAccess({
|
||||
const permissionMatches = hasRequiredPermission(link.permission, requiredPermission);
|
||||
|
||||
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 (!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) {
|
||||
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);
|
||||
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 {
|
||||
hasAccess: true,
|
||||
canComment: link.permission === 'COMMENT',
|
||||
canDownload: link.allowDownloads,
|
||||
allowGuests: link.allowGuests,
|
||||
requiresPassword: false,
|
||||
link,
|
||||
|
||||
@@ -310,6 +310,7 @@ model Comment {
|
||||
// Guest author info (when authorId is null)
|
||||
guestName String?
|
||||
guestEmail String?
|
||||
guestIdentityId String?
|
||||
|
||||
// Video version relation
|
||||
versionId String
|
||||
@@ -326,6 +327,7 @@ model Comment {
|
||||
@@index([versionId])
|
||||
@@index([parentId])
|
||||
@@index([authorId])
|
||||
@@index([guestIdentityId])
|
||||
@@index([timestamp])
|
||||
@@index([tagId])
|
||||
@@index([versionId, isResolved, timestamp])
|
||||
@@ -376,6 +378,7 @@ model ShareLink {
|
||||
|
||||
// Settings
|
||||
allowGuests Boolean @default(true) // Allow comments without account
|
||||
allowDownloads Boolean @default(false) // Allow downloading video via share link
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
Reference in New Issue
Block a user