diff --git a/app/api/videos/[videoId]/assets/[assetId]/download/route.ts b/app/api/videos/[videoId]/assets/[assetId]/download/route.ts index 82d2cc8..6e01d4e 100644 --- a/app/api/videos/[videoId]/assets/[assetId]/download/route.ts +++ b/app/api/videos/[videoId]/assets/[assetId]/download/route.ts @@ -10,6 +10,7 @@ import { extractAudioFileNameFromProxyUrl, extractVideoFileNameFromProxyUrl, getVideoAssetAccessContext, + withFileExtension, } from '@/lib/video-assets'; import { buildVideoObjectKey } from '@/lib/video-upload-validation'; import { logError } from '@/lib/logger'; @@ -55,6 +56,10 @@ function sanitizeFileName(value: string): string { return sanitized.length > 0 ? sanitized : 'asset'; } +function withExtension(displayName: string, extension: string): string { + return withFileExtension(sanitizeFileName(displayName), extension); +} + function toAsciiFileName(value: string): string { const normalized = value .normalize('NFKD') @@ -117,7 +122,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { if (!fileName) return apiErrors.badRequest('Invalid image asset URL'); const key = `images/${fileName}`; const extension = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.png'; - const downloadName = `${sanitizeFileName(asset.displayName)}${extension}`; + const downloadName = withExtension(asset.displayName, extension); const contentDisposition = buildContentDisposition(downloadName); return proxyR2MediaObject({ @@ -139,7 +144,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { if (!fileName) return apiErrors.badRequest('Invalid audio asset URL'); const key = `voice/${fileName}`; const ext = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.webm'; - const downloadName = `${sanitizeFileName(asset.displayName)}${ext}`; + const downloadName = withExtension(asset.displayName, ext); const contentDisposition = buildContentDisposition(downloadName); const extKey = ext.replace('.', ''); const contentType = AUDIO_CONTENT_TYPE_BY_EXTENSION[extKey] || 'audio/webm'; @@ -162,7 +167,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { if (!fileName) return apiErrors.badRequest('Invalid video asset URL'); const key = buildVideoObjectKey(fileName); const ext = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.mp4'; - const downloadName = `${sanitizeFileName(asset.displayName)}${ext}`; + const downloadName = withExtension(asset.displayName, ext); const contentDisposition = buildContentDisposition(downloadName); const extKey = ext.replace('.', ''); const contentType = VIDEO_CONTENT_TYPE_BY_EXTENSION[extKey] || 'video/mp4'; diff --git a/lib/project-download.ts b/lib/project-download.ts index 91936c3..4d11caf 100644 --- a/lib/project-download.ts +++ b/lib/project-download.ts @@ -4,6 +4,7 @@ import { extractImageFileNameFromProxyUrl, extractVideoFileNameFromProxyUrl, sanitizeAssetDisplayName, + withFileExtension, } from '@/lib/video-assets'; const DEFAULT_MAX_FILES = 250; @@ -171,21 +172,21 @@ function buildAssetFileName(videoIndex: number, videoTitle: string, asset: Asset if (asset.provider === VideoAssetProvider.R2_IMAGE) { const fileName = extractImageFileNameFromProxyUrl(asset.sourceUrl); - return `${stem}${extensionFromUrl(fileName ?? '', '.png')}`; + return withFileExtension(stem, extensionFromUrl(fileName ?? '', '.png')); } if (asset.provider === VideoAssetProvider.R2_AUDIO) { const fileName = extractAudioFileNameFromProxyUrl(asset.sourceUrl); - return `${stem}${extensionFromUrl(fileName ?? '', '.webm')}`; + return withFileExtension(stem, extensionFromUrl(fileName ?? '', '.webm')); } if (asset.provider === VideoAssetProvider.R2_VIDEO) { const fileName = extractVideoFileNameFromProxyUrl(asset.sourceUrl); - return `${stem}${extensionFromUrl(fileName ?? '', '.mp4')}`; + return withFileExtension(stem, extensionFromUrl(fileName ?? '', '.mp4')); } if (asset.provider === VideoAssetProvider.BUNNY) { - return `${stem}.mp4`; + return withFileExtension(stem, '.mp4'); } - return `${stem}.bin`; + return withFileExtension(stem, '.bin'); } function versionDownloadUrl(version: VersionRow): string | null { diff --git a/lib/video-assets.ts b/lib/video-assets.ts index 754bb79..0601ed9 100644 --- a/lib/video-assets.ts +++ b/lib/video-assets.ts @@ -66,6 +66,14 @@ export function sanitizeAssetDisplayName( return normalized.slice(0, 200); } +/** + * A voice comment's display name is the generated file name, extension and all, + * so blindly appending the extension named the download `.webm.webm`. + */ +export function withFileExtension(name: string, extension: string): string { + return name.toLowerCase().endsWith(extension.toLowerCase()) ? name : `${name}${extension}`; +} + export function extractImageKeyFromProxyUrl(url: string): string | null { if (!SAFE_IMAGE_PROXY_PATH.test(url)) return null; const filename = url.slice(IMAGE_PROXY_PREFIX.length); diff --git a/tests/api/lib-video-assets.test.ts b/tests/api/lib-video-assets.test.ts index 89f12c6..1b458a6 100644 --- a/tests/api/lib-video-assets.test.ts +++ b/tests/api/lib-video-assets.test.ts @@ -19,6 +19,7 @@ import { extractVideoKeyFromProxyUrl, getVideoAssetAccessContext, sanitizeAssetDisplayName, + withFileExtension, SAFE_BUNNY_VIDEO_ID, } from '@/lib/video-assets'; import { apiRequest } from '../helpers/request'; @@ -83,6 +84,28 @@ describe('sanitizeAssetDisplayName', () => { }); }); +// A voice comment is stored under its generated file name, so its display name +// already ends in .webm and every download was named `.webm.webm`. +describe('withFileExtension', () => { + it('leaves a name that already ends in the extension alone', () => { + expect(withFileExtension('33661b60-b658-47f9-bd40-71c32a0a2fdb.webm', '.webm')).toBe( + '33661b60-b658-47f9-bd40-71c32a0a2fdb.webm' + ); + }); + + it('ignores the case the extension was written in', () => { + expect(withFileExtension('Take 2.WEBM', '.webm')).toBe('Take 2.WEBM'); + }); + + it('appends when the name carries no extension', () => { + expect(withFileExtension('Voice Comment', '.webm')).toBe('Voice Comment.webm'); + }); + + it('appends when the name ends in a different extension', () => { + expect(withFileExtension('clip.mp4', '.webm')).toBe('clip.mp4.webm'); + }); +}); + describe('proxy URL extraction', () => { it('derives the image key and file name from a canonical image URL', () => { expect(extractImageKeyFromProxyUrl(IMAGE_URL)).toBe( diff --git a/tests/unit/lib/project-download.test.ts b/tests/unit/lib/project-download.test.ts index 0eb1623..176a2eb 100644 --- a/tests/unit/lib/project-download.test.ts +++ b/tests/unit/lib/project-download.test.ts @@ -1221,6 +1221,30 @@ describe('buildProjectDownloadManifest assets', () => { expect(manifest.files[0]?.fileName).toBe('01-Intro-asset-B roll.png'); }); + // A voice comment's display name is the stored file name, extension included. + it('does not repeat an extension the display name already carries', () => { + const manifest = buildProjectDownloadManifest( + 'Project', + [ + video({ + versions: [], + assets: [ + asset({ + provider: VideoAssetProvider.R2_AUDIO, + displayName: '33661b60-b658-47f9-bd40-71c32a0a2fdb.webm', + sourceUrl: '/api/upload/audio/33661b60-b658-47f9-bd40-71c32a0a2fdb.webm', + }), + ], + }), + ], + { includeAssets: true } + ); + + expect(manifest.files[0]?.fileName).toBe( + '01-Intro-asset-33661b60-b658-47f9-bd40-71c32a0a2fdb.webm' + ); + }); + it('always names a bunny asset .mp4 regardless of the source url', () => { const manifest = buildProjectDownloadManifest( 'Project',