fix(cover): harden cover fetching and validate dashboard scroller lenses

This commit is contained in:
neon
2026-04-01 03:38:20 -06:00
parent bfd193df62
commit acc5f833e8
19 changed files with 1126 additions and 333 deletions
+11
View File
@@ -1 +1,12 @@
export const COVER_PROVIDERS = Symbol('COVER_PROVIDERS');
export const COVER_PROXY_USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
export const COVER_PROXY_TIMEOUT_MS = 10_000;
export const COVER_PROXY_MAX_IMAGE_BYTES = 20 * 1024 * 1024;
export const COVER_PROXY_MAX_REDIRECTS = 5;
export const COVER_CUSTOM_FILE_PREFIX = 'cover_custom.';
export const COVER_EXTRACTED_FILE_PREFIX = 'cover_extracted.';
export const COVER_THUMBNAIL_FILE_NAME = 'thumbnail.jpg';
+6 -10
View File
@@ -6,6 +6,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { CoverService } from './cover.service';
import { SearchCoversQueryDto } from './dto/search-covers-query.dto';
import { UploadCoverFromUrlDto } from './dto/upload-cover-from-url.dto';
type MultipartRequest = FastifyRequest & { file: () => Promise<MultipartFile | undefined> };
@@ -16,17 +17,12 @@ export class CoverController {
@Get('cover/search')
@RequirePermission(Permission.LibraryEditMetadata)
async searchCovers(
@Query('title') title: string,
@Query('author') author?: string,
@Query('isAudiobook') isAudiobook?: string,
@Query('provider') provider?: string,
) {
async searchCovers(@Query() query: SearchCoversQueryDto) {
return this.coverService.searchCovers({
title,
author,
isAudiobook: isAudiobook === 'true',
provider,
title: query.title,
author: query.author,
isAudiobook: query.isAudiobook,
provider: query.provider,
});
}
+256 -88
View File
@@ -1,7 +1,14 @@
import { type LookupAddress, lookup } from 'dns/promises';
import type { CoverSearchResult } from '@projectx/types';
import { CoverProviderRegistry } from './provider-registry';
import { COVER_PROXY_MAX_IMAGE_BYTES, COVER_PROXY_USER_AGENT } from './constants';
import { CoverService } from './cover.service';
import type { CoverProviderRegistry } from './provider-registry';
import { DUCKDUCKGO_PROVIDER_KEY, ITUNES_PROVIDER_KEY } from './providers/cover-provider';
vi.mock('dns/promises', () => ({
lookup: vi.fn(),
}));
function makeResult(url: string, previewUrl: string): CoverSearchResult {
return {
@@ -14,97 +21,258 @@ function makeResult(url: string, previewUrl: string): CoverSearchResult {
};
}
describe('CoverService.searchCovers', () => {
it('selects providers, deduplicates by source/url, and proxies preview URLs', async () => {
const providerA = {
key: 'duckduckgo' as const,
search: vi
.fn()
.mockResolvedValue([
makeResult('https://img.example/a.jpg', 'https://thumb.example/a.jpg'),
makeResult('https://img.example/b.jpg', 'https://thumb.example/b.jpg'),
]),
};
const providerB = {
key: 'itunes' as const,
search: vi
.fn()
.mockResolvedValue([
makeResult('https://img.example/b.jpg', 'https://thumb.example/b.jpg'),
makeResult('https://img.example/c.jpg', 'https://thumb.example/c.jpg'),
]),
};
const providerRegistry = {
select: vi.fn().mockReturnValue([providerA, providerB]),
} as unknown as CoverProviderRegistry;
function makeImageResponse(bytes: Uint8Array, contentType = 'image/jpeg', status = 200): Response {
return new Response(bytes, {
status,
headers: {
'content-type': contentType,
},
});
}
const service = new CoverService(
{} as never,
{} as never,
{} as never,
{ get: vi.fn().mockReturnValue('/tmp/books') } as never,
providerRegistry,
);
const results = await service.searchCovers({ title: 'Dune', author: 'Frank Herbert', provider: 'all' });
expect((providerRegistry.select as unknown as { mock: { calls: unknown[][] } }).mock.calls[0][0]).toBe('all');
expect(providerA.search).toHaveBeenCalledWith({ title: 'Dune', author: 'Frank Herbert' });
expect(providerB.search).toHaveBeenCalledWith({ title: 'Dune', author: 'Frank Herbert' });
expect(results).toHaveLength(3);
expect(results.map((r) => r.sourceUrl)).toEqual(['https://img.example/a.jpg', 'https://img.example/b.jpg', 'https://img.example/c.jpg']);
expect(results[0].previewUrl).toBe('/api/v1/books/cover/proxy?url=https%3A%2F%2Fthumb.example%2Fa.jpg');
function makeStreamResponse(chunks: Uint8Array[], contentType = 'image/jpeg', status = 200): Response {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk);
controller.close();
},
});
it('interleaves first five iTunes covers with duckduckgo when provider is all', async () => {
const providerA = {
key: 'duckduckgo' as const,
search: vi
.fn()
.mockResolvedValue([
makeResult('https://img.example/ddg-1.jpg', 'https://thumb.example/ddg-1.jpg'),
makeResult('https://img.example/ddg-2.jpg', 'https://thumb.example/ddg-2.jpg'),
makeResult('https://img.example/ddg-3.jpg', 'https://thumb.example/ddg-3.jpg'),
makeResult('https://img.example/ddg-4.jpg', 'https://thumb.example/ddg-4.jpg'),
]),
};
const providerB = {
key: 'itunes' as const,
search: vi
.fn()
.mockResolvedValue([
makeResult('https://img.example/it-1.jpg', 'https://thumb.example/it-1.jpg'),
makeResult('https://img.example/it-2.jpg', 'https://thumb.example/it-2.jpg'),
makeResult('https://img.example/it-3.jpg', 'https://thumb.example/it-3.jpg'),
makeResult('https://img.example/it-4.jpg', 'https://thumb.example/it-4.jpg'),
makeResult('https://img.example/it-5.jpg', 'https://thumb.example/it-5.jpg'),
makeResult('https://img.example/it-6.jpg', 'https://thumb.example/it-6.jpg'),
]),
};
const providerRegistry = {
select: vi.fn().mockReturnValue([providerA, providerB]),
} as unknown as CoverProviderRegistry;
return new Response(stream, {
status,
headers: {
'content-type': contentType,
},
});
}
const service = new CoverService(
{} as never,
{} as never,
{} as never,
{ get: vi.fn().mockReturnValue('/tmp/books') } as never,
providerRegistry,
);
function makeRedirectResponse(location: string, status = 302): Response {
return new Response(null, {
status,
headers: {
location,
},
});
}
const results = await service.searchCovers({ title: 'Dune', provider: 'all' });
expect(results.map((r) => r.sourceUrl)).toEqual([
'https://img.example/ddg-1.jpg',
'https://img.example/it-1.jpg',
'https://img.example/ddg-2.jpg',
'https://img.example/it-2.jpg',
'https://img.example/ddg-3.jpg',
'https://img.example/it-3.jpg',
'https://img.example/ddg-4.jpg',
'https://img.example/it-4.jpg',
'https://img.example/it-5.jpg',
'https://img.example/it-6.jpg',
]);
function createService(providerRegistry: CoverProviderRegistry): CoverService {
return new CoverService({} as never, {} as never, {} as never, { get: vi.fn().mockReturnValue('/tmp/books') } as never, providerRegistry);
}
describe('CoverService', () => {
const originalFetch = global.fetch;
const lookupMock = vi.mocked(lookup);
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
lookupMock.mockReset();
lookupMock.mockResolvedValue([{ address: '93.184.216.34', family: 4 } as LookupAddress]);
fetchMock = vi.fn();
global.fetch = fetchMock as unknown as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('searchCovers', () => {
it('selects providers, deduplicates by source/url, and proxies preview URLs', async () => {
const providerA = {
key: DUCKDUCKGO_PROVIDER_KEY,
search: vi
.fn()
.mockResolvedValue([
makeResult('https://img.example/a.jpg', 'https://thumb.example/a.jpg'),
makeResult('https://img.example/b.jpg', 'https://thumb.example/b.jpg'),
]),
};
const providerB = {
key: ITUNES_PROVIDER_KEY,
search: vi
.fn()
.mockResolvedValue([
makeResult('https://img.example/b.jpg', 'https://thumb.example/b.jpg'),
makeResult('https://img.example/c.jpg', 'https://thumb.example/c.jpg'),
]),
};
const providerRegistry = {
select: vi.fn().mockReturnValue([providerA, providerB]),
} as unknown as CoverProviderRegistry;
const service = createService(providerRegistry);
const results = await service.searchCovers({ title: 'Dune', author: 'Frank Herbert', provider: 'all' });
expect((providerRegistry.select as unknown as { mock: { calls: unknown[][] } }).mock.calls[0][0]).toBe('all');
expect(providerA.search).toHaveBeenCalledWith({ title: 'Dune', author: 'Frank Herbert' });
expect(providerB.search).toHaveBeenCalledWith({ title: 'Dune', author: 'Frank Herbert' });
expect(results).toHaveLength(3);
expect(results.map((result) => result.sourceUrl)).toEqual([
'https://img.example/a.jpg',
'https://img.example/b.jpg',
'https://img.example/c.jpg',
]);
expect(results[0].previewUrl).toBe('/api/v1/books/cover/proxy?url=https%3A%2F%2Fthumb.example%2Fa.jpg');
});
it('interleaves first five iTunes covers with duckduckgo when provider is all', async () => {
const providerA = {
key: DUCKDUCKGO_PROVIDER_KEY,
search: vi
.fn()
.mockResolvedValue([
makeResult('https://img.example/ddg-1.jpg', 'https://thumb.example/ddg-1.jpg'),
makeResult('https://img.example/ddg-2.jpg', 'https://thumb.example/ddg-2.jpg'),
makeResult('https://img.example/ddg-3.jpg', 'https://thumb.example/ddg-3.jpg'),
makeResult('https://img.example/ddg-4.jpg', 'https://thumb.example/ddg-4.jpg'),
]),
};
const providerB = {
key: ITUNES_PROVIDER_KEY,
search: vi
.fn()
.mockResolvedValue([
makeResult('https://img.example/it-1.jpg', 'https://thumb.example/it-1.jpg'),
makeResult('https://img.example/it-2.jpg', 'https://thumb.example/it-2.jpg'),
makeResult('https://img.example/it-3.jpg', 'https://thumb.example/it-3.jpg'),
makeResult('https://img.example/it-4.jpg', 'https://thumb.example/it-4.jpg'),
makeResult('https://img.example/it-5.jpg', 'https://thumb.example/it-5.jpg'),
makeResult('https://img.example/it-6.jpg', 'https://thumb.example/it-6.jpg'),
]),
};
const providerRegistry = {
select: vi.fn().mockReturnValue([providerA, providerB]),
} as unknown as CoverProviderRegistry;
const service = createService(providerRegistry);
const results = await service.searchCovers({ title: 'Dune', provider: 'all' });
expect(results.map((result) => result.sourceUrl)).toEqual([
'https://img.example/ddg-1.jpg',
'https://img.example/it-1.jpg',
'https://img.example/ddg-2.jpg',
'https://img.example/it-2.jpg',
'https://img.example/ddg-3.jpg',
'https://img.example/it-3.jpg',
'https://img.example/ddg-4.jpg',
'https://img.example/it-4.jpg',
'https://img.example/it-5.jpg',
'https://img.example/it-6.jpg',
]);
});
});
describe('proxyImage', () => {
function createProxyService(): CoverService {
return createService({ select: vi.fn().mockReturnValue([]) } as unknown as CoverProviderRegistry);
}
it('returns image bytes and normalized content type', async () => {
const service = createProxyService();
fetchMock.mockResolvedValueOnce(makeImageResponse(Buffer.from('bytes'), 'image/jpeg; charset=utf-8'));
const result = await service.proxyImage('https://covers.example.com/cover.jpg');
expect(result.contentType).toBe('image/jpeg');
expect(result.buffer).toEqual(Buffer.from('bytes'));
expect(fetchMock).toHaveBeenCalledWith(
'https://covers.example.com/cover.jpg',
expect.objectContaining({
redirect: 'manual',
headers: expect.objectContaining({
Accept: 'image/*',
'User-Agent': COVER_PROXY_USER_AGENT,
}),
}),
);
});
it('rejects invalid URLs before requesting remote data', async () => {
const service = createProxyService();
await expect(service.proxyImage('not a url')).rejects.toThrow('Invalid URL');
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects unsupported URL protocols', async () => {
const service = createProxyService();
await expect(service.proxyImage('ftp://covers.example.com/cover.jpg')).rejects.toThrow('URL must use http or https');
});
it('rejects localhost and private hosts', async () => {
const service = createProxyService();
await expect(service.proxyImage('http://127.0.0.1/cover.jpg')).rejects.toThrow('URL host is not allowed');
await expect(service.proxyImage('https://localhost/cover.jpg')).rejects.toThrow('URL host is not allowed');
});
it('rejects domains that resolve to private addresses', async () => {
const service = createProxyService();
lookupMock.mockResolvedValueOnce([{ address: '10.0.0.7', family: 4 } as LookupAddress]);
await expect(service.proxyImage('https://internal.example.com/cover.jpg')).rejects.toThrow('URL host is not allowed');
});
it('rejects non-image responses', async () => {
const service = createProxyService();
fetchMock.mockResolvedValueOnce(makeImageResponse(Buffer.from('<html/>'), 'text/html'));
await expect(service.proxyImage('https://covers.example.com/cover.jpg')).rejects.toThrow('URL does not point to an image');
});
it('rejects oversized responses', async () => {
const service = createProxyService();
fetchMock.mockResolvedValueOnce(makeStreamResponse([new Uint8Array(COVER_PROXY_MAX_IMAGE_BYTES), new Uint8Array([1])], 'image/png'));
await expect(service.proxyImage('https://covers.example.com/cover.jpg')).rejects.toThrow('Image exceeds 20 MB limit');
});
it('follows redirects to a safe image host', async () => {
const service = createProxyService();
fetchMock
.mockResolvedValueOnce(makeRedirectResponse('/images/cover.jpg'))
.mockResolvedValueOnce(makeImageResponse(Buffer.from('png-bytes'), 'image/png'));
const result = await service.proxyImage('https://covers.example.com/start');
expect(result.contentType).toBe('image/png');
expect(result.buffer).toEqual(Buffer.from('png-bytes'));
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenNthCalledWith(2, 'https://covers.example.com/images/cover.jpg', expect.objectContaining({ redirect: 'manual' }));
});
it('rejects redirects without a location header', async () => {
const service = createProxyService();
fetchMock.mockResolvedValueOnce(new Response(null, { status: 302 }));
await expect(service.proxyImage('https://covers.example.com/start')).rejects.toThrow('Image redirect is missing location');
});
it('rejects redirect chains longer than the configured limit', async () => {
const service = createProxyService();
for (let i = 0; i < 6; i += 1) {
fetchMock.mockResolvedValueOnce(makeRedirectResponse(`https://covers.example.com/r/${i}`));
}
await expect(service.proxyImage('https://covers.example.com/start')).rejects.toThrow('Too many image redirects');
expect(fetchMock).toHaveBeenCalledTimes(6);
});
it('maps AbortError failures to timeout errors', async () => {
const service = createProxyService();
const abortError = new Error('aborted');
abortError.name = 'AbortError';
fetchMock.mockRejectedValueOnce(abortError);
await expect(service.proxyImage('https://covers.example.com/cover.jpg')).rejects.toThrow('Image request timed out');
});
it('rejects unresolved hostnames', async () => {
const service = createProxyService();
lookupMock.mockRejectedValueOnce(new Error('dns failure'));
await expect(service.proxyImage('https://missing.example.com/cover.jpg')).rejects.toThrow('Unable to resolve URL host');
});
});
});
+372 -109
View File
@@ -1,23 +1,42 @@
import { lookup } from 'dns/promises';
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CoverSearchResult } from '@projectx/types';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises';
import { isIP } from 'net';
import { join } from 'path';
import type { RequestUser } from '../../common/types/request-user';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { bookMetadata } from '../../db/schema';
import { coverDirPath, generateThumbnail, imageExt } from '../metadata/lib/cover';
import { BookReadService } from '../book/book-read.service';
import { LibraryService } from '../library/library.service';
import type { RequestUser } from '../../common/types/request-user';
import { CoverSearchParams } from './providers/cover-provider';
import { CoverSearchResult } from '@projectx/types';
import { coverDirPath, generateThumbnail, imageExt } from '../metadata/lib/cover';
import {
COVER_CUSTOM_FILE_PREFIX,
COVER_EXTRACTED_FILE_PREFIX,
COVER_PROXY_MAX_IMAGE_BYTES,
COVER_PROXY_MAX_REDIRECTS,
COVER_PROXY_TIMEOUT_MS,
COVER_PROXY_USER_AGENT,
COVER_THUMBNAIL_FILE_NAME,
} from './constants';
import { CoverProviderRegistry } from './provider-registry';
import {
COVER_PROVIDER_ALL_KEY,
type CoverProviderKey,
type CoverSearchParams,
DUCKDUCKGO_PROVIDER_KEY,
ITUNES_PROVIDER_KEY,
} from './providers/cover-provider';
type Db = NodePgDatabase<typeof schema>;
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
const SAFE_REMOTE_PROTOCOLS = new Set(['http:', 'https:']);
const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
const ITUNES_INTERLEAVE_LIMIT = 5;
@Injectable()
export class CoverService {
@@ -37,19 +56,22 @@ export class CoverService {
async searchCovers(params: CoverSearchParams & { provider?: string }): Promise<CoverSearchResult[]> {
const { provider, ...searchParams } = params;
const providers = this.providerRegistry.select(provider);
const resultsByProvider = new Map<string, CoverSearchResult[]>();
const resultsByProvider = new Map<CoverProviderKey, CoverSearchResult[]>();
for (const coverProvider of providers) {
let results: CoverSearchResult[] = [];
try {
results = await coverProvider.search(searchParams);
} catch (error) {
this.logger.warn(`Cover provider "${coverProvider.key}" failed: ${error instanceof Error ? error.message : String(error)}`);
this.logger.warn(
`[cover.search_provider] [fail] provider=${coverProvider.key} errorClass=${errorClass(error)} error="${sanitizeErrorMessage(error)}" - cover provider search failed`,
);
}
resultsByProvider.set(coverProvider.key, results);
}
const ordered =
provider === 'all'
provider === COVER_PROVIDER_ALL_KEY
? this.orderAllProviderResults(
providers.map((providerEntry) => providerEntry.key),
resultsByProvider,
@@ -59,27 +81,129 @@ export class CoverService {
return this.dedupeAndProxy(ordered);
}
private orderAllProviderResults(keys: string[], resultsByProvider: Map<string, CoverSearchResult[]>): CoverSearchResult[] {
const ddgResults = resultsByProvider.get('duckduckgo') ?? [];
const itunesResults = resultsByProvider.get('itunes') ?? [];
const interleaved = this.interleaveITunesWithDuckDuckGo(ddgResults, itunesResults, 5);
async proxyImage(url: string): Promise<{ buffer: Buffer; contentType: string }> {
const startedAt = Date.now();
this.logger.log(`[cover.proxy_image] [start] urlHost=${hostForLog(url)} - cover image proxy started`);
const remaining = keys.filter((key) => key !== 'duckduckgo' && key !== 'itunes').flatMap((key) => resultsByProvider.get(key) ?? []);
try {
const image = await this.fetchRemoteImage(url);
this.logger.log(
`[cover.proxy_image] [end] urlHost=${hostForLog(url)} durationMs=${Date.now() - startedAt} bytes=${image.buffer.length} contentType=${image.contentType} - cover image proxy completed`,
);
return image;
} catch (error) {
this.logger.warn(
`[cover.proxy_image] [fail] urlHost=${hostForLog(url)} durationMs=${Date.now() - startedAt} errorClass=${errorClass(error)} error="${sanitizeErrorMessage(error)}" - cover image proxy failed`,
);
if (error instanceof BadRequestException) throw error;
throw new BadRequestException('Failed to proxy image');
}
}
async uploadCover(bookId: number, buffer: Buffer, mimeType: string, user: RequestUser): Promise<void> {
const startedAt = Date.now();
this.logger.log(`[cover.upload] [start] bookId=${bookId} userId=${user.id} - custom cover upload started`);
try {
if (!mimeType.startsWith('image/')) throw new BadRequestException('File must be an image');
await this.verifyAccess(bookId, user);
await this.saveCustomCover(bookId, buffer);
await this.setCoverSource(bookId, 'custom');
this.logger.log(
`[cover.upload] [end] bookId=${bookId} userId=${user.id} durationMs=${Date.now() - startedAt} coverSource=custom - custom cover upload completed`,
);
} catch (error) {
this.logger.warn(
`[cover.upload] [fail] bookId=${bookId} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass(error)} error="${sanitizeErrorMessage(error)}" - custom cover upload failed`,
);
throw error;
}
}
async uploadCoverFromUrl(bookId: number, url: string, user: RequestUser): Promise<void> {
const startedAt = Date.now();
this.logger.log(
`[cover.upload_from_url] [start] bookId=${bookId} userId=${user.id} urlHost=${hostForLog(url)} - custom cover upload from URL started`,
);
try {
await this.verifyAccess(bookId, user);
const { buffer } = await this.fetchRemoteImage(url);
await this.saveCustomCover(bookId, buffer);
await this.setCoverSource(bookId, 'custom');
this.logger.log(
`[cover.upload_from_url] [end] bookId=${bookId} userId=${user.id} urlHost=${hostForLog(url)} durationMs=${Date.now() - startedAt} coverSource=custom - custom cover upload from URL completed`,
);
} catch (error) {
this.logger.warn(
`[cover.upload_from_url] [fail] bookId=${bookId} userId=${user.id} urlHost=${hostForLog(url)} durationMs=${Date.now() - startedAt} errorClass=${errorClass(error)} error="${sanitizeErrorMessage(error)}" - custom cover upload from URL failed`,
);
throw error;
}
}
async deleteCover(bookId: number, user: RequestUser): Promise<'extracted' | null> {
const startedAt = Date.now();
this.logger.log(`[cover.delete] [start] bookId=${bookId} userId=${user.id} - cover deletion started`);
try {
await this.verifyAccess(bookId, user);
const dir = coverDirPath(this.booksPath, bookId);
await this.deleteFilesByPrefix(dir, COVER_CUSTOM_FILE_PREFIX);
const extractedPath = await this.findExtractedCover(bookId);
if (!extractedPath) {
await this.removeFileIfPresent(join(dir, COVER_THUMBNAIL_FILE_NAME));
await this.setCoverSource(bookId, null);
this.logger.log(
`[cover.delete] [end] bookId=${bookId} userId=${user.id} durationMs=${Date.now() - startedAt} coverSource=null - cover deletion completed`,
);
return null;
}
const bytes = await readFile(extractedPath);
const thumb = await generateThumbnail(bytes);
await writeFile(join(dir, COVER_THUMBNAIL_FILE_NAME), thumb);
await this.setCoverSource(bookId, 'extracted');
this.logger.log(
`[cover.delete] [end] bookId=${bookId} userId=${user.id} durationMs=${Date.now() - startedAt} coverSource=extracted - cover deletion completed`,
);
return 'extracted';
} catch (error) {
this.logger.warn(
`[cover.delete] [fail] bookId=${bookId} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass(error)} error="${sanitizeErrorMessage(error)}" - cover deletion failed`,
);
throw error;
}
}
private orderAllProviderResults(keys: CoverProviderKey[], resultsByProvider: Map<CoverProviderKey, CoverSearchResult[]>): CoverSearchResult[] {
const duckDuckGoResults = resultsByProvider.get(DUCKDUCKGO_PROVIDER_KEY) ?? [];
const iTunesResults = resultsByProvider.get(ITUNES_PROVIDER_KEY) ?? [];
const interleaved = this.interleaveITunesWithDuckDuckGo(duckDuckGoResults, iTunesResults, ITUNES_INTERLEAVE_LIMIT);
const remaining = keys
.filter((key) => key !== DUCKDUCKGO_PROVIDER_KEY && key !== ITUNES_PROVIDER_KEY)
.flatMap((key) => resultsByProvider.get(key) ?? []);
return [...interleaved, ...remaining];
}
private interleaveITunesWithDuckDuckGo(ddgResults: CoverSearchResult[], itunesResults: CoverSearchResult[], firstN: number): CoverSearchResult[] {
const leadingITunes = itunesResults.slice(0, firstN);
const trailingITunes = itunesResults.slice(firstN);
private interleaveITunesWithDuckDuckGo(
duckDuckGoResults: CoverSearchResult[],
iTunesResults: CoverSearchResult[],
firstN: number,
): CoverSearchResult[] {
const leadingITunes = iTunesResults.slice(0, firstN);
const trailingITunes = iTunesResults.slice(firstN);
const mixed: CoverSearchResult[] = [];
const rounds = Math.max(ddgResults.length, leadingITunes.length);
const rounds = Math.max(duckDuckGoResults.length, leadingITunes.length);
for (let i = 0; i < rounds; i += 1) {
const ddg = ddgResults[i];
if (ddg) mixed.push(ddg);
const itunes = leadingITunes[i];
if (itunes) mixed.push(itunes);
const duckDuckGo = duckDuckGoResults[i];
if (duckDuckGo) mixed.push(duckDuckGo);
const iTunes = leadingITunes[i];
if (iTunes) mixed.push(iTunes);
}
mixed.push(...trailingITunes);
@@ -101,84 +225,22 @@ export class CoverService {
return deduped;
}
async proxyImage(url: string): Promise<{ buffer: Buffer; contentType: string }> {
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
},
});
if (!response.ok) throw new Error(`Proxy failed: ${response.status}`);
const contentType = response.headers.get('content-type') || 'image/jpeg';
const buffer = Buffer.from(await response.arrayBuffer());
return { buffer, contentType };
} catch (error) {
this.logger.error(`Proxy error for ${url}: ${error.message}`);
throw new BadRequestException('Failed to proxy image');
}
}
async uploadCover(bookId: number, buffer: Buffer, mimeType: string, user: RequestUser): Promise<void> {
if (!mimeType.startsWith('image/')) throw new BadRequestException('File must be an image');
await this.verifyAccess(bookId, user);
await this.saveCustomCover(bookId, buffer);
await this.setCoverSource(bookId, 'custom');
}
async uploadCoverFromUrl(bookId: number, url: string, user: RequestUser): Promise<void> {
await this.verifyAccess(bookId, user);
const buffer = await this.fetchImageFromUrl(url);
await this.saveCustomCover(bookId, buffer);
await this.setCoverSource(bookId, 'custom');
}
async deleteCover(bookId: number, user: RequestUser): Promise<'extracted' | null> {
await this.verifyAccess(bookId, user);
const dir = coverDirPath(this.booksPath, bookId);
const files = await readdir(dir).catch(() => [] as string[]);
for (const f of files.filter((f) => f.startsWith('cover_custom.'))) {
await unlink(join(dir, f)).catch(() => {});
}
const extractedPath = await this.findExtractedCover(bookId);
if (extractedPath) {
const bytes = await readFile(extractedPath);
const thumb = await generateThumbnail(bytes);
await writeFile(join(dir, 'thumbnail.jpg'), thumb);
await this.setCoverSource(bookId, 'extracted');
return 'extracted';
} else {
await unlink(join(dir, 'thumbnail.jpg')).catch(() => {});
await this.setCoverSource(bookId, null);
return null;
}
}
private async saveCustomCover(bookId: number, buffer: Buffer): Promise<void> {
const dir = coverDirPath(this.booksPath, bookId);
await mkdir(dir, { recursive: true });
const existing = await readdir(dir).catch(() => [] as string[]);
for (const f of existing.filter((f) => f.startsWith('cover_custom.'))) {
await unlink(join(dir, f)).catch(() => {});
}
await this.deleteFilesByPrefix(dir, COVER_CUSTOM_FILE_PREFIX);
const ext = imageExt(buffer);
await writeFile(join(dir, `cover_custom.${ext}`), buffer);
await writeFile(join(dir, `${COVER_CUSTOM_FILE_PREFIX}${ext}`), buffer);
const thumb = await generateThumbnail(buffer);
await writeFile(join(dir, 'thumbnail.jpg'), thumb);
await writeFile(join(dir, COVER_THUMBNAIL_FILE_NAME), thumb);
}
private async findExtractedCover(bookId: number): Promise<string | null> {
const dir = coverDirPath(this.booksPath, bookId);
try {
const files = await readdir(dir);
const found = files.find((f) => f.startsWith('cover_extracted.'));
return found ? join(dir, found) : null;
} catch {
return null;
}
const files = await this.readDirIfExists(dir);
const found = files.find((fileName) => fileName.startsWith(COVER_EXTRACTED_FILE_PREFIX));
return found ? join(dir, found) : null;
}
private async verifyAccess(bookId: number, user: RequestUser): Promise<void> {
@@ -195,39 +257,240 @@ export class CoverService {
.onConflictDoUpdate({ target: bookMetadata.bookId, set: { coverSource: source, updatedAt: now } });
}
private async fetchImageFromUrl(url: string): Promise<Buffer> {
private async fetchRemoteImage(rawUrl: string): Promise<{ buffer: Buffer; contentType: string }> {
const parsedUrl = await this.parseRemoteImageUrl(rawUrl);
return this.fetchRemoteImageWithRedirects(parsedUrl);
}
private async parseRemoteImageUrl(rawUrl: string): Promise<URL> {
const candidate = rawUrl.trim();
if (!candidate) throw new BadRequestException('Invalid URL');
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
parsedUrl = new URL(candidate);
} catch {
throw new BadRequestException('Invalid URL');
}
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
if (!SAFE_REMOTE_PROTOCOLS.has(parsedUrl.protocol)) {
throw new BadRequestException('URL must use http or https');
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new BadRequestException(`Failed to fetch image: HTTP ${res.status}`);
const contentType = res.headers.get('content-type') ?? '';
if (!contentType.startsWith('image/')) throw new BadRequestException('URL does not point to an image');
await this.ensureSafeRemoteHost(parsedUrl.hostname);
return parsedUrl;
}
if (!res.body) throw new BadRequestException('Empty response body');
const chunks: Uint8Array[] = [];
let total = 0;
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
total += chunk.length;
if (total > MAX_IMAGE_BYTES) throw new BadRequestException('Image exceeds 20 MB limit');
chunks.push(chunk);
private async fetchRemoteImageWithRedirects(url: URL, redirectCount = 0): Promise<{ buffer: Buffer; contentType: string }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), COVER_PROXY_TIMEOUT_MS);
try {
const response = await fetch(url.toString(), {
signal: controller.signal,
redirect: 'manual',
headers: {
'User-Agent': COVER_PROXY_USER_AGENT,
Accept: 'image/*',
},
});
if (this.isRedirectStatus(response.status)) {
if (redirectCount >= COVER_PROXY_MAX_REDIRECTS) {
throw new BadRequestException('Too many image redirects');
}
const location = response.headers.get('location');
if (!location) throw new BadRequestException('Image redirect is missing location');
const redirectUrl = await this.resolveRedirectUrl(url, location);
return this.fetchRemoteImageWithRedirects(redirectUrl, redirectCount + 1);
}
if (!response.ok) throw new BadRequestException(`Failed to fetch image: HTTP ${response.status}`);
const contentType = this.extractImageContentType(response);
const buffer = await this.readImageBuffer(response);
return { buffer, contentType };
} catch (error) {
if (error instanceof BadRequestException) throw error;
if (error instanceof Error && error.name === 'AbortError') {
throw new BadRequestException('Image request timed out');
}
return Buffer.concat(chunks);
} catch (err) {
if (err instanceof BadRequestException) throw err;
throw new BadRequestException('Failed to fetch image from URL');
} finally {
clearTimeout(timeout);
}
}
private async resolveRedirectUrl(currentUrl: URL, location: string): Promise<URL> {
let redirectUrl: URL;
try {
redirectUrl = new URL(location, currentUrl);
} catch {
throw new BadRequestException('Image redirect URL is invalid');
}
if (!SAFE_REMOTE_PROTOCOLS.has(redirectUrl.protocol)) {
throw new BadRequestException('URL must use http or https');
}
await this.ensureSafeRemoteHost(redirectUrl.hostname);
return redirectUrl;
}
private async ensureSafeRemoteHost(hostname: string): Promise<void> {
const normalizedHost = hostname.trim().toLowerCase();
if (!normalizedHost) throw new BadRequestException('URL host is required');
if (normalizedHost === 'localhost' || normalizedHost.endsWith('.localhost') || normalizedHost.endsWith('.local')) {
throw new BadRequestException('URL host is not allowed');
}
const ipFamily = isIP(normalizedHost);
if (ipFamily > 0) {
if (isPrivateOrLocalAddress(normalizedHost)) {
throw new BadRequestException('URL host is not allowed');
}
return;
}
let resolved;
try {
resolved = await lookup(normalizedHost, { all: true, verbatim: true });
} catch {
throw new BadRequestException('Unable to resolve URL host');
}
if (resolved.length === 0) throw new BadRequestException('Unable to resolve URL host');
if (resolved.some((entry) => isPrivateOrLocalAddress(entry.address))) {
throw new BadRequestException('URL host is not allowed');
}
}
private extractImageContentType(response: Response): string {
const headerValue = response.headers.get('content-type') ?? '';
const contentType = headerValue.split(';')[0]?.trim().toLowerCase();
if (!contentType?.startsWith('image/')) {
throw new BadRequestException('URL does not point to an image');
}
return contentType;
}
private async readImageBuffer(response: Response): Promise<Buffer> {
if (!response.body) throw new BadRequestException('Empty response body');
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
totalBytes += value.byteLength;
if (totalBytes > COVER_PROXY_MAX_IMAGE_BYTES) {
throw new BadRequestException('Image exceeds 20 MB limit');
}
chunks.push(value);
}
return Buffer.concat(chunks);
}
private async readDirIfExists(path: string): Promise<string[]> {
try {
return await readdir(path);
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) return [];
throw error;
}
}
private async deleteFilesByPrefix(path: string, prefix: string): Promise<void> {
const files = await this.readDirIfExists(path);
for (const fileName of files) {
if (!fileName.startsWith(prefix)) continue;
await this.removeFileIfPresent(join(path, fileName));
}
}
private async removeFileIfPresent(path: string): Promise<void> {
try {
await unlink(path);
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) return;
throw error;
}
}
private isRedirectStatus(status: number): boolean {
return REDIRECT_STATUS_CODES.has(status);
}
}
function hostForLog(rawUrl: string): string {
try {
return new URL(rawUrl).hostname || 'invalid';
} catch {
return 'invalid';
}
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === 'object' && 'code' in error && (error as { code?: string }).code === code);
}
function sanitizeErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return message.replace(/"/g, '\\"');
}
function errorClass(error: unknown): string {
return error instanceof Error ? error.name : 'Error';
}
function isPrivateOrLocalAddress(address: string): boolean {
const normalized = address.toLowerCase();
const mappedV4Prefix = '::ffff:';
const maybeV4 = normalized.startsWith(mappedV4Prefix) ? normalized.slice(mappedV4Prefix.length) : normalized;
const family = isIP(maybeV4);
if (family === 4) {
return isPrivateOrLocalV4(maybeV4);
}
if (family === 6) {
return (
maybeV4 === '::1' ||
maybeV4 === '::' ||
maybeV4.startsWith('fc') ||
maybeV4.startsWith('fd') ||
maybeV4.startsWith('fe8') ||
maybeV4.startsWith('fe9') ||
maybeV4.startsWith('fea') ||
maybeV4.startsWith('feb')
);
}
return true;
}
function isPrivateOrLocalV4(address: string): boolean {
const octets = address.split('.').map((part) => Number(part));
if (octets.length !== 4 || octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) {
return true;
}
if (octets[0] === 10) return true;
if (octets[0] === 127) return true;
if (octets[0] === 0) return true;
if (octets[0] === 169 && octets[1] === 254) return true;
if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) return true;
if (octets[0] === 192 && octets[1] === 168) return true;
if (octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127) return true;
if (octets[0] >= 224) return true;
return false;
}
@@ -0,0 +1,51 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { SearchCoversQueryDto } from './search-covers-query.dto';
async function validateInput(input: Record<string, unknown>) {
const dto = plainToInstance(SearchCoversQueryDto, input);
const errors = await validate(dto);
return { dto, errors };
}
describe('SearchCoversQueryDto', () => {
it('accepts a valid minimal query', async () => {
const { errors } = await validateInput({ title: 'Dune' });
expect(errors).toHaveLength(0);
});
it('trims title and author values', async () => {
const { dto, errors } = await validateInput({ title: ' Dune ', author: ' Frank Herbert ' });
expect(errors).toHaveLength(0);
expect(dto.title).toBe('Dune');
expect(dto.author).toBe('Frank Herbert');
});
it('parses boolean query values for isAudiobook', async () => {
const { dto, errors } = await validateInput({ title: 'Dune', isAudiobook: ' TRUE ' });
expect(errors).toHaveLength(0);
expect(dto.isAudiobook).toBe(true);
});
it('rejects invalid isAudiobook values', async () => {
const { errors } = await validateInput({ title: 'Dune', isAudiobook: 'yes' });
expect(errors).toEqual(expect.arrayContaining([expect.objectContaining({ property: 'isAudiobook' })]));
});
it('allows known provider values and rejects unknown values', async () => {
expect((await validateInput({ title: 'Dune', provider: 'duckduckgo' })).errors).toHaveLength(0);
expect((await validateInput({ title: 'Dune', provider: 'itunes' })).errors).toHaveLength(0);
expect((await validateInput({ title: 'Dune', provider: 'all' })).errors).toHaveLength(0);
expect((await validateInput({ title: 'Dune', provider: 'unknown' })).errors).toEqual(
expect.arrayContaining([expect.objectContaining({ property: 'provider' })]),
);
});
it('requires a non-empty title', async () => {
expect((await validateInput({})).errors).toEqual(expect.arrayContaining([expect.objectContaining({ property: 'title' })]));
expect((await validateInput({ title: ' ' })).errors).toEqual(expect.arrayContaining([expect.objectContaining({ property: 'title' })]));
});
});
@@ -0,0 +1,44 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { COVER_PROVIDER_ALL_KEY, COVER_PROVIDER_KEYS, type CoverSearchProvider } from '../providers/cover-provider';
const COVER_SEARCH_PROVIDER_VALUES = [...COVER_PROVIDER_KEYS, COVER_PROVIDER_ALL_KEY] as const;
function trimQueryString(value: unknown): unknown {
return typeof value === 'string' ? value.trim() : value;
}
function parseBooleanQueryValue(value: unknown): unknown {
if (value === undefined) return undefined;
if (value === true || value === false) return value;
if (typeof value !== 'string') return value;
const normalized = value.trim().toLowerCase();
if (normalized === 'true') return true;
if (normalized === 'false') return false;
return value;
}
export class SearchCoversQueryDto {
@Transform(({ value }) => trimQueryString(value))
@IsString()
@IsNotEmpty()
title!: string;
@IsOptional()
@Transform(({ value }) => trimQueryString(value))
@IsString()
@IsNotEmpty()
author?: string;
@IsOptional()
@Transform(({ value }) => parseBooleanQueryValue(value))
@IsBoolean()
isAudiobook?: boolean;
@IsOptional()
@IsString()
@IsIn(COVER_SEARCH_PROVIDER_VALUES)
provider?: CoverSearchProvider;
}
@@ -1,6 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { CoverProvider } from './providers/cover-provider';
import { CoverProvider, DUCKDUCKGO_PROVIDER_KEY, ITUNES_PROVIDER_KEY } from './providers/cover-provider';
import { CoverProviderRegistry } from './provider-registry';
function makeProvider(key: CoverProvider['key']): CoverProvider {
@@ -11,8 +11,8 @@ function makeProvider(key: CoverProvider['key']): CoverProvider {
}
describe('CoverProviderRegistry', () => {
const duckduckgo = makeProvider('duckduckgo');
const itunes = makeProvider('itunes');
const duckduckgo = makeProvider(DUCKDUCKGO_PROVIDER_KEY);
const itunes = makeProvider(ITUNES_PROVIDER_KEY);
const registry = new CoverProviderRegistry([duckduckgo, itunes]);
it('defaults to duckduckgo when provider is omitted', () => {
@@ -24,7 +24,11 @@ describe('CoverProviderRegistry', () => {
});
it('returns the requested provider', () => {
expect(registry.select('itunes')).toEqual([itunes]);
expect(registry.select(ITUNES_PROVIDER_KEY)).toEqual([itunes]);
});
it('trims provider values before selecting', () => {
expect(registry.select(` ${ITUNES_PROVIDER_KEY} `)).toEqual([itunes]);
});
it('throws for unknown provider keys', () => {
@@ -1,7 +1,7 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { COVER_PROVIDERS } from './constants';
import { CoverProvider, CoverProviderKey } from './providers/cover-provider';
import { COVER_PROVIDER_ALL_KEY, COVER_PROVIDER_KEYS, CoverProvider, CoverProviderKey, DEFAULT_COVER_PROVIDER_KEY } from './providers/cover-provider';
@Injectable()
export class CoverProviderRegistry {
@@ -10,20 +10,20 @@ export class CoverProviderRegistry {
private readonly providers: CoverProvider[],
) {}
all(): CoverProvider[] {
return this.providers;
}
select(provider?: string): CoverProvider[] {
const selected = provider?.trim();
if (!selected) return [this.requireProvider('duckduckgo')];
if (selected === 'all') return this.providers;
if (selected === 'duckduckgo' || selected === 'itunes') {
if (!selected) return [this.requireProvider(DEFAULT_COVER_PROVIDER_KEY)];
if (selected === COVER_PROVIDER_ALL_KEY) return this.providers;
if (this.isCoverProviderKey(selected)) {
return [this.requireProvider(selected)];
}
throw new BadRequestException(`Unknown cover provider: ${selected}`);
}
private isCoverProviderKey(provider: string): provider is CoverProviderKey {
return COVER_PROVIDER_KEYS.includes(provider as CoverProviderKey);
}
private requireProvider(key: CoverProviderKey): CoverProvider {
const found = this.providers.find((provider) => provider.key === key);
if (!found) throw new BadRequestException(`Cover provider not registered: ${key}`);
@@ -1,8 +1,13 @@
import { CoverSearchResult } from '@projectx/types';
export const COVER_PROVIDER_KEYS = ['duckduckgo', 'itunes'] as const;
export const DUCKDUCKGO_PROVIDER_KEY = 'duckduckgo' as const;
export const ITUNES_PROVIDER_KEY = 'itunes' as const;
export const COVER_PROVIDER_KEYS = [DUCKDUCKGO_PROVIDER_KEY, ITUNES_PROVIDER_KEY] as const;
export const COVER_PROVIDER_ALL_KEY = 'all' as const;
export const DEFAULT_COVER_PROVIDER_KEY = DUCKDUCKGO_PROVIDER_KEY;
export type CoverProviderKey = (typeof COVER_PROVIDER_KEYS)[number];
export type CoverSearchProvider = CoverProviderKey | 'all';
export type CoverSearchProvider = CoverProviderKey | typeof COVER_PROVIDER_ALL_KEY;
export interface CoverSearchParams {
title: string;
@@ -1,21 +1,18 @@
import { Injectable, Logger } from '@nestjs/common';
import { CoverSearchResult } from '@projectx/types';
import { CoverProvider, CoverSearchParams } from './cover-provider';
import { COVER_PROXY_USER_AGENT } from '../constants';
import { CoverProvider, CoverSearchParams, DUCKDUCKGO_PROVIDER_KEY } from './cover-provider';
@Injectable()
export class DuckDuckGoCoverProvider implements CoverProvider {
private readonly logger = new Logger(DuckDuckGoCoverProvider.name);
readonly key = 'duckduckgo';
readonly key = DUCKDUCKGO_PROVIDER_KEY;
private static readonly SEARCH_BASE_URL = 'https://duckduckgo.com/?q=';
private static readonly JSON_BASE_URL = 'https://duckduckgo.com/i.js?o=json&q=';
private static readonly SITE_FILTER = '+(site%3Aamazon.com+OR+site%3Agoodreads.com)';
private static readonly SEARCH_PARAMS_TALL = '&iar=images&iaf=size%3ALarge%2Clayout%3ATall';
private static readonly SEARCH_PARAMS_SQUARE = '&iar=images&iaf=size%3ALarge%2Clayout%3ASquare';
private static readonly USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
async search(params: CoverSearchParams): Promise<CoverSearchResult[]> {
const { title, author, isAudiobook } = params;
const bookType = isAudiobook ? 'audiobook' : 'book';
@@ -70,7 +67,7 @@ export class DuckDuckGoCoverProvider implements CoverProvider {
const response = await fetch(searchUrl, {
headers: {
'User-Agent': DuckDuckGoCoverProvider.USER_AGENT,
'User-Agent': COVER_PROXY_USER_AGENT,
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
},
@@ -92,7 +89,7 @@ export class DuckDuckGoCoverProvider implements CoverProvider {
const apiUrl = `${DuckDuckGoCoverProvider.JSON_BASE_URL}${encodeURIComponent(query)}${searchParams}&vqd=${vqd}`;
const apiResponse = await fetch(apiUrl, {
headers: {
'User-Agent': DuckDuckGoCoverProvider.USER_AGENT,
'User-Agent': COVER_PROXY_USER_AGENT,
Accept: 'application/json, text/javascript, */*; q=0.01',
Referer: searchUrl,
'x-vqd-4': vqd,
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { CoverSearchResult, type ITunesCoverResolution } from '@projectx/types';
import { ProviderConfigService } from '../../metadata-preferences/provider-config.service';
import { CoverProvider, CoverSearchParams } from './cover-provider';
import { CoverProvider, CoverSearchParams, ITUNES_PROVIDER_KEY } from './cover-provider';
type ITunesCoverSearchResult = {
artworkUrl100?: string;
@@ -15,7 +15,7 @@ type ITunesCoverSearchResponse = {
@Injectable()
export class ITunesCoverProvider implements CoverProvider {
readonly key = 'itunes' as const;
readonly key = ITUNES_PROVIDER_KEY;
private readonly logger = new Logger(ITunesCoverProvider.name);
private static readonly SEARCH_URL = 'https://itunes.apple.com/search';
@@ -16,6 +16,6 @@ export class DashboardController {
@Query('lensId', new DefaultValuePipe(0), ParseIntPipe) lensId: number,
@CurrentUser() user: RequestUser,
) {
return this.dashboardService.getScroller(type, user, limit, lensId || undefined);
return this.dashboardService.getScroller(type, user, limit, lensId);
}
}
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { BookModule } from '../book/book.module';
import { LensModule } from '../lens/lens.module';
import { LibraryModule } from '../library/library.module';
import { DashboardController } from './dashboard.controller';
@@ -7,7 +8,7 @@ import { DashboardRepository } from './dashboard.repository';
import { DashboardService } from './dashboard.service';
@Module({
imports: [LibraryModule, LensModule],
imports: [BookModule, LibraryModule, LensModule],
controllers: [DashboardController],
providers: [DashboardService, DashboardRepository],
})
@@ -0,0 +1,131 @@
import { DashboardRepository } from './dashboard.repository';
function makeCountChain<T>(rows: T) {
const chain: Record<string, vi.Mock> = {
from: vi.fn(),
leftJoin: vi.fn(),
where: vi.fn(),
};
chain.from.mockReturnValue(chain);
chain.leftJoin.mockReturnValue(chain);
chain.where.mockResolvedValue(rows);
return chain;
}
function makeLimitChain<T>(rows: T) {
const chain: Record<string, vi.Mock> = {
from: vi.fn(),
innerJoin: vi.fn(),
leftJoin: vi.fn(),
where: vi.fn(),
orderBy: vi.fn(),
limit: vi.fn(),
offset: vi.fn(),
};
chain.from.mockReturnValue(chain);
chain.innerJoin.mockReturnValue(chain);
chain.leftJoin.mockReturnValue(chain);
chain.where.mockReturnValue(chain);
chain.orderBy.mockReturnValue(chain);
chain.limit.mockResolvedValue(rows);
return chain;
}
function makeOffsetChain<T>(rows: T) {
const chain: Record<string, vi.Mock> = {
from: vi.fn(),
leftJoin: vi.fn(),
where: vi.fn(),
orderBy: vi.fn(),
limit: vi.fn(),
offset: vi.fn(),
};
chain.from.mockReturnValue(chain);
chain.leftJoin.mockReturnValue(chain);
chain.where.mockReturnValue(chain);
chain.orderBy.mockReturnValue(chain);
chain.limit.mockReturnValue(chain);
chain.offset.mockResolvedValue(rows);
return chain;
}
describe('DashboardRepository', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
it('returns empty results without querying when no libraries are accessible', async () => {
const db = { select: vi.fn() };
const repo = new DashboardRepository(db as never);
await expect(repo.findRecentlyAddedBookIds([], 20)).resolves.toEqual([]);
await expect(repo.findContinueReadingBookIds([], 1, 20)).resolves.toEqual([]);
await expect(repo.findRandomBookIds([], 1, 20)).resolves.toEqual([]);
expect(db.select).not.toHaveBeenCalled();
});
it('maps recently added rows to id list', async () => {
const listChain = makeLimitChain([{ id: 5 }, { id: 2 }]);
const db = { select: vi.fn().mockReturnValue(listChain) };
const repo = new DashboardRepository(db as never);
const result = await repo.findRecentlyAddedBookIds([10], 2);
expect(result).toEqual([5, 2]);
expect(listChain.limit).toHaveBeenCalledWith(2);
});
it('maps continue-reading rows to id list', async () => {
const listChain = makeLimitChain([{ id: 40 }, { id: 9 }]);
const db = { select: vi.fn().mockReturnValue(listChain) };
const repo = new DashboardRepository(db as never);
const result = await repo.findContinueReadingBookIds([8], 55, 10);
expect(result).toEqual([40, 9]);
expect(listChain.limit).toHaveBeenCalledWith(10);
});
it('returns empty random ids when there are no candidates', async () => {
const countChain = makeCountChain([{ total: '0' }]);
const db = { select: vi.fn().mockReturnValue(countChain) };
const repo = new DashboardRepository(db as never);
const result = await repo.findRandomBookIds([5], 7, 20);
expect(result).toEqual([]);
expect(db.select).toHaveBeenCalledTimes(1);
});
it('uses zero offset for random ids when total candidates are below requested limit', async () => {
const countChain = makeCountChain([{ total: '3' }]);
const listChain = makeOffsetChain([{ id: 1 }, { id: 2 }, { id: 3 }]);
const db = {
select: vi.fn().mockReturnValueOnce(countChain).mockReturnValueOnce(listChain),
};
const repo = new DashboardRepository(db as never);
const result = await repo.findRandomBookIds([5], 7, 20);
expect(result).toEqual([1, 2, 3]);
expect(listChain.limit).toHaveBeenCalledWith(20);
expect(listChain.offset).toHaveBeenCalledWith(0);
});
it('computes random offset within candidate range before reading random ids', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.5);
const countChain = makeCountChain([{ total: '12' }]);
const listChain = makeOffsetChain([{ id: 21 }, { id: 22 }]);
const db = {
select: vi.fn().mockReturnValueOnce(countChain).mockReturnValueOnce(listChain),
};
const repo = new DashboardRepository(db as never);
const result = await repo.findRandomBookIds([3, 4], 99, 5);
expect(result).toEqual([21, 22]);
expect(listChain.offset).toHaveBeenCalledWith(4);
randomSpy.mockRestore();
});
});
@@ -1,88 +1,34 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, desc, eq, gt, inArray, isNull, lt, or, sql } from 'drizzle-orm';
import { and, asc, count, desc, eq, gt, inArray, isNull, lt, or } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { BookCard } from '@projectx/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { authors, bookAuthors, bookFiles, bookGenres, bookMetadata, books, genres, readingProgress } from '../../db/schema';
import { assembleBookCards } from '../book/utils/assemble-book-cards';
import { bookFiles, books, readingProgress } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
const BOOK_CARD_FIELDS = {
id: books.id,
status: books.status,
primaryFileId: books.primaryFileId,
folderPath: books.folderPath,
addedAt: books.addedAt,
title: bookMetadata.title,
seriesName: bookMetadata.seriesName,
seriesIndex: bookMetadata.seriesIndex,
publishedYear: bookMetadata.publishedYear,
language: bookMetadata.language,
rating: bookMetadata.rating,
};
@Injectable()
export class DashboardRepository {
constructor(@Inject(DB) private readonly db: Db) {}
private async fetchSupplementary(bookIds: number[], userId: number) {
if (bookIds.length === 0) {
return { authorRows: [], fileRows: [], genreRows: [], progressRows: [] };
}
const [authorRows, fileRows, genreRows, primaryRows] = await Promise.all([
this.db
.select({ bookId: bookAuthors.bookId, name: authors.name })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(inArray(bookAuthors.bookId, bookIds))
.orderBy(bookAuthors.displayOrder),
this.db
.select({ bookId: bookFiles.bookId, id: bookFiles.id, format: bookFiles.format, role: bookFiles.role })
.from(bookFiles)
.where(inArray(bookFiles.bookId, bookIds)),
this.db
.select({ bookId: bookGenres.bookId, name: genres.name })
.from(bookGenres)
.innerJoin(genres, eq(genres.id, bookGenres.genreId))
.where(inArray(bookGenres.bookId, bookIds)),
this.db.select({ id: books.id, primaryFileId: books.primaryFileId }).from(books).where(inArray(books.id, bookIds)),
]);
const primaryFileIds = primaryRows.map((r) => r.primaryFileId).filter((id): id is number => id != null);
const progressRows =
primaryFileIds.length > 0
? await this.db
.select({ bookFileId: readingProgress.bookFileId, percentage: readingProgress.percentage })
.from(readingProgress)
.where(and(eq(readingProgress.userId, userId), inArray(readingProgress.bookFileId, primaryFileIds)))
: [];
return { authorRows, fileRows, genreRows, progressRows };
}
async findRecentlyAdded(accessibleLibraryIds: number[], userId: number, limit: number): Promise<BookCard[]> {
async findRecentlyAddedBookIds(accessibleLibraryIds: number[], limit: number): Promise<number[]> {
if (accessibleLibraryIds.length === 0) return [];
const rows = await this.db
.select(BOOK_CARD_FIELDS)
.select({ id: books.id })
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(inArray(books.libraryId, accessibleLibraryIds))
.orderBy(desc(books.addedAt))
.orderBy(desc(books.addedAt), desc(books.id))
.limit(limit);
const bookIds = rows.map((r) => r.id);
const { authorRows, fileRows, genreRows, progressRows } = await this.fetchSupplementary(bookIds, userId);
return assembleBookCards(rows, authorRows, fileRows, genreRows, progressRows);
return rows.map((row) => row.id);
}
async findContinueReading(accessibleLibraryIds: number[], userId: number, limit: number): Promise<BookCard[]> {
const rawRows = await this.db
.select(BOOK_CARD_FIELDS)
async findContinueReadingBookIds(accessibleLibraryIds: number[], userId: number, limit: number): Promise<number[]> {
if (accessibleLibraryIds.length === 0) return [];
const rows = await this.db
.select({ id: books.id })
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.innerJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.innerJoin(
readingProgress,
@@ -94,33 +40,41 @@ export class DashboardRepository {
),
)
.where(inArray(books.libraryId, accessibleLibraryIds))
.orderBy(desc(readingProgress.updatedAt))
.orderBy(desc(readingProgress.updatedAt), desc(books.id))
.limit(limit);
const seen = new Set<number>();
const rows = rawRows.filter((r) => !seen.has(r.id) && seen.add(r.id));
const bookIds = rows.map((r) => r.id);
const { authorRows, fileRows, genreRows, progressRows } = await this.fetchSupplementary(bookIds, userId);
return assembleBookCards(rows, authorRows, fileRows, genreRows, progressRows);
return rows.map((row) => row.id);
}
async findRandom(accessibleLibraryIds: number[], userId: number, limit: number): Promise<BookCard[]> {
const rawRows = await this.db
.select(BOOK_CARD_FIELDS)
async findRandomBookIds(accessibleLibraryIds: number[], userId: number, limit: number): Promise<number[]> {
if (accessibleLibraryIds.length === 0) return [];
const unreadFilter = and(
inArray(books.libraryId, accessibleLibraryIds),
or(isNull(readingProgress.bookFileId), eq(readingProgress.percentage, 0)),
);
const [{ total }] = await this.db
.select({ total: count() })
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.leftJoin(readingProgress, and(eq(readingProgress.bookFileId, bookFiles.id), eq(readingProgress.userId, userId)))
.where(and(inArray(books.libraryId, accessibleLibraryIds), or(isNull(readingProgress.bookFileId), eq(readingProgress.percentage, 0))))
.orderBy(sql.raw('RANDOM()'))
.limit(limit);
.where(unreadFilter);
const seen = new Set<number>();
const rows = rawRows.filter((r) => !seen.has(r.id) && seen.add(r.id));
const totalCandidates = Number(total);
if (totalCandidates === 0) return [];
const bookIds = rows.map((r) => r.id);
const { authorRows, fileRows, genreRows, progressRows } = await this.fetchSupplementary(bookIds, userId);
return assembleBookCards(rows, authorRows, fileRows, genreRows, progressRows);
const maxOffset = Math.max(totalCandidates - limit, 0);
const offset = maxOffset === 0 ? 0 : Math.floor(Math.random() * (maxOffset + 1));
const rows = await this.db
.select({ id: books.id })
.from(books)
.leftJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.leftJoin(readingProgress, and(eq(readingProgress.bookFileId, bookFiles.id), eq(readingProgress.userId, userId)))
.where(unreadFilter)
.orderBy(asc(books.id))
.limit(limit)
.offset(offset);
return rows.map((row) => row.id);
}
}
@@ -0,0 +1,145 @@
import { BadRequestException } from '@nestjs/common';
import type { RequestUser } from '../../common/types/request-user';
import { DashboardService } from './dashboard.service';
import { ScrollerType } from './dto/scroller-type.enum';
function makeUser(overrides: Partial<RequestUser> = {}): RequestUser {
return {
id: 42,
username: 'reader',
name: 'Reader',
email: null,
active: true,
isSuperuser: false,
isDefaultPassword: false,
tokenVersion: 1,
settings: {},
avatarUrl: null,
provisioningMethod: 'local',
permissions: [],
...overrides,
};
}
function makeService() {
const dashboardRepo = {
findRecentlyAddedBookIds: vi.fn(),
findContinueReadingBookIds: vi.fn(),
findRandomBookIds: vi.fn(),
};
const bookReadService = {
findCardsByBookIds: vi.fn(),
};
const libraryService = {
findAccessibleLibraryIds: vi.fn(),
};
const lensService = {
executeLens: vi.fn(),
};
const service = new DashboardService(dashboardRepo as never, bookReadService as never, libraryService as never, lensService as never);
return { service, dashboardRepo, bookReadService, libraryService, lensService };
}
function makeFindCardsResult(idsInRowOrder: number[]) {
const now = new Date('2026-01-01T00:00:00.000Z');
return {
rows: idsInRowOrder.map((id) => ({
id,
status: 'present',
primaryFileId: id * 10,
folderPath: `/books/${id}`,
addedAt: now,
title: `Book ${id}`,
seriesName: null,
seriesIndex: null,
publishedYear: null,
language: null,
rating: null,
})),
authorRows: [],
fileRows: idsInRowOrder.map((id) => ({ bookId: id, id: id * 10, format: 'epub', role: 'primary' })),
genreRows: [],
progressRows: [],
statusRows: [],
total: idsInRowOrder.length,
};
}
describe('DashboardService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('rejects lens scroller calls when lensId is missing or invalid', async () => {
const { service, lensService } = makeService();
await expect(service.getScroller(ScrollerType.LENS, makeUser(), 20, 0)).rejects.toThrow(BadRequestException);
await expect(service.getScroller(ScrollerType.LENS, makeUser(), 20, -2)).rejects.toThrow(BadRequestException);
expect(lensService.executeLens).not.toHaveBeenCalled();
});
it('executes lens scroller with max limit clamp and returns lens items', async () => {
const { service, lensService } = makeService();
const user = makeUser({ id: 7 });
const items = [{ id: 11 }, { id: 12 }];
lensService.executeLens.mockResolvedValue({ items, total: 2, page: 0, size: 50 });
const result = await service.getScroller(ScrollerType.LENS, user, 999, 88);
expect(lensService.executeLens).toHaveBeenCalledWith(88, user, 0, 50);
expect(result).toEqual(items);
});
it('returns empty list when user has no accessible libraries', async () => {
const { service, dashboardRepo, bookReadService, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([]);
const result = await service.getScroller(ScrollerType.RECENTLY_ADDED, makeUser(), 20);
expect(result).toEqual([]);
expect(dashboardRepo.findRecentlyAddedBookIds).not.toHaveBeenCalled();
expect(bookReadService.findCardsByBookIds).not.toHaveBeenCalled();
});
it('loads recently added cards with min limit clamp and preserves repository id order', async () => {
const { service, dashboardRepo, bookReadService, libraryService } = makeService();
const user = makeUser({ id: 5 });
libraryService.findAccessibleLibraryIds.mockResolvedValue([100, 200]);
dashboardRepo.findRecentlyAddedBookIds.mockResolvedValue([9, 3]);
bookReadService.findCardsByBookIds.mockResolvedValue(makeFindCardsResult([3, 9]));
const result = await service.getScroller(ScrollerType.RECENTLY_ADDED, user, 0);
expect(dashboardRepo.findRecentlyAddedBookIds).toHaveBeenCalledWith([100, 200], 1);
expect(bookReadService.findCardsByBookIds).toHaveBeenCalledWith([9, 3], 5);
expect(result.map((card) => card.id)).toEqual([9, 3]);
});
it('routes continue reading requests to repository with clamped max limit', async () => {
const { service, dashboardRepo, bookReadService, libraryService } = makeService();
const user = makeUser({ id: 9 });
libraryService.findAccessibleLibraryIds.mockResolvedValue([301]);
dashboardRepo.findContinueReadingBookIds.mockResolvedValue([4]);
bookReadService.findCardsByBookIds.mockResolvedValue(makeFindCardsResult([4]));
const result = await service.getScroller(ScrollerType.CONTINUE_READING, user, 500);
expect(dashboardRepo.findContinueReadingBookIds).toHaveBeenCalledWith([301], 9, 50);
expect(result.map((card) => card.id)).toEqual([4]);
});
it('routes random requests to repository and skips card fetch when no ids are returned', async () => {
const { service, dashboardRepo, bookReadService, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([901]);
dashboardRepo.findRandomBookIds.mockResolvedValue([]);
const result = await service.getScroller(ScrollerType.RANDOM, makeUser({ id: 3 }), 20);
expect(dashboardRepo.findRandomBookIds).toHaveBeenCalledWith([901], 3, 20);
expect(result).toEqual([]);
expect(bookReadService.findCardsByBookIds).not.toHaveBeenCalled();
});
});
@@ -1,7 +1,9 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import type { BookCard } from '@projectx/types';
import type { RequestUser } from '../../common/types/request-user';
import { BookReadService } from '../book/book-read.service';
import { assembleBookCards } from '../book/utils/assemble-book-cards';
import { LensService } from '../lens/lens.service';
import { LibraryService } from '../library/library.service';
import { DashboardRepository } from './dashboard.repository';
@@ -13,32 +15,46 @@ const MAX_LIMIT = 50;
export class DashboardService {
constructor(
private readonly dashboardRepo: DashboardRepository,
private readonly bookReadService: BookReadService,
private readonly libraryService: LibraryService,
private readonly lensService: LensService,
) {}
private async loadCardsByIds(bookIds: number[], userId: number): Promise<BookCard[]> {
if (bookIds.length === 0) return [];
const { rows, authorRows, fileRows, genreRows, progressRows } = await this.bookReadService.findCardsByBookIds(bookIds, userId);
const cards = assembleBookCards(rows, authorRows, fileRows, genreRows, progressRows);
const cardsById = new Map(cards.map((card) => [card.id, card]));
return bookIds.map((id) => cardsById.get(id)).filter((card): card is BookCard => card != null);
}
async getScroller(type: ScrollerType, user: RequestUser, limit: number, lensId?: number): Promise<BookCard[]> {
const clampedLimit = Math.min(Math.max(1, limit), MAX_LIMIT);
if (type === ScrollerType.LENS) {
if (!lensId) return [];
if (!lensId || lensId <= 0) {
throw new BadRequestException('lensId is required and must be a positive integer when scroller type is lens');
}
const result = await this.lensService.executeLens(lensId, user, 0, clampedLimit);
return result.items;
}
const libs = await this.libraryService.findAll(user);
const accessibleLibraryIds = libs.map((l) => l.id);
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
if (accessibleLibraryIds.length === 0) return [];
let bookIds: number[];
switch (type) {
case ScrollerType.RECENTLY_ADDED:
return this.dashboardRepo.findRecentlyAdded(accessibleLibraryIds, user.id, clampedLimit);
bookIds = await this.dashboardRepo.findRecentlyAddedBookIds(accessibleLibraryIds, clampedLimit);
break;
case ScrollerType.CONTINUE_READING:
return this.dashboardRepo.findContinueReading(accessibleLibraryIds, user.id, clampedLimit);
bookIds = await this.dashboardRepo.findContinueReadingBookIds(accessibleLibraryIds, user.id, clampedLimit);
break;
case ScrollerType.RANDOM:
return this.dashboardRepo.findRandom(accessibleLibraryIds, user.id, clampedLimit);
default:
return [];
bookIds = await this.dashboardRepo.findRandomBookIds(accessibleLibraryIds, user.id, clampedLimit);
break;
}
return this.loadCardsByIds(bookIds, user.id);
}
}
@@ -1,6 +1,5 @@
export enum ScrollerType {
RECENTLY_ADDED = 'recently-added',
CONTINUE_READING = 'continue-reading',
RANDOM = 'random',
LENS = 'lens',
}
import { SCROLLER_TYPE } from '@projectx/types';
import type { ScrollerType as SharedScrollerType } from '@projectx/types';
export const ScrollerType = SCROLLER_TYPE;
export type ScrollerType = SharedScrollerType;