security(settings): gate Book Dock settings behind manage_book_dock

Require manage_book_dock for the settings UI and the scoped API so regular Book Dock users cannot
view or modify administrative configuration.

Reject every Book Dock key on the generic PATCH /app-settings/:key route as well, including
book_dock_paused. That route accepted the same keys with any string value, so an operator with
manage_app_settings could store an out-of-range threshold or an unknown metadata mode and bypass
the validation the scoped endpoints apply.

Closes #1003
This commit is contained in:
neon
2026-08-14 03:11:48 +00:00
parent 8cbc8bd8ab
commit 26b7c602f8
25 changed files with 584 additions and 167 deletions
@@ -28,6 +28,16 @@ export const APP_SETTING_KEYS = {
MAX_UPLOAD_SIZE_MB: 'max_upload_size_mb',
} as const;
export const BOOK_DOCK_MANAGED_SETTING_KEYS: readonly string[] = [
APP_SETTING_KEYS.BOOK_DOCK_PAUSED,
APP_SETTING_KEYS.BOOK_DOCK_AUTO_FETCH_METADATA,
APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_ENABLED,
APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_THRESHOLD,
APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_LIBRARY_ID,
APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_FOLDER_ID,
APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_METADATA_MODE,
];
export const DEFAULT_AUDIT_RETENTION_DAYS = 90;
export interface OidcFullConfig extends OidcBaseConfig {
@@ -5,10 +5,9 @@ import { AppSettingsService } from '../app-settings/app-settings.service';
import { GITHUB_RELEASES_API } from './app-info.constants';
import { AppInfoService } from './app-info.service';
function makeConfig(version = 'v1.2.3', bookDockPath = '/data/book-dock'): jest.Mocked<ConfigService> {
function makeConfig(version = 'v1.2.3'): jest.Mocked<ConfigService> {
return {
get: vi.fn().mockImplementation((key: string) => {
if (key === 'storage.bookDockPath') return bookDockPath;
if (key === 'app.version') return version;
return undefined;
}),
@@ -62,27 +61,16 @@ describe('AppInfoService', () => {
expect(info.latestVersion).toBeNull();
});
it('returns bookDockPath from config', async () => {
const service = new AppInfoService(makeConfig('v1.0.0', '/books/bookdrop'), makeAppSettings());
expect((await service.getAppInfo()).bookDockPath).toBe('/books/bookdrop');
});
it('falls back to /data/book-dock when storage.bookDockPath is not configured', async () => {
const config = { get: vi.fn().mockReturnValue(undefined) } as unknown as ConfigService;
const service = new AppInfoService(config, makeAppSettings());
expect((await service.getAppInfo()).bookDockPath).toBe('/data/book-dock');
});
it('includes bookDockPath in full response shape', async () => {
const service = new AppInfoService(makeConfig('Local build', '/app/data/book-dock'), makeAppSettings());
it('does not expose the Book Dock filesystem path', async () => {
const service = new AppInfoService(makeConfig('Local build'), makeAppSettings());
const info = await service.getAppInfo();
expect(info).toMatchObject({
version: 'Local build',
updateAvailable: null,
latestVersion: null,
bookDockPath: '/app/data/book-dock',
maxUploadSizeMb: 500,
});
expect(info).not.toHaveProperty('bookDockPath');
});
it('fetches from GitHub if checked for the first time', async () => {
@@ -1,6 +1,5 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { join } from 'path';
import type { AppInfoResponse } from '@bookorbit/types';
@@ -35,9 +34,6 @@ export class AppInfoService implements OnApplicationBootstrap {
async getAppInfo(): Promise<AppInfoResponse> {
const version = this.config.get<string>('app.version') ?? 'Local build';
const enabled = await this.appSettingsService.isUpdateCheckEnabled();
const appDataPath = this.config.get<string>('storage.appDataPath') ?? '/data';
const bookDockPath = this.config.get<string>('storage.bookDockPath') ?? join(appDataPath, 'book-dock');
if (enabled && SEMVER_RE.test(version)) {
const now = Date.now();
if (now - this.lastCheckTime > 600_000) {
@@ -52,7 +48,6 @@ export class AppInfoService implements OnApplicationBootstrap {
version,
updateAvailable: this.updateAvailable,
latestVersion: this.latestVersion,
bookDockPath,
maxUploadSizeMb,
};
}
@@ -117,4 +117,23 @@ describe('AppSettingsRepository', () => {
await expect(repo.upsert('key', 'value')).resolves.toBeUndefined();
});
});
describe('upsertMany', () => {
it('writes all settings in one conflict-safe statement', async () => {
const settings = [
{ key: 'book_dock_auto_fetch_metadata', value: 'false' },
{ key: 'book_dock_auto_finalize_threshold', value: '90' },
];
await repo.upsertMany(settings);
expect(db.values).toHaveBeenCalledWith(settings);
expect(db.onConflictDoUpdate).toHaveBeenCalledOnce();
});
it('does not query for an empty update', async () => {
await repo.upsertMany([]);
expect(db.insert).not.toHaveBeenCalled();
});
});
});
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { eq, inArray, ne } from 'drizzle-orm';
import { eq, inArray, ne, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -38,4 +38,12 @@ export class AppSettingsRepository {
async upsert(key: string, value: string): Promise<void> {
await this.db.insert(schema.appSettings).values({ key, value }).onConflictDoUpdate({ target: schema.appSettings.key, set: { value } });
}
async upsertMany(settings: Array<{ key: string; value: string }>): Promise<void> {
if (settings.length === 0) return;
await this.db
.insert(schema.appSettings)
.values(settings)
.onConflictDoUpdate({ target: schema.appSettings.key, set: { value: sql`excluded.value` } });
}
}
@@ -20,6 +20,7 @@ function makeRepo(): jest.Mocked<AppSettingsRepository> {
findExistingLibraryIds: vi.fn().mockResolvedValue([]),
updateByKey: vi.fn().mockResolvedValue(null),
upsert: vi.fn().mockResolvedValue(undefined),
upsertMany: vi.fn().mockResolvedValue(undefined),
} as unknown as jest.Mocked<AppSettingsRepository>;
}
@@ -41,7 +42,7 @@ describe('AppSettingsService', () => {
beforeEach(() => {
repo = makeRepo();
config = makeConfig();
service = new AppSettingsService(repo, config);
service = new AppSettingsService(repo, config, { appDataPath: '/data', bookDockPath: '/data/book-dock', libraryBrowseRoot: '/' });
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
});
@@ -91,6 +92,19 @@ describe('AppSettingsService', () => {
repo.updateByKey.mockResolvedValue(null);
await expect(service.update('nonexistent_key', 'value')).rejects.toThrow(NotFoundException);
});
it.each([
'book_dock_paused',
'book_dock_auto_fetch_metadata',
'book_dock_auto_finalize_enabled',
'book_dock_auto_finalize_threshold',
'book_dock_auto_finalize_library_id',
'book_dock_auto_finalize_folder_id',
'book_dock_auto_finalize_metadata_mode',
])('rejects %s so it cannot bypass the Book Dock validation', async (key) => {
await expect(service.update(key, 'anything')).rejects.toThrow(BadRequestException);
expect(repo.updateByKey).not.toHaveBeenCalled();
});
});
describe('isBookDockAutoFetchEnabled', () => {
@@ -350,7 +364,11 @@ describe('AppSettingsService', () => {
});
it('passes allowLocal: false when nodeEnv is production', async () => {
const prodService = new AppSettingsService(repo, makeConfig('production'));
const prodService = new AppSettingsService(repo, makeConfig('production'), {
appDataPath: '/data',
bookDockPath: '/data/book-dock',
libraryBrowseRoot: '/',
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(discoveryDoc) }));
await prodService.testOidcConnection('https://kc.example.com/realms/main');
expect(vi.mocked(ensureSafeUrl)).toHaveBeenCalledWith('https://kc.example.com/realms/main', { allowLocal: false, allowPrivate: false });
@@ -358,7 +376,11 @@ describe('AppSettingsService', () => {
});
it('passes allowLocal: true when production override is enabled', async () => {
const prodService = new AppSettingsService(repo, makeConfig('production', true));
const prodService = new AppSettingsService(repo, makeConfig('production', true), {
appDataPath: '/data',
bookDockPath: '/data/book-dock',
libraryBrowseRoot: '/',
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(discoveryDoc) }));
await prodService.testOidcConnection('https://kc.example.com/realms/main');
expect(vi.mocked(ensureSafeUrl)).toHaveBeenCalledWith('https://kc.example.com/realms/main', { allowLocal: true, allowPrivate: true });
@@ -422,6 +444,73 @@ describe('AppSettingsService', () => {
});
});
describe('Book Dock settings', () => {
it('returns the protected configuration with safe defaults', async () => {
repo.findMany.mockResolvedValue([
{ key: 'book_dock_auto_fetch_metadata', value: 'invalid' },
{ key: 'book_dock_auto_finalize_enabled', value: 'invalid' },
{ key: 'book_dock_auto_finalize_threshold', value: '101' },
{ key: 'book_dock_auto_finalize_library_id', value: '-1' },
{ key: 'book_dock_auto_finalize_folder_id', value: 'abc' },
{ key: 'book_dock_auto_finalize_metadata_mode', value: 'invalid' },
] as never);
await expect(service.getBookDockSettings()).resolves.toEqual({
bookDockPath: '/data/book-dock',
autoFetchMetadata: true,
autoFinalizeEnabled: false,
autoFinalizeThreshold: 85,
autoFinalizeLibraryId: null,
autoFinalizeFolderId: null,
autoFinalizeMetadataMode: 'safe_merge',
});
});
it('parses stored configuration values', async () => {
repo.findMany.mockResolvedValue([
{ key: 'book_dock_auto_fetch_metadata', value: 'false' },
{ key: 'book_dock_auto_finalize_enabled', value: 'true' },
{ key: 'book_dock_auto_finalize_threshold', value: '95' },
{ key: 'book_dock_auto_finalize_library_id', value: '7' },
{ key: 'book_dock_auto_finalize_folder_id', value: '11' },
{ key: 'book_dock_auto_finalize_metadata_mode', value: 'embedded_only' },
] as never);
await expect(service.getBookDockSettings()).resolves.toMatchObject({
autoFetchMetadata: false,
autoFinalizeEnabled: true,
autoFinalizeThreshold: 95,
autoFinalizeLibraryId: 7,
autoFinalizeFolderId: 11,
autoFinalizeMetadataMode: 'embedded_only',
});
});
it('updates the complete configuration together and returns the refreshed settings', async () => {
repo.findMany.mockResolvedValue([]);
const request = {
autoFetchMetadata: false,
autoFinalizeEnabled: true,
autoFinalizeThreshold: 90,
autoFinalizeLibraryId: 4,
autoFinalizeFolderId: null,
autoFinalizeMetadataMode: 'fetched_only' as const,
};
await service.updateBookDockSettings(request);
expect(repo.upsertMany).toHaveBeenCalledWith([
{ key: 'book_dock_auto_fetch_metadata', value: 'false' },
{ key: 'book_dock_auto_finalize_enabled', value: 'true' },
{ key: 'book_dock_auto_finalize_threshold', value: '90' },
{ key: 'book_dock_auto_finalize_library_id', value: '4' },
{ key: 'book_dock_auto_finalize_folder_id', value: '' },
{ key: 'book_dock_auto_finalize_metadata_mode', value: 'fetched_only' },
]);
expect(repo.findMany).toHaveBeenCalledAfter(repo.upsertMany);
});
});
describe('getUploadPattern / getDownloadPattern / getUploadPatternBookPerFolder', () => {
it('returns DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE when not set', async () => {
repo.findByKey.mockResolvedValue(undefined);
@@ -1,5 +1,5 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService, type ConfigType } from '@nestjs/config';
import {
AuthorAutoEnrichmentWriteMode,
@@ -10,11 +10,15 @@ import {
DEFAULT_METADATA_SCORE_WEIGHTS,
type MetadataScoreWeights,
type BookDockAutoFinalizeMetadataMode,
type BookDockSettings,
type UpdateBookDockSettingsRequest,
} from '@bookorbit/types';
import { StatsCache } from '../../common/cache/stats-cache';
import { storageConfig } from '../../config/config';
import {
APP_SETTING_KEYS,
BOOK_DOCK_MANAGED_SETTING_KEYS,
DEFAULT_LIBRARY_ACCESS_CONFIG,
DEFAULT_OIDC_CONFIG,
type OidcFullConfig,
@@ -49,6 +53,7 @@ export class AppSettingsService {
constructor(
private readonly repo: AppSettingsRepository,
private readonly config: ConfigService,
@Inject(storageConfig.KEY) private readonly storage: ConfigType<typeof storageConfig>,
) {}
listSettings() {
@@ -66,6 +71,9 @@ export class AppSettingsService {
}
async update(key: string, value: string) {
if (BOOK_DOCK_MANAGED_SETTING_KEYS.includes(key)) {
throw new BadRequestException(`Setting '${key}' is managed by the Book Dock endpoints and cannot be written here`);
}
if (key === APP_SETTING_KEYS.MAX_UPLOAD_SIZE_MB) {
const parsed = parseInt(value, 10);
if (isNaN(parsed) || parsed <= 0) {
@@ -92,6 +100,44 @@ export class AppSettingsService {
await this.repo.upsert(APP_SETTING_KEYS.BOOK_DOCK_PAUSED, String(paused));
}
async getBookDockSettings(): Promise<BookDockSettings> {
const keys = [
APP_SETTING_KEYS.BOOK_DOCK_AUTO_FETCH_METADATA,
APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_ENABLED,
APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_THRESHOLD,
APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_LIBRARY_ID,
APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_FOLDER_ID,
APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_METADATA_MODE,
];
const rows = await this.repo.findMany(keys);
const settings = new Map(rows.map((row) => [row.key, row.value]));
const libraryId = parseOptionalPositiveInt(settings.get(APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_LIBRARY_ID));
const folderId = parseOptionalPositiveInt(settings.get(APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_FOLDER_ID));
return {
bookDockPath: this.storage.bookDockPath,
autoFetchMetadata: parseBooleanSetting(settings.get(APP_SETTING_KEYS.BOOK_DOCK_AUTO_FETCH_METADATA), true),
autoFinalizeEnabled: parseBooleanSetting(settings.get(APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_ENABLED), false),
autoFinalizeThreshold: parseAutoFinalizeThreshold(settings.get(APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_THRESHOLD)),
autoFinalizeLibraryId: libraryId,
autoFinalizeFolderId: folderId,
autoFinalizeMetadataMode: parseAutoFinalizeMetadataMode(settings.get(APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_METADATA_MODE)),
};
}
async updateBookDockSettings(settings: UpdateBookDockSettingsRequest): Promise<BookDockSettings> {
await this.repo.upsertMany([
{ key: APP_SETTING_KEYS.BOOK_DOCK_AUTO_FETCH_METADATA, value: String(settings.autoFetchMetadata) },
{ key: APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_ENABLED, value: String(settings.autoFinalizeEnabled) },
{ key: APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_THRESHOLD, value: String(settings.autoFinalizeThreshold) },
{ key: APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_LIBRARY_ID, value: settings.autoFinalizeLibraryId?.toString() ?? '' },
{ key: APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_FOLDER_ID, value: settings.autoFinalizeFolderId?.toString() ?? '' },
{ key: APP_SETTING_KEYS.BOOK_DOCK_AUTO_FINALIZE_METADATA_MODE, value: settings.autoFinalizeMetadataMode },
]);
return this.getBookDockSettings();
}
async getAuthorsAutoEnrichmentWriteMode(): Promise<AuthorAutoEnrichmentWriteMode> {
const row = await this.repo.findByKey(APP_SETTING_KEYS.AUTHORS_AUTO_ENRICHMENT_WRITE_MODE);
const mode = row?.value?.trim();
@@ -320,6 +366,17 @@ function parseAutoFinalizeMetadataMode(value: string | undefined): BookDockAutoF
return 'safe_merge';
}
function parseOptionalPositiveInt(value: string | undefined): number | null {
if (!value) return null;
const parsed = parseInt(value, 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function parseAutoFinalizeThreshold(value: string | undefined): number {
const parsed = parseInt(value ?? '85', 10);
return Number.isInteger(parsed) && parsed >= 50 && parsed <= 100 ? parsed : 85;
}
function mergeOidcConfig(base: OidcFullConfig, patch: Partial<OidcFullConfig>): OidcFullConfig {
return {
...base,
@@ -42,7 +42,11 @@ function makeController() {
const ingestService = { ingestUpload: vi.fn() };
const finalizeService = { previewNames: vi.fn(), previewFinalize: vi.fn(), discardDuplicateCandidates: vi.fn(), finalize: vi.fn() };
const watcherService = { rescan: vi.fn() };
const appSettings = { getMaxUploadSizeMb: vi.fn().mockResolvedValue(500) };
const appSettings = {
getMaxUploadSizeMb: vi.fn().mockResolvedValue(500),
getBookDockSettings: vi.fn(),
updateBookDockSettings: vi.fn(),
};
const controller = new BookDockController(
service as never,
@@ -205,6 +209,29 @@ describe('BookDockController', () => {
expect(service.resumeProcessing).toHaveBeenCalledTimes(1);
});
it('reads and updates Book Dock settings through the scoped service methods', async () => {
const { controller, appSettings } = makeController();
const settings = {
bookDockPath: '/data/book-dock',
autoFetchMetadata: true,
autoFinalizeEnabled: false,
autoFinalizeThreshold: 85,
autoFinalizeLibraryId: null,
autoFinalizeFolderId: null,
autoFinalizeMetadataMode: 'safe_merge' as const,
};
const update = { ...settings };
delete (update as Partial<typeof settings>).bookDockPath;
appSettings.getBookDockSettings.mockResolvedValue(settings);
appSettings.updateBookDockSettings.mockResolvedValue(settings);
await expect(controller.getSettings()).resolves.toEqual(settings);
await expect(controller.updateSettings(update as any)).resolves.toEqual(settings);
expect(appSettings.getBookDockSettings).toHaveBeenCalledOnce();
expect(appSettings.updateBookDockSettings).toHaveBeenCalledWith(update);
});
it('marks bulk edit endpoint as demo-restricted', () => {
expect(Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, BookDockController.prototype.bulkEdit)).toEqual({
permission: Permission.DemoRestricted,
@@ -222,6 +249,8 @@ describe('BookDockController', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, BookDockController.prototype.pause)).toBe(Permission.ManageBookDock);
expect(Reflect.getMetadata(PERMISSION_KEY, BookDockController.prototype.resume)).toBe(Permission.ManageBookDock);
expect(Reflect.getMetadata(PERMISSION_KEY, BookDockController.prototype.rescan)).toBe(Permission.ManageBookDock);
expect(Reflect.getMetadata(PERMISSION_KEY, BookDockController.prototype.getSettings)).toBe(Permission.ManageBookDock);
expect(Reflect.getMetadata(PERMISSION_KEY, BookDockController.prototype.updateSettings)).toBe(Permission.ManageBookDock);
});
it('grants global scope only to superusers and Book Dock managers', async () => {
@@ -14,6 +14,7 @@ import {
Query,
Req,
Res,
Put,
} from '@nestjs/common';
import { createReadStream } from 'fs';
import { access } from 'fs/promises';
@@ -35,6 +36,7 @@ import { BookDockIngestService } from './book-dock-ingest.service';
import { BookDockFinalizeService } from './book-dock-finalize.service';
import { BookDockWatcherService } from './book-dock-watcher.service';
import { ListBookDockFilesDto } from './dto/list-book-dock-files.dto';
import { UpdateBookDockSettingsDto } from './dto/update-book-dock-settings.dto';
import {
UpdateBookDockFileDto,
FinalizeBookDockDto,
@@ -93,6 +95,23 @@ export class BookDockController {
return this.service.resumeProcessing();
}
@Get('settings')
@RequirePermission(Permission.ManageBookDock)
getSettings() {
return this.appSettings.getBookDockSettings();
}
@Put('settings')
@RequirePermission(Permission.ManageBookDock)
@Auditable({
action: AuditAction.AppSettingsUpdate,
resource: AuditResource.AppSettings,
description: 'Updated Book Dock settings',
})
updateSettings(@Body() dto: UpdateBookDockSettingsDto) {
return this.appSettings.updateBookDockSettings(dto);
}
@Get('statistics')
getStatistics(@CurrentUser() user: RequestUser) {
return this.service.getStatistics(user.id, this.canManageAll(user));
@@ -0,0 +1,54 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { UpdateBookDockSettingsDto } from './update-book-dock-settings.dto';
const validSettings = {
autoFetchMetadata: true,
autoFinalizeEnabled: false,
autoFinalizeThreshold: 85,
autoFinalizeLibraryId: null,
autoFinalizeFolderId: null,
autoFinalizeMetadataMode: 'safe_merge',
};
async function errorsFor(overrides: Record<string, unknown> = {}) {
return validate(plainToInstance(UpdateBookDockSettingsDto, { ...validSettings, ...overrides }));
}
describe('UpdateBookDockSettingsDto', () => {
it('accepts the complete valid settings shape', async () => {
await expect(errorsFor()).resolves.toHaveLength(0);
await expect(
errorsFor({
autoFinalizeThreshold: 100,
autoFinalizeLibraryId: 3,
autoFinalizeFolderId: 9,
autoFinalizeMetadataMode: 'embedded_only',
}),
).resolves.toHaveLength(0);
});
it.each([
['autoFetchMetadata', 'true'],
['autoFinalizeEnabled', 1],
['autoFinalizeThreshold', 49],
['autoFinalizeThreshold', 101],
['autoFinalizeThreshold', 85.5],
['autoFinalizeLibraryId', 0],
['autoFinalizeFolderId', -1],
['autoFinalizeMetadataMode', 'replace_all'],
])('rejects invalid %s values', async (field, value) => {
const errors = await errorsFor({ [field]: value });
expect(errors.map((error) => error.property)).toContain(field);
});
it.each(Object.keys(validSettings))('requires %s', async (field) => {
const input = { ...validSettings } as Record<string, unknown>;
delete input[field];
const errors = await validate(plainToInstance(UpdateBookDockSettingsDto, input));
expect(errors.map((error) => error.property)).toContain(field);
});
});
@@ -0,0 +1,31 @@
import { IsBoolean, IsIn, IsInt, Max, Min, ValidateIf } from 'class-validator';
import type { BookDockAutoFinalizeMetadataMode, UpdateBookDockSettingsRequest } from '@bookorbit/types';
const AUTO_FINALIZE_METADATA_MODES: BookDockAutoFinalizeMetadataMode[] = ['safe_merge', 'fetched_only', 'embedded_only'];
export class UpdateBookDockSettingsDto implements UpdateBookDockSettingsRequest {
@IsBoolean()
autoFetchMetadata!: boolean;
@IsBoolean()
autoFinalizeEnabled!: boolean;
@IsInt()
@Min(50)
@Max(100)
autoFinalizeThreshold!: number;
@ValidateIf((_, value) => value !== null)
@IsInt()
@Min(1)
autoFinalizeLibraryId!: number | null;
@ValidateIf((_, value) => value !== null)
@IsInt()
@Min(1)
autoFinalizeFolderId!: number | null;
@IsIn(AUTO_FINALIZE_METADATA_MODES)
autoFinalizeMetadataMode!: BookDockAutoFinalizeMetadataMode;
}
@@ -1,5 +1,5 @@
{
"totalRoutes": 372,
"totalRoutes": 374,
"byPermission": {
"(none)": 166,
"Permission.ManageMetadataConfig": 32,
@@ -20,7 +20,7 @@
"Permission.LibraryDeleteBooks": 1,
"Permission.LibraryDownload": 1,
"Permission.LibraryUpload": 2,
"Permission.ManageBookDock": 1,
"Permission.ManageBookDock": 3,
"Permission.ManageIcons": 1,
"Permission.HardcoverSync": 1,
"Permission.ReadwiseSync": 1,
@@ -23,6 +23,30 @@
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-dock/book-dock.controller.ts",
"className": "BookDockController",
"methodName": "getSettings",
"httpMethod": "GET",
"path": "/book-dock/settings",
"permissions": ["Permission.ManageBookDock"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-dock/book-dock.controller.ts",
"className": "BookDockController",
"methodName": "updateSettings",
"httpMethod": "PUT",
"path": "/book-dock/settings",
"permissions": ["Permission.ManageBookDock"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-dock/book-dock.controller.ts",
"className": "BookDockController",