300 lines
7.8 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { getS3ObjectStream, isS3Url } from '@/lib/s3'
import { findValidMaterialShareToken } from '@/lib/material-share-token'
import { createLocalFileStreamResponse } from '@/lib/local-file-stream'
import { isPublicShareEnabled } from '@/lib/public-share-settings'
import { access } from 'fs/promises'
import path from 'path'
import { Readable } from 'stream'
export const dynamic = 'force-dynamic'
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const fileUrl = searchParams.get('url') || ''
const shareToken = searchParams.get('shareToken') || ''
if (!fileUrl) {
return NextResponse.json(
{ error: '缺少文件地址' },
{ status: 400 }
)
}
if (!isAllowedMaterialFile(fileUrl)) {
return NextResponse.json(
{ error: '文件地址不允许访问' },
{ status: 403 }
)
}
const session = await getServerSession(authOptions)
if (!session?.user && !shareToken) {
return NextResponse.json(
{ error: '请先登录或使用有效分享链接' },
{ status: 401 }
)
}
const materialId = await findKnownMaterialFileId(fileUrl)
if (!materialId) {
return NextResponse.json(
{ error: '文件不属于当前素材库记录' },
{ status: 404 }
)
}
if (!session?.user) {
if (!(await isPublicShareEnabled())) {
return NextResponse.json(
{ error: '公开分享功能已关闭' },
{ status: 403 }
)
}
const validShareToken = shareToken
? await findValidMaterialShareToken(shareToken, materialId)
: null
if (!validShareToken) {
return NextResponse.json(
{ error: '请先登录或使用有效分享链接' },
{ status: 401 }
)
}
}
const range = req.headers.get('range')
if (isS3Url(fileUrl)) {
const s3Object = await getS3ObjectStream(fileUrl, range)
const bodyStream = toWebStream(s3Object.body)
const headers: Record<string, string> = {
'Content-Type': s3Object.contentType || inferContentType(fileUrl),
'Accept-Ranges': s3Object.acceptRanges || 'bytes',
'Cache-Control': 'private, max-age=300',
}
if (range && s3Object.contentRange) {
headers['Content-Range'] = s3Object.contentRange
}
if (s3Object.contentLength) {
headers['Content-Length'] = s3Object.contentLength.toString()
}
return new NextResponse(bodyStream as any, {
status: range ? 206 : 200,
headers,
})
}
const filePath = resolveLocalMaterialPath(fileUrl)
if (!filePath) {
return NextResponse.json(
{ error: '文件地址不允许访问' },
{ status: 403 }
)
}
try {
await access(filePath)
} catch {
return NextResponse.json(
{ error: '文件不存在,可能已被删除' },
{ status: 404 }
)
}
return createLocalFileStreamResponse(filePath, {
contentType: inferContentType(filePath),
range,
headers: {
'Cache-Control': 'private, max-age=300',
},
})
} catch (error) {
console.error('[MaterialAsset] GET error:', error)
return NextResponse.json(
{ error: '读取素材文件失败' },
{ status: 500 }
)
}
}
function isAllowedMaterialFile(fileUrl: string) {
if (isS3Url(fileUrl)) {
return true
}
return Boolean(resolveLocalMaterialPath(fileUrl))
}
async function findKnownMaterialFileId(fileUrl: string) {
const candidates = await prisma.material.findMany({
where: {
deletedAt: null,
OR: [
{ previewUrl: fileUrl },
{ zipFilePath: fileUrl },
{ videoSourceUrl: fileUrl },
{ videoCoverUrl: fileUrl },
{ videoPreview540Url: fileUrl },
{ videoPreviewClipUrl: fileUrl },
{ audioUrl: fileUrl },
{ displayImages: { contains: fileUrl } },
{ previewUrls: { contains: fileUrl } },
{ metadata: { contains: fileUrl } },
],
},
take: 50,
select: {
id: true,
previewUrl: true,
zipFilePath: true,
videoSourceUrl: true,
videoCoverUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
audioUrl: true,
displayImages: true,
previewUrls: true,
metadata: true,
},
})
const material = candidates.find((candidate) => materialContainsFileUrl(candidate, fileUrl))
return material?.id ?? null
}
function materialContainsFileUrl(
material: {
previewUrl: string
zipFilePath: string
videoSourceUrl: string | null
videoCoverUrl: string | null
videoPreview540Url: string | null
videoPreviewClipUrl: string | null
audioUrl: string | null
displayImages: string
previewUrls: string | null
metadata: string | null
},
fileUrl: string
) {
const directFields = [
material.previewUrl,
material.zipFilePath,
material.videoSourceUrl,
material.videoCoverUrl,
material.videoPreview540Url,
material.videoPreviewClipUrl,
material.audioUrl,
]
if (directFields.some((value) => value === fileUrl)) {
return true
}
const displayImages = parseUrlList(material.displayImages)
const previewUrls = [
...parseUrlList(material.previewUrls),
...parseMetadataPreviewUrls(material.metadata),
]
return displayImages.includes(fileUrl) || previewUrls.includes(fileUrl)
}
function parseUrlList(value: string | null | undefined): string[] {
if (!value) return []
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed)) {
return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
}
} catch {
// Legacy rows may store a single URL string instead of a JSON array.
}
const trimmed = value.trim()
return trimmed ? [trimmed] : []
}
function parseMetadataPreviewUrls(value: string | null | undefined): string[] {
if (!value) return []
try {
const parsed = JSON.parse(value)
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.previewUrls)) {
return parsed.previewUrls.filter((item: unknown): item is string =>
typeof item === 'string' && item.trim().length > 0
)
}
} catch {
return []
}
return []
}
function resolveLocalMaterialPath(filePathOrUrl: string) {
if (!filePathOrUrl || isS3Url(filePathOrUrl)) return null
const workspace = process.cwd()
const rawPath = path.isAbsolute(filePathOrUrl)
? filePathOrUrl
: path.join(workspace, filePathOrUrl)
const resolved = path.resolve(rawPath)
const allowedRoots = [
path.resolve(workspace, 'uploads'),
path.resolve(workspace, 'public'),
]
return allowedRoots.some((root) => resolved === root || resolved.startsWith(`${root}${path.sep}`))
? resolved
: null
}
function inferContentType(filePathOrUrl: string) {
const ext = path.extname(filePathOrUrl.split('?')[0] || '').toLowerCase()
const types: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.m4a': 'audio/mp4',
}
return types[ext] || 'application/octet-stream'
}
function toWebStream(body: any) {
if (!body) {
throw new Error('S3 object body is empty')
}
if (typeof (body as any).transformToWebStream === 'function') {
return (body as any).transformToWebStream()
}
if (body instanceof Readable) {
return Readable.toWeb(body)
}
return body as ReadableStream<any>
}