mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 }]))
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user