188 lines
4.7 KiB
TypeScript
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),
|
|
}
|
|
}
|