feat(settings): add cross-platform path sanitization option

Book titles and author names can contain characters that are invalid on Windows,
SMB/NFS shares, or cloud sync platforms (e.g., `:`, `?`, `*`, `"`). Without
sanitization these characters appear verbatim in generated file and folder names,
which causes failures on Windows filesystems, broken NFS/SMB mounts, and sync issues.

Add a toggleable app setting that, when enabled, sanitizes illegal filesystem
characters in all pattern-resolved paths (upload, rename, book-dock finalize, and
download). Each token value is sanitized before substitution: invalid chars are
replaced with `_`, trailing dots and spaces are stripped, and Windows reserved names
(CON, AUX, NUL, COM1-COM9, LPT1-LPT9) get a suffix to avoid conflicts. The setting
defaults to off to preserve existing behavior.

Closes #103
This commit is contained in:
neon
2026-05-25 05:17:17 +00:00
parent c481304e0d
commit 1479989f74
18 changed files with 331 additions and 13 deletions
@@ -13,6 +13,7 @@ import { useMediaQuery } from '@vueuse/core'
import { useFileNamingPattern } from './composables/useFileNamingPattern'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Badge } from '@/components/ui/badge'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
@@ -31,10 +32,14 @@ const {
savingFolder,
loadingDownload,
savingDownload,
crossPlatformSanitizationEnabled,
loadingCrossPlatformSanitization,
savingCrossPlatformSanitization,
savingLibraryId,
fetchGlobalPattern,
fetchFolderPattern,
fetchDownloadPattern,
fetchCrossPlatformSanitization,
fetchLibraries,
onGlobalPatternInput,
onFolderPatternInput,
@@ -42,6 +47,7 @@ const {
saveGlobalPattern,
saveFolderPattern,
saveDownloadPattern,
saveCrossPlatformSanitization,
saveLibraryPattern,
clearLibraryPattern,
getEffectivePreview,
@@ -213,7 +219,7 @@ onMounted(async () => {
modifierHelpOpen.value = !isMobile.value
conditionalHelpOpen.value = !isMobile.value
examplesOpen.value = !isMobile.value
await Promise.all([fetchGlobalPattern(), fetchFolderPattern(), fetchDownloadPattern(), fetchLibraries()])
await Promise.all([fetchGlobalPattern(), fetchFolderPattern(), fetchDownloadPattern(), fetchCrossPlatformSanitization(), fetchLibraries()])
previewGlobalPattern.value = globalPattern.value
previewFolderPattern.value = folderPattern.value
previewDownloadPattern.value = downloadPattern.value
@@ -294,6 +300,30 @@ onUnmounted(() => {
<section class="space-y-4">
<p class="settings-group-label">Global Defaults</p>
<div class="border border-border rounded-lg bg-card overflow-hidden divide-y divide-border shadow-xs">
<div class="px-4 py-4 md:px-6 md:py-5">
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div class="w-full lg:max-w-md">
<p class="settings-label">Cross-platform path sanitization</p>
<p class="settings-hint">Replace filesystem-unsafe characters in generated upload and rename paths.</p>
</div>
<div class="flex items-center gap-3 w-full lg:w-auto">
<ToggleSwitch
v-model="crossPlatformSanitizationEnabled"
:disabled="loadingCrossPlatformSanitization || savingCrossPlatformSanitization"
/>
<button
class="flex items-center justify-center gap-2 h-9 px-3 rounded-md bg-primary text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50 lg:h-8"
:disabled="loadingCrossPlatformSanitization || savingCrossPlatformSanitization"
@click="saveCrossPlatformSanitization"
>
<Loader2 v-if="savingCrossPlatformSanitization" :size="14" class="animate-spin" />
<Save v-else :size="14" />
<span>Save</span>
</button>
</div>
</div>
</div>
<!-- File as Book upload pattern -->
<div class="px-4 py-4 md:px-6 md:py-5 space-y-4">
<div class="flex flex-col lg:flex-row lg:items-start justify-between gap-4">
@@ -32,6 +32,9 @@ export function useFileNamingPattern() {
const downloadError = ref('')
const loadingDownload = ref(false)
const savingDownload = ref(false)
const crossPlatformSanitizationEnabled = ref(false)
const loadingCrossPlatformSanitization = ref(false)
const savingCrossPlatformSanitization = ref(false)
const savingLibraryId = ref<number | null>(null)
const { libraries, fetchLibraries } = useLibraries()
@@ -75,6 +78,19 @@ export function useFileNamingPattern() {
}
}
async function fetchCrossPlatformSanitization() {
loadingCrossPlatformSanitization.value = true
try {
const res = await api('/api/v1/app-settings/cross-platform-path-sanitization')
if (res.ok) {
const data: { enabled: boolean } = await res.json()
crossPlatformSanitizationEnabled.value = data.enabled
}
} finally {
loadingCrossPlatformSanitization.value = false
}
}
function onGlobalPatternInput(value: string) {
globalPattern.value = value
globalError.value = value && !validatePattern(value) ? 'Pattern contains invalid characters' : ''
@@ -147,6 +163,24 @@ export function useFileNamingPattern() {
}
}
async function saveCrossPlatformSanitization() {
savingCrossPlatformSanitization.value = true
try {
const res = await api('/api/v1/app-settings/cross-platform-path-sanitization', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: crossPlatformSanitizationEnabled.value }),
})
if (res.ok) {
toast.success('Cross-platform path sanitization saved')
} else {
toast.error('Failed to save cross-platform path sanitization')
}
} finally {
savingCrossPlatformSanitization.value = false
}
}
async function saveLibraryPattern(library: Library) {
savingLibraryId.value = library.id
try {
@@ -182,6 +216,7 @@ export function useFileNamingPattern() {
folderError,
downloadPattern,
downloadError,
crossPlatformSanitizationEnabled,
libraries,
loadingGlobal,
savingGlobal,
@@ -189,10 +224,13 @@ export function useFileNamingPattern() {
savingFolder,
loadingDownload,
savingDownload,
loadingCrossPlatformSanitization,
savingCrossPlatformSanitization,
savingLibraryId,
fetchGlobalPattern,
fetchFolderPattern,
fetchDownloadPattern,
fetchCrossPlatformSanitization,
fetchLibraries,
onGlobalPatternInput,
onFolderPatternInput,
@@ -200,6 +238,7 @@ export function useFileNamingPattern() {
saveGlobalPattern,
saveFolderPattern,
saveDownloadPattern,
saveCrossPlatformSanitization,
saveLibraryPattern,
clearLibraryPattern,
getEffectivePreview,
+71 -6
View File
@@ -32,8 +32,37 @@ export const PATTERN_TOKENS = [
] as const;
export type PatternToken = (typeof PATTERN_TOKENS)[number]["token"];
export type PathResolverOptions = {
sanitizeForCrossPlatform?: boolean;
replacementCharacter?: "_" | "-";
};
const MODIFIER_PLACEHOLDER_REGEX = /\{([^}:]+)(?::([^}]+))?}/g;
const WINDOWS_RESERVED_NAMES = new Set([
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
]);
const INVALID_SEGMENT_CHARS_REGEX = /[<>:"/\\|?*\u0000-\u001F]/g;
export function applyModifier(value: string, modifier: string, fieldName: string): string {
if (!value) return value;
@@ -97,6 +126,38 @@ export function validatePattern(pattern: string): boolean {
return validPatternRegex.test(pattern);
}
function normalizeResolverOptions(options?: PathResolverOptions): Required<PathResolverOptions> {
return {
sanitizeForCrossPlatform: options?.sanitizeForCrossPlatform ?? false,
replacementCharacter: options?.replacementCharacter === "-" ? "-" : "_",
};
}
function sanitizePathSegmentValue(value: string, replacementCharacter: "_" | "-"): string {
let sanitized = value.replace(INVALID_SEGMENT_CHARS_REGEX, replacementCharacter).trim().replace(/[. ]+$/g, "");
if (!sanitized || sanitized === "." || sanitized === "..") {
sanitized = replacementCharacter;
}
const leadingStem = sanitized.split(".")[0]?.toUpperCase() ?? "";
if (WINDOWS_RESERVED_NAMES.has(leadingStem)) {
sanitized = `${sanitized}${replacementCharacter}`;
}
return sanitized;
}
function sanitizeResolutionValues(values: Record<string, string>, options: Required<PathResolverOptions>): Record<string, string> {
if (!options.sanitizeForCrossPlatform) return values;
const sanitized: Record<string, string> = {};
for (const [key, value] of Object.entries(values)) {
sanitized[key] = sanitizePathSegmentValue(value, options.replacementCharacter);
}
return sanitized;
}
function normalizeDotExt(ext: string): string {
const trimmed = ext.trim();
if (!trimmed) return "";
@@ -126,14 +187,16 @@ function ensurePathLastSegmentExtension(path: string, dotExt: string): string {
*
* Returns null if the pattern resolves to an empty string.
*/
export function resolveUploadPath(pattern: string, values: Record<string, string>, ext: string): string | null {
const resolved = replacePlaceholders(pattern, values);
export function resolveUploadPath(pattern: string, values: Record<string, string>, ext: string, options?: PathResolverOptions): string | null {
const normalizedOptions = normalizeResolverOptions(options);
const resolvedValues = sanitizeResolutionValues(values, normalizedOptions);
const resolved = replacePlaceholders(pattern, resolvedValues);
if (!resolved) return null;
const dotExt = normalizeDotExt(ext);
if (resolved.endsWith("/")) {
const filename = ensureTrailingExtension(values["originalFilename"] ?? "upload", dotExt);
const filename = ensureTrailingExtension(resolvedValues["originalFilename"] ?? "upload", dotExt);
return resolved + filename;
}
@@ -148,12 +211,14 @@ export function resolveUploadPath(pattern: string, values: Record<string, string
*
* Returns null if the pattern resolves to an empty filename.
*/
export function resolveDownloadFilename(pattern: string, values: Record<string, string>, ext: string): string | null {
const resolved = replacePlaceholders(pattern, values);
export function resolveDownloadFilename(pattern: string, values: Record<string, string>, ext: string, options?: PathResolverOptions): string | null {
const normalizedOptions = normalizeResolverOptions(options);
const resolvedValues = sanitizeResolutionValues(values, normalizedOptions);
const resolved = replacePlaceholders(pattern, resolvedValues);
if (!resolved) return null;
const dotExt = normalizeDotExt(ext);
let stem = resolved.endsWith("/") ? (values["originalFilename"] ?? "") : (resolved.split("/").filter(Boolean).pop() ?? "");
let stem = resolved.endsWith("/") ? (resolvedValues["originalFilename"] ?? "") : (resolved.split("/").filter(Boolean).pop() ?? "");
stem = stem.trim();
if (!stem) return null;
@@ -18,6 +18,7 @@ export const APP_SETTING_KEYS = {
UPLOAD_FILE_PATTERN: 'upload_file_pattern',
UPLOAD_FILE_PATTERN_BOOK_PER_FOLDER: 'upload_file_pattern_book_per_folder',
DOWNLOAD_FILE_PATTERN: 'download_file_pattern',
CROSS_PLATFORM_PATH_SANITIZATION_ENABLED: 'cross_platform_path_sanitization_enabled',
METADATA_SCORE_WEIGHTS: 'metadata_score_weights',
AUDIT_RETENTION_DAYS: 'audit_retention_days',
INITIAL_SETUP_COMPLETED_AT: 'initial_setup_completed_at',
@@ -19,6 +19,8 @@ function makeService(): jest.Mocked<AppSettingsService> {
setUploadPatternBookPerFolder: vi.fn(),
getDownloadPattern: vi.fn(),
setDownloadPattern: vi.fn(),
isCrossPlatformPathSanitizationEnabled: vi.fn(),
setCrossPlatformPathSanitizationEnabled: vi.fn(),
getAutoFinalizeSettings: vi.fn(),
getMetadataScoreWeights: vi.fn(),
setMetadataScoreWeights: vi.fn(),
@@ -122,6 +124,20 @@ describe('AppSettingsController', () => {
});
});
describe('getCrossPlatformPathSanitization / setCrossPlatformPathSanitization', () => {
it('getCrossPlatformPathSanitization returns enabled flag from service', async () => {
service.isCrossPlatformPathSanitizationEnabled.mockResolvedValue(true);
expect(await controller.getCrossPlatformPathSanitization()).toEqual({ enabled: true });
});
it('setCrossPlatformPathSanitization calls service and returns enabled flag', async () => {
service.setCrossPlatformPathSanitizationEnabled.mockResolvedValue(undefined);
const result = await controller.setCrossPlatformPathSanitization({ enabled: true });
expect(service.setCrossPlatformPathSanitizationEnabled).toHaveBeenCalledWith(true);
expect(result).toEqual({ enabled: true });
});
});
describe('OIDC provider CRUD', () => {
it('getOidcProvidersPublic returns enabled providers without secrets', async () => {
oidcProviderService.findEnabled.mockResolvedValue([
@@ -8,6 +8,7 @@ import { AppSettingsService } from './app-settings.service';
import { OidcProviderService } from './oidc-provider.service';
import { UpdateAppSettingDto } from './dto/update-app-setting.dto';
import { UpdateFilePatternDto } from './dto/update-file-pattern.dto';
import { UpdateBooleanSettingDto } from './dto/update-boolean-setting.dto';
import { CreateOidcProviderDto } from './dto/create-oidc-provider.dto';
import { UpdateOidcProviderDto } from './dto/update-oidc-provider.dto';
import { CreateGroupMappingDto } from './dto/create-group-mapping.dto';
@@ -80,6 +81,23 @@ export class AppSettingsController {
return { pattern: dto.pattern };
}
@Get('cross-platform-path-sanitization')
async getCrossPlatformPathSanitization() {
return { enabled: await this.appSettingsService.isCrossPlatformPathSanitizationEnabled() };
}
@Put('cross-platform-path-sanitization')
@HttpCode(HttpStatus.OK)
@Auditable({
action: AuditAction.AppSettingsUpdate,
resource: AuditResource.AppSettings,
description: 'Updated cross-platform path sanitization setting',
})
async setCrossPlatformPathSanitization(@Body() dto: UpdateBooleanSettingDto) {
await this.appSettingsService.setCrossPlatformPathSanitizationEnabled(dto.enabled);
return { enabled: dto.enabled };
}
// --- OIDC Provider CRUD ---
@Public()
@@ -399,6 +399,21 @@ describe('AppSettingsService', () => {
await service.setUploadPatternBookPerFolder('{title}/');
expect(repo.upsert).toHaveBeenCalledWith('upload_file_pattern_book_per_folder', '{title}/');
});
it('cross-platform path sanitization defaults to false when not set', async () => {
repo.findByKey.mockResolvedValue(undefined);
expect(await service.isCrossPlatformPathSanitizationEnabled()).toBe(false);
});
it('cross-platform path sanitization returns true when enabled', async () => {
repo.findByKey.mockResolvedValue({ key: 'cross_platform_path_sanitization_enabled', value: 'true' } as never);
expect(await service.isCrossPlatformPathSanitizationEnabled()).toBe(true);
});
it('upserts cross-platform path sanitization setting', async () => {
await service.setCrossPlatformPathSanitizationEnabled(true);
expect(repo.upsert).toHaveBeenCalledWith('cross_platform_path_sanitization_enabled', 'true');
});
});
describe('getMetadataScoreWeights', () => {
@@ -107,6 +107,15 @@ export class AppSettingsService {
await this.repo.upsert(APP_SETTING_KEYS.DOWNLOAD_FILE_PATTERN, pattern);
}
async isCrossPlatformPathSanitizationEnabled(): Promise<boolean> {
const row = await this.repo.findByKey(APP_SETTING_KEYS.CROSS_PLATFORM_PATH_SANITIZATION_ENABLED);
return parseBooleanSetting(row?.value, false);
}
async setCrossPlatformPathSanitizationEnabled(enabled: boolean): Promise<void> {
await this.repo.upsert(APP_SETTING_KEYS.CROSS_PLATFORM_PATH_SANITIZATION_ENABLED, String(enabled));
}
async updateOidcConfig(config: Partial<OidcFullConfig>): Promise<OidcFullConfig> {
const current = await this.getOidcConfig();
const merged = mergeOidcConfig(current, config);
@@ -4,6 +4,7 @@ import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { UpdateAppSettingDto } from './update-app-setting.dto';
import { UpdateBooleanSettingDto } from './update-boolean-setting.dto';
import { UpdateFilePatternDto } from './update-file-pattern.dto';
import { UpdateOidcConfigDto } from './update-oidc-config.dto';
@@ -24,6 +25,12 @@ describe('App settings DTO validation', () => {
expect((await errorsFor(UpdateFilePatternDto, { pattern: 'x'.repeat(501) })).length).toBeGreaterThan(0);
});
it('validates boolean setting payload', async () => {
expect((await errorsFor(UpdateBooleanSettingDto, { enabled: true })).length).toBe(0);
expect((await errorsFor(UpdateBooleanSettingDto, { enabled: false })).length).toBe(0);
expect((await errorsFor(UpdateBooleanSettingDto, { enabled: 'true' })).length).toBeGreaterThan(0);
});
it('validates nested OIDC claimMapping and autoProvision fields', async () => {
expect(
(
@@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class UpdateBooleanSettingDto {
@IsBoolean()
enabled: boolean;
}
@@ -36,6 +36,7 @@ function makeService() {
getAutoFinalizeSettings: vi.fn(),
getUploadPattern: vi.fn().mockResolvedValue(null),
getUploadPatternBookPerFolder: vi.fn().mockResolvedValue(null),
isCrossPlatformPathSanitizationEnabled: vi.fn().mockResolvedValue(false),
};
const metadataService = {
downloadAndSaveCover: vi.fn().mockResolvedValue(false),
@@ -532,6 +533,21 @@ describe('BookDockFinalizeService', () => {
expect(result[0].newName).toBe('Dune.epub');
});
it('previewNames sanitizes generated names when cross-platform mode is enabled', async () => {
const { service, repo, appSettings, db } = makeService();
appSettings.isCrossPlatformPathSanitizationEnabled.mockResolvedValue(true);
appSettings.getUploadPattern.mockResolvedValue('{authors:first}/{title}');
repo.findByIds.mockResolvedValue([makeRow({ id: 1, selectedMetadata: { title: 'AUX', authors: ['CON'] } as BookDockMetadata })]);
db.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
});
const result = await service.previewNames([1], false, [], undefined);
expect(result[0].newName).toBe('CON_/AUX_.epub');
});
it('applyMetadata updates scalar metadata fields and related author/genre rows', async () => {
const { service, db, metadataService } = makeService();
const updateChain = {
@@ -755,6 +771,16 @@ describe('BookDockFinalizeService', () => {
expect(appSettings.getUploadPattern).not.toHaveBeenCalled();
});
it('resolveDestination sanitizes token-derived names when cross-platform mode is enabled', async () => {
const { service, appSettings } = makeService();
appSettings.isCrossPlatformPathSanitizationEnabled.mockResolvedValue(true);
const row = makeRow({ fileName: 'book.epub', selectedMetadata: { title: 'AUX', authors: ['CON'] } as BookDockMetadata });
await expect((service as any).resolveDestination({ fileNamingPattern: '{authors:first}/{title}' }, '/library', row, 'epub')).resolves.toBe(
'/library/CON_/AUX_.epub',
);
});
it('findDuplicate resolves from prebuilt lookup before querying database', async () => {
const { service } = makeService();
const duplicateLookup = new Map([['library:4|isbn13:9780306406157', 88]]);
@@ -285,6 +285,7 @@ export class BookDockFinalizeService implements OnModuleInit {
const rows = await this.repo.findByIds(ids);
const appPatternFile = await this.appSettings.getUploadPattern();
const appPatternFolder = await this.appSettings.getUploadPatternBookPerFolder();
const sanitizeForCrossPlatform = await this.appSettings.isCrossPlatformPathSanitizationEnabled();
const libraryIds = [...new Set(rows.map((row) => row.targetLibraryId ?? defaultLibraryId).filter((id): id is number => id != null))];
const libraryMap = libraryIds.length
? new Map((await this.db.select().from(libraries).where(inArray(libraries.id, libraryIds))).map((lib) => [lib.id, lib]))
@@ -302,7 +303,7 @@ export class BookDockFinalizeService implements OnModuleInit {
if (pattern) {
const tokens = this.buildPatternTokens(meta, row.fileName, format);
const resolved = resolveUploadPath(pattern, tokens, format);
const resolved = resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform });
if (resolved) newName = resolved;
}
@@ -454,11 +455,12 @@ export class BookDockFinalizeService implements OnModuleInit {
(library.organizationMode === 'book_per_folder'
? await this.appSettings.getUploadPatternBookPerFolder()
: await this.appSettings.getUploadPattern());
const sanitizeForCrossPlatform = await this.appSettings.isCrossPlatformPathSanitizationEnabled();
const meta = row.selectedMetadata ?? row.embeddedMetadata ?? {};
if (pattern) {
const tokens = this.buildPatternTokens(meta, row.fileName, format);
const resolved = resolveUploadPath(pattern, tokens, format);
const resolved = resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform });
if (resolved) return join(folderPath, resolved);
}
@@ -82,6 +82,7 @@ describe('FileRenameService', () => {
const appSettings = {
getUploadPattern: vi.fn().mockResolvedValue('{authors}/{title}'),
getUploadPatternBookPerFolder: vi.fn().mockResolvedValue('{authors}/{title}/{title}'),
isCrossPlatformPathSanitizationEnabled: vi.fn().mockResolvedValue(false),
};
const notificationService = {
notify: vi.fn().mockResolvedValue(undefined),
@@ -221,6 +222,33 @@ describe('FileRenameService', () => {
);
});
it('sanitizes token-derived rename destinations when cross-platform mode is enabled', async () => {
const { service, renameRepo, appSettings } = makeService();
appSettings.isCrossPlatformPathSanitizationEnabled.mockResolvedValue(true);
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
file: {
absolutePath: '/library/Old Title.epub',
relPath: 'Old Title.epub',
},
metadata: { title: 'AUX' },
authors: ['CON'],
bookFolderPath: '/library/Old Title.epub',
}),
);
const result = await service.performRename(5, 12);
expect(result).toEqual(
expect.objectContaining({
status: 'success',
oldPath: '/library/Old Title.epub',
newPath: '/library/CON_/AUX_.epub',
}),
);
expect(mockRename).toHaveBeenCalledWith('/library/Old Title.epub', '/library/CON_/AUX_.epub');
});
it('renames the folder, primary file, and companion files in book-per-folder mode', async () => {
const { service, renameRepo, lockService } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
@@ -93,7 +93,8 @@ export class FileRenameService implements OnModuleDestroy {
const format = (data.file.format ?? extname(data.file.absolutePath).slice(1)).toLowerCase();
const originalStem = basename(data.file.absolutePath, extname(data.file.absolutePath));
const tokens = buildTokens(data.metadata, data.authors, originalStem, format);
const resolvedRelPath = resolveUploadPath(pattern, tokens, format);
const sanitizeForCrossPlatform = await this.appSettings.isCrossPlatformPathSanitizationEnabled();
const resolvedRelPath = resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform });
if (!resolvedRelPath) {
return this.logAndReturn(bookId, startedAt, { status: 'skipped', reason: 'pattern resolved to empty' });
+1
View File
@@ -41,6 +41,7 @@ const DEFAULT_APP_SETTINGS: Array<typeof schema.appSettings.$inferInsert> = [
{ key: APP_SETTING_KEYS.OIDC_CONFIG, value: JSON.stringify(DEFAULT_OIDC_CONFIG) },
{ key: APP_SETTING_KEYS.UPDATE_CHECK_ENABLED, value: 'true' },
{ key: APP_SETTING_KEYS.HARDCOVER_ENABLED, value: 'false' },
{ key: APP_SETTING_KEYS.CROSS_PLATFORM_PATH_SANITIZATION_ENABLED, value: 'false' },
];
@Injectable()
@@ -392,6 +392,31 @@ describe('resolveDownloadFilename', () => {
});
});
describe('cross-platform sanitization option', () => {
it('keeps legacy output unchanged when sanitization is disabled', () => {
expect(resolveUploadPath('{authors}/{title}', { ...FULL, authors: 'CON', title: 'Book: Volume 1?' }, 'epub')).toBe('CON/Book: Volume 1?.epub');
});
it('sanitizes invalid characters and reserved names in upload path tokens when enabled', () => {
expect(
resolveUploadPath('{authors}/{title}', { ...FULL, authors: 'CON', title: 'Book: Volume 1?' }, 'epub', { sanitizeForCrossPlatform: true }),
).toBe('CON_/Book_ Volume 1_.epub');
});
it('supports dash replacement character when sanitization is enabled', () => {
expect(
resolveDownloadFilename('{title}', { ...FULL, title: 'Book: Volume 1?' }, 'epub', {
sanitizeForCrossPlatform: true,
replacementCharacter: '-',
}),
).toBe('Book- Volume 1-.epub');
});
it('trims unsafe trailing dot/space and guards reserved names with extensions', () => {
expect(resolveDownloadFilename('{title}', { ...FULL, title: 'NUL.txt ' }, 'epub', { sanitizeForCrossPlatform: true })).toBe('NUL.txt_.epub');
});
});
// ── validatePattern ───────────────────────────────────────────────────────────
describe('validatePattern', () => {
@@ -41,7 +41,7 @@ describe('UploadService', () => {
select: vi.fn(),
};
const appSettings = { getUploadPattern: vi.fn(), getUploadPatternBookPerFolder: vi.fn() };
const appSettings = { getUploadPattern: vi.fn(), getUploadPatternBookPerFolder: vi.fn(), isCrossPlatformPathSanitizationEnabled: vi.fn() };
const libraryService = { verifyUserAccess: vi.fn() };
const validator = {
sanitizeFilename: vi.fn(),
@@ -78,6 +78,7 @@ describe('UploadService', () => {
libraryService.verifyUserAccess.mockResolvedValue(undefined);
appSettings.getUploadPattern.mockResolvedValue(null);
appSettings.getUploadPatternBookPerFolder.mockResolvedValue(null);
appSettings.isCrossPlatformPathSanitizationEnabled.mockResolvedValue(false);
mockFsAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
mockExtractEpubMetadata.mockResolvedValue(null);
});
@@ -118,6 +119,33 @@ describe('UploadService', () => {
expect(processor.extractMetadataAsync).toHaveBeenCalledWith(99, '/library/Frank Herbert/Dune.epub', 'epub');
});
it('sanitizes generated destination path tokens when cross-platform mode is enabled', async () => {
appSettings.isCrossPlatformPathSanitizationEnabled.mockResolvedValue(true);
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{authors:first}/{title}.{extension}' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockExtractEpubMetadata.mockResolvedValue({
title: 'AUX',
subtitle: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
isbn13: null,
authors: [{ name: 'CON' }],
tags: [],
description: null,
isbn10: null,
});
const result = await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(result).toEqual({ bookId: 99, filename: 'AUX_.epub', format: 'epub', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/CON_/AUX_.epub');
});
it('falls back to stem folder when no naming pattern is configured', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }]))
+3 -2
View File
@@ -101,12 +101,13 @@ export class UploadService {
(library.organizationMode === 'book_per_folder'
? await this.appSettings.getUploadPatternBookPerFolder()
: await this.appSettings.getUploadPattern());
const sanitizeForCrossPlatform = await this.appSettings.isCrossPlatformPathSanitizationEnabled();
if (pattern) {
const stem = basename(filename, extname(filename));
const tokens = await this.buildPatternTokens(tempPath, format, stem);
if (library.organizationMode === 'book_per_file') {
const resolvedFilename = resolveDownloadFilename(pattern, tokens, format);
const resolvedFilename = resolveDownloadFilename(pattern, tokens, format, { sanitizeForCrossPlatform });
if (resolvedFilename) {
const absolutePath = join(libraryFolderPath, resolvedFilename);
return {
@@ -116,7 +117,7 @@ export class UploadService {
};
}
} else {
const resolved = resolveUploadPath(pattern, tokens, format);
const resolved = resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform });
if (resolved) {
const absolutePath = join(libraryFolderPath, resolved);