From 93e85683e91332b85f5961ac06ac4a046b0a3b6a Mon Sep 17 00:00:00 2001 From: yusufipk Date: Thu, 30 Jul 2026 23:11:58 +0700 Subject: [PATCH] fix(downloads): stop repeating an extension the name already carries A voice comment's display name is the generated file name, extension included, so appending the extension again downloaded it as .webm.webm. Both download paths, the single asset route and the project zip, now append only when the name does not already end in it. --- .../assets/[assetId]/download/route.ts | 11 ++++++--- lib/project-download.ts | 11 +++++---- lib/video-assets.ts | 8 +++++++ tests/api/lib-video-assets.test.ts | 23 ++++++++++++++++++ tests/unit/lib/project-download.test.ts | 24 +++++++++++++++++++ 5 files changed, 69 insertions(+), 8 deletions(-) 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',