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