diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..f85449a1a --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +server/bin/kepubify/* binary diff --git a/Dockerfile b/Dockerfile index 1df99bd21..7b9755456 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,8 +50,9 @@ ENV PORT=3000 COPY --from=server-builder --chown=node:node /deploy ./ COPY --from=client-builder --chown=node:node /app/client/dist ./public COPY --from=server-builder --chown=node:node /app/server/entrypoint.sh ./entrypoint.sh +COPY --chown=node:node server/bin/kepubify/ ./bin/kepubify/ -RUN chmod +x /app/entrypoint.sh && mkdir -p /books /data/covers /data/book-bucket /tmp && chown -R node:node /data /tmp +RUN chmod +x /app/entrypoint.sh /app/bin/kepubify/* && mkdir -p /books /data/covers /data/book-bucket /tmp && chown -R node:node /data /tmp EXPOSE 3000 diff --git a/client/src/features/settings/KoboSettings.vue b/client/src/features/settings/KoboSettings.vue index e520c7b54..0c333c875 100644 --- a/client/src/features/settings/KoboSettings.vue +++ b/client/src/features/settings/KoboSettings.vue @@ -203,10 +203,6 @@ async function saveSettings() { Copy -

- On your Kobo device, go to Settings → Account → Add account → Other and enter this URL. - Any username/password will work. -

({ })); import { chmod, mkdir, stat, writeFile } from 'fs/promises'; +import { join } from 'path'; import { KepubifyBinaryService } from './kepubify-binary.service'; @@ -14,11 +15,13 @@ const mkdirMock = vi.mocked(mkdir); const statMock = vi.mocked(stat); const writeFileMock = vi.mocked(writeFile); -describe('KepubifyBinaryService', () => { - const config = { - get: vi.fn().mockReturnValue('/app-data'), - }; +const config = { get: vi.fn().mockReturnValue('/app-data') }; +function makeService() { + return new KepubifyBinaryService(config as never); +} + +describe('KepubifyBinaryService', () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -28,79 +31,156 @@ describe('KepubifyBinaryService', () => { vi.restoreAllMocks(); }); - it('detects expected binary names by platform/arch', () => { - const service = new KepubifyBinaryService(config as never); + describe('detectBinaryName', () => { + it.each([ + ['darwin', 'arm64', 'kepubify-darwin-arm64'], + ['darwin', 'x64', 'kepubify-darwin-64bit'], + ['linux', 'x64', 'kepubify-linux-64bit'], + ['linux', 'arm64', 'kepubify-linux-arm64'], + ['linux', 'arm', 'kepubify-linux-arm'], + ['linux', 'ia32', 'kepubify-linux-32bit'], + ])('platform=%s arch=%s → %s', (platform, arch, expected) => { + const service = makeService(); + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform as NodeJS.Platform); + vi.spyOn(process, 'arch', 'get').mockReturnValue(arch); + expect((service as any).detectBinaryName()).toBe(expected); + }); - vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); - vi.spyOn(process, 'arch', 'get').mockReturnValue('arm64'); - expect((service as any).detectBinaryName()).toBe('kepubify-darwin-arm64'); - - vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); - vi.spyOn(process, 'arch', 'get').mockReturnValue('x64'); - expect((service as any).detectBinaryName()).toBe('kepubify-linux-64bit'); + it('throws for unsupported platforms', () => { + const service = makeService(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32' as NodeJS.Platform); + vi.spyOn(process, 'arch', 'get').mockReturnValue('x64'); + expect(() => (service as any).detectBinaryName()).toThrow('Unsupported platform'); + }); }); - it('throws for unsupported platforms', () => { - const service = new KepubifyBinaryService(config as never); - vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); - vi.spyOn(process, 'arch', 'get').mockReturnValue('x64'); + describe('getBinaryPath', () => { + it('caches result and only resolves once across multiple calls', async () => { + const service = makeService(); + vi.spyOn(service as any, 'detectBinaryName').mockReturnValue('kepubify-linux-64bit'); + const resolveSpy = vi.spyOn(service as any, 'resolveBinaryPath').mockResolvedValue('/resolved/kepubify'); - expect(() => (service as any).detectBinaryName()).toThrow('Unsupported platform'); + const first = await service.getBinaryPath(); + const second = await service.getBinaryPath(); + + expect(first).toBe('/resolved/kepubify'); + expect(second).toBe('/resolved/kepubify'); + expect(resolveSpy).toHaveBeenCalledTimes(1); + }); }); - it('caches computed binary path after first resolution', async () => { - const service = new KepubifyBinaryService(config as never); - const detectSpy = vi.spyOn(service as any, 'detectBinaryName').mockReturnValue('kepubify-linux-64bit'); - const ensureSpy = vi.spyOn(service as any, 'ensureBinary').mockResolvedValue(undefined); + describe('resolveBinaryPath', () => { + it('returns bundled path when binary is present in bin/ dir', async () => { + const service = makeService(); + const expectedBundled = join(process.cwd(), 'bin', 'kepubify', 'kepubify-linux-64bit'); + statMock.mockResolvedValueOnce({} as never); - await expect(service.getBinaryPath()).resolves.toBe('/app-data/.tools/kepubify/kepubify-linux-64bit'); - await expect(service.getBinaryPath()).resolves.toBe('/app-data/.tools/kepubify/kepubify-linux-64bit'); + const result = await (service as any).resolveBinaryPath('kepubify-linux-64bit'); - expect(detectSpy).toHaveBeenCalledTimes(1); - expect(ensureSpy).toHaveBeenCalledTimes(1); + expect(result).toBe(expectedBundled); + expect(chmodMock).toHaveBeenCalledWith(expectedBundled, 0o755); + expect(mkdirMock).not.toHaveBeenCalled(); + }); + + it('falls back to download path when bundled binary is missing', async () => { + const service = makeService(); + statMock + .mockRejectedValueOnce(new Error('ENOENT')) // bundled path missing + .mockResolvedValueOnce({} as never); // download cache hit + + const result = await (service as any).resolveBinaryPath('kepubify-linux-64bit'); + + expect(result).toBe('/app-data/.tools/kepubify/kepubify-linux-64bit'); + }); + + it('downloads and returns download path when neither bundled nor cached', async () => { + const service = makeService(); + statMock + .mockRejectedValueOnce(new Error('ENOENT')) // bundled missing + .mockRejectedValueOnce(new Error('ENOENT')); // download cache missing + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)) })); + + const result = await (service as any).resolveBinaryPath('kepubify-linux-64bit'); + + expect(result).toBe('/app-data/.tools/kepubify/kepubify-linux-64bit'); + expect(mkdirMock).toHaveBeenCalledWith('/app-data/.tools/kepubify', { recursive: true }); + }); }); - it('ensureBinary keeps executable bit for already-cached binaries', async () => { - const service = new KepubifyBinaryService(config as never); - statMock.mockResolvedValue({} as never); + describe('ensureDownloaded', () => { + it('chmods and returns without downloading when binary is already cached', async () => { + const service = makeService(); + statMock.mockResolvedValueOnce({} as never); - await expect((service as any).ensureBinary('/tmp/kepubify', 'kepubify-linux-64bit', '/tmp')).resolves.toBeUndefined(); + await (service as any).ensureDownloaded('/cache/kepubify', 'kepubify-linux-64bit', '/cache'); - expect(chmodMock).toHaveBeenCalledWith('/tmp/kepubify', 0o755); - expect(mkdirMock).not.toHaveBeenCalled(); - }); + expect(chmodMock).toHaveBeenCalledWith('/cache/kepubify', 0o755); + expect(mkdirMock).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); + }); - it('downloads missing binary and writes executable file', async () => { - const service = new KepubifyBinaryService(config as never); - statMock.mockRejectedValue(new Error('missing')); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - arrayBuffer: () => Promise.resolve(Uint8Array.from([1, 2, 3]).buffer), - }), - ); + it('downloads, writes, and chmods when binary is not cached', async () => { + const service = makeService(); + statMock.mockRejectedValueOnce(new Error('ENOENT')); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(Uint8Array.from([0xde, 0xad, 0xbe, 0xef]).buffer), + }), + ); - await (service as any).ensureBinary('/tmp/kepubify', 'kepubify-linux-64bit', '/tmp'); + await (service as any).ensureDownloaded('/cache/kepubify', 'kepubify-linux-64bit', '/cache/dir'); - expect(mkdirMock).toHaveBeenCalledWith('/tmp', { recursive: true }); - expect(writeFileMock).toHaveBeenCalledWith('/tmp/kepubify', Buffer.from([1, 2, 3])); - expect(chmodMock).toHaveBeenCalledWith('/tmp/kepubify', 0o755); - }); + expect(mkdirMock).toHaveBeenCalledWith('/cache/dir', { recursive: true }); + expect(writeFileMock).toHaveBeenCalledWith('/cache/kepubify', Buffer.from([0xde, 0xad, 0xbe, 0xef])); + expect(chmodMock).toHaveBeenCalledWith('/cache/kepubify', 0o755); + }); - it('throws when binary download returns non-ok status', async () => { - const service = new KepubifyBinaryService(config as never); - statMock.mockRejectedValue(new Error('missing')); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 503, - }), - ); + it('fetches from the bookorbit/bookorbit-tools URL (not neonsolstice)', async () => { + const service = makeService(); + statMock.mockRejectedValueOnce(new Error('ENOENT')); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)) }); + vi.stubGlobal('fetch', fetchMock); - await expect((service as any).ensureBinary('/tmp/kepubify', 'kepubify-linux-64bit', '/tmp')).rejects.toThrow( - 'Failed to download kepubify: HTTP 503', - ); + await (service as any).ensureDownloaded('/cache/kepubify', 'kepubify-darwin-arm64', '/cache/dir'); + + const calledUrl: string = fetchMock.mock.calls[0][0]; + expect(calledUrl).toContain('bookorbit/bookorbit-tools'); + expect(calledUrl).not.toContain('neonsolstice'); + expect(calledUrl).toContain('kepubify-darwin-arm64'); + }); + + it('throws when download responds with a non-ok status', async () => { + const service = makeService(); + statMock.mockRejectedValueOnce(new Error('ENOENT')); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 })); + + await expect((service as any).ensureDownloaded('/cache/kepubify', 'kepubify-linux-64bit', '/cache/dir')).rejects.toThrow( + 'Failed to download kepubify: HTTP 404', + ); + expect(writeFileMock).not.toHaveBeenCalled(); + }); + + it('throws when download responds with a 503', async () => { + const service = makeService(); + statMock.mockRejectedValueOnce(new Error('ENOENT')); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 503 })); + + await expect((service as any).ensureDownloaded('/cache/kepubify', 'kepubify-linux-64bit', '/cache/dir')).rejects.toThrow( + 'Failed to download kepubify: HTTP 503', + ); + }); + + it('re-throws network-level fetch errors', async () => { + const service = makeService(); + statMock.mockRejectedValueOnce(new Error('ENOENT')); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED'))); + + await expect((service as any).ensureDownloaded('/cache/kepubify', 'kepubify-linux-64bit', '/cache/dir')).rejects.toThrow( + 'connect ECONNREFUSED', + ); + expect(writeFileMock).not.toHaveBeenCalled(); + }); }); }); diff --git a/server/src/modules/kobo/services/kepubify-binary.service.ts b/server/src/modules/kobo/services/kepubify-binary.service.ts index 07d1fa9df..ad24cf450 100644 --- a/server/src/modules/kobo/services/kepubify-binary.service.ts +++ b/server/src/modules/kobo/services/kepubify-binary.service.ts @@ -4,28 +4,48 @@ import { join } from 'path'; import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -const KEPUBIFY_BASE_URL = 'https://github.com/neonsolstice/projectx-tools/raw/main/kepubify/'; +const KEPUBIFY_BASE_URL = 'https://github.com/bookorbit/bookorbit-tools/raw/main/kepubify/'; @Injectable() export class KepubifyBinaryService { private readonly logger = new Logger(KepubifyBinaryService.name); private readonly appDataPath: string; + private readonly bundledBinDir: string; private cachedBinaryPath: string | null = null; constructor(private readonly config: ConfigService) { this.appDataPath = this.config.get('storage.appDataPath')!; + this.bundledBinDir = join(process.cwd(), 'bin', 'kepubify'); } async getBinaryPath(): Promise { if (this.cachedBinaryPath) return this.cachedBinaryPath; const binaryName = this.detectBinaryName(); - const toolsDir = join(this.appDataPath, '.tools', 'kepubify'); - const binaryPath = join(toolsDir, binaryName); - await this.ensureBinary(binaryPath, binaryName, toolsDir); + const binaryPath = await this.resolveBinaryPath(binaryName); this.cachedBinaryPath = binaryPath; return binaryPath; } + private async resolveBinaryPath(binaryName: string): Promise { + const bundledPath = join(this.bundledBinDir, binaryName); + try { + await stat(bundledPath); + } catch { + // Not bundled (e.g. macOS dev) - fall back to download + const toolsDir = join(this.appDataPath, '.tools', 'kepubify'); + const downloadPath = join(toolsDir, binaryName); + await this.ensureDownloaded(downloadPath, binaryName, toolsDir); + return downloadPath; + } + try { + await chmod(bundledPath, 0o755); + } catch { + // Read-only filesystem (e.g. Docker with read_only: true) - permissions + // are already set correctly by the Dockerfile, so this is safe to ignore. + } + return bundledPath; + } + private detectBinaryName(): string { const platform = process.platform; const arch = process.arch; @@ -41,27 +61,44 @@ export class KepubifyBinaryService { throw new Error(`Unsupported platform for kepubify: ${platform} / ${arch}`); } - private async ensureBinary(binaryPath: string, binaryName: string, toolsDir: string): Promise { + private async ensureDownloaded(binaryPath: string, binaryName: string, toolsDir: string): Promise { try { await stat(binaryPath); await chmod(binaryPath, 0o755); return; } catch { - // Binary not cached, download it + // Not cached - download } const url = `${KEPUBIFY_BASE_URL}${binaryName}`; - this.logger.log(`Downloading kepubify binary from ${url}`); + const startedAt = Date.now(); + this.logger.log(`[kepubify.download] [start] binaryName=${binaryName} - downloading kepubify binary`); await mkdir(toolsDir, { recursive: true }); - const response = await fetch(url); - if (!response.ok) throw new Error(`Failed to download kepubify: HTTP ${response.status}`); + let response: Response; + try { + response = await fetch(url); + } catch (err) { + const durationMs = Date.now() - startedAt; + this.logger.error( + `[kepubify.download] [fail] binaryName=${binaryName} durationMs=${durationMs} errorClass=${(err as Error).constructor.name} error="${(err as Error).message}" - kepubify download failed`, + ); + throw err; + } + + if (!response.ok) { + const durationMs = Date.now() - startedAt; + this.logger.error( + `[kepubify.download] [fail] binaryName=${binaryName} durationMs=${durationMs} errorClass=HttpError error="HTTP ${response.status}" - kepubify download failed`, + ); + throw new Error(`Failed to download kepubify: HTTP ${response.status}`); + } const buffer = await response.arrayBuffer(); await writeFile(binaryPath, Buffer.from(buffer)); await chmod(binaryPath, 0o755); - this.logger.log(`Downloaded kepubify to ${binaryPath}`); + this.logger.log(`[kepubify.download] [end] binaryName=${binaryName} durationMs=${Date.now() - startedAt} - downloaded kepubify binary`); } } diff --git a/server/src/modules/kobo/services/kobo-download.service.test.ts b/server/src/modules/kobo/services/kobo-download.service.test.ts index d6333ee2a..ff29623ad 100644 --- a/server/src/modules/kobo/services/kobo-download.service.test.ts +++ b/server/src/modules/kobo/services/kobo-download.service.test.ts @@ -192,6 +192,43 @@ describe('KoboDownloadService', () => { await expect((service as any).streamFile('/books/missing.epub', 99, 'epub', reply)).rejects.toThrow(NotFoundException); }); + it('streamFile uses application/epub+zip for kepub.epub format', async () => { + const deps = makeDeps(); + const service = new KoboDownloadService( + deps.db as never, + deps.config as never, + deps.kepubifyBinaryService as never, + deps.settingsService as never, + deps.bookAccessService as never, + ); + const reply = makeReply(); + statMock.mockResolvedValueOnce({ size: 4096 } as never); + createReadStreamMock.mockReturnValue({} as never); + + await (service as any).streamFile('/cache/44/hash.kepub.epub', 55, 'kepub.epub', reply); + + expect(reply.type).toHaveBeenCalledWith('application/epub+zip'); + expect(reply.header).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="book-55.kepub.epub"'); + }); + + it('streamFile falls back to application/octet-stream for unknown formats', async () => { + const deps = makeDeps(); + const service = new KoboDownloadService( + deps.db as never, + deps.config as never, + deps.kepubifyBinaryService as never, + deps.settingsService as never, + deps.bookAccessService as never, + ); + const reply = makeReply(); + statMock.mockResolvedValueOnce({ size: 100 } as never); + createReadStreamMock.mockReturnValue({} as never); + + await (service as any).streamFile('/books/book.xyz', 10, 'xyz', reply); + + expect(reply.type).toHaveBeenCalledWith('application/octet-stream'); + }); + it('streamKepub serves cached files when present', async () => { const deps = makeDeps(); const service = new KoboDownloadService( diff --git a/server/src/modules/kobo/services/kobo-download.service.ts b/server/src/modules/kobo/services/kobo-download.service.ts index 5fd31c8fa..ae9247b6a 100644 --- a/server/src/modules/kobo/services/kobo-download.service.ts +++ b/server/src/modules/kobo/services/kobo-download.service.ts @@ -22,6 +22,7 @@ const execFileAsync = promisify(execFile); const MIME: Record = { epub: 'application/epub+zip', + 'kepub.epub': 'application/epub+zip', pdf: 'application/pdf', };