补充公开分享总开关并兼容转码恢复缺表场景
This commit is contained in:
parent
1a9921c9fe
commit
c7d93111c4
@ -59,6 +59,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
|
||||
const [enableRegistration, setEnableRegistration] = useState(true)
|
||||
const [enableAiImageEdit, setEnableAiImageEdit] = useState(false)
|
||||
const [enableTunnelService, setEnableTunnelService] = useState(false)
|
||||
const [enablePublicShare, setEnablePublicShare] = useState(true)
|
||||
// 首页删除按钮:true = 仅管理员可见,false = 所有人可见
|
||||
const [homeDeleteButtonAdminOnly, setHomeDeleteButtonAdminOnly] = useState(true)
|
||||
const [premiumRatio, setPremiumRatio] = useState(10)
|
||||
@ -112,6 +113,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
|
||||
setEnableRegistration(data.enableRegistration ?? true)
|
||||
setEnableAiImageEdit(data.enableAiImageEdit ?? false)
|
||||
setEnableTunnelService(data.enableTunnelService ?? false)
|
||||
setEnablePublicShare(data.enablePublicShare ?? true)
|
||||
setHomeDeleteButtonAdminOnly((data.homeDeleteButtonMode ?? 'ADMIN_ONLY') !== 'ALL')
|
||||
setPremiumRatio(typeof data.premiumRatio === 'number' ? data.premiumRatio : 10)
|
||||
setLastSavedAt(data.updatedAt ? new Date(data.updatedAt) : null)
|
||||
@ -161,6 +163,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
|
||||
enableRegistration,
|
||||
enableAiImageEdit,
|
||||
enableTunnelService,
|
||||
enablePublicShare,
|
||||
homeDeleteButtonMode: homeDeleteButtonAdminOnly ? 'ADMIN_ONLY' : 'ALL',
|
||||
premiumRatio: normalizedRatio,
|
||||
}),
|
||||
@ -615,6 +618,13 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
|
||||
onChange: setHomeDeleteButtonAdminOnly,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
title: '公开分享链接',
|
||||
desc: '关闭后不能新建外部分享链接,已有分享链接也会停止公开访问。',
|
||||
value: enablePublicShare,
|
||||
onChange: setEnablePublicShare,
|
||||
disabled: !sessionUser?.isSuperAdmin,
|
||||
},
|
||||
{
|
||||
title: '图片编辑能力(预留)',
|
||||
desc: '控制素材详情页的图片编辑入口(功能预留)。',
|
||||
|
||||
@ -27,10 +27,15 @@ jest.mock('@/lib/material-share-token', () => ({
|
||||
findValidMaterialShareToken: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/public-share-settings', () => ({
|
||||
isPublicShareEnabled: 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 { isPublicShareEnabled } = require('@/lib/public-share-settings')
|
||||
|
||||
const buildMaterialRecord = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'material-1',
|
||||
@ -51,6 +56,7 @@ describe('GET /api/materials/asset', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
isS3Url.mockImplementation((value?: string | null) => /^https?:\/\//.test(value || ''))
|
||||
isPublicShareEnabled.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('未登录时拒绝读取素材文件', async () => {
|
||||
@ -127,6 +133,25 @@ describe('GET /api/materials/asset', () => {
|
||||
expect(findValidMaterialShareToken).toHaveBeenCalledWith('share-token', 'material-1')
|
||||
})
|
||||
|
||||
it('公开分享开关关闭时拒绝通过分享票据读取素材文件', async () => {
|
||||
const fileUrl = 'http://minio:9000/bucket/preview.webp'
|
||||
getServerSession.mockResolvedValue(null)
|
||||
prisma.material.findMany.mockResolvedValue([buildMaterialRecord({ previewUrl: fileUrl })])
|
||||
isPublicShareEnabled.mockResolvedValue(false)
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest(
|
||||
`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}&shareToken=share-token`
|
||||
)
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data.error).toBe('公开分享功能已关闭')
|
||||
expect(findValidMaterialShareToken).not.toHaveBeenCalled()
|
||||
expect(getS3ObjectStream).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('允许读取 metadata.previewUrls 中登记过的预览图', async () => {
|
||||
const fileUrl = 'http://minio:9000/bucket/metadata-preview.webp'
|
||||
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
|
||||
|
||||
@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
|
||||
import { getS3ObjectStream, isS3Url } from '@/lib/s3'
|
||||
import { findValidMaterialShareToken } from '@/lib/material-share-token'
|
||||
import { createLocalFileStreamResponse } from '@/lib/local-file-stream'
|
||||
import { isPublicShareEnabled } from '@/lib/public-share-settings'
|
||||
import { access } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { Readable } from 'stream'
|
||||
@ -48,6 +49,13 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
if (!(await isPublicShareEnabled())) {
|
||||
return NextResponse.json(
|
||||
{ error: '公开分享功能已关闭' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const validShareToken = shareToken
|
||||
? await findValidMaterialShareToken(shareToken, materialId)
|
||||
: null
|
||||
|
||||
@ -32,13 +32,19 @@ jest.mock('@/lib/url-rewrite', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/public-share-settings', () => ({
|
||||
isPublicShareEnabled: jest.fn(),
|
||||
}))
|
||||
|
||||
const { getServerSession } = require('next-auth')
|
||||
const { prisma } = require('@/lib/prisma')
|
||||
const { createMaterialShareToken, findValidMaterialShareToken } = require('@/lib/material-share-token')
|
||||
const { isPublicShareEnabled } = require('@/lib/public-share-settings')
|
||||
|
||||
describe('/api/materials/share', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
isPublicShareEnabled.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('未登录时拒绝创建分享票据', async () => {
|
||||
@ -57,6 +63,25 @@ describe('/api/materials/share', () => {
|
||||
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 })
|
||||
isPublicShareEnabled.mockResolvedValue(false)
|
||||
|
||||
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(prisma.material.findFirst).not.toHaveBeenCalled()
|
||||
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 })
|
||||
@ -132,6 +157,20 @@ describe('/api/materials/share', () => {
|
||||
expect(prisma.material.findFirst).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('公开分享开关关闭时旧分享链接也不能继续公开读取', async () => {
|
||||
isPublicShareEnabled.mockResolvedValue(false)
|
||||
|
||||
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(403)
|
||||
expect(data.error).toBe('公开分享功能已关闭')
|
||||
expect(findValidMaterialShareToken).not.toHaveBeenCalled()
|
||||
expect(prisma.material.findFirst).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('有效分享票据可读取分享数据并把图片改成带票据的代理地址', async () => {
|
||||
findValidMaterialShareToken.mockResolvedValue({ id: 'token-row', materialId: 'material-1' })
|
||||
prisma.material.findFirst.mockResolvedValue({
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
canManageMaterialShare,
|
||||
getMaterialShareActor,
|
||||
} from '@/lib/material-share-permissions'
|
||||
import { isPublicShareEnabled } from '@/lib/public-share-settings'
|
||||
|
||||
function parseDisplayImages(value: string | null): string[] {
|
||||
if (!value) return []
|
||||
@ -36,6 +37,13 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
if (!(await isPublicShareEnabled())) {
|
||||
return NextResponse.json(
|
||||
{ error: '公开分享功能已关闭' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const materialId = typeof body?.materialId === 'string' ? body.materialId.trim() : ''
|
||||
|
||||
@ -104,6 +112,13 @@ export async function GET(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
if (!(await isPublicShareEnabled())) {
|
||||
return NextResponse.json(
|
||||
{ error: '公开分享功能已关闭' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const validShareToken = await findValidMaterialShareToken(shareToken, id)
|
||||
if (!validShareToken) {
|
||||
return NextResponse.json(
|
||||
|
||||
@ -27,15 +27,21 @@ jest.mock('@/lib/material-share-token', () => ({
|
||||
findValidMaterialShareToken: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/public-share-settings', () => ({
|
||||
isPublicShareEnabled: 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 { isPublicShareEnabled } = require('@/lib/public-share-settings')
|
||||
|
||||
describe('GET /api/materials/stream', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
isS3Url.mockImplementation((value?: string | null) => /^https?:\/\//.test(value || ''))
|
||||
isPublicShareEnabled.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('未登录且没有有效分享票据时拒绝播放', async () => {
|
||||
@ -84,4 +90,26 @@ describe('GET /api/materials/stream', () => {
|
||||
expect(findValidMaterialShareToken).toHaveBeenCalledWith('share-token', 'material-1')
|
||||
expect(response.headers.get('access-control-allow-origin')).toBeNull()
|
||||
})
|
||||
|
||||
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)
|
||||
isPublicShareEnabled.mockResolvedValue(false)
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost/api/materials/stream?id=material-1&shareToken=share-token')
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data.error).toBe('公开分享功能已关闭')
|
||||
expect(findValidMaterialShareToken).not.toHaveBeenCalled()
|
||||
expect(getS3ObjectStream).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
|
||||
import { getS3ObjectStream, isS3Url } from '@/lib/s3'
|
||||
import { findValidMaterialShareToken } from '@/lib/material-share-token'
|
||||
import { createLocalFileStreamResponse } from '@/lib/local-file-stream'
|
||||
import { isPublicShareEnabled } from '@/lib/public-share-settings'
|
||||
import { access } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { Readable } from 'stream'
|
||||
@ -48,6 +49,13 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user) {
|
||||
if (!(await isPublicShareEnabled())) {
|
||||
return NextResponse.json(
|
||||
{ error: '公开分享功能已关闭' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const validShareToken = shareToken
|
||||
? await findValidMaterialShareToken(shareToken, material.id)
|
||||
: null
|
||||
|
||||
@ -81,6 +81,7 @@ const mockSettings = {
|
||||
enableUserActivation: false,
|
||||
enableAiImageEdit: true,
|
||||
enableTunnelService: true,
|
||||
enablePublicShare: true,
|
||||
}
|
||||
|
||||
describe('GET /api/system-settings', () => {
|
||||
@ -125,6 +126,7 @@ describe('GET /api/system-settings', () => {
|
||||
expect(data.enableUserActivation).toBeUndefined()
|
||||
expect(data.enableAiImageEdit).toBeUndefined()
|
||||
expect(data.enableTunnelService).toBeUndefined()
|
||||
expect(data.enablePublicShare).toBeUndefined()
|
||||
expect(data.enabledBuiltinTemplates).toBeUndefined()
|
||||
expect(data.homeDeleteButtonMode).toBeUndefined()
|
||||
})
|
||||
@ -144,6 +146,7 @@ describe('GET /api/system-settings', () => {
|
||||
expect(data.s3Bucket).toBe('private-bucket')
|
||||
expect(data.enableRegistration).toBe(true)
|
||||
expect(data.enableAiImageEdit).toBe(true)
|
||||
expect(data.enablePublicShare).toBe(true)
|
||||
expect(data.enabledBuiltinTemplates).toBe('["3dmax-material"]')
|
||||
expect(data.homeDeleteButtonMode).toBe('ADMIN_ONLY')
|
||||
})
|
||||
@ -237,6 +240,7 @@ describe('PUT /api/system-settings 权限边界', () => {
|
||||
siteName: '普通管理员不应能改',
|
||||
s3SecretKey: 'new-secret',
|
||||
enableRegistration: false,
|
||||
enablePublicShare: false,
|
||||
}),
|
||||
}))
|
||||
const data = await response.json()
|
||||
@ -274,6 +278,7 @@ describe('PUT /api/system-settings 权限边界', () => {
|
||||
siteName: '新系统名',
|
||||
s3SecretKey: 'new-secret',
|
||||
enableRegistration: false,
|
||||
enablePublicShare: false,
|
||||
}),
|
||||
}))
|
||||
const data = await response.json()
|
||||
@ -288,6 +293,11 @@ describe('PUT /api/system-settings 权限边界', () => {
|
||||
enableRegistration: false,
|
||||
}),
|
||||
})
|
||||
expect(prisma.$executeRawUnsafe).toHaveBeenCalledWith(
|
||||
`UPDATE "system_settings" SET "enablePublicShare" = $1 WHERE "id" = $2`,
|
||||
false,
|
||||
'settings-1',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ type SystemSettingsRow = {
|
||||
enableUserActivation: boolean
|
||||
enableAiImageEdit: boolean
|
||||
enableTunnelService: boolean
|
||||
enablePublicShare?: boolean | null
|
||||
enabledBuiltinTemplates?: string | null
|
||||
homeDeleteButtonMode?: string | null
|
||||
}
|
||||
@ -88,6 +89,7 @@ function buildPublicFallbackSettings(): SystemSettingsRow {
|
||||
enableUserActivation: false,
|
||||
enableAiImageEdit: false,
|
||||
enableTunnelService: false,
|
||||
enablePublicShare: true,
|
||||
enabledBuiltinTemplates: null,
|
||||
homeDeleteButtonMode: 'ADMIN_ONLY',
|
||||
}
|
||||
@ -135,19 +137,25 @@ export async function GET() {
|
||||
enableAiImageEdit: true,
|
||||
enableTunnelService: true,
|
||||
// homeDeleteButtonMode 是可选列,这里不直接在 select 中声明,避免旧版本类型不匹配
|
||||
// enabledBuiltinTemplates 可能不存在,先不查询
|
||||
// enabledBuiltinTemplates / enablePublicShare 可能不存在,先不查询
|
||||
},
|
||||
}) as SystemSettingsRow | null
|
||||
|
||||
// 如果字段存在,尝试查询它
|
||||
try {
|
||||
const fullSettings = await prisma.$queryRaw<Array<{ enabledBuiltinTemplates?: string | null; homeDeleteButtonMode?: string | null; premiumRatio?: number | null }>>`
|
||||
SELECT "enabledBuiltinTemplates", "homeDeleteButtonMode", "premiumRatio" FROM "system_settings" LIMIT 1
|
||||
const fullSettings = await prisma.$queryRaw<Array<{
|
||||
enabledBuiltinTemplates?: string | null
|
||||
homeDeleteButtonMode?: string | null
|
||||
premiumRatio?: number | null
|
||||
enablePublicShare?: boolean | null
|
||||
}>>`
|
||||
SELECT "enabledBuiltinTemplates", "homeDeleteButtonMode", "premiumRatio", "enablePublicShare" FROM "system_settings" LIMIT 1
|
||||
`
|
||||
if (fullSettings[0] && settings) {
|
||||
settings.enabledBuiltinTemplates = fullSettings[0].enabledBuiltinTemplates
|
||||
settings.homeDeleteButtonMode = fullSettings[0].homeDeleteButtonMode || 'ADMIN_ONLY'
|
||||
settings.premiumRatio = fullSettings[0].premiumRatio ?? 10 // 默认值10%
|
||||
settings.enablePublicShare = fullSettings[0].enablePublicShare !== false
|
||||
}
|
||||
} catch {
|
||||
// 字段不存在,设置为默认值
|
||||
@ -155,6 +163,7 @@ export async function GET() {
|
||||
settings.enabledBuiltinTemplates = null
|
||||
settings.homeDeleteButtonMode = 'ADMIN_ONLY'
|
||||
settings.premiumRatio = settings.premiumRatio ?? 10 // 确保有默认值
|
||||
settings.enablePublicShare = true
|
||||
}
|
||||
}
|
||||
|
||||
@ -173,6 +182,8 @@ export async function GET() {
|
||||
enableTunnelService: featureFlags.tunnelEnabled,
|
||||
},
|
||||
}) as SystemSettingsRow
|
||||
|
||||
settings.enablePublicShare = true
|
||||
} else {
|
||||
// 已存在的旧数据,如果 LOGO 为空则自动补上默认值,并保存一次;不要在 GET 中补写对象存储密钥
|
||||
let needUpdate = false
|
||||
@ -269,6 +280,7 @@ export async function PUT(request: NextRequest) {
|
||||
enableRegistration,
|
||||
enableAiImageEdit,
|
||||
enableTunnelService,
|
||||
enablePublicShare,
|
||||
enabledBuiltinTemplates,
|
||||
homeDeleteButtonMode,
|
||||
premiumRatio,
|
||||
@ -310,12 +322,13 @@ export async function PUT(request: NextRequest) {
|
||||
// 通过原生 SQL 写入可选列,避免类型不匹配
|
||||
try {
|
||||
await prisma.$executeRawUnsafe(
|
||||
`UPDATE "system_settings" SET "homeDeleteButtonMode" = $1 WHERE "id" = $2`,
|
||||
`UPDATE "system_settings" SET "homeDeleteButtonMode" = $1, "enablePublicShare" = $2 WHERE "id" = $3`,
|
||||
homeDeleteButtonMode ?? 'ADMIN_ONLY',
|
||||
enablePublicShare !== undefined ? Boolean(enablePublicShare) : true,
|
||||
settings.id
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('[SystemSettings] Failed to set homeDeleteButtonMode on create:', e)
|
||||
console.error('[SystemSettings] Failed to set optional columns on create:', e)
|
||||
}
|
||||
} else {
|
||||
// 更新
|
||||
@ -354,6 +367,18 @@ export async function PUT(request: NextRequest) {
|
||||
console.error('[SystemSettings] Failed to update homeDeleteButtonMode:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (enablePublicShare !== undefined) {
|
||||
try {
|
||||
await prisma.$executeRawUnsafe(
|
||||
`UPDATE "system_settings" SET "enablePublicShare" = $1 WHERE "id" = $2`,
|
||||
Boolean(enablePublicShare),
|
||||
settings.id
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('[SystemSettings] Failed to update enablePublicShare:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清除缓存,强制下次重新加载
|
||||
|
||||
@ -74,7 +74,7 @@
|
||||
|
||||
客户影响:
|
||||
|
||||
即使业务接口加了登录,如果对象存储本身公开下载,拿到文件 URL 的人仍能绕过系统直接访问素材文件。当前已完成默认私有化、内部播放、分享票据、前端撤销和 MinIO 匿名访问收口:新部署和升级初始化默认会把素材 bucket 的匿名访问设为 `none`;新上传不再默认设置 `public-read`;内部素材预览类 URL 默认改为应用内代理地址;视频/音频流接口默认要求登录;公开分享必须携带短期分享票据;素材负责人/上传者/管理员可以在前端查看、刷新和撤销该素材的分享链接。
|
||||
即使业务接口加了登录,如果对象存储本身公开下载,拿到文件 URL 的人仍能绕过系统直接访问素材文件。当前已完成默认私有化、内部播放、分享票据、前端撤销、系统级公开分享开关和 MinIO 匿名访问收口:新部署和升级初始化默认会把素材 bucket 的匿名访问设为 `none`;新上传不再默认设置 `public-read`;内部素材预览类 URL 默认改为应用内代理地址;视频/音频流接口默认要求登录;公开分享必须携带短期分享票据;素材负责人/上传者/管理员可以在前端查看、刷新和撤销该素材的分享链接;最高权限账号可以在系统设置里关闭公开分享,关闭后不能新建外部分享链接,已有分享链接也不能继续公开访问。
|
||||
|
||||
证据:
|
||||
|
||||
@ -95,7 +95,12 @@
|
||||
- `app/api/materials/share/tokens/[id]/route.ts`:新增撤销分享链接接口,通过写入 `revokedAt` 让链接失效,不硬删除记录。
|
||||
- `app/page.tsx`:分享弹窗已接入已有分享链接列表、到期时间、撤销状态、刷新和撤销操作。
|
||||
- `app/share/[id]/page.tsx`:公开分享页已隐藏下载按钮,避免外部访问者点击一个后端必然要求登录的下载入口;当前公开分享定位为可预览,不直接开放源文件下载。
|
||||
- `prisma/schema.prisma`、`prisma/migrations/20260526090000_add_public_share_toggle/migration.sql`:系统设置新增 `enablePublicShare`,默认开启,旧库升级会自动补列。
|
||||
- `app/admin/_components/AppearanceSettingsPanel.tsx`:后台“功能开关”新增公开分享链接开关,仅最高权限账号可修改。
|
||||
- `lib/public-share-settings.ts`:新增公开分享开关读取函数,读取失败时按关闭处理,避免数据库异常时误放开外部分享。
|
||||
- `app/api/materials/share/route.ts`:公开分享开关关闭时拒绝创建新分享票据,也拒绝旧分享票据继续读取分享数据。
|
||||
- `app/api/materials/asset/route.ts`:素材文件代理默认要求登录;未登录时必须带有效分享票据,且文件地址必须属于对应素材记录。
|
||||
- `app/api/materials/asset/route.ts`、`app/api/materials/stream/route.ts`:公开分享开关关闭时,未登录用户不能再通过旧分享票据读取素材文件或播放音视频;登录用户内部预览不受影响。
|
||||
- `app/api/materials/stream/route.ts`:视频/音频流接口默认要求登录;未登录时必须带有效分享票据,并只查询未软删除素材。
|
||||
- `app/api/materials/stream/route.ts`:S3 和本地 legacy 流响应已改为 `Cache-Control: private, max-age=300`,不再返回 `Access-Control-Allow-Origin: *`。
|
||||
- `app/api/materials/share/__tests__/route.test.ts`、`app/api/materials/share/tokens/__tests__/route.test.ts`、`app/api/materials/share/tokens/[id]/__tests__/route.test.ts`、`app/api/materials/asset/__tests__/route.test.ts`、`app/api/materials/stream/__tests__/route.test.ts`、`lib/__tests__/material-share-token.test.ts`、`lib/__tests__/s3.test.ts`、`lib/__tests__/url-rewrite.test.ts`:新增/更新测试,锁定默认私有上传、素材代理 URL、代理接口权限边界、分享票据校验、分享列表不泄露 token、撤销链接后失效、无票据播放拒绝、有票据播放可用且不返回通配 CORS。
|
||||
@ -107,9 +112,9 @@
|
||||
- 已完成:内部视频/音频播放不再允许未登录裸 ID 访问,改为登录态内同源播放。
|
||||
- 已完成:公开分享不再只靠裸素材 ID,第一版改为“有权限用户创建分享票据,公开访问携带票据,票据短期有效并可后端撤销”。
|
||||
- 已完成:主页分享弹窗已接入分享链接列表、有效期展示、撤销入口和刷新入口。
|
||||
- 已完成:后台新增系统级公开分享开关,关闭后不能新建外部分享链接,已有分享链接也会停止公开访问。
|
||||
- 已完成:公开分享页不再展示下载按钮;源文件下载继续走登录态和用户下载权限。
|
||||
- 仍需继续:外部 S3 或历史对象级 ACL 需要单独运维审计;MinIO 初始化只能处理当前 Compose 管理的 MinIO bucket 匿名策略。
|
||||
- 仍需继续:必要时再加系统级分享开关。
|
||||
- 仍需继续:Logo、头像等公共资源和私有素材需要拆分策略;登录页 Logo 不能简单套用登录后的素材代理。
|
||||
|
||||
### P0-04 CORS 反射任意来源且允许携带凭据(已修复主应用 API 和素材流)
|
||||
|
||||
@ -49,7 +49,7 @@
|
||||
- 收藏/点赞:可在素材卡片或详情里操作。
|
||||
- 下载:是否显示下载按钮取决于当前权限和素材状态。
|
||||
- 下载记录:系统会写入 `material_download_logs`,方便后续审计。
|
||||
- 分享链接:可用于外部预览;当前分享页存在下载入口,是否适合对外开放下载,需要按项目权限和交付策略确认,不能写成“外部分享一定不允许下载原文件”。
|
||||
- 分享链接:可用于外部预览,默认 7 天有效,可由有权限的用户撤销。管理员可以在后台关闭公开分享;关闭后不能新建外部分享链接,已有链接也会停止公开访问。当前公开分享页定位为预览,不直接开放源文件下载。
|
||||
|
||||
### 5. 所有权移交
|
||||
|
||||
@ -72,7 +72,8 @@
|
||||
| 上传后长时间等待转码 | 刷新后再看;仍异常则联系管理员检查转码队列和 `ffmpeg` |
|
||||
| 我的素材为空 | 可能暂无上传、素材已移交、或当前账号归属变化,联系管理员核对 |
|
||||
| 下载按钮不可见 | 可能没有下载权限,或素材仍在处理中 |
|
||||
| 分享给外部前不确定能不能下载 | 先用测试素材验证分享页行为,再决定是否对外发送 |
|
||||
| 分享链接打不开 | 可能链接已过期、已撤销,或管理员关闭了公开分享 |
|
||||
| 分享给外部前不确定是否合规 | 先用测试素材验证分享页行为,再决定是否对外发送 |
|
||||
|
||||
### 7. 建议的新手任务
|
||||
|
||||
|
||||
@ -127,6 +127,28 @@ describe('video-transcode-service', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('数据库缺少 video_transcode_jobs 表时,启动恢复会安全跳过', async () => {
|
||||
const { recoverStaleVideoTranscodeJobs } = require('../video-transcode-service')
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
prisma.videoTranscodeJob.findMany.mockRejectedValue({
|
||||
code: 'P2021',
|
||||
meta: { table: 'public.video_transcode_jobs' },
|
||||
message: 'The table public.video_transcode_jobs does not exist',
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await recoverStaleVideoTranscodeJobs()
|
||||
|
||||
expect(result).toEqual({ recovered: 0, hasMore: false, skipped: true })
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('跳过启动恢复'))
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled()
|
||||
expect(transcodeQueue.add).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('恢复批次达到上限时会标记 hasMore 并安排继续恢复', async () => {
|
||||
jest.useFakeTimers()
|
||||
const { recoverStaleVideoTranscodeJobs } = require('../video-transcode-service')
|
||||
|
||||
21
lib/public-share-settings.ts
Normal file
21
lib/public-share-settings.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ensureSystemSettingsOptionalColumns } from '@/lib/system-settings-columns'
|
||||
|
||||
export async function isPublicShareEnabled(): Promise<boolean> {
|
||||
try {
|
||||
await ensureSystemSettingsOptionalColumns()
|
||||
|
||||
const rows = await prisma.$queryRaw<Array<{ enablePublicShare: boolean | null }>>`
|
||||
SELECT "enablePublicShare" FROM "system_settings" LIMIT 1
|
||||
`
|
||||
|
||||
if (!rows[0]) {
|
||||
return true
|
||||
}
|
||||
|
||||
return rows[0].enablePublicShare !== false
|
||||
} catch (error) {
|
||||
console.warn('[PublicShare] 读取公开分享开关失败,已按关闭处理:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -22,5 +22,5 @@ export async function ensureSystemSettingsOptionalColumns() {
|
||||
// 首页删除按钮模式:ADMIN_ONLY / ALL
|
||||
await addColumnIfMissing('homeDeleteButtonMode')
|
||||
await addColumnIfMissing('premiumRatio', 'DOUBLE PRECISION', '10')
|
||||
await addColumnIfMissing('enablePublicShare', 'BOOLEAN', 'TRUE')
|
||||
}
|
||||
|
||||
|
||||
@ -65,6 +65,18 @@ function getErrorMessage(error: unknown) {
|
||||
return '未知错误'
|
||||
}
|
||||
|
||||
function isVideoTranscodeSchemaMissing(error: unknown) {
|
||||
const code = (error as any)?.code
|
||||
const table = String((error as any)?.meta?.table || '')
|
||||
const message = getErrorMessage(error)
|
||||
|
||||
return (
|
||||
(code === 'P2021' && (table.includes('video_transcode_jobs') || message.includes('video_transcode_jobs'))) ||
|
||||
(code === 'P2022' && message.includes('video_transcode_jobs')) ||
|
||||
(code === 'P2010' && message.includes('video_transcode_jobs'))
|
||||
)
|
||||
}
|
||||
|
||||
async function runVideoTranscodeTask(
|
||||
materialId: string,
|
||||
videoSourceUrl: string,
|
||||
@ -229,46 +241,62 @@ export async function recoverStaleVideoTranscodeJobs() {
|
||||
return { recovered: 0, hasMore: false, skipped: true }
|
||||
}
|
||||
|
||||
const staleJobs = await videoTranscodeJob.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ status: 'queued' },
|
||||
{ status: 'processing', lockedAt: null },
|
||||
{ status: 'processing', lockedAt: { lt: staleBefore } },
|
||||
],
|
||||
material: {
|
||||
videoSourceUrl: { not: null },
|
||||
videoProcessingStatus: { in: ['pending', 'processing'] },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
material: {
|
||||
select: {
|
||||
id: true,
|
||||
videoSourceUrl: true,
|
||||
videoCoverUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: RECOVERY_BATCH_SIZE,
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
})
|
||||
|
||||
const orphanMaterials = await prisma.$queryRaw<Array<{
|
||||
let staleJobs: any[] = []
|
||||
let orphanMaterials: Array<{
|
||||
id: string
|
||||
videoSourceUrl: string | null
|
||||
videoCoverUrl: string | null
|
||||
}>>`
|
||||
SELECT m.id, m."videoSourceUrl", m."videoCoverUrl"
|
||||
FROM "materials" m
|
||||
LEFT JOIN "video_transcode_jobs" j ON j."materialId" = m.id
|
||||
WHERE (m."materialKind" = 'VIDEO' OR m."materialType" = '视频')
|
||||
AND m."videoSourceUrl" IS NOT NULL
|
||||
AND m."videoProcessingStatus" IN ('pending', 'processing')
|
||||
AND j.id IS NULL
|
||||
ORDER BY m."updatedAt" ASC
|
||||
LIMIT ${RECOVERY_BATCH_SIZE}
|
||||
`
|
||||
}> = []
|
||||
|
||||
try {
|
||||
staleJobs = await videoTranscodeJob.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ status: 'queued' },
|
||||
{ status: 'processing', lockedAt: null },
|
||||
{ status: 'processing', lockedAt: { lt: staleBefore } },
|
||||
],
|
||||
material: {
|
||||
videoSourceUrl: { not: null },
|
||||
videoProcessingStatus: { in: ['pending', 'processing'] },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
material: {
|
||||
select: {
|
||||
id: true,
|
||||
videoSourceUrl: true,
|
||||
videoCoverUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: RECOVERY_BATCH_SIZE,
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
})
|
||||
|
||||
orphanMaterials = await prisma.$queryRaw<Array<{
|
||||
id: string
|
||||
videoSourceUrl: string | null
|
||||
videoCoverUrl: string | null
|
||||
}>>`
|
||||
SELECT m.id, m."videoSourceUrl", m."videoCoverUrl"
|
||||
FROM "materials" m
|
||||
LEFT JOIN "video_transcode_jobs" j ON j."materialId" = m.id
|
||||
WHERE (m."materialKind" = 'VIDEO' OR m."materialType" = '视频')
|
||||
AND m."videoSourceUrl" IS NOT NULL
|
||||
AND m."videoProcessingStatus" IN ('pending', 'processing')
|
||||
AND j.id IS NULL
|
||||
ORDER BY m."updatedAt" ASC
|
||||
LIMIT ${RECOVERY_BATCH_SIZE}
|
||||
`
|
||||
} catch (error) {
|
||||
if (isVideoTranscodeSchemaMissing(error)) {
|
||||
console.warn('[Transcode] 跳过启动恢复:数据库缺少 video_transcode_jobs 表,请执行数据库迁移')
|
||||
return { recovered: 0, hasMore: false, skipped: true }
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
let recovered = 0
|
||||
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "system_settings"
|
||||
ADD COLUMN IF NOT EXISTS "enablePublicShare" BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
@ -385,6 +385,7 @@ model SystemSettings {
|
||||
enableUserActivation Boolean @default(false)
|
||||
enableAiImageEdit Boolean @default(false)
|
||||
enableTunnelService Boolean @default(false)
|
||||
enablePublicShare Boolean @default(true)
|
||||
enabledBuiltinTemplates String? // JSON array of enabled builtin template keys (empty array or null = all enabled)
|
||||
categoryManagedFields String? // JSON array,记录分类管理已启用的字段 key
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user