diff --git a/AGENTS.md b/AGENTS.md index 6b6ffd1..f71b199 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ bun run dev # Start Next.js dev server bun run build # Build for production (runs typecheck first) bun run typecheck # Run TypeScript type checking only bun run lint # Run ESLint +bun run check # typecheck + lint you should run this one bun test # Run all tests bun test path/to/test.ts # Run specific test file ``` @@ -38,6 +39,7 @@ bun run db:setup # Full DB setup: generate + push + extras - **Always use bun** - Never use npm or pnpm. - Pre-build runs typecheck automatically via `prebuild` script. - Post-install runs `prisma generate` automatically. +- Do not run dev server. Assume already running. --- @@ -121,21 +123,6 @@ async function createProject(data: CreateProjectInput) { } ``` -### Validation - -- Use Zod for runtime validation -- Create reusable validation schemas -- Validate at API boundaries (server actions, API routes) - -```typescript -import { z } from 'zod' -export const createProjectSchema = z.object({ - name: z.string().min(1).max(100), - description: z.string().max(500).optional(), - visibility: z.enum(['PUBLIC', 'PRIVATE']), -}) -``` - ### Database (Prisma) - Use Prisma client from `@/lib/db` @@ -180,7 +167,6 @@ prisma/ # Database schema ## Additional Guidelines -1. **Run typecheck before committing** - `bun run typecheck` must pass -2. **Run lint before committing** - `bun run lint` must pass -3. **Environment variables** - Copy `.env.example` to `.env` -4. **Database changes** - After modifying Prisma schema, run `bun run db:generate` +1. **Run check before committing** - `bun run check` must pass +2. **Environment variables** - Copy `.env.example` to `.env` +3. **Database changes** - After modifying Prisma schema, run `bun run db:generate` diff --git a/app/(dashboard)/projects/[projectId]/videos/[videoId]/share/page.tsx b/app/(dashboard)/projects/[projectId]/videos/[videoId]/share/page.tsx new file mode 100644 index 0000000..f65f16c --- /dev/null +++ b/app/(dashboard)/projects/[projectId]/videos/[videoId]/share/page.tsx @@ -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(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 ( +
+
+ + + Back to Video + + + + + Share Video For Review + + Create a private link so reviewers can watch and comment on this single video. + + + + {loading ? ( +
+ + Loading link settings... +
+ ) : shareUrl ? ( +
+
+ + +
+
+ + +
+
+
+ {hasPassword ? : } + Link password +
+
+ setPassword(e.target.value)} + disabled={submitting} + /> + + {hasPassword && ( + + )} +
+
+
+ ) : ( + + )} + +

+ This link allows guests to leave comments without an account. You can optionally protect it with a password. +

+ + {error && ( +

{error}

+ )} +
+
+
+
+ ); +} diff --git a/app/api/projects/[projectId]/videos/[videoId]/share/route.ts b/app/api/projects/[projectId]/videos/[videoId]/share/route.ts new file mode 100644 index 0000000..4fb7120 --- /dev/null +++ b/app/api/projects/[projectId]/videos/[videoId]/share/route.ts @@ -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'); + } +} diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts index 6ade886..c9b6044 100644 --- a/app/api/versions/[versionId]/comments/route.ts +++ b/app/api/versions/[versionId]/comments/route.ts @@ -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'); } diff --git a/app/api/watch/[videoId]/route.ts b/app/api/watch/[videoId]/route.ts index f1e9f90..a499d8e 100644 --- a/app/api/watch/[videoId]/route.ts +++ b/app/api/watch/[videoId]/route.ts @@ -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'); diff --git a/app/layout.tsx b/app/layout.tsx index e9261cb..a2d3142 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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: "/", }, diff --git a/app/watch/[videoId]/page.tsx b/app/watch/[videoId]/page.tsx index a909daa..55cbdac 100644 --- a/app/watch/[videoId]/page.tsx +++ b/app/watch/[videoId]/page.tsx @@ -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 ( - - ); +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 ; + } + + if (unlock === '1') { + return ; + } + + return ; } diff --git a/app/watch/[videoId]/session/route.ts b/app/watch/[videoId]/session/route.ts new file mode 100644 index 0000000..219cf92 --- /dev/null +++ b/app/watch/[videoId]/session/route.ts @@ -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; +} diff --git a/components/share-link-bootstrap.tsx b/components/share-link-bootstrap.tsx new file mode 100644 index 0000000..c6f012a --- /dev/null +++ b/components/share-link-bootstrap.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Loader2 } from 'lucide-react'; + +interface ShareLinkBootstrapProps { + videoId: string; + shareToken: string; +} + +export function ShareLinkBootstrap({ videoId, shareToken }: ShareLinkBootstrapProps) { + const router = useRouter(); + const [error, setError] = useState(''); + + useEffect(() => { + let isCancelled = false; + + async function establishSession() { + try { + const response = await fetch(`/watch/${videoId}/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ shareToken }), + }); + + if (isCancelled) return; + + if (response.ok) { + router.replace(`/watch/${videoId}`); + router.refresh(); + return; + } + + const payload = (await response.json().catch(() => null)) as { requiresPassword?: boolean; error?: string } | null; + if (payload?.requiresPassword) { + router.replace(`/watch/${videoId}?unlock=1`); + return; + } + + setError(payload?.error || 'Invalid or expired share link'); + } catch { + if (!isCancelled) { + setError('Failed to open share link'); + } + } + } + + void establishSession(); + return () => { + isCancelled = true; + }; + }, [router, shareToken, videoId]); + + return ( +
+
+
+ +
+

Opening shared video

+

{error || 'Verifying link access...'}

+
+
+ ); +} diff --git a/components/share-link-unlock.tsx b/components/share-link-unlock.tsx new file mode 100644 index 0000000..0f418af --- /dev/null +++ b/components/share-link-unlock.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Lock, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +interface ShareLinkUnlockProps { + videoId: string; +} + +export function ShareLinkUnlock({ videoId }: ShareLinkUnlockProps) { + const router = useRouter(); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const submitPassword = async () => { + if (!password.trim()) return; + + setIsSubmitting(true); + setError(''); + + try { + const response = await fetch(`/watch/${videoId}/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { error?: string } | null; + setError(payload?.error || 'Invalid password'); + return; + } + + router.replace(`/watch/${videoId}`); + router.refresh(); + } catch { + setError('Failed to verify password'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+
+
+ +
+

Password Required

+

Enter the password to continue to the shared video.

+
+ +
+ setPassword(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + void submitPassword(); + } + }} + autoFocus + /> + + + + {error &&

{error}

} +
+ +

+ Or sign in with your account +

+
+
+ ); +} diff --git a/components/video-card.tsx b/components/video-card.tsx index 4c176ef..57b4599 100644 --- a/components/video-card.tsx +++ b/components/video-card.tsx @@ -12,6 +12,10 @@ import { Link as LinkIcon, AlertCircle, CheckCircle2, + Share2, + Pencil, + Plus, + Trash2, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; @@ -245,16 +249,25 @@ export function VideoCard({ video, projectId, onDeleted }: VideoCardProps) { + + + + Share + + setShowEditDialog(true)}> + Edit setShowVersionDialog(true)}> + Add Version setShowDeleteDialog(true)} > + Delete diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index cedaa95..dc215f2 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -40,6 +40,7 @@ import { Image as ImageIcon, Download, FileText, + Share2, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -386,6 +387,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi }, [mode]); const isGuest = video ? !video.isAuthenticated : false; + const canInitializePlayer = mode !== 'watch' || !isGuest || guestNameConfirmed; const [showVersionDialog, setShowVersionDialog] = useState(false); const [newVersionUrl, setNewVersionUrl] = useState(''); @@ -505,7 +507,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const apiBasePath = mode === 'dashboard' ? `/api/projects/${propProjectId}/videos/${videoId}` - : `/api/watch/${videoId}`; + : `/api/watch/${videoId}?includeComments=true`; useEffect(() => { async function fetchVideo() { @@ -641,7 +643,10 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const embedUrl = useMemo(() => { if (!activeVersion) return ''; if (activeVersion.providerId === 'youtube') { - return `https://www.youtube.com/embed/${activeVersion.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`; + const base = `https://www.youtube.com/embed/${activeVersion.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`; + if (typeof window === 'undefined') return base; + const origin = window.location.origin; + return `${base}&origin=${encodeURIComponent(origin)}`; } if (activeVersion.providerId === 'bunny') { return `https://${BUNNY_PULL_ZONE_HOSTNAME}/${activeVersion.videoId}/playlist.m3u8`; @@ -703,6 +708,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi }, [isApiLoaded]); useEffect(() => { + if (!canInitializePlayer) return; if (!activeProviderId) return; const isYoutube = activeProviderId === 'youtube'; const isBunny = activeProviderId === 'bunny'; @@ -1041,7 +1047,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi bunnyRetryTimerRef.current = null; } }; - }, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, video?.isAuthenticated, videoId]); + }, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, video?.isAuthenticated, videoId, canInitializePlayer]); // Save detected duration to DB if the version doesn't have one stored useEffect(() => { @@ -2884,6 +2890,12 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi {mode === 'dashboard' && ( <> +
@@ -3138,6 +3150,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi width="100%" height="100%" className="absolute inset-0 w-full h-full border-0" + referrerPolicy="origin-when-cross-origin" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen /> diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index 8f5732e..ad6070f 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -17,6 +17,8 @@ export const RATE_LIMIT_CONFIGS: Record = { // Auth — strict to prevent brute force / credential stuffing register: { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour login: { windowMs: 15 * 60 * 1000, maxRequests: 10 }, // 10 per 15 min + 'share-unlock': { windowMs: 15 * 60 * 1000, maxRequests: 20 }, // 20 per 15 min per IP + 'share-unlock-token': { windowMs: 15 * 60 * 1000, maxRequests: 8 }, // 8 per 15 min per IP+token // Content creation — moderate limits comment: { windowMs: 60 * 1000, maxRequests: 15 }, // 15 per minute diff --git a/lib/share-links.ts b/lib/share-links.ts new file mode 100644 index 0000000..8afe822 --- /dev/null +++ b/lib/share-links.ts @@ -0,0 +1,84 @@ +import bcrypt from 'bcryptjs'; +import type { ShareLink, SharePermission } from '@prisma/client'; +import { db } from '@/lib/db'; + +export const MAX_SHARE_PASSWORD_LENGTH = 128; + +interface ValidateShareLinkParams { + token: string; + projectId: string; + videoId?: string; + requiredPermission?: SharePermission; + presentedPassword?: string; + passwordVerified?: boolean; +} + +export interface ShareLinkAccessResult { + hasAccess: boolean; + canComment: boolean; + allowGuests: boolean; + requiresPassword: boolean; + link: ShareLink | null; +} + +function hasRequiredPermission( + actual: SharePermission, + required: SharePermission +): boolean { + if (required === 'VIEW') return actual === 'VIEW' || actual === 'COMMENT'; + return actual === 'COMMENT'; +} + +function isLinkExpired(link: ShareLink): boolean { + if (!link.expiresAt) return false; + return link.expiresAt.getTime() <= Date.now(); +} + +export async function validateShareLinkAccess({ + token, + projectId, + videoId, + requiredPermission = 'VIEW', + presentedPassword, + passwordVerified = false, +}: ValidateShareLinkParams): Promise { + const link = await db.shareLink.findUnique({ + where: { token }, + }); + + if (!link) { + return { hasAccess: false, canComment: false, allowGuests: false, requiresPassword: false, link: null }; + } + + const projectMatches = link.projectId === projectId; + // When a specific video is requested, require the link to be scoped to that exact video. + const videoMatches = videoId === undefined ? link.videoId === null : link.videoId === videoId; + const permissionMatches = hasRequiredPermission(link.permission, requiredPermission); + + if (!projectMatches || !videoMatches || !permissionMatches || isLinkExpired(link)) { + return { hasAccess: false, canComment: false, allowGuests: false, requiresPassword: false, link }; + } + + if (link.passwordHash && !passwordVerified) { + if (!presentedPassword) { + return { hasAccess: false, canComment: false, allowGuests: false, requiresPassword: true, link }; + } + + if (presentedPassword.length > MAX_SHARE_PASSWORD_LENGTH) { + return { hasAccess: false, canComment: false, allowGuests: false, requiresPassword: true, link }; + } + + const isPasswordValid = await bcrypt.compare(presentedPassword, link.passwordHash); + if (!isPasswordValid) { + return { hasAccess: false, canComment: false, allowGuests: false, requiresPassword: true, link }; + } + } + + return { + hasAccess: true, + canComment: link.permission === 'COMMENT', + allowGuests: link.allowGuests, + requiresPassword: false, + link, + }; +} diff --git a/lib/share-session.ts b/lib/share-session.ts new file mode 100644 index 0000000..628014b --- /dev/null +++ b/lib/share-session.ts @@ -0,0 +1,128 @@ +import { createHmac, timingSafeEqual } from 'crypto'; +import type { NextRequest } from 'next/server'; + +const DEFAULT_SESSION_TTL_SECONDS = 60 * 60 * 24 * 14; // 14 days +const PENDING_TTL_SECONDS = 60 * 10; // 10 minutes + +interface ShareSessionPayload { + token: string; + videoId: string; + exp: number; + passwordVerified: boolean; +} + +interface PendingSharePayload { + token: string; + videoId: string; + exp: number; +} + +function getSessionSecret(): string { + const secret = process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET; + if (!secret) { + throw new Error('Missing AUTH_SECRET/NEXTAUTH_SECRET for share session signing'); + } + return secret; +} + +function sign(data: string): string { + return createHmac('sha256', getSessionSecret()).update(data).digest('base64url'); +} + +function createSignedValue(payload: object): string { + const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); + const signature = sign(encodedPayload); + return `${encodedPayload}.${signature}`; +} + +function parseSignedValue(value: string): T | null { + const [encodedPayload, signature] = value.split('.'); + if (!encodedPayload || !signature) return null; + + const expectedSignature = sign(encodedPayload); + const actualBytes = Buffer.from(signature); + const expectedBytes = Buffer.from(expectedSignature); + if (actualBytes.length !== expectedBytes.length) return null; + if (!timingSafeEqual(actualBytes, expectedBytes)) return null; + + try { + return JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')) as T; + } catch { + return null; + } +} + +export function getShareSessionCookieName(videoId: string): string { + return `openframe_share_session_${videoId}`; +} + +export function getPendingShareCookieName(videoId: string): string { + return `openframe_share_pending_${videoId}`; +} + +export function createShareSessionValue( + token: string, + videoId: string, + passwordVerified: boolean, + ttlSeconds = DEFAULT_SESSION_TTL_SECONDS +): string { + return createSignedValue({ + token, + videoId, + passwordVerified, + exp: Math.floor(Date.now() / 1000) + ttlSeconds, + } satisfies ShareSessionPayload); +} + +export function createPendingShareValue(token: string, videoId: string, ttlSeconds = PENDING_TTL_SECONDS): string { + return createSignedValue({ + token, + videoId, + exp: Math.floor(Date.now() / 1000) + ttlSeconds, + } satisfies PendingSharePayload); +} + +export function getShareSessionFromRequest( + request: NextRequest, + videoId: string +): { token: string; passwordVerified: boolean } | null { + const cookieName = getShareSessionCookieName(videoId); + const cookieValue = request.cookies.get(cookieName)?.value; + if (!cookieValue) return null; + + const payload = parseSignedValue(cookieValue); + if (!payload || payload.videoId !== videoId) { + return null; + } + + if (payload.exp <= Math.floor(Date.now() / 1000)) { + return null; + } + + return { token: payload.token, passwordVerified: payload.passwordVerified }; +} + +export function getPendingShareTokenFromRequest(request: NextRequest, videoId: string): string | null { + const cookieName = getPendingShareCookieName(videoId); + const cookieValue = request.cookies.get(cookieName)?.value; + if (!cookieValue) return null; + + const payload = parseSignedValue(cookieValue); + if (!payload || payload.videoId !== videoId) { + return null; + } + + if (payload.exp <= Math.floor(Date.now() / 1000)) { + return null; + } + + return payload.token; +} + +export const shareSessionCookieConfig = { + maxAge: DEFAULT_SESSION_TTL_SECONDS, +} as const; + +export const pendingShareCookieConfig = { + maxAge: PENDING_TTL_SECONDS, +} as const; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 36bc416..aeb23b3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -233,6 +233,7 @@ model Video { // Relations versions VideoVersion[] + shareLinks ShareLink[] @@index([projectId]) @@map("videos") @@ -363,14 +364,14 @@ model ShareLink { // What is being shared projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + videoId String? + video Video? @relation(fields: [videoId], references: [id], onDelete: Cascade) // Permissions permission SharePermission @default(VIEW) // Optional restrictions expiresAt DateTime? // Link expiration - maxUses Int? // Maximum number of uses - useCount Int @default(0) passwordHash String? // Bcrypt hash of optional password protection // Settings @@ -380,6 +381,9 @@ model ShareLink { createdAt DateTime @default(now()) @@index([projectId]) + @@index([videoId]) + @@index([projectId, videoId]) + @@unique([projectId, videoId, permission]) @@index([token]) @@index([token, expiresAt]) @@map("share_links")