收紧素材分享与文件访问安全
This commit is contained in:
parent
0dabf6cee8
commit
bd50dbd6e0
213
app/api/materials/asset/__tests__/route.test.ts
Normal file
213
app/api/materials/asset/__tests__/route.test.ts
Normal file
@ -0,0 +1,213 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { Readable } from 'stream'
|
||||
import { GET } from '../route'
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
material: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/s3', () => ({
|
||||
getS3ObjectStream: jest.fn(),
|
||||
isS3Url: jest.fn((value?: string | null) => /^https?:\/\//.test(value || '')),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/material-share-token', () => ({
|
||||
findValidMaterialShareToken: jest.fn(),
|
||||
}))
|
||||
|
||||
const { getServerSession } = require('next-auth')
|
||||
const { prisma } = require('@/lib/prisma')
|
||||
const { getS3ObjectStream, isS3Url } = require('@/lib/s3')
|
||||
const { findValidMaterialShareToken } = require('@/lib/material-share-token')
|
||||
|
||||
const buildMaterialRecord = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'material-1',
|
||||
previewUrl: 'http://minio:9000/bucket/preview.webp',
|
||||
zipFilePath: 'http://minio:9000/bucket/material.zip',
|
||||
videoSourceUrl: null,
|
||||
videoCoverUrl: null,
|
||||
videoPreview540Url: null,
|
||||
videoPreviewClipUrl: null,
|
||||
audioUrl: null,
|
||||
displayImages: '[]',
|
||||
previewUrls: null,
|
||||
metadata: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('GET /api/materials/asset', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
isS3Url.mockImplementation((value?: string | null) => /^https?:\/\//.test(value || ''))
|
||||
})
|
||||
|
||||
it('未登录时拒绝读取素材文件', async () => {
|
||||
getServerSession.mockResolvedValue(null)
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost/api/materials/asset?url=http%3A%2F%2Fminio%3A9000%2Fbucket%2Fpreview.webp')
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data.error).toBe('请先登录或使用有效分享链接')
|
||||
expect(prisma.material.findMany).not.toHaveBeenCalled()
|
||||
expect(getS3ObjectStream).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('文件地址不属于素材记录时拒绝读取', async () => {
|
||||
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
|
||||
prisma.material.findMany.mockResolvedValue([])
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost/api/materials/asset?url=http%3A%2F%2Fminio%3A9000%2Fbucket%2Funknown.webp')
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(data.error).toBe('文件不属于当前素材库记录')
|
||||
expect(getS3ObjectStream).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('已登录且文件属于素材记录时代理返回 S3 文件流', async () => {
|
||||
const fileUrl = 'http://minio:9000/bucket/preview.webp'
|
||||
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
|
||||
prisma.material.findMany.mockResolvedValue([buildMaterialRecord({ previewUrl: fileUrl })])
|
||||
getS3ObjectStream.mockResolvedValue({
|
||||
body: Readable.from([Buffer.from('asset-body')]),
|
||||
contentLength: 10,
|
||||
contentType: 'image/webp',
|
||||
acceptRanges: 'bytes',
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest(`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}`)
|
||||
)
|
||||
const body = await response.text()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-type')).toBe('image/webp')
|
||||
expect(body).toBe('asset-body')
|
||||
expect(getS3ObjectStream).toHaveBeenCalledWith(fileUrl, null)
|
||||
})
|
||||
|
||||
it('未登录但分享票据有效时允许读取素材文件', async () => {
|
||||
const fileUrl = 'http://minio:9000/bucket/preview.webp'
|
||||
getServerSession.mockResolvedValue(null)
|
||||
prisma.material.findMany.mockResolvedValue([buildMaterialRecord({ previewUrl: fileUrl })])
|
||||
findValidMaterialShareToken.mockResolvedValue({ id: 'token-row', materialId: 'material-1' })
|
||||
getS3ObjectStream.mockResolvedValue({
|
||||
body: Readable.from([Buffer.from('asset-body')]),
|
||||
contentLength: 10,
|
||||
contentType: 'image/webp',
|
||||
acceptRanges: 'bytes',
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest(
|
||||
`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}&shareToken=share-token`
|
||||
)
|
||||
)
|
||||
const body = await response.text()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body).toBe('asset-body')
|
||||
expect(findValidMaterialShareToken).toHaveBeenCalledWith('share-token', 'material-1')
|
||||
})
|
||||
|
||||
it('允许读取 metadata.previewUrls 中登记过的预览图', async () => {
|
||||
const fileUrl = 'http://minio:9000/bucket/metadata-preview.webp'
|
||||
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
|
||||
prisma.material.findMany.mockResolvedValue([
|
||||
buildMaterialRecord({
|
||||
metadata: JSON.stringify({
|
||||
previewUrls: [fileUrl],
|
||||
templateFields: {
|
||||
externalRef: 'http://minio:9000/bucket/not-this.webp',
|
||||
},
|
||||
}),
|
||||
})
|
||||
])
|
||||
getS3ObjectStream.mockResolvedValue({
|
||||
body: Readable.from([Buffer.from('metadata-preview')]),
|
||||
contentLength: 16,
|
||||
contentType: 'image/webp',
|
||||
acceptRanges: 'bytes',
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest(`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}`)
|
||||
)
|
||||
|
||||
await expect(response.text()).resolves.toBe('metadata-preview')
|
||||
expect(response.status).toBe(200)
|
||||
expect(getS3ObjectStream).toHaveBeenCalledWith(fileUrl, null)
|
||||
})
|
||||
|
||||
it('不会把 metadata 普通自定义字段里的 URL 当成可代理文件', async () => {
|
||||
const fileUrl = 'http://minio:9000/bucket/custom-field.webp'
|
||||
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
|
||||
prisma.material.findMany.mockResolvedValue([
|
||||
buildMaterialRecord({
|
||||
metadata: JSON.stringify({
|
||||
templateFields: {
|
||||
externalRef: fileUrl,
|
||||
},
|
||||
}),
|
||||
})
|
||||
])
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest(`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}`)
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(data.error).toBe('文件不属于当前素材库记录')
|
||||
expect(getS3ObjectStream).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('第一条候选只是 metadata 普通字段命中时,会继续寻找真实文件归属素材', async () => {
|
||||
const fileUrl = 'http://minio:9000/bucket/shared.webp'
|
||||
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
|
||||
prisma.material.findMany.mockResolvedValue([
|
||||
buildMaterialRecord({
|
||||
id: 'material-false-positive',
|
||||
previewUrl: 'http://minio:9000/bucket/other.webp',
|
||||
metadata: JSON.stringify({
|
||||
templateFields: { externalRef: fileUrl },
|
||||
}),
|
||||
}),
|
||||
buildMaterialRecord({
|
||||
id: 'material-real-owner',
|
||||
previewUrl: fileUrl,
|
||||
}),
|
||||
])
|
||||
getS3ObjectStream.mockResolvedValue({
|
||||
body: Readable.from([Buffer.from('real-owner')]),
|
||||
contentLength: 10,
|
||||
contentType: 'image/webp',
|
||||
acceptRanges: 'bytes',
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest(`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}`)
|
||||
)
|
||||
|
||||
await expect(response.text()).resolves.toBe('real-owner')
|
||||
expect(response.status).toBe(200)
|
||||
expect(getS3ObjectStream).toHaveBeenCalledWith(fileUrl, null)
|
||||
})
|
||||
})
|
||||
291
app/api/materials/asset/route.ts
Normal file
291
app/api/materials/asset/route.ts
Normal file
@ -0,0 +1,291 @@
|
||||
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 { 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) {
|
||||
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>
|
||||
}
|
||||
104
app/api/materials/download/__tests__/route.test.ts
Normal file
104
app/api/materials/download/__tests__/route.test.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import path from 'path'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { GET } from '../route'
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
material: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
$executeRaw: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/s3', () => ({
|
||||
getS3ObjectStream: jest.fn(),
|
||||
isS3Url: jest.fn(() => false),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/material-download-log', () => ({
|
||||
ensureMaterialDownloadLogsTable: jest.fn(),
|
||||
}))
|
||||
|
||||
const { getServerSession } = require('next-auth')
|
||||
const { prisma } = require('@/lib/prisma')
|
||||
const { getS3ObjectStream } = require('@/lib/s3')
|
||||
const { ensureMaterialDownloadLogsTable } = require('@/lib/material-download-log')
|
||||
|
||||
describe('GET /api/materials/download', () => {
|
||||
let tempDir: string
|
||||
let filePath: string
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks()
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'flnxi-download-'))
|
||||
filePath = path.join(tempDir, 'source.zip')
|
||||
await writeFile(filePath, '0123456789')
|
||||
|
||||
getServerSession.mockResolvedValue({
|
||||
user: {
|
||||
id: 'owner-1',
|
||||
role: 'ADMIN',
|
||||
email: 'owner@example.com',
|
||||
},
|
||||
})
|
||||
|
||||
prisma.material.findUnique.mockResolvedValue({
|
||||
id: 'material-1',
|
||||
userId: 'owner-1',
|
||||
productModel: 'MODEL',
|
||||
zipFilePath: filePath,
|
||||
zipFileName: 'source.zip',
|
||||
materialType: '3DMAX',
|
||||
categoryName: '分类',
|
||||
sceneName: '场景',
|
||||
authorName: '作者',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('legacy 本地文件下载应流式返回完整文件', async () => {
|
||||
const response = await GET(new NextRequest('http://localhost/api/materials/download?id=material-1'))
|
||||
|
||||
await expect(response.text()).resolves.toBe('0123456789')
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-length')).toBe('10')
|
||||
expect(response.headers.get('accept-ranges')).toBe('bytes')
|
||||
expect(response.headers.get('content-disposition')).toContain("filename*=UTF-8''")
|
||||
expect(getS3ObjectStream).not.toHaveBeenCalled()
|
||||
expect(ensureMaterialDownloadLogsTable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('legacy 本地文件 Range 下载应只返回指定范围', async () => {
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost/api/materials/download?id=material-1', {
|
||||
headers: {
|
||||
range: 'bytes=3-6',
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
await expect(response.text()).resolves.toBe('3456')
|
||||
expect(response.status).toBe(206)
|
||||
expect(response.headers.get('content-range')).toBe('bytes 3-6/10')
|
||||
expect(response.headers.get('content-length')).toBe('4')
|
||||
expect(response.headers.get('content-disposition')).toContain("filename*=UTF-8''")
|
||||
})
|
||||
})
|
||||
@ -4,7 +4,8 @@ import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getS3ObjectStream, isS3Url } from '@/lib/s3'
|
||||
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'
|
||||
import { readFile, access } from 'fs/promises'
|
||||
import { createLocalFileStreamResponse } from '@/lib/local-file-stream'
|
||||
import { access } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { Readable } from 'stream'
|
||||
|
||||
@ -214,33 +215,12 @@ export async function GET(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const fileBuffer = await readFile(filePath)
|
||||
|
||||
if (material.materialType === '视频' && rangeHeader) {
|
||||
const fileSize = fileBuffer.length
|
||||
const parts = rangeHeader.replace(/bytes=/, '').split('-')
|
||||
const start = parseInt(parts[0], 10)
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
|
||||
const chunkSize = end - start + 1
|
||||
const chunk = fileBuffer.slice(start, end + 1)
|
||||
|
||||
return new NextResponse(chunk as any, {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunkSize.toString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return new NextResponse(fileBuffer as any, {
|
||||
return createLocalFileStreamResponse(filePath, {
|
||||
contentType,
|
||||
range: rangeHeader,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': contentDispositionHeader,
|
||||
'Content-Length': fileBuffer.length.toString(),
|
||||
'Accept-Ranges': 'bytes', // 支持Range请求(用于视频流式传输)
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
@ -272,4 +252,3 @@ function toWebStream(body: any) {
|
||||
|
||||
return body as ReadableStream<any>
|
||||
}
|
||||
|
||||
|
||||
185
app/api/materials/share/__tests__/route.test.ts
Normal file
185
app/api/materials/share/__tests__/route.test.ts
Normal file
@ -0,0 +1,185 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { GET, POST } from '../route'
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
material: {
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/material-share-token', () => ({
|
||||
createMaterialShareToken: jest.fn(),
|
||||
findValidMaterialShareToken: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/url-rewrite', () => ({
|
||||
toSharedMaterialAssetProxyUrl: jest.fn((url, token) =>
|
||||
url ? `/api/materials/asset?url=${encodeURIComponent(url)}&shareToken=${encodeURIComponent(token)}` : null
|
||||
),
|
||||
}))
|
||||
|
||||
const { getServerSession } = require('next-auth')
|
||||
const { prisma } = require('@/lib/prisma')
|
||||
const { createMaterialShareToken, findValidMaterialShareToken } = require('@/lib/material-share-token')
|
||||
|
||||
describe('/api/materials/share', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('未登录时拒绝创建分享票据', async () => {
|
||||
getServerSession.mockResolvedValue(null)
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest('http://localhost/api/materials/share', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ materialId: 'material-1' }),
|
||||
})
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data.error).toBe('请先登录')
|
||||
expect(createMaterialShareToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('已登录用户可以为未删除素材创建分享票据', async () => {
|
||||
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
|
||||
prisma.user.findUnique.mockResolvedValue({ id: 'user-1', role: 'USER', isSuperAdmin: false })
|
||||
prisma.material.findFirst.mockResolvedValue({
|
||||
id: 'material-1',
|
||||
userId: 'user-1',
|
||||
ownerType: 'USER',
|
||||
ownerId: 'user-1',
|
||||
createdByUserId: 'user-1',
|
||||
})
|
||||
createMaterialShareToken.mockResolvedValue({
|
||||
token: 'share-token',
|
||||
expiresAt: new Date('2026-05-31T00:00:00.000Z'),
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest('http://localhost/api/materials/share', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ materialId: 'material-1' }),
|
||||
})
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(prisma.material.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: 'material-1', deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
ownerType: true,
|
||||
ownerId: true,
|
||||
createdByUserId: true,
|
||||
},
|
||||
})
|
||||
expect(createMaterialShareToken).toHaveBeenCalledWith('material-1', 'user-1')
|
||||
expect(data.shareToken).toBe('share-token')
|
||||
})
|
||||
|
||||
it('无关普通用户不能为别人的素材创建分享票据', async () => {
|
||||
getServerSession.mockResolvedValue({ user: { id: 'user-3', role: 'USER' } })
|
||||
prisma.user.findUnique.mockResolvedValue({ id: 'user-3', role: 'USER', isSuperAdmin: false })
|
||||
prisma.material.findFirst.mockResolvedValue({
|
||||
id: 'material-1',
|
||||
userId: 'user-1',
|
||||
ownerType: 'USER',
|
||||
ownerId: 'user-1',
|
||||
createdByUserId: 'user-1',
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest('http://localhost/api/materials/share', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ materialId: 'material-1' }),
|
||||
})
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data.error).toBe('无权分享该素材')
|
||||
expect(createMaterialShareToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('没有有效分享票据时拒绝公开读取素材', async () => {
|
||||
findValidMaterialShareToken.mockResolvedValue(null)
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost/api/materials/share?id=material-1&token=bad-token')
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data.error).toBe('分享链接无效或已过期')
|
||||
expect(prisma.material.findFirst).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('有效分享票据可读取分享数据并把图片改成带票据的代理地址', async () => {
|
||||
findValidMaterialShareToken.mockResolvedValue({ id: 'token-row', materialId: 'material-1' })
|
||||
prisma.material.findFirst.mockResolvedValue({
|
||||
id: 'material-1',
|
||||
previewUrl: 'http://minio:9000/bucket/preview.webp',
|
||||
productModel: 'SPU-001',
|
||||
brandName: null,
|
||||
seriesName: null,
|
||||
skuCode: null,
|
||||
materialTexture: null,
|
||||
categoryName: '店铺',
|
||||
sceneName: '场景',
|
||||
materialType: '3DMAX',
|
||||
renderer: 'CR',
|
||||
renderTime: null,
|
||||
imageResolution: null,
|
||||
colorMode: null,
|
||||
authorName: '作者',
|
||||
zipFileName: 'material.zip',
|
||||
zipFileSize: BigInt(1024),
|
||||
downloadCount: 0,
|
||||
likeCount: 0,
|
||||
displayImages: JSON.stringify(['http://minio:9000/bucket/detail.webp']),
|
||||
createdAt: new Date('2026-05-24T00:00:00.000Z'),
|
||||
isPremium: false,
|
||||
videoCoverUrl: null,
|
||||
videoPreview540Url: null,
|
||||
videoPreviewClipUrl: null,
|
||||
audioUrl: null,
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost/api/materials/share?id=material-1&token=share-token')
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(findValidMaterialShareToken).toHaveBeenCalledWith('share-token', 'material-1')
|
||||
expect(prisma.material.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: 'material-1', deletedAt: null },
|
||||
select: expect.objectContaining({
|
||||
id: true,
|
||||
previewUrl: true,
|
||||
audioUrl: true,
|
||||
}),
|
||||
})
|
||||
expect(data.zipFileSize).toBe('1024')
|
||||
expect(data.previewUrl).toContain('shareToken=share-token')
|
||||
expect(data.displayImages[0]).toContain('shareToken=share-token')
|
||||
})
|
||||
})
|
||||
@ -2,6 +2,12 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { createMaterialShareToken, findValidMaterialShareToken } from '@/lib/material-share-token'
|
||||
import { toSharedMaterialAssetProxyUrl } from '@/lib/url-rewrite'
|
||||
import {
|
||||
canManageMaterialShare,
|
||||
getMaterialShareActor,
|
||||
} from '@/lib/material-share-permissions'
|
||||
|
||||
function parseDisplayImages(value: string | null): string[] {
|
||||
if (!value) return []
|
||||
@ -18,11 +24,78 @@ function parseDisplayImages(value: string | null): string[] {
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Get single material for sharing (公开访问,无需登录)
|
||||
// 创建公开分享票据(登录用户)
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
const actor = await getMaterialShareActor(session)
|
||||
if (!actor) {
|
||||
return NextResponse.json(
|
||||
{ error: '请先登录' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const materialId = typeof body?.materialId === 'string' ? body.materialId.trim() : ''
|
||||
|
||||
if (!materialId) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少素材ID' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const material = await prisma.material.findFirst({
|
||||
where: {
|
||||
id: materialId,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
ownerType: true,
|
||||
ownerId: true,
|
||||
createdByUserId: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!material) {
|
||||
return NextResponse.json(
|
||||
{ error: '素材不存在或已删除' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!canManageMaterialShare(material, actor)) {
|
||||
return NextResponse.json(
|
||||
{ error: '无权分享该素材' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const tokenResult = await createMaterialShareToken(material.id, actor.id)
|
||||
|
||||
return NextResponse.json({
|
||||
materialId: material.id,
|
||||
shareToken: tokenResult.token,
|
||||
expiresAt: tokenResult.expiresAt.toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error creating material share token:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '创建分享链接失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Get single material for sharing (公开访问,但必须携带有效分享票据)
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const id = searchParams.get('id')
|
||||
const shareToken = searchParams.get('token') || searchParams.get('shareToken') || ''
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
@ -31,8 +104,19 @@ export async function GET(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const material = await prisma.material.findUnique({
|
||||
where: { id },
|
||||
const validShareToken = await findValidMaterialShareToken(shareToken, id)
|
||||
if (!validShareToken) {
|
||||
return NextResponse.json(
|
||||
{ error: '分享链接无效或已过期' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const material = await prisma.material.findFirst({
|
||||
where: {
|
||||
id,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
previewUrl: true,
|
||||
@ -56,6 +140,10 @@ export async function GET(req: NextRequest) {
|
||||
displayImages: true,
|
||||
createdAt: true,
|
||||
isPremium: true,
|
||||
videoCoverUrl: true,
|
||||
videoPreview540Url: true,
|
||||
videoPreviewClipUrl: true,
|
||||
audioUrl: true,
|
||||
}
|
||||
})
|
||||
|
||||
@ -70,7 +158,14 @@ export async function GET(req: NextRequest) {
|
||||
const materialData = {
|
||||
...material,
|
||||
zipFileSize: material.zipFileSize.toString(),
|
||||
displayImages: parseDisplayImages(material.displayImages),
|
||||
previewUrl: toSharedMaterialAssetProxyUrl(material.previewUrl, shareToken),
|
||||
displayImages: parseDisplayImages(material.displayImages).map((url) =>
|
||||
toSharedMaterialAssetProxyUrl(url, shareToken)
|
||||
),
|
||||
videoCoverUrl: toSharedMaterialAssetProxyUrl(material.videoCoverUrl, shareToken),
|
||||
videoPreview540Url: toSharedMaterialAssetProxyUrl(material.videoPreview540Url, shareToken),
|
||||
videoPreviewClipUrl: toSharedMaterialAssetProxyUrl(material.videoPreviewClipUrl, shareToken),
|
||||
audioUrl: toSharedMaterialAssetProxyUrl(material.audioUrl, shareToken),
|
||||
}
|
||||
|
||||
return NextResponse.json(materialData)
|
||||
|
||||
130
app/api/materials/share/tokens/[id]/__tests__/route.test.ts
Normal file
130
app/api/materials/share/tokens/[id]/__tests__/route.test.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { DELETE } from '../route'
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
materialShareToken: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const { getServerSession } = require('next-auth')
|
||||
const { prisma } = require('@/lib/prisma')
|
||||
|
||||
describe('DELETE /api/materials/share/tokens/[id]', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('未登录时拒绝撤销分享链接', async () => {
|
||||
getServerSession.mockResolvedValue(null)
|
||||
|
||||
const response = await DELETE(
|
||||
new NextRequest('http://localhost/api/materials/share/tokens/token-1', { method: 'DELETE' }),
|
||||
{ params: { id: 'token-1' } }
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data.error).toBe('请先登录')
|
||||
expect(prisma.materialShareToken.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('无关普通用户不能撤销别人的素材分享链接', async () => {
|
||||
getServerSession.mockResolvedValue({ user: { id: 'user-3', role: 'USER' } })
|
||||
prisma.user.findUnique.mockResolvedValue({ id: 'user-3', role: 'USER', isSuperAdmin: false })
|
||||
prisma.materialShareToken.findUnique.mockResolvedValue({
|
||||
id: 'token-1',
|
||||
revokedAt: null,
|
||||
material: {
|
||||
id: 'material-1',
|
||||
userId: 'user-1',
|
||||
ownerType: 'USER',
|
||||
ownerId: 'user-1',
|
||||
createdByUserId: 'user-1',
|
||||
deletedAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
const response = await DELETE(
|
||||
new NextRequest('http://localhost/api/materials/share/tokens/token-1', { method: 'DELETE' }),
|
||||
{ params: { id: 'token-1' } }
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data.error).toBe('无权撤销该分享链接')
|
||||
expect(prisma.materialShareToken.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('素材负责人可以撤销分享链接', async () => {
|
||||
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
|
||||
prisma.user.findUnique.mockResolvedValue({ id: 'user-1', role: 'USER', isSuperAdmin: false })
|
||||
prisma.materialShareToken.findUnique.mockResolvedValue({
|
||||
id: 'token-1',
|
||||
revokedAt: null,
|
||||
material: {
|
||||
id: 'material-1',
|
||||
userId: 'user-1',
|
||||
ownerType: 'USER',
|
||||
ownerId: 'user-1',
|
||||
createdByUserId: 'user-1',
|
||||
deletedAt: null,
|
||||
},
|
||||
})
|
||||
prisma.materialShareToken.update.mockResolvedValue({ id: 'token-1' })
|
||||
|
||||
const response = await DELETE(
|
||||
new NextRequest('http://localhost/api/materials/share/tokens/token-1', { method: 'DELETE' }),
|
||||
{ params: { id: 'token-1' } }
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(prisma.materialShareToken.update).toHaveBeenCalledWith({
|
||||
where: { id: 'token-1' },
|
||||
data: { revokedAt: expect.any(Date) },
|
||||
})
|
||||
})
|
||||
|
||||
it('已经撤销的分享链接再次撤销也返回成功且不重复写入', async () => {
|
||||
getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } })
|
||||
prisma.user.findUnique.mockResolvedValue({ id: 'admin-1', role: 'ADMIN', isSuperAdmin: false })
|
||||
prisma.materialShareToken.findUnique.mockResolvedValue({
|
||||
id: 'token-1',
|
||||
revokedAt: new Date('2026-05-24T00:00:00.000Z'),
|
||||
material: {
|
||||
id: 'material-1',
|
||||
userId: 'user-1',
|
||||
ownerType: 'USER',
|
||||
ownerId: 'user-1',
|
||||
createdByUserId: 'user-1',
|
||||
deletedAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
const response = await DELETE(
|
||||
new NextRequest('http://localhost/api/materials/share/tokens/token-1', { method: 'DELETE' }),
|
||||
{ params: { id: 'token-1' } }
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(prisma.materialShareToken.update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
82
app/api/materials/share/tokens/[id]/route.ts
Normal file
82
app/api/materials/share/tokens/[id]/route.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
canManageMaterialShare,
|
||||
getMaterialShareActor,
|
||||
} from '@/lib/material-share-permissions'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
const actor = await getMaterialShareActor(session)
|
||||
if (!actor) {
|
||||
return NextResponse.json(
|
||||
{ error: '请先登录' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const tokenId = params.id?.trim()
|
||||
if (!tokenId) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少分享链接ID' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const token = await prisma.materialShareToken.findUnique({
|
||||
where: { id: tokenId },
|
||||
include: {
|
||||
material: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
ownerType: true,
|
||||
ownerId: true,
|
||||
createdByUserId: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!token || token.material.deletedAt) {
|
||||
return NextResponse.json(
|
||||
{ error: '分享链接不存在或素材已删除' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!canManageMaterialShare(token.material, actor)) {
|
||||
return NextResponse.json(
|
||||
{ error: '无权撤销该分享链接' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!token.revokedAt) {
|
||||
await prisma.materialShareToken.update({
|
||||
where: { id: token.id },
|
||||
data: { revokedAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
id: token.id,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[MaterialShareTokens] DELETE error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '撤销分享链接失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
129
app/api/materials/share/tokens/__tests__/route.test.ts
Normal file
129
app/api/materials/share/tokens/__tests__/route.test.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { GET } from '../route'
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
material: {
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
materialShareToken: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const { getServerSession } = require('next-auth')
|
||||
const { prisma } = require('@/lib/prisma')
|
||||
|
||||
describe('GET /api/materials/share/tokens', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('未登录时拒绝查看分享链接列表', async () => {
|
||||
getServerSession.mockResolvedValue(null)
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost/api/materials/share/tokens?materialId=material-1')
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data.error).toBe('请先登录')
|
||||
expect(prisma.material.findFirst).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('无关普通用户不能查看别人的素材分享链接', async () => {
|
||||
getServerSession.mockResolvedValue({ user: { id: 'user-3', role: 'USER' } })
|
||||
prisma.user.findUnique.mockResolvedValue({ id: 'user-3', role: 'USER', isSuperAdmin: false })
|
||||
prisma.material.findFirst.mockResolvedValue({
|
||||
id: 'material-1',
|
||||
userId: 'user-1',
|
||||
ownerType: 'USER',
|
||||
ownerId: 'user-1',
|
||||
createdByUserId: 'user-1',
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost/api/materials/share/tokens?materialId=material-1')
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data.error).toBe('无权查看该素材的分享链接')
|
||||
expect(prisma.materialShareToken.findMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('素材负责人可以查看分享链接状态,但不会返回 token 明文或 hash', async () => {
|
||||
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
|
||||
prisma.user.findUnique.mockResolvedValue({ id: 'user-1', role: 'USER', isSuperAdmin: false })
|
||||
prisma.material.findFirst.mockResolvedValue({
|
||||
id: 'material-1',
|
||||
userId: 'user-1',
|
||||
ownerType: 'USER',
|
||||
ownerId: 'user-1',
|
||||
createdByUserId: 'user-1',
|
||||
})
|
||||
prisma.materialShareToken.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'token-1',
|
||||
materialId: 'material-1',
|
||||
createdByUserId: 'user-1',
|
||||
createdAt: new Date('2026-05-24T00:00:00.000Z'),
|
||||
expiresAt: new Date('2999-01-01T00:00:00.000Z'),
|
||||
revokedAt: null,
|
||||
createdBy: {
|
||||
id: 'user-1',
|
||||
username: 'owner',
|
||||
email: 'owner@example.com',
|
||||
realName: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'token-2',
|
||||
materialId: 'material-1',
|
||||
createdByUserId: 'user-1',
|
||||
createdAt: new Date('2026-05-23T00:00:00.000Z'),
|
||||
expiresAt: new Date('2026-05-30T00:00:00.000Z'),
|
||||
revokedAt: new Date('2026-05-24T01:00:00.000Z'),
|
||||
createdBy: {
|
||||
id: 'user-1',
|
||||
username: 'owner',
|
||||
email: 'owner@example.com',
|
||||
realName: '负责人',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost/api/materials/share/tokens?materialId=material-1')
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.tokens).toHaveLength(2)
|
||||
expect(data.tokens[0]).toEqual(expect.objectContaining({
|
||||
id: 'token-1',
|
||||
status: 'active',
|
||||
createdByName: 'owner',
|
||||
}))
|
||||
expect(data.tokens[1]).toEqual(expect.objectContaining({
|
||||
id: 'token-2',
|
||||
status: 'revoked',
|
||||
createdByName: '负责人',
|
||||
}))
|
||||
expect(JSON.stringify(data)).not.toContain('tokenHash')
|
||||
expect(JSON.stringify(data)).not.toContain('share-token')
|
||||
})
|
||||
})
|
||||
107
app/api/materials/share/tokens/route.ts
Normal file
107
app/api/materials/share/tokens/route.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
canManageMaterialShare,
|
||||
getMaterialShareActor,
|
||||
} from '@/lib/material-share-permissions'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
const actor = await getMaterialShareActor(session)
|
||||
if (!actor) {
|
||||
return NextResponse.json(
|
||||
{ error: '请先登录' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const materialId = searchParams.get('materialId')?.trim() || ''
|
||||
if (!materialId) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少素材ID' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const material = await prisma.material.findFirst({
|
||||
where: {
|
||||
id: materialId,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
ownerType: true,
|
||||
ownerId: true,
|
||||
createdByUserId: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!material) {
|
||||
return NextResponse.json(
|
||||
{ error: '素材不存在或已删除' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!canManageMaterialShare(material, actor)) {
|
||||
return NextResponse.json(
|
||||
{ error: '无权查看该素材的分享链接' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const tokens = await prisma.materialShareToken.findMany({
|
||||
where: { materialId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
select: {
|
||||
id: true,
|
||||
materialId: true,
|
||||
createdByUserId: true,
|
||||
expiresAt: true,
|
||||
revokedAt: true,
|
||||
createdAt: true,
|
||||
createdBy: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
realName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
materialId,
|
||||
tokens: tokens.map((token) => ({
|
||||
id: token.id,
|
||||
materialId: token.materialId,
|
||||
createdByUserId: token.createdByUserId,
|
||||
createdByName: token.createdBy.realName || token.createdBy.username || token.createdBy.email,
|
||||
createdAt: token.createdAt.toISOString(),
|
||||
expiresAt: token.expiresAt.toISOString(),
|
||||
revokedAt: token.revokedAt ? token.revokedAt.toISOString() : null,
|
||||
status: token.revokedAt
|
||||
? 'revoked'
|
||||
: token.expiresAt <= now
|
||||
? 'expired'
|
||||
: 'active',
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[MaterialShareTokens] GET error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '获取分享链接失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
87
app/api/materials/stream/__tests__/route.test.ts
Normal file
87
app/api/materials/stream/__tests__/route.test.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { Readable } from 'stream'
|
||||
import { GET } from '../route'
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
material: {
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/s3', () => ({
|
||||
getS3ObjectStream: jest.fn(),
|
||||
isS3Url: jest.fn((value?: string | null) => /^https?:\/\//.test(value || '')),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/material-share-token', () => ({
|
||||
findValidMaterialShareToken: jest.fn(),
|
||||
}))
|
||||
|
||||
const { getServerSession } = require('next-auth')
|
||||
const { prisma } = require('@/lib/prisma')
|
||||
const { getS3ObjectStream, isS3Url } = require('@/lib/s3')
|
||||
const { findValidMaterialShareToken } = require('@/lib/material-share-token')
|
||||
|
||||
describe('GET /api/materials/stream', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
isS3Url.mockImplementation((value?: string | null) => /^https?:\/\//.test(value || ''))
|
||||
})
|
||||
|
||||
it('未登录且没有有效分享票据时拒绝播放', async () => {
|
||||
prisma.material.findFirst.mockResolvedValue({
|
||||
id: 'material-1',
|
||||
zipFilePath: 'http://minio:9000/bucket/video.mp4',
|
||||
zipFileName: 'video.mp4',
|
||||
materialType: '视频',
|
||||
materialKind: 'VIDEO',
|
||||
})
|
||||
getServerSession.mockResolvedValue(null)
|
||||
findValidMaterialShareToken.mockResolvedValue(null)
|
||||
|
||||
const response = await GET(new NextRequest('http://localhost/api/materials/stream?id=material-1'))
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data.error).toBe('请先登录或使用有效分享链接')
|
||||
expect(getS3ObjectStream).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('未登录但分享票据有效时允许播放', async () => {
|
||||
prisma.material.findFirst.mockResolvedValue({
|
||||
id: 'material-1',
|
||||
zipFilePath: 'http://minio:9000/bucket/video.mp4',
|
||||
zipFileName: 'video.mp4',
|
||||
materialType: '视频',
|
||||
materialKind: 'VIDEO',
|
||||
})
|
||||
getServerSession.mockResolvedValue(null)
|
||||
findValidMaterialShareToken.mockResolvedValue({ id: 'token-row', materialId: 'material-1' })
|
||||
getS3ObjectStream.mockResolvedValue({
|
||||
body: Readable.from([Buffer.from('video-body')]),
|
||||
contentLength: 10,
|
||||
contentType: 'video/mp4',
|
||||
acceptRanges: 'bytes',
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost/api/materials/stream?id=material-1&shareToken=share-token')
|
||||
)
|
||||
const body = await response.text()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body).toBe('video-body')
|
||||
expect(findValidMaterialShareToken).toHaveBeenCalledWith('share-token', 'material-1')
|
||||
expect(response.headers.get('access-control-allow-origin')).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -1,17 +1,22 @@
|
||||
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 { readFile, access } from 'fs/promises'
|
||||
import { findValidMaterialShareToken } from '@/lib/material-share-token'
|
||||
import { createLocalFileStreamResponse } from '@/lib/local-file-stream'
|
||||
import { access } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { Readable } from 'stream'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// 视频流式传输API(允许未登录用户访问,仅用于视频播放)
|
||||
// 视频/音频流式传输 API:内部播放需登录,公开分享需有效分享票据。
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const id = searchParams.get('id')
|
||||
const shareToken = searchParams.get('shareToken') || searchParams.get('token') || ''
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
@ -20,8 +25,18 @@ export async function GET(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const material = await prisma.material.findUnique({
|
||||
where: { id },
|
||||
const material = await prisma.material.findFirst({
|
||||
where: {
|
||||
id,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
zipFilePath: true,
|
||||
zipFileName: true,
|
||||
materialType: true,
|
||||
materialKind: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!material) {
|
||||
@ -31,6 +46,20 @@ export async function GET(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user) {
|
||||
const validShareToken = shareToken
|
||||
? await findValidMaterialShareToken(shareToken, material.id)
|
||||
: null
|
||||
|
||||
if (!validShareToken) {
|
||||
return NextResponse.json(
|
||||
{ error: '请先登录或使用有效分享链接' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 只允许视频和音频类型
|
||||
const isVideo = material.materialType === '视频' || material.materialKind === 'VIDEO'
|
||||
const isAudio = material.materialType === '音频' || material.materialKind === 'AUDIO'
|
||||
@ -79,12 +108,7 @@ export async function GET(req: NextRequest) {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': s3Object.contentType || contentType,
|
||||
'Accept-Ranges': s3Object.acceptRanges || 'bytes',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
// CORS headers for audio visualization
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Range',
|
||||
'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges',
|
||||
'Cache-Control': 'private, max-age=300',
|
||||
}
|
||||
|
||||
if (range && s3Object.contentRange) {
|
||||
@ -116,44 +140,11 @@ export async function GET(req: NextRequest) {
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
const fileBuffer = await readFile(filePath)
|
||||
|
||||
// CORS headers for audio visualization
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Range',
|
||||
'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges',
|
||||
}
|
||||
|
||||
if (range) {
|
||||
const fileSize = fileBuffer.length
|
||||
const parts = range.replace(/bytes=/, '').split('-')
|
||||
const start = parseInt(parts[0], 10)
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
|
||||
const chunkSize = end - start + 1
|
||||
const chunk = fileBuffer.slice(start, end + 1)
|
||||
|
||||
return new NextResponse(chunk as any, {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunkSize.toString(),
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
...corsHeaders,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return new NextResponse(fileBuffer as any, {
|
||||
return createLocalFileStreamResponse(filePath, {
|
||||
contentType,
|
||||
range,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': fileBuffer.length.toString(),
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
...corsHeaders,
|
||||
'Cache-Control': 'private, max-age=300',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
* SharePage 组件
|
||||
*
|
||||
* 素材分享页面
|
||||
* 显示素材详情、预览图、下载等功能
|
||||
* 显示素材详情和预览图
|
||||
* 支持PSD/模特类型的多图展示
|
||||
* 使用三层架构样式系统,支持深浅色主题
|
||||
*
|
||||
@ -39,6 +39,10 @@ interface Material {
|
||||
authorName: string
|
||||
zipFileName: string
|
||||
zipFileSize: string | number
|
||||
videoCoverUrl?: string | null
|
||||
videoPreview540Url?: string | null
|
||||
videoPreviewClipUrl?: string | null
|
||||
audioUrl?: string | null
|
||||
downloadCount: number
|
||||
likeCount: number
|
||||
displayImages: string[] // PSD展示图片URLs
|
||||
@ -53,11 +57,10 @@ export default function SharePage() {
|
||||
const { alert } = useDialog()
|
||||
const params = useParams()
|
||||
const materialId = params.id as string
|
||||
const [shareToken, setShareToken] = useState('')
|
||||
|
||||
const [material, setMaterial] = useState<Material | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [downloadProgress, setDownloadProgress] = useState(0)
|
||||
const [showBackToTop, setShowBackToTop] = useState(false)
|
||||
const [aiEditEnabled, setAiEditEnabled] = useState(
|
||||
(process.env.NEXT_PUBLIC_AI_EDIT_ENABLED || '').toLowerCase() === 'true'
|
||||
@ -65,7 +68,11 @@ export default function SharePage() {
|
||||
|
||||
const fetchMaterial = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/materials/share?id=${materialId}`)
|
||||
const tokenParam = new URLSearchParams(window.location.search).get('token') || ''
|
||||
setShareToken(tokenParam)
|
||||
const response = await fetch(
|
||||
`/api/materials/share?id=${encodeURIComponent(materialId)}&token=${encodeURIComponent(tokenParam)}`
|
||||
)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setMaterial(data)
|
||||
@ -96,7 +103,7 @@ export default function SharePage() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [materialId, router, session])
|
||||
}, [materialId, router, session, alert])
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'loading') {
|
||||
@ -181,77 +188,6 @@ export default function SharePage() {
|
||||
)
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!material) return
|
||||
|
||||
setDownloading(true)
|
||||
setDownloadProgress(0)
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percentComplete = Math.round((e.loaded / e.total) * 100)
|
||||
setDownloadProgress(percentComplete)
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const blob = new Blob([xhr.response], { type: 'application/zip' })
|
||||
|
||||
const contentDisposition = xhr.getResponseHeader('Content-Disposition')
|
||||
let filename = 'material.zip'
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
|
||||
if (filenameMatch && filenameMatch[1]) {
|
||||
filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''))
|
||||
}
|
||||
}
|
||||
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('下载处理失败:', error)
|
||||
alert({
|
||||
title: '下载失败',
|
||||
message: '下载失败,请稍后重试',
|
||||
variant: 'error',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
alert({
|
||||
title: '下载失败',
|
||||
message: '下载失败',
|
||||
variant: 'error',
|
||||
})
|
||||
}
|
||||
setDownloading(false)
|
||||
setDownloadProgress(0)
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
alert({
|
||||
title: '下载失败',
|
||||
message: '下载失败,请稍后重试',
|
||||
variant: 'error',
|
||||
})
|
||||
setDownloading(false)
|
||||
setDownloadProgress(0)
|
||||
})
|
||||
|
||||
xhr.open('GET', `/api/materials/download?id=${material.id}`)
|
||||
xhr.responseType = 'arraybuffer'
|
||||
xhr.send()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||
@ -357,39 +293,6 @@ export default function SharePage() {
|
||||
{renderAdditionalInfo('compact')}
|
||||
</div>
|
||||
|
||||
{/* Download Button */}
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
className={cn(
|
||||
'w-full py-3 text-sm font-medium relative overflow-hidden disabled:opacity-75 rounded-xl',
|
||||
'bg-blue-600 hover:bg-blue-700 text-white transition-colors',
|
||||
styles.buttonStyles.primary
|
||||
)}
|
||||
>
|
||||
{downloading ? (
|
||||
<>
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 bg-blue-600 transition-all duration-300"
|
||||
style={{ width: `${downloadProgress}%` }}
|
||||
/>
|
||||
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
下载中 {downloadProgress}%
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
立即下载
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* AI改图(占位,默认隐藏。后续由开关控制显示) */}
|
||||
{aiEditEnabled && (
|
||||
<button
|
||||
@ -425,10 +328,13 @@ export default function SharePage() {
|
||||
<video
|
||||
controls
|
||||
className="w-full h-auto max-h-[800px]"
|
||||
poster={material.previewUrl}
|
||||
poster={material.videoCoverUrl || material.previewUrl}
|
||||
preload="metadata"
|
||||
>
|
||||
<source src={`/api/materials/stream?id=${material.id}`} type="video/mp4" />
|
||||
<source
|
||||
src={`/api/materials/stream?id=${material.id}&shareToken=${encodeURIComponent(shareToken)}`}
|
||||
type="video/mp4"
|
||||
/>
|
||||
您的浏览器不支持视频播放。
|
||||
</video>
|
||||
</div>
|
||||
@ -483,39 +389,6 @@ export default function SharePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Download Button */}
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
className={cn(
|
||||
'w-full py-4 text-lg font-medium relative overflow-hidden disabled:opacity-75 rounded-xl',
|
||||
'bg-blue-600 hover:bg-blue-700 text-white transition-colors',
|
||||
styles.buttonStyles.primary
|
||||
)}
|
||||
>
|
||||
{downloading ? (
|
||||
<>
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 bg-blue-600 transition-all duration-300"
|
||||
style={{ width: `${downloadProgress}%` }}
|
||||
/>
|
||||
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||
<svg className="w-6 h-6 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
下载中 {downloadProgress}%
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
立即下载
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className={cn(
|
||||
@ -593,39 +466,6 @@ export default function SharePage() {
|
||||
|
||||
{renderAdditionalInfo('full')}
|
||||
|
||||
{/* Download Button */}
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
className={cn(
|
||||
'w-full py-4 text-lg font-medium relative overflow-hidden disabled:opacity-75 rounded-xl',
|
||||
'bg-blue-600 hover:bg-blue-700 text-white transition-colors',
|
||||
styles.buttonStyles.primary
|
||||
)}
|
||||
>
|
||||
{downloading ? (
|
||||
<>
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 bg-blue-600 transition-all duration-300"
|
||||
style={{ width: `${downloadProgress}%` }}
|
||||
/>
|
||||
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||
<svg className="w-6 h-6 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
下载中 {downloadProgress}%
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
立即下载
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className={cn(
|
||||
|
||||
69
lib/__tests__/local-file-stream.test.ts
Normal file
69
lib/__tests__/local-file-stream.test.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import path from 'path'
|
||||
import { createLocalFileStreamResponse } from '../local-file-stream'
|
||||
|
||||
describe('createLocalFileStreamResponse', () => {
|
||||
let tempDir: string
|
||||
let filePath: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'flnxi-local-stream-'))
|
||||
filePath = path.join(tempDir, 'sample.txt')
|
||||
await writeFile(filePath, '0123456789')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('无 Range 时应流式返回完整文件', async () => {
|
||||
const response = await createLocalFileStreamResponse(filePath, {
|
||||
contentType: 'text/plain',
|
||||
headers: {
|
||||
'Cache-Control': 'private, max-age=300',
|
||||
},
|
||||
})
|
||||
|
||||
await expect(response.text()).resolves.toBe('0123456789')
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-type')).toBe('text/plain')
|
||||
expect(response.headers.get('content-length')).toBe('10')
|
||||
expect(response.headers.get('accept-ranges')).toBe('bytes')
|
||||
expect(response.headers.get('cache-control')).toBe('private, max-age=300')
|
||||
})
|
||||
|
||||
test('Range 请求应只返回指定字节范围', async () => {
|
||||
const response = await createLocalFileStreamResponse(filePath, {
|
||||
contentType: 'text/plain',
|
||||
range: 'bytes=2-5',
|
||||
})
|
||||
|
||||
await expect(response.text()).resolves.toBe('2345')
|
||||
expect(response.status).toBe(206)
|
||||
expect(response.headers.get('content-range')).toBe('bytes 2-5/10')
|
||||
expect(response.headers.get('content-length')).toBe('4')
|
||||
})
|
||||
|
||||
test('后缀 Range 请求应返回文件尾部', async () => {
|
||||
const response = await createLocalFileStreamResponse(filePath, {
|
||||
contentType: 'text/plain',
|
||||
range: 'bytes=-3',
|
||||
})
|
||||
|
||||
await expect(response.text()).resolves.toBe('789')
|
||||
expect(response.status).toBe(206)
|
||||
expect(response.headers.get('content-range')).toBe('bytes 7-9/10')
|
||||
expect(response.headers.get('content-length')).toBe('3')
|
||||
})
|
||||
|
||||
test('非法 Range 应返回 416', async () => {
|
||||
const response = await createLocalFileStreamResponse(filePath, {
|
||||
contentType: 'text/plain',
|
||||
range: 'bytes=20-30',
|
||||
})
|
||||
|
||||
expect(response.status).toBe(416)
|
||||
expect(response.headers.get('content-range')).toBe('bytes */10')
|
||||
})
|
||||
})
|
||||
55
lib/__tests__/material-share-token.test.ts
Normal file
55
lib/__tests__/material-share-token.test.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import {
|
||||
createMaterialShareToken,
|
||||
findValidMaterialShareToken,
|
||||
} from '../material-share-token'
|
||||
import { prisma } from '../prisma'
|
||||
|
||||
jest.mock('../prisma', () => ({
|
||||
prisma: {
|
||||
materialShareToken: {
|
||||
create: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
describe('material-share-token', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('创建分享票据时只保存哈希,不保存明文 token', async () => {
|
||||
const token = await createMaterialShareToken('material-1', 'user-1')
|
||||
|
||||
expect(token.token).toEqual(expect.any(String))
|
||||
expect(token.token.length).toBeGreaterThan(30)
|
||||
expect(prisma.materialShareToken.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tokenHash: expect.any(String),
|
||||
materialId: 'material-1',
|
||||
createdByUserId: 'user-1',
|
||||
expiresAt: expect.any(Date),
|
||||
}),
|
||||
})
|
||||
expect((prisma.materialShareToken.create as jest.Mock).mock.calls[0][0].data.tokenHash).not.toBe(token.token)
|
||||
})
|
||||
|
||||
it('查找有效分享票据时要求未过期、未撤销且素材未删除', async () => {
|
||||
await findValidMaterialShareToken('plain-token', 'material-1')
|
||||
|
||||
expect(prisma.materialShareToken.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
tokenHash: expect.any(String),
|
||||
materialId: 'material-1',
|
||||
revokedAt: null,
|
||||
expiresAt: { gt: expect.any(Date) },
|
||||
material: {
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
material: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
332
lib/__tests__/url-rewrite.test.ts
Normal file
332
lib/__tests__/url-rewrite.test.ts
Normal file
@ -0,0 +1,332 @@
|
||||
/**
|
||||
* URL 重写工具测试
|
||||
* 测试 lib/url-rewrite.ts 中的核心功能
|
||||
*/
|
||||
|
||||
export {}
|
||||
|
||||
// 保存原始环境变量
|
||||
const originalEnv = process.env;
|
||||
|
||||
describe('url-rewrite', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('rewriteStorageUrlForClient', () => {
|
||||
describe('基本功能', () => {
|
||||
it('应该返回 null 当输入为空', async () => {
|
||||
const { rewriteStorageUrlForClient } = await import('../url-rewrite');
|
||||
expect(rewriteStorageUrlForClient(null)).toBeNull();
|
||||
expect(rewriteStorageUrlForClient(undefined)).toBeNull();
|
||||
// 空字符串在第一个检查中被判断为 falsy,返回 '' ?? null = ''
|
||||
// 这是代码的实际行为:空字符串返回空字符串
|
||||
expect(rewriteStorageUrlForClient('')).toBe('');
|
||||
// 空白字符串通过第一个检查,trim 后为空,返回 null
|
||||
expect(rewriteStorageUrlForClient(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('应该保持相对路径不变', async () => {
|
||||
const { rewriteStorageUrlForClient } = await import('../url-rewrite');
|
||||
expect(rewriteStorageUrlForClient('path/to/file.jpg')).toBe('path/to/file.jpg');
|
||||
expect(rewriteStorageUrlForClient('/absolute/path.jpg')).toBe('/absolute/path.jpg');
|
||||
});
|
||||
|
||||
it('应该保持 data: URL 不变', async () => {
|
||||
const { rewriteStorageUrlForClient } = await import('../url-rewrite');
|
||||
const dataUrl = 'data:image/png;base64,iVBORw0KGgo=';
|
||||
expect(rewriteStorageUrlForClient(dataUrl)).toBe(dataUrl);
|
||||
});
|
||||
|
||||
it('应该保持非本地 URL 不变', async () => {
|
||||
process.env.SERVER_IP = '192.168.1.100';
|
||||
process.env.MINIO_API_PORT = '9000';
|
||||
|
||||
const { rewriteStorageUrlForClient } = await import('../url-rewrite');
|
||||
const externalUrl = 'https://cdn.example.com/file.jpg';
|
||||
expect(rewriteStorageUrlForClient(externalUrl)).toBe(externalUrl);
|
||||
});
|
||||
});
|
||||
|
||||
describe('localhost 重写', () => {
|
||||
it('应该将 localhost URL 重写为 SERVER_IP', async () => {
|
||||
process.env.SERVER_IP = '192.168.1.100';
|
||||
process.env.MINIO_API_PORT = '9000';
|
||||
|
||||
const { rewriteStorageUrlForClient } = await import('../url-rewrite');
|
||||
const result = rewriteStorageUrlForClient('http://localhost:9000/bucket/file.jpg');
|
||||
|
||||
expect(result).toBe('http://192.168.1.100:9000/bucket/file.jpg');
|
||||
});
|
||||
|
||||
it('应该将 127.0.0.1 URL 重写为 SERVER_IP', async () => {
|
||||
process.env.SERVER_IP = '192.168.1.100';
|
||||
process.env.MINIO_API_PORT = '9000';
|
||||
|
||||
const { rewriteStorageUrlForClient } = await import('../url-rewrite');
|
||||
const result = rewriteStorageUrlForClient('http://127.0.0.1:9000/bucket/file.jpg');
|
||||
|
||||
expect(result).toBe('http://192.168.1.100:9000/bucket/file.jpg');
|
||||
});
|
||||
|
||||
it('没有 SERVER_IP 时应该保持原样', async () => {
|
||||
delete process.env.SERVER_IP;
|
||||
delete process.env.APP_PUBLIC_URL;
|
||||
delete process.env.NEXTAUTH_URL;
|
||||
delete process.env.S3_PUBLIC_DOMAIN;
|
||||
|
||||
const { rewriteStorageUrlForClient } = await import('../url-rewrite');
|
||||
const url = 'http://localhost:9000/bucket/file.jpg';
|
||||
expect(rewriteStorageUrlForClient(url)).toBe(url);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Docker 内部 IP 重写', () => {
|
||||
it('应该将 172.17.x.x (docker0) 重写为 SERVER_IP', async () => {
|
||||
process.env.SERVER_IP = '192.168.1.100';
|
||||
process.env.MINIO_API_PORT = '9000';
|
||||
|
||||
const { rewriteStorageUrlForClient } = await import('../url-rewrite');
|
||||
const result = rewriteStorageUrlForClient('http://172.17.0.2:9000/bucket/file.jpg');
|
||||
|
||||
expect(result).toBe('http://192.168.1.100:9000/bucket/file.jpg');
|
||||
});
|
||||
|
||||
it('应该将 172.18.x.x (docker network) 重写为 SERVER_IP', async () => {
|
||||
process.env.SERVER_IP = '192.168.1.100';
|
||||
process.env.MINIO_API_PORT = '9000';
|
||||
|
||||
const { rewriteStorageUrlForClient } = await import('../url-rewrite');
|
||||
const result = rewriteStorageUrlForClient('http://172.18.0.5:9000/bucket/file.jpg');
|
||||
|
||||
expect(result).toBe('http://192.168.1.100:9000/bucket/file.jpg');
|
||||
});
|
||||
|
||||
it('应该将 172.19-31.x.x 范围内的 IP 重写为 SERVER_IP', async () => {
|
||||
process.env.SERVER_IP = '192.168.1.100';
|
||||
process.env.MINIO_API_PORT = '9000';
|
||||
|
||||
const { rewriteStorageUrlForClient } = await import('../url-rewrite');
|
||||
|
||||
// 测试多个 Docker 网络 IP 段
|
||||
const dockerIPs = [
|
||||
'172.19.0.1',
|
||||
'172.20.0.1',
|
||||
'172.25.0.1',
|
||||
'172.30.0.1',
|
||||
'172.31.255.255',
|
||||
];
|
||||
|
||||
for (const ip of dockerIPs) {
|
||||
jest.resetModules();
|
||||
process.env.SERVER_IP = '192.168.1.100';
|
||||
process.env.MINIO_API_PORT = '9000';
|
||||
|
||||
const { rewriteStorageUrlForClient: rewrite } = await import('../url-rewrite');
|
||||
const result = rewrite(`http://${ip}:9000/bucket/file.jpg`);
|
||||
expect(result).toBe('http://192.168.1.100:9000/bucket/file.jpg');
|
||||
}
|
||||
});
|
||||
|
||||
it('不应该重写非 Docker 内部 IP (172.16.x.x)', async () => {
|
||||
process.env.SERVER_IP = '192.168.1.100';
|
||||
process.env.MINIO_API_PORT = '9000';
|
||||
|
||||
const { rewriteStorageUrlForClient } = await import('../url-rewrite');
|
||||
// 172.16.x.x 不在 Docker 默认网络范围内
|
||||
const result = rewriteStorageUrlForClient('http://172.16.0.1:9000/bucket/file.jpg');
|
||||
|
||||
expect(result).toBe('http://172.16.0.1:9000/bucket/file.jpg');
|
||||
});
|
||||
|
||||
it('不应该重写 10.x.x.x 私有 IP', async () => {
|
||||
process.env.SERVER_IP = '192.168.1.100';
|
||||
process.env.MINIO_API_PORT = '9000';
|
||||
|
||||
const { rewriteStorageUrlForClient } = await import('../url-rewrite');
|
||||
const result = rewriteStorageUrlForClient('http://10.0.0.5:9000/bucket/file.jpg');
|
||||
|
||||
expect(result).toBe('http://10.0.0.5:9000/bucket/file.jpg');
|
||||
});
|
||||
|
||||
it('不应该重写 192.168.x.x 私有 IP', async () => {
|
||||
process.env.SERVER_IP = '192.168.1.100';
|
||||
process.env.MINIO_API_PORT = '9000';
|
||||
|
||||
const { rewriteStorageUrlForClient } = await import('../url-rewrite');
|
||||
const result = rewriteStorageUrlForClient('http://192.168.0.5:9000/bucket/file.jpg');
|
||||
|
||||
expect(result).toBe('http://192.168.0.5:9000/bucket/file.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('端口处理', () => {
|
||||
it('应该使用 S3_PUBLIC_DOMAIN 中的端口', async () => {
|
||||
process.env.SERVER_IP = '192.168.1.100';
|
||||
process.env.S3_PUBLIC_DOMAIN = 'http://example.com:8080/bucket';
|
||||
process.env.MINIO_API_PORT = '9000';
|
||||
|
||||
const { rewriteStorageUrlForClient } = await import('../url-rewrite');
|
||||
const result = rewriteStorageUrlForClient('http://localhost:9000/bucket/file.jpg');
|
||||
|
||||
expect(result).toBe('http://192.168.1.100:8080/bucket/file.jpg');
|
||||
});
|
||||
|
||||
it('应该使用默认端口 9000 当没有配置时', async () => {
|
||||
process.env.SERVER_IP = '192.168.1.100';
|
||||
delete process.env.S3_PUBLIC_DOMAIN;
|
||||
delete process.env.MINIO_API_PORT;
|
||||
|
||||
const { rewriteStorageUrlForClient } = await import('../url-rewrite');
|
||||
const result = rewriteStorageUrlForClient('http://localhost:3000/bucket/file.jpg');
|
||||
|
||||
expect(result).toBe('http://192.168.1.100:9000/bucket/file.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('从 APP_PUBLIC_URL 推断主机', () => {
|
||||
it('应该从 APP_PUBLIC_URL 推断主机名', async () => {
|
||||
delete process.env.SERVER_IP;
|
||||
process.env.APP_PUBLIC_URL = 'http://myapp.example.com:3000';
|
||||
process.env.MINIO_API_PORT = '9000';
|
||||
|
||||
const { rewriteStorageUrlForClient } = await import('../url-rewrite');
|
||||
const result = rewriteStorageUrlForClient('http://localhost:9000/bucket/file.jpg');
|
||||
|
||||
expect(result).toBe('http://myapp.example.com:9000/bucket/file.jpg');
|
||||
});
|
||||
|
||||
it('应该从 NEXTAUTH_URL 推断主机名', async () => {
|
||||
delete process.env.SERVER_IP;
|
||||
delete process.env.APP_PUBLIC_URL;
|
||||
process.env.NEXTAUTH_URL = 'http://auth.example.com:3000';
|
||||
process.env.MINIO_API_PORT = '9000';
|
||||
|
||||
const { rewriteStorageUrlForClient } = await import('../url-rewrite');
|
||||
const result = rewriteStorageUrlForClient('http://localhost:9000/bucket/file.jpg');
|
||||
|
||||
expect(result).toBe('http://auth.example.com:9000/bucket/file.jpg');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteMaterialStorageUrlsForClient', () => {
|
||||
it('默认应该将素材对象中的文件 URL 改为应用代理地址', async () => {
|
||||
const { rewriteMaterialStorageUrlsForClient } = await import('../url-rewrite');
|
||||
|
||||
const material = {
|
||||
id: '123',
|
||||
previewUrl: 'http://localhost:9000/bucket/preview.jpg',
|
||||
videoCoverUrl: 'http://172.17.0.2:9000/bucket/cover.jpg',
|
||||
videoPreview540Url: 'http://127.0.0.1:9000/bucket/preview540.mp4',
|
||||
audioUrl: 'http://172.18.0.5:9000/bucket/audio.mp3',
|
||||
displayImages: [
|
||||
'http://localhost:9000/bucket/img1.jpg',
|
||||
'http://172.17.0.2:9000/bucket/img2.jpg',
|
||||
],
|
||||
name: 'Test Material',
|
||||
};
|
||||
|
||||
const result = rewriteMaterialStorageUrlsForClient(material);
|
||||
|
||||
expect(result.previewUrl).toBe('/api/materials/asset?url=http%3A%2F%2Flocalhost%3A9000%2Fbucket%2Fpreview.jpg');
|
||||
expect(result.videoCoverUrl).toBe('/api/materials/asset?url=http%3A%2F%2F172.17.0.2%3A9000%2Fbucket%2Fcover.jpg');
|
||||
expect(result.videoPreview540Url).toBe('/api/materials/asset?url=http%3A%2F%2F127.0.0.1%3A9000%2Fbucket%2Fpreview540.mp4');
|
||||
expect(result.audioUrl).toBe('/api/materials/asset?url=http%3A%2F%2F172.18.0.5%3A9000%2Fbucket%2Faudio.mp3');
|
||||
expect(result.displayImages).toEqual([
|
||||
'/api/materials/asset?url=http%3A%2F%2Flocalhost%3A9000%2Fbucket%2Fimg1.jpg',
|
||||
'/api/materials/asset?url=http%3A%2F%2F172.17.0.2%3A9000%2Fbucket%2Fimg2.jpg',
|
||||
]);
|
||||
expect(result.name).toBe('Test Material');
|
||||
expect(result.id).toBe('123');
|
||||
});
|
||||
|
||||
it('关闭素材代理时应该重写素材对象中的所有 URL 字段', async () => {
|
||||
process.env.MATERIAL_ASSET_PROXY_ENABLED = '0';
|
||||
process.env.SERVER_IP = '192.168.1.100';
|
||||
process.env.MINIO_API_PORT = '9000';
|
||||
|
||||
const { rewriteMaterialStorageUrlsForClient } = await import('../url-rewrite');
|
||||
|
||||
const material = {
|
||||
id: '123',
|
||||
previewUrl: 'http://localhost:9000/bucket/preview.jpg',
|
||||
videoCoverUrl: 'http://172.17.0.2:9000/bucket/cover.jpg',
|
||||
videoPreview540Url: 'http://127.0.0.1:9000/bucket/preview540.mp4',
|
||||
audioUrl: 'http://172.18.0.5:9000/bucket/audio.mp3',
|
||||
displayImages: [
|
||||
'http://localhost:9000/bucket/img1.jpg',
|
||||
'http://172.17.0.2:9000/bucket/img2.jpg',
|
||||
],
|
||||
name: 'Test Material',
|
||||
};
|
||||
|
||||
const result = rewriteMaterialStorageUrlsForClient(material);
|
||||
|
||||
expect(result.previewUrl).toBe('http://192.168.1.100:9000/bucket/preview.jpg');
|
||||
expect(result.videoCoverUrl).toBe('http://192.168.1.100:9000/bucket/cover.jpg');
|
||||
expect(result.videoPreview540Url).toBe('http://192.168.1.100:9000/bucket/preview540.mp4');
|
||||
expect(result.audioUrl).toBe('http://192.168.1.100:9000/bucket/audio.mp3');
|
||||
expect(result.displayImages).toEqual([
|
||||
'http://192.168.1.100:9000/bucket/img1.jpg',
|
||||
'http://192.168.1.100:9000/bucket/img2.jpg',
|
||||
]);
|
||||
expect(result.name).toBe('Test Material');
|
||||
expect(result.id).toBe('123');
|
||||
});
|
||||
|
||||
it('应该处理空对象', async () => {
|
||||
const { rewriteMaterialStorageUrlsForClient } = await import('../url-rewrite');
|
||||
expect(rewriteMaterialStorageUrlsForClient({})).toEqual({});
|
||||
});
|
||||
|
||||
it('应该处理 null/undefined', async () => {
|
||||
const { rewriteMaterialStorageUrlsForClient } = await import('../url-rewrite');
|
||||
expect(rewriteMaterialStorageUrlsForClient(null as any)).toBeNull();
|
||||
expect(rewriteMaterialStorageUrlsForClient(undefined as any)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该保持非 URL 字段不变', async () => {
|
||||
process.env.SERVER_IP = '192.168.1.100';
|
||||
|
||||
const { rewriteMaterialStorageUrlsForClient } = await import('../url-rewrite');
|
||||
|
||||
const material = {
|
||||
id: '123',
|
||||
previewUrl: null,
|
||||
videoCoverUrl: undefined,
|
||||
displayImages: [],
|
||||
name: 'Test',
|
||||
count: 42,
|
||||
};
|
||||
|
||||
const result = rewriteMaterialStorageUrlsForClient(material);
|
||||
|
||||
expect(result.previewUrl).toBeNull();
|
||||
// rewriteStorageUrlForClient 将 undefined 转换为 null
|
||||
expect(result.videoCoverUrl).toBeNull();
|
||||
expect(result.displayImages).toEqual([]);
|
||||
expect(result.name).toBe('Test');
|
||||
expect(result.count).toBe(42);
|
||||
});
|
||||
|
||||
it('应该保持 data: URL 和相对路径不变', async () => {
|
||||
const { rewriteMaterialStorageUrlsForClient } = await import('../url-rewrite');
|
||||
|
||||
const material = {
|
||||
previewUrl: 'data:image/png;base64,iVBORw0KGgo=',
|
||||
videoCoverUrl: '/logo/header.png',
|
||||
};
|
||||
|
||||
const result = rewriteMaterialStorageUrlsForClient(material);
|
||||
|
||||
expect(result.previewUrl).toBe(material.previewUrl);
|
||||
expect(result.videoCoverUrl).toBe('/logo/header.png');
|
||||
});
|
||||
});
|
||||
});
|
||||
103
lib/local-file-stream.ts
Normal file
103
lib/local-file-stream.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { createReadStream } from 'fs'
|
||||
import { stat } from 'fs/promises'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Readable } from 'stream'
|
||||
|
||||
interface LocalFileStreamResponseOptions {
|
||||
contentType: string
|
||||
range?: string | null
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
interface ByteRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export async function createLocalFileStreamResponse(
|
||||
filePath: string,
|
||||
options: LocalFileStreamResponseOptions
|
||||
) {
|
||||
const fileStat = await stat(filePath)
|
||||
const fileSize = fileStat.size
|
||||
const baseHeaders: Record<string, string> = {
|
||||
...(options.headers || {}),
|
||||
'Content-Type': options.contentType,
|
||||
'Accept-Ranges': 'bytes',
|
||||
}
|
||||
|
||||
const range = options.range?.trim()
|
||||
if (range) {
|
||||
const parsedRange = parseByteRange(range, fileSize)
|
||||
if (!parsedRange) {
|
||||
return new NextResponse(null, {
|
||||
status: 416,
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
'Content-Range': `bytes */${fileSize}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const { start, end } = parsedRange
|
||||
const stream = createReadStream(filePath, { start, end })
|
||||
const chunkSize = end - start + 1
|
||||
|
||||
return new NextResponse(Readable.toWeb(stream) as any, {
|
||||
status: 206,
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Content-Length': chunkSize.toString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const stream = createReadStream(filePath)
|
||||
return new NextResponse(Readable.toWeb(stream) as any, {
|
||||
status: 200,
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
'Content-Length': fileSize.toString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function parseByteRange(rangeHeader: string, fileSize: number): ByteRange | null {
|
||||
if (!Number.isFinite(fileSize) || fileSize < 0) return null
|
||||
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader)
|
||||
if (!match) return null
|
||||
|
||||
const [, startText, endText] = match
|
||||
if (!startText && !endText) return null
|
||||
|
||||
if (!startText) {
|
||||
const suffixLength = Number.parseInt(endText, 10)
|
||||
if (!Number.isFinite(suffixLength) || suffixLength <= 0 || fileSize === 0) return null
|
||||
|
||||
return {
|
||||
start: Math.max(fileSize - suffixLength, 0),
|
||||
end: fileSize - 1,
|
||||
}
|
||||
}
|
||||
|
||||
const start = Number.parseInt(startText, 10)
|
||||
const requestedEnd = endText ? Number.parseInt(endText, 10) : fileSize - 1
|
||||
|
||||
if (
|
||||
!Number.isFinite(start) ||
|
||||
!Number.isFinite(requestedEnd) ||
|
||||
fileSize === 0 ||
|
||||
start < 0 ||
|
||||
requestedEnd < start ||
|
||||
start >= fileSize
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
end: Math.min(requestedEnd, fileSize - 1),
|
||||
}
|
||||
}
|
||||
59
lib/material-share-permissions.ts
Normal file
59
lib/material-share-permissions.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export type MaterialShareActor = {
|
||||
id: string
|
||||
role: string
|
||||
isSuperAdmin: boolean
|
||||
}
|
||||
|
||||
export type MaterialShareTarget = {
|
||||
userId: string
|
||||
ownerType?: string | null
|
||||
ownerId?: string | null
|
||||
createdByUserId?: string | null
|
||||
}
|
||||
|
||||
export async function getMaterialShareActor(session: unknown): Promise<MaterialShareActor | null> {
|
||||
const userId = (session as any)?.user?.id
|
||||
if (!userId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
isSuperAdmin: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
isSuperAdmin: Boolean(user.isSuperAdmin),
|
||||
}
|
||||
}
|
||||
|
||||
export function canManageMaterialShare(
|
||||
material: MaterialShareTarget,
|
||||
actor: MaterialShareActor | null
|
||||
) {
|
||||
if (!actor) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (actor.isSuperAdmin || actor.role === 'ADMIN') {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
material.userId === actor.id ||
|
||||
material.createdByUserId === actor.id ||
|
||||
(material.ownerType === 'USER' && material.ownerId === actor.id)
|
||||
)
|
||||
}
|
||||
53
lib/material-share-token.ts
Normal file
53
lib/material-share-token.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
function hashMaterialShareToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
|
||||
export function getMaterialShareTokenHash(token: string): string {
|
||||
return hashMaterialShareToken(token)
|
||||
}
|
||||
|
||||
export async function createMaterialShareToken(
|
||||
materialId: string,
|
||||
createdByUserId: string,
|
||||
ttlMs = DEFAULT_TTL_MS
|
||||
): Promise<{ token: string; expiresAt: Date }> {
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
const expiresAt = new Date(Date.now() + ttlMs)
|
||||
|
||||
await prisma.materialShareToken.create({
|
||||
data: {
|
||||
tokenHash: hashMaterialShareToken(token),
|
||||
materialId,
|
||||
createdByUserId,
|
||||
expiresAt,
|
||||
},
|
||||
})
|
||||
|
||||
return { token, expiresAt }
|
||||
}
|
||||
|
||||
export async function findValidMaterialShareToken(token: string, materialId?: string) {
|
||||
const normalizedToken = token?.trim()
|
||||
if (!normalizedToken) return null
|
||||
|
||||
const now = new Date()
|
||||
return prisma.materialShareToken.findFirst({
|
||||
where: {
|
||||
tokenHash: hashMaterialShareToken(normalizedToken),
|
||||
...(materialId ? { materialId } : {}),
|
||||
revokedAt: null,
|
||||
expiresAt: { gt: now },
|
||||
material: {
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
material: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -7,6 +7,17 @@
|
||||
|
||||
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1'])
|
||||
|
||||
// Docker 内部网络 IP 段(需要重写为宿主机 IP)
|
||||
const DOCKER_INTERNAL_PREFIXES = ['172.17.', '172.18.', '172.19.', '172.20.', '172.21.', '172.22.', '172.23.', '172.24.', '172.25.', '172.26.', '172.27.', '172.28.', '172.29.', '172.30.', '172.31.']
|
||||
|
||||
function isDockerInternalIP(hostname: string): boolean {
|
||||
return DOCKER_INTERNAL_PREFIXES.some(prefix => hostname.startsWith(prefix))
|
||||
}
|
||||
|
||||
function isLocalOrDockerIP(hostname: string): boolean {
|
||||
return LOCAL_HOSTS.has(hostname) || isDockerInternalIP(hostname)
|
||||
}
|
||||
|
||||
function getSafeMinioBase(): { protocol: string; host: string; port: string } | null {
|
||||
const publicDomain = (process.env.S3_PUBLIC_DOMAIN || '').trim()
|
||||
const serverIp = (process.env.SERVER_IP || '').trim()
|
||||
@ -68,6 +79,31 @@ function getSafeMinioBase(): { protocol: string; host: string; port: string } |
|
||||
}
|
||||
}
|
||||
|
||||
function shouldProxyMaterialAssets() {
|
||||
const value = (process.env.MATERIAL_ASSET_PROXY_ENABLED ?? '1').trim().toLowerCase()
|
||||
return value !== '0' && value !== 'false' && value !== 'no'
|
||||
}
|
||||
|
||||
export function toMaterialAssetProxyUrl(url?: string | null): string | null {
|
||||
if (!url || typeof url !== 'string') return url ?? null
|
||||
|
||||
const trimmed = url.trim()
|
||||
if (!trimmed) return null
|
||||
if (trimmed.startsWith('/api/materials/asset?') || trimmed.startsWith('data:')) return trimmed
|
||||
if (!/^https?:\/\//i.test(trimmed)) return trimmed
|
||||
|
||||
return `/api/materials/asset?url=${encodeURIComponent(trimmed)}`
|
||||
}
|
||||
|
||||
export function toSharedMaterialAssetProxyUrl(url: string | null | undefined, token: string): string | null {
|
||||
const proxiedUrl = toMaterialAssetProxyUrl(url)
|
||||
if (!proxiedUrl || !proxiedUrl.startsWith('/api/materials/asset?')) {
|
||||
return proxiedUrl
|
||||
}
|
||||
|
||||
return `${proxiedUrl}&shareToken=${encodeURIComponent(token)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 将存储相关的 URL 中的 localhost/127.0.0.1 替换为 SERVER_IP:MINIO_API_PORT
|
||||
* - 仅在 URL 为 http/https 且 host 为本机时生效
|
||||
@ -92,12 +128,12 @@ export function rewriteStorageUrlForClient(url?: string | null): string | null {
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed)
|
||||
if (!LOCAL_HOSTS.has(parsed.hostname)) {
|
||||
// 不是 localhost / 127.0.0.1,直接返回
|
||||
if (!isLocalOrDockerIP(parsed.hostname)) {
|
||||
// 不是 localhost / 127.0.0.1 / Docker 内部 IP,直接返回
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// 将 localhost:9000 替换为 SERVER_IP:MINIO_API_PORT
|
||||
// 将 localhost:9000 或 Docker 内部 IP 替换为 SERVER_IP:MINIO_API_PORT
|
||||
parsed.protocol = minioBase.protocol
|
||||
parsed.hostname = minioBase.host
|
||||
parsed.port = minioBase.port
|
||||
@ -109,6 +145,14 @@ export function rewriteStorageUrlForClient(url?: string | null): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteMaterialAssetUrlForClient(url?: string | null): string | null {
|
||||
if (shouldProxyMaterialAssets()) {
|
||||
return toMaterialAssetProxyUrl(url)
|
||||
}
|
||||
|
||||
return rewriteStorageUrlForClient(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量重写素材对象中的所有存储 URL 字段
|
||||
*/
|
||||
@ -122,18 +166,21 @@ export function rewriteMaterialStorageUrlsForClient<T extends Record<string, any
|
||||
'videoCoverUrl',
|
||||
'videoPreview540Url',
|
||||
'videoPreviewClipUrl',
|
||||
'audioUrl',
|
||||
'videoSourceUrl',
|
||||
'zipFilePath',
|
||||
] as const
|
||||
|
||||
urlFields.forEach((field) => {
|
||||
if (field in cloned) {
|
||||
cloned[field] = rewriteStorageUrlForClient(cloned[field])
|
||||
cloned[field] = rewriteMaterialAssetUrlForClient(cloned[field])
|
||||
}
|
||||
})
|
||||
|
||||
// displayImages: string[]
|
||||
if (Array.isArray(cloned.displayImages)) {
|
||||
cloned.displayImages = cloned.displayImages.map((item: any) =>
|
||||
typeof item === 'string' ? rewriteStorageUrlForClient(item) : item
|
||||
typeof item === 'string' ? rewriteMaterialAssetUrlForClient(item) : item
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
-- 素材公开分享票据:替代直接使用素材 ID 公开访问
|
||||
CREATE TABLE IF NOT EXISTS "material_share_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"materialId" TEXT NOT NULL,
|
||||
"createdByUserId" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "material_share_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "material_share_tokens_tokenHash_key"
|
||||
ON "material_share_tokens"("tokenHash");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "material_share_tokens_materialId_idx"
|
||||
ON "material_share_tokens"("materialId");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "material_share_tokens_createdByUserId_idx"
|
||||
ON "material_share_tokens"("createdByUserId");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "material_share_tokens_expiresAt_idx"
|
||||
ON "material_share_tokens"("expiresAt");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "material_share_tokens_revokedAt_idx"
|
||||
ON "material_share_tokens"("revokedAt");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'material_share_tokens_materialId_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "material_share_tokens"
|
||||
ADD CONSTRAINT "material_share_tokens_materialId_fkey"
|
||||
FOREIGN KEY ("materialId") REFERENCES "materials"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'material_share_tokens_createdByUserId_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "material_share_tokens"
|
||||
ADD CONSTRAINT "material_share_tokens_createdByUserId_fkey"
|
||||
FOREIGN KEY ("createdByUserId") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
@ -40,10 +40,28 @@ model User {
|
||||
children User[] @relation("UserHierarchy")
|
||||
favorites MaterialFavorite[]
|
||||
browseHistory BrowseHistory[]
|
||||
materialShareTokens MaterialShareToken[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model MaterialShareToken {
|
||||
id String @id @default(uuid())
|
||||
tokenHash String @unique
|
||||
materialId String
|
||||
createdByUserId String
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
material Material @relation(fields: [materialId], references: [id], onDelete: Cascade)
|
||||
createdBy User @relation(fields: [createdByUserId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([materialId])
|
||||
@@index([createdByUserId])
|
||||
@@index([expiresAt])
|
||||
@@index([revokedAt])
|
||||
@@map("material_share_tokens")
|
||||
}
|
||||
model UploadTemplate {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
@ -205,6 +223,7 @@ model Material {
|
||||
downloadLogs MaterialDownloadLog[]
|
||||
logs MaterialLog[]
|
||||
browseHistory BrowseHistory[]
|
||||
shareTokens MaterialShareToken[]
|
||||
template UploadTemplate? @relation(fields: [templateId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user