fix: allow protected video share links
This commit is contained in:
parent
0255368562
commit
2f4ee50a97
@ -170,7 +170,12 @@ export async function GET(request: NextRequest) {
|
||||
availableAt: videoPreviewPolicy.videoPreviewAvailableAt,
|
||||
remainingDays: videoPreviewPolicy.videoPreviewRemainingDays,
|
||||
},
|
||||
{ status: 403 },
|
||||
{
|
||||
status: 403,
|
||||
headers: {
|
||||
'Cache-Control': 'private, no-store, max-age=0',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
81
app/share/[id]/page.test.tsx
Normal file
81
app/share/[id]/page.test.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import SharePage from './page'
|
||||
|
||||
const mockPush = jest.fn()
|
||||
const mockReplace = jest.fn()
|
||||
const mockBack = jest.fn()
|
||||
const originalFetch = global.fetch
|
||||
|
||||
jest.mock('next-auth/react', () => ({
|
||||
useSession: () => ({ data: null, status: 'unauthenticated' }),
|
||||
}))
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useParams: () => ({ id: 'video-material-1' }),
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
replace: mockReplace,
|
||||
back: mockBack,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/Header', () => ({
|
||||
__esModule: true,
|
||||
default: () => <header data-testid="header" />,
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/useToast', () => ({
|
||||
useToast: () => ({ toast: null, showToast: jest.fn() }),
|
||||
}))
|
||||
|
||||
jest.mock('@/components/ConfirmActionDialog', () => ({
|
||||
useConfirmActionDialog: () => ({
|
||||
confirmAction: jest.fn(),
|
||||
confirmActionDialog: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('video material share page', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({
|
||||
error: '视频类素材上传后满 15 天才能查看详情',
|
||||
code: 'VIDEO_WAIT_PERIOD',
|
||||
details: '视频类素材上传后满 15 天才能预览和下载,当前还需约 12 天',
|
||||
availableAt: '2026-09-01T00:00:00.000Z',
|
||||
remainingDays: 12,
|
||||
}),
|
||||
}) as jest.Mock
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
global.fetch = originalFetch
|
||||
})
|
||||
|
||||
test('保护期内保留分享页并明确提示同一链接稍后可用', async () => {
|
||||
render(<SharePage />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '视频暂不可查看' })).toBeInTheDocument()
|
||||
expect(screen.getByText('当前还需约 12 天')).toBeInTheDocument()
|
||||
expect(screen.getByText(/分享链接已经生效,无需重新分享/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '重新检查' })).toBeInTheDocument()
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/materials/share?id=video-material-1',
|
||||
{ cache: 'no-store' },
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -20,7 +20,7 @@ import MaterialDetailHeader, {
|
||||
} from '@/components/detail/MaterialDetailHeader'
|
||||
import { useConfirmActionDialog } from '@/components/ConfirmActionDialog'
|
||||
import DocumentAssetViewer, { type ViewerAssetItem } from '@/components/DocumentAssetViewer'
|
||||
import { parseDownloadError } from '@/lib/download-errors'
|
||||
import { DownloadErrorCode, parseDownloadError } from '@/lib/download-errors'
|
||||
import { validateAndTriggerDownload } from '@/lib/client-download'
|
||||
import { parseApiError, parseFetchError } from '@/lib/error-handler'
|
||||
import { getCertificateReportDisplayTitle, isCertificateReportMaterialType } from '@/lib/certificate-report'
|
||||
@ -84,6 +84,36 @@ interface Material {
|
||||
}>
|
||||
}
|
||||
|
||||
interface VideoShareWaitState {
|
||||
message: string
|
||||
availableAt: string | null
|
||||
remainingDays: number
|
||||
}
|
||||
|
||||
function parseVideoShareWaitState(value: unknown): VideoShareWaitState | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
|
||||
const payload = value as Record<string, unknown>
|
||||
if (payload.code !== DownloadErrorCode.VIDEO_WAIT_PERIOD) return null
|
||||
|
||||
const parsedRemainingDays = Number(payload.remainingDays)
|
||||
const remainingDays = Number.isFinite(parsedRemainingDays)
|
||||
? Math.max(1, Math.ceil(parsedRemainingDays))
|
||||
: 15
|
||||
|
||||
return {
|
||||
message:
|
||||
typeof payload.details === 'string' && payload.details.trim()
|
||||
? payload.details
|
||||
: '视频类素材上传后满 15 天才能查看详情',
|
||||
availableAt:
|
||||
typeof payload.availableAt === 'string' && payload.availableAt.trim()
|
||||
? payload.availableAt
|
||||
: null,
|
||||
remainingDays,
|
||||
}
|
||||
}
|
||||
|
||||
export default function SharePage() {
|
||||
const { data: session, status } = useSession()
|
||||
const router = useRouter()
|
||||
@ -91,6 +121,7 @@ export default function SharePage() {
|
||||
const materialId = params?.id as string
|
||||
|
||||
const [material, setMaterial] = useState<Material | null>(null)
|
||||
const [videoShareWait, setVideoShareWait] = useState<VideoShareWaitState | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [downloadProgress, setDownloadProgress] = useState(0)
|
||||
@ -172,6 +203,7 @@ export default function SharePage() {
|
||||
const fetchMaterial = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setVideoShareWait(null)
|
||||
|
||||
// 如果是视频类型,立即开始预加载视频(在获取数据前就开始)
|
||||
const isVideoType = materialId && (
|
||||
@ -298,7 +330,17 @@ export default function SharePage() {
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const error = await parseFetchError(response)
|
||||
const errorPayload = await response.json().catch(() => null)
|
||||
const waitState = parseVideoShareWaitState(errorPayload)
|
||||
if (response.status === 403 && waitState) {
|
||||
setMaterial(null)
|
||||
setVideoShareWait(waitState)
|
||||
return
|
||||
}
|
||||
|
||||
const error = errorPayload
|
||||
? parseApiError(errorPayload)
|
||||
: await parseFetchError(response)
|
||||
showToast(error.message, { variant: 'error' })
|
||||
router.push('/')
|
||||
}
|
||||
@ -666,6 +708,53 @@ export default function SharePage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (videoShareWait) {
|
||||
const availableAt = videoShareWait.availableAt
|
||||
? new Date(videoShareWait.availableAt)
|
||||
: null
|
||||
const availableAtLabel = availableAt && !Number.isNaN(availableAt.getTime())
|
||||
? format(availableAt, 'yyyy-MM-dd HH:mm')
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header session={session} />
|
||||
<main className="container mx-auto px-4 py-8 sm:py-12">
|
||||
<section
|
||||
aria-labelledby="video-share-wait-title"
|
||||
className="mx-auto max-w-2xl rounded-2xl border border-gray-200 bg-white px-5 py-8 text-center shadow-lg dark:border-gray-700 dark:bg-gray-800 sm:px-8 sm:py-10"
|
||||
>
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-blue-50 text-blue-600 dark:bg-blue-950/50 dark:text-blue-300">
|
||||
<svg className="h-7 w-7" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 id="video-share-wait-title" className="mt-5 text-2xl font-semibold text-gray-950 dark:text-white">
|
||||
视频暂不可查看
|
||||
</h1>
|
||||
<p className="mx-auto mt-3 max-w-xl text-base leading-7 text-gray-600 dark:text-gray-300">
|
||||
{videoShareWait.message}
|
||||
</p>
|
||||
<div className="mx-auto mt-6 max-w-md rounded-xl bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:bg-gray-900/70 dark:text-gray-300">
|
||||
<p>当前还需约 {videoShareWait.remainingDays} 天</p>
|
||||
{availableAtLabel && <p className="mt-1">预计开放时间:{availableAtLabel}</p>}
|
||||
</div>
|
||||
<p className="mt-5 text-sm leading-6 text-gray-500 dark:text-gray-400">
|
||||
分享链接已经生效,无需重新分享;保护期结束后,使用同一个链接即可查看。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fetchMaterial()}
|
||||
className="mt-6 inline-flex min-h-11 items-center justify-center rounded-lg bg-blue-600 px-5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900"
|
||||
>
|
||||
重新检查
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!material) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
|
||||
@ -140,6 +140,25 @@ describe('MaterialCard', () => {
|
||||
expect(screen.getByText('15天后可查看详情')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('15天保护期内仍可复制分享链接', () => {
|
||||
render(
|
||||
<MaterialCard
|
||||
{...defaultProps}
|
||||
material={{
|
||||
...baseMaterial,
|
||||
materialType: '淘系-逛逛视频',
|
||||
videoPreviewBlocked: true,
|
||||
videoPreviewRemainingDays: 12,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '分享' }))
|
||||
|
||||
expect(defaultProps.onShare).toHaveBeenCalledWith('material-1')
|
||||
expect(defaultProps.showToast).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('接口未返回并发上限时按当前单任务策略展示', () => {
|
||||
render(
|
||||
<MaterialCard
|
||||
|
||||
@ -763,13 +763,9 @@ export default function MaterialCard({
|
||||
|
||||
{/* 分享按钮 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isPreviewWaiting) {
|
||||
showToast(previewUnavailableMessage, { variant: 'error' })
|
||||
return
|
||||
}
|
||||
onShare(material.id)
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => onShare(material.id)}
|
||||
aria-label="分享"
|
||||
className="flex-none h-10 w-10 flex items-center justify-center rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"
|
||||
title="分享"
|
||||
>
|
||||
|
||||
@ -5,6 +5,7 @@ import path from 'path'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { GET } from '@/app/api/materials/share/route'
|
||||
import { DownloadErrorCode } from '@/lib/download-errors'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
@ -122,6 +123,53 @@ describe('materials share security', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('未满15天的视频分享链接保留有效,但不返回任何视频内容', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(buildMaterial({
|
||||
materialType: '淘系-逛逛视频',
|
||||
createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000),
|
||||
videoSourceUrl: '/uploads/materials/source.mp4',
|
||||
videoPreview540Url: 'https://example.com/preview.mp4',
|
||||
videoPreviewClipUrl: 'https://example.com/clip.mp4',
|
||||
}))
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost/api/materials/share?id=material-video'),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store, max-age=0')
|
||||
expect(body).toMatchObject({
|
||||
code: DownloadErrorCode.VIDEO_WAIT_PERIOD,
|
||||
remainingDays: expect.any(Number),
|
||||
availableAt: expect.any(String),
|
||||
})
|
||||
expect(body.details).toContain('满 15 天')
|
||||
expect(body).not.toHaveProperty('videoSourceUrl')
|
||||
expect(body).not.toHaveProperty('videoPreview540Url')
|
||||
expect(body).not.toHaveProperty('videoPreviewClipUrl')
|
||||
})
|
||||
|
||||
test('视频满15天后同一分享链接正常返回预览', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(buildMaterial({
|
||||
materialType: '淘系-逛逛视频',
|
||||
createdAt: new Date(Date.now() - 16 * 24 * 60 * 60 * 1000),
|
||||
videoSourceUrl: '/uploads/materials/source.mp4',
|
||||
videoPreview540Url: 'https://example.com/preview.mp4',
|
||||
videoPreviewClipUrl: 'https://example.com/clip.mp4',
|
||||
}))
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost/api/materials/share?id=material-video'),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.videoPreview540Url).toBeTruthy()
|
||||
expect(body.videoPreviewClipUrl).toBeTruthy()
|
||||
expect(body.videoSourceUrl).toBeNull()
|
||||
})
|
||||
|
||||
test('素材所有者可收藏和编辑,但不能获得管理员删除权限', async () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||||
user: { id: 'owner-1' },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user