fix(auth): split Book Dock access from Book Dock administration

Book Dock was a single permission that the standard preset handed to every user, so an ordinary
account could pause processing, trigger a rescan, and finalize straight into the library. Keep
book_dock_access for personal upload and review, and move global control behind a new
manage_book_dock permission in the administration group.

Finalization now also requires library_upload, since it writes into a library. Per-file reads and
mutations are scoped to the uploader unless the caller can manage the dock, which closes the gap
that let any Book Dock user read, edit, or discard another user's entry by id. The summary
websocket now announces a change instead of broadcasting global counts, so each client refetches
its own scoped totals.

Closes #961
This commit is contained in:
neon
2026-08-09 21:27:33 +00:00
parent 68fae9a121
commit ca3c637271
31 changed files with 424 additions and 215 deletions
@@ -27,6 +27,9 @@ describe('common decorators', () => {
@RequirePermission(Permission.ManageUsers)
withPermission() {}
@RequirePermission(Permission.BookDockAccess, Permission.LibraryUpload)
withPermissions() {}
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot perform bulk edits')
withoutPermission() {}
@@ -40,6 +43,10 @@ describe('common decorators', () => {
expect(Reflect.getMetadata(IS_PUBLIC_KEY, DecoratedController.prototype.open)).toBe(true);
expect(Reflect.getMetadata(ALLOW_DEFAULT_PASSWORD_KEY, DecoratedController.prototype.allowDefaultPassword)).toBe(true);
expect(Reflect.getMetadata(PERMISSION_KEY, DecoratedController.prototype.withPermission)).toBe(Permission.ManageUsers);
expect(Reflect.getMetadata(PERMISSION_KEY, DecoratedController.prototype.withPermissions)).toEqual([
Permission.BookDockAccess,
Permission.LibraryUpload,
]);
expect(Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, DecoratedController.prototype.withoutPermission)).toEqual({
permission: Permission.DemoRestricted,
message: 'Demo-restricted account cannot perform bulk edits',
@@ -2,4 +2,5 @@ import { SetMetadata } from '@nestjs/common';
import { Permission } from '@bookorbit/types';
export const PERMISSION_KEY = 'permission';
export const RequirePermission = (permission: Permission) => SetMetadata(PERMISSION_KEY, permission);
export const RequirePermission = (...permissions: [Permission, ...Permission[]]) =>
SetMetadata(PERMISSION_KEY, permissions.length === 1 ? permissions[0] : permissions);
@@ -95,7 +95,7 @@ describe('PermissionGuard', () => {
permission: Permission.DemoRestricted,
};
}
if (key === PERMISSION_KEY) return Permission.ManageUsers;
if (key === PERMISSION_KEY) return [Permission.ManageUsers];
return undefined;
}),
};
@@ -131,7 +131,7 @@ describe('PermissionGuard', () => {
const reflector = {
getAllAndOverride: vi.fn((key: string) => {
if (key === IS_PUBLIC_KEY) return false;
if (key === PERMISSION_KEY) return Permission.ManageUsers;
if (key === PERMISSION_KEY) return [Permission.ManageUsers];
return undefined;
}),
};
@@ -145,7 +145,7 @@ describe('PermissionGuard', () => {
const reflector = {
getAllAndOverride: vi.fn((key: string) => {
if (key === IS_PUBLIC_KEY) return false;
if (key === PERMISSION_KEY) return Permission.ManageUsers;
if (key === PERMISSION_KEY) return [Permission.ManageUsers];
return undefined;
}),
};
@@ -154,4 +154,23 @@ describe('PermissionGuard', () => {
expect(guard.canActivate(makeContext(makeUser()))).toBe(true);
});
it('requires every permission attached to a route', () => {
const reflector = {
getAllAndOverride: vi.fn((key: string) => {
if (key === IS_PUBLIC_KEY) return false;
if (key === PERMISSION_KEY) return [Permission.BookDockAccess, Permission.LibraryUpload];
return undefined;
}),
};
const permissionService = {
userHas: vi.fn((_user: RequestUser, permission: Permission) => permission === Permission.BookDockAccess),
userHasExplicit: vi.fn().mockReturnValue(false),
};
const guard = new PermissionGuard(reflector as never, permissionService as never);
expect(() => guard.canActivate(makeContext(makeUser()))).toThrow(`Missing permission: ${Permission.LibraryUpload}`);
expect(permissionService.userHas).toHaveBeenCalledWith(expect.any(Object), Permission.BookDockAccess);
expect(permissionService.userHas).toHaveBeenCalledWith(expect.any(Object), Permission.LibraryUpload);
});
});
+8 -3
View File
@@ -28,11 +28,16 @@ export class PermissionGuard implements CanActivate {
throw new ForbiddenException(forbidden.message ?? `Forbidden permission: ${forbidden.permission}`);
}
const required = this.reflector.getAllAndOverride<Permission | undefined>(PERMISSION_KEY, [context.getHandler(), context.getClass()]);
const required = this.reflector.getAllAndOverride<Permission | Permission[] | undefined>(PERMISSION_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required) return true;
if (!this.permissionService.userHas(user, required)) {
throw new ForbiddenException(`Missing permission: ${required}`);
const permissions = Array.isArray(required) ? required : [required];
const missing = permissions.find((permission) => !this.permissionService.userHas(user, permission));
if (missing) {
throw new ForbiddenException(`Missing permission: ${missing}`);
}
return true;
}
@@ -17,11 +17,7 @@ describe('Architecture boundaries', () => {
const modulesRoot = join(process.cwd(), 'src/modules');
it('prevents controllers from importing repositories directly', async () => {
const allowlist = [
'src/modules/authors/authors.controller.ts',
'src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts',
'src/modules/book-dock/book-dock.controller.ts',
].sort();
const allowlist = ['src/modules/authors/authors.controller.ts', 'src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts'].sort();
const allFiles = await listFilesRecursive(modulesRoot);
const controllerFiles = allFiles.filter((file) => file.endsWith('.controller.ts'));
@@ -68,7 +68,7 @@ function makeService() {
on: vi.fn(),
};
const gateway = {
emitSummary: vi.fn(),
emitChanged: vi.fn(),
};
const processingState = {
isPaused: vi.fn().mockResolvedValue(false),
@@ -187,7 +187,7 @@ describe('BookDockFinalizeService', () => {
success: true,
bookId: 42,
} as never);
vi.spyOn(service as never, 'emitSummary').mockResolvedValue(undefined as never);
vi.spyOn(service as never, 'emitChange').mockReturnValue(undefined as never);
await service.triggerAutoFinalize(row.id);
@@ -221,7 +221,7 @@ describe('BookDockFinalizeService', () => {
success: true,
bookId: 42,
} as never);
vi.spyOn(service as never, 'emitSummary').mockResolvedValue(undefined as never);
vi.spyOn(service as never, 'emitChange').mockReturnValue(undefined as never);
await service.triggerAutoFinalize(row.id);
@@ -255,7 +255,7 @@ describe('BookDockFinalizeService', () => {
success: true,
bookId: 42,
} as never);
vi.spyOn(service as never, 'emitSummary').mockResolvedValue(undefined as never);
vi.spyOn(service as never, 'emitChange').mockReturnValue(undefined as never);
await service.triggerAutoFinalize(row.id);
@@ -288,7 +288,7 @@ describe('BookDockFinalizeService', () => {
success: true,
bookId: 42,
} as never);
vi.spyOn(service as never, 'emitSummary').mockResolvedValue(undefined as never);
vi.spyOn(service as never, 'emitChange').mockReturnValue(undefined as never);
await service.triggerAutoFinalize(row.id);
@@ -318,7 +318,7 @@ describe('BookDockFinalizeService', () => {
success: true,
bookId: 42,
} as never);
vi.spyOn(service as never, 'emitSummary').mockResolvedValue(undefined as never);
vi.spyOn(service as never, 'emitChange').mockReturnValue(undefined as never);
await service.triggerAutoFinalize(row.id);
@@ -374,7 +374,7 @@ describe('BookDockFinalizeService', () => {
afterId: undefined,
status: 'ready',
userId: 0,
isSuperuser: true,
canManageAll: true,
});
expect(enqueueSpy).toHaveBeenCalledWith(1);
expect(enqueueSpy).not.toHaveBeenCalledWith(2);
@@ -408,9 +408,9 @@ describe('BookDockFinalizeService', () => {
success: true,
bookId: 101,
} as never);
vi.spyOn(service as never, 'emitSummary').mockResolvedValue(undefined as never);
vi.spyOn(service as never, 'emitChange').mockReturnValue(undefined as never);
const result = await service.finalize(7, true, [1, 2], false, [], 5, 9);
const result = await service.finalize(7, true, true, [1, 2], false, [], 5, 9);
expect(result.total).toBe(2);
expect(result.succeeded).toBe(1);
@@ -443,9 +443,9 @@ describe('BookDockFinalizeService', () => {
bookId: analysis.fileId + 9,
}) as never,
);
vi.spyOn(service as never, 'emitSummary').mockResolvedValue(undefined as never);
vi.spyOn(service as never, 'emitChange').mockReturnValue(undefined as never);
const result = await service.finalize(7, true, [], true, [], 5, 9, [], 'ready', 'foo');
const result = await service.finalize(7, true, true, [], true, [], 5, 9, [], 'ready', 'foo');
expect(repo.findSelectionBatch).toHaveBeenCalledTimes(2);
expect(result.total).toBe(2);
@@ -830,9 +830,9 @@ describe('BookDockFinalizeService', () => {
success: true,
bookId: 99,
} as never);
vi.spyOn(service as never, 'emitSummary').mockResolvedValue(undefined as never);
vi.spyOn(service as never, 'emitChange').mockReturnValue(undefined as never);
await service.finalize(1, true, [4], false, [], 5, 9, [{ fileId: 4, targetFileName: '1_alt' }]);
await service.finalize(1, true, true, [4], false, [], 5, 9, [{ fileId: 4, targetFileName: '1_alt' }]);
const overrideMapPassed = prepareSpy.mock.calls[0]?.[3] as Map<number, unknown>;
expect(overrideMapPassed.get(4)).toMatchObject({ fileId: 4, targetFileName: '1_alt' });
@@ -930,7 +930,7 @@ describe('BookDockFinalizeService', () => {
return Promise.resolve(undefined) as never;
});
const preview = await service.previewFinalize(1, true, [1, 2, 3], false, [], undefined, undefined);
const preview = await service.previewFinalize(1, true, true, [1, 2, 3], false, [], undefined, undefined);
expect(preview).toMatchObject({
total: 3,
@@ -981,7 +981,7 @@ describe('BookDockFinalizeService', () => {
return Promise.resolve(undefined) as never;
});
const result = await service.discardDuplicateCandidates(1, true, [1, 2, 3], false, [], undefined, undefined);
const result = await service.discardDuplicateCandidates(1, true, true, [1, 2, 3], false, [], undefined, undefined);
expect(result).toEqual({ total: 3, discarded: 1, skipped: 2, discardedFileIds: [1] });
expect(mockUnlink).toHaveBeenCalledWith('/dock/duplicate.epub');
@@ -172,6 +172,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
async finalize(
userId: number,
isSuperuser: boolean,
canManageAll: boolean,
fileIds: number[] | undefined,
selectAll: boolean | undefined,
excludedIds: number[] | undefined,
@@ -198,7 +199,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
status,
search,
userId,
isSuperuser,
canManageAll,
});
if (rows.length === 0) break;
@@ -215,7 +216,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
} else {
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
const batch = ids.slice(i, i + BATCH_SIZE);
const rows = await this.repo.findByIds(batch, userId, isSuperuser);
const rows = await this.repo.findByIds(batch, userId, canManageAll);
const prepared = await this.prepareFinalizeBatch(rows, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser);
const analysisById = new Map(prepared.analyses.map((analysis) => [analysis.fileId, analysis]));
@@ -240,7 +241,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
}
}
await this.emitSummary();
this.emitChange();
this.notificationService
.notify({
@@ -321,6 +322,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
async previewFinalize(
userId: number,
isSuperuser: boolean,
canManageAll: boolean,
fileIds: number[] | undefined,
selectAll: boolean | undefined,
excludedIds: number[] | undefined,
@@ -333,7 +335,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
const summary = createFinalizePreviewSummary();
const overrideMap = new Map((overrides ?? []).map((o) => [o.fileId, o]));
await this.processFinalizeSelection(userId, isSuperuser, fileIds, selectAll, excludedIds, status, search, async (rows, missingIds) => {
await this.processFinalizeSelection(userId, canManageAll, fileIds, selectAll, excludedIds, status, search, async (rows, missingIds) => {
const prepared = await this.prepareFinalizeBatch(rows, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser);
for (const candidate of prepared.analyses) {
const analysis = await this.classifyDestination(candidate, prepared.existingDestinations);
@@ -355,6 +357,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
async discardDuplicateCandidates(
userId: number,
isSuperuser: boolean,
canManageAll: boolean,
fileIds: number[] | undefined,
selectAll: boolean | undefined,
excludedIds: number[] | undefined,
@@ -373,7 +376,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
const discardedFileIds: number[] = [];
try {
await this.processFinalizeSelection(userId, isSuperuser, fileIds, selectAll, excludedIds, status, search, async (rows, missingIds) => {
await this.processFinalizeSelection(userId, canManageAll, fileIds, selectAll, excludedIds, status, search, async (rows, missingIds) => {
total += rows.length + missingIds.length;
const prepared = await this.prepareFinalizeBatch(rows, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser);
const duplicateRows: BookDockFileRow[] = [];
@@ -393,7 +396,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
discarded += duplicateRows.length;
});
await this.emitSummary();
this.emitChange();
const result = { total, discarded, skipped: total - discarded, discardedFileIds: selectAll ? [] : discardedFileIds };
this.logger.log(
`[book_dock.discard_duplicates] [end] userId=${userId} durationMs=${Date.now() - startedAt} total=${total} discarded=${discarded} skipped=${result.skipped} - duplicate discard completed`,
@@ -549,7 +552,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
private async processFinalizeSelection(
userId: number,
isSuperuser: boolean,
canManageAll: boolean,
fileIds: number[] | undefined,
selectAll: boolean | undefined,
excludedIds: number[] | undefined,
@@ -567,7 +570,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
status,
search,
userId,
isSuperuser,
canManageAll,
});
if (rows.length === 0) break;
@@ -580,7 +583,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
const ids = dedupeIds(fileIds ?? []);
for (let index = 0; index < ids.length; index += BATCH_SIZE) {
const batch = ids.slice(index, index + BATCH_SIZE);
const rows = await this.repo.findByIds(batch, userId, isSuperuser);
const rows = await this.repo.findByIds(batch, userId, canManageAll);
const rowById = new Map(rows.map((row) => [row.id, row]));
const missingIds = batch.filter((id) => !rowById.has(id));
await processBatch(rows, missingIds);
@@ -611,7 +614,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
const result = await this.finalizeFile(rowForFinalize, settings.libraryId, settings.folderId, new Map(), 0, true);
if (result.success) {
this.logger.log(`Auto-finalized Book Dock file ${fileId} -> book ${result.bookId} (confidence ${row.confidence}%)`);
await this.emitSummary();
this.emitChange();
this.notificationService
.notify({
@@ -655,7 +658,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
afterId,
status: 'ready',
userId: 0,
isSuperuser: true,
canManageAll: true,
});
if (rows.length === 0) break;
@@ -684,14 +687,14 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
excludedIds: number[] | undefined,
defaultLibraryId: number | undefined,
userId: number | undefined,
isSuperuser: boolean | undefined,
canManageAll: boolean | undefined,
status?: string,
search?: string,
): Promise<{ fileId: number; fileName: string; newName: string }[]> {
const ids = selectAll ? await this.repo.findAllIds(excludedIds, status, search, userId, isSuperuser) : (fileIds ?? []);
const ids = selectAll ? await this.repo.findAllIds(excludedIds, status, search, userId, canManageAll) : (fileIds ?? []);
if (!ids.length) return [];
const rows = await this.repo.findByIds(ids, userId, isSuperuser);
const rows = await this.repo.findByIds(ids, userId, canManageAll);
const appPatternFile = await this.appSettings.getUploadPattern();
const appPatternFolder = await this.appSettings.getUploadPatternBookPerFolder();
const sanitizeForCrossPlatform = await this.appSettings.isCrossPlatformPathSanitizationEnabled();
@@ -891,10 +894,8 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
return folder;
}
private async emitSummary(): Promise<void> {
const summary = await this.repo.countsByStatus();
const paused = await this.processingState.isPaused();
this.gateway.emitSummary({ ...summary, paused });
private emitChange(): void {
this.gateway.emitChanged();
}
}
@@ -59,7 +59,7 @@ function makeService(bookDockPath = '/books/book-dock') {
};
const gateway = {
emitSummary: vi.fn(),
emitChanged: vi.fn(),
};
const service = new BookDockIngestService(
@@ -568,7 +568,7 @@ describe('BookDockIngestService', () => {
status: 'ready',
embeddedMetadata: { title: 'Dune' },
});
const emitSummarySpy = vi.spyOn(service as any, 'emitSummary').mockResolvedValue(undefined);
const emitSummarySpy = vi.spyOn(service as any, 'emitChange').mockReturnValue(undefined);
const runWithSources = vi.fn().mockResolvedValue({ resolved: {}, sources: {} });
(metadataFetchPipeline as any).runWithSources = runWithSources;
@@ -583,7 +583,7 @@ describe('BookDockIngestService', () => {
it('extractMetadataAsync emits summary and ingestion events after successful extraction chain', async () => {
const { service, repo, metadataService, events } = makeService();
const autoFetchSpy = vi.spyOn(service as any, 'autoFetchMetadataAsync').mockResolvedValue(undefined);
const emitSummarySpy = vi.spyOn(service as any, 'emitSummary').mockResolvedValue(undefined);
const emitSummarySpy = vi.spyOn(service as any, 'emitChange').mockReturnValue(undefined);
repo.findById.mockResolvedValue({ id: 12, status: 'pending', format: 'epub', absolutePath: '/bucket/12.epub' });
(service as any).extractMetadataAsync(12, 'epub');
@@ -625,7 +625,7 @@ describe('BookDockIngestService', () => {
afterId: undefined,
status: 'pending',
userId: 0,
isSuperuser: true,
canManageAll: true,
});
expect(enqueueSpy).toHaveBeenCalledWith(1, { primary: 1, secondary: 1 });
expect(enqueueSpy).toHaveBeenCalledWith(2, { primary: 2, secondary: 2 });
@@ -168,7 +168,7 @@ export class BookDockIngestService implements OnApplicationBootstrap, OnModuleDe
afterId,
status: 'pending',
userId: 0,
isSuperuser: true,
canManageAll: true,
});
if (rows.length === 0) break;
@@ -203,10 +203,10 @@ export class BookDockIngestService implements OnApplicationBootstrap, OnModuleDe
const coversDir = join(this.bookDockPath, 'covers');
await this.repo.update(fileId, { status: 'extracting' });
await this.emitSummary();
this.emitChange();
await this.metadataService.extractAndSave(fileId, row.absolutePath, format, coversDir);
await this.autoFetchMetadataAsync(fileId);
await this.emitSummary();
this.emitChange();
this.events.emit(BOOK_DOCK_FILE_INGESTED, fileId);
}
@@ -234,7 +234,7 @@ export class BookDockIngestService implements OnApplicationBootstrap, OnModuleDe
if (!params.title && !params.isbn) return;
await this.repo.update(fileId, { status: 'fetching' });
await this.emitSummary();
this.emitChange();
try {
const { resolved, sources, providerIds = {} } = await this.metadataFetchPipeline.runWithSources(params, {});
const fetched = normalizeBookDockMetadata(resolved) ?? {};
@@ -274,10 +274,8 @@ export class BookDockIngestService implements OnApplicationBootstrap, OnModuleDe
return join(dir, uniqueName);
}
private async emitSummary(): Promise<void> {
const summary = await this.repo.countsByStatus();
const paused = await this.processingState.isPaused();
this.gateway.emitSummary({ ...summary, paused });
private emitChange(): void {
this.gateway.emitChanged();
}
}
@@ -37,7 +37,7 @@ function makeService(bookDockPath = '/data/book-dock') {
countsByStatus: vi.fn().mockResolvedValue({ pending: 1, ready: 2, error: 0, total: 3 }),
};
const gateway = {
emitSummary: vi.fn(),
emitChanged: vi.fn(),
};
const processingState = {
isPaused: vi.fn().mockResolvedValue(false),
@@ -67,7 +67,7 @@ describe('BookDockWatcherService', () => {
it('rescan walks files and emits summary', async () => {
const { service } = makeService();
const walkSpy = vi.spyOn(service as any, 'walkAndIngest').mockResolvedValue(undefined);
const emitSpy = vi.spyOn(service as any, 'emitSummary').mockResolvedValue(undefined);
const emitSpy = vi.spyOn(service as any, 'emitChange').mockReturnValue(undefined);
await service.rescan();
@@ -78,7 +78,7 @@ describe('BookDockWatcherService', () => {
it('rescan uses a custom configured Book Dock path', async () => {
const { service } = makeService('/books/bookdrop');
const walkSpy = vi.spyOn(service as any, 'walkAndIngest').mockResolvedValue(undefined);
const emitSpy = vi.spyOn(service as any, 'emitSummary').mockResolvedValue(undefined);
const emitSpy = vi.spyOn(service as any, 'emitChange').mockReturnValue(undefined);
await service.rescan();
@@ -116,7 +116,7 @@ describe('BookDockWatcherService', () => {
expect(waitForStability).toHaveBeenCalledWith('/data/book-dock/book.epub');
expect(ingestService.ingestFromWatchedFolder).toHaveBeenCalledWith('/data/book-dock/book.epub');
expect(gateway.emitSummary).toHaveBeenCalledWith({ pending: 1, ready: 2, error: 0, total: 3, paused: false });
expect(gateway.emitChanged).toHaveBeenCalledTimes(1);
});
it('rescan skips discovery while paused and emits current summary', async () => {
@@ -128,7 +128,7 @@ describe('BookDockWatcherService', () => {
expect(walkSpy).not.toHaveBeenCalled();
expect(ingestService.ingestFromWatchedFolder).not.toHaveBeenCalled();
expect(gateway.emitSummary).toHaveBeenCalledWith({ pending: 1, ready: 2, error: 0, total: 3, paused: true });
expect(gateway.emitChanged).toHaveBeenCalledTimes(1);
});
it('process(delete) removes db row and cover files before emitting summary', async () => {
@@ -140,7 +140,7 @@ describe('BookDockWatcherService', () => {
expect(unlink).toHaveBeenCalledWith('/data/book-dock/covers/12.jpg');
expect(unlink).toHaveBeenCalledWith('/data/book-dock/covers/12_thumb.jpg');
expect(repo.deleteById).toHaveBeenCalledWith(12);
expect(gateway.emitSummary).toHaveBeenCalledWith({ pending: 1, ready: 2, error: 0, total: 3, paused: false });
expect(gateway.emitChanged).toHaveBeenCalledTimes(1);
});
it('process(create) skips stability and ingest while paused', async () => {
@@ -51,11 +51,11 @@ export class BookDockWatcherService implements OnApplicationBootstrap, OnModuleD
async rescan(): Promise<void> {
if (await this.processingState.isPaused()) {
await this.emitSummary();
this.emitChange();
return;
}
await this.walkAndIngest(this.bookDockPath);
await this.emitSummary();
this.emitChange();
}
private async startWatcher(): Promise<void> {
@@ -110,7 +110,7 @@ export class BookDockWatcherService implements OnApplicationBootstrap, OnModuleD
await waitForStability(path);
if (await this.processingState.isPaused()) return;
const id = await this.ingestService.ingestFromWatchedFolder(path);
if (id !== null) await this.emitSummary();
if (id !== null) this.emitChange();
} else {
const row = await this.repo.findByAbsolutePath(path);
if (row) {
@@ -120,7 +120,7 @@ export class BookDockWatcherService implements OnApplicationBootstrap, OnModuleD
}
await this.repo.deleteById(row.id);
}
await this.emitSummary();
this.emitChange();
}
}
@@ -146,10 +146,8 @@ export class BookDockWatcherService implements OnApplicationBootstrap, OnModuleD
}
}
private async emitSummary(): Promise<void> {
const summary = await this.repo.countsByStatus();
const paused = await this.processingState.isPaused();
this.gateway.emitSummary({ ...summary, paused });
private emitChange(): void {
this.gateway.emitChanged();
}
}
@@ -3,6 +3,7 @@ import { Readable } from 'stream';
import { Permission } from '@bookorbit/types';
import { FORBIDDEN_PERMISSION_KEY } from '../../common/decorators/forbid-permission.decorator';
import { PERMISSION_KEY } from '../../common/decorators/require-permission.decorator';
import { BookDockController } from './book-dock.controller';
vi.mock('fs', () => ({
@@ -26,6 +27,7 @@ function makeController() {
getSummary: vi.fn(),
getStatistics: vi.fn(),
getFile: vi.fn(),
getCoverPath: vi.fn(),
updateFile: vi.fn(),
discardFile: vi.fn(),
bulkDiscard: vi.fn(),
@@ -40,7 +42,6 @@ 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 repo = { findById: vi.fn() };
const appSettings = { getMaxUploadSizeMb: vi.fn().mockResolvedValue(500) };
const controller = new BookDockController(
@@ -48,14 +49,13 @@ function makeController() {
ingestService as never,
finalizeService as never,
watcherService as never,
repo as never,
appSettings as never,
);
return { controller, service, ingestService, finalizeService, watcherService, repo, appSettings };
return { controller, service, ingestService, finalizeService, watcherService, appSettings };
}
const MOCK_USER = { id: 1, isSuperuser: false } as any;
const MOCK_USER = { id: 1, isSuperuser: false, permissions: [Permission.BookDockAccess] } as any;
describe('BookDockController', () => {
beforeEach(() => {
@@ -76,33 +76,34 @@ describe('BookDockController', () => {
order: 'desc',
search: undefined,
userId: MOCK_USER.id,
isSuperuser: MOCK_USER.isSuperuser,
canManageAll: false,
});
});
it('getCover throws when file has no cover path or cover file does not exist', async () => {
const { controller, repo } = makeController();
const { controller, service } = makeController();
const reply = { header: vi.fn(), send: vi.fn() } as any;
repo.findById.mockResolvedValueOnce({ id: 1, coverPath: null });
await expect(controller.getCover(1, reply)).rejects.toBeInstanceOf(NotFoundException);
service.getCoverPath.mockRejectedValueOnce(new NotFoundException('No cover available'));
await expect(controller.getCover(MOCK_USER, 1, reply)).rejects.toBeInstanceOf(NotFoundException);
repo.findById.mockResolvedValueOnce({ id: 1, coverPath: '/covers/1.jpg' });
service.getCoverPath.mockResolvedValueOnce('/covers/1.jpg');
vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT'));
await expect(controller.getCover(1, reply)).rejects.toBeInstanceOf(NotFoundException);
await expect(controller.getCover(MOCK_USER, 1, reply)).rejects.toBeInstanceOf(NotFoundException);
});
it('getCover streams cover bytes with proper headers', async () => {
const { controller, repo } = makeController();
repo.findById.mockResolvedValue({ id: 1, coverPath: '/covers/1.jpg' });
const { controller, service } = makeController();
service.getCoverPath.mockResolvedValue('/covers/1.jpg');
vi.mocked(access).mockResolvedValue(undefined as never);
const reply = {
header: vi.fn().mockReturnThis(),
send: vi.fn(),
} as any;
await controller.getCover(1, reply);
await controller.getCover(MOCK_USER, 1, reply);
expect(service.getCoverPath).toHaveBeenCalledWith(1, MOCK_USER.id, false);
expect(createReadStream).toHaveBeenCalledWith('/covers/1.jpg');
expect(reply.header).toHaveBeenCalledWith('Content-Type', 'image/jpeg');
expect(reply.header).toHaveBeenCalledWith('Cache-Control', 'private, max-age=3600');
@@ -131,7 +132,7 @@ describe('BookDockController', () => {
await expect(controller.upload(MOCK_USER, req)).resolves.toEqual({ id: 44, fileName: 'book.epub' });
expect(ingestService.ingestUpload).toHaveBeenCalledWith('book.epub', expect.any(Readable), MOCK_USER.id);
expect(service.getFile).toHaveBeenCalledWith(44);
expect(service.getFile).toHaveBeenCalledWith(44, MOCK_USER.id, false);
});
it('bulk and finalize endpoints delegate payload fields as expected', async () => {
@@ -174,18 +175,18 @@ describe('BookDockController', () => {
overrides: [],
} as any);
await controller.finalize(
{ id: 99, isSuperuser: true } as any,
{ id: 99, isSuperuser: true, permissions: [] } as any,
{ fileIds: [1], defaultLibraryId: 2, defaultFolderId: 3, selectAll: false, excludedIds: [], overrides: [] } as any,
);
await controller.rescan();
await controller.pause();
await controller.resume();
expect(service.bulkSetTarget).toHaveBeenCalledWith([5], false, [6], null, null, undefined, undefined, MOCK_USER.id, MOCK_USER.isSuperuser);
expect(finalizeService.previewNames).toHaveBeenCalledWith([10], false, [], 2, MOCK_USER.id, MOCK_USER.isSuperuser, undefined, undefined);
expect(finalizeService.previewFinalize).toHaveBeenCalledWith(1, false, [10], false, [], 2, 3, [], undefined, undefined);
expect(finalizeService.discardDuplicateCandidates).toHaveBeenCalledWith(1, false, [10], false, [], 2, 3, [], undefined, undefined);
expect(finalizeService.finalize).toHaveBeenCalledWith(99, true, [1], false, [], 2, 3, [], undefined, undefined);
expect(service.bulkSetTarget).toHaveBeenCalledWith([5], false, [6], null, null, undefined, undefined, MOCK_USER.id, false);
expect(finalizeService.previewNames).toHaveBeenCalledWith([10], false, [], 2, MOCK_USER.id, false, undefined, undefined);
expect(finalizeService.previewFinalize).toHaveBeenCalledWith(1, false, false, [10], false, [], 2, 3, [], undefined, undefined);
expect(finalizeService.discardDuplicateCandidates).toHaveBeenCalledWith(1, false, false, [10], false, [], 2, 3, [], undefined, undefined);
expect(finalizeService.finalize).toHaveBeenCalledWith(99, true, true, [1], false, [], 2, 3, [], undefined, undefined);
expect(watcherService.rescan).toHaveBeenCalled();
expect(service.pauseProcessing).toHaveBeenCalledTimes(1);
expect(service.resumeProcessing).toHaveBeenCalledTimes(1);
@@ -198,4 +199,28 @@ describe('BookDockController', () => {
});
expect(Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, BookDockController.prototype.finalize)).toBeUndefined();
});
it('requires upload permission for finalization and management permission for global controls', () => {
const finalizationPermissions = [Permission.BookDockAccess, Permission.LibraryUpload];
expect(Reflect.getMetadata(PERMISSION_KEY, BookDockController.prototype.previewFinalize)).toEqual(finalizationPermissions);
expect(Reflect.getMetadata(PERMISSION_KEY, BookDockController.prototype.discardFinalizeDuplicates)).toEqual(finalizationPermissions);
expect(Reflect.getMetadata(PERMISSION_KEY, BookDockController.prototype.finalize)).toEqual(finalizationPermissions);
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);
});
it('grants global scope only to superusers and Book Dock managers', async () => {
const { controller, service } = makeController();
service.getSummary.mockResolvedValue({});
await controller.getSummary(MOCK_USER);
await controller.getSummary({ ...MOCK_USER, permissions: [Permission.BookDockAccess, Permission.ManageBookDock] });
await controller.getSummary({ ...MOCK_USER, isSuperuser: true, permissions: [] });
expect(service.getSummary).toHaveBeenNthCalledWith(1, MOCK_USER.id, false);
expect(service.getSummary).toHaveBeenNthCalledWith(2, MOCK_USER.id, true);
expect(service.getSummary).toHaveBeenNthCalledWith(3, MOCK_USER.id, true);
});
});
@@ -34,7 +34,6 @@ import { BookDockService } from './book-dock.service';
import { BookDockIngestService } from './book-dock-ingest.service';
import { BookDockFinalizeService } from './book-dock-finalize.service';
import { BookDockWatcherService } from './book-dock-watcher.service';
import { BookDockRepository } from './book-dock.repository';
import { ListBookDockFilesDto } from './dto/list-book-dock-files.dto';
import {
UpdateBookDockFileDto,
@@ -57,7 +56,6 @@ export class BookDockController {
private readonly ingestService: BookDockIngestService,
private readonly finalizeService: BookDockFinalizeService,
private readonly watcherService: BookDockWatcherService,
private readonly repo: BookDockRepository,
private readonly appSettings: AppSettingsService,
) {}
@@ -71,22 +69,24 @@ export class BookDockController {
order: query.order ?? 'desc',
search: query.search,
userId: user.id,
isSuperuser: user.isSuperuser,
canManageAll: this.canManageAll(user),
});
}
@Get('summary')
getSummary(@CurrentUser() user: RequestUser) {
return this.service.getSummary(user.id, user.isSuperuser);
return this.service.getSummary(user.id, this.canManageAll(user));
}
@Post('pause')
@RequirePermission(Permission.ManageBookDock)
@HttpCode(HttpStatus.OK)
pause() {
return this.service.pauseProcessing();
}
@Post('resume')
@RequirePermission(Permission.ManageBookDock)
@HttpCode(HttpStatus.OK)
resume() {
return this.service.resumeProcessing();
@@ -94,26 +94,25 @@ export class BookDockController {
@Get('statistics')
getStatistics(@CurrentUser() user: RequestUser) {
return this.service.getStatistics(user.id, user.isSuperuser);
return this.service.getStatistics(user.id, this.canManageAll(user));
}
@Get('files/:id')
getFile(@Param('id', ParseIntPipe) id: number) {
return this.service.getFile(id);
getFile(@CurrentUser() user: RequestUser, @Param('id', ParseIntPipe) id: number) {
return this.service.getFile(id, user.id, this.canManageAll(user));
}
@Get('files/:id/cover')
async getCover(@Param('id', ParseIntPipe) id: number, @Res() reply: FastifyReply) {
const row = await this.repo.findById(id);
if (!row?.coverPath) throw new NotFoundException('No cover available');
async getCover(@CurrentUser() user: RequestUser, @Param('id', ParseIntPipe) id: number, @Res() reply: FastifyReply) {
const coverPath = await this.service.getCoverPath(id, user.id, this.canManageAll(user));
const exists = await access(row.coverPath)
const exists = await access(coverPath)
.then(() => true)
.catch(() => false);
if (!exists) throw new NotFoundException('Cover file not found on disk');
const stream = createReadStream(row.coverPath);
const contentType = imageContentTypeFromPath(row.coverPath);
const stream = createReadStream(coverPath);
const contentType = imageContentTypeFromPath(coverPath);
reply.header('Content-Type', contentType);
reply.header('Cache-Control', 'private, max-age=3600');
return reply.send(stream);
@@ -127,34 +126,34 @@ export class BookDockController {
if (!data) throw new BadRequestException('No file provided');
const fileId = await this.ingestService.ingestUpload(data.filename, data.file as unknown as Readable, user.id);
return this.service.getFile(fileId);
return this.service.getFile(fileId, user.id, this.canManageAll(user));
}
@Patch('files/:id')
updateFile(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateBookDockFileDto) {
return this.service.updateFile(id, dto);
updateFile(@CurrentUser() user: RequestUser, @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateBookDockFileDto) {
return this.service.updateFile(id, dto, user.id, this.canManageAll(user));
}
@Delete('files/:id')
@HttpCode(HttpStatus.NO_CONTENT)
discardFile(@Param('id', ParseIntPipe) id: number) {
return this.service.discardFile(id);
discardFile(@CurrentUser() user: RequestUser, @Param('id', ParseIntPipe) id: number) {
return this.service.discardFile(id, user.id, this.canManageAll(user));
}
@Post('files/discard')
@HttpCode(HttpStatus.NO_CONTENT)
bulkDiscard(@CurrentUser() user: RequestUser, @Body() dto: BulkDiscardDto) {
return this.service.bulkDiscard(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, user.isSuperuser);
return this.service.bulkDiscard(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, this.canManageAll(user));
}
@Post('files/apply-fetched')
applyFetched(@CurrentUser() user: RequestUser, @Body() dto: BulkApplyFetchedDto) {
return this.service.bulkApplyFetched(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, user.isSuperuser);
return this.service.bulkApplyFetched(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, this.canManageAll(user));
}
@Post('files/retry-fetch')
retryFetch(@CurrentUser() user: RequestUser, @Body() dto: BulkRetryFetchDto) {
return this.service.bulkRetryFetch(dto.fileIds, dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, user.isSuperuser);
return this.service.bulkRetryFetch(dto.fileIds, dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, this.canManageAll(user));
}
@Post('files/set-target')
@@ -168,13 +167,13 @@ export class BookDockController {
dto.status,
dto.search,
user.id,
user.isSuperuser,
this.canManageAll(user),
);
}
@Post('files/selection-summary')
selectionSummary(@CurrentUser() user: RequestUser, @Body() dto: SelectionSummaryDto) {
return this.service.selectionSummary(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, user.isSuperuser);
return this.service.selectionSummary(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search, user.id, this.canManageAll(user));
}
@Post('files/bulk-edit')
@@ -190,7 +189,7 @@ export class BookDockController {
dto.status,
dto.search,
user.id,
user.isSuperuser,
this.canManageAll(user),
);
}
@@ -202,18 +201,20 @@ export class BookDockController {
dto.excludedIds,
dto.defaultLibraryId,
user.id,
user.isSuperuser,
this.canManageAll(user),
dto.status,
dto.search,
);
}
@Post('finalize/preview')
@RequirePermission(Permission.BookDockAccess, Permission.LibraryUpload)
@HttpCode(HttpStatus.OK)
previewFinalize(@CurrentUser() user: RequestUser, @Body() dto: FinalizeBookDockDto) {
return this.finalizeService.previewFinalize(
user.id,
user.isSuperuser,
this.canManageAll(user),
dto.fileIds,
dto.selectAll,
dto.excludedIds,
@@ -226,11 +227,13 @@ export class BookDockController {
}
@Post('finalize/discard-duplicates')
@RequirePermission(Permission.BookDockAccess, Permission.LibraryUpload)
@HttpCode(HttpStatus.OK)
discardFinalizeDuplicates(@CurrentUser() user: RequestUser, @Body() dto: FinalizeBookDockDto) {
return this.finalizeService.discardDuplicateCandidates(
user.id,
user.isSuperuser,
this.canManageAll(user),
dto.fileIds,
dto.selectAll,
dto.excludedIds,
@@ -243,6 +246,7 @@ export class BookDockController {
}
@Post('finalize')
@RequirePermission(Permission.BookDockAccess, Permission.LibraryUpload)
@Auditable({
action: AuditAction.BookDockFinalize,
resource: AuditResource.BookDockFile,
@@ -258,6 +262,7 @@ export class BookDockController {
return this.finalizeService.finalize(
user.id,
isSuperuser,
this.canManageAll(user),
dto.fileIds,
dto.selectAll,
dto.excludedIds,
@@ -270,8 +275,13 @@ export class BookDockController {
}
@Post('rescan')
@RequirePermission(Permission.ManageBookDock)
@HttpCode(HttpStatus.NO_CONTENT)
rescan() {
return this.watcherService.rescan();
}
private canManageAll(user: RequestUser): boolean {
return user.isSuperuser || user.permissions.includes(Permission.ManageBookDock);
}
}
@@ -60,19 +60,13 @@ describe('BookDockGateway', () => {
expect(client.disconnect).not.toHaveBeenCalled();
});
it('emitSummary broadcasts summary payload on websocket namespace', () => {
it('broadcasts an invalidation event without leaking a global summary', () => {
const { gateway } = makeGateway();
const emit = vi.fn();
gateway.server = { emit } as any;
gateway.emitSummary({ pending: 2, ready: 4, error: 1, total: 7, paused: true });
gateway.emitChanged();
expect(emit).toHaveBeenCalledWith('book-dock:summary', {
pending: 2,
ready: 4,
error: 1,
total: 7,
paused: true,
});
expect(emit).toHaveBeenCalledWith('book-dock:changed');
});
});
@@ -3,7 +3,6 @@ import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import type { BookDockSummary } from '@bookorbit/types';
import { AuthService } from '../auth/auth.service';
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
@@ -36,7 +35,7 @@ export class BookDockGateway implements OnGatewayConnection, OnGatewayDisconnect
this.logger.debug(`WS disconnected: socket=${client.id}`);
}
emitSummary(summary: BookDockSummary): void {
this.server?.emit('book-dock:summary', summary);
emitChanged(): void {
this.server?.emit('book-dock:changed');
}
}
@@ -162,7 +162,7 @@ describe('BookDockRepository', () => {
const repo = new BookDockRepository(db as never);
await expect(
repo.findSelectionBatch({ limit: 2, afterId: 10, excludedIds: [3], status: 'error', search: 'foo', userId: 1, isSuperuser: false }),
repo.findSelectionBatch({ limit: 2, afterId: 10, excludedIds: [3], status: 'error', search: 'foo', userId: 1, canManageAll: false }),
).resolves.toEqual([{ id: 11 }, { id: 12 }]);
expect(selectBuilder.orderBy).toHaveBeenCalled();
expect(selectBuilder.limit).toHaveBeenCalledWith(2);
@@ -183,7 +183,7 @@ describe('BookDockRepository', () => {
order: 'asc',
search: 'dune',
userId: 1,
isSuperuser: true,
canManageAll: true,
}),
).resolves.toEqual({
items: [{ id: 1 }, { id: 2 }],
@@ -203,7 +203,7 @@ describe('BookDockRepository', () => {
sort: 'createdAt',
order: 'desc',
userId: 1,
isSuperuser: true,
canManageAll: true,
});
expect(selectBuilder.orderBy).toHaveBeenCalledWith(expect.objectContaining({ op: 'desc' }), expect.objectContaining({ op: 'desc' }));
@@ -248,4 +248,34 @@ describe('BookDockRepository', () => {
await expect(repo.findByIds([1, 2])).resolves.toEqual([{ id: 1 }, { id: 2 }]);
await expect(repo.setTargetsByIds([1, 2], 4, 5)).resolves.toBe(2);
});
it('limits personal queries to files uploaded by that user', async () => {
const { db, selectBuilder } = makeDb();
selectBuilder.where.mockResolvedValueOnce([{ id: 1, uploadedBy: 7 }]);
const repo = new BookDockRepository(db as never);
await repo.findByIds([1, 2], 7, false);
expect(selectBuilder.where).toHaveBeenCalledWith(
expect.objectContaining({
op: 'and',
clauses: expect.arrayContaining([expect.objectContaining({ op: 'eq', right: 7 })]),
}),
);
});
it('does not add an uploader filter for global Book Dock managers', async () => {
const { db, selectBuilder } = makeDb();
selectBuilder.where.mockResolvedValueOnce([]);
const repo = new BookDockRepository(db as never);
await repo.findByIds([1, 2], 7, true);
expect(selectBuilder.where).toHaveBeenCalledWith(
expect.objectContaining({
op: 'and',
clauses: [expect.objectContaining({ op: 'inArray' })],
}),
);
});
});
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, asc, count, desc, eq, gt, inArray, isNull, notInArray, or, sum, type SQL } from 'drizzle-orm';
import { and, asc, count, desc, eq, gt, inArray, notInArray, sum, type SQL } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -25,7 +25,7 @@ export interface ListOptions {
order: string;
search?: string;
userId: number;
isSuperuser: boolean;
canManageAll: boolean;
}
export interface SelectionBatchOptions {
@@ -35,7 +35,7 @@ export interface SelectionBatchOptions {
status?: string;
search?: string;
userId: number;
isSuperuser: boolean;
canManageAll: boolean;
}
@Injectable()
@@ -43,7 +43,7 @@ export class BookDockRepository {
constructor(@Inject(DB) private readonly db: Db) {}
async findAll(opts: ListOptions): Promise<{ items: BookDockFileRow[]; total: number }> {
const conditions = this.buildSelectionConditions(opts.status, opts.search, opts.userId, opts.isSuperuser);
const conditions = this.buildSelectionConditions(opts.status, opts.search, opts.userId, opts.canManageAll);
const where = conditions.length ? and(...conditions) : undefined;
@@ -98,19 +98,19 @@ export class BookDockRepository {
await this.db.delete(bookDockFiles).where(eq(bookDockFiles.absolutePath, path));
}
async findAllIds(excludedIds?: number[], status?: string, search?: string, userId?: number, isSuperuser?: boolean): Promise<number[]> {
const conditions = this.buildSelectionConditions(status, search, userId, isSuperuser ?? true);
async findAllIds(excludedIds?: number[], status?: string, search?: string, userId?: number, canManageAll?: boolean): Promise<number[]> {
const conditions = this.buildSelectionConditions(status, search, userId, canManageAll ?? true);
if (excludedIds?.length) conditions.push(notInArray(bookDockFiles.id, excludedIds));
const where = conditions.length ? and(...conditions) : undefined;
const rows = await this.db.select({ id: bookDockFiles.id }).from(bookDockFiles).where(where);
return rows.map((r) => r.id);
}
async findByIds(ids: number[], userId?: number, isSuperuser?: boolean): Promise<BookDockFileRow[]> {
async findByIds(ids: number[], userId?: number, canManageAll?: boolean): Promise<BookDockFileRow[]> {
if (ids.length === 0) return [];
const conditions: SQL[] = [inArray(bookDockFiles.id, ids)];
if (userId !== undefined && !isSuperuser) {
conditions.push(or(eq(bookDockFiles.uploadedBy, userId), isNull(bookDockFiles.uploadedBy))!);
if (userId !== undefined && !canManageAll) {
conditions.push(eq(bookDockFiles.uploadedBy, userId));
}
return this.db
.select()
@@ -134,7 +134,7 @@ export class BookDockRepository {
}
async findSelectionBatch(options: SelectionBatchOptions): Promise<BookDockFileRow[]> {
const conditions = this.buildSelectionConditions(options.status, options.search, options.userId, options.isSuperuser);
const conditions = this.buildSelectionConditions(options.status, options.search, options.userId, options.canManageAll);
if (options.excludedIds?.length) conditions.push(notInArray(bookDockFiles.id, options.excludedIds));
if (options.afterId !== undefined) conditions.push(gt(bookDockFiles.id, options.afterId));
const where = conditions.length ? and(...conditions) : undefined;
@@ -151,8 +151,8 @@ export class BookDockRepository {
return updated.length;
}
async countsByStatus(userId?: number, isSuperuser?: boolean): Promise<{ pending: number; ready: number; error: number; total: number }> {
const visibilityCondition = userId !== undefined ? this.buildVisibilityCondition(userId, isSuperuser ?? true) : undefined;
async countsByStatus(userId?: number, canManageAll?: boolean): Promise<{ pending: number; ready: number; error: number; total: number }> {
const visibilityCondition = userId !== undefined ? this.buildVisibilityCondition(userId, canManageAll ?? true) : undefined;
const rows = await this.db
.select({
status: bookDockFiles.status,
@@ -175,12 +175,12 @@ export class BookDockRepository {
async getStatistics(
userId?: number,
isSuperuser?: boolean,
canManageAll?: boolean,
): Promise<{
totalSizeBytes: number;
byFormat: { format: string; count: number; sizeBytes: number }[];
}> {
const visibilityCondition = userId !== undefined ? this.buildVisibilityCondition(userId, isSuperuser ?? true) : undefined;
const visibilityCondition = userId !== undefined ? this.buildVisibilityCondition(userId, canManageAll ?? true) : undefined;
const rows = await this.db
.select({
format: bookDockFiles.format,
@@ -201,12 +201,12 @@ export class BookDockRepository {
return { totalSizeBytes, byFormat };
}
private buildVisibilityCondition(userId: number, isSuperuser: boolean): SQL | undefined {
if (isSuperuser) return undefined;
return or(eq(bookDockFiles.uploadedBy, userId), isNull(bookDockFiles.uploadedBy));
private buildVisibilityCondition(userId: number, canManageAll: boolean): SQL | undefined {
if (canManageAll) return undefined;
return eq(bookDockFiles.uploadedBy, userId);
}
private buildSelectionConditions(status?: string, search?: string, userId?: number, isSuperuser?: boolean): SQL[] {
private buildSelectionConditions(status?: string, search?: string, userId?: number, canManageAll?: boolean): SQL[] {
const conditions: SQL[] = [];
if (status === 'pending') {
conditions.push(inArray(bookDockFiles.status, ['pending', 'extracting', 'fetching']));
@@ -214,8 +214,8 @@ export class BookDockRepository {
conditions.push(eq(bookDockFiles.status, status));
}
if (search) conditions.push(accentInsensitiveIlike(bookDockFiles.fileName, `%${search}%`));
if (userId !== undefined && !isSuperuser) {
conditions.push(or(eq(bookDockFiles.uploadedBy, userId), isNull(bookDockFiles.uploadedBy))!);
if (userId !== undefined && !canManageAll) {
conditions.push(eq(bookDockFiles.uploadedBy, userId));
}
return conditions;
}
@@ -1,4 +1,4 @@
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Logger, NotFoundException } from '@nestjs/common';
import { BookDockService } from './book-dock.service';
@@ -26,6 +26,7 @@ function row(overrides?: Record<string, unknown>) {
metadataEditedAt: null,
absolutePath: '/bucket/book.epub',
coverPath: null,
uploadedBy: 1,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...overrides,
@@ -66,7 +67,7 @@ function makeService() {
resume: vi.fn().mockResolvedValue(undefined),
};
const gateway = {
emitSummary: vi.fn(),
emitChanged: vi.fn(),
};
const service = new BookDockService(
db as never,
@@ -101,7 +102,7 @@ describe('BookDockService', () => {
sort: 'createdAt',
order: 'desc',
userId: 1,
isSuperuser: false,
canManageAll: false,
});
expect(result.total).toBe(1);
@@ -141,7 +142,7 @@ describe('BookDockService', () => {
total: 1,
});
const result = await service.listFiles({ page: 1, limit: 20, sort: 'createdAt', order: 'desc', userId: 1, isSuperuser: false });
const result = await service.listFiles({ page: 1, limit: 20, sort: 'createdAt', order: 'desc', userId: 1, canManageAll: false });
const normalizedMetadata = {
title: 'Fetched',
@@ -157,7 +158,18 @@ describe('BookDockService', () => {
const { service, repo } = makeService();
repo.findById.mockResolvedValue(undefined);
await expect(service.getFile(123)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.getFile(123, 1, false)).rejects.toBeInstanceOf(NotFoundException);
});
it('enforces ownership for individual file reads and allows Book Dock managers to bypass it', async () => {
const { service, repo } = makeService();
repo.findById.mockResolvedValue(row({ uploadedBy: 2 }));
await expect(service.getFile(1, 1, false)).rejects.toBeInstanceOf(ForbiddenException);
await expect(service.getFile(1, 1, true)).resolves.toEqual(expect.objectContaining({ id: 1 }));
repo.findById.mockResolvedValue(row({ uploadedBy: null }));
await expect(service.getFile(1, 1, false)).rejects.toBeInstanceOf(ForbiddenException);
});
it('updateFile validates destination and stamps metadataEditedAt for selected metadata updates', async () => {
@@ -166,11 +178,16 @@ describe('BookDockService', () => {
repo.update.mockResolvedValue(row({ selectedMetadata: { title: 'Edited' } }));
const assertValidTarget = vi.spyOn(service as any, 'assertValidTarget').mockResolvedValue(undefined);
await service.updateFile(1, {
selectedMetadata: { title: 'Edited' },
targetLibraryId: 5,
targetFolderId: 9,
});
await service.updateFile(
1,
{
selectedMetadata: { title: 'Edited' },
targetLibraryId: 5,
targetFolderId: 9,
},
1,
false,
);
expect(assertValidTarget).toHaveBeenCalledWith(5, 9);
expect(repo.update).toHaveBeenCalledWith(
@@ -187,7 +204,7 @@ describe('BookDockService', () => {
repo.findById.mockResolvedValue(row());
repo.update.mockResolvedValue(undefined);
await expect(service.updateFile(1, {})).rejects.toBeInstanceOf(NotFoundException);
await expect(service.updateFile(1, {}, 1, false)).rejects.toBeInstanceOf(NotFoundException);
});
it('bulkApplyFetched counts applied, skipped, and skipped-edited records', async () => {
@@ -289,7 +306,7 @@ describe('BookDockService', () => {
.mockResolvedValueOnce([row({ id: 2, absolutePath: '/bucket/2.epub', coverPath: '/covers/2.png' })])
.mockResolvedValueOnce([]);
await service.discardFile(1);
await service.discardFile(1, 1, false);
await service.bulkDiscard([], true);
expect(vi.mocked(unlink)).toHaveBeenCalledWith('/bucket/book.epub');
@@ -318,7 +335,7 @@ describe('BookDockService', () => {
expect(processingState.pause).toHaveBeenCalledTimes(1);
expect(ingestService.pauseProcessing).toHaveBeenCalledTimes(1);
expect(finalizeService.pauseProcessing).toHaveBeenCalledTimes(1);
expect(gateway.emitSummary).toHaveBeenCalledWith({ pending: 4, ready: 5, error: 0, total: 9, paused: true });
expect(gateway.emitChanged).toHaveBeenCalledTimes(1);
});
it('resumeProcessing resumes queues, broadcasts summary, and starts recovery once', async () => {
@@ -337,6 +354,6 @@ describe('BookDockService', () => {
expect(ingestService.requeueProcessableFiles).toHaveBeenCalledTimes(1);
expect(finalizeService.requeueAutoFinalizeCandidates).toHaveBeenCalledTimes(1);
expect(watcherService.rescan).toHaveBeenCalledTimes(1);
expect(gateway.emitSummary).toHaveBeenCalledWith({ pending: 2, ready: 3, error: 1, total: 6, paused: false });
expect(gateway.emitChanged).toHaveBeenCalledTimes(1);
});
});
@@ -1,4 +1,4 @@
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { unlink } from 'fs/promises';
import { eq, inArray } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
@@ -45,18 +45,24 @@ export class BookDockService {
};
}
async getFile(id: number): Promise<BookDockFile> {
const row = await this.repo.findById(id);
if (!row) throw new NotFoundException('Book Dock file not found');
async getFile(id: number, userId: number, canManageAll: boolean): Promise<BookDockFile> {
const row = await this.findFileForUser(id, userId, canManageAll);
return toDto(row);
}
async getCoverPath(id: number, userId: number, canManageAll: boolean): Promise<string> {
const row = await this.findFileForUser(id, userId, canManageAll);
if (!row.coverPath) throw new NotFoundException('No cover available');
return row.coverPath;
}
async updateFile(
id: number,
data: { selectedMetadata?: Partial<BookDockMetadata>; targetLibraryId?: number | null; targetFolderId?: number | null },
userId: number,
canManageAll: boolean,
): Promise<BookDockFile> {
const row = await this.repo.findById(id);
if (!row) throw new NotFoundException('Book Dock file not found');
await this.findFileForUser(id, userId, canManageAll);
if (data.targetLibraryId !== undefined || data.targetFolderId !== undefined) {
await this.assertValidTarget(data.targetLibraryId, data.targetFolderId);
@@ -71,9 +77,8 @@ export class BookDockService {
return toDto(updated);
}
async discardFile(id: number): Promise<void> {
const row = await this.repo.findById(id);
if (!row) throw new NotFoundException('Book Dock file not found');
async discardFile(id: number, userId: number, canManageAll: boolean): Promise<void> {
const row = await this.findFileForUser(id, userId, canManageAll);
await this.cleanupFiles(row);
await this.repo.deleteById(id);
@@ -86,7 +91,7 @@ export class BookDockService {
status?: string,
search?: string,
userId?: number,
isSuperuser?: boolean,
canManageAll?: boolean,
): Promise<void> {
await this.processSelectionRows(
{
@@ -96,7 +101,7 @@ export class BookDockService {
status,
search,
userId,
isSuperuser,
canManageAll,
},
async (rows) => {
for (const row of rows) {
@@ -117,7 +122,7 @@ export class BookDockService {
status?: string,
search?: string,
userId?: number,
isSuperuser?: boolean,
canManageAll?: boolean,
): Promise<{ total: number; updated: number; failed: number }> {
let updated = 0;
let failed = 0;
@@ -129,7 +134,7 @@ export class BookDockService {
status,
search,
userId,
isSuperuser,
canManageAll,
},
async (rows) => {
for (const row of rows) {
@@ -167,7 +172,7 @@ export class BookDockService {
status?: string,
search?: string,
userId?: number,
isSuperuser?: boolean,
canManageAll?: boolean,
): Promise<{ total: number; applied: number; skipped: number; skippedEdited: number }> {
let applied = 0;
let skipped = 0;
@@ -180,7 +185,7 @@ export class BookDockService {
status,
search,
userId,
isSuperuser,
canManageAll,
},
async (rows) => {
for (const row of rows) {
@@ -211,7 +216,7 @@ export class BookDockService {
status?: string,
search?: string,
userId?: number,
isSuperuser?: boolean,
canManageAll?: boolean,
): Promise<{ total: number; queued: number }> {
let queued = 0;
const total = await this.processSelectionRows(
@@ -222,7 +227,7 @@ export class BookDockService {
status,
search,
userId,
isSuperuser,
canManageAll,
},
(rows) => {
const errorRows = rows.filter((row) => row.status === 'error');
@@ -245,7 +250,7 @@ export class BookDockService {
status?: string,
search?: string,
userId?: number,
isSuperuser?: boolean,
canManageAll?: boolean,
): Promise<{ total: number; updated: number; failed: number }> {
await this.assertValidTarget(targetLibraryId, targetFolderId);
let updated = 0;
@@ -257,7 +262,7 @@ export class BookDockService {
status,
search,
userId,
isSuperuser,
canManageAll,
},
async (rows) => {
updated += await this.repo.setTargetsByIds(
@@ -278,7 +283,7 @@ export class BookDockService {
status?: string,
search?: string,
userId?: number,
isSuperuser?: boolean,
canManageAll?: boolean,
): Promise<{ total: number; withDestination: number; withoutDestination: number }> {
const destinationPairCounts = new Map<string, number>();
const folderIdSet = new Set<number>();
@@ -290,7 +295,7 @@ export class BookDockService {
status,
search,
userId,
isSuperuser,
canManageAll,
},
(rows) => {
for (const row of rows) {
@@ -324,13 +329,13 @@ export class BookDockService {
return { total, withDestination, withoutDestination: total - withDestination };
}
async getSummary(userId?: number, isSuperuser?: boolean): Promise<BookDockSummary> {
const [summary, paused] = await Promise.all([this.repo.countsByStatus(userId, isSuperuser), this.processingState.isPaused()]);
async getSummary(userId?: number, canManageAll?: boolean): Promise<BookDockSummary> {
const [summary, paused] = await Promise.all([this.repo.countsByStatus(userId, canManageAll), this.processingState.isPaused()]);
return { ...summary, paused };
}
async getStatistics(userId?: number, isSuperuser?: boolean) {
return this.repo.getStatistics(userId, isSuperuser);
async getStatistics(userId?: number, canManageAll?: boolean) {
return this.repo.getStatistics(userId, canManageAll);
}
async pauseProcessing(): Promise<BookDockSummary> {
@@ -340,7 +345,7 @@ export class BookDockService {
await this.processingState.pause();
this.ingestService.pauseProcessing();
this.finalizeService.pauseProcessing();
const summary = await this.emitSummary();
const summary = await this.emitChange();
this.logger.log(`[book_dock.processing_pause] [end] durationMs=${Date.now() - startedAt} - Book Dock processing paused`);
return summary;
} catch (error) {
@@ -356,7 +361,7 @@ export class BookDockService {
await this.processingState.resume();
await Promise.all([this.ingestService.resumeProcessing(), this.finalizeService.resumeProcessing()]);
this.startResumeBackgroundWork();
const summary = await this.emitSummary();
const summary = await this.emitChange();
this.logger.log(`[book_dock.processing_resume] [end] durationMs=${Date.now() - startedAt} - Book Dock processing resumed`);
return summary;
} catch (error) {
@@ -374,6 +379,15 @@ export class BookDockService {
}
}
private async findFileForUser(id: number, userId: number, canManageAll: boolean): Promise<BookDockFileRow> {
const row = await this.repo.findById(id);
if (!row) throw new NotFoundException('Book Dock file not found');
if (!canManageAll && row.uploadedBy !== userId) {
throw new ForbiddenException('You do not have access to this Book Dock file');
}
return row;
}
private startResumeBackgroundWork(): void {
if (this.resumeWorkPromise) return;
this.resumeWorkPromise = this.resumeBackgroundWork().finally(() => {
@@ -396,9 +410,9 @@ export class BookDockService {
}
}
private async emitSummary(): Promise<BookDockSummary> {
private async emitChange(): Promise<BookDockSummary> {
const summary = await this.getSummary();
this.gateway.emitSummary(summary);
this.gateway.emitChanged();
return summary;
}
@@ -447,13 +461,13 @@ export class BookDockService {
status?: string;
search?: string;
userId?: number;
isSuperuser?: boolean;
canManageAll?: boolean;
},
processBatch: (rows: BookDockFileRow[]) => Promise<void> | void,
): Promise<number> {
let total = 0;
const userId = options.userId ?? 0;
const isSuperuser = options.isSuperuser ?? true;
const canManageAll = options.canManageAll ?? true;
if (options.selectAll) {
let afterId: number | undefined;
@@ -465,7 +479,7 @@ export class BookDockService {
status: options.status,
search: options.search,
userId,
isSuperuser,
canManageAll,
});
if (rows.length === 0) break;
await processBatch(rows);
@@ -478,7 +492,7 @@ export class BookDockService {
const ids = dedupeIds(options.fileIds);
for (let index = 0; index < ids.length; index += BULK_SELECTION_BATCH_SIZE) {
const batchIds = ids.slice(index, index + BULK_SELECTION_BATCH_SIZE);
const rows = await this.repo.findByIds(batchIds);
const rows = await this.repo.findByIds(batchIds, userId, canManageAll);
if (rows.length === 0) continue;
await processBatch(rows);
total += rows.length;
+29 -1
View File
@@ -207,7 +207,7 @@ describe('Authorization matrix (e2e)', () => {
koboActive: await createUserAndLogin(ctx, { permissions: [Permission.KoboSync] }),
koboDisabled: await createUserAndLogin(ctx, { permissions: [Permission.KoboSync] }),
koboRevoked: await createUserAndLogin(ctx, { permissions: [Permission.KoboSync] }),
bookDockUser: await createUserAndLogin(ctx, { permissions: [Permission.BookDockAccess] }),
bookDockUser: await createUserAndLogin(ctx, { permissions: [Permission.BookDockAccess, Permission.LibraryUpload] }),
uploadUser: await createUserAndLogin(ctx, { permissions: [Permission.LibraryUpload] }),
ownerUser: await createUserAndLogin(ctx),
otherUser: await createUserAndLogin(ctx),
@@ -630,6 +630,11 @@ describe('Authorization matrix (e2e)', () => {
path: '/app-settings',
token: 'allPerms',
},
[Permission.ManageBookDock]: {
method: 'POST',
path: '/book-dock/rescan',
token: 'allPerms',
},
[Permission.ManageUsers]: {
method: 'GET',
path: '/users',
@@ -1201,18 +1206,41 @@ describe('Authorization matrix (e2e)', () => {
});
describe('service authz - library scoped data and mixed batch semantics', () => {
it("prevents Book Dock users from reading or mutating another user's entries", async () => {
const foreignRow = await createBookDockRow(ctx, {
fileName: `authz-foreign-book-dock-${randomUUID()}.fb2`,
uploadedBy: personas.otherUser.userId,
});
for (const request of [
{ method: 'GET' as const, payload: undefined },
{ method: 'PATCH' as const, payload: { selectedMetadata: { title: 'Unauthorized edit' } } },
{ method: 'DELETE' as const, payload: undefined },
]) {
const response = await ctx.app.inject({
method: request.method,
url: `/api/v1/book-dock/files/${foreignRow.id}`,
headers: authHeader(personas.bookDockUser.accessToken),
payload: request.payload,
});
expectError(response, 403, 'You do not have access to this Book Dock file');
}
});
it('returns mixed-result finalize envelope for Book Dock authorization failures', async () => {
const accessibleRow = await createBookDockRow(ctx, {
fileName: `authz-finalize-ok-${randomUUID()}.fb2`,
targetLibraryId: libraryA.libraryId,
targetFolderId: libraryA.libraryFolderId,
status: 'ready',
uploadedBy: personas.bookDockUser.userId,
});
const inaccessibleRow = await createBookDockRow(ctx, {
fileName: `authz-finalize-denied-${randomUUID()}.fb2`,
targetLibraryId: libraryB.libraryId,
targetFolderId: libraryB.libraryFolderId,
status: 'ready',
uploadedBy: personas.bookDockUser.userId,
});
const response = await ctx.app.inject({
@@ -774,14 +774,18 @@ describe('Book Dock ingest + finalize (e2e)', () => {
it('enforces Book Dock permission and library access for finalize', async () => {
const noPermissionUser = await createUserAndLogin(context);
const withPermissionUser = await createUserAndLogin(context, {
const accessOnlyUser = await createUserAndLogin(context, {
permissions: [Permission.BookDockAccess],
});
const withPermissionUser = await createUserAndLogin(context, {
permissions: [Permission.BookDockAccess, Permission.LibraryUpload],
});
const destination = await createLibraryWithFolder(context);
const bookDockRow = await createBookDockRow(context, {
fileName: 'permission-check.fb2',
selectedMetadata: { title: 'Permission Check' },
status: 'ready',
uploadedBy: withPermissionUser.userId,
});
const forbiddenSummary = await context.app.inject({
@@ -798,6 +802,19 @@ describe('Book Dock ingest + finalize (e2e)', () => {
});
expect(allowedSummary.statusCode).toBe(200);
const missingUploadPermission = await context.app.inject({
method: 'POST',
url: '/api/v1/book-dock/finalize',
headers: authHeader(accessOnlyUser.accessToken),
payload: {
fileIds: [bookDockRow.id],
defaultLibraryId: destination.libraryId,
defaultFolderId: destination.libraryFolderId,
},
});
expect(missingUploadPermission.statusCode).toBe(403);
expect(missingUploadPermission.json()).toMatchObject({ message: `Missing permission: ${Permission.LibraryUpload}` });
const noAccessFinalize = await context.app.inject({
method: 'POST',
url: '/api/v1/book-dock/finalize',
@@ -82,6 +82,7 @@ export interface CreateBookDockRowInput {
confidence?: number | null;
errorMessage?: string | null;
metadataEditedAt?: Date | null;
uploadedBy?: number | null;
}
export interface CreateReadingSessionInput {
@@ -347,6 +348,7 @@ export async function createBookDockRow(
confidence: input.confidence ?? null,
errorMessage: input.errorMessage ?? null,
metadataEditedAt: input.metadataEditedAt ?? null,
uploadedBy: input.uploadedBy ?? null,
})
.returning();
@@ -5,7 +5,7 @@
"Permission.ManageMetadataConfig": 32,
"Permission.EmailSend": 30,
"Permission.LibraryEditMetadata": 33,
"Permission.BookDockAccess": 17,
"Permission.BookDockAccess": 16,
"Permission.ManageAppSettings": 22,
"Permission.ManageLibraries": 14,
"Permission.ManageUsers": 11,
@@ -19,7 +19,8 @@
"Permission.ViewUserActivity": 6,
"Permission.LibraryDeleteBooks": 1,
"Permission.LibraryDownload": 1,
"Permission.LibraryUpload": 1,
"Permission.LibraryUpload": 2,
"Permission.ManageBookDock": 1,
"Permission.ManageIcons": 1,
"Permission.HardcoverSync": 1,
"Permission.ReadwiseSync": 1,
@@ -173,7 +173,7 @@
"methodName": "finalize",
"httpMethod": "POST",
"path": "/book-dock/finalize",
"permissions": ["Permission.BookDockAccess"],
"permissions": ["Permission.BookDockAccess", "Permission.LibraryUpload"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -185,7 +185,7 @@
"methodName": "rescan",
"httpMethod": "POST",
"path": "/book-dock/rescan",
"permissions": ["Permission.BookDockAccess"],
"permissions": ["Permission.ManageBookDock"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -64,6 +64,7 @@ export interface CreateBookDockRowInput {
confidence?: number | null;
errorMessage?: string | null;
metadataEditedAt?: Date | null;
uploadedBy?: number | null;
}
export interface CreatedLibrary {
@@ -271,6 +272,7 @@ export async function createBookDockRow(
confidence: input.confidence ?? null,
errorMessage: input.errorMessage ?? null,
metadataEditedAt: input.metadataEditedAt ?? null,
uploadedBy: input.uploadedBy ?? null,
})
.returning();