Files
OpenFrame/app/watch/[videoId]/session/route.ts
T
yusufipk 5871d4d87d fix(auth): build verify-email redirects from the configured public origin
Redirects were built relative to `request.url`, which behind a reverse proxy
resolves to the container-internal address. Verification succeeded but the
browser was sent to localhost:3000, so users saw a connection error instead of
the "email verified" confirmation.

Add getPublicOrigin() (NEXTAUTH_URL, then NEXT_PUBLIC_APP_URL, falling back to
the request origin for local development) and use it for every verify-email
redirect. The legacy GET redirect in the watch session route had the same
defect and is fixed alongside it.
2026-07-25 14:57:57 +07:00

149 lines
4.8 KiB
TypeScript

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 { getPublicOrigin, isTrustedSameOriginRequest } from '@/lib/request-origin';
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 (!isTrustedSameOriginRequest(request)) {
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}`, getPublicOrigin(request));
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;
}