mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(scanner): add kepub and audio format support to uploads
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user