362 lines
11 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'
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<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,
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<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 = 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 (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)) {
const blockedPreview = await findBlockedVideoPreviewByUrl([
request.url,
`/api/uploads/${normalizedRelativePath}`,
`/uploads/${normalizedRelativePath}`,
normalizedRelativePath,
])
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)
}