mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(share): add video-level secure share links with password unlock and session-based watch/comment access
This commit is contained in:
@@ -19,6 +19,7 @@ bun run dev # Start Next.js dev server
|
|||||||
bun run build # Build for production (runs typecheck first)
|
bun run build # Build for production (runs typecheck first)
|
||||||
bun run typecheck # Run TypeScript type checking only
|
bun run typecheck # Run TypeScript type checking only
|
||||||
bun run lint # Run ESLint
|
bun run lint # Run ESLint
|
||||||
|
bun run check # typecheck + lint you should run this one
|
||||||
bun test # Run all tests
|
bun test # Run all tests
|
||||||
bun test path/to/test.ts # Run specific test file
|
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.
|
- **Always use bun** - Never use npm or pnpm.
|
||||||
- Pre-build runs typecheck automatically via `prebuild` script.
|
- Pre-build runs typecheck automatically via `prebuild` script.
|
||||||
- Post-install runs `prisma generate` automatically.
|
- 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)
|
### Database (Prisma)
|
||||||
|
|
||||||
- Use Prisma client from `@/lib/db`
|
- Use Prisma client from `@/lib/db`
|
||||||
@@ -180,7 +167,6 @@ prisma/ # Database schema
|
|||||||
|
|
||||||
## Additional Guidelines
|
## Additional Guidelines
|
||||||
|
|
||||||
1. **Run typecheck before committing** - `bun run typecheck` must pass
|
1. **Run check before committing** - `bun run check` must pass
|
||||||
2. **Run lint before committing** - `bun run lint` must pass
|
2. **Environment variables** - Copy `.env.example` to `.env`
|
||||||
3. **Environment variables** - Copy `.env.example` to `.env`
|
3. **Database changes** - After modifying Prisma schema, run `bun run db:generate`
|
||||||
4. **Database changes** - After modifying Prisma schema, run `bun run db:generate`
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { auth } from '@/lib/auth';
|
|||||||
import { rateLimit } from '@/lib/rate-limit';
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
import { notifyProjectOwner } from '@/lib/notifications';
|
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 { getShareSessionFromRequest } from '@/lib/share-session';
|
||||||
|
|
||||||
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;
|
||||||
@@ -36,6 +38,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const project = version.video.project;
|
const project = version.video.project;
|
||||||
|
const shareSession = getShareSessionFromRequest(request, version.video.id);
|
||||||
const isOwner = session?.user?.id === project.ownerId;
|
const isOwner = session?.user?.id === project.ownerId;
|
||||||
const isMember = project.members.length > 0;
|
const isMember = project.members.length > 0;
|
||||||
const isPublic = project.visibility === 'PUBLIC';
|
const isPublic = project.visibility === 'PUBLIC';
|
||||||
@@ -58,7 +61,17 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
isWorkspaceMember = !!wsMember || wsOwner?.ownerId === session.user.id;
|
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');
|
return apiErrors.forbidden('Access denied');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +157,6 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
project: {
|
project: {
|
||||||
include: {
|
include: {
|
||||||
members: { where: { userId: session?.user?.id || '' } },
|
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 project = version.video.project;
|
||||||
|
const shareSession = getShareSessionFromRequest(request, version.video.id);
|
||||||
const isOwner = session?.user?.id === project.ownerId;
|
const isOwner = session?.user?.id === project.ownerId;
|
||||||
const isMember = project.members.length > 0;
|
const isMember = project.members.length > 0;
|
||||||
const hasCommentLink = project.shareLinks.length > 0;
|
|
||||||
const isPublic = project.visibility === 'PUBLIC';
|
const isPublic = project.visibility === 'PUBLIC';
|
||||||
|
|
||||||
// Check workspace membership for comment access
|
// Check workspace membership for comment access
|
||||||
let isWorkspaceMember = false;
|
let isWorkspaceMember = false;
|
||||||
if (!isOwner && !isMember && !isPublic && !hasCommentLink && session?.user?.id) {
|
if (!isOwner && !isMember && !isPublic && session?.user?.id) {
|
||||||
const wsMember = await db.workspaceMember.findUnique({
|
const wsMember = await db.workspaceMember.findUnique({
|
||||||
where: {
|
where: {
|
||||||
workspaceId_userId: {
|
workspaceId_userId: {
|
||||||
@@ -180,8 +192,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
isWorkspaceMember = !!wsMember || wsOwner?.ownerId === session.user.id;
|
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
|
// Check if user can comment
|
||||||
const canComment = isOwner || isMember || isPublic || hasCommentLink || isWorkspaceMember;
|
const canComment = isOwner || isMember || isPublic || isWorkspaceMember || shareAccess.canComment;
|
||||||
if (!canComment) {
|
if (!canComment) {
|
||||||
return apiErrors.forbidden('Access denied');
|
return apiErrors.forbidden('Access denied');
|
||||||
}
|
}
|
||||||
@@ -215,6 +237,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
// Guest comment validation
|
// Guest comment validation
|
||||||
const isGuest = !session?.user?.id;
|
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) {
|
if (isGuest && !guestName) {
|
||||||
return apiErrors.badRequest('Guest name is required for guest comments');
|
return apiErrors.badRequest('Guest name is required for guest comments');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ 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';
|
||||||
|
|
||||||
type RouteParams = { params: Promise<{ videoId: string }> };
|
type RouteParams = { params: Promise<{ videoId: string }> };
|
||||||
|
|
||||||
@@ -31,9 +33,50 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
comments: {
|
comments: {
|
||||||
orderBy: { timestamp: 'asc' },
|
orderBy: { timestamp: 'asc' },
|
||||||
where: { parentId: null },
|
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 } },
|
author: { select: { id: true, name: true, image: true } },
|
||||||
tag: { select: { id: true, name: true, color: 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 } },
|
_count: { select: { comments: true } },
|
||||||
@@ -57,13 +100,25 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
// Check access including workspace membership
|
// Check access including workspace membership
|
||||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
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');
|
return apiErrors.forbidden('Access denied');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 canCommentWithMembership = access.hasAccess;
|
||||||
|
const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
|
||||||
const response = successResponse({
|
const response = successResponse({
|
||||||
...videoData,
|
...videoData,
|
||||||
projectId: video.projectId,
|
projectId: video.projectId,
|
||||||
@@ -75,7 +130,7 @@ 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,
|
||||||
canComment: access.hasAccess,
|
canComment: canCommentWithMembership || canCommentWithShareLink,
|
||||||
});
|
});
|
||||||
|
|
||||||
return withCacheControl(response, 'private, no-cache');
|
return withCacheControl(response, 'private, no-cache');
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ export const metadata: Metadata = {
|
|||||||
creator: seoConfig.name,
|
creator: seoConfig.name,
|
||||||
publisher: seoConfig.name,
|
publisher: seoConfig.name,
|
||||||
category: "technology",
|
category: "technology",
|
||||||
referrer: "origin-when-cross-origin",
|
referrer: "no-referrer",
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: "/",
|
canonical: "/",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useParams } from 'next/navigation';
|
|
||||||
import { VideoPageContent } from '@/components/video-page-content';
|
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() {
|
interface WatchPageProps {
|
||||||
const params = useParams();
|
params: Promise<{ videoId: string }>;
|
||||||
const videoId = params.videoId as string;
|
searchParams: Promise<{ shareToken?: string; unlock?: string }>;
|
||||||
|
}
|
||||||
return (
|
|
||||||
<VideoPageContent
|
export default async function WatchPage({ params, searchParams }: WatchPageProps) {
|
||||||
mode="watch"
|
const { videoId } = await params;
|
||||||
videoId={videoId}
|
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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="h-screen flex items-center justify-center bg-background px-4">
|
||||||
|
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-sm text-center space-y-3">
|
||||||
|
<div className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-lg font-semibold">Opening shared video</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{error || 'Verifying link access...'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="h-screen flex items-center justify-center bg-background px-4">
|
||||||
|
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-sm">
|
||||||
|
<div className="text-center mb-5">
|
||||||
|
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 mb-3">
|
||||||
|
<Lock className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold">Password Required</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Enter the password to continue to the shared video.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
void submitPassword();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button className="w-full" disabled={isSubmitting} onClick={() => void submitPassword()}>
|
||||||
|
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Continue'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground text-center mt-4">
|
||||||
|
Or <Link href="/login" className="underline hover:text-foreground">sign in</Link> with your account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ import {
|
|||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Share2,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
@@ -245,16 +249,25 @@ export function VideoCard({ video, projectId, onDeleted }: VideoCardProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/projects/${projectId}/videos/${video.id}/share`}>
|
||||||
|
<Share2 className="mr-2 h-4 w-4" />
|
||||||
|
Share
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => setShowEditDialog(true)}>
|
<DropdownMenuItem onSelect={() => setShowEditDialog(true)}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => setShowVersionDialog(true)}>
|
<DropdownMenuItem onSelect={() => setShowVersionDialog(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Version
|
Add Version
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
onSelect={() => setShowDeleteDialog(true)}
|
onSelect={() => setShowDeleteDialog(true)}
|
||||||
>
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Download,
|
Download,
|
||||||
FileText,
|
FileText,
|
||||||
|
Share2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -386,6 +387,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
}, [mode]);
|
}, [mode]);
|
||||||
|
|
||||||
const isGuest = video ? !video.isAuthenticated : false;
|
const isGuest = video ? !video.isAuthenticated : false;
|
||||||
|
const canInitializePlayer = mode !== 'watch' || !isGuest || guestNameConfirmed;
|
||||||
|
|
||||||
const [showVersionDialog, setShowVersionDialog] = useState(false);
|
const [showVersionDialog, setShowVersionDialog] = useState(false);
|
||||||
const [newVersionUrl, setNewVersionUrl] = useState('');
|
const [newVersionUrl, setNewVersionUrl] = useState('');
|
||||||
@@ -505,7 +507,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
|
|
||||||
const apiBasePath = mode === 'dashboard'
|
const apiBasePath = mode === 'dashboard'
|
||||||
? `/api/projects/${propProjectId}/videos/${videoId}`
|
? `/api/projects/${propProjectId}/videos/${videoId}`
|
||||||
: `/api/watch/${videoId}`;
|
: `/api/watch/${videoId}?includeComments=true`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchVideo() {
|
async function fetchVideo() {
|
||||||
@@ -641,7 +643,10 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
const embedUrl = useMemo(() => {
|
const embedUrl = useMemo(() => {
|
||||||
if (!activeVersion) return '';
|
if (!activeVersion) return '';
|
||||||
if (activeVersion.providerId === 'youtube') {
|
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') {
|
if (activeVersion.providerId === 'bunny') {
|
||||||
return `https://${BUNNY_PULL_ZONE_HOSTNAME}/${activeVersion.videoId}/playlist.m3u8`;
|
return `https://${BUNNY_PULL_ZONE_HOSTNAME}/${activeVersion.videoId}/playlist.m3u8`;
|
||||||
@@ -703,6 +708,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
}, [isApiLoaded]);
|
}, [isApiLoaded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!canInitializePlayer) return;
|
||||||
if (!activeProviderId) return;
|
if (!activeProviderId) return;
|
||||||
const isYoutube = activeProviderId === 'youtube';
|
const isYoutube = activeProviderId === 'youtube';
|
||||||
const isBunny = activeProviderId === 'bunny';
|
const isBunny = activeProviderId === 'bunny';
|
||||||
@@ -1041,7 +1047,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
bunnyRetryTimerRef.current = null;
|
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
|
// Save detected duration to DB if the version doesn't have one stored
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2884,6 +2890,12 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
|
|
||||||
{mode === 'dashboard' && (
|
{mode === 'dashboard' && (
|
||||||
<>
|
<>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link href={`/projects/${projectId}/videos/${videoId}/share`}>
|
||||||
|
<Share2 className="h-4 w-4 mr-1" />
|
||||||
|
Share Video
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
<div className="hidden sm:flex items-center gap-2">
|
<div className="hidden sm:flex items-center gap-2">
|
||||||
<Dialog open={showVersionDialog} onOpenChange={setShowVersionDialog}>
|
<Dialog open={showVersionDialog} onOpenChange={setShowVersionDialog}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -3138,6 +3150,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
className="absolute inset-0 w-full h-full border-0"
|
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"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
|
|||||||
// Auth — strict to prevent brute force / credential stuffing
|
// Auth — strict to prevent brute force / credential stuffing
|
||||||
register: { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
|
register: { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
|
||||||
login: { windowMs: 15 * 60 * 1000, maxRequests: 10 }, // 10 per 15 min
|
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
|
// Content creation — moderate limits
|
||||||
comment: { windowMs: 60 * 1000, maxRequests: 15 }, // 15 per minute
|
comment: { windowMs: 60 * 1000, maxRequests: 15 }, // 15 per minute
|
||||||
|
|||||||
@@ -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<ShareLinkAccessResult> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<T>(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<ShareSessionPayload>(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<PendingSharePayload>(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;
|
||||||
@@ -233,6 +233,7 @@ model Video {
|
|||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
versions VideoVersion[]
|
versions VideoVersion[]
|
||||||
|
shareLinks ShareLink[]
|
||||||
|
|
||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
@@map("videos")
|
@@map("videos")
|
||||||
@@ -363,14 +364,14 @@ model ShareLink {
|
|||||||
// What is being shared
|
// What is being shared
|
||||||
projectId String
|
projectId String
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
videoId String?
|
||||||
|
video Video? @relation(fields: [videoId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
permission SharePermission @default(VIEW)
|
permission SharePermission @default(VIEW)
|
||||||
|
|
||||||
// Optional restrictions
|
// Optional restrictions
|
||||||
expiresAt DateTime? // Link expiration
|
expiresAt DateTime? // Link expiration
|
||||||
maxUses Int? // Maximum number of uses
|
|
||||||
useCount Int @default(0)
|
|
||||||
passwordHash String? // Bcrypt hash of optional password protection
|
passwordHash String? // Bcrypt hash of optional password protection
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
@@ -380,6 +381,9 @@ model ShareLink {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
|
@@index([videoId])
|
||||||
|
@@index([projectId, videoId])
|
||||||
|
@@unique([projectId, videoId, permission])
|
||||||
@@index([token])
|
@@index([token])
|
||||||
@@index([token, expiresAt])
|
@@index([token, expiresAt])
|
||||||
@@map("share_links")
|
@@map("share_links")
|
||||||
|
|||||||
Reference in New Issue
Block a user