'use strict'; const fs = require('fs'); const net = require('net'); const path = require('path'); const pkg = require('./package.json'); const db = require('./src/database'); const ftp = require('./src/ftp'); const { initializeDataPaths } = require('./src/platform/data-paths'); const { readBuildInfo } = require('./src/platform/build-info'); const { createLogger } = require('./src/platform/logger'); const { SettingsStore } = require('./src/persistence/settings-store'); const { SecretsStore } = require('./src/security/secrets-store'); const { createRegistry } = require('./src/server/create-registry'); const { createAppServer } = require('./src/server/app-server'); const appRoot = __dirname; const paths = initializeDataPaths(); const logger = createLogger(paths.logs); const settings = new SettingsStore(path.join(paths.config, 'settings.json')); const keyFile = process.env.APP_SECRET_KEY_FILE || path.join(paths.secrets, 'master.key'); const secrets = new SecretsStore({ filePath: path.join(paths.secrets, 'secrets.enc.json'), keyFile }); const capabilities = { platform: 'linux-server', language: { default: 'en', fallback: 'en' }, features: { browserUi: { available: true, mode: 'native-web' }, ftp: { available: true }, https: { available: true, mode: 'reverse-proxy', configured: Boolean(process.env.CHIMERA_PUBLIC_ORIGIN || process.env.PORKFOLIO_PUBLIC_ORIGIN) }, gamesDatabase: { available: true }, transferEvents: { available: true, transport: 'websocket' }, systemView: { available: true, mode: 'browser-media-devices', secureContextRequired: true }, ffpkgConversion: { available: false, reason: 'Windows worker required.' }, ffpfscConversion: { available: true, mode: 'native-worker' }, exfatConversion: { available: false, reason: 'Windows worker required.' }, arbitrarySqlWrite: { available: false, reason: 'SQL Explorer is read-only in Docker.' }, shellAccess: { available: false, reason: 'General shell access is intentionally disabled.' }, localGameLibraries: { available: true, roots: ['/games/ps4', '/games/ps5'], readOnly: true }, elfArsenal: { available: true, requiresPayload: true, port: 6969 }, payloadManager: { available: true, requiresPayload: true, port: 8084 }, cheats: { available: true, mode: 'server-cache-and-ftp' }, localSaveManager: { available: true, requiresPayload: true, port: 8082 }, media: { available: true, mode: 'ftp-cache' }, localBackups: { available: true, roots: ['/games/ps4', '/games/ps5', '/game-backups'] }, backporkGenerator: { available: true, mode: 'native-worker', pupUnpacker: '/usr/local/bin/ps5-pup-unpacker' }, bdjbGenerator: { available: true, mode: 'bundled-linux-sdk' }, y2jbGenerator: { available: true, mode: 'server-download' }, ecosystemHub: { available: true, mode: 'official-upstream-connectors' }, compatibilityEngine: { available: true, automaticExecution: false }, consoleHealth: { available: true, scheduled: true, notifications: 'optional-discord' }, safeUpdatePlanner: { available: true, dependencyOrder: true, automaticExecution: false }, payloadManagerRepository: { available: true, path: '/payloads.json', cors: true, checksums: true }, websrvDiscovery: { available: true, endpoint: '/mdns' }, homebrewHub: { available: true, requiresPayload: true, backend: 'ps5-payload-dev/websrv', port: 8080 }, remoteSources: { available: true, protocols: ['smb', 'nfs', 'sftp', 'ftp', 'webdav', 'http', 'https'], credentialsEncrypted: true }, etaHenProfiles: { available: true, snapshots: true, uploadVerification: true }, etaHenPlugins: { available: true, usbPriorityAware: true }, cheatsV2: { available: true, sources: ['etaHEN/PS5_Cheats', 'GoldHEN/GoldHEN_Cheat_Repository', 'illusionyy/PS-Game-Patch'] }, saveVault: { available: true, backend: 'earthonion/garlic-savemgr', port: 8082 }, consoleTimeline: { available: true, ports: [9081, 3232], expertShellProxy: false }, gameUpdateCenter: { available: true, officialManifestHostsOnly: true, pieceAndPackageHashes: true, automaticInstall: false }, payloadSupplyChain: { available: true, localRehash: true, officialSources: true, automaticExecution: false }, liveSession: { available: true, backend: 'ps5debug-NG', readOnlyCommands: 5, memoryAccess: false, kernelAccess: false }, websrvFileExplorer: { available: true, readOnly: true, fixedRoots: true, smbProfilesOnly: true }, remotePlayWake: { available: true, backend: 'chiaki-ng-compatible', registrationKeyEncrypted: true }, remotePlay: { available: true, mode: 'paired-native-client-worker', discovery: true, wake: true, sessionOrchestration: true, embeddedVideo: false, hmacWorker: true }, itemzflowJobs: { available: true, mode: 'read-only-log-monitor', undocumentedCommands: false }, backupReplication: { available: true, target: '/replication', sourceDeletion: false, sha256Verification: true, scheduledOptIn: true }, prometheusMetrics: { available: true, path: '/metrics', bearerTokenRequired: true }, sessionManagement: { available: true, revoke: true, passwordRotation: true }, pacbrewCatalog: { available: true, mode: 'read-only-recipes', install: false }, ps5LinuxLab: { available: true, mode: 'read-only-preflight', payloadSend: false, blockDeviceWrite: false }, auditCenter: { available: true, filters: true, boundedRetention: true, authenticatedExport: true }, recoveryVault: { available: true, schema: 2, sha256: true, externalImport: true, automaticRestore: false }, shadowMountPreflight: { available: true, invalidConfigBlocked: true, criticalConfirmation: true }, passiveKlog: { available: true, port: 3232, zeroWrite: true, payloadLaunch: false, explicitStart: true }, }, }; function backupDatabase() { const chimeraSource = path.join(paths.database, 'chimera.db'); const legacySource = path.join(paths.database, 'porkfolio.db'); const source = fs.existsSync(chimeraSource) ? chimeraSource : legacySource; if (!fs.existsSync(source)) return; const stamp = new Date().toISOString().replace(/[:.]/g, '-'); fs.copyFileSync(source, path.join(paths.backups, `chimera-${stamp}.db`)); const retention = Math.max(1, Number(settings.get('backup.retention', 14)) || 14); const backups = fs.readdirSync(paths.backups).filter(name => /^chimera-.*\.db$/.test(name)).sort().reverse(); for (const stale of backups.slice(retention)) fs.unlinkSync(path.join(paths.backups, stale)); } let appServer; let registryInstance; let shuttingDown = false; function portReachable(host, port, timeoutMs = 1_500) { return new Promise(resolve => { const socket = net.createConnection({ host, port }); let settled = false; const finish = reachable => { if (settled) return; settled = true; socket.destroy(); resolve(reachable); }; socket.setTimeout(timeoutMs); socket.once('connect', () => finish(true)); socket.once('timeout', () => finish(false)); socket.once('error', () => finish(false)); }); } async function autoConnectFtp() { if (!settings.get('autoConnect', true)) { logger.info('[FTP] Automatisch verbinden is uitgeschakeld'); return { connected: false, reason: 'disabled' }; } const host = String(settings.get('ftp.host', '') || '').trim(); if (!host) { logger.warn('[FTP] Automatisch verbinden overgeslagen: PS5-host ontbreekt'); return { connected: false, reason: 'host-missing' }; } const configured = settings.get('ftp.ports', ['1337', '2121', '21']); const preferred = settings.get('ftp.port', '1337'); const ports = [...new Set([preferred, ...(Array.isArray(configured) ? configured : [configured])] .map(Number) .filter(port => Number.isInteger(port) && port >= 1 && port <= 65535))]; const errors = []; const reachability = new Map(await Promise.all(ports.map(async port => [port, await portReachable(host, port)]))); for (const port of ports) { if (!reachability.get(port)) { errors.push({ port, error: 'TCP-poort niet bereikbaar' }); continue; } try { await ftp.connect({ host, port, user: String(settings.get('ftp.user', '') || ''), password: String(secrets.get('ftp.password') || ''), }); settings.set('ftp.port', String(port)); logger.info('[FTP] PS5 automatisch verbonden', { host, port }); return { connected: true, host, port }; } catch (error) { errors.push({ port, error: error.message }); } } logger.warn('[FTP] Automatisch verbinden met de PS5 mislukt', { host, attempts: errors }); return { connected: false, reason: 'unreachable', host, attempts: errors }; } async function shutdown(signal) { if (shuttingDown) return; shuttingDown = true; logger.info('Graceful shutdown gestart', { signal }); try { registryInstance?.maintenance?.stopOperations?.(); } catch (_) {} try { await registryInstance?.ps5P15A10Runtime?.stopEngine?.(); } catch (error) { logger.warn('[PS5Upload] diagnostische engine stoppen mislukt', { error: error.message }); } try { await registryInstance?.ps5ProfileRuntime?.stopEngine?.(); } catch (error) { logger.warn('[PS5Upload] productie-engine stoppen mislukt', { error: error.message }); } try { await ftp.disconnect(); } catch (_) {} try { db.close(); } catch (error) { logger.error('Database sluiten mislukt', error); } try { if (appServer) await appServer.close(); } catch (error) { logger.error('HTTP-server sluiten mislukt', error); } logger.info('Graceful shutdown voltooid'); } async function main() { backupDatabase(); db.configure({ dataDir: paths.database, logger }); await db.initialize(); const registry = createRegistry({ db, ftp, settings, secrets, paths, logger, capabilities, appRoot, broadcast: (channel, ...args) => appServer?.broadcast(channel, ...args) }); registryInstance = registry; appServer = createAppServer({ appRoot, paths, settings, secrets, registry, logger, ftp, capabilities, version: pkg.version }); const port = Number(process.env.PORT || 6967); const host = process.env.HOST || '0.0.0.0'; await appServer.listen(port, host); logger.info('Chimera server gestart', { host, port, dataRoot: paths.root, version: pkg.version, build: readBuildInfo() }); try { await registry.ps5ProfileRuntime.startEngine(); } catch (error) { logger.warn('[PS5Upload] engine autostart geblokkeerd', { code: error.code, error: error.message }); } registry.maintenance.seedPayloadSources(); if (settings.get('admin.passwordHash', '')) { await autoConnectFtp(); registry.maintenance.startOperations(); } else { logger.info('[Operations] Taakplanner wacht tot de eerste configuratie is voltooid'); } } process.on('SIGTERM', () => shutdown('SIGTERM').finally(() => process.exit(0))); process.on('SIGINT', () => shutdown('SIGINT').finally(() => process.exit(0))); process.on('uncaughtException', error => { logger.error('Uncaught exception', error); shutdown('uncaughtException').finally(() => process.exit(1)); }); process.on('unhandledRejection', error => logger.error('Unhandled rejection', error)); if (require.main === module) main().catch(error => { logger.error('Serverstart mislukt', error); process.exitCode = 1; }); module.exports = { main, shutdown, capabilities, autoConnectFtp, portReachable };