381 lines
12 KiB
TypeScript
381 lines
12 KiB
TypeScript
// 文件访问API - 提供本地上传文件的访问
|
|
import { NextRequest } from 'next/server'
|
|
import { getServerSession } from 'next-auth'
|
|
import fs from 'fs'
|
|
import path from 'path'
|
|
import { lookup as mimeTypeLookup } from 'mime-types'
|
|
import { authOptions } from '@/lib/auth'
|
|
import { prisma } from '@/lib/prisma'
|
|
import {
|
|
canUserDownloadMaterial,
|
|
canUserDownloadSource,
|
|
hasSystemAdminPermission,
|
|
} from '@/lib/department-permission'
|
|
import { DownloadErrorCode } from '@/lib/download-errors'
|
|
import { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper'
|
|
import { findBlockedVideoPreviewByUrl, isVideoMediaPath } from '@/lib/video-preview-access'
|
|
import { userHasVideoEarlyAccess } from '@/lib/video-download-policy'
|
|
import { canAccessMaterialPrivacy, isPrivateTaskDetail, TASK_DETAIL_PRIVACY_SELECT, type TaskDetailPrivacyMaterial } from '@/lib/task-detail-privacy'
|
|
|
|
export const runtime = 'nodejs'
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
type FileAccessKind = 'preview' | 'source'
|
|
|
|
interface FileMaterial extends TaskDetailPrivacyMaterial {
|
|
id: string
|
|
materialType: string
|
|
}
|
|
|
|
interface ResolvedFileAccess {
|
|
kind: FileAccessKind
|
|
material: FileMaterial
|
|
}
|
|
|
|
function isPathInside(basePath: string, targetPath: string): boolean {
|
|
return targetPath === basePath || targetPath.startsWith(`${basePath}${path.sep}`)
|
|
}
|
|
|
|
function normalizeStoredPath(value: unknown): string {
|
|
return String(value || '').trim().replace(/\\/g, '/').replace(/^\.\//, '')
|
|
}
|
|
|
|
function buildStoredPathCandidates(relativePath: string, filePath: string): string[] {
|
|
const normalizedRelativePath = normalizeStoredPath(relativePath).replace(/^\/+/, '')
|
|
const candidates = new Set<string>([
|
|
normalizedRelativePath,
|
|
`uploads/${normalizedRelativePath}`,
|
|
`/uploads/${normalizedRelativePath}`,
|
|
`/api/uploads/${normalizedRelativePath}`,
|
|
normalizeStoredPath(filePath),
|
|
])
|
|
|
|
for (const candidate of Array.from(candidates)) {
|
|
candidates.add(candidate.replace(/\//g, '\\'))
|
|
}
|
|
|
|
return Array.from(candidates).filter(Boolean)
|
|
}
|
|
|
|
function isPreviewAssetType(assetType: unknown): boolean {
|
|
const normalized = String(assetType || '').trim()
|
|
return (
|
|
['preview', 'main-preview', 'sku-preview', 'detail-preview', 'report-image'].includes(normalized) ||
|
|
normalized.startsWith('standard-model-white-bg-view:') ||
|
|
normalized.startsWith('standard-model-white-bg-other:')
|
|
)
|
|
}
|
|
|
|
async function resolveFileAccess(
|
|
candidates: string[],
|
|
): Promise<ResolvedFileAccess | null> {
|
|
const normalizedCandidates = new Set(candidates.map(normalizeStoredPath))
|
|
const matchesCandidate = (value: unknown) => normalizedCandidates.has(normalizeStoredPath(value))
|
|
|
|
const materials = await prisma.material.findMany({
|
|
where: {
|
|
deletedAt: null,
|
|
OR: [
|
|
{ previewUrl: { in: candidates } },
|
|
{ zipFileName: { in: candidates } },
|
|
{ zipFilePath: { in: candidates } },
|
|
{ videoSourceUrl: { in: candidates } },
|
|
{ videoPreview540Url: { in: candidates } },
|
|
{ videoPreviewClipUrl: { in: candidates } },
|
|
{ videoCoverUrl: { in: candidates } },
|
|
{ displayImages: { hasSome: candidates } },
|
|
],
|
|
} as any,
|
|
select: {
|
|
id: true,
|
|
materialType: true,
|
|
...TASK_DETAIL_PRIVACY_SELECT,
|
|
previewUrl: true,
|
|
zipFileName: true,
|
|
zipFilePath: true,
|
|
videoSourceUrl: true,
|
|
videoPreview540Url: true,
|
|
videoPreviewClipUrl: true,
|
|
videoCoverUrl: true,
|
|
displayImages: true,
|
|
},
|
|
take: 20,
|
|
})
|
|
|
|
const assetMatches = await prisma.materialAsset.findMany({
|
|
where: {
|
|
fileUrl: { in: candidates },
|
|
material: { deletedAt: null },
|
|
} as any,
|
|
select: {
|
|
assetType: true,
|
|
material: {
|
|
select: {
|
|
id: true,
|
|
materialType: true,
|
|
...TASK_DETAIL_PRIVACY_SELECT,
|
|
},
|
|
},
|
|
},
|
|
take: 20,
|
|
})
|
|
|
|
for (const material of materials as any[]) {
|
|
if (
|
|
matchesCandidate(material.zipFileName) ||
|
|
matchesCandidate(material.zipFilePath) ||
|
|
matchesCandidate(material.videoSourceUrl)
|
|
) {
|
|
return { kind: 'source', material }
|
|
}
|
|
}
|
|
|
|
for (const asset of assetMatches as any[]) {
|
|
if (!isPreviewAssetType(asset.assetType)) {
|
|
return { kind: 'source', material: asset.material }
|
|
}
|
|
}
|
|
|
|
for (const material of materials as any[]) {
|
|
if (
|
|
matchesCandidate(material.previewUrl) ||
|
|
matchesCandidate(material.videoPreview540Url) ||
|
|
matchesCandidate(material.videoPreviewClipUrl) ||
|
|
matchesCandidate(material.videoCoverUrl) ||
|
|
(Array.isArray(material.displayImages) && material.displayImages.some(matchesCandidate))
|
|
) {
|
|
return { kind: 'preview', material }
|
|
}
|
|
}
|
|
|
|
const previewAsset = (assetMatches as any[]).find((asset) => isPreviewAssetType(asset.assetType))
|
|
return previewAsset ? { kind: 'preview', material: previewAsset.material } : null
|
|
}
|
|
|
|
async function authorizeSourceAccess(
|
|
request: NextRequest,
|
|
material: FileMaterial,
|
|
): Promise<Response | null> {
|
|
const session = await getServerSession(authOptions)
|
|
if (!session?.user) {
|
|
return Response.json(
|
|
{ error: '请先登录' },
|
|
{ status: 401, headers: { 'Cache-Control': 'no-store' } },
|
|
)
|
|
}
|
|
|
|
const sessionUser = session.user as any
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: String(sessionUser.id || '') },
|
|
select: {
|
|
role: true,
|
|
department: true,
|
|
managedDepartment: true,
|
|
canDownload: true,
|
|
isSuperAdmin: true,
|
|
},
|
|
})
|
|
|
|
if (!user) {
|
|
return Response.json(
|
|
{ error: '用户不存在' },
|
|
{ status: 403, headers: { 'Cache-Control': 'no-store' } },
|
|
)
|
|
}
|
|
|
|
if (hasSystemAdminPermission(user.role, Boolean(user.isSuperAdmin))) {
|
|
return null
|
|
}
|
|
|
|
if (!user.canDownload) {
|
|
return Response.json(
|
|
{ error: '无权限下载该文件' },
|
|
{ status: 403, headers: { 'Cache-Control': 'no-store' } },
|
|
)
|
|
}
|
|
|
|
const canDownload = isPrivateTaskDetail(material) || await canUserDownloadMaterial(
|
|
user.department,
|
|
user.role,
|
|
user.managedDepartment,
|
|
material.materialType,
|
|
String(sessionUser.id),
|
|
)
|
|
if (!canDownload) {
|
|
return Response.json(
|
|
{ error: '无权限下载该文件' },
|
|
{ status: 403, headers: { 'Cache-Control': 'no-store' } },
|
|
)
|
|
}
|
|
|
|
if (isProductPackageType(material.materialType) || is3DOriginalType(material.materialType)) {
|
|
const canDownloadSource = await canUserDownloadSource(
|
|
user.department,
|
|
user.role,
|
|
user.managedDepartment,
|
|
material.materialType,
|
|
String(sessionUser.id),
|
|
)
|
|
if (!canDownloadSource) {
|
|
return Response.json(
|
|
{ error: '无权限下载源文件' },
|
|
{ status: 403, headers: { 'Cache-Control': 'no-store' } },
|
|
)
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
async function handleFileRequest(
|
|
request: NextRequest,
|
|
pathSegments: string[],
|
|
headOnly: boolean,
|
|
): Promise<Response> {
|
|
if (pathSegments.length === 1 && pathSegments[0] === 'health') {
|
|
return new Response(headOnly ? null : 'OK', { status: 200 })
|
|
}
|
|
|
|
if (
|
|
pathSegments.length === 0 ||
|
|
pathSegments.some((segment) => !segment || segment === '.' || segment === '..' || /[\\/]/.test(segment))
|
|
) {
|
|
return new Response(headOnly ? null : 'Forbidden', { status: 403 })
|
|
}
|
|
|
|
const uploadDir = path.resolve(process.env.UPLOAD_DIR || '/app/uploads')
|
|
const relativePath = path.join(...pathSegments)
|
|
const normalizedRelativePath = relativePath.replace(/\\/g, '/')
|
|
const filePath = path.resolve(uploadDir, relativePath)
|
|
if (!isPathInside(uploadDir, filePath)) {
|
|
return new Response(headOnly ? null : 'Forbidden', { status: 403 })
|
|
}
|
|
|
|
const access = await resolveFileAccess(buildStoredPathCandidates(relativePath, filePath))
|
|
if (!access) {
|
|
return new Response(headOnly ? null : 'Not Found', { status: 404 })
|
|
}
|
|
if (isPrivateTaskDetail(access.material)) {
|
|
const session = await getServerSession(authOptions)
|
|
if (!(await canAccessMaterialPrivacy(access.material, (session?.user as any)?.id))) {
|
|
return new Response(headOnly ? null : '无权限访问该私密素材', {
|
|
status: session?.user ? 403 : 401,
|
|
headers: { 'Cache-Control': 'private, no-store', Vary: 'Cookie' },
|
|
})
|
|
}
|
|
}
|
|
|
|
if (access.kind === 'source') {
|
|
const deniedResponse = await authorizeSourceAccess(request, access.material)
|
|
if (deniedResponse) {
|
|
return headOnly
|
|
? new Response(null, {
|
|
status: deniedResponse.status,
|
|
headers: { 'Cache-Control': 'no-store' },
|
|
})
|
|
: deniedResponse
|
|
}
|
|
}
|
|
|
|
if (isVideoMediaPath(normalizedRelativePath)) {
|
|
// 懒获取登录态:只有视频文件才需要判定 15 天保护期豁免。
|
|
const videoSession = await getServerSession(authOptions)
|
|
const blockedPreview = await findBlockedVideoPreviewByUrl(
|
|
[
|
|
request.url,
|
|
`/api/uploads/${normalizedRelativePath}`,
|
|
`/uploads/${normalizedRelativePath}`,
|
|
normalizedRelativePath,
|
|
],
|
|
new Date(),
|
|
{ bypassWaitPeriod: userHasVideoEarlyAccess(videoSession?.user) },
|
|
)
|
|
|
|
if (blockedPreview) {
|
|
return headOnly
|
|
? new Response(null, { status: 403, headers: { 'Cache-Control': 'no-store' } })
|
|
: Response.json(
|
|
{
|
|
error: '视频类素材上传后满 15 天才能预览和下载',
|
|
code: DownloadErrorCode.VIDEO_WAIT_PERIOD,
|
|
details: blockedPreview.message,
|
|
},
|
|
{ status: 403, headers: { 'Cache-Control': 'no-store' } },
|
|
)
|
|
}
|
|
}
|
|
|
|
try {
|
|
const stats = await fs.promises.stat(filePath)
|
|
if (!stats.isFile()) {
|
|
return new Response(headOnly ? null : 'Not Found', { status: 404 })
|
|
}
|
|
|
|
const contentType = mimeTypeLookup(path.extname(filePath)) || 'application/octet-stream'
|
|
const cacheControl =
|
|
access.kind === 'source' || isPrivateTaskDetail(access.material) ? 'private, no-store' : 'public, max-age=31536000, immutable'
|
|
|
|
if (headOnly) {
|
|
return new Response(null, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Length': String(stats.size),
|
|
'Content-Type': contentType,
|
|
'Cache-Control': cacheControl,
|
|
},
|
|
})
|
|
}
|
|
|
|
const range = request.headers.get('range')
|
|
if (range) {
|
|
const parts = range.match(/bytes=(\d*)-(\d*)/)
|
|
const start = parts && parts[1] ? Number.parseInt(parts[1], 10) : 0
|
|
const end = parts && parts[2] ? Number.parseInt(parts[2], 10) : stats.size - 1
|
|
if (Number.isNaN(start) || Number.isNaN(end) || start > end || end >= stats.size) {
|
|
return new Response('Invalid Range', { status: 416 })
|
|
}
|
|
|
|
const stream = fs.createReadStream(filePath, { start, end })
|
|
return new Response(stream as any, {
|
|
status: 206,
|
|
headers: {
|
|
'Content-Range': `bytes ${start}-${end}/${stats.size}`,
|
|
'Accept-Ranges': 'bytes',
|
|
'Content-Length': String(end - start + 1),
|
|
'Content-Type': contentType,
|
|
'Cache-Control': cacheControl,
|
|
},
|
|
})
|
|
}
|
|
|
|
const stream = fs.createReadStream(filePath)
|
|
return new Response(stream as any, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Length': String(stats.size),
|
|
'Content-Type': contentType,
|
|
'Cache-Control': cacheControl,
|
|
},
|
|
})
|
|
} catch (error: any) {
|
|
if (error?.code === 'ENOENT') {
|
|
return new Response(headOnly ? null : 'Not Found', { status: 404 })
|
|
}
|
|
return new Response(headOnly ? null : 'Server Error', { status: 500 })
|
|
}
|
|
}
|
|
|
|
export async function GET(
|
|
request: NextRequest,
|
|
{ params }: { params: { path: string[] } },
|
|
) {
|
|
return handleFileRequest(request, params.path || [], false)
|
|
}
|
|
|
|
export async function HEAD(
|
|
request: NextRequest,
|
|
{ params }: { params: { path: string[] } },
|
|
) {
|
|
return handleFileRequest(request, params.path || [], true)
|
|
}
|