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:
@@ -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 { 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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user