mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(download): enforce real file extension in pattern resolver
This commit is contained in:
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user