mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Add guest upload tokens and share-session aware permissions
This commit is contained in:
@@ -2,19 +2,12 @@ import { NextRequest } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
|
||||
// Default tags to create for new projects
|
||||
const DEFAULT_TAGS = [
|
||||
{ name: 'Feedback', color: '#3B82F6', position: 0 },
|
||||
{ name: 'Technical', color: '#EF4444', position: 1 },
|
||||
{ name: 'Creative', color: '#8B5CF6', position: 2 },
|
||||
{ name: 'Approved', color: '#22C55E', position: 3 },
|
||||
{ name: 'Urgent', color: '#F59E0B', position: 4 },
|
||||
];
|
||||
|
||||
// Helper to check project access
|
||||
async function checkProjectAccess(projectId: string, userId: string) {
|
||||
const project = await db.project.findUnique({
|
||||
@@ -51,36 +44,56 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
const { projectId } = await params;
|
||||
const videoId = request.nextUrl.searchParams.get('videoId');
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true, visibility: true },
|
||||
});
|
||||
if (!project) return apiErrors.notFound('Project');
|
||||
|
||||
if (session?.user?.id) {
|
||||
const { project: accessibleProject } = await checkProjectAccess(projectId, session.user.id);
|
||||
if (!accessibleProject) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
} else {
|
||||
let hasGuestAccess = project.visibility === 'PUBLIC';
|
||||
if (!hasGuestAccess && videoId) {
|
||||
const video = await db.video.findFirst({
|
||||
where: { id: videoId, projectId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (video) {
|
||||
const shareSession = getShareSessionFromRequest(request, video.id);
|
||||
const shareAccess = shareSession
|
||||
? await validateShareLinkAccess({
|
||||
token: shareSession.token,
|
||||
projectId,
|
||||
videoId: video.id,
|
||||
requiredPermission: 'COMMENT',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
||||
hasGuestAccess = shareAccess.canComment && shareAccess.allowGuests;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasGuestAccess) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
}
|
||||
|
||||
const { project } = await checkProjectAccess(projectId, session.user.id);
|
||||
if (!project) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
|
||||
let tags = await db.commentTag.findMany({
|
||||
const tags = await db.commentTag.findMany({
|
||||
where: { projectId },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
|
||||
// Auto-create default tags if none exist (idempotent with skipDuplicates
|
||||
// to handle race conditions from concurrent requests)
|
||||
if (tags.length === 0) {
|
||||
await db.commentTag.createMany({
|
||||
data: DEFAULT_TAGS.map((tag) => ({ ...tag, projectId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
tags = await db.commentTag.findMany({
|
||||
where: { projectId },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
const response = successResponse(tags);
|
||||
return withCacheControl(response, 'private, max-age=120, stale-while-revalidate=300');
|
||||
const cacheControl = session?.user?.id
|
||||
? 'private, max-age=120, stale-while-revalidate=300'
|
||||
: 'private, no-cache';
|
||||
return withCacheControl(response, cacheControl);
|
||||
} catch (error) {
|
||||
console.error('Error fetching tags:', error);
|
||||
return apiErrors.internalError('Failed to fetch tags');
|
||||
|
||||
@@ -106,6 +106,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
isAuthenticated: !!session?.user?.id,
|
||||
currentUserId: session?.user?.id || null,
|
||||
currentUserName: session?.user?.name || null,
|
||||
canDownload: access.hasAccess,
|
||||
canManageTags: access.canEdit,
|
||||
});
|
||||
|
||||
return withCacheControl(response, 'private, no-cache');
|
||||
|
||||
@@ -30,8 +30,27 @@ async function requireShareManagementAccess(projectId: string, videoId: string,
|
||||
return { error: null, video };
|
||||
}
|
||||
|
||||
function resolveShareBaseUrl(request: NextRequest): string {
|
||||
const configuredBaseUrl = process.env.NEXTAUTH_URL ?? process.env.NEXT_PUBLIC_APP_URL;
|
||||
const normalizedConfiguredBaseUrl = configuredBaseUrl?.trim();
|
||||
|
||||
if (normalizedConfiguredBaseUrl) {
|
||||
const withProtocol = /^https?:\/\//i.test(normalizedConfiguredBaseUrl)
|
||||
? normalizedConfiguredBaseUrl
|
||||
: `https://${normalizedConfiguredBaseUrl}`;
|
||||
|
||||
try {
|
||||
return new URL(withProtocol).origin;
|
||||
} catch {
|
||||
// Fallback to request origin when env configuration is invalid.
|
||||
}
|
||||
}
|
||||
|
||||
return request.nextUrl.origin;
|
||||
}
|
||||
|
||||
function buildWatchUrl(request: NextRequest, videoId: string, token: string): string {
|
||||
const url = new URL(`/watch/${videoId}`, request.nextUrl.origin);
|
||||
const url = new URL(`/watch/${videoId}`, resolveShareBaseUrl(request));
|
||||
url.searchParams.set('shareToken', token);
|
||||
return url.toString();
|
||||
}
|
||||
@@ -44,6 +63,7 @@ function serializeShareLink(
|
||||
token: string;
|
||||
permission: string;
|
||||
allowGuests: boolean;
|
||||
allowDownloads: boolean;
|
||||
expiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
passwordHash: string | null;
|
||||
@@ -59,6 +79,7 @@ function serializeShareLink(
|
||||
token: link.token,
|
||||
permission: link.permission,
|
||||
allowGuests: link.allowGuests,
|
||||
allowDownloads: link.allowDownloads,
|
||||
expiresAt: link.expiresAt,
|
||||
createdAt: link.createdAt,
|
||||
hasPassword: !!link.passwordHash,
|
||||
@@ -91,6 +112,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
token: true,
|
||||
permission: true,
|
||||
allowGuests: true,
|
||||
allowDownloads: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
passwordHash: true,
|
||||
@@ -123,6 +145,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const allowGuests = typeof body?.allowGuests === 'boolean' ? body.allowGuests : true;
|
||||
const allowDownloads = typeof body?.allowDownloads === 'boolean' ? body.allowDownloads : false;
|
||||
const password = typeof body?.password === 'string' ? body.password.trim() : '';
|
||||
if (password.length > MAX_SHARE_PASSWORD_LENGTH) {
|
||||
return apiErrors.badRequest(`Password must be ${MAX_SHARE_PASSWORD_LENGTH} characters or fewer`);
|
||||
@@ -135,6 +158,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
token: string;
|
||||
permission: string;
|
||||
allowGuests: boolean;
|
||||
allowDownloads: boolean;
|
||||
expiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
passwordHash: string | null;
|
||||
@@ -158,6 +182,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
data: {
|
||||
token,
|
||||
allowGuests,
|
||||
allowDownloads,
|
||||
passwordHash,
|
||||
expiresAt: null,
|
||||
},
|
||||
@@ -166,6 +191,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
token: true,
|
||||
permission: true,
|
||||
allowGuests: true,
|
||||
allowDownloads: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
passwordHash: true,
|
||||
@@ -180,6 +206,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
videoId,
|
||||
permission: 'COMMENT',
|
||||
allowGuests,
|
||||
allowDownloads,
|
||||
passwordHash,
|
||||
},
|
||||
select: {
|
||||
@@ -187,6 +214,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
token: true,
|
||||
permission: true,
|
||||
allowGuests: true,
|
||||
allowDownloads: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
passwordHash: true,
|
||||
@@ -232,6 +260,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const allowGuests = typeof body?.allowGuests === 'boolean' ? body.allowGuests : undefined;
|
||||
const allowDownloads = typeof body?.allowDownloads === 'boolean' ? body.allowDownloads : undefined;
|
||||
const rawPassword = typeof body?.password === 'string' ? body.password : undefined;
|
||||
const clearPassword = body?.clearPassword === true;
|
||||
if (rawPassword !== undefined && rawPassword.length > MAX_SHARE_PASSWORD_LENGTH) {
|
||||
@@ -266,6 +295,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...(allowGuests !== undefined ? { allowGuests } : {}),
|
||||
...(allowDownloads !== undefined ? { allowDownloads } : {}),
|
||||
...(passwordHashUpdate !== undefined ? { passwordHash: passwordHashUpdate } : {}),
|
||||
...(shouldRotateToken ? { token: randomBytes(24).toString('base64url') } : {}),
|
||||
},
|
||||
@@ -274,6 +304,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
token: true,
|
||||
permission: true,
|
||||
allowGuests: true,
|
||||
allowDownloads: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
passwordHash: true,
|
||||
|
||||
Reference in New Issue
Block a user