fix(download): enforce real file extension in pattern resolver

This commit is contained in:
neon
2026-05-18 17:43:54 -06:00
parent fabd1747d3
commit 25117e3d0b
3 changed files with 87 additions and 8 deletions
+27 -8
View File
@@ -97,6 +97,28 @@ export function validatePattern(pattern: string): boolean {
return validPatternRegex.test(pattern);
}
function normalizeDotExt(ext: string): string {
const trimmed = ext.trim();
if (!trimmed) return "";
const bare = trimmed.startsWith(".") ? trimmed.slice(1) : trimmed;
if (!bare) return "";
return `.${bare}`;
}
function ensureTrailingExtension(value: string, dotExt: string): string {
if (!dotExt) return value;
return value.toLowerCase().endsWith(dotExt.toLowerCase()) ? value : `${value}${dotExt}`;
}
function ensurePathLastSegmentExtension(path: string, dotExt: string): string {
if (!dotExt) return path;
const segments = path.split("/");
const lastIdx = segments.length - 1;
const lastSegment = segments[lastIdx] ?? "";
segments[lastIdx] = ensureTrailingExtension(lastSegment, dotExt);
return segments.join("/");
}
/**
* Resolves a file naming pattern to a relative path (no leading slash).
* - Pattern ending with '/' → folder path; original filename is used as the file stem.
@@ -108,16 +130,14 @@ export function resolveUploadPath(pattern: string, values: Record<string, string
const resolved = replacePlaceholders(pattern, values);
if (!resolved) return null;
const dotExt = ext.startsWith(".") ? ext : `.${ext}`;
const dotExt = normalizeDotExt(ext);
if (resolved.endsWith("/")) {
const filename = (values["originalFilename"] ?? "upload") + dotExt;
const filename = ensureTrailingExtension(values["originalFilename"] ?? "upload", dotExt);
return resolved + filename;
}
const lastSegment = resolved.split("/").pop() ?? "";
const hasExt = /\.[a-z0-9]{2,5}$/i.test(lastSegment);
return hasExt ? resolved : resolved + dotExt;
return ensurePathLastSegmentExtension(resolved, dotExt);
}
/**
@@ -132,11 +152,10 @@ export function resolveDownloadFilename(pattern: string, values: Record<string,
const resolved = replacePlaceholders(pattern, values);
if (!resolved) return null;
const dotExt = ext.startsWith(".") ? ext : `.${ext}`;
const dotExt = normalizeDotExt(ext);
let stem = resolved.endsWith("/") ? (values["originalFilename"] ?? "") : (resolved.split("/").filter(Boolean).pop() ?? "");
stem = stem.trim();
if (!stem) return null;
const hasExt = /\.[a-z0-9]{2,5}$/i.test(stem);
return hasExt ? stem : stem + dotExt;
return ensureTrailingExtension(stem, dotExt);
}
@@ -320,6 +320,34 @@ describe('BookService', () => {
expect(filename).toBe('Dune.pdf');
});
it('preserves numeric stem suffix and still appends real extension', async () => {
const { service, appSettings, bookRepo } = makeService();
appSettings.getDownloadPattern.mockResolvedValue('{originalFilename}');
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(10, { title: 'Lost in the Cañon' })]);
const filename = await service.resolveDownloadFilename({
bookId: 10,
absolutePath: '/books/Lost in the Cañon.37466.epub',
format: 'epub',
});
expect(filename).toBe('Lost in the Cañon.37466.epub');
});
it('enforces actual file extension when pattern specifies a different extension', async () => {
const { service, appSettings, bookRepo } = makeService();
appSettings.getDownloadPattern.mockResolvedValue('{title}.pdf');
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(10, { title: 'Dune' })]);
const filename = await service.resolveDownloadFilename({
bookId: 10,
absolutePath: '/books/dune.epub',
format: 'epub',
});
expect(filename).toBe('Dune.pdf.epub');
});
});
describe('export files', () => {
@@ -301,6 +301,16 @@ describe('resolveUploadPath', () => {
expect(resolveUploadPath('{title}', FULL, 'epub')).toBe('Neuromancer.epub');
expect(resolveUploadPath('{title}', FULL, '.epub')).toBe('Neuromancer.epub');
});
it('appends the real extension when filename stem ends with a numeric suffix', () => {
expect(resolveUploadPath('{originalFilename}', { ...FULL, originalFilename: 'Lost in the Cañon.37466' }, 'epub')).toBe(
'Lost in the Cañon.37466.epub',
);
});
it('enforces the requested extension when pattern includes a different extension', () => {
expect(resolveUploadPath('{title}.pdf', FULL, 'epub')).toBe('Neuromancer.pdf.epub');
});
});
describe('folder-only mode (trailing slash)', () => {
@@ -311,6 +321,10 @@ describe('resolveUploadPath', () => {
it('works with a nested folder pattern', () => {
expect(resolveUploadPath('{authors}/{series}/', FULL, 'epub')).toBe('William Gibson/Sprawl/neuromancer.epub');
});
it('does not double-append when original filename already ends with extension', () => {
expect(resolveUploadPath('{authors}/', { ...FULL, originalFilename: 'neuromancer.epub' }, 'epub')).toBe('William Gibson/neuromancer.epub');
});
});
describe('originalFilename token', () => {
@@ -358,6 +372,24 @@ describe('resolveDownloadFilename', () => {
it('returns null when pattern resolves to an empty name', () => {
expect(resolveDownloadFilename('{series}', PARTIAL, 'epub')).toBeNull();
});
it('appends extension when stem ends with a numeric suffix', () => {
expect(resolveDownloadFilename('{originalFilename}', { ...FULL, originalFilename: 'Lost in the Cañon.37466' }, 'epub')).toBe(
'Lost in the Cañon.37466.epub',
);
});
it('enforces extension for folder patterns with numeric-suffix title', () => {
expect(resolveDownloadFilename('{authors}/{title}', { ...FULL, title: 'Specimens.32070' }, 'epub')).toBe('Specimens.32070.epub');
});
it('enforces the requested extension when pattern includes a different extension', () => {
expect(resolveDownloadFilename('{title}.pdf', FULL, 'epub')).toBe('Neuromancer.pdf.epub');
});
it('does not double-append when resolved stem already ends with extension', () => {
expect(resolveDownloadFilename('{originalFilename}', { ...FULL, originalFilename: 'neuromancer.epub' }, 'epub')).toBe('neuromancer.epub');
});
});
// ── validatePattern ───────────────────────────────────────────────────────────