fix(docker): rebuild kepubify with Go 1.26.3, fix read-only fs chmod

Replaced pre-compiled kepubify binaries (built with Go 1.17.8, 53
HIGH/CRITICAL CVEs) with binaries rebuilt from source using Go 1.26.3
and golang.org/x/text upgraded to v0.3.8, resolving all fixable CVEs.

Also fixed KepubifyBinaryService incorrectly falling back to downloading
the binary on every startup in read-only containers. chmod() was throwing
EROFS, and the single catch block treated it as a missing file. Split
stat and chmod into separate try/catch so only a missing-file error
triggers the download fallback.
This commit is contained in:
neon
2026-05-11 18:42:18 +00:00
parent 6e68937944
commit 6b650981da
9 changed files with 229 additions and 76 deletions
+1
View File
@@ -0,0 +1 @@
server/bin/kepubify/* binary
+2 -1
View File
@@ -50,8 +50,9 @@ ENV PORT=3000
COPY --from=server-builder --chown=node:node /deploy ./ COPY --from=server-builder --chown=node:node /deploy ./
COPY --from=client-builder --chown=node:node /app/client/dist ./public 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 --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 EXPOSE 3000
@@ -203,10 +203,6 @@ async function saveSettings() {
Copy Copy
</button> </button>
</div> </div>
<p class="mt-3 text-xs text-muted-foreground leading-relaxed">
On your Kobo device, go to <span class="font-semibold text-foreground">Settings → Account → Add account → Other</span> and enter this URL.
Any username/password will work.
</p>
</div> </div>
<div <div
class="flex items-center gap-2 text-xs font-medium text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30 px-3 py-2 rounded-md border border-amber-200 dark:border-amber-900/50" class="flex items-center gap-2 text-xs font-medium text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30 px-3 py-2 rounded-md border border-amber-200 dark:border-amber-900/50"
Binary file not shown.
Binary file not shown.
@@ -6,6 +6,7 @@ vi.mock('fs/promises', () => ({
})); }));
import { chmod, mkdir, stat, writeFile } from 'fs/promises'; import { chmod, mkdir, stat, writeFile } from 'fs/promises';
import { join } from 'path';
import { KepubifyBinaryService } from './kepubify-binary.service'; import { KepubifyBinaryService } from './kepubify-binary.service';
@@ -14,11 +15,13 @@ const mkdirMock = vi.mocked(mkdir);
const statMock = vi.mocked(stat); const statMock = vi.mocked(stat);
const writeFileMock = vi.mocked(writeFile); 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(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -28,79 +31,156 @@ describe('KepubifyBinaryService', () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it('detects expected binary names by platform/arch', () => { describe('detectBinaryName', () => {
const service = new KepubifyBinaryService(config as never); 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'); it('throws for unsupported platforms', () => {
vi.spyOn(process, 'arch', 'get').mockReturnValue('arm64'); const service = makeService();
expect((service as any).detectBinaryName()).toBe('kepubify-darwin-arm64'); vi.spyOn(process, 'platform', 'get').mockReturnValue('win32' as NodeJS.Platform);
vi.spyOn(process, 'arch', 'get').mockReturnValue('x64');
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); expect(() => (service as any).detectBinaryName()).toThrow('Unsupported platform');
vi.spyOn(process, 'arch', 'get').mockReturnValue('x64'); });
expect((service as any).detectBinaryName()).toBe('kepubify-linux-64bit');
}); });
it('throws for unsupported platforms', () => { describe('getBinaryPath', () => {
const service = new KepubifyBinaryService(config as never); it('caches result and only resolves once across multiple calls', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); const service = makeService();
vi.spyOn(process, 'arch', 'get').mockReturnValue('x64'); 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 () => { describe('resolveBinaryPath', () => {
const service = new KepubifyBinaryService(config as never); it('returns bundled path when binary is present in bin/ dir', async () => {
const detectSpy = vi.spyOn(service as any, 'detectBinaryName').mockReturnValue('kepubify-linux-64bit'); const service = makeService();
const ensureSpy = vi.spyOn(service as any, 'ensureBinary').mockResolvedValue(undefined); 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'); const result = await (service as any).resolveBinaryPath('kepubify-linux-64bit');
await expect(service.getBinaryPath()).resolves.toBe('/app-data/.tools/kepubify/kepubify-linux-64bit');
expect(detectSpy).toHaveBeenCalledTimes(1); expect(result).toBe(expectedBundled);
expect(ensureSpy).toHaveBeenCalledTimes(1); 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 () => { describe('ensureDownloaded', () => {
const service = new KepubifyBinaryService(config as never); it('chmods and returns without downloading when binary is already cached', async () => {
statMock.mockResolvedValue({} as never); 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(chmodMock).toHaveBeenCalledWith('/cache/kepubify', 0o755);
expect(mkdirMock).not.toHaveBeenCalled(); expect(mkdirMock).not.toHaveBeenCalled();
}); expect(writeFileMock).not.toHaveBeenCalled();
});
it('downloads missing binary and writes executable file', async () => { it('downloads, writes, and chmods when binary is not cached', async () => {
const service = new KepubifyBinaryService(config as never); const service = makeService();
statMock.mockRejectedValue(new Error('missing')); statMock.mockRejectedValueOnce(new Error('ENOENT'));
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockResolvedValue({ vi.fn().mockResolvedValue({
ok: true, ok: true,
arrayBuffer: () => Promise.resolve(Uint8Array.from([1, 2, 3]).buffer), 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(mkdirMock).toHaveBeenCalledWith('/cache/dir', { recursive: true });
expect(writeFileMock).toHaveBeenCalledWith('/tmp/kepubify', Buffer.from([1, 2, 3])); expect(writeFileMock).toHaveBeenCalledWith('/cache/kepubify', Buffer.from([0xde, 0xad, 0xbe, 0xef]));
expect(chmodMock).toHaveBeenCalledWith('/tmp/kepubify', 0o755); expect(chmodMock).toHaveBeenCalledWith('/cache/kepubify', 0o755);
}); });
it('throws when binary download returns non-ok status', async () => { it('fetches from the bookorbit/bookorbit-tools URL (not neonsolstice)', async () => {
const service = new KepubifyBinaryService(config as never); const service = makeService();
statMock.mockRejectedValue(new Error('missing')); statMock.mockRejectedValueOnce(new Error('ENOENT'));
vi.stubGlobal( const fetchMock = vi.fn().mockResolvedValue({ ok: true, arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)) });
'fetch', vi.stubGlobal('fetch', fetchMock);
vi.fn().mockResolvedValue({
ok: false,
status: 503,
}),
);
await expect((service as any).ensureBinary('/tmp/kepubify', 'kepubify-linux-64bit', '/tmp')).rejects.toThrow( await (service as any).ensureDownloaded('/cache/kepubify', 'kepubify-darwin-arm64', '/cache/dir');
'Failed to download kepubify: HTTP 503',
); 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();
});
}); });
}); });
@@ -4,28 +4,48 @@ import { join } from 'path';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; 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() @Injectable()
export class KepubifyBinaryService { export class KepubifyBinaryService {
private readonly logger = new Logger(KepubifyBinaryService.name); private readonly logger = new Logger(KepubifyBinaryService.name);
private readonly appDataPath: string; private readonly appDataPath: string;
private readonly bundledBinDir: string;
private cachedBinaryPath: string | null = null; private cachedBinaryPath: string | null = null;
constructor(private readonly config: ConfigService) { constructor(private readonly config: ConfigService) {
this.appDataPath = this.config.get<string>('storage.appDataPath')!; this.appDataPath = this.config.get<string>('storage.appDataPath')!;
this.bundledBinDir = join(process.cwd(), 'bin', 'kepubify');
} }
async getBinaryPath(): Promise<string> { async getBinaryPath(): Promise<string> {
if (this.cachedBinaryPath) return this.cachedBinaryPath; if (this.cachedBinaryPath) return this.cachedBinaryPath;
const binaryName = this.detectBinaryName(); const binaryName = this.detectBinaryName();
const toolsDir = join(this.appDataPath, '.tools', 'kepubify'); const binaryPath = await this.resolveBinaryPath(binaryName);
const binaryPath = join(toolsDir, binaryName);
await this.ensureBinary(binaryPath, binaryName, toolsDir);
this.cachedBinaryPath = binaryPath; this.cachedBinaryPath = binaryPath;
return binaryPath; return binaryPath;
} }
private async resolveBinaryPath(binaryName: string): Promise<string> {
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 { private detectBinaryName(): string {
const platform = process.platform; const platform = process.platform;
const arch = process.arch; const arch = process.arch;
@@ -41,27 +61,44 @@ export class KepubifyBinaryService {
throw new Error(`Unsupported platform for kepubify: ${platform} / ${arch}`); throw new Error(`Unsupported platform for kepubify: ${platform} / ${arch}`);
} }
private async ensureBinary(binaryPath: string, binaryName: string, toolsDir: string): Promise<void> { private async ensureDownloaded(binaryPath: string, binaryName: string, toolsDir: string): Promise<void> {
try { try {
await stat(binaryPath); await stat(binaryPath);
await chmod(binaryPath, 0o755); await chmod(binaryPath, 0o755);
return; return;
} catch { } catch {
// Binary not cached, download it // Not cached - download
} }
const url = `${KEPUBIFY_BASE_URL}${binaryName}`; 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 }); await mkdir(toolsDir, { recursive: true });
const response = await fetch(url); let response: Response;
if (!response.ok) throw new Error(`Failed to download kepubify: HTTP ${response.status}`); 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(); const buffer = await response.arrayBuffer();
await writeFile(binaryPath, Buffer.from(buffer)); await writeFile(binaryPath, Buffer.from(buffer));
await chmod(binaryPath, 0o755); 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`);
} }
} }
@@ -192,6 +192,43 @@ describe('KoboDownloadService', () => {
await expect((service as any).streamFile('/books/missing.epub', 99, 'epub', reply)).rejects.toThrow(NotFoundException); 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 () => { it('streamKepub serves cached files when present', async () => {
const deps = makeDeps(); const deps = makeDeps();
const service = new KoboDownloadService( const service = new KoboDownloadService(
@@ -22,6 +22,7 @@ const execFileAsync = promisify(execFile);
const MIME: Record<string, string> = { const MIME: Record<string, string> = {
epub: 'application/epub+zip', epub: 'application/epub+zip',
'kepub.epub': 'application/epub+zip',
pdf: 'application/pdf', pdf: 'application/pdf',
}; };