feat(book-requests): link releases to their source page and hold imports for review

Torznab and Newznab read the feed's details page, plugins may send infoUrl, and the
search drops any link that could carry a credential. A grab can ask to be held for
review whatever its score, and the held review shows the file's own edition facts,
its cover and the release page. Torznab guids are now digested like Newznab's.

Refs #1438
This commit is contained in:
neon
2026-10-20 16:42:16 +00:00
parent 3c87418416
commit a728bf293b
44 changed files with 25375 additions and 68 deletions
@@ -0,0 +1,2 @@
ALTER TABLE "book_request_downloads" ADD COLUMN "release_info_url" text;--> statement-breakpoint
ALTER TABLE "book_request_downloads" ADD COLUMN "hold_for_review" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -855,6 +855,13 @@
"when": 1792444043652,
"tag": "0121_add_device_annotation_sync",
"breakpoints": true
},
{
"idx": 122,
"version": "7",
"when": 1792511452628,
"tag": "0122_add_release_info_url_and_review_hold",
"breakpoints": true
}
]
}
+10
View File
@@ -103,6 +103,16 @@ export const bookRequestDownloads = pgTable(
releaseSeeders: integer('release_seeders'),
releaseFormat: varchar('release_format', { length: 20 }),
freeleech: boolean('freeleech').notNull().default(false),
/**
* The release's page on its source, already vetted for credentials, kept so the person
* reviewing what landed can compare it against what the source said it was.
*/
releaseInfoUrl: text('release_info_url'),
/**
* Whoever sent this release asked to look at the import before it is filed, so it waits in
* review whatever its verification score.
*/
holdForReview: boolean('hold_for_review').notNull().default(false),
/**
* Null for an attempt that was refused before anything was handed over: a tracker that would
@@ -11,13 +11,17 @@ import {
ParseIntPipe,
Post,
Query,
Res,
} from '@nestjs/common';
import { RouteConfig } from '@nestjs/platform-fastify';
import type { FastifyReply } from 'fastify';
import { createReadStream } from 'fs';
import { AuditAction, AuditResource, Permission } from '@bookorbit/types';
import { Auditable } from '../../common/decorators/auditable.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { imageContentTypeFromPath } from '../../common/image-content-type';
import type { RequestUser } from '../../common/types/request-user';
import { BookRequestService } from './book-request.service';
import { DownloadClientConfigService } from './download-clients/download-client-config.service';
@@ -196,6 +200,14 @@ export class BookRequestAdminController {
return this.verification.getReview(id);
}
@Get(':id/review/cover')
async getReviewCover(@Param('id', ParseIntPipe) id: number, @Res() reply: FastifyReply) {
const coverPath = await this.verification.getReviewCoverPath(id);
reply.header('Content-Type', imageContentTypeFromPath(coverPath));
reply.header('Cache-Control', 'private, no-cache');
return reply.send(createReadStream(coverPath));
}
/** The approver disagrees with the score. Files the held import exactly as a pass would have. */
@Post(':id/force-file')
@HttpCode(HttpStatus.OK)
@@ -1,10 +1,13 @@
import { Body, Controller, DefaultValuePipe, Get, HttpCode, HttpStatus, Param, ParseBoolPipe, ParseIntPipe, Post, Query } from '@nestjs/common';
import { Body, Controller, DefaultValuePipe, Get, HttpCode, HttpStatus, Param, ParseBoolPipe, ParseIntPipe, Post, Query, Res } from '@nestjs/common';
import { RouteConfig } from '@nestjs/platform-fastify';
import type { FastifyReply } from 'fastify';
import { createReadStream } from 'fs';
import { AuditAction, AuditResource, Permission } from '@bookorbit/types';
import { Auditable } from '../../common/decorators/auditable.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { imageContentTypeFromPath } from '../../common/image-content-type';
import type { RequestUser } from '../../common/types/request-user';
import { BookRequestService } from './book-request.service';
import { DownloadClientConfigService } from './download-clients/download-client-config.service';
@@ -109,6 +112,15 @@ export class BookRequestSelfFulfilController {
return this.verification.getReview(id);
}
@Get(':id/review/cover')
async getReviewCover(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser, @Res() reply: FastifyReply) {
await this.service.assertCanFulfil(id, user);
const coverPath = await this.verification.getReviewCoverPath(id);
reply.header('Content-Type', imageContentTypeFromPath(coverPath));
reply.header('Cache-Control', 'private, no-cache');
return reply.send(createReadStream(coverPath));
}
/**
* Overriding the verifier on a release you picked deliberately. Without this the feature would
* dead-end exactly where auto-approval does: a below-threshold score on your own choice is a
@@ -1,5 +1,5 @@
import { Type } from 'class-transformer';
import { IsBase64, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
import { IsBase64, IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
import { MAX_INDEXER_RELEASE_GUID_LENGTH } from '@bookorbit/types';
/** The longest base64 `.torrent` the grab takes. The 2 MiB the service accepts is about 2.7 Mi characters. */
@@ -55,4 +55,12 @@ export class GrabBookRequestDto {
@IsInt()
@Min(1)
downloadClientId?: number;
/**
* Wait in review once the download lands, whatever the verification score. Advertised as the
* `book-request-review-hold` server feature, because a server older than the field rejects it.
*/
@IsOptional()
@IsBoolean()
holdForReview?: boolean;
}
@@ -15,3 +15,15 @@ describe('book request release guid DTOs', () => {
await expect(validate(dto)).resolves.toEqual([]);
});
});
describe('GrabBookRequestDto.holdForReview', () => {
it('is optional, so a client that has never heard of it grabs exactly as before', async () => {
await expect(validate(Object.assign(new GrabBookRequestDto(), { indexerId: 1, releaseGuid: 'g' }))).resolves.toEqual([]);
});
it('takes a boolean and nothing else', async () => {
await expect(validate(Object.assign(new GrabBookRequestDto(), { indexerId: 1, releaseGuid: 'g', holdForReview: true }))).resolves.toEqual([]);
const errors = await validate(Object.assign(new GrabBookRequestDto(), { indexerId: 1, releaseGuid: 'g', holdForReview: 'yes' }));
expect(errors.map((error) => error.property)).toEqual(['holdForReview']);
});
});
@@ -45,3 +45,31 @@ describe('BookRequestDownloadRepository.findLatestForRequests', () => {
expect(queries).toHaveLength(0);
});
});
describe('BookRequestDownloadRepository.findTriedReleaseKeys', () => {
function repositoryReturning(rows: Array<{ indexerId: number | null; releaseGuid: string | null }>) {
const db = { select: () => ({ from: () => ({ where: () => Promise.resolve(rows) }) }) };
return new BookRequestDownloadRepository(db as never);
}
/**
* Torznab guids are digested now, and an attempt made before that stored the raw link. Without
* the digested key alongside it, the next automated retry would pick the release that failed.
*/
it('also matches a raw URL guid stored before guids were digested', async () => {
const keys = await repositoryReturning([{ indexerId: 3, releaseGuid: 'https://tracker.example.com/details/1' }]).findTriedReleaseKeys(9);
expect(keys.has('3:https://tracker.example.com/details/1')).toBe(true);
expect(keys.size).toBe(2);
expect([...keys].some((key) => /^3:[a-f0-9]{64}$/.test(key))).toBe(true);
});
it('adds nothing for an opaque guid or a hand-pasted magnet', async () => {
const keys = await repositoryReturning([
{ indexerId: 3, releaseGuid: '12345' },
{ indexerId: null, releaseGuid: null },
]).findTriedReleaseKeys(9);
expect([...keys]).toEqual(['3:12345']);
});
});
@@ -20,6 +20,7 @@ import {
type BookRequestDownloadRow,
type NewBookRequestDownloadRow,
} from '../../../db/schema';
import { publicReleaseGuid } from '../indexers/release-guid';
/** Newest first, and bounded: an attempt list is something a person reads, not a full history. */
const MAX_ATTEMPTS_PER_REQUEST = 20;
@@ -345,6 +346,10 @@ export class BookRequestDownloadRepository {
// A hand-pasted magnet has neither, and there is nothing about it to match a release on.
if (row.indexerId === null || row.releaseGuid === null) continue;
keys.add(`${row.indexerId}:${row.releaseGuid}`);
// Torznab attempts made before its guids were digested stored the raw value, which a search
// now reports digested. Without the second key a retry would pick the release that just failed.
const digested = publicReleaseGuid(row.indexerId, row.releaseGuid);
if (digested !== row.releaseGuid) keys.add(`${row.indexerId}:${digested}`);
}
return keys;
}
@@ -374,6 +374,30 @@ describe('RequestFulfillmentService.grab from a picked release', () => {
);
});
it('keeps the release page and the request to review it on the attempt', async () => {
const { service, downloads } = makeService({
releases: { find: vi.fn().mockReturnValue({ ...RELEASE, infoUrl: 'https://tracker.example.com/details/1' }) },
indexerAdapter: { fetchTorrentFile: vi.fn().mockResolvedValue(torrentBytes()) },
});
await service.grab(7, { indexerId: 9, releaseGuid: 'r-1', holdForReview: true }, user());
expect(downloads.create).toHaveBeenCalledWith(
expect.objectContaining({ releaseInfoUrl: 'https://tracker.example.com/details/1', holdForReview: true }),
);
});
it('files a grab on its score unless the sender asked to review it', async () => {
const { service, downloads } = makeService({
releases: { find: vi.fn().mockReturnValue(RELEASE) },
indexerAdapter: { fetchTorrentFile: vi.fn().mockResolvedValue(torrentBytes()) },
});
await service.grab(7, { indexerId: 9, releaseGuid: 'r-1' }, user());
expect(downloads.create).toHaveBeenCalledWith(expect.objectContaining({ releaseInfoUrl: null, holdForReview: false }));
});
it('hands a magnet redirect from a Torznab download endpoint to the torrent client', async () => {
const { service, downloads, adapter } = makeService({
releases: { find: vi.fn().mockReturnValue(RELEASE) },
@@ -265,6 +265,8 @@ export class RequestFulfillmentService {
automated: user === null,
releaseTitle: grab.releaseTitle,
releaseGuid: grab.releaseGuid ?? null,
releaseInfoUrl: grab.releaseInfoUrl ?? null,
holdForReview: dto.holdForReview === true,
releaseSizeBytes: grab.releaseSizeBytes,
releaseSeeders: grab.releaseSeeders ?? null,
releaseFormat: grab.releaseFormat ?? null,
@@ -385,6 +387,7 @@ export class RequestFulfillmentService {
automated: user === null,
releaseTitle: release.title.slice(0, 500),
releaseGuid,
releaseInfoUrl: release.infoUrl ?? null,
releaseSizeBytes: release.sizeBytes,
releaseSeeders: release.seeders,
releaseFormat: resolveFormat(release)?.slice(0, 20) ?? null,
@@ -643,6 +646,7 @@ export class RequestFulfillmentService {
indexerAdapterType: indexer.adapterType,
releaseGuid: release.guid,
releaseTitle: release.title.slice(0, 500),
releaseInfoUrl: release.infoUrl ?? null,
releaseSizeBytes: release.sizeBytes,
releaseSeeders: release.seeders,
// The resolved format, not the raw field: most indexers state none and it is read off the
@@ -959,6 +963,8 @@ interface ResolvedGrab extends ParsedGrab {
indexerName?: string;
indexerAdapterType?: string;
releaseGuid?: string;
/** Vetted by the search that produced the release, so it is safe to keep and show. */
releaseInfoUrl?: string | null;
releaseSeeders?: number | null;
releaseFormat?: string | null;
freeleech?: boolean;
@@ -1,3 +1,4 @@
import { NotFoundException } from '@nestjs/common';
import { NotificationType, type BookDockMetadata } from '@bookorbit/types';
import type { BookRequestDownloadRow } from '../../../db/schema';
@@ -122,7 +123,7 @@ describe('verifyAgainstRequest with an operator-set threshold', () => {
* handler: one file, one score, two operator settings, two outcomes.
*/
describe('RequestVerificationService against the configured threshold', () => {
function makeService(threshold: number, verificationEnabled = true, overrides: { requestSettled?: boolean } = {}) {
function makeService(threshold: number, verificationEnabled = true, overrides: { requestSettled?: boolean; holdForReview?: boolean } = {}) {
const events = { on: vi.fn() };
const dockRepo = {
findById: vi.fn().mockResolvedValue({ id: 100, fetchedMetadata: metadata({ authors: ['F. Herbert'] }), embeddedMetadata: null }),
@@ -130,7 +131,13 @@ describe('RequestVerificationService against the configured threshold', () => {
const dock = { discardFile: vi.fn().mockResolvedValue(undefined) };
const finalizeService = { finalizeManagedFile: vi.fn().mockResolvedValue({ success: true, bookId: 55 }) };
const downloads = {
findByBookDockFileId: vi.fn().mockResolvedValue({ id: 11, requestId: 7, status: 'importing', bookDockFileId: 100 } as BookRequestDownloadRow),
findByBookDockFileId: vi.fn().mockResolvedValue({
id: 11,
requestId: 7,
status: 'importing',
bookDockFileId: 100,
holdForReview: overrides.holdForReview ?? false,
} as BookRequestDownloadRow),
update: vi.fn().mockResolvedValue(undefined),
updateIf: vi.fn().mockImplementation((id: number, _expected: unknown, patch: Record<string, unknown>) => Promise.resolve({ id, ...patch })),
};
@@ -237,6 +244,41 @@ describe('RequestVerificationService against the configured threshold', () => {
);
expect(fulfillment.holdForReview).not.toHaveBeenCalled();
});
it('holds a passing import when the person who sent it asked to look first', async () => {
const { ingest, finalizeService, fulfillment } = makeService(70, true, { holdForReview: true });
await ingest();
expect(finalizeService.finalizeManagedFile).not.toHaveBeenCalled();
expect(fulfillment.holdForReview).toHaveBeenCalledWith(
expect.objectContaining({ id: 11 }),
expect.objectContaining({ id: 7 }),
expect.stringContaining('reviewed'),
);
});
it('holds an import the sender asked to review even with checking switched off', async () => {
const { ingest, finalizeService, fulfillment } = makeService(70, false, { holdForReview: true });
await ingest();
expect(finalizeService.finalizeManagedFile).not.toHaveBeenCalled();
expect(fulfillment.holdForReview).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ id: 7 }),
expect.stringContaining('reviewed'),
);
});
it('holds a failing import for its score rather than for the request to look', async () => {
const { ingest, fulfillment } = makeService(95, true, { holdForReview: true });
await ingest();
expect(fulfillment.holdForReview).toHaveBeenCalledTimes(1);
expect(fulfillment.holdForReview).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ id: 7 }), expect.stringContaining('scored'));
});
});
/**
@@ -477,6 +519,77 @@ describe('RequestVerificationService review surface', () => {
expect(review).toMatchObject({ bookDockFileId: null, verification: null, files: [], canFile: false });
});
it('says whether the sender asked for the review, and links the release it came from', async () => {
const { service } = makeService({ downloadRow: { holdForReview: true, releaseInfoUrl: 'https://tracker.example.com/details/1' } });
expect(await service.getReview(7)).toMatchObject({ heldOnRequest: true, releaseInfoUrl: 'https://tracker.example.com/details/1' });
});
it('keeps the release link once the dock entry is gone, since the source still describes it', async () => {
const { service } = makeService({
requestRow: { bookDockFileId: null },
downloadRow: { bookDockFileId: null, releaseInfoUrl: 'https://tracker.example.com/details/1' },
});
expect(await service.getReview(7)).toMatchObject({
bookDockFileId: null,
releaseInfoUrl: 'https://tracker.example.com/details/1',
edition: null,
hasCover: false,
});
});
/** A provider match is itself a guess at the edition, which is what these facts exist to check. */
it("states the edition from the file's own metadata, never from the provider match", async () => {
const { service } = makeService({
dockRow: {
id: 100,
fileName: 'dune.m4b',
fileSize: 1024,
format: 'm4b',
unitDirectory: null,
coverPath: '/data/dock/covers/100.jpg',
fetchedMetadata: metadata({ publisher: 'Provider Press', pageCount: 900 }),
embeddedMetadata: {
publisher: ' Macmillan Audio ',
publishedYear: 2007,
language: 'en',
narrators: ['Scott Brick', ' '],
durationSeconds: 75_600,
abridged: false,
chapters: [{ title: 'One' }, { title: 'Two' }],
},
},
});
const review = await service.getReview(7);
expect(review.edition).toEqual({
publisher: 'Macmillan Audio',
publishedDate: '2007',
language: 'en',
pageCount: null,
isbn10: null,
narrators: ['Scott Brick'],
durationSeconds: 75_600,
abridged: false,
chapterCount: 2,
});
expect(review.hasCover).toBe(true);
});
it('reports no edition for a file that states none of it', async () => {
const { service } = makeService();
expect(await service.getReview(7)).toMatchObject({ edition: null, hasCover: false, heldOnRequest: false, releaseInfoUrl: null });
});
it('answers no cover for a held import without one', async () => {
const { service } = makeService();
await expect(service.getReviewCoverPath(7)).rejects.toBeInstanceOf(NotFoundException);
});
it('finds the entry through the attempt when only the request lost its pointer', async () => {
const { service } = makeService({ requestRow: { bookDockFileId: null } });
expect((await service.getReview(7)).bookDockFileId).toBe(100);
@@ -7,12 +7,14 @@ import {
NotFoundException,
OnModuleInit,
} from '@nestjs/common';
import { access } from 'fs/promises';
import { basename } from 'path';
import { DEFAULT_BOOK_REQUEST_AUTOMATION_SETTINGS, NotificationType, UNSETTLED_BOOK_REQUEST_DOWNLOAD_STATUSES } from '@bookorbit/types';
import type {
BookDockMetadata,
BookRequestStatus,
BookRequestReview,
BookRequestReviewEdition,
BookRequestReviewFile,
BookRequestVerificationReason,
BookRequestVerificationRow,
@@ -47,6 +49,9 @@ import { RequestFulfillmentService } from './request-fulfillment.service';
*/
const DEFAULT_VERIFICATION_THRESHOLD = DEFAULT_BOOK_REQUEST_AUTOMATION_SETTINGS.verificationThreshold;
/** Prose for the stored reason and the notification. The drawer reads `heldOnRequest` instead. */
const HELD_ON_REQUEST_REASON = 'the release was sent to be reviewed before it is filed';
const TITLE_WEIGHT = 0.7;
const AUTHOR_WEIGHT = 0.3;
@@ -123,8 +128,13 @@ export class RequestVerificationService implements OnModuleInit {
const { verificationEnabled, verificationThreshold } = await this.settings.get();
// Off means the operator has chosen to trust the grab: the import goes straight into the
// target library without waiting on a score, and nothing is ever held for a human to look at.
// target library without waiting on a score, and nothing is held for a human to look at
// unless the person who sent the release asked to look at it.
if (!verificationEnabled) {
if (download.holdForReview) {
await this.holdOnRequest(download, joined.request, fileId);
return;
}
this.logger.log(
`[book_request.verify] [end] requestId=${joined.request.id} dockFileId=${fileId} enabled=false - verification is off, filing the import as grabbed`,
);
@@ -141,10 +151,24 @@ export class RequestVerificationService implements OnModuleInit {
await this.holdForReview(download, joined.request, outcome);
return;
}
// After the score rather than instead of it: a release that also failed verification is held
// for that reason, which says more than "you asked".
if (download.holdForReview) {
await this.holdOnRequest(download, joined.request, fileId);
return;
}
await this.finalize(download, joined.request, fileId);
}
/** The sender's own request to look first outranks a passing score and verification being off. */
private async holdOnRequest(download: BookRequestDownloadRow, request: BookRequestRow, fileId: number): Promise<void> {
this.logger.log(
`[book_request.verify] [end] requestId=${request.id} dockFileId=${fileId} holdForReview=true - held for the review the sender asked for`,
);
await this.fulfillment.holdForReview(download, request, HELD_ON_REQUEST_REASON);
}
private async finalize(download: BookRequestDownloadRow, request: BookRequestRow, fileId: number): Promise<void> {
const filed = await this.fileIntoLibrary(download, request, fileId);
if (!filed.ok) await this.fulfillment.holdForReview(download, request, filed.reason);
@@ -264,15 +288,29 @@ export class RequestVerificationService implements OnModuleInit {
* that sent them there.
*/
async getReview(requestId: number): Promise<BookRequestReview> {
const { request, fileId } = await this.loadHeld(requestId);
const { request, download, fileId } = await this.loadHeld(requestId);
const row = fileId === null ? undefined : await this.dockRepo.findById(fileId);
const canFile = (request.targetFolderId ?? (await this.resolveFolder(request.targetLibraryId))) !== null;
const heldOnRequest = download?.holdForReview ?? false;
const releaseInfoUrl = download?.releaseInfoUrl ?? null;
// Filing or discarding the entry by hand empties both pointers to it, which leaves the request
// held over a file that is not there any more. Saying so is the only useful thing left.
if (!row) {
return { requestId, bookDockFileId: null, verification: null, files: [], totalSizeBytes: null, canFile: false };
return {
requestId,
bookDockFileId: null,
verification: null,
files: [],
totalSizeBytes: null,
canFile: false,
heldOnRequest,
releaseInfoUrl,
edition: null,
hasCover: false,
};
}
const [unitFiles, settings] = await Promise.all([
@@ -310,9 +348,30 @@ export class RequestVerificationService implements OnModuleInit {
files,
totalSizeBytes: sized.length ? sized.reduce((total, file) => total + (file.fileSize ?? 0), 0) : null,
canFile,
heldOnRequest,
releaseInfoUrl,
edition: editionFacts(row.embeddedMetadata),
hasCover: Boolean(row.coverPath),
};
}
/**
* Where the cover found in a held import is on disk. Read through the request rather than the
* Book Dock's own route, because the person reviewing an import need not hold Book Dock
* permissions; the request route has already decided they may look at this one.
*/
async getReviewCoverPath(requestId: number): Promise<string> {
const { fileId } = await this.loadHeld(requestId);
const row = fileId === null ? undefined : await this.dockRepo.findById(fileId);
if (!row?.coverPath) throw new NotFoundException('This import has no cover');
try {
await access(row.coverPath);
} catch {
throw new NotFoundException('This import has no cover');
}
return row.coverPath;
}
/**
* An approver has looked at a held import and decided it is the right book anyway. Runs exactly
* the filing a passing score would have run. The score is not rewritten on the way through: it
@@ -553,6 +612,33 @@ function joinTitle(title: string, subtitle: string | null): string {
* The unit's own directory name, which is what a release names after the book. Only offered for a
* multi-file unit: a loose single file has no directory that means anything.
*/
/** Null when the file states none of it, so the drawer draws no empty section. */
function editionFacts(metadata: BookDockMetadata | null): BookRequestReviewEdition | null {
if (!metadata) return null;
const facts: BookRequestReviewEdition = {
publisher: nonEmpty(metadata.publisher),
publishedDate: nonEmpty(metadata.publishedDate) ?? (isCount(metadata.publishedYear) ? String(metadata.publishedYear) : null),
language: nonEmpty(metadata.language),
pageCount: isCount(metadata.pageCount) ? metadata.pageCount : null,
isbn10: nonEmpty(metadata.isbn10),
narrators: Array.isArray(metadata.narrators) ? metadata.narrators.map((narrator) => nonEmpty(narrator)).filter((name) => name !== null) : [],
durationSeconds: isCount(metadata.durationSeconds) ? metadata.durationSeconds : null,
abridged: typeof metadata.abridged === 'boolean' ? metadata.abridged : null,
chapterCount: Array.isArray(metadata.chapters) && metadata.chapters.length > 0 ? metadata.chapters.length : null,
};
const stated = Object.values(facts).some((value) => (Array.isArray(value) ? value.length > 0 : value !== null));
return stated ? facts : null;
}
function nonEmpty(value: string | null | undefined): string | null {
const trimmed = typeof value === 'string' ? value.trim() : '';
return trimmed === '' ? null : trimmed;
}
function isCount(value: number | null | undefined): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
function unitTitleFallback(row: { unitDirectory: string | null }): string | null {
if (!row.unitDirectory) return null;
const name = basename(row.unitDirectory)
@@ -104,6 +104,24 @@ describe('NewznabAdapter', () => {
});
});
it('reads the details page the feed publishes in comments', async () => {
fetchMock.mockResolvedValue(
new Response(
feed(`<item>
<title>Dune EPUB</title>
<guid isPermaLink="false">release-guid</guid>
<link>https://indexer.example.com/api?t=get&amp;id=release-guid&amp;apikey=secret-key</link>
<comments>https://indexer.example.com/details/release-guid#comments</comments>
</item>`),
),
);
const [release] = await new NewznabAdapter().search(query(), config(), AbortSignal.timeout(1000));
// Raw here: the search service vets it, which is where the anchor and any key are dealt with.
expect(release?.infoUrl).toBe('https://indexer.example.com/details/release-guid#comments');
});
it('falls back to title and author for a search without an ISBN', async () => {
fetchMock.mockResolvedValue(new Response(feed('')));
@@ -8,6 +8,7 @@ import { readBoundedBytes, ResponseTooLargeError } from '../../../../common/util
import { sanitizeLogValue } from '../../../../common/utils/log-sanitize.utils';
import { safeFetch } from '../../../../common/utils/safe-fetch';
import { ensureSafeUrl, PrivateAddressException } from '../../../../common/utils/ssrf.utils';
import { publicReleaseGuid } from '../release-guid';
import { buildSearchText } from '../search-text';
import {
IndexerSearchException,
@@ -41,6 +42,8 @@ interface NewznabItem {
title?: string;
guid?: unknown;
link?: string;
/** The release's details page, which the Newznab spec points at its comment thread. */
comments?: unknown;
size?: string | number;
pubDate?: string;
category?: string | string[];
@@ -295,6 +298,7 @@ function parseItems(xml: string, indexerId: number): ReleaseCandidate[] {
const sourceGuid = readGuid(item);
if (!withinBound(sourceGuid) || !withinBound(link) || !link || !/^https?:/i.test(link)) return [];
const guid = publicReleaseGuid(indexerId, sourceGuid ?? link);
const infoUrl = readText(item.comments);
const publishedAt = parseDate(item.pubDate);
const fileCount = toNumber(attrs.get('files'));
const format = attrs.get('format');
@@ -303,6 +307,7 @@ function parseItems(xml: string, indexerId: number): ReleaseCandidate[] {
indexerId,
guid,
title,
...(infoUrl && withinBound(infoUrl) ? { infoUrl } : {}),
downloadUrl: link,
sizeBytes: toNumber(attrs.get('size') ?? item.size ?? enclosure?.['@_length']),
seeders: null,
@@ -332,22 +337,21 @@ function attrMap(item: NewznabItem): Map<string, string> {
}
function readGuid(item: NewznabItem): string | undefined {
if (typeof item.guid === 'string' && item.guid.trim()) return item.guid.trim();
if (typeof item.guid === 'number') return String(item.guid);
if (item.guid && typeof item.guid === 'object') {
const text = (item.guid as { '#text'?: unknown })['#text'];
return readText(item.guid);
}
/** An element's text, whether the parser handed it over bare or as `{ '#text', ...attributes }`. */
function readText(value: unknown): string | undefined {
if (typeof value === 'string' && value.trim()) return value.trim();
if (typeof value === 'number') return String(value);
if (value && typeof value === 'object') {
const text = (value as { '#text'?: unknown })['#text'];
if (typeof text === 'string' && text.trim()) return text.trim();
if (typeof text === 'number') return String(text);
}
return undefined;
}
/** A URL-shaped GUID can carry the API key, so expose only a stable opaque identifier for it. */
function publicReleaseGuid(indexerId: number, value: string): string {
if (!/^https?:/i.test(value)) return value;
return createHash('sha256').update(String(indexerId)).update('\0release\0').update(value).digest('hex');
}
function withinBound(value: string | undefined): boolean {
return value === undefined || value.length <= MAX_FEED_FIELD_CHARS;
}
@@ -97,6 +97,7 @@ const ITEM = `
<title>Dune - Frank Herbert (retail) [EPUB]</title>
<guid>https://tracker.example.com/details/1</guid>
<link>https://tracker.example.com/download/1.torrent?key=abc</link>
<comments>https://tracker.example.com/details/1</comments>
<pubDate>Tue, 01 Apr 2025 10:00:00 +0000</pubDate>
<enclosure url="https://tracker.example.com/download/1.torrent?key=abc" length="1048576" type="application/x-bittorrent" />
<torznab:attr name="seeders" value="42" />
@@ -189,8 +190,8 @@ describe('TorznabAdapter', () => {
expect(release).toMatchObject({
indexerId: 7,
guid: 'https://tracker.example.com/details/1',
title: 'Dune - Frank Herbert (retail) [EPUB]',
infoUrl: 'https://tracker.example.com/details/1',
downloadUrl: 'https://tracker.example.com/download/1.torrent?key=abc',
infoHash: 'c9e15763f722f23e98a29decdfae341b98d53056',
sizeBytes: 1048576,
@@ -201,6 +202,41 @@ describe('TorznabAdapter', () => {
expect(release.leechers).toBe(8);
});
/**
* Jackett reuses the tracker's download link as the guid for some trackers, and a private
* tracker's download link carries the passkey. The guid is what the picker holds on to, so it
* must not be the link itself.
*/
it('digests a URL-shaped guid so a passkeyed link never reaches a client', async () => {
const item = `<item><title>Dune</title><guid>https://tracker.example.com/download.php?id=1&amp;passkey=p4ssk3y</guid><link>https://tracker.example.com/download.php?id=1&amp;passkey=p4ssk3y</link></item>`;
fetchMock.mockResolvedValue(new Response(feed(item)));
const [first] = await new TorznabAdapter().search(query(), config(), AbortSignal.timeout(1000));
fetchMock.mockResolvedValue(new Response(feed(item)));
const [second] = await new TorznabAdapter().search(query(), config(), AbortSignal.timeout(1000));
expect(first.guid).toMatch(/^[a-f0-9]{64}$/);
expect(first.guid).not.toContain('p4ssk3y');
// Stable across searches, which is what lets a grab name the release it was shown.
expect(second.guid).toBe(first.guid);
});
it('keeps an opaque guid exactly as the proxy sent it', async () => {
fetchMock.mockResolvedValue(new Response(feed(`<item><title>Dune</title><guid>12345</guid><link>https://t.example/1.torrent</link></item>`)));
const [release] = await new TorznabAdapter().search(query(), config(), AbortSignal.timeout(1000));
expect(release.guid).toBe('12345');
});
it('leaves the page link out when the proxy publishes none', async () => {
fetchMock.mockResolvedValue(new Response(feed(`<item><title>Dune</title><guid>1</guid><link>https://t.example/1.torrent</link></item>`)));
const [release] = await new TorznabAdapter().search(query(), config(), AbortSignal.timeout(1000));
expect(release.infoUrl).toBeUndefined();
});
it('treats a lone item as a list rather than dropping it', async () => {
fetchMock.mockResolvedValue(new Response(feed(ITEM)));
expect(await new TorznabAdapter().search(query(), config(), AbortSignal.timeout(1000))).toHaveLength(1);
@@ -14,6 +14,7 @@ import {
type ResolvedIndexerConfig,
type TorrentFetchResult,
} from '../indexer-adapter';
import { publicReleaseGuid } from '../release-guid';
import { normalizeProviderSeedRatio, normalizeTorznabSeedTimeSeconds } from '../seed-goal.utils';
// An ISBN is the one query a tracker almost never indexes, so it stays out of the search text.
import { buildSearchText } from '../search-text';
@@ -44,6 +45,8 @@ interface TorznabItem {
title?: string;
guid?: unknown;
link?: string;
/** The release's details page on the tracker, which Jackett and Prowlarr both publish here. */
comments?: unknown;
size?: string | number;
pubDate?: string;
enclosure?: { '@_url'?: string; '@_length'?: string | number } | Array<{ '@_url'?: string; '@_length'?: string | number }>;
@@ -265,8 +268,9 @@ function parseItems(xml: string, indexerId: number): ReleaseCandidate[] {
const attrs = attrMap(item);
const enclosure = toArray(item.enclosure)[0];
const guid = readGuid(item) ?? enclosure?.['@_url'] ?? title;
if (!withinFieldBound(guid)) continue;
const sourceGuid = readGuid(item) ?? enclosure?.['@_url'] ?? title;
if (!withinFieldBound(sourceGuid)) continue;
const guid = publicReleaseGuid(indexerId, sourceGuid);
// A proxy may publish the release as a magnet in `link`, in an enclosure, or in an attribute.
const link = withinFieldBound(item.link ?? enclosure?.['@_url']) ? (item.link ?? enclosure?.['@_url']) : undefined;
const magnet = attrs.get('magneturl') ?? (link?.startsWith('magnet:') ? link : undefined);
@@ -280,12 +284,14 @@ function parseItems(xml: string, indexerId: number): ReleaseCandidate[] {
const seedRatioGoal = normalizeProviderSeedRatio(toNumber(attrs.get('minimumratio')));
const seedTimeMinutes = normalizeTorznabSeedTimeSeconds(toNumber(attrs.get('minimumseedtime')));
const bookTitle = attrs.get('booktitle')?.trim();
const infoUrl = readText(item.comments);
releases.push({
indexerId,
guid,
title,
...(bookTitle ? { bookTitle } : {}),
...(infoUrl && withinFieldBound(infoUrl) ? { infoUrl } : {}),
...(downloadUrl ? { downloadUrl } : {}),
...(magnet ? { magnet } : {}),
...(infoHash ? { infoHash: infoHash.toLowerCase() } : {}),
@@ -343,11 +349,15 @@ function withinFieldBound(value: string | undefined): boolean {
/** `guid` is a bare string on some proxies and `{ '#text', '@_isPermaLink' }` on others. */
function readGuid(item: TorznabItem): string | undefined {
const guid = item.guid;
if (typeof guid === 'string' && guid.trim()) return guid.trim();
if (typeof guid === 'number') return String(guid);
if (guid && typeof guid === 'object') {
const text = (guid as { '#text'?: unknown })['#text'];
return readText(item.guid);
}
/** An element's text, whether the parser handed it over bare or as `{ '#text', ...attributes }`. */
function readText(value: unknown): string | undefined {
if (typeof value === 'string' && value.trim()) return value.trim();
if (typeof value === 'number') return String(value);
if (value && typeof value === 'object') {
const text = (value as { '#text'?: unknown })['#text'];
if (typeof text === 'string' && text.trim()) return text.trim();
if (typeof text === 'number') return String(text);
}
@@ -91,6 +91,11 @@ export interface ReleaseCandidate {
downloadUrl?: string;
magnet?: string;
infoHash?: string;
/**
* The release's page on its source, as the adapter read it. Untrusted until the search service
* has run it through `safeReleaseInfoUrl`, which is the only thing that may hand it to a client.
*/
infoUrl?: string;
sizeBytes: number | null;
/**
* Null means the indexer reported no swarm counts, which must not be read as zero seeders: the
@@ -135,6 +135,27 @@ describe('IndexerSearchService', () => {
expect(result.indexers[0]).toMatchObject({ ok: true, count: 2, filtered: 1 });
});
it('hands the picker a vetted page link and drops one carrying the indexer credential', async () => {
const adapter = {
search: vi
.fn()
.mockResolvedValue([
release({ guid: 'safe', infoUrl: 'https://tracker.example.com/details/1#comments' }),
release({ guid: 'keyed', infoUrl: 'https://tracker.example.com/details/2?ref=stored-credential' }),
release({ guid: 'scripted', infoUrl: 'javascript:alert(1)' }),
release({ guid: 'none' }),
]),
};
const { service } = makeService([indexer({ adapterType: pluginType('example-plugin'), credential: 'stored-credential' })], {
'example-plugin': adapter,
});
const result = await service.search(request());
const pages = Object.fromEntries(result.releases.map((item) => [item.guid, item.infoUrl]));
expect(pages).toEqual({ safe: 'https://tracker.example.com/details/1', keyed: null, scripted: null, none: null });
});
/** The picker for an old comic request says why, rather than asking every tracker for nothing. */
it('refuses to search for a request made for the retired comic medium', async () => {
const search = vi.fn();
@@ -26,6 +26,7 @@ import { IndexerSearchException, type ReleaseCandidate, type ReleaseQuery, type
import { IndexerConfigService } from './indexer-config.service';
import { IndexerOperationLock } from './indexer-operation-lock';
import { IndexerRegistry } from './indexer-registry';
import { indexerSecrets, withSafeInfoUrl } from './release-info-url';
import { rejectRelease, scoreRelease, toReleaseItem, type ScoredRelease, type ScoringRequest } from './release-scoring';
import { buildSearchText } from './search-text';
@@ -427,7 +428,10 @@ export class IndexerSearchService {
() => new IndexerSearchException('timeout', `${config.name} did not answer in time`),
);
}
return { config, releases, query: indexerQuery };
// Vetted here rather than in each adapter, so a plugin is held to the same rule as the
// built-ins and a candidate refreshed for a grab is held to it a second time.
const secrets = indexerSecrets(config);
return { config, releases: releases.map((release) => withSafeInfoUrl(release, secrets)), query: indexerQuery };
} catch (error) {
const failure: IndexerSearchFailure = error instanceof IndexerSearchException ? error.failure : 'error';
const message = error instanceof Error ? error.message : String(error);
@@ -125,6 +125,15 @@ describe('PluginIndexerAdapter', () => {
expect(releases.map((r) => r.guid)).toEqual(['g5']);
});
it('carries the page a plugin names for a release, and nothing that is not text', async () => {
const releases = await searched([
{ guid: 'g1', title: 'Dune', infoUrl: ' https://source.example.org/book/1 ' },
{ guid: 'g2', title: 'Dune', infoUrl: { href: 'https://source.example.org/book/2' } },
]);
expect(releases.map((release) => release.infoUrl)).toEqual(['https://source.example.org/book/1', undefined]);
});
/** "1984" and "2001" are books, and a source that states one as a number still means the title. */
it('keeps a title or a guid a source stated as a number', async () => {
const [release] = await searched([{ guid: 4471, title: 1984 }]);
@@ -308,6 +308,7 @@ function sanitizeCandidate(raw: PluginReleaseCandidate, indexerId: number): Rele
guid,
title,
...optional('bookTitle', text(raw.bookTitle, MAX_PLUGIN_TEXT_LENGTH)),
...optional('infoUrl', text(raw.infoUrl, MAX_PLUGIN_URL_LENGTH)),
...optional('downloadUrl', text(raw.downloadUrl, MAX_PLUGIN_URL_LENGTH)),
...optional('magnet', text(raw.magnet, MAX_PLUGIN_URL_LENGTH)),
...optional('infoHash', text(raw.infoHash, MAX_PLUGIN_TEXT_LENGTH)),
@@ -0,0 +1,15 @@
import { createHash } from 'crypto';
/**
* The identifier a client sees for a release, and the one a grab names it by.
*
* A source guid shaped like a URL or a magnet can carry a credential: a Newznab guid is often the
* API-keyed details link, a Torznab proxy may reuse the tracker's passkeyed download link, and a
* private tracker's magnet names its passkeyed announce URL. A digest keeps the identity stable
* from one search to the next without the value ever leaving the server. Anything else is
* already an opaque id and passes through unchanged.
*/
export function publicReleaseGuid(indexerId: number, value: string): string {
if (!/^(https?|magnet):/i.test(value)) return value;
return createHash('sha256').update(String(indexerId)).update('\0release\0').update(value).digest('hex');
}
@@ -0,0 +1,84 @@
import { indexerSecrets, safeReleaseInfoUrl, withSafeInfoUrl } from './release-info-url';
describe('safeReleaseInfoUrl', () => {
it('passes an ordinary details page through', () => {
expect(safeReleaseInfoUrl('https://tracker.example.com/details.php?id=123&hit=1', [])).toBe(
'https://tracker.example.com/details.php?id=123&hit=1',
);
});
it('opens a Newznab comments link at the top of its page', () => {
expect(safeReleaseInfoUrl('https://indexer.example.com/details/abc#comments', [])).toBe('https://indexer.example.com/details/abc');
});
it('keeps any other anchor, which a hash-routed tracker needs to land on the release', () => {
expect(safeReleaseInfoUrl('https://tracker.example.com/#/torrent/9', [])).toBe('https://tracker.example.com/#/torrent/9');
});
it.each(['javascript:alert(1)', 'data:text/html,hi', 'magnet:?xt=urn:btih:abc', 'ftp://tracker.example.com/1', '/details/1', 'not a url'])(
'drops %s, which is not an http(s) page',
(raw) => {
expect(safeReleaseInfoUrl(raw, [])).toBeUndefined();
},
);
it.each([
'https://indexer.example.com/details/1?apikey=abc',
'https://indexer.example.com/details/1?api_key=abc',
'https://jackett.example.com/dl/x?jackett_apikey=abc',
'https://tracker.example.com/details.php?id=1&passkey=abc',
'https://tracker.example.com/details.php?id=1&torrent_pass=abc',
'https://tracker.example.com/details.php?id=1&authkey=abc',
'https://tracker.example.com/details.php?id=1&access_token=abc',
'https://tracker.example.com/details.php?id=1&rsskey=abc',
'https://cdn.example.com/page?X-Amz-Signature=abc',
])('drops %s, whose query names a credential', (raw) => {
expect(safeReleaseInfoUrl(raw, [])).toBeUndefined();
});
it('does not mistake an author parameter for an auth one', () => {
expect(safeReleaseInfoUrl('https://library.example.org/search?author=Herbert', [])).toBe('https://library.example.org/search?author=Herbert');
});
it("drops a link carrying the indexer's own secret anywhere, path included", () => {
expect(safeReleaseInfoUrl('https://tracker.example.com/details/1/s3cr3t-value', ['s3cr3t-value'])).toBeUndefined();
expect(safeReleaseInfoUrl('https://tracker.example.com/details/1?ref=s3cr3t%2Fvalue', ['s3cr3t/value'])).toBeUndefined();
});
it('ignores a secret too short to search for without matching ordinary text', () => {
expect(safeReleaseInfoUrl('https://tracker.example.com/details/1', ['1'])).toBe('https://tracker.example.com/details/1');
});
it('drops a link carrying user info', () => {
expect(safeReleaseInfoUrl('https://user:pass@tracker.example.com/details/1', [])).toBeUndefined();
});
it('drops a link longer than any page address', () => {
expect(safeReleaseInfoUrl(`https://tracker.example.com/${'a'.repeat(2100)}`, [])).toBeUndefined();
});
});
describe('indexerSecrets', () => {
it('collects the credential and any key pasted into the base URL', () => {
expect(indexerSecrets({ credential: 'stored-key', baseUrl: 'http://127.0.0.1:9117/api?apikey=base-key&t=caps' })).toEqual([
'stored-key',
'base-key',
]);
});
it('survives an unparseable base URL', () => {
expect(indexerSecrets({ credential: null, baseUrl: 'not a url' })).toEqual([]);
});
});
describe('withSafeInfoUrl', () => {
const candidate = { indexerId: 1, guid: 'g', title: 'Dune', sizeBytes: null, seeders: null, leechers: null };
it('returns the same candidate when there is no page link to vet', () => {
expect(withSafeInfoUrl(candidate, [])).toBe(candidate);
});
it('removes a page link that may not be shown', () => {
expect(withSafeInfoUrl({ ...candidate, infoUrl: 'https://t.example/d/1?passkey=x' }, []).infoUrl).toBeUndefined();
});
});
@@ -0,0 +1,88 @@
import type { ReleaseCandidate, ResolvedIndexerConfig } from './indexer-adapter';
/** A details page is a short address. One longer than this is carrying something other than a page. */
export const MAX_RELEASE_INFO_URL_LENGTH = 2_048;
/** Below this a credential is too short to search a URL for without matching ordinary text. */
const MIN_SECRET_LENGTH = 6;
/**
* Whether a query parameter name is one a credential travels under. Matched per word of the name,
* so `jackett_apikey`, `api_key`, `torrent_pass` and `access_token` all fall to it while `author`
* does not. Deliberately broad: a page link wrongly dropped costs the approver a search on the
* tracker, while a key wrongly shown costs the account.
*/
function isSecretParamName(name: string): boolean {
return name
.toLowerCase()
.split(/[^a-z0-9]+/)
.some((word) => /key$|^pass|token$|^secret|^auth$|^sig(nature)?$/.test(word));
}
/**
* The link a client may be shown for a release's page, or undefined where there is none it may.
*
* The value comes from a feed or a plugin nobody vets and lands in an `href`, so only http(s)
* passes: anything else is either useless or `javascript:`. A link that could be carrying a
* credential is dropped rather than cleaned. Rewriting one means knowing every tracker's
* parameter names, and a key embedded in a path survives any rewrite; the indexer's own secrets
* are matched anywhere in the address instead, which catches a key wherever it sits.
*/
export function safeReleaseInfoUrl(raw: string | undefined, secrets: readonly string[]): string | undefined {
const trimmed = raw?.trim();
if (!trimmed || trimmed.length > MAX_RELEASE_INFO_URL_LENGTH) return undefined;
let url: URL;
try {
url = new URL(trimmed);
} catch {
return undefined;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined;
if (url.username || url.password) return undefined;
for (const name of url.searchParams.keys()) {
if (isSecretParamName(name)) return undefined;
}
const haystacks = [url.href, safeDecode(url.href)];
for (const secret of secrets) {
if (secret.length >= MIN_SECRET_LENGTH && haystacks.some((haystack) => haystack.includes(secret))) return undefined;
}
// Newznab points `comments` at the thread at the foot of the details page. The page itself,
// with the description at the top, is what an approver opens it for.
if (url.hash.toLowerCase() === '#comments') url.hash = '';
return url.href;
}
/**
* Every value the server holds for this indexer that must never reach a client: the stored
* credential, and any key the operator pasted into the base URL's own query string, which some
* proxies hand out as the endpoint to configure.
*/
export function indexerSecrets(config: Pick<ResolvedIndexerConfig, 'credential' | 'baseUrl'>): string[] {
const secrets = config.credential ? [config.credential] : [];
try {
for (const [name, value] of new URL(config.baseUrl).searchParams) {
if (value && isSecretParamName(name)) secrets.push(value);
}
} catch {
// An unparseable base URL cannot have been searched, so it has nothing to leak.
}
return secrets;
}
/** The candidate with its page link vetted, or with no page link at all. */
export function withSafeInfoUrl(candidate: ReleaseCandidate, secrets: readonly string[]): ReleaseCandidate {
if (candidate.infoUrl === undefined) return candidate;
const infoUrl = safeReleaseInfoUrl(candidate.infoUrl, secrets);
return infoUrl === candidate.infoUrl ? candidate : { ...candidate, infoUrl };
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
@@ -194,6 +194,7 @@ export function toReleaseItem(scored: ScoredRelease, indexerName: string, reques
indexerName,
guid: candidate.guid,
title: candidate.title,
infoUrl: candidate.infoUrl ?? null,
sizeBytes: candidate.sizeBytes,
seeders: candidate.seeders,
leechers: candidate.leechers,
@@ -1,5 +1,5 @@
{
"totalRoutes": 663,
"totalRoutes": 665,
"byPermission": {
"(none)": 287,
"Permission.ManageMetadataConfig": 41,
@@ -25,9 +25,9 @@
"Permission.HardcoverSync": 1,
"Permission.ReadwiseSync": 1,
"Permission.StorygraphSync": 1,
"Permission.BookRequestAccess": 26,
"Permission.ManageBookRequests": 20,
"Permission.BookRequestSelfFulfill": 11,
"Permission.BookRequestAccess": 27,
"Permission.ManageBookRequests": 21,
"Permission.BookRequestSelfFulfill": 12,
"Permission.PodcastManageFeeds": 16,
"Permission.PodcastManageRetention": 1,
"Permission.PodcastEditMetadata": 5,
@@ -528,6 +528,18 @@
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-request/book-request-admin.controller.ts",
"className": "BookRequestAdminController",
"methodName": "getReviewCover",
"httpMethod": "GET",
"path": "/admin/book-requests/:id/review/cover",
"permissions": ["Permission.ManageBookRequests"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-request/book-request-admin.controller.ts",
"className": "BookRequestAdminController",
@@ -1056,6 +1068,18 @@
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-request/book-request-self-fulfil.controller.ts",
"className": "BookRequestSelfFulfilController",
"methodName": "getReviewCover",
"httpMethod": "GET",
"path": "/book-request-fulfilment/:id/review/cover",
"permissions": ["Permission.BookRequestAccess", "Permission.BookRequestSelfFulfill"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-request/book-request-self-fulfil.controller.ts",
"className": "BookRequestSelfFulfilController",