mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
Add guest upload tokens and share-session aware permissions
This commit is contained in:
@@ -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" />}
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
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);
|
const tags = await db.commentTag.findMany({
|
||||||
if (!project) {
|
|
||||||
return apiErrors.notFound('Project');
|
|
||||||
}
|
|
||||||
|
|
||||||
let tags = await db.commentTag.findMany({
|
|
||||||
where: { projectId },
|
where: { projectId },
|
||||||
orderBy: { position: 'asc' },
|
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);
|
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,
|
||||||
|
|||||||
+26
-13
@@ -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,19 +138,31 @@ 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) => {
|
||||||
data: {
|
const createdProject = await tx.project.create({
|
||||||
name: name.trim(),
|
data: {
|
||||||
description: description?.trim() || null,
|
name: name.trim(),
|
||||||
slug,
|
description: description?.trim() || null,
|
||||||
visibility: visibility || ProjectVisibility.PRIVATE,
|
slug,
|
||||||
ownerId: session.user.id,
|
visibility: visibility || ProjectVisibility.PRIVATE,
|
||||||
workspaceId,
|
ownerId: session.user.id,
|
||||||
},
|
workspaceId,
|
||||||
include: {
|
},
|
||||||
owner: { select: { id: true, name: true, image: true } },
|
include: {
|
||||||
_count: { select: { videos: true, members: true } },
|
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);
|
const response = successResponse(project, 201);
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,13 +3648,15 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem
|
{canDeleteComment && (
|
||||||
className="text-destructive"
|
<DropdownMenuItem
|
||||||
onClick={() => handleDeleteComment(comment.id)}
|
className="text-destructive"
|
||||||
>
|
onClick={() => handleDeleteComment(comment.id)}
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
>
|
||||||
Delete
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
</DropdownMenuItem>
|
Delete
|
||||||
|
</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,13 +3870,15 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem
|
{canDeleteReply && (
|
||||||
className="text-destructive"
|
<DropdownMenuItem
|
||||||
onClick={() => handleDeleteComment(reply.id)}
|
className="text-destructive"
|
||||||
>
|
onClick={() => handleDeleteComment(reply.id)}
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
>
|
||||||
Delete
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
</DropdownMenuItem>
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
@@ -4345,13 +4408,17 @@ 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>
|
||||||
))}
|
))}
|
||||||
<DropdownMenuSeparator />
|
{video.canManageTags && (
|
||||||
<DropdownMenuItem asChild>
|
<>
|
||||||
<Link href={`/projects/${projectId}/settings#comment-tags`} className="gap-2 text-muted-foreground">
|
<DropdownMenuSeparator />
|
||||||
<Tag className="h-3 w-3" />
|
<DropdownMenuItem asChild>
|
||||||
Manage Tags
|
<Link href={`/projects/${projectId}/settings#comment-tags`} className="gap-2 text-muted-foreground">
|
||||||
</Link>
|
<Tag className="h-3 w-3" />
|
||||||
</DropdownMenuItem>
|
Manage Tags
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+39
-11
@@ -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 size = fileSizes.get(r2Key) || 0;
|
const dedupeKey = `${billedUserId}:${r2Key}`;
|
||||||
userStorage[comment.authorId].voice += size;
|
if (!seenKeys.has(dedupeKey)) {
|
||||||
userStorage[comment.authorId].total += size;
|
seenKeys.add(dedupeKey);
|
||||||
|
const size = fileSizes.get(r2Key) || 0;
|
||||||
|
userStorage[billedUserId].voice += 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 size = fileSizes.get(r2Key) || 0;
|
const dedupeKey = `${billedUserId}:${r2Key}`;
|
||||||
userStorage[comment.authorId].image += size;
|
if (!seenKeys.has(dedupeKey)) {
|
||||||
userStorage[comment.authorId].total += size;
|
seenKeys.add(dedupeKey);
|
||||||
|
const size = fileSizes.get(r2Key) || 0;
|
||||||
|
userStorage[billedUserId].image += size;
|
||||||
|
userStorage[billedUserId].total += size;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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 {
|
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,
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user