mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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 <uuid>.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.
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
|||||||
extractAudioFileNameFromProxyUrl,
|
extractAudioFileNameFromProxyUrl,
|
||||||
extractVideoFileNameFromProxyUrl,
|
extractVideoFileNameFromProxyUrl,
|
||||||
getVideoAssetAccessContext,
|
getVideoAssetAccessContext,
|
||||||
|
withFileExtension,
|
||||||
} from '@/lib/video-assets';
|
} from '@/lib/video-assets';
|
||||||
import { buildVideoObjectKey } from '@/lib/video-upload-validation';
|
import { buildVideoObjectKey } from '@/lib/video-upload-validation';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
@@ -55,6 +56,10 @@ function sanitizeFileName(value: string): string {
|
|||||||
return sanitized.length > 0 ? sanitized : 'asset';
|
return sanitized.length > 0 ? sanitized : 'asset';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withExtension(displayName: string, extension: string): string {
|
||||||
|
return withFileExtension(sanitizeFileName(displayName), extension);
|
||||||
|
}
|
||||||
|
|
||||||
function toAsciiFileName(value: string): string {
|
function toAsciiFileName(value: string): string {
|
||||||
const normalized = value
|
const normalized = value
|
||||||
.normalize('NFKD')
|
.normalize('NFKD')
|
||||||
@@ -117,7 +122,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
if (!fileName) return apiErrors.badRequest('Invalid image asset URL');
|
if (!fileName) return apiErrors.badRequest('Invalid image asset URL');
|
||||||
const key = `images/${fileName}`;
|
const key = `images/${fileName}`;
|
||||||
const extension = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.png';
|
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);
|
const contentDisposition = buildContentDisposition(downloadName);
|
||||||
|
|
||||||
return proxyR2MediaObject({
|
return proxyR2MediaObject({
|
||||||
@@ -139,7 +144,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
if (!fileName) return apiErrors.badRequest('Invalid audio asset URL');
|
if (!fileName) return apiErrors.badRequest('Invalid audio asset URL');
|
||||||
const key = `voice/${fileName}`;
|
const key = `voice/${fileName}`;
|
||||||
const ext = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.webm';
|
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 contentDisposition = buildContentDisposition(downloadName);
|
||||||
const extKey = ext.replace('.', '');
|
const extKey = ext.replace('.', '');
|
||||||
const contentType = AUDIO_CONTENT_TYPE_BY_EXTENSION[extKey] || 'audio/webm';
|
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');
|
if (!fileName) return apiErrors.badRequest('Invalid video asset URL');
|
||||||
const key = buildVideoObjectKey(fileName);
|
const key = buildVideoObjectKey(fileName);
|
||||||
const ext = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.mp4';
|
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 contentDisposition = buildContentDisposition(downloadName);
|
||||||
const extKey = ext.replace('.', '');
|
const extKey = ext.replace('.', '');
|
||||||
const contentType = VIDEO_CONTENT_TYPE_BY_EXTENSION[extKey] || 'video/mp4';
|
const contentType = VIDEO_CONTENT_TYPE_BY_EXTENSION[extKey] || 'video/mp4';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
extractImageFileNameFromProxyUrl,
|
extractImageFileNameFromProxyUrl,
|
||||||
extractVideoFileNameFromProxyUrl,
|
extractVideoFileNameFromProxyUrl,
|
||||||
sanitizeAssetDisplayName,
|
sanitizeAssetDisplayName,
|
||||||
|
withFileExtension,
|
||||||
} from '@/lib/video-assets';
|
} from '@/lib/video-assets';
|
||||||
|
|
||||||
const DEFAULT_MAX_FILES = 250;
|
const DEFAULT_MAX_FILES = 250;
|
||||||
@@ -171,21 +172,21 @@ function buildAssetFileName(videoIndex: number, videoTitle: string, asset: Asset
|
|||||||
|
|
||||||
if (asset.provider === VideoAssetProvider.R2_IMAGE) {
|
if (asset.provider === VideoAssetProvider.R2_IMAGE) {
|
||||||
const fileName = extractImageFileNameFromProxyUrl(asset.sourceUrl);
|
const fileName = extractImageFileNameFromProxyUrl(asset.sourceUrl);
|
||||||
return `${stem}${extensionFromUrl(fileName ?? '', '.png')}`;
|
return withFileExtension(stem, extensionFromUrl(fileName ?? '', '.png'));
|
||||||
}
|
}
|
||||||
if (asset.provider === VideoAssetProvider.R2_AUDIO) {
|
if (asset.provider === VideoAssetProvider.R2_AUDIO) {
|
||||||
const fileName = extractAudioFileNameFromProxyUrl(asset.sourceUrl);
|
const fileName = extractAudioFileNameFromProxyUrl(asset.sourceUrl);
|
||||||
return `${stem}${extensionFromUrl(fileName ?? '', '.webm')}`;
|
return withFileExtension(stem, extensionFromUrl(fileName ?? '', '.webm'));
|
||||||
}
|
}
|
||||||
if (asset.provider === VideoAssetProvider.R2_VIDEO) {
|
if (asset.provider === VideoAssetProvider.R2_VIDEO) {
|
||||||
const fileName = extractVideoFileNameFromProxyUrl(asset.sourceUrl);
|
const fileName = extractVideoFileNameFromProxyUrl(asset.sourceUrl);
|
||||||
return `${stem}${extensionFromUrl(fileName ?? '', '.mp4')}`;
|
return withFileExtension(stem, extensionFromUrl(fileName ?? '', '.mp4'));
|
||||||
}
|
}
|
||||||
if (asset.provider === VideoAssetProvider.BUNNY) {
|
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 {
|
function versionDownloadUrl(version: VersionRow): string | null {
|
||||||
|
|||||||
@@ -66,6 +66,14 @@ export function sanitizeAssetDisplayName(
|
|||||||
return normalized.slice(0, 200);
|
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 `<uuid>.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 {
|
export function extractImageKeyFromProxyUrl(url: string): string | null {
|
||||||
if (!SAFE_IMAGE_PROXY_PATH.test(url)) return null;
|
if (!SAFE_IMAGE_PROXY_PATH.test(url)) return null;
|
||||||
const filename = url.slice(IMAGE_PROXY_PREFIX.length);
|
const filename = url.slice(IMAGE_PROXY_PREFIX.length);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
extractVideoKeyFromProxyUrl,
|
extractVideoKeyFromProxyUrl,
|
||||||
getVideoAssetAccessContext,
|
getVideoAssetAccessContext,
|
||||||
sanitizeAssetDisplayName,
|
sanitizeAssetDisplayName,
|
||||||
|
withFileExtension,
|
||||||
SAFE_BUNNY_VIDEO_ID,
|
SAFE_BUNNY_VIDEO_ID,
|
||||||
} from '@/lib/video-assets';
|
} from '@/lib/video-assets';
|
||||||
import { apiRequest } from '../helpers/request';
|
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 `<uuid>.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', () => {
|
describe('proxy URL extraction', () => {
|
||||||
it('derives the image key and file name from a canonical image URL', () => {
|
it('derives the image key and file name from a canonical image URL', () => {
|
||||||
expect(extractImageKeyFromProxyUrl(IMAGE_URL)).toBe(
|
expect(extractImageKeyFromProxyUrl(IMAGE_URL)).toBe(
|
||||||
|
|||||||
@@ -1221,6 +1221,30 @@ describe('buildProjectDownloadManifest assets', () => {
|
|||||||
expect(manifest.files[0]?.fileName).toBe('01-Intro-asset-B roll.png');
|
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', () => {
|
it('always names a bunny asset .mp4 regardless of the source url', () => {
|
||||||
const manifest = buildProjectDownloadManifest(
|
const manifest = buildProjectDownloadManifest(
|
||||||
'Project',
|
'Project',
|
||||||
|
|||||||
Reference in New Issue
Block a user