收紧素材分享与文件访问安全

This commit is contained in:
WuYongJie 2026-05-25 15:36:51 +08:00
parent 0dabf6cee8
commit bd50dbd6e0
22 changed files with 2282 additions and 260 deletions

View 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)
})
})

View 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>
}

View 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''")
})
})

View File

@ -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>
}

View 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')
})
})

View File

@ -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)

View 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()
})
})

View 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 }
)
}
}

View 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')
})
})

View 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 }
)
}
}

View 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()
})
})

View File

@ -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) {

View File

@ -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(

View 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')
})
})

View 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,
},
})
})
})

View 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
View 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),
}
}

View 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)
)
}

View 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,
},
})
}

View File

@ -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
)
}

View File

@ -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 $$;

View File

@ -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)