mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
A voice comment's display name is the generated file name, extension included, so appending the extension again downloaded it as <uuid>.webm.webm. Both download paths, the single asset route and the project zip, now append only when the name does not already end in it.
214 lines
6.9 KiB
TypeScript
214 lines
6.9 KiB
TypeScript
import type { NextRequest } from 'next/server';
|
|
import type { VideoAsset } from '@prisma/client';
|
|
import { auth, checkProjectAccess } from '@/lib/auth';
|
|
import { db } from '@/lib/db';
|
|
import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
|
|
import { getShareSessionFromRequest } from '@/lib/share-session';
|
|
import { validateShareLinkAccess } from '@/lib/share-links';
|
|
import { canDownloadProjectMedia } from '@/lib/project-download';
|
|
|
|
const IMAGE_PROXY_PREFIX = '/api/upload/image/';
|
|
const AUDIO_PROXY_PREFIX = '/api/upload/audio/';
|
|
const VIDEO_PROXY_PREFIX = '/api/upload/video/';
|
|
|
|
export const SAFE_IMAGE_PROXY_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;
|
|
export const SAFE_AUDIO_PROXY_PATH =
|
|
/^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
|
export const SAFE_VIDEO_PROXY_PATH =
|
|
/^\/api\/upload\/video\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
|
export const SAFE_BUNNY_VIDEO_ID = /^[A-Za-z0-9_-]{8,128}$/;
|
|
|
|
export type VideoAssetAccessContext = {
|
|
video: {
|
|
id: string;
|
|
title: string;
|
|
projectId: string;
|
|
project: {
|
|
id: string;
|
|
name: string;
|
|
ownerId: string;
|
|
workspaceId: string;
|
|
visibility: string;
|
|
allowDownloads: boolean;
|
|
workspace: {
|
|
id: string;
|
|
ownerId: string;
|
|
};
|
|
};
|
|
};
|
|
hasViewAccess: boolean;
|
|
/**
|
|
* Whether the viewer has any relationship to the project: owner, project member,
|
|
* workspace member, or a valid share link. Distinguishes "you may not" from "there is
|
|
* no such thing", so a route can answer 404 for another tenant's id without answering
|
|
* 404 to somebody whose access merely lapsed.
|
|
*/
|
|
viewerBelongsToProject: boolean;
|
|
canUploadAssets: boolean;
|
|
canDownloadAssets: boolean;
|
|
canManageAssets: boolean;
|
|
viewerUserId: string | null;
|
|
viewerGuestIdentityId: string | null;
|
|
};
|
|
|
|
export function sanitizeAssetDisplayName(
|
|
value: string | null | undefined,
|
|
fallback: string
|
|
): string {
|
|
const raw = typeof value === 'string' ? value : '';
|
|
const normalized = raw
|
|
.replace(/[\u0000-\u001F\u007F]/g, '')
|
|
.replace(/[\[\]\(\)]/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
if (normalized.length === 0) return fallback;
|
|
return normalized.slice(0, 200);
|
|
}
|
|
|
|
/**
|
|
* A voice comment's display name is the generated file name, extension and all,
|
|
* so blindly appending the extension named the download `<uuid>.webm.webm`.
|
|
*/
|
|
export function withFileExtension(name: string, extension: string): string {
|
|
return name.toLowerCase().endsWith(extension.toLowerCase()) ? name : `${name}${extension}`;
|
|
}
|
|
|
|
export function extractImageKeyFromProxyUrl(url: string): string | null {
|
|
if (!SAFE_IMAGE_PROXY_PATH.test(url)) return null;
|
|
const filename = url.slice(IMAGE_PROXY_PREFIX.length);
|
|
if (!filename) return null;
|
|
return `images/${filename}`;
|
|
}
|
|
|
|
export function extractImageFileNameFromProxyUrl(url: string): string | null {
|
|
if (!SAFE_IMAGE_PROXY_PATH.test(url)) return null;
|
|
const filename = url.slice(IMAGE_PROXY_PREFIX.length);
|
|
return filename || null;
|
|
}
|
|
|
|
export function extractAudioKeyFromProxyUrl(url: string): string | null {
|
|
if (!SAFE_AUDIO_PROXY_PATH.test(url)) return null;
|
|
const filename = url.slice(AUDIO_PROXY_PREFIX.length);
|
|
if (!filename) return null;
|
|
return `voice/${filename}`;
|
|
}
|
|
|
|
export function extractAudioFileNameFromProxyUrl(url: string): string | null {
|
|
if (!SAFE_AUDIO_PROXY_PATH.test(url)) return null;
|
|
const filename = url.slice(AUDIO_PROXY_PREFIX.length);
|
|
return filename || null;
|
|
}
|
|
|
|
export function extractVideoKeyFromProxyUrl(url: string): string | null {
|
|
if (!SAFE_VIDEO_PROXY_PATH.test(url)) return null;
|
|
const filename = url.slice(VIDEO_PROXY_PREFIX.length);
|
|
if (!filename) return null;
|
|
return `videos/${filename}`;
|
|
}
|
|
|
|
export function extractVideoFileNameFromProxyUrl(url: string): string | null {
|
|
if (!SAFE_VIDEO_PROXY_PATH.test(url)) return null;
|
|
const filename = url.slice(VIDEO_PROXY_PREFIX.length);
|
|
return filename || null;
|
|
}
|
|
|
|
export function canDeleteAssetForViewer(
|
|
asset: Pick<VideoAsset, 'uploadedByUserId' | 'uploadedByGuestIdentityId'>,
|
|
viewer: Pick<
|
|
VideoAssetAccessContext,
|
|
'canManageAssets' | 'viewerUserId' | 'viewerGuestIdentityId'
|
|
>
|
|
): boolean {
|
|
if (viewer.canManageAssets) return true;
|
|
if (viewer.viewerUserId && asset.uploadedByUserId === viewer.viewerUserId) return true;
|
|
if (
|
|
!viewer.viewerUserId &&
|
|
viewer.viewerGuestIdentityId &&
|
|
asset.uploadedByGuestIdentityId &&
|
|
asset.uploadedByGuestIdentityId === viewer.viewerGuestIdentityId
|
|
) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function getVideoAssetAccessContext(
|
|
request: NextRequest,
|
|
videoId: string,
|
|
requiredPermission: 'VIEW' | 'COMMENT' = 'VIEW'
|
|
): Promise<VideoAssetAccessContext | null> {
|
|
const session = await auth();
|
|
const video = await db.video.findUnique({
|
|
where: { id: videoId },
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
projectId: true,
|
|
project: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
ownerId: true,
|
|
workspaceId: true,
|
|
visibility: true,
|
|
allowDownloads: true,
|
|
workspace: {
|
|
select: {
|
|
id: true,
|
|
ownerId: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!video) return null;
|
|
|
|
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,
|
|
passwordVerified: shareSession.passwordVerified,
|
|
})
|
|
: {
|
|
hasAccess: false,
|
|
canComment: false,
|
|
canDownload: false,
|
|
allowGuests: false,
|
|
requiresPassword: false,
|
|
link: null,
|
|
};
|
|
|
|
const hasViewAccess = access.hasAccess || shareAccess.hasAccess;
|
|
const canCommentWithMembership = !!session?.user?.id && access.hasAccess;
|
|
const canCommentWithShare =
|
|
shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
|
|
const canUploadAssets = canCommentWithMembership || canCommentWithShare;
|
|
const canDownloadWithMembership = canDownloadProjectMedia(video.project, access);
|
|
const canDownloadWithShare = shareAccess.hasAccess && shareAccess.canDownload;
|
|
const canDownloadAssets = hasViewAccess && (canDownloadWithMembership || canDownloadWithShare);
|
|
|
|
const viewerUserId = session?.user?.id ?? null;
|
|
const viewerGuestIdentityId = viewerUserId ? null : getGuestIdentityFromRequest(request);
|
|
|
|
const viewerBelongsToProject =
|
|
access.isOwner || access.isProjectMember || access.isWorkspaceMember || shareAccess.hasAccess;
|
|
|
|
return {
|
|
video,
|
|
hasViewAccess,
|
|
viewerBelongsToProject,
|
|
canUploadAssets,
|
|
canDownloadAssets,
|
|
canManageAssets: access.canEdit,
|
|
viewerUserId,
|
|
viewerGuestIdentityId,
|
|
};
|
|
}
|