300 lines
7.8 KiB
TypeScript
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>
|
|
}
|