// 文件访问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' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' type FileAccessKind = 'preview' | 'source' interface FileMaterial { 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([ 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 { 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, 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, }, }, }, 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 { 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 = 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 { 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 (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' ? '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) }