feat(scanner): add kepub and audio format support to uploads

This commit is contained in:
neon
2026-04-07 13:56:58 -06:00
parent 54f2894762
commit 0e9ae2ae10
23 changed files with 1018 additions and 31 deletions
@@ -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
@@ -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
+2
View File
@@ -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<string, string> = {
epub: "EPUB e-book",
kepub: "KEPUB e-book",
pdf: "PDF document",
cbz: "CBZ comic",
cbr: "CBR comic",
@@ -0,0 +1,6 @@
import type { MultipartFile } from '@fastify/multipart';
import type { FastifyRequest } from 'fastify';
export type MultipartRequest = FastifyRequest & {
file: (opts?: object) => Promise<MultipartFile | undefined>;
};
@@ -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'));
});
});
});
@@ -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;
}
}
@@ -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 {
+2 -4
View File
@@ -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<MultipartFile | undefined> };
@Controller('books')
export class CoverController {
constructor(private readonly coverService: CoverService) {}
+154 -1
View File
@@ -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<typeof vi.fn> }) {
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();
});
});
});
+5 -2
View File
@@ -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;
@@ -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',
}),
@@ -59,8 +59,10 @@ export class MetadataService {
this.booksPath = this.config.get<string>('storage.booksPath')!;
const audio = new AudioFormatExtractor();
const mobi = new MobiFormatExtractor();
const epub = new EpubFormatExtractor();
this.extractorMap = new Map<string, FormatExtractor>([
['epub', new EpubFormatExtractor()],
['epub', epub],
['kepub', epub],
['pdf', new PdfFormatExtractor()],
['mobi', mobi],
['azw3', mobi],
+56
View File
@@ -0,0 +1,56 @@
import type { RequestUser } from '../../common/types/request-user';
export function makeRequestUser(overrides?: Partial<RequestUser>): 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),
}),
};
}
@@ -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');
});
});
});
@@ -12,7 +12,24 @@ import { fingerprintFile } from '../scanner/lib/hash';
type Db = NodePgDatabase<typeof schema>;
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 {
@@ -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<typeof createWriteStream>);
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);
});
});
@@ -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');
});
});
});
@@ -3,8 +3,10 @@ import { extname } from 'path';
export const SUPPORTED_BOOK_FORMATS = new Set([
'epub',
'kepub',
'pdf',
'mobi',
'azw',
'azw3',
'cbz',
'cbr',
@@ -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);
});
});
@@ -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<MultipartFile | undefined>;
};
@Controller('libraries')
export class UploadController {
constructor(private readonly uploadService: UploadService) {}
@@ -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<typeof fsAccess>;
const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction<typeof extractEpubMetadata>;
const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction<typeof extractCbzMetadata>;
const mockParseMobiFile = parseMobiFile as MockedFunction<typeof parseMobiFile>;
const mockParsePdfFile = parsePdfFile as MockedFunction<typeof parsePdfFile>;
function selectChain(rows: unknown[]) {
const whereResult: PromiseLike<unknown[]> & { 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');
});
});
@@ -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');
});
});
+4 -5
View File
@@ -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<MultipartFile | undefined> };
@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);