feat(share): add video-level secure share links with password unlock and session-based watch/comment access

This commit is contained in:
Yusuf İpek
2026-02-23 17:11:32 +03:00
parent d15e5192ac
commit 9058317247
16 changed files with 1253 additions and 46 deletions
@@ -0,0 +1,274 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { ArrowLeft, Check, Copy, Link2, Loader2, RefreshCcw, ShieldOff, Lock, ShieldCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
type RouteParams = Promise<{ projectId: string; videoId: string }>;
interface VideoSharePageProps {
params: RouteParams;
}
interface ShareLinkData {
id: string;
token: string;
allowGuests: boolean;
hasPassword: boolean;
}
interface ShareResponse {
data: {
link: ShareLinkData | null;
shareUrl: string | null;
};
error?: string;
}
export default function VideoSharePage({ params }: VideoSharePageProps) {
const [projectId, setProjectId] = useState('');
const [videoId, setVideoId] = useState('');
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState('');
const [shareUrl, setShareUrl] = useState<string | null>(null);
const [hasPassword, setHasPassword] = useState(false);
const [password, setPassword] = useState('');
useEffect(() => {
params.then(({ projectId: nextProjectId, videoId: nextVideoId }) => {
setProjectId(nextProjectId);
setVideoId(nextVideoId);
});
}, [params]);
useEffect(() => {
if (!projectId || !videoId) return;
async function loadShareLink() {
setLoading(true);
setError('');
try {
const response = await fetch(`/api/projects/${projectId}/videos/${videoId}/share`, { cache: 'no-store' });
const payload = (await response.json()) as ShareResponse;
if (!response.ok || payload.error) {
setError(payload.error || 'Failed to load share link');
setShareUrl(null);
return;
}
setShareUrl(payload.data.shareUrl);
setHasPassword(!!payload.data.link?.hasPassword);
} catch {
setError('Failed to load share link');
setShareUrl(null);
setHasPassword(false);
} finally {
setLoading(false);
}
}
loadShareLink();
}, [projectId, videoId]);
const copyLink = async () => {
if (!shareUrl) return;
await navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const createShareLink = async () => {
if (!projectId || !videoId) return;
setSubmitting(true);
setError('');
try {
const response = await fetch(`/api/projects/${projectId}/videos/${videoId}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ allowGuests: true }),
});
const payload = (await response.json()) as ShareResponse;
if (!response.ok || payload.error) {
setError(payload.error || 'Failed to create share link');
return;
}
setShareUrl(payload.data.shareUrl);
setHasPassword(!!payload.data.link?.hasPassword);
setPassword('');
} catch {
setError('Failed to create share link');
} finally {
setSubmitting(false);
}
};
const revokeShareLink = async () => {
if (!projectId || !videoId) return;
setSubmitting(true);
setError('');
try {
const response = await fetch(`/api/projects/${projectId}/videos/${videoId}/share`, {
method: 'DELETE',
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
setError(payload?.error || 'Failed to revoke share link');
return;
}
setShareUrl(null);
setHasPassword(false);
setPassword('');
} catch {
setError('Failed to revoke share link');
} finally {
setSubmitting(false);
}
};
const updateSecuritySettings = async (clearPassword = false) => {
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({
...(clearPassword ? { clearPassword: true } : {}),
...(!clearPassword ? { password } : {}),
}),
});
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 link security');
return;
}
const data = (payload as ShareResponse).data;
setShareUrl(data.shareUrl);
setHasPassword(!!data.link?.hasPassword);
setPassword('');
} catch {
setError('Failed to update link security');
} finally {
setSubmitting(false);
}
};
return (
<div className="min-h-[calc(100vh-4rem)] flex items-start justify-center py-12 px-4">
<div className="w-full max-w-xl space-y-6">
<Link
href={`/projects/${projectId}/videos/${videoId}`}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to Video
</Link>
<Card className="border-border/50 shadow-lg">
<CardHeader>
<CardTitle className="text-2xl">Share Video For Review</CardTitle>
<CardDescription>
Create a private link so reviewers can watch and comment on this single video.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<div className="flex items-center text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading link settings...
</div>
) : shareUrl ? (
<div className="space-y-3">
<div className="flex gap-2">
<Input value={shareUrl} readOnly className="font-mono text-sm h-11 bg-muted/50" />
<Button
variant={copied ? 'default' : 'outline'}
size="icon"
className="h-11 w-11 shrink-0"
onClick={copyLink}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<div className="flex gap-2">
<Button onClick={createShareLink} disabled={submitting} variant="outline">
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <RefreshCcw className="h-4 w-4 mr-2" />}
Regenerate Link
</Button>
<Button onClick={revokeShareLink} disabled={submitting} variant="destructive">
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <ShieldOff className="h-4 w-4 mr-2" />}
Revoke Link
</Button>
</div>
<div className="rounded-lg border p-3 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
{hasPassword ? <ShieldCheck className="h-4 w-4 text-green-600" /> : <Lock className="h-4 w-4" />}
Link password
</div>
<div className="flex gap-2">
<Input
type="password"
placeholder={hasPassword ? 'Enter new password to replace current one' : 'Set a password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={submitting}
/>
<Button
onClick={() => updateSecuritySettings(false)}
disabled={submitting || !password.trim()}
variant="outline"
>
Save
</Button>
{hasPassword && (
<Button
onClick={() => updateSecuritySettings(true)}
disabled={submitting}
variant="outline"
>
Remove
</Button>
)}
</div>
</div>
</div>
) : (
<Button onClick={createShareLink} disabled={submitting}>
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Link2 className="h-4 w-4 mr-2" />}
Create Review Link
</Button>
)}
<p className="text-xs text-muted-foreground">
This link allows guests to leave comments without an account. You can optionally protect it with a password.
</p>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,320 @@
import { randomBytes } from 'crypto';
import bcrypt from 'bcryptjs';
import { NextRequest } from 'next/server';
import { Prisma } from '@prisma/client';
import { auth, checkProjectAccess } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { db } from '@/lib/db';
import { rateLimit } from '@/lib/rate-limit';
import { MAX_SHARE_PASSWORD_LENGTH } from '@/lib/share-links';
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
async function requireShareManagementAccess(projectId: string, videoId: string, userId?: string) {
const video = await db.video.findFirst({
where: { id: videoId, projectId },
include: {
project: true,
},
});
if (!video) {
return { error: apiErrors.notFound('Video') as Response, video: null };
}
const access = await checkProjectAccess(video.project, userId);
if (!access.canEdit) {
return { error: apiErrors.forbidden('Access denied') as Response, video: null };
}
return { error: null, video };
}
function buildWatchUrl(request: NextRequest, videoId: string, token: string): string {
const url = new URL(`/watch/${videoId}`, request.nextUrl.origin);
url.searchParams.set('shareToken', token);
return url.toString();
}
function serializeShareLink(
request: NextRequest,
videoId: string,
link: {
id: string;
token: string;
permission: string;
allowGuests: boolean;
expiresAt: Date | null;
createdAt: Date;
passwordHash: string | null;
} | null
) {
if (!link) {
return { link: null, shareUrl: null };
}
return {
link: {
id: link.id,
token: link.token,
permission: link.permission,
allowGuests: link.allowGuests,
expiresAt: link.expiresAt,
createdAt: link.createdAt,
hasPassword: !!link.passwordHash,
},
shareUrl: buildWatchUrl(request, videoId, link.token),
};
}
// GET /api/projects/[projectId]/videos/[videoId]/share
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const { projectId, videoId } = await params;
const { error } = await requireShareManagementAccess(projectId, videoId, session.user.id);
if (error) return error;
const link = await db.shareLink.findFirst({
where: {
projectId,
videoId,
permission: 'COMMENT',
},
orderBy: { createdAt: 'desc' },
select: {
id: true,
token: true,
permission: true,
allowGuests: true,
expiresAt: true,
createdAt: true,
passwordHash: true,
},
});
const response = successResponse(serializeShareLink(request, videoId, link));
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error fetching video share link:', error);
return apiErrors.internalError('Failed to fetch video share link');
}
}
// POST /api/projects/[projectId]/videos/[videoId]/share
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const { projectId, videoId } = await params;
const { error } = await requireShareManagementAccess(projectId, videoId, session.user.id);
if (error) return error;
const body = await request.json().catch(() => ({}));
const allowGuests = typeof body?.allowGuests === 'boolean' ? body.allowGuests : true;
const password = typeof body?.password === 'string' ? body.password.trim() : '';
if (password.length > MAX_SHARE_PASSWORD_LENGTH) {
return apiErrors.badRequest(`Password must be ${MAX_SHARE_PASSWORD_LENGTH} characters or fewer`);
}
const passwordHash = password ? await bcrypt.hash(password, 12) : null;
const token = randomBytes(24).toString('base64url');
let link: {
id: string;
token: string;
permission: string;
allowGuests: boolean;
expiresAt: Date | null;
createdAt: Date;
passwordHash: string | null;
} | null = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
link = await db.$transaction(async (tx) => {
const existing = await tx.shareLink.findFirst({
where: {
projectId,
videoId,
permission: 'COMMENT',
},
orderBy: { createdAt: 'desc' },
select: { id: true },
});
if (existing) {
return tx.shareLink.update({
where: { id: existing.id },
data: {
token,
allowGuests,
passwordHash,
expiresAt: null,
},
select: {
id: true,
token: true,
permission: true,
allowGuests: true,
expiresAt: true,
createdAt: true,
passwordHash: true,
},
});
}
return tx.shareLink.create({
data: {
token,
projectId,
videoId,
permission: 'COMMENT',
allowGuests,
passwordHash,
},
select: {
id: true,
token: true,
permission: true,
allowGuests: true,
expiresAt: true,
createdAt: true,
passwordHash: true,
},
});
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
break;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2034' && attempt < 2) {
continue;
}
throw error;
}
}
if (!link) {
return apiErrors.internalError('Failed to create video share link');
}
const response = successResponse(serializeShareLink(request, videoId, link));
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error creating video share link:', error);
return apiErrors.internalError('Failed to create video share link');
}
}
// PATCH /api/projects/[projectId]/videos/[videoId]/share
export async function PATCH(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const { projectId, videoId } = await params;
const { error } = await requireShareManagementAccess(projectId, videoId, session.user.id);
if (error) return error;
const body = await request.json().catch(() => ({}));
const allowGuests = typeof body?.allowGuests === 'boolean' ? body.allowGuests : undefined;
const rawPassword = typeof body?.password === 'string' ? body.password : undefined;
const clearPassword = body?.clearPassword === true;
if (rawPassword !== undefined && rawPassword.length > MAX_SHARE_PASSWORD_LENGTH) {
return apiErrors.badRequest(`Password must be ${MAX_SHARE_PASSWORD_LENGTH} characters or fewer`);
}
const existing = await db.shareLink.findFirst({
where: {
projectId,
videoId,
permission: 'COMMENT',
},
orderBy: { createdAt: 'desc' },
});
if (!existing) {
return apiErrors.notFound('Share link');
}
let passwordHashUpdate: string | null | undefined;
if (clearPassword) {
passwordHashUpdate = null;
} else if (rawPassword !== undefined) {
const trimmedPassword = rawPassword.trim();
if (trimmedPassword.length > 0) {
passwordHashUpdate = await bcrypt.hash(trimmedPassword, 12);
}
}
const shouldRotateToken = clearPassword || rawPassword !== undefined;
const updated = await db.shareLink.update({
where: { id: existing.id },
data: {
...(allowGuests !== undefined ? { allowGuests } : {}),
...(passwordHashUpdate !== undefined ? { passwordHash: passwordHashUpdate } : {}),
...(shouldRotateToken ? { token: randomBytes(24).toString('base64url') } : {}),
},
select: {
id: true,
token: true,
permission: true,
allowGuests: true,
expiresAt: true,
createdAt: true,
passwordHash: true,
},
});
const response = successResponse(serializeShareLink(request, videoId, updated));
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error updating video share link:', error);
return apiErrors.internalError('Failed to update video share link');
}
}
// DELETE /api/projects/[projectId]/videos/[videoId]/share
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const { projectId, videoId } = await params;
const { error } = await requireShareManagementAccess(projectId, videoId, session.user.id);
if (error) return error;
await db.shareLink.deleteMany({
where: {
projectId,
videoId,
permission: 'COMMENT',
},
});
const response = successResponse({ message: 'Video share link revoked' });
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error deleting video share link:', error);
return apiErrors.internalError('Failed to delete video share link');
}
}
+30 -5
View File
@@ -4,6 +4,8 @@ import { auth } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit';
import { notifyProjectOwner } from '@/lib/notifications';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { validateShareLinkAccess } from '@/lib/share-links';
import { getShareSessionFromRequest } from '@/lib/share-session';
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;
@@ -36,6 +38,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
}
const project = version.video.project;
const shareSession = getShareSessionFromRequest(request, version.video.id);
const isOwner = session?.user?.id === project.ownerId;
const isMember = project.members.length > 0;
const isPublic = project.visibility === 'PUBLIC';
@@ -58,7 +61,17 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
isWorkspaceMember = !!wsMember || wsOwner?.ownerId === session.user.id;
}
if (!isOwner && !isMember && !isPublic && !isWorkspaceMember) {
const shareAccess = shareSession
? await validateShareLinkAccess({
token: shareSession.token,
projectId: project.id,
videoId: version.video.id,
requiredPermission: 'VIEW',
passwordVerified: shareSession.passwordVerified,
})
: { hasAccess: false, requiresPassword: false };
if (!isOwner && !isMember && !isPublic && !isWorkspaceMember && !shareAccess.hasAccess) {
return apiErrors.forbidden('Access denied');
}
@@ -144,7 +157,6 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
project: {
include: {
members: { where: { userId: session?.user?.id || '' } },
shareLinks: { where: { permission: 'COMMENT' } },
},
},
},
@@ -157,14 +169,14 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}
const project = version.video.project;
const shareSession = getShareSessionFromRequest(request, version.video.id);
const isOwner = session?.user?.id === project.ownerId;
const isMember = project.members.length > 0;
const hasCommentLink = project.shareLinks.length > 0;
const isPublic = project.visibility === 'PUBLIC';
// Check workspace membership for comment access
let isWorkspaceMember = false;
if (!isOwner && !isMember && !isPublic && !hasCommentLink && session?.user?.id) {
if (!isOwner && !isMember && !isPublic && session?.user?.id) {
const wsMember = await db.workspaceMember.findUnique({
where: {
workspaceId_userId: {
@@ -180,8 +192,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
isWorkspaceMember = !!wsMember || wsOwner?.ownerId === session.user.id;
}
const shareAccess = shareSession
? await validateShareLinkAccess({
token: shareSession.token,
projectId: project.id,
videoId: version.video.id,
requiredPermission: 'COMMENT',
passwordVerified: shareSession.passwordVerified,
})
: { hasAccess: false, canComment: false, allowGuests: false, requiresPassword: false };
// Check if user can comment
const canComment = isOwner || isMember || isPublic || hasCommentLink || isWorkspaceMember;
const canComment = isOwner || isMember || isPublic || isWorkspaceMember || shareAccess.canComment;
if (!canComment) {
return apiErrors.forbidden('Access denied');
}
@@ -215,6 +237,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// Guest comment validation
const isGuest = !session?.user?.id;
if (isGuest && shareAccess.hasAccess && !shareAccess.allowGuests) {
return apiErrors.forbidden('This share link requires sign in to comment');
}
if (isGuest && !guestName) {
return apiErrors.badRequest('Guest name is required for guest comments');
}
+58 -3
View File
@@ -3,6 +3,8 @@ import { db } from '@/lib/db';
import { auth, checkProjectAccess } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { rateLimit } from '@/lib/rate-limit';
import { validateShareLinkAccess } from '@/lib/share-links';
import { getShareSessionFromRequest } from '@/lib/share-session';
type RouteParams = { params: Promise<{ videoId: string }> };
@@ -31,9 +33,50 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
comments: {
orderBy: { timestamp: 'asc' },
where: { parentId: null },
include: {
select: {
id: true,
content: true,
timestamp: true,
timestampEnd: true,
createdAt: true,
updatedAt: true,
isResolved: true,
resolvedAt: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
annotationData: true,
parentId: true,
authorId: true,
tagId: true,
versionId: true,
guestName: true,
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
replies: {
orderBy: { createdAt: 'asc' },
select: {
id: true,
content: true,
timestamp: true,
timestampEnd: true,
createdAt: true,
updatedAt: true,
isResolved: true,
resolvedAt: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
annotationData: true,
parentId: true,
authorId: true,
tagId: true,
versionId: true,
guestName: true,
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
},
},
},
},
_count: { select: { comments: true } },
@@ -57,13 +100,25 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// Check access including workspace membership
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: 'VIEW',
passwordVerified: shareSession.passwordVerified,
})
: { hasAccess: false, canComment: false, allowGuests: false, requiresPassword: false };
if (!access.hasAccess) {
if (!access.hasAccess && !shareAccess.hasAccess) {
return apiErrors.forbidden('Access denied');
}
// Include auth context so the client knows if the viewer is a guest
const { project, ...videoData } = video;
const canCommentWithMembership = access.hasAccess;
const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
const response = successResponse({
...videoData,
projectId: video.projectId,
@@ -75,7 +130,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
isAuthenticated: !!session?.user?.id,
currentUserId: session?.user?.id || null,
currentUserName: session?.user?.name || null,
canComment: access.hasAccess,
canComment: canCommentWithMembership || canCommentWithShareLink,
});
return withCacheControl(response, 'private, no-cache');
+1 -1
View File
@@ -32,7 +32,7 @@ export const metadata: Metadata = {
creator: seoConfig.name,
publisher: seoConfig.name,
category: "technology",
referrer: "origin-when-cross-origin",
referrer: "no-referrer",
alternates: {
canonical: "/",
},
+20 -13
View File
@@ -1,16 +1,23 @@
'use client';
import { useParams } from 'next/navigation';
import { VideoPageContent } from '@/components/video-page-content';
import { ShareLinkBootstrap } from '@/components/share-link-bootstrap';
import { ShareLinkUnlock } from '@/components/share-link-unlock';
export default function WatchPage() {
const params = useParams();
const videoId = params.videoId as string;
return (
<VideoPageContent
mode="watch"
videoId={videoId}
/>
);
interface WatchPageProps {
params: Promise<{ videoId: string }>;
searchParams: Promise<{ shareToken?: string; unlock?: string }>;
}
export default async function WatchPage({ params, searchParams }: WatchPageProps) {
const { videoId } = await params;
const { shareToken, unlock } = await searchParams;
if (typeof shareToken === 'string' && shareToken.length > 0) {
return <ShareLinkBootstrap videoId={videoId} shareToken={shareToken} />;
}
if (unlock === '1') {
return <ShareLinkUnlock videoId={videoId} />;
}
return <VideoPageContent mode="watch" videoId={videoId} />;
}
+144
View File
@@ -0,0 +1,144 @@
import { createHash } from 'crypto';
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { checkRateLimit, getClientIp, rateLimit, rateLimitHeaders } from '@/lib/rate-limit';
import { MAX_SHARE_PASSWORD_LENGTH, validateShareLinkAccess } from '@/lib/share-links';
import {
createPendingShareValue,
createShareSessionValue,
getPendingShareCookieName,
getPendingShareTokenFromRequest,
getShareSessionCookieName,
pendingShareCookieConfig,
shareSessionCookieConfig,
} from '@/lib/share-session';
type RouteParams = { params: Promise<{ videoId: string }> };
async function findVideo(videoId: string) {
return db.video.findUnique({
where: { id: videoId },
select: { id: true, projectId: true },
});
}
function baseCookieOptions(maxAge: number) {
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge,
};
}
function validateSameOriginRequest(request: NextRequest): NextResponse | null {
const origin = request.headers.get('origin');
if (!origin) {
return NextResponse.json({ error: 'Missing Origin header' }, { status: 403 });
}
if (origin !== request.nextUrl.origin) {
return NextResponse.json({ error: 'Cross-origin requests are not allowed' }, { status: 403 });
}
return null;
}
export async function GET(request: NextRequest, { params }: RouteParams) {
const { videoId } = await params;
const cleanWatchUrl = new URL(`/watch/${videoId}`, request.nextUrl.origin);
const legacyShareToken = request.nextUrl.searchParams.get('shareToken');
// Keep GET route for backwards compatibility, but never establish session from GET.
if (legacyShareToken) {
cleanWatchUrl.searchParams.set('shareToken', legacyShareToken);
}
return NextResponse.redirect(cleanWatchUrl);
}
export async function POST(request: NextRequest, { params }: RouteParams) {
const originError = validateSameOriginRequest(request);
if (originError) return originError;
const globalLimit = await rateLimit(request, 'share-unlock');
if (globalLimit) return globalLimit;
const { videoId } = await params;
const video = await findVideo(videoId);
if (!video) {
return NextResponse.json({ error: 'Video not found' }, { status: 404 });
}
const body = await request.json().catch(() => ({}));
const password = typeof body?.password === 'string' ? body.password : '';
const shareTokenFromBody = typeof body?.shareToken === 'string' ? body.shareToken.trim() : '';
if (password.length > MAX_SHARE_PASSWORD_LENGTH) {
return NextResponse.json({ error: 'Password is too long' }, { status: 400 });
}
const pendingToken = getPendingShareTokenFromRequest(request, video.id);
const tokenForAttempt = shareTokenFromBody || pendingToken;
if (!tokenForAttempt) {
return NextResponse.json({ error: 'Share session expired. Open the share link again.' }, { status: 401 });
}
// Additional throttle bound to token+IP to reduce password guessing against one link.
const ip = getClientIp(request);
const tokenFingerprint = createHash('sha256').update(tokenForAttempt).digest('hex').slice(0, 24);
const tokenScopedLimit = await checkRateLimit(
`${ip}:share-unlock:${tokenFingerprint}`,
'share-unlock-token',
{ windowMs: 15 * 60 * 1000, maxRequests: 8 }
);
if (!tokenScopedLimit.allowed) {
return NextResponse.json(
{ error: 'Too many attempts. Please try again later.' },
{
status: 429,
headers: rateLimitHeaders(tokenScopedLimit, 8),
}
);
}
const access = await validateShareLinkAccess({
token: tokenForAttempt,
projectId: video.projectId,
videoId: video.id,
requiredPermission: 'VIEW',
presentedPassword: password,
});
if (access.requiresPassword && shareTokenFromBody) {
const response = NextResponse.json({ requiresPassword: true }, { status: 401 });
response.cookies.set(
getPendingShareCookieName(video.id),
createPendingShareValue(tokenForAttempt, video.id),
baseCookieOptions(pendingShareCookieConfig.maxAge)
);
response.cookies.delete(getShareSessionCookieName(video.id));
return response;
}
if (!access.hasAccess) {
const response = NextResponse.json(
{ error: access.requiresPassword ? 'Invalid password' : 'Share session is invalid' },
{ status: 401 }
);
response.cookies.delete(getShareSessionCookieName(video.id));
return response;
}
const response = NextResponse.json({ success: true });
response.cookies.set(
getShareSessionCookieName(video.id),
createShareSessionValue(tokenForAttempt, video.id, !!access.link?.passwordHash),
baseCookieOptions(shareSessionCookieConfig.maxAge)
);
response.cookies.delete(getPendingShareCookieName(video.id));
return response;
}