gongxiangp2/lib/video-preview-access.ts

188 lines
4.7 KiB
TypeScript

import { prisma } from './prisma'
import {
getVideoDownloadAvailability,
getVideoPreviewUnavailableMessage,
type VideoDownloadAvailability,
type VideoPolicyOptions,
} from './video-download-policy'
const VIDEO_MEDIA_EXTENSIONS = ['.mp4', '.webm', '.ogg']
type VideoMaterialMatch = {
id: string
materialType: string | null
createdAt: Date | string | null
}
export type BlockedVideoPreviewMatch = {
material: VideoMaterialMatch
availability: VideoDownloadAvailability
message: string
}
function safeDecode(value: string) {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
function normalizePathSlashes(value: string) {
return value.replace(/\\/g, '/').replace(/\/+/g, '/')
}
function getPathnameCandidate(value: string) {
try {
return new URL(value).pathname
} catch {
return value
}
}
function getFileNameCandidate(value: string) {
const pathname = normalizePathSlashes(getPathnameCandidate(value)).split(/[?#]/)[0]
const fileName = pathname.split('/').filter(Boolean).pop() || ''
return safeDecode(fileName)
}
export function isVideoMediaPath(pathname: string) {
const normalizedPath = safeDecode(String(pathname || '')).toLowerCase().split(/[?#]/)[0]
return VIDEO_MEDIA_EXTENSIONS.some((extension) => normalizedPath.endsWith(extension))
}
export function buildVideoUrlCandidates(values: Array<string | URL | null | undefined>) {
const candidates = new Set<string>()
for (const value of values) {
if (!value) continue
const rawValue = String(value).trim()
if (!rawValue) continue
const decodedValue = safeDecode(rawValue)
candidates.add(rawValue)
candidates.add(decodedValue)
const pathname = normalizePathSlashes(getPathnameCandidate(decodedValue)).split(/[?#]/)[0]
if (pathname) {
const decodedPathname = safeDecode(pathname)
candidates.add(pathname)
candidates.add(decodedPathname)
candidates.add(decodedPathname.replace(/^\/+/, ''))
if (decodedPathname.startsWith('/api/uploads/')) {
const uploadRelativePath = decodedPathname.replace(/^\/api\/uploads\/+/, '')
candidates.add(uploadRelativePath)
candidates.add(`uploads/${uploadRelativePath}`)
candidates.add(`/uploads/${uploadRelativePath}`)
}
if (decodedPathname.startsWith('/uploads/')) {
const uploadRelativePath = decodedPathname.replace(/^\/uploads\/+/, '')
candidates.add(uploadRelativePath)
candidates.add(`uploads/${uploadRelativePath}`)
candidates.add(`/uploads/${uploadRelativePath}`)
}
}
}
return Array.from(candidates).filter(Boolean)
}
function buildExactUrlWhere(candidates: string[]) {
return [
{ videoPreviewClipUrl: { in: candidates } },
{ videoPreview540Url: { in: candidates } },
{ videoSourceUrl: { in: candidates } },
{ zipFilePath: { in: candidates } },
{ zipFileName: { in: candidates } },
]
}
function buildFileNameWhere(fileNames: string[]) {
return fileNames.flatMap((fileName) => [
{ videoPreviewClipUrl: { contains: fileName } },
{ videoPreview540Url: { contains: fileName } },
{ videoSourceUrl: { contains: fileName } },
{ zipFilePath: { contains: fileName } },
{ zipFileName: { contains: fileName } },
])
}
async function findVideoMaterialByUrlCandidates(
candidates: string[],
): Promise<VideoMaterialMatch | null> {
if (candidates.length === 0) {
return null
}
const select = {
id: true,
materialType: true,
createdAt: true,
} as const
const exactMatch = await prisma.material.findFirst({
where: {
deletedAt: null,
OR: buildExactUrlWhere(candidates),
},
select,
})
if (exactMatch) {
return exactMatch
}
const fileNames = Array.from(
new Set(
candidates
.map(getFileNameCandidate)
.filter((fileName) => fileName.length >= 12 && isVideoMediaPath(fileName)),
),
)
if (fileNames.length === 0) {
return null
}
return prisma.material.findFirst({
where: {
deletedAt: null,
OR: buildFileNameWhere(fileNames),
},
select,
})
}
export async function findBlockedVideoPreviewByUrl(
values: Array<string | URL | null | undefined>,
now: Date = new Date(),
options?: VideoPolicyOptions,
): Promise<BlockedVideoPreviewMatch | null> {
const candidates = buildVideoUrlCandidates(values)
const material = await findVideoMaterialByUrlCandidates(candidates)
if (!material) {
return null
}
const availability = getVideoDownloadAvailability(
material.materialType,
material.createdAt,
now,
options,
)
if (availability.allowed) {
return null
}
return {
material,
availability,
message: getVideoPreviewUnavailableMessage(availability),
}
}