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