From 0e9ae2ae1032140a01b79e9964b939e73a2ac198 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:56:58 -0600 Subject: [PATCH] feat(scanner): add kepub and audio format support to uploads --- .../composables/useBookBucketUpload.ts | 2 +- .../library/composables/useBookUpload.ts | 2 +- packages/types/src/library.ts | 2 + server/src/common/types/multipart-request.ts | 6 + .../book-bucket-ingest.service.test.ts | 223 +++++++++++ .../book-bucket/book-bucket-ingest.service.ts | 5 +- .../book-bucket/book-bucket.controller.ts | 7 +- server/src/modules/cover/cover.controller.ts | 6 +- .../src/modules/cover/cover.service.test.ts | 155 +++++++- server/src/modules/cover/cover.service.ts | 7 +- .../modules/library/library.service.test.ts | 2 +- .../src/modules/metadata/metadata.service.ts | 4 +- server/src/modules/upload/test-helpers.ts | 56 +++ .../upload/upload-processor.service.test.ts | 22 ++ .../upload/upload-processor.service.ts | 19 +- .../upload/upload-storage.service.test.ts | 43 +++ .../upload/upload-validator.service.test.ts | 60 +++ .../upload/upload-validator.service.ts | 2 + .../modules/upload/upload.controller.test.ts | 48 +++ .../src/modules/upload/upload.controller.ts | 7 +- .../src/modules/upload/upload.service.test.ts | 348 +++++++++++++++++- .../modules/user/user-avatar.service.test.ts | 14 + server/src/modules/user/user.controller.ts | 9 +- 23 files changed, 1018 insertions(+), 31 deletions(-) create mode 100644 server/src/common/types/multipart-request.ts create mode 100644 server/src/modules/book-bucket/book-bucket-ingest.service.test.ts create mode 100644 server/src/modules/upload/test-helpers.ts diff --git a/client/src/features/book-bucket/composables/useBookBucketUpload.ts b/client/src/features/book-bucket/composables/useBookBucketUpload.ts index d9a64e2cb..be1548e30 100644 --- a/client/src/features/book-bucket/composables/useBookBucketUpload.ts +++ b/client/src/features/book-bucket/composables/useBookBucketUpload.ts @@ -2,7 +2,7 @@ import { ref } from 'vue' import { getAccessToken } from '@/lib/api' import type { BookBucketFile } from '@projectx/types' -export const SUPPORTED_FORMATS = ['epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2'] +export const SUPPORTED_FORMATS = ['epub', 'kepub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2', 'm4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac'] export const SUPPORTED_FORMATS_ACCEPT = SUPPORTED_FORMATS.map((f) => `.${f}`).join(',') const MAX_UPLOAD_BYTES = 500 * 1024 * 1024 diff --git a/client/src/features/library/composables/useBookUpload.ts b/client/src/features/library/composables/useBookUpload.ts index 08ee7e148..20b0dbbb0 100644 --- a/client/src/features/library/composables/useBookUpload.ts +++ b/client/src/features/library/composables/useBookUpload.ts @@ -2,7 +2,7 @@ import { computed, ref } from 'vue' import { getAccessToken } from '@/lib/api' import type { UploadResult } from '@projectx/types' -export const SUPPORTED_FORMATS = ['epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2'] +export const SUPPORTED_FORMATS = ['epub', 'kepub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2', 'm4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac'] export const SUPPORTED_FORMATS_ACCEPT = SUPPORTED_FORMATS.map((f) => `.${f}`).join(',') export const MAX_UPLOAD_BYTES = 500 * 1024 * 1024 // 500 MB diff --git a/packages/types/src/library.ts b/packages/types/src/library.ts index 38c9ecea3..ad938f320 100644 --- a/packages/types/src/library.ts +++ b/packages/types/src/library.ts @@ -3,6 +3,7 @@ export type CoverAspectRatio = "2/3" | "1/1"; export const DEFAULT_FORMAT_PRIORITY = [ "epub", + "kepub", "pdf", "cbz", "cbr", @@ -21,6 +22,7 @@ export const DEFAULT_FORMAT_PRIORITY = [ export const FORMAT_LABELS: Record = { epub: "EPUB e-book", + kepub: "KEPUB e-book", pdf: "PDF document", cbz: "CBZ comic", cbr: "CBR comic", diff --git a/server/src/common/types/multipart-request.ts b/server/src/common/types/multipart-request.ts new file mode 100644 index 000000000..ee334d1ac --- /dev/null +++ b/server/src/common/types/multipart-request.ts @@ -0,0 +1,6 @@ +import type { MultipartFile } from '@fastify/multipart'; +import type { FastifyRequest } from 'fastify'; + +export type MultipartRequest = FastifyRequest & { + file: (opts?: object) => Promise; +}; diff --git a/server/src/modules/book-bucket/book-bucket-ingest.service.test.ts b/server/src/modules/book-bucket/book-bucket-ingest.service.test.ts new file mode 100644 index 000000000..050b6676d --- /dev/null +++ b/server/src/modules/book-bucket/book-bucket-ingest.service.test.ts @@ -0,0 +1,223 @@ +import { BadRequestException, Logger } from '@nestjs/common'; +import { Readable } from 'stream'; +import { stat } from 'fs/promises'; + +import { BookBucketIngestService } from './book-bucket-ingest.service'; + +vi.mock('fs/promises', () => ({ + stat: vi.fn(), +})); + +const mockedStat = vi.mocked(stat); + +function makeService() { + const config = { + get: vi.fn((key: string) => { + if (key === 'storage.booksPath') return '/books'; + if (key === 'storage.bookBucketPath') return undefined; + return undefined; + }), + }; + + const repo = { + create: vi.fn(), + findById: vi.fn(), + findByAbsolutePath: vi.fn().mockResolvedValue(null), + update: vi.fn(), + countsByStatus: vi.fn().mockResolvedValue({}), + }; + + const validator = { + sanitizeFilename: vi.fn(), + }; + + const storage = { + streamToTemp: vi.fn(), + moveToPath: vi.fn(), + cleanup: vi.fn().mockResolvedValue(undefined), + }; + + const metadataService = { + extractAndSave: vi.fn().mockResolvedValue(undefined), + }; + + const events = { + emit: vi.fn(), + }; + + const appSettings = { + isBookBucketAutoFetchEnabled: vi.fn().mockResolvedValue(false), + }; + + const metadataFetchPipeline = {}; + + const gateway = { + emitSummary: vi.fn(), + }; + + const service = new BookBucketIngestService( + config as never, + repo as never, + validator as never, + storage as never, + metadataService as never, + events as never, + appSettings as never, + metadataFetchPipeline as never, + gateway as never, + ); + + return { service, repo, validator, storage }; +} + +describe('BookBucketIngestService', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {}); + vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); + vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {}); + }); + + describe('ingestUpload', () => { + it('succeeds and returns the row ID', async () => { + const { service, validator, storage, repo } = makeService(); + + validator.sanitizeFilename.mockReturnValue('book.epub'); + mockedStat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + storage.streamToTemp.mockResolvedValue({ tempPath: '/tmp/upload.bin', sizeBytes: 1024 }); + storage.moveToPath.mockResolvedValue(undefined); + repo.create.mockResolvedValue({ id: 42 }); + + const result = await service.ingestUpload('raw.epub', new Readable({ read() {} })); + + expect(result).toBe(42); + expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/books/book-bucket/book.epub'); + expect(repo.create).toHaveBeenCalledWith({ + fileName: 'book.epub', + absolutePath: '/books/book-bucket/book.epub', + fileSize: 1024, + format: 'epub', + status: 'pending', + }); + }); + + it('rejects unsupported file format before streaming', async () => { + const { service, validator, storage } = makeService(); + + validator.sanitizeFilename.mockReturnValue('file.xyz'); + + const err = await service.ingestUpload('file.xyz', new Readable({ read() {} })).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(BadRequestException); + expect((err as BadRequestException).message).toMatch(/Unsupported file type/); + expect(storage.streamToTemp).not.toHaveBeenCalled(); + }); + + it('cleans both tempPath and destPath when repo.create fails after move', async () => { + const { service, validator, storage, repo } = makeService(); + + validator.sanitizeFilename.mockReturnValue('book.epub'); + mockedStat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + storage.streamToTemp.mockResolvedValue({ tempPath: '/tmp/upload.bin', sizeBytes: 1024 }); + storage.moveToPath.mockResolvedValue(undefined); + repo.create.mockRejectedValue(new Error('insert failed')); + + await expect(service.ingestUpload('raw.epub', new Readable({ read() {} }))).rejects.toThrow('insert failed'); + + expect(storage.cleanup).toHaveBeenCalledTimes(2); + expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin'); + expect(storage.cleanup).toHaveBeenCalledWith('/books/book-bucket/book.epub'); + }); + + it('cleans both tempPath and destPath when moveToPath fails', async () => { + const { service, validator, storage } = makeService(); + + validator.sanitizeFilename.mockReturnValue('book.epub'); + mockedStat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + storage.streamToTemp.mockResolvedValue({ tempPath: '/tmp/upload.bin', sizeBytes: 1024 }); + storage.moveToPath.mockRejectedValue(new Error('move failed')); + + await expect(service.ingestUpload('raw.epub', new Readable({ read() {} }))).rejects.toThrow('move failed'); + + expect(storage.cleanup).toHaveBeenCalledTimes(2); + expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin'); + expect(storage.cleanup).toHaveBeenCalledWith('/books/book-bucket/book.epub'); + }); + + it('appends a unique suffix when the destination filename already exists', async () => { + const { service, validator, storage, repo } = makeService(); + + validator.sanitizeFilename.mockReturnValue('book.epub'); + mockedStat.mockResolvedValue({ size: 100 } as never); + storage.streamToTemp.mockResolvedValue({ tempPath: '/tmp/upload.bin', sizeBytes: 1024 }); + storage.moveToPath.mockResolvedValue(undefined); + repo.create.mockResolvedValue({ id: 7 }); + + const result = await service.ingestUpload('raw.epub', new Readable({ read() {} })); + + expect(result).toBe(7); + const destArg = storage.moveToPath.mock.calls[0][1] as string; + expect(destArg).toMatch(/\/books\/book-bucket\/book-\d+-[a-z0-9]+\.epub$/); + }); + }); + + describe('ingestFromWatchedFolder', () => { + it('skips if file already exists in repo', async () => { + const { service, repo } = makeService(); + repo.findByAbsolutePath.mockResolvedValue({ id: 1 }); + + const result = await service.ingestFromWatchedFolder('/watched/book.epub'); + expect(result).toBeNull(); + expect(repo.create).not.toHaveBeenCalled(); + }); + + it('skips unsupported extensions', async () => { + const { service, repo } = makeService(); + + const result = await service.ingestFromWatchedFolder('/watched/readme.txt'); + expect(result).toBeNull(); + expect(repo.create).not.toHaveBeenCalled(); + }); + + it('skips if stat fails (file vanished)', async () => { + const { service, repo } = makeService(); + mockedStat.mockRejectedValue(new Error('ENOENT')); + + const result = await service.ingestFromWatchedFolder('/watched/book.epub'); + expect(result).toBeNull(); + expect(repo.create).not.toHaveBeenCalled(); + }); + + it('ingests a valid file from watched folder', async () => { + const { service, repo } = makeService(); + mockedStat.mockResolvedValue({ size: 2048 } as never); + repo.create.mockResolvedValue({ id: 10 }); + + const result = await service.ingestFromWatchedFolder('/watched/novel.epub'); + expect(result).toBe(10); + expect(repo.create).toHaveBeenCalledWith({ + fileName: 'novel.epub', + absolutePath: '/watched/novel.epub', + fileSize: 2048, + format: 'epub', + status: 'pending', + }); + }); + + it('logs warning when extractMetadataAsync fails', async () => { + const { service, repo } = makeService(); + mockedStat.mockResolvedValue({ size: 1024 } as never); + repo.create.mockResolvedValue({ id: 5 }); + + const metadataService = (service as any).metadataService; + metadataService.extractAndSave.mockRejectedValue(new Error('parse failed')); + + const result = await service.ingestFromWatchedFolder('/watched/book.epub'); + expect(result).toBe(5); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(Logger.prototype.warn).toHaveBeenCalledWith(expect.stringContaining('parse failed')); + }); + }); +}); diff --git a/server/src/modules/book-bucket/book-bucket-ingest.service.ts b/server/src/modules/book-bucket/book-bucket-ingest.service.ts index 46340490d..356f47d6d 100644 --- a/server/src/modules/book-bucket/book-bucket-ingest.service.ts +++ b/server/src/modules/book-bucket/book-bucket-ingest.service.ts @@ -43,9 +43,10 @@ export class BookBucketIngestService { } const { tempPath, sizeBytes } = await this.storage.streamToTemp(fileStream); + let destPath: string | null = null; try { - const destPath = await this.resolveUniquePath(join(this.bookBucketPath, filename)); + destPath = await this.resolveUniquePath(join(this.bookBucketPath, filename)); await this.storage.moveToPath(tempPath, destPath); @@ -61,7 +62,7 @@ export class BookBucketIngestService { return row.id; } catch (err) { - await this.storage.cleanup(tempPath); + await Promise.allSettled([this.storage.cleanup(tempPath), destPath ? this.storage.cleanup(destPath) : Promise.resolve()]); throw err; } } diff --git a/server/src/modules/book-bucket/book-bucket.controller.ts b/server/src/modules/book-bucket/book-bucket.controller.ts index 6e410752f..863c7d232 100644 --- a/server/src/modules/book-bucket/book-bucket.controller.ts +++ b/server/src/modules/book-bucket/book-bucket.controller.ts @@ -18,7 +18,7 @@ import { import { createReadStream } from 'fs'; import { access } from 'fs/promises'; import { Readable } from 'stream'; -import type { FastifyReply, FastifyRequest } from 'fastify'; +import type { FastifyReply } from 'fastify'; import { Permission } from '@projectx/types'; import type { BookBucketMetadata } from '@projectx/types'; @@ -26,6 +26,7 @@ import { AuditAction, AuditResource } from '@projectx/types'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { Auditable } from '../../common/decorators/auditable.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator'; +import type { MultipartRequest } from '../../common/types/multipart-request'; import type { RequestUser } from '../../common/types/request-user'; import { BookBucketService } from './book-bucket.service'; import { BookBucketIngestService } from './book-bucket-ingest.service'; @@ -46,10 +47,6 @@ import { } from './dto/index'; import { MAX_UPLOAD_BYTES } from '../upload/upload-storage.service'; -type MultipartRequest = FastifyRequest & { - file: (opts?: object) => Promise<{ filename: string; file: NodeJS.ReadableStream } | undefined>; -}; - @Controller('book-bucket') @RequirePermission(Permission.BookBucketAccess) export class BookBucketController { diff --git a/server/src/modules/cover/cover.controller.ts b/server/src/modules/cover/cover.controller.ts index 339b18282..b010c7193 100644 --- a/server/src/modules/cover/cover.controller.ts +++ b/server/src/modules/cover/cover.controller.ts @@ -1,16 +1,14 @@ import { Permission } from '@projectx/types'; import { BadRequestException, Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Query, Req, Res } from '@nestjs/common'; -import type { MultipartFile } from '@fastify/multipart'; -import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { FastifyReply } from 'fastify'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator'; +import type { MultipartRequest } from '../../common/types/multipart-request'; 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 }; - @Controller('books') export class CoverController { constructor(private readonly coverService: CoverService) {} diff --git a/server/src/modules/cover/cover.service.test.ts b/server/src/modules/cover/cover.service.test.ts index 9b8adbcdb..1ade02ee1 100644 --- a/server/src/modules/cover/cover.service.test.ts +++ b/server/src/modules/cover/cover.service.test.ts @@ -1,8 +1,10 @@ import { type LookupAddress, lookup } from 'dns/promises'; +import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises'; import type { CoverSearchResult } from '@projectx/types'; import type { RequestUser } from '../../common/types/request-user'; -import { COVER_PROXY_MAX_IMAGE_BYTES, COVER_PROXY_USER_AGENT } from './constants'; +import { coverDirPath, generateThumbnail, imageExt } from '../metadata/lib/cover'; +import { COVER_CUSTOM_FILE_PREFIX, COVER_PROXY_MAX_IMAGE_BYTES, COVER_PROXY_USER_AGENT, COVER_THUMBNAIL_FILE_NAME } from './constants'; import { CoverService } from './cover.service'; import type { CoverProviderRegistry } from './provider-registry'; import { DUCKDUCKGO_PROVIDER_KEY, ITUNES_PROVIDER_KEY } from './providers/cover-provider'; @@ -11,6 +13,20 @@ vi.mock('dns/promises', () => ({ lookup: vi.fn(), })); +vi.mock('fs/promises', () => ({ + mkdir: vi.fn(), + readdir: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + unlink: vi.fn(), +})); + +vi.mock('../metadata/lib/cover', () => ({ + coverDirPath: vi.fn(), + generateThumbnail: vi.fn(), + imageExt: vi.fn(), +})); + function makeResult(url: string, previewUrl: string): CoverSearchResult { return { url, @@ -85,6 +101,24 @@ function createService(providerRegistry: CoverProviderRegistry, options?: { asse ); } +function createMutationService(options?: { assertFieldsUnlocked?: ReturnType }) { + const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined); + const values = vi.fn().mockReturnValue({ onConflictDoUpdate }); + const insert = vi.fn().mockReturnValue({ values }); + const mockDb = { insert }; + + const service = new CoverService( + mockDb as never, + { findLibraryIdByBookId: vi.fn().mockResolvedValue(7) } as never, + { assertFieldsUnlocked: options?.assertFieldsUnlocked ?? vi.fn().mockResolvedValue(undefined) } as never, + { verifyUserAccess: vi.fn().mockResolvedValue(undefined) } as never, + { get: vi.fn().mockReturnValue('/tmp/books') } as never, + { select: vi.fn().mockReturnValue([]) } as unknown as CoverProviderRegistry, + ); + + return { service, mockDb }; +} + describe('CoverService', () => { const originalFetch = global.fetch; const lookupMock = vi.mocked(lookup); @@ -300,6 +334,73 @@ describe('CoverService', () => { await expect(service.proxyImage('https://missing.example.com/cover.jpg')).rejects.toThrow('Unable to resolve URL host'); }); + + describe('SSRF protection', () => { + it.each([ + ['10.0.0.1', '10.x range'], + ['172.16.0.1', '172.16-31.x range'], + ['172.31.255.255', '172.31 upper bound'], + ['192.168.1.1', '192.168.x range'], + ['0.0.0.0', '0.x range'], + ['169.254.169.254', 'link-local'], + ['100.64.0.1', 'CGN lower bound'], + ['100.127.255.255', 'CGN upper bound'], + ])('rejects IPv4 literal %s (%s)', async (ip) => { + const service = createProxyService(); + await expect(service.proxyImage(`http://${ip}/cover.jpg`)).rejects.toThrow('URL host is not allowed'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects multicast IPv4 address', async () => { + const service = createProxyService(); + await expect(service.proxyImage('http://224.0.0.1/cover.jpg')).rejects.toThrow('URL host is not allowed'); + }); + + it.each([ + ['::1', 'loopback'], + ['fc00::1', 'unique local fc'], + ['fd12::1', 'unique local fd'], + ['fe80::1', 'link-local'], + ])('rejects IPv6 literal %s (%s)', async (ip) => { + const service = createProxyService(); + await expect(service.proxyImage(`http://[${ip}]/cover.jpg`)).rejects.toThrow('URL host is not allowed'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects IPv4-mapped IPv6 address that resolves to private', async () => { + const service = createProxyService(); + lookupMock.mockResolvedValueOnce([{ address: '::ffff:10.0.0.1', family: 6 } as LookupAddress]); + + await expect(service.proxyImage('https://sneaky.example.com/cover.jpg')).rejects.toThrow('URL host is not allowed'); + }); + + it('rejects .localhost domains', async () => { + const service = createProxyService(); + await expect(service.proxyImage('http://evil.localhost/cover.jpg')).rejects.toThrow('URL host is not allowed'); + }); + + it('rejects .local domains', async () => { + const service = createProxyService(); + await expect(service.proxyImage('http://printer.local/cover.jpg')).rejects.toThrow('URL host is not allowed'); + }); + + it('rejects when DNS resolves to mixed private+public', async () => { + const service = createProxyService(); + lookupMock.mockResolvedValueOnce([ + { address: '93.184.216.34', family: 4 }, + { address: '10.0.0.1', family: 4 }, + ] as LookupAddress[]); + + await expect(service.proxyImage('https://mixed.example.com/cover.jpg')).rejects.toThrow('URL host is not allowed'); + }); + + it('rejects redirect to private host', async () => { + const service = createProxyService(); + fetchMock.mockResolvedValueOnce(makeRedirectResponse('http://10.0.0.1/internal')); + + await expect(service.proxyImage('https://covers.example.com/start')).rejects.toThrow('URL host is not allowed'); + }); + }); }); describe('manual cover mutations', () => { @@ -330,5 +431,57 @@ describe('CoverService', () => { expect(assertFieldsUnlocked).toHaveBeenCalledWith(12, ['cover']); }); + + it('rejects non-image mimetype on upload', async () => { + const { service } = createMutationService(); + + await expect(service.uploadCover(12, Buffer.from('img'), 'text/plain', makeUser())).rejects.toThrow('File must be an image'); + }); + + it('saves custom cover file and thumbnail when unlocked', async () => { + const { service } = createMutationService(); + + vi.mocked(coverDirPath).mockReturnValue('/tmp/books/covers/12'); + vi.mocked(imageExt).mockReturnValue('jpg'); + vi.mocked(generateThumbnail).mockResolvedValue(Buffer.from('thumb')); + vi.mocked(readdir).mockResolvedValue([] as never); + vi.mocked(mkdir).mockResolvedValue(undefined as never); + vi.mocked(writeFile).mockResolvedValue(undefined); + + await service.uploadCover(12, Buffer.from('image-data'), 'image/jpeg', makeUser()); + + expect(mkdir).toHaveBeenCalledWith('/tmp/books/covers/12', { recursive: true }); + expect(writeFile).toHaveBeenCalledTimes(2); + expect(writeFile).toHaveBeenCalledWith(`/tmp/books/covers/12/${COVER_CUSTOM_FILE_PREFIX}jpg`, Buffer.from('image-data')); + expect(writeFile).toHaveBeenCalledWith(`/tmp/books/covers/12/${COVER_THUMBNAIL_FILE_NAME}`, Buffer.from('thumb')); + }); + + it('removes custom cover and restores extracted if available', async () => { + const { service } = createMutationService(); + + vi.mocked(coverDirPath).mockReturnValue('/tmp/books/covers/12'); + vi.mocked(readdir).mockResolvedValue(['cover_custom.jpg', 'cover_extracted.png'] as never); + vi.mocked(readFile).mockResolvedValue(Buffer.from('extracted-img')); + vi.mocked(generateThumbnail).mockResolvedValue(Buffer.from('thumb')); + vi.mocked(unlink).mockResolvedValue(undefined); + vi.mocked(writeFile).mockResolvedValue(undefined); + + const result = await service.deleteCover(12, makeUser()); + + expect(result).toBe('extracted'); + expect(unlink).toHaveBeenCalledWith('/tmp/books/covers/12/cover_custom.jpg'); + }); + + it('returns null when no extracted cover exists after delete', async () => { + const { service } = createMutationService(); + + vi.mocked(coverDirPath).mockReturnValue('/tmp/books/covers/12'); + vi.mocked(readdir).mockResolvedValue(['cover_custom.jpg'] as never); + vi.mocked(unlink).mockResolvedValue(undefined); + + const result = await service.deleteCover(12, makeUser()); + + expect(result).toBeNull(); + }); }); }); diff --git a/server/src/modules/cover/cover.service.ts b/server/src/modules/cover/cover.service.ts index ad62dc501..5ddfefa8a 100644 --- a/server/src/modules/cover/cover.service.ts +++ b/server/src/modules/cover/cover.service.ts @@ -352,9 +352,12 @@ export class CoverService { throw new BadRequestException('URL host is not allowed'); } - const ipFamily = isIP(normalizedHost); + // URL.hostname wraps IPv6 in brackets (e.g. [::1]); strip them for isIP/range checks + const bareHost = normalizedHost.startsWith('[') && normalizedHost.endsWith(']') ? normalizedHost.slice(1, -1) : normalizedHost; + + const ipFamily = isIP(bareHost); if (ipFamily > 0) { - if (isPrivateOrLocalAddress(normalizedHost)) { + if (isPrivateOrLocalAddress(bareHost)) { throw new BadRequestException('URL host is not allowed'); } return; diff --git a/server/src/modules/library/library.service.test.ts b/server/src/modules/library/library.service.test.ts index 824e0c39f..1274185b6 100644 --- a/server/src/modules/library/library.service.test.ts +++ b/server/src/modules/library/library.service.test.ts @@ -108,7 +108,7 @@ describe('LibraryService', () => { displayOrder: 0, watch: false, metadataPrecedence: ['folderStructure', 'embedded', 'nfoFile', 'opfFile', 'sidecar'], - formatPriority: ['epub', 'pdf', 'cbz', 'cbr', 'cb7', 'mobi', 'azw3', 'azw', 'fb2', 'm4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac'], + formatPriority: ['epub', 'kepub', 'pdf', 'cbz', 'cbr', 'cb7', 'mobi', 'azw3', 'azw', 'fb2', 'm4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac'], organizationMode: 'book_per_folder', coverAspectRatio: '2/3', }), diff --git a/server/src/modules/metadata/metadata.service.ts b/server/src/modules/metadata/metadata.service.ts index a61bda28a..d7a82c7d9 100644 --- a/server/src/modules/metadata/metadata.service.ts +++ b/server/src/modules/metadata/metadata.service.ts @@ -59,8 +59,10 @@ export class MetadataService { this.booksPath = this.config.get('storage.booksPath')!; const audio = new AudioFormatExtractor(); const mobi = new MobiFormatExtractor(); + const epub = new EpubFormatExtractor(); this.extractorMap = new Map([ - ['epub', new EpubFormatExtractor()], + ['epub', epub], + ['kepub', epub], ['pdf', new PdfFormatExtractor()], ['mobi', mobi], ['azw3', mobi], diff --git a/server/src/modules/upload/test-helpers.ts b/server/src/modules/upload/test-helpers.ts new file mode 100644 index 000000000..66e67f826 --- /dev/null +++ b/server/src/modules/upload/test-helpers.ts @@ -0,0 +1,56 @@ +import type { RequestUser } from '../../common/types/request-user'; + +export function makeRequestUser(overrides?: Partial): RequestUser { + return { + id: 1, + username: 'test-user', + name: 'Test User', + email: null, + active: true, + isSuperuser: false, + isDefaultPassword: false, + tokenVersion: 1, + settings: {}, + avatarUrl: null, + provisioningMethod: 'local', + permissions: [], + ...overrides, + }; +} + +export function makeLibraryRow( + overrides?: Partial<{ + id: number; + allowedFormats: string[]; + fileNamingPattern: string | null; + organizationMode: string | null; + }>, +) { + return { + id: 1, + allowedFormats: [] as string[], + fileNamingPattern: null as string | null, + organizationMode: 'book_per_folder' as string | null, + ...overrides, + }; +} + +export function makeFolderRow(overrides?: Partial<{ id: number; libraryId: number; path: string }>) { + return { + id: 1, + libraryId: 1, + path: '/library', + ...overrides, + }; +} + +export function selectChain(rows: unknown[]) { + const whereResult = Promise.resolve(rows) as any; + whereResult.limit = vi.fn().mockResolvedValue(rows); + + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue(whereResult), + }), + }; +} diff --git a/server/src/modules/upload/upload-processor.service.test.ts b/server/src/modules/upload/upload-processor.service.test.ts index 588b4f065..dd6af503c 100644 --- a/server/src/modules/upload/upload-processor.service.test.ts +++ b/server/src/modules/upload/upload-processor.service.test.ts @@ -135,4 +135,26 @@ describe('UploadProcessorService', () => { expect(metadataService.extractAndSave).toHaveBeenCalledWith(9, '/tmp/a.epub', 'epub'); expect(warn).toHaveBeenCalledWith(expect.stringContaining('upstream failed')); }); + + describe('with undefined orchestrator', () => { + let serviceNoOrch: UploadProcessorService; + + beforeEach(() => { + metadataService.extractAndSave.mockResolvedValue(undefined); + serviceNoOrch = new UploadProcessorService(db as any, metadataService as any, undefined); + }); + + it('createBookRecord completes without scheduling when orchestrator is undefined', async () => { + const result = await serviceNoOrch.createBookRecord(1, 2, '/folder', '/folder/book.epub', 'book/book.epub', 'epub', 12345); + + expect(result).toEqual({ bookId: 42 }); + expect(orchestrator.scheduleIfEligible).not.toHaveBeenCalled(); + }); + + it('extractMetadataAsync handles supported format boundary', () => { + serviceNoOrch.extractMetadataAsync(1, '/tmp/file.azw', 'azw'); + + expect(metadataService.extractAndSave).toHaveBeenCalledWith(1, '/tmp/file.azw', 'azw'); + }); + }); }); diff --git a/server/src/modules/upload/upload-processor.service.ts b/server/src/modules/upload/upload-processor.service.ts index 6d463ee16..741dc5fcb 100644 --- a/server/src/modules/upload/upload-processor.service.ts +++ b/server/src/modules/upload/upload-processor.service.ts @@ -12,7 +12,24 @@ import { fingerprintFile } from '../scanner/lib/hash'; type Db = NodePgDatabase; -const METADATA_FORMATS = new Set(['epub', 'mobi', 'azw3', 'azw', 'cbz', 'cbr', 'cb7', 'fb2', 'pdf']); +const METADATA_FORMATS = new Set([ + 'epub', + 'kepub', + 'mobi', + 'azw3', + 'azw', + 'cbz', + 'cbr', + 'cb7', + 'fb2', + 'pdf', + 'm4b', + 'm4a', + 'mp3', + 'opus', + 'ogg', + 'flac', +]); @Injectable() export class UploadProcessorService { diff --git a/server/src/modules/upload/upload-storage.service.test.ts b/server/src/modules/upload/upload-storage.service.test.ts index ade76df17..5c6acf2d4 100644 --- a/server/src/modules/upload/upload-storage.service.test.ts +++ b/server/src/modules/upload/upload-storage.service.test.ts @@ -101,4 +101,47 @@ describe('UploadStorageService', () => { await service.cleanup('/tmp/bad'); expect(warn).toHaveBeenCalledWith(expect.stringContaining('io fail')); }); + + it('propagates stat error after successful write in streamToTemp', async () => { + const source = new PassThrough(); + const sink = new PassThrough(); + mockCreateWriteStream.mockReturnValue(sink as unknown as ReturnType); + mockStat.mockRejectedValue(new Error('stat failed')); + + const p = service.streamToTemp(source); + source.end(Buffer.from('data')); + + await expect(p).rejects.toThrow('stat failed'); + }); + + it('propagates mkdir error in moveToPath', async () => { + mockMkdir.mockRejectedValue(new Error('mkdir failed')); + + await expect(service.moveToPath('/tmp/a', '/books/x/file.epub')).rejects.toThrow('mkdir failed'); + expect(mockRename).not.toHaveBeenCalled(); + }); + + it('propagates copyFile error during EXDEV fallback', async () => { + mockRename.mockRejectedValue(Object.assign(new Error('cross-device'), { code: 'EXDEV' })); + mockCopyFile.mockRejectedValue(new Error('copy failed')); + + await expect(service.moveToPath('/tmp/a', '/books/x/file.epub')).rejects.toThrow('copy failed'); + }); + + it('propagates unlink error after copy in EXDEV fallback', async () => { + mockRename.mockRejectedValue(Object.assign(new Error('cross-device'), { code: 'EXDEV' })); + mockCopyFile.mockResolvedValue(undefined); + mockUnlink.mockRejectedValue(Object.assign(new Error('unlink failed'), { code: 'EBUSY' })); + + await service.moveToPath('/tmp/a', '/books/x/file.epub'); + expect(mockCopyFile).toHaveBeenCalledWith('/tmp/a', '/books/x/file.epub'); + expect(mockUnlink).toHaveBeenCalledWith('/tmp/a'); + }); + + it('concurrent cleanup calls are safe', async () => { + mockUnlink.mockRejectedValue(Object.assign(new Error('gone'), { code: 'ENOENT' })); + + await Promise.all([service.cleanup('/tmp/file'), service.cleanup('/tmp/file'), service.cleanup('/tmp/file')]); + expect(mockUnlink).toHaveBeenCalledTimes(3); + }); }); diff --git a/server/src/modules/upload/upload-validator.service.test.ts b/server/src/modules/upload/upload-validator.service.test.ts index 600f3ebc0..8ac3e3283 100644 --- a/server/src/modules/upload/upload-validator.service.test.ts +++ b/server/src/modules/upload/upload-validator.service.test.ts @@ -33,6 +33,26 @@ describe('UploadValidatorService', () => { it('rejects an audio format when blocked by library policy', () => { expect(() => service.validateFormat('book.m4b', ['epub', 'pdf'])).toThrow(BadRequestException); }); + + it('accepts .azw format', () => { + expect(service.validateFormat('book.azw', [])).toBe('azw'); + }); + + it('accepts .kepub format', () => { + expect(service.validateFormat('book.kepub', [])).toBe('kepub'); + }); + + it('double extension uses last extension', () => { + expect(service.validateFormat('book.epub.pdf', [])).toBe('pdf'); + }); + + it('no extension is rejected', () => { + expect(() => service.validateFormat('book', [])).toThrow(BadRequestException); + }); + + it('empty allowed list means global set is used', () => { + expect(service.validateFormat('book.epub', [])).toBe('epub'); + }); }); describe('sanitizeFilename', () => { @@ -54,5 +74,45 @@ describe('UploadValidatorService', () => { it('adds upload stem when stem is empty but extension exists', () => { expect(service.sanitizeFilename('.epub')).toBe('upload.epub'); }); + + it('preserves CJK and accented characters', () => { + expect(service.sanitizeFilename('日本語の本.epub')).toBe('日本語の本.epub'); + expect(service.sanitizeFilename('café.pdf')).toBe('café.pdf'); + }); + + it('replaces null bytes with underscores', () => { + expect(service.sanitizeFilename('\0\0\0')).toBe('___'); + }); + + it('handles filename of only dots', () => { + const result = service.sanitizeFilename('...'); + expect(result.length).toBeLessThanOrEqual(255); + }); + + it('trims trailing spaces', () => { + expect(service.sanitizeFilename(' book.epub ')).toBe('book.epub'); + }); + + it('handles dotfile-like names with real extension', () => { + expect(service.sanitizeFilename('..hidden.epub')).toBe('..hidden.epub'); + }); + + it('truncates stem to keep total under 255 with long extension', () => { + const raw = `${'x'.repeat(300)}.epub`; + const result = service.sanitizeFilename(raw); + expect(result.length).toBeLessThanOrEqual(255); + expect(result.endsWith('.epub')).toBe(true); + expect(result.startsWith('x'.repeat(250))).toBe(true); + }); + + it('truncates to 255 when extension alone exceeds limit', () => { + const raw = `a.${'z'.repeat(260)}`; + const result = service.sanitizeFilename(raw); + expect(result.length).toBe(255); + }); + + it('preserves original extension casing', () => { + expect(service.sanitizeFilename('Book.EPUB')).toBe('Book.EPUB'); + }); }); }); diff --git a/server/src/modules/upload/upload-validator.service.ts b/server/src/modules/upload/upload-validator.service.ts index 670008321..521998cc2 100644 --- a/server/src/modules/upload/upload-validator.service.ts +++ b/server/src/modules/upload/upload-validator.service.ts @@ -3,8 +3,10 @@ import { extname } from 'path'; export const SUPPORTED_BOOK_FORMATS = new Set([ 'epub', + 'kepub', 'pdf', 'mobi', + 'azw', 'azw3', 'cbz', 'cbr', diff --git a/server/src/modules/upload/upload.controller.test.ts b/server/src/modules/upload/upload.controller.test.ts index 57c462226..cb3d52aed 100644 --- a/server/src/modules/upload/upload.controller.test.ts +++ b/server/src/modules/upload/upload.controller.test.ts @@ -56,4 +56,52 @@ describe('UploadController', () => { expect(req.file).toHaveBeenCalledWith({ limits: { fileSize: MAX_UPLOAD_BYTES } }); expect(uploadService.upload).toHaveBeenCalledWith(3, 12, 'book.epub', stream, { id: 5, isSuperuser: false, permissions: [] }); }); + + it('returns the value from uploadService.upload directly', async () => { + const stream = {}; + const req = { + file: vi.fn().mockResolvedValue({ filename: 'Dune.epub', file: stream }), + }; + const user = { id: 1, isSuperuser: false, permissions: [] } as any; + uploadService.upload.mockResolvedValue({ bookId: 42, filename: 'Dune.epub', format: 'epub', sizeBytes: 1234 }); + + const result = await controller.uploadBook(1, undefined, user, req as any); + + expect(result).toEqual({ bookId: 42, filename: 'Dune.epub', format: 'epub', sizeBytes: 1234 }); + }); + + it('folderId=undefined passes undefined to service', async () => { + const stream = {}; + const req = { + file: vi.fn().mockResolvedValue({ filename: 'book.epub', file: stream }), + }; + const user = { id: 1, isSuperuser: false, permissions: [] } as any; + uploadService.upload.mockResolvedValue({ bookId: 1 }); + + await controller.uploadBook(1, undefined, user, req as any); + + expect(uploadService.upload).toHaveBeenCalledWith(1, undefined, 'book.epub', stream, user); + }); + + it('folderId="0" is rejected (0 is not a positive integer)', async () => { + const req = { + file: vi.fn().mockResolvedValue({ filename: 'book.epub', file: {} }), + }; + const user = { id: 1, isSuperuser: false, permissions: [] } as any; + + await expect(controller.uploadBook(1, '0', user, req as any)).rejects.toThrow(new BadRequestException('Invalid folderId')); + }); + + it('folderId with leading zeros "007" is accepted, parsed as 7', async () => { + const stream = {}; + const req = { + file: vi.fn().mockResolvedValue({ filename: 'book.epub', file: stream }), + }; + const user = { id: 1, isSuperuser: false, permissions: [] } as any; + uploadService.upload.mockResolvedValue({ bookId: 1 }); + + await controller.uploadBook(1, '007', user, req as any); + + expect(uploadService.upload).toHaveBeenCalledWith(1, 7, 'book.epub', stream, user); + }); }); diff --git a/server/src/modules/upload/upload.controller.ts b/server/src/modules/upload/upload.controller.ts index 3759aa265..0ef08e1bf 100644 --- a/server/src/modules/upload/upload.controller.ts +++ b/server/src/modules/upload/upload.controller.ts @@ -1,18 +1,13 @@ import { Permission } from '@projectx/types'; import { BadRequestException, Controller, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Query, Req } from '@nestjs/common'; -import type { MultipartFile } from '@fastify/multipart'; -import type { FastifyRequest } from 'fastify'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator'; +import type { MultipartRequest } from '../../common/types/multipart-request'; import type { RequestUser } from '../../common/types/request-user'; import { UploadService } from './upload.service'; import { MAX_UPLOAD_BYTES } from './upload-storage.service'; -type MultipartRequest = FastifyRequest & { - file: (opts?: object) => Promise; -}; - @Controller('libraries') export class UploadController { constructor(private readonly uploadService: UploadService) {} diff --git a/server/src/modules/upload/upload.service.test.ts b/server/src/modules/upload/upload.service.test.ts index 9f7bb90c2..8ea89c9db 100644 --- a/server/src/modules/upload/upload.service.test.ts +++ b/server/src/modules/upload/upload.service.test.ts @@ -9,13 +9,19 @@ vi.mock('../metadata/lib/mobi-parser', () => ({ parseMobiFile: vi.fn() })); vi.mock('../metadata/lib/pdf-parser', () => ({ parsePdfFile: vi.fn() })); import { access as fsAccess } from 'fs/promises'; -import { BadRequestException, ConflictException, Logger } from '@nestjs/common'; +import { BadRequestException, ConflictException, ForbiddenException, Logger, NotFoundException } from '@nestjs/common'; import { extractEpubMetadata } from '../metadata/lib/epub'; +import { extractCbzMetadata } from '../metadata/lib/cbz-metadata'; +import { parseMobiFile } from '../metadata/lib/mobi-parser'; +import { parsePdfFile } from '../metadata/lib/pdf-parser'; import { UploadService } from './upload.service'; const mockFsAccess = fsAccess as MockedFunction; const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction; +const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction; +const mockParseMobiFile = parseMobiFile as MockedFunction; +const mockParsePdfFile = parsePdfFile as MockedFunction; function selectChain(rows: unknown[]) { const whereResult: PromiseLike & { limit: vi.Mock } = { @@ -206,4 +212,344 @@ describe('UploadService', () => { await expect(service.upload(1, undefined, 'raw.epub', {} as any, user)).rejects.toBeInstanceOf(BadRequestException); expect(storage.streamToTemp).not.toHaveBeenCalled(); }); + + it('book_per_file with pattern uses resolveDownloadFilename for flat layout', async () => { + db.select + .mockReturnValueOnce( + selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{title}.{extension}', organizationMode: 'book_per_file' }]), + ) + .mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }])); + + mockExtractEpubMetadata.mockResolvedValue({ + title: 'Dune', + subtitle: null, + publisher: null, + publishedYear: null, + language: null, + seriesName: null, + seriesIndex: null, + isbn13: null, + authors: [{ name: 'Frank Herbert' }], + tags: [], + description: null, + isbn10: null, + }); + + const result = await service.upload(1, 2, 'raw.epub', {} as any, user); + + expect(result).toEqual({ bookId: 99, filename: 'Dune.epub', format: 'epub', sizeBytes: 456 }); + expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Dune.epub'); + expect(processor.createBookRecord).toHaveBeenCalledWith(1, 2, '/library/Dune.epub', '/library/Dune.epub', 'Dune.epub', 'epub', 456); + }); + + it('book_per_file without pattern uses filename directly', async () => { + db.select + .mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null, organizationMode: 'book_per_file' }])) + .mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }])); + + const result = await service.upload(1, 2, 'raw.epub', {} as any, user); + + expect(result).toEqual({ bookId: 99, filename: 'book.epub', format: 'epub', sizeBytes: 456 }); + expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/book.epub'); + expect(processor.createBookRecord).toHaveBeenCalledWith(1, 2, '/library/book.epub', '/library/book.epub', 'book.epub', 'epub', 456); + }); + + it('book_per_file falls back to filename when pattern resolves to null', async () => { + db.select + .mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{title}', organizationMode: 'book_per_file' }])) + .mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }])); + + mockExtractEpubMetadata.mockResolvedValue(null); + + const result = await service.upload(1, 2, 'raw.epub', {} as any, user); + + expect(result).toEqual({ bookId: 99, filename: 'book.epub', format: 'epub', sizeBytes: 456 }); + expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/book.epub'); + expect(processor.createBookRecord).toHaveBeenCalledWith(1, 2, '/library/book.epub', '/library/book.epub', 'book.epub', 'epub', 456); + }); + + it('book_per_file throws ConflictException when destination exists', async () => { + db.select + .mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null, organizationMode: 'book_per_file' }])) + .mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }])); + mockFsAccess.mockResolvedValue(undefined); + + await expect(service.upload(1, 2, 'raw.epub', {} as any, user)).rejects.toBeInstanceOf(ConflictException); + expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin'); + expect(storage.moveToPath).not.toHaveBeenCalled(); + }); + + it('mobi metadata populates tokens', async () => { + validator.validateFormat.mockReturnValue('mobi'); + validator.sanitizeFilename.mockReturnValue('book.mobi'); + db.select + .mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['mobi'], fileNamingPattern: '{authors:first}/{title}.{extension}' }])) + .mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }])); + + mockParseMobiFile.mockResolvedValue({ + title: 'The Stand', + authors: ['Stephen King'], + publisher: 'Doubleday', + isbn: '9780385121682', + publishedDate: '1978-10-03', + language: 'en', + description: null, + tags: [], + coverRecordIndex: null, + recordOffsets: [], + }); + + const result = await service.upload(1, 2, 'raw.mobi', {} as any, user); + + expect(result).toEqual({ bookId: 99, filename: 'The Stand.mobi', format: 'mobi', sizeBytes: 456 }); + expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Stephen King/The Stand.mobi'); + }); + + it('pdf metadata populates tokens', async () => { + validator.validateFormat.mockReturnValue('pdf'); + validator.sanitizeFilename.mockReturnValue('doc.pdf'); + db.select + .mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['pdf'], fileNamingPattern: '{title}.{extension}' }])) + .mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }])); + + mockParsePdfFile.mockResolvedValue({ + title: 'Research Paper', + authors: [{ name: 'Jane Doe', sortName: null }], + publisher: 'Academic Press', + subtitle: null, + description: null, + publishedYear: null, + language: null, + genres: [], + tags: [], + isbn10: null, + isbn13: null, + seriesName: null, + seriesIndex: null, + rating: null, + pageCount: null, + googleBooksId: null, + goodreadsId: null, + amazonId: null, + hardcoverId: null, + openLibraryId: null, + itunesId: null, + }); + + const result = await service.upload(1, 2, 'raw.pdf', {} as any, user); + + expect(result).toEqual({ bookId: 99, filename: 'Research Paper.pdf', format: 'pdf', sizeBytes: 456 }); + expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Research Paper.pdf'); + }); + + it('cbz metadata populates tokens', async () => { + validator.validateFormat.mockReturnValue('cbz'); + validator.sanitizeFilename.mockReturnValue('comic.cbz'); + db.select + .mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['cbz'], fileNamingPattern: '{series}/{title}.{extension}' }])) + .mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }])); + + mockExtractCbzMetadata.mockResolvedValue({ + title: 'Issue 1', + seriesName: 'Batman', + seriesIndex: 1, + authors: [{ name: 'Bob Kane', sortName: null }], + subtitle: null, + description: null, + publisher: null, + publishedYear: null, + language: null, + pageCount: null, + rating: null, + isbn10: null, + isbn13: null, + genres: [], + tags: [], + googleBooksId: null, + goodreadsId: null, + amazonId: null, + hardcoverId: null, + openLibraryId: null, + itunesId: null, + comicMetadata: null, + }); + + const result = await service.upload(1, 2, 'raw.cbz', {} as any, user); + + expect(result).toEqual({ bookId: 99, filename: 'Issue 1.cbz', format: 'cbz', sizeBytes: 456 }); + expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Batman/Issue 1.cbz'); + }); + + it('unsupported format returns base tokens only', async () => { + validator.validateFormat.mockReturnValue('fb2'); + validator.sanitizeFilename.mockReturnValue('book.fb2'); + db.select + .mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['fb2'], fileNamingPattern: '{title}' }])) + .mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }])); + + const result = await service.upload(1, 2, 'raw.fb2', {} as any, user); + + expect(result).toEqual({ bookId: 99, filename: 'book.fb2', format: 'fb2', sizeBytes: 456 }); + expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/book/book.fb2'); + }); + + it('series index formatting - whole number zero-padded', async () => { + db.select + .mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{series} {seriesIndex}/{title}.{extension}' }])) + .mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }])); + + mockExtractEpubMetadata.mockResolvedValue({ + title: 'Book Three', + subtitle: null, + publisher: null, + publishedYear: null, + language: null, + seriesName: 'Trilogy', + seriesIndex: 3, + isbn13: null, + authors: [{ name: 'Author' }], + tags: [], + description: null, + isbn10: null, + }); + + const result = await service.upload(1, 2, 'raw.epub', {} as any, user); + + expect(result).toEqual({ bookId: 99, filename: 'Book Three.epub', format: 'epub', sizeBytes: 456 }); + expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Trilogy 03/Book Three.epub'); + }); + + it('series index formatting - decimal preserved', async () => { + db.select + .mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{series} {seriesIndex}/{title}.{extension}' }])) + .mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }])); + + mockExtractEpubMetadata.mockResolvedValue({ + title: 'Book Three', + subtitle: null, + publisher: null, + publishedYear: null, + language: null, + seriesName: 'Trilogy', + seriesIndex: 1.5, + isbn13: null, + authors: [{ name: 'Author' }], + tags: [], + description: null, + isbn10: null, + }); + + const result = await service.upload(1, 2, 'raw.epub', {} as any, user); + + expect(result).toEqual({ bookId: 99, filename: 'Book Three.epub', format: 'epub', sizeBytes: 456 }); + expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Trilogy 01.5/Book Three.epub'); + }); + + it('mobi parser throws - logs warning and returns base tokens', async () => { + validator.validateFormat.mockReturnValue('mobi'); + validator.sanitizeFilename.mockReturnValue('book.mobi'); + db.select + .mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['mobi'], fileNamingPattern: '{title}' }])) + .mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }])); + + mockParseMobiFile.mockRejectedValue(new Error('mobi parse error')); + + const result = await service.upload(1, 2, 'raw.mobi', {} as any, user); + + expect(result).toEqual({ bookId: 99, filename: 'book.mobi', format: 'mobi', sizeBytes: 456 }); + expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/book/book.mobi'); + }); + + it('throws NotFoundException when library does not exist', async () => { + db.select.mockReturnValueOnce(selectChain([])); + + await expect(service.upload(999, undefined, 'raw.epub', {} as any, user)).rejects.toBeInstanceOf(NotFoundException); + expect(storage.streamToTemp).not.toHaveBeenCalled(); + }); + + it('throws when user has no access to library', async () => { + db.select.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }])); + libraryService.verifyUserAccess.mockRejectedValue(new ForbiddenException('No access')); + + await expect(service.upload(1, undefined, 'raw.epub', {} as any, user)).rejects.toBeInstanceOf(ForbiddenException); + expect(storage.streamToTemp).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException when folder does not exist', async () => { + db.select.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }])).mockReturnValueOnce(selectChain([])); + + await expect(service.upload(1, 999, 'raw.epub', {} as any, user)).rejects.toBeInstanceOf(BadRequestException); + expect(storage.streamToTemp).not.toHaveBeenCalled(); + }); + + it('library pattern takes precedence over global upload pattern', async () => { + db.select + .mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{title}.{extension}' }])) + .mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }])); + + appSettings.getUploadPattern.mockResolvedValue('{authors:first}/{title}.{extension}'); + + mockExtractEpubMetadata.mockResolvedValue({ + title: 'MyBook', + subtitle: null, + publisher: null, + publishedYear: null, + language: null, + seriesName: null, + seriesIndex: null, + isbn13: null, + authors: [{ name: 'AuthorX' }], + tags: [], + description: null, + isbn10: null, + }); + + const result = await service.upload(1, 2, 'raw.epub', {} as any, user); + + expect(result).toEqual({ bookId: 99, filename: 'MyBook.epub', format: 'epub', sizeBytes: 456 }); + expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/MyBook.epub'); + expect(appSettings.getUploadPattern).not.toHaveBeenCalled(); + }); + + it('global pattern used when library pattern is null', async () => { + db.select + .mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }])) + .mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }])); + + appSettings.getUploadPattern.mockResolvedValue('{title}.{extension}'); + + mockExtractEpubMetadata.mockResolvedValue({ + title: 'GlobalBook', + subtitle: null, + publisher: null, + publishedYear: null, + language: null, + seriesName: null, + seriesIndex: null, + isbn13: null, + authors: [], + tags: [], + description: null, + isbn10: null, + }); + + const result = await service.upload(1, 2, 'raw.epub', {} as any, user); + + expect(result).toEqual({ bookId: 99, filename: 'GlobalBook.epub', format: 'epub', sizeBytes: 456 }); + expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/GlobalBook.epub'); + expect(appSettings.getUploadPattern).toHaveBeenCalled(); + }); + + it('pattern resolves to null falls back to stem-folder layout', async () => { + db.select + .mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{title}' }])) + .mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }])); + + mockExtractEpubMetadata.mockResolvedValue(null); + + const result = await service.upload(1, 2, 'raw.epub', {} as any, user); + + expect(result).toEqual({ bookId: 99, filename: 'book.epub', format: 'epub', sizeBytes: 456 }); + expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/book/book.epub'); + }); }); diff --git a/server/src/modules/user/user-avatar.service.test.ts b/server/src/modules/user/user-avatar.service.test.ts index 86778269a..d853f9ada 100644 --- a/server/src/modules/user/user-avatar.service.test.ts +++ b/server/src/modules/user/user-avatar.service.test.ts @@ -51,4 +51,18 @@ describe('UserAvatarService', () => { it('blocks reading other users avatar without permission', async () => { await expect(service.getAvatarPath({ id: 1, isSuperuser: false, permissions: [] } as any, 2)).rejects.toBeInstanceOf(ForbiddenException); }); + + it('rejects empty file (0 bytes)', async () => { + await expect(service.uploadOwnAvatar({ id: 1 } as any, Buffer.alloc(0), 'image/png')).rejects.toThrow('File is empty'); + }); + + it('rejects file exceeding MAX_USER_AVATAR_BYTES', async () => { + await expect(service.uploadOwnAvatar({ id: 1 } as any, Buffer.alloc(5 * 1024 * 1024 + 1), 'image/png')).rejects.toThrow( + 'Image exceeds 5 MB limit', + ); + }); + + it('rejects corrupt image data with Invalid image file', async () => { + await expect(service.uploadOwnAvatar({ id: 1 } as any, Buffer.from('not-an-image'), 'image/png')).rejects.toThrow('Invalid image file'); + }); }); diff --git a/server/src/modules/user/user.controller.ts b/server/src/modules/user/user.controller.ts index a2d81bf2d..18da34164 100644 --- a/server/src/modules/user/user.controller.ts +++ b/server/src/modules/user/user.controller.ts @@ -19,25 +19,24 @@ import { Req, Res, } from '@nestjs/common'; -import type { MultipartFile } from '@fastify/multipart'; -import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { FastifyReply } from 'fastify'; import { createReadStream } from 'fs'; import { stat } from 'fs/promises'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { Auditable } from '../../common/decorators/auditable.decorator'; +import type { MultipartRequest } from '../../common/types/multipart-request'; import type { RequestUser } from '../../common/types/request-user'; import { CreateUserDto } from './dto/create-user.dto'; import { SetLibrariesDto } from './dto/set-libraries.dto'; import { SetPermissionsDto } from './dto/set-permissions.dto'; import { UpdateMeDto } from './dto/update-me.dto'; import { UpdateUserDto } from './dto/update-user.dto'; +import { MAX_USER_AVATAR_BYTES } from './user-avatar.service'; import { UserAvatarService } from './user-avatar.service'; import { UserService } from './user.service'; -type MultipartRequest = FastifyRequest & { file: () => Promise }; - @Controller('users') export class UserController { constructor( @@ -80,7 +79,7 @@ export class UserController { description: () => 'User uploaded a profile picture', }) async uploadMyAvatar(@CurrentUser() user: RequestUser, @Req() req: MultipartRequest) { - const data = await req.file(); + const data = await req.file({ limits: { fileSize: MAX_USER_AVATAR_BYTES } }); if (!data) throw new BadRequestException('No file provided'); const buffer = await data.toBuffer(); return this.userAvatarService.uploadOwnAvatar(user, buffer, data.mimetype);