diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts index 617c408..3062d8f 100644 --- a/app/api/materials/[id]/route.ts +++ b/app/api/materials/[id]/route.ts @@ -8,7 +8,6 @@ import { canUserDownloadSource, canUserViewMaterial, isSystemAdmin, - isShareLinkAccess, } from '@/lib/department-permission'; import { updateMaterialWithSharedRules, type MaterialUpdateInput } from '@/lib/material-update'; import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set'; @@ -138,6 +137,14 @@ export async function GET( { params }: { params: { id: string } } ) { try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json( + { error: '请先登录' }, + { status: 401 } + ); + } + let material; try { @@ -284,8 +291,6 @@ export async function GET( return NextResponse.json({ error: '素材不存在' }, { status: 404 }); } - // 权限检查:如果不是分享链接访问,检查查看权限 - const isShare = isShareLinkAccess(request); let canAccessSource = false; const videoPreviewPolicy = getVideoPreviewPolicyFields( (material as any).materialType, @@ -304,79 +309,64 @@ export async function GET( { status: 403 }, ); - if (isShare && videoPreviewPolicy.videoPreviewBlocked) { + const userRole = (session.user as any).role; + const userDepartment = (session.user as any).department; + const managedDepartment = (session.user as any).managedDepartment; + const userId = (session.user as any).id; + + // 超级管理员不受限制 + const { isSuperAdmin } = await import('@/lib/department-permission'); + const userIsSuperAdmin = await isSuperAdmin(userId); + if (!userIsSuperAdmin) { + const canView = await canUserViewMaterial( + userDepartment, + userRole, + managedDepartment, + (material as any).materialType, + userId + ); + + if (!canView) { + return NextResponse.json( + { error: '无权限查看该素材' }, + { status: 403 } + ); + } + } + + const canBypassVideoPreviewGate = isSystemAdmin(userRole) || userIsSuperAdmin; + if (videoPreviewPolicy.videoPreviewBlocked && !canBypassVideoPreviewGate) { return buildVideoPreviewBlockedResponse(); } - if (!isShare) { - const session = await getServerSession(authOptions); - if (session?.user) { - const userRole = (session.user as any).role; - const userDepartment = (session.user as any).department; - const managedDepartment = (session.user as any).managedDepartment; - const userId = (session.user as any).id; - - // 超级管理员不受限制 - const { isSuperAdmin } = await import('@/lib/department-permission'); - const userIsSuperAdmin = await isSuperAdmin(userId); - if (!userIsSuperAdmin) { - const canView = await canUserViewMaterial( - userDepartment, - userRole, - managedDepartment, - (material as any).materialType, - userId - ); - - if (!canView) { - return NextResponse.json( - { error: '无权限查看该素材' }, - { status: 403 } - ); - } - } - - const canBypassVideoPreviewGate = isSystemAdmin(userRole) || userIsSuperAdmin; - if (videoPreviewPolicy.videoPreviewBlocked && !canBypassVideoPreviewGate) { - return buildVideoPreviewBlockedResponse(); - } - - if (isSystemAdmin(userRole) || userIsSuperAdmin) { - canAccessSource = true; - } else { - const userDownloadSetting = await prisma.user.findUnique({ - where: { id: userId }, - select: { canDownload: true }, - }); - if (userDownloadSetting?.canDownload) { - const canDownloadMaterial = await canUserDownloadMaterial( + if (isSystemAdmin(userRole) || userIsSuperAdmin) { + canAccessSource = true; + } else { + const userDownloadSetting = await prisma.user.findUnique({ + where: { id: userId }, + select: { canDownload: true }, + }); + if (userDownloadSetting?.canDownload) { + const canDownloadMaterial = await canUserDownloadMaterial( + userDepartment, + userRole, + managedDepartment, + (material as any).materialType, + userId + ); + const requiresSourcePermission = + isProductPackageType((material as any).materialType) || + is3DOriginalType((material as any).materialType); + const hasSourcePermission = requiresSourcePermission + ? await canUserDownloadSource( userDepartment, userRole, managedDepartment, - (material as any).materialType, + '详情模板', userId - ); - const requiresSourcePermission = - isProductPackageType((material as any).materialType) || - is3DOriginalType((material as any).materialType); - const hasSourcePermission = requiresSourcePermission - ? await canUserDownloadSource( - userDepartment, - userRole, - managedDepartment, - '详情模板', - userId - ) - : true; - canAccessSource = canDownloadMaterial && hasSourcePermission; - } - } - } else { - // 未登录用户且不是分享链接,不允许查看 - return NextResponse.json( - { error: '请先登录' }, - { status: 401 } - ); + ) + : true; + canAccessSource = canDownloadMaterial && hasSourcePermission; } } diff --git a/app/api/materials/download/route.ts b/app/api/materials/download/route.ts index c059f07..6d57892 100644 --- a/app/api/materials/download/route.ts +++ b/app/api/materials/download/route.ts @@ -13,14 +13,13 @@ import path from 'path'; import { lookup } from 'mime-types'; import { canUserDownloadMaterial, - isShareLinkAccess, canUserDownloadSource, } from '@/lib/department-permission'; import { DownloadErrorCode, createDownloadError, } from '@/lib/download-errors'; -import { isProductPackageType } from '@/lib/material-type-mapper'; +import { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper'; import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'; import { resolveStoredUploadPath } from '@/lib/upload-paths'; import { @@ -35,35 +34,30 @@ function normalizeStoredLocalPath(filePath: string) { // GET - 下载素材 export async function GET(request: NextRequest) { try { - const isShare = isShareLinkAccess(request); - - // 如果不是分享链接,需要登录 - if (!isShare) { - const session = await getServerSession(authOptions); - if (!session) { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json( + createDownloadError(DownloadErrorCode.UNAUTHORIZED), + { status: 401 } + ); + } + + const userId = (session.user as any).id; + const userRole = (session.user as any).role; + + // 检查用户级别的下载权限 + if (userRole !== 'ADMIN') { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { canDownload: true }, + }); + + if (!user || !user.canDownload) { return NextResponse.json( - createDownloadError(DownloadErrorCode.UNAUTHORIZED), - { status: 401 } + createDownloadError(DownloadErrorCode.FORBIDDEN), + { status: 403 } ); } - - const userId = (session.user as any).id; - const userRole = (session.user as any).role; - - // 检查用户级别的下载权限 - if (userRole !== 'ADMIN') { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { canDownload: true }, - }); - - if (!user || !user.canDownload) { - return NextResponse.json( - createDownloadError(DownloadErrorCode.FORBIDDEN), - { status: 403 } - ); - } - } } const { searchParams } = new URL(request.url); @@ -81,8 +75,8 @@ export async function GET(request: NextRequest) { } // 查询素材 - const material = await prisma.material.findUnique({ - where: { id: materialId }, + const material = await prisma.material.findFirst({ + where: { id: materialId, deletedAt: null }, select: { id: true, zipFilePath: true, @@ -118,50 +112,46 @@ export async function GET(request: NextRequest) { ); } - // 权限检查:如果不是分享链接,检查部门级别的下载权限 - if (!isShare) { - const session = await getServerSession(authOptions); - if (session?.user) { - const userRole = (session.user as any).role; - const userDepartment = (session.user as any).department; - const managedDepartment = (session.user as any).managedDepartment; - const userId = (session.user as any).id; - - // 超级管理员不受限制 - const { isSuperAdmin } = await import('@/lib/department-permission'); - if (!(await isSuperAdmin(userId))) { - const canDownload = await canUserDownloadMaterial( - userDepartment, - userRole, - managedDepartment, - material.materialType, - userId - ); - - if (!canDownload) { - return NextResponse.json( - createDownloadError(DownloadErrorCode.NO_PERMISSION), - { status: 403 } - ); - } + // 所有下载请求都必须检查部门和源文件权限,查询参数不能绕过。 + const userDepartment = (session.user as any).department; + const managedDepartment = (session.user as any).managedDepartment; - // 任务-详情:PSD下载权限参考"详情模板"类型的 canDownloadSource 权限 - if (isProductPackageType(material.materialType) && downloadType === 'psd') { - const canDownloadPsd = await canUserDownloadSource( - userDepartment, - userRole, - managedDepartment, - '详情模板', - userId - ); - - if (!canDownloadPsd) { - return NextResponse.json( - createDownloadError(DownloadErrorCode.NO_PERMISSION, '无权限下载 PSD 源文件'), - { status: 403 } - ); - } - } + // 超级管理员不受限制 + const { isSuperAdmin } = await import('@/lib/department-permission'); + if (!(await isSuperAdmin(userId))) { + const canDownload = await canUserDownloadMaterial( + userDepartment, + userRole, + managedDepartment, + material.materialType, + userId + ); + + if (!canDownload) { + return NextResponse.json( + createDownloadError(DownloadErrorCode.NO_PERMISSION), + { status: 403 } + ); + } + + // 任务-详情 PSD 和任务原图源文件统一检查 canDownloadSource。 + const requiresSourcePermission = + is3DOriginalType(material.materialType) || + (isProductPackageType(material.materialType) && downloadType === 'psd'); + if (requiresSourcePermission) { + const canDownloadSourceFile = await canUserDownloadSource( + userDepartment, + userRole, + managedDepartment, + '详情模板', + userId + ); + + if (!canDownloadSourceFile) { + return NextResponse.json( + createDownloadError(DownloadErrorCode.NO_PERMISSION, '无权限下载该源文件'), + { status: 403 } + ); } } } @@ -326,8 +316,8 @@ async function serveFile(filePath: string, material: any, fileNameSuffix: string 'Content-Type': mimeType, 'Content-Length': fileStats.size.toString(), 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, - // 静态文件:长期缓存(1天),允许重新验证 - 'Cache-Control': 'public, max-age=86400, must-revalidate', + // 鉴权下载不允许浏览器或前置代理共享缓存。 + 'Cache-Control': 'private, no-store, max-age=0', 'Accept-Ranges': 'bytes', 'Vary': 'Accept-Encoding', 'ETag': `"${Buffer.from(`${filePath}-${fileStats.size}`).toString('base64').slice(0, 27)}"`, diff --git a/app/api/materials/share/route.ts b/app/api/materials/share/route.ts index d65efec..bd58f06 100644 --- a/app/api/materials/share/route.ts +++ b/app/api/materials/share/route.ts @@ -115,8 +115,8 @@ export async function GET(request: NextRequest) { const session = await getServerSession(authOptions); const userId = session?.user ? (session.user as any).id : undefined; - const material = await prisma.material.findUnique({ - where: { id }, + const material = await prisma.material.findFirst({ + where: { id, deletedAt: null }, select: { id: true, previewUrl: true, diff --git a/app/page.tsx b/app/page.tsx index dab1d5c..3c4110f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -14,6 +14,7 @@ import { parseDownloadError } from '@/lib/download-errors' import { validateAndTriggerDownload } from '@/lib/client-download' import { parseApiError, handleError, parseFetchError } from '@/lib/error-handler' import { normalizeVideoMaterialType, isVideoMaterialType as isVideoMaterialTypeValue } from '@/lib/video-material-types' +import { buildPublicMaterialShareUrl } from '@/lib/public-app-url' // 首页拆分组件 import { SearchBar, FilterPanel, LoadMoreTrigger, MaterialGrid } from '@/components/home' @@ -1074,7 +1075,7 @@ export default function Home() { // 分享功能 const handleShare = async (materialId: string) => { - const url = `${window.location.origin}/share/${materialId}` + const url = buildPublicMaterialShareUrl(materialId) setShareUrl(url) try { await navigator.clipboard.writeText(url) diff --git a/lib/__tests__/material-edit-routes.test.ts b/lib/__tests__/material-edit-routes.test.ts index 487a568..e961bf4 100644 --- a/lib/__tests__/material-edit-routes.test.ts +++ b/lib/__tests__/material-edit-routes.test.ts @@ -10,7 +10,6 @@ import { canUserDownloadMaterial, canUserDownloadSource, canUserViewMaterial, - isShareLinkAccess, isSuperAdmin, isSystemAdmin, } from '@/lib/department-permission' @@ -39,7 +38,6 @@ jest.mock('@/lib/department-permission', () => ({ canUserDownloadMaterial: jest.fn(), canUserDownloadSource: jest.fn(), canUserViewMaterial: jest.fn(), - isShareLinkAccess: jest.fn(), isSuperAdmin: jest.fn(), isSystemAdmin: jest.fn(), })) @@ -65,7 +63,6 @@ describe('material edit routes', () => { managedDepartment: null, }, }) - ;(isShareLinkAccess as jest.Mock).mockReturnValue(false) ;(isSuperAdmin as jest.Mock).mockResolvedValue(false) ;(isSystemAdmin as jest.Mock).mockReturnValue(false) ;(canUserViewMaterial as jest.Mock).mockResolvedValue(true) @@ -166,6 +163,23 @@ describe('material edit routes', () => { ) }) + test.each(['share=1', 'shareId=anything'])( + 'GET /api/materials/[id] 不能通过 %s 绕过登录校验', + async (shareParam) => { + ;(getServerSession as jest.Mock).mockResolvedValue(null) + + const response = await getMaterialById( + new NextRequest(`http://localhost/api/materials/material-1?${shareParam}`), + { params: { id: 'material-1' } }, + ) + const body = await response.json() + + expect(response.status).toBe(401) + expect(body.error).toBe('请先登录') + expect(prisma.material.findFirst).not.toHaveBeenCalled() + }, + ) + test('GET /api/materials/[id] 应将 assets 中的 BigInt fileSize 序列化为字符串', async () => { ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ id: 'material-1', diff --git a/lib/__tests__/materials-download-video-wait-route.test.ts b/lib/__tests__/materials-download-video-wait-route.test.ts index 31f7cd6..81d92aa 100644 --- a/lib/__tests__/materials-download-video-wait-route.test.ts +++ b/lib/__tests__/materials-download-video-wait-route.test.ts @@ -3,11 +3,17 @@ import { NextRequest } from 'next/server' import { GET } from '@/app/api/materials/download/route' import { prisma } from '@/lib/prisma' +import { getServerSession } from 'next-auth' +import { + canUserDownloadMaterial, + canUserDownloadSource, + isSuperAdmin, +} from '@/lib/department-permission' jest.mock('@/lib/prisma', () => ({ prisma: { material: { - findUnique: jest.fn(), + findFirst: jest.fn(), }, user: { findUnique: jest.fn(), @@ -23,13 +29,35 @@ jest.mock('@/lib/auth', () => ({ authOptions: {}, })) +jest.mock('@/lib/department-permission', () => { + const actual = jest.requireActual('@/lib/department-permission') + return { + ...actual, + canUserDownloadMaterial: jest.fn(), + canUserDownloadSource: jest.fn(), + isSuperAdmin: jest.fn(), + } +}) + describe('materials download route video wait period', () => { beforeEach(() => { jest.clearAllMocks() + ;(getServerSession as jest.Mock).mockResolvedValue({ + user: { + id: 'admin-1', + role: 'ADMIN', + department: '管理部', + managedDepartment: null, + }, + }) + ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isSuperAdmin: true }) + ;(isSuperAdmin as jest.Mock).mockResolvedValue(true) + ;(canUserDownloadMaterial as jest.Mock).mockResolvedValue(true) + ;(canUserDownloadSource as jest.Mock).mockResolvedValue(true) }) function mockMaterial(materialType: string, createdAt: Date, zipFilePath = '') { - ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ id: 'material-1', zipFilePath, zipFileName: 'video.mp4', @@ -43,6 +71,118 @@ describe('materials download route video wait period', () => { }) } + test.each(['share=1', 'shareId=anything'])( + '匿名请求不能通过 %s 绕过下载登录校验', + async (shareParam) => { + ;(getServerSession as jest.Mock).mockResolvedValue(null) + + const response = await GET( + new NextRequest(`http://localhost/api/materials/download?id=material-1&${shareParam}`), + ) + const body = await response.json() + + expect(response.status).toBe(401) + expect(body.code).toBe('UNAUTHORIZED') + expect(prisma.material.findFirst).not.toHaveBeenCalled() + }, + ) + + test('无下载权限用户不能通过 share 参数绕过个人权限', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ + user: { + id: 'user-1', + role: 'USER', + department: '测试部门', + managedDepartment: null, + }, + }) + ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ canDownload: false }) + + const response = await GET( + new NextRequest('http://localhost/api/materials/download?id=material-1&share=1'), + ) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.code).toBe('FORBIDDEN') + expect(prisma.material.findFirst).not.toHaveBeenCalled() + }) + + test('已软删除素材不能通过旧 ID 直接下载', async () => { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue(null) + + const response = await GET( + new NextRequest('http://localhost/api/materials/download?id=material-deleted'), + ) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body.code).toBe('MATERIAL_NOT_FOUND') + expect(prisma.material.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: 'material-deleted', + deletedAt: null, + }, + }), + ) + }) + + test('任务原图源文件不能通过 share 参数绕过源文件权限', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ + user: { + id: 'user-1', + role: 'USER', + department: '测试部门', + managedDepartment: null, + }, + }) + ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ canDownload: true }) + ;(isSuperAdmin as jest.Mock).mockResolvedValue(false) + ;(canUserDownloadMaterial as jest.Mock).mockResolvedValue(true) + ;(canUserDownloadSource as jest.Mock).mockResolvedValue(false) + mockMaterial('任务-3D原图', new Date('2026-01-01T00:00:00.000Z'), 'uploads/source.zip') + + const response = await GET( + new NextRequest('http://localhost/api/materials/download?id=material-1&share=1'), + ) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.code).toBe('NO_PERMISSION') + expect(canUserDownloadSource).toHaveBeenCalledWith( + '测试部门', + 'USER', + null, + '详情模板', + 'user-1', + ) + }) + + test('部门下载权限不能通过 share 参数绕过', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ + user: { + id: 'user-1', + role: 'USER', + department: '测试部门', + managedDepartment: null, + }, + }) + ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ canDownload: true }) + ;(isSuperAdmin as jest.Mock).mockResolvedValue(false) + ;(canUserDownloadMaterial as jest.Mock).mockResolvedValue(false) + mockMaterial('3D场景', new Date('2026-01-01T00:00:00.000Z'), 'uploads/source.zip') + + const response = await GET( + new NextRequest('http://localhost/api/materials/download?id=material-1&share=1'), + ) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.code).toBe('NO_PERMISSION') + expect(canUserDownloadSource).not.toHaveBeenCalled() + }) + test('非安装视频未满 15 天时应禁止下载,分享链接也不能绕过', async () => { mockMaterial('淘系-主图视频', new Date()) diff --git a/lib/__tests__/materials-route-ai-generated.test.ts b/lib/__tests__/materials-route-ai-generated.test.ts index 63c0aa2..846870f 100644 --- a/lib/__tests__/materials-route-ai-generated.test.ts +++ b/lib/__tests__/materials-route-ai-generated.test.ts @@ -5,7 +5,7 @@ import { getServerSession } from 'next-auth' import { GET } from '@/app/api/materials/route' import { prisma } from '@/lib/prisma' -import { getUserViewableMaterialTypes, isShareLinkAccess, isSuperAdmin, isSystemAdmin } from '@/lib/department-permission' +import { getUserViewableMaterialTypes, isSuperAdmin, isSystemAdmin } from '@/lib/department-permission' jest.mock('next-auth', () => ({ getServerSession: jest.fn(), @@ -43,7 +43,6 @@ jest.mock('@/lib/prisma', () => ({ jest.mock('@/lib/department-permission', () => ({ isSystemAdmin: jest.fn(), getUserViewableMaterialTypes: jest.fn(), - isShareLinkAccess: jest.fn(), isSuperAdmin: jest.fn(), })) @@ -99,7 +98,6 @@ describe('materials route ai generated', () => { isSuperAdmin: false, }, }) - ;(isShareLinkAccess as jest.Mock).mockReturnValue(false) ;(isSuperAdmin as jest.Mock).mockResolvedValue(false) ;(isSystemAdmin as jest.Mock).mockReturnValue(false) ;(getUserViewableMaterialTypes as jest.Mock).mockResolvedValue(['任务-详情']) diff --git a/lib/__tests__/materials-share-security.test.ts b/lib/__tests__/materials-share-security.test.ts new file mode 100644 index 0000000..1853c98 --- /dev/null +++ b/lib/__tests__/materials-share-security.test.ts @@ -0,0 +1,60 @@ +/** @jest-environment node */ + +import fs from 'fs' +import path from 'path' +import { NextRequest } from 'next/server' +import { getServerSession } from 'next-auth' +import { GET } from '@/app/api/materials/share/route' +import { prisma } from '@/lib/prisma' + +jest.mock('@/lib/prisma', () => ({ + prisma: { + material: { + findFirst: jest.fn(), + }, + }, +})) + +jest.mock('next-auth', () => ({ + getServerSession: jest.fn(), +})) + +jest.mock('@/lib/auth', () => ({ + authOptions: {}, +})) + +describe('materials share security', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(getServerSession as jest.Mock).mockResolvedValue(null) + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue(null) + }) + + test('公开分享只查询未删除素材', async () => { + const response = await GET( + new NextRequest('http://localhost/api/materials/share?id=material-deleted'), + ) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body.error).toBe('素材不存在') + expect(prisma.material.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: 'material-deleted', + deletedAt: null, + }, + }), + ) + }) + + test('鉴权下载响应禁止共享缓存', () => { + const routeSource = fs.readFileSync( + path.join(process.cwd(), 'app/api/materials/download/route.ts'), + 'utf8', + ) + + expect(routeSource).toContain("'Cache-Control': 'private, no-store, max-age=0'") + expect(routeSource).not.toContain("'Cache-Control': 'public, max-age=86400, must-revalidate'") + }) +}) diff --git a/lib/__tests__/public-app-url.test.ts b/lib/__tests__/public-app-url.test.ts new file mode 100644 index 0000000..9e8c79b --- /dev/null +++ b/lib/__tests__/public-app-url.test.ts @@ -0,0 +1,47 @@ +/** @jest-environment node */ + +import { buildPublicMaterialShareUrl, getPublicAppOrigin } from '@/lib/public-app-url' + +const originalNextPublicAppUrl = process.env.NEXT_PUBLIC_APP_PUBLIC_URL +const originalAppPublicUrl = process.env.APP_PUBLIC_URL +const originalNextAuthUrl = process.env.NEXTAUTH_URL + +function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name] + return + } + process.env[name] = value +} + +describe('public app origin', () => { + afterEach(() => { + restoreEnv('NEXT_PUBLIC_APP_PUBLIC_URL', originalNextPublicAppUrl) + restoreEnv('APP_PUBLIC_URL', originalAppPublicUrl) + restoreEnv('NEXTAUTH_URL', originalNextAuthUrl) + }) + + test('uses the configured public origin', () => { + process.env.NEXT_PUBLIC_APP_PUBLIC_URL = 'https://sc.jiansun.vip/some-path' + process.env.APP_PUBLIC_URL = 'http://192.168.58.20:4320' + process.env.NEXTAUTH_URL = 'http://localhost:3000' + + expect(getPublicAppOrigin()).toBe('https://sc.jiansun.vip') + }) + + test.each([ + 'http://192.168.58.20:4320', + 'http://10.0.0.8:3000', + 'http://172.16.0.9:3000', + 'http://localhost:3000', + ])('never exposes a private origin: %s', (privateOrigin) => { + process.env.NEXT_PUBLIC_APP_PUBLIC_URL = privateOrigin + process.env.APP_PUBLIC_URL = privateOrigin + process.env.NEXTAUTH_URL = privateOrigin + + expect(getPublicAppOrigin()).toBe('https://sc.jiansun.vip') + expect(buildPublicMaterialShareUrl('material/id')).toBe( + 'https://sc.jiansun.vip/share/material%2Fid', + ) + }) +}) diff --git a/lib/department-permission.ts b/lib/department-permission.ts index 54c9725..3eb9a34 100644 --- a/lib/department-permission.ts +++ b/lib/department-permission.ts @@ -440,14 +440,6 @@ export async function canUserDownloadMaterial( return permission?.canDownload ?? false; } -/** - * 检查是否是分享链接访问 - */ -export function isShareLinkAccess(request: Request): boolean { - const url = new URL(request.url); - return url.searchParams.has('share') || url.searchParams.has('shareId'); -} - /** * 为部门创建默认权限配置 * 使用数据库中实际存在的所有素材类型 diff --git a/lib/public-app-url.ts b/lib/public-app-url.ts index 8d0b8e0..ca31731 100644 --- a/lib/public-app-url.ts +++ b/lib/public-app-url.ts @@ -46,6 +46,10 @@ export function getPublicAppOrigin(): string { return DEFAULT_PUBLIC_APP_URL } +export function buildPublicMaterialShareUrl(materialId: string): string { + return `${getPublicAppOrigin()}/share/${encodeURIComponent(materialId)}` +} + export function getAllowedPublicAppOrigins(): string[] { return Array.from(new Set([getPublicAppOrigin(), ...LEGACY_PUBLIC_APP_ORIGINS])) }