feat: name downloaded file after the video title and add a back-to-home link
The download endpoint served files under their internal uuid-based storage name; it now builds the Content-Disposition filename from the probed video title (persisted on submit) with the original extension, falling back to the old behavior when no title is available. The status page also gets an always-visible link back to the homepage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { Link } from '@/navigation'
|
||||
import { StatusView } from '@/components/StatusView'
|
||||
|
||||
export default async function StatusPage({
|
||||
@@ -11,6 +12,14 @@ export default async function StatusPage({
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-center px-6 py-16">
|
||||
<div className="w-full max-w-md mb-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-gray-500 hover:text-violet-600 dark:text-gray-400 dark:hover:text-violet-400 transition-colors"
|
||||
>
|
||||
← {t('backHome')}
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-50 mb-10">
|
||||
{t('heading')}
|
||||
</h1>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { GET } from '../route'
|
||||
import * as tokenLib from '@/lib/token'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { writeFileSync, unlinkSync } from 'fs'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
|
||||
jest.mock('@/lib/token')
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
@@ -8,12 +11,17 @@ jest.mock('@/lib/prisma', () => ({
|
||||
}))
|
||||
|
||||
const mockValidate = tokenLib.validateToken as jest.Mock
|
||||
const mockTokenUpdate = prisma.downloadToken.update as jest.Mock
|
||||
|
||||
function req(token: string) {
|
||||
return new Request(`http://localhost/api/download/${token}`)
|
||||
}
|
||||
|
||||
describe('GET /api/download/[token]', () => {
|
||||
beforeEach(() => {
|
||||
mockTokenUpdate.mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('returns 404 for invalid or expired token', async () => {
|
||||
mockValidate.mockResolvedValue(null)
|
||||
const res = await GET(req('bad'), { params: Promise.resolve({ token: 'bad' }) })
|
||||
@@ -23,10 +31,24 @@ describe('GET /api/download/[token]', () => {
|
||||
})
|
||||
|
||||
it('returns 404 when file does not exist on disk', async () => {
|
||||
mockValidate.mockResolvedValue({ downloadId: 'dl-1', filePath: '/nonexistent/path/abc.mp4' })
|
||||
mockValidate.mockResolvedValue({ downloadId: 'dl-1', filePath: '/nonexistent/path/abc.mp4', title: null })
|
||||
const res = await GET(req('ok-tok'), { params: Promise.resolve({ token: 'ok-tok' }) })
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error).toBe('File not found on server')
|
||||
})
|
||||
|
||||
it('serves the file with a title-based Content-Disposition filename', async () => {
|
||||
const filePath = path.join(os.tmpdir(), `test-${Date.now()}.mp4`)
|
||||
writeFileSync(filePath, 'video-bytes')
|
||||
|
||||
mockValidate.mockResolvedValue({ downloadId: 'dl-1', filePath, title: 'My Cool Video' })
|
||||
const res = await GET(req('ok-tok'), { params: Promise.resolve({ token: 'ok-tok' }) })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('Content-Disposition')).toContain('filename="My Cool Video.mp4"')
|
||||
|
||||
await res.text()
|
||||
unlinkSync(filePath)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createReadStream, statSync } from 'fs'
|
||||
import path from 'path'
|
||||
import { validateToken } from '@/lib/token'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { buildDownloadFilename, contentDispositionHeader } from '@/lib/filename'
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
@@ -15,7 +16,7 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { filePath } = result
|
||||
const { filePath, title } = result
|
||||
|
||||
let stat: ReturnType<typeof statSync>
|
||||
try {
|
||||
@@ -29,12 +30,12 @@ export async function GET(
|
||||
data: { usedAt: new Date() },
|
||||
})
|
||||
|
||||
const fileName = path.basename(filePath)
|
||||
const fileName = buildDownloadFilename(title, path.basename(filePath))
|
||||
const stream = createReadStream(filePath)
|
||||
|
||||
return new NextResponse(stream as unknown as ReadableStream, {
|
||||
headers: {
|
||||
'Content-Disposition': `attachment; filename="${fileName}"`,
|
||||
'Content-Disposition': contentDispositionHeader(fileName),
|
||||
'Content-Length': String(stat.size),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
|
||||
@@ -40,6 +40,20 @@ describe('GET /api/downloads/[uuid]', () => {
|
||||
expect(body.fileSize).toBe('1048576')
|
||||
})
|
||||
|
||||
it('derives fileName from the video title when available', async () => {
|
||||
mockFindUnique.mockResolvedValue({ ...baseDownload, title: 'My Video', tokens: [] })
|
||||
const res = await GET(req('abc'), { params: Promise.resolve({ uuid: 'abc' }) })
|
||||
const body = await res.json()
|
||||
expect(body.fileName).toBe('My Video.mp4')
|
||||
})
|
||||
|
||||
it('falls back to the stored fileName when there is no title', async () => {
|
||||
mockFindUnique.mockResolvedValue({ ...baseDownload, title: null, tokens: [] })
|
||||
const res = await GET(req('abc'), { params: Promise.resolve({ uuid: 'abc' }) })
|
||||
const body = await res.json()
|
||||
expect(body.fileName).toBe('video.mp4')
|
||||
})
|
||||
|
||||
it('returns downloadToken when status is DONE and token is valid', async () => {
|
||||
mockFindUnique.mockResolvedValue({
|
||||
...baseDownload,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { buildDownloadFilename } from '@/lib/filename'
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
@@ -28,7 +29,7 @@ export async function GET(
|
||||
format: download.format,
|
||||
quality: download.quality,
|
||||
subtitles: download.subtitles,
|
||||
fileName: download.fileName,
|
||||
fileName: download.fileName ? buildDownloadFilename(download.title, download.fileName) : null,
|
||||
fileSize: download.fileSize?.toString() ?? null,
|
||||
errorMsg: download.errorMsg,
|
||||
submittedAt: download.submittedAt,
|
||||
|
||||
@@ -88,4 +88,21 @@ describe('POST /api/downloads', () => {
|
||||
data: expect.objectContaining({ clipStart: 10, clipEnd: 30, audioQuality: '192' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('stores the video title when provided', async () => {
|
||||
await POST(req({
|
||||
url: 'https://y.com', format: 'mp4', quality: 'best', subtitles: false,
|
||||
title: 'My Video',
|
||||
}))
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ title: 'My Video' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('stores a null title when not provided', async () => {
|
||||
await POST(req({ url: 'https://y.com', format: 'mp4', quality: 'best', subtitles: false }))
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ title: null }),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function POST(req: NextRequest) {
|
||||
clipEnd,
|
||||
audioQuality: body.audioQuality ? String(body.audioQuality) : null,
|
||||
extraArgs: body.extraArgs ?? null,
|
||||
title: body.title ? String(body.title) : null,
|
||||
ipAddress: ip,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -136,6 +136,7 @@ export function SubmitForm({ examplePlaceholder }: { examplePlaceholder?: string
|
||||
url,
|
||||
format,
|
||||
quality,
|
||||
title: probe.data.title || null,
|
||||
subtitles,
|
||||
subtitleLangs: subtitles && subtitleLangs.length ? subtitleLangs : null,
|
||||
audioQuality: format === 'mp3' ? audioQuality : null,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { sanitizeFilenameStem, buildDownloadFilename, contentDispositionHeader } from '../filename'
|
||||
|
||||
describe('sanitizeFilenameStem', () => {
|
||||
it('strips filesystem-illegal characters', () => {
|
||||
expect(sanitizeFilenameStem('a/b\\c:d*e?f"g<h>i|j')).toBe('a b c d e f g h i j')
|
||||
})
|
||||
|
||||
it('collapses whitespace and trims', () => {
|
||||
expect(sanitizeFilenameStem(' My Video Title ')).toBe('My Video Title')
|
||||
})
|
||||
|
||||
it('falls back to "download" when the result is empty', () => {
|
||||
expect(sanitizeFilenameStem('///???')).toBe('download')
|
||||
})
|
||||
|
||||
it('truncates very long titles', () => {
|
||||
const long = 'a'.repeat(300)
|
||||
expect(sanitizeFilenameStem(long).length).toBe(150)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDownloadFilename', () => {
|
||||
it('returns the stored filename when title is null', () => {
|
||||
expect(buildDownloadFilename(null, 'abc-123.mp4')).toBe('abc-123.mp4')
|
||||
})
|
||||
|
||||
it('builds a sanitized name with the original extension', () => {
|
||||
expect(buildDownloadFilename('My Awesome Video', 'abc-123.mp4')).toBe('My Awesome Video.mp4')
|
||||
})
|
||||
|
||||
it('preserves the extension for audio downloads', () => {
|
||||
expect(buildDownloadFilename('Podcast Episode', 'abc-123.mp3')).toBe('Podcast Episode.mp3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('contentDispositionHeader', () => {
|
||||
it('includes an ASCII fallback and a UTF-8 encoded filename*', () => {
|
||||
const header = contentDispositionHeader('Café Vidéo.mp4')
|
||||
expect(header).toContain('attachment;')
|
||||
expect(header).toContain('filename="Caf_ Vid_o.mp4"')
|
||||
expect(header).toContain(`filename*=UTF-8''${encodeURIComponent('Café Vidéo.mp4')}`)
|
||||
})
|
||||
})
|
||||
@@ -60,4 +60,16 @@ describe('validateToken', () => {
|
||||
filePath: '/storage/abc.mp4',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the video title alongside the file path', async () => {
|
||||
mockFindUnique.mockResolvedValue({
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
download: { id: 'dl-1', filePath: '/storage/abc.mp4', title: 'My Video' },
|
||||
})
|
||||
expect(await validateToken('tok')).toEqual({
|
||||
downloadId: 'dl-1',
|
||||
filePath: '/storage/abc.mp4',
|
||||
title: 'My Video',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import path from 'path'
|
||||
|
||||
const ILLEGAL_CHARS = /[<>:"/\\|?*\x00-\x1F]/g
|
||||
|
||||
export function sanitizeFilenameStem(title: string): string {
|
||||
const cleaned = title.replace(ILLEGAL_CHARS, ' ').replace(/\s+/g, ' ').trim()
|
||||
return cleaned.slice(0, 150) || 'download'
|
||||
}
|
||||
|
||||
export function buildDownloadFilename(title: string | null, storedFileName: string): string {
|
||||
if (!title) return storedFileName
|
||||
const ext = path.extname(storedFileName)
|
||||
return `${sanitizeFilenameStem(title)}${ext}`
|
||||
}
|
||||
|
||||
export function contentDispositionHeader(filename: string): string {
|
||||
const asciiFallback = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, "'")
|
||||
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`
|
||||
}
|
||||
+7
-3
@@ -13,13 +13,17 @@ export async function createToken(downloadId: string): Promise<string> {
|
||||
|
||||
export async function validateToken(
|
||||
token: string
|
||||
): Promise<{ downloadId: string; filePath: string } | null> {
|
||||
): Promise<{ downloadId: string; filePath: string; title: string | null } | null> {
|
||||
const record = await prisma.downloadToken.findUnique({
|
||||
where: { token },
|
||||
include: { download: { select: { id: true, filePath: true } } },
|
||||
include: { download: { select: { id: true, filePath: true, title: true } } },
|
||||
})
|
||||
if (!record) return null
|
||||
if (record.expiresAt < new Date()) return null
|
||||
if (!record.download.filePath) return null
|
||||
return { downloadId: record.download.id, filePath: record.download.filePath }
|
||||
return {
|
||||
downloadId: record.download.id,
|
||||
filePath: record.download.filePath,
|
||||
title: record.download.title,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user