diff --git a/admin/app/services/kiwix_library_service.ts b/admin/app/services/kiwix_library_service.ts index 183afe6..81f63fa 100644 --- a/admin/app/services/kiwix_library_service.ts +++ b/admin/app/services/kiwix_library_service.ts @@ -201,6 +201,56 @@ export class KiwixLibraryService { } } + /** + * True if the library XML parses and has a root. A truncated or + * corrupt file (e.g. an interrupted write) fails this even though it exists, + * so the caller can rebuild rather than leave Kiwix serving a broken library. + * An empty-but-well-formed library is considered valid (nothing to repair). + */ + private _isValidLibraryXml(xmlContent: string): boolean { + try { + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + isArray: (name) => name === 'book', + }) + const parsed = parser.parse(xmlContent) + return parsed?.library !== undefined && parsed?.library !== null + } catch { + return false + } + } + + /** + * Boot-time safety net: if the library XML is missing or unparseable, rebuild + * it from the ZIM files on disk so Kiwix (running in library mode with + * --monitorLibrary) doesn't come up serving an empty/broken library with no + * path to recovery. This covers files lost or corrupted outside the normal + * download flow (storage relocation, interrupted write, manual deletion). + * + * Returns true if a rebuild was performed. Filesystem errors other than + * "not found" are surfaced rather than masked by a rebuild. + */ + async ensureLibraryXmlHealthy(): Promise { + let content: string + try { + content = await readFile(this.getLibraryFilePath(), 'utf-8') + } catch (err: any) { + if (err?.code === 'ENOENT') { + logger.warn('[KiwixLibraryService] Library XML missing on startup; rebuilding from disk.') + await this.rebuildFromDisk() + return true + } + throw err + } + + if (this._isValidLibraryXml(content)) return false + + logger.warn('[KiwixLibraryService] Library XML present but invalid; rebuilding from disk.') + await this.rebuildFromDisk() + return true + } + async rebuildFromDisk(opts?: { excludeFilenames?: string[] }): Promise { const dirPath = join(process.cwd(), ZIM_STORAGE_PATH) await ensureDirectoryExists(dirPath) diff --git a/admin/providers/kiwix_migration_provider.ts b/admin/providers/kiwix_migration_provider.ts index 5e010b3..e9bccf7 100644 --- a/admin/providers/kiwix_migration_provider.ts +++ b/admin/providers/kiwix_migration_provider.ts @@ -22,6 +22,7 @@ export default class KiwixMigrationProvider { const Service = (await import('#models/service')).default const { SERVICE_NAMES } = await import('../constants/service_names.js') const { DockerService } = await import('#services/docker_service') + const { KiwixLibraryService } = await import('#services/kiwix_library_service') const kiwixService = await Service.query() .where('service_name', SERVICE_NAMES.KIWIX) @@ -36,7 +37,15 @@ export default class KiwixMigrationProvider { const isLegacy = await dockerService.isKiwixOnLegacyConfig() if (!isLegacy) { - logger.info('[KiwixMigrationProvider] Kiwix is already in library mode — no migration needed.') + // Already in library mode. Make sure the library XML actually exists and + // is parseable — if it was lost or corrupted outside the download flow, + // rebuild it from disk so Kiwix isn't left serving an empty library. + const rebuilt = await new KiwixLibraryService().ensureLibraryXmlHealthy() + logger.info( + rebuilt + ? '[KiwixMigrationProvider] Rebuilt missing/invalid Kiwix library XML on startup.' + : '[KiwixMigrationProvider] Kiwix is already in library mode — no migration needed.' + ) return }