import Fastify from 'fastify'; import cors from '@fastify/cors'; import fastifyStatic from '@fastify/static'; import { buildFastifyOptions, config } from './config.js'; import { normalizePayloadRulesConfig } from './services/payloadRules.js'; import { authMiddleware } from './middleware/auth.js'; import { sitesRoutes } from './routes/api/sites.js'; import { accountsRoutes } from './routes/api/accounts.js'; import { checkinRoutes } from './routes/api/checkin.js'; import { tokensRoutes } from './routes/api/tokens.js'; import { statsRoutes } from './routes/api/stats.js'; import { authRoutes } from './routes/api/auth.js'; import { settingsRoutes } from './routes/api/settings.js'; import { accountTokensRoutes } from './routes/api/accountTokens.js'; import { searchRoutes } from './routes/api/search.js'; import { eventsRoutes } from './routes/api/events.js'; import { taskRoutes } from './routes/api/tasks.js'; import { testRoutes } from './routes/api/test.js'; import { monitorRoutes } from './routes/api/monitor.js'; import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js'; import { oauthRoutes } from './routes/api/oauth.js'; import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js'; import { proxyRoutes } from './routes/proxy/router.js'; import { startScheduler } from './services/checkinScheduler.js'; import { rebuildTokenRoutesFromAvailability } from './services/modelService.js'; import { startProxyFileRetentionService, stopProxyFileRetentionService } from './services/proxyFileRetentionService.js'; import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js'; import { buildStartupSummaryLines } from './services/startupInfo.js'; import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js'; import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js'; import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js'; import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js'; import { startSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js'; import { reloadBackupWebdavScheduler } from './services/backupService.js'; import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js'; import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js'; import { existsSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, normalize, resolve, sep } from 'path'; import { normalizeLogCleanupRetentionDays } from './services/logCleanupService.js'; import { db, ensureProxyFileCompatibilityColumns, ensureProxyLogClientColumns, ensureProxyLogDownstreamApiKeyIdColumn, ensureProxyLogBillingDetailsColumn, ensureRouteGroupingCompatibilityColumns, ensureSiteCompatibilityColumns, runtimeDbDialect, schema, switchRuntimeDatabase, type RuntimeDbDialect, } from './db/index.js'; function toSettingsMap(rows: Array<{ key: string; value: string }>) { return new Map(rows.map((row) => [row.key, row.value])); } function parseSettingFromMap(settingsMap: Map, key: string): T | undefined { const raw = settingsMap.get(key); if (typeof raw !== 'string' || !raw) return undefined; try { return JSON.parse(raw) as T; } catch { return undefined; } } function toStringList(value: unknown): string[] { if (Array.isArray(value)) { return value .map((item) => (typeof item === 'string' ? item.trim() : '')) .filter((item) => item.length > 0); } if (typeof value === 'string') { return value .split(',') .map((item) => item.trim()) .filter((item) => item.length > 0); } return []; } function normalizeSavedDbType(value: unknown): RuntimeDbDialect | null { if (typeof value !== 'string') return null; const normalized = value.trim().toLowerCase(); if (normalized === 'sqlite') return 'sqlite'; if (normalized === 'mysql') return 'mysql'; if (normalized === 'postgres' || normalized === 'postgresql') return 'postgres'; return null; } function validateSavedDbUrl(dialect: RuntimeDbDialect, value: unknown): string | null { if (typeof value !== 'string') return null; const normalized = value.trim(); if (!normalized) return null; if (dialect === 'sqlite') return normalized; if (dialect === 'mysql' && normalized.startsWith('mysql://')) return normalized; if (dialect === 'postgres' && (normalized.startsWith('postgres://') || normalized.startsWith('postgresql://'))) return normalized; return null; } function extractSavedRuntimeDatabaseConfig(settingsMap: Map): { dialect: RuntimeDbDialect; dbUrl: string; ssl: boolean } | null { const rawType = parseSettingFromMap(settingsMap, 'db_type'); const rawUrl = parseSettingFromMap(settingsMap, 'db_url'); const rawSsl = parseSettingFromMap(settingsMap, 'db_ssl'); const dialect = normalizeSavedDbType(rawType); if (!dialect) return null; const dbUrl = validateSavedDbUrl(dialect, rawUrl); if (!dbUrl) return null; return { dialect, dbUrl, ssl: typeof rawSsl === 'boolean' ? rawSsl : false, }; } const LOG_CLEANUP_SETTING_KEYS = [ 'log_cleanup_cron', 'log_cleanup_usage_logs_enabled', 'log_cleanup_program_logs_enabled', 'log_cleanup_retention_days', ] as const; function hasExplicitLogCleanupSettings(settingsMap: Map): boolean { return LOG_CLEANUP_SETTING_KEYS.some((key) => settingsMap.has(key)); } function applyRuntimeSettings(settingsMap: Map) { const authToken = parseSettingFromMap(settingsMap, 'auth_token'); if (typeof authToken === 'string' && authToken) config.authToken = authToken; const proxyToken = parseSettingFromMap(settingsMap, 'proxy_token'); if (typeof proxyToken === 'string' && proxyToken) config.proxyToken = proxyToken; const systemProxyUrl = parseSettingFromMap(settingsMap, 'system_proxy_url'); if (typeof systemProxyUrl === 'string') config.systemProxyUrl = systemProxyUrl; const proxyErrorKeywords = parseSettingFromMap(settingsMap, 'proxy_error_keywords'); if (proxyErrorKeywords !== undefined) { config.proxyErrorKeywords = toStringList(proxyErrorKeywords); } const proxyEmptyContentFailEnabled = parseSettingFromMap(settingsMap, 'proxy_empty_content_fail_enabled'); if (typeof proxyEmptyContentFailEnabled === 'boolean') { config.proxyEmptyContentFailEnabled = proxyEmptyContentFailEnabled; } const globalBlockedBrands = parseSettingFromMap(settingsMap, 'global_blocked_brands'); if (Array.isArray(globalBlockedBrands)) { config.globalBlockedBrands = globalBlockedBrands.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean); } const codexHeaderDefaults = parseSettingFromMap(settingsMap, 'codex_header_defaults'); if (codexHeaderDefaults && typeof codexHeaderDefaults === 'object') { const next = codexHeaderDefaults as Record; config.codexHeaderDefaults = { userAgent: typeof next.userAgent === 'string' ? next.userAgent.trim() : (typeof next['user-agent'] === 'string' ? next['user-agent'].trim() : config.codexHeaderDefaults.userAgent), betaFeatures: typeof next.betaFeatures === 'string' ? next.betaFeatures.trim() : (typeof next['beta-features'] === 'string' ? next['beta-features'].trim() : config.codexHeaderDefaults.betaFeatures), }; } if (settingsMap.has('payload_rules')) { config.payloadRules = normalizePayloadRulesConfig(parseSettingFromMap(settingsMap, 'payload_rules')); } const checkinCron = parseSettingFromMap(settingsMap, 'checkin_cron'); if (typeof checkinCron === 'string' && checkinCron) config.checkinCron = checkinCron; const checkinScheduleMode = parseSettingFromMap(settingsMap, 'checkin_schedule_mode'); if (checkinScheduleMode === 'cron' || checkinScheduleMode === 'interval') { config.checkinScheduleMode = checkinScheduleMode; } const checkinIntervalHours = parseSettingFromMap(settingsMap, 'checkin_interval_hours'); if (typeof checkinIntervalHours === 'number' && Number.isFinite(checkinIntervalHours) && checkinIntervalHours >= 1 && checkinIntervalHours <= 24) { config.checkinIntervalHours = Math.trunc(checkinIntervalHours); } const balanceRefreshCron = parseSettingFromMap(settingsMap, 'balance_refresh_cron'); if (typeof balanceRefreshCron === 'string' && balanceRefreshCron) config.balanceRefreshCron = balanceRefreshCron; const logCleanupCron = parseSettingFromMap(settingsMap, 'log_cleanup_cron'); if (typeof logCleanupCron === 'string' && logCleanupCron) config.logCleanupCron = logCleanupCron; const logCleanupUsageLogsEnabled = parseSettingFromMap(settingsMap, 'log_cleanup_usage_logs_enabled'); if (typeof logCleanupUsageLogsEnabled === 'boolean') { config.logCleanupUsageLogsEnabled = logCleanupUsageLogsEnabled; } const logCleanupProgramLogsEnabled = parseSettingFromMap(settingsMap, 'log_cleanup_program_logs_enabled'); if (typeof logCleanupProgramLogsEnabled === 'boolean') { config.logCleanupProgramLogsEnabled = logCleanupProgramLogsEnabled; } const logCleanupRetentionDays = parseSettingFromMap(settingsMap, 'log_cleanup_retention_days'); if (typeof logCleanupRetentionDays === 'number' && Number.isFinite(logCleanupRetentionDays) && logCleanupRetentionDays >= 1) { config.logCleanupRetentionDays = normalizeLogCleanupRetentionDays(logCleanupRetentionDays); } const routingWeights = parseSettingFromMap>(settingsMap, 'routing_weights'); if (routingWeights && typeof routingWeights === 'object') { config.routingWeights = { ...config.routingWeights, ...routingWeights, }; } const routingFallbackUnitCost = parseSettingFromMap(settingsMap, 'routing_fallback_unit_cost'); if (typeof routingFallbackUnitCost === 'number' && Number.isFinite(routingFallbackUnitCost) && routingFallbackUnitCost > 0) { config.routingFallbackUnitCost = Math.max(1e-6, routingFallbackUnitCost); } const webhookUrl = parseSettingFromMap(settingsMap, 'webhook_url'); if (typeof webhookUrl === 'string') config.webhookUrl = webhookUrl; const barkUrl = parseSettingFromMap(settingsMap, 'bark_url'); if (typeof barkUrl === 'string') config.barkUrl = barkUrl; const serverChanKey = parseSettingFromMap(settingsMap, 'serverchan_key'); if (typeof serverChanKey === 'string') config.serverChanKey = serverChanKey; const telegramEnabled = parseSettingFromMap(settingsMap, 'telegram_enabled'); if (typeof telegramEnabled === 'boolean') config.telegramEnabled = telegramEnabled; const telegramApiBaseUrl = parseSettingFromMap(settingsMap, 'telegram_api_base_url'); if (typeof telegramApiBaseUrl === 'string' && telegramApiBaseUrl.trim()) { config.telegramApiBaseUrl = telegramApiBaseUrl.trim().replace(/\/+$/, ''); } const telegramBotToken = parseSettingFromMap(settingsMap, 'telegram_bot_token'); if (typeof telegramBotToken === 'string') config.telegramBotToken = telegramBotToken; const telegramChatId = parseSettingFromMap(settingsMap, 'telegram_chat_id'); if (typeof telegramChatId === 'string') config.telegramChatId = telegramChatId; const telegramUseSystemProxy = parseSettingFromMap(settingsMap, 'telegram_use_system_proxy'); if (typeof telegramUseSystemProxy === 'boolean') config.telegramUseSystemProxy = telegramUseSystemProxy; const telegramMessageThreadId = parseSettingFromMap(settingsMap, 'telegram_message_thread_id'); if (typeof telegramMessageThreadId === 'string') config.telegramMessageThreadId = telegramMessageThreadId; const smtpEnabled = parseSettingFromMap(settingsMap, 'smtp_enabled'); if (typeof smtpEnabled === 'boolean') config.smtpEnabled = smtpEnabled; const smtpHost = parseSettingFromMap(settingsMap, 'smtp_host'); if (typeof smtpHost === 'string') config.smtpHost = smtpHost; const smtpPort = parseSettingFromMap(settingsMap, 'smtp_port'); if (typeof smtpPort === 'number' && Number.isFinite(smtpPort) && smtpPort > 0) { config.smtpPort = smtpPort; } const smtpSecure = parseSettingFromMap(settingsMap, 'smtp_secure'); if (typeof smtpSecure === 'boolean') config.smtpSecure = smtpSecure; const smtpUser = parseSettingFromMap(settingsMap, 'smtp_user'); if (typeof smtpUser === 'string') config.smtpUser = smtpUser; const smtpPass = parseSettingFromMap(settingsMap, 'smtp_pass'); if (typeof smtpPass === 'string') config.smtpPass = smtpPass; const smtpFrom = parseSettingFromMap(settingsMap, 'smtp_from'); if (typeof smtpFrom === 'string') config.smtpFrom = smtpFrom; const smtpTo = parseSettingFromMap(settingsMap, 'smtp_to'); if (typeof smtpTo === 'string') config.smtpTo = smtpTo; const notifyCooldownSec = parseSettingFromMap(settingsMap, 'notify_cooldown_sec'); if (typeof notifyCooldownSec === 'number' && Number.isFinite(notifyCooldownSec) && notifyCooldownSec >= 0) { config.notifyCooldownSec = Math.trunc(notifyCooldownSec); } const adminIpAllowlist = parseSettingFromMap(settingsMap, 'admin_ip_allowlist'); if (adminIpAllowlist !== undefined) { config.adminIpAllowlist = toStringList(adminIpAllowlist); } } // Ensure the current runtime database is bootstrapped before reading settings. await ensureRuntimeDatabaseReady({ dialect: runtimeDbDialect, connectionString: config.dbUrl, ssl: config.dbSsl, }); // Load runtime config overrides from settings try { const initialRows = await db.select().from(schema.settings).all(); const initialMap = toSettingsMap(initialRows); const savedDbConfig = extractSavedRuntimeDatabaseConfig(initialMap); const activeDbUrl = (config.dbUrl || '').trim(); const originalRuntimeConfig = { dialect: runtimeDbDialect, dbUrl: activeDbUrl, ssl: config.dbSsl, }; if (savedDbConfig && (savedDbConfig.dialect !== runtimeDbDialect || savedDbConfig.dbUrl !== activeDbUrl || savedDbConfig.ssl !== config.dbSsl)) { try { await switchRuntimeDatabase(savedDbConfig.dialect, savedDbConfig.dbUrl, savedDbConfig.ssl); console.log(`Loaded runtime DB config from settings: ${savedDbConfig.dialect}`); } catch (error) { const currentDbUrl = (config.dbUrl || '').trim(); const switchedAway = runtimeDbDialect !== originalRuntimeConfig.dialect || currentDbUrl !== originalRuntimeConfig.dbUrl || config.dbSsl !== originalRuntimeConfig.ssl; if (switchedAway) { await switchRuntimeDatabase( originalRuntimeConfig.dialect, originalRuntimeConfig.dbUrl, originalRuntimeConfig.ssl, ); } console.warn(`Failed to switch runtime DB from settings: ${(error as Error)?.message || 'unknown error'}`); } } await ensureSiteCompatibilityColumns(); await ensureRouteGroupingCompatibilityColumns(); await ensureProxyFileCompatibilityColumns(); await ensureProxyLogClientColumns(); await ensureProxyLogDownstreamApiKeyIdColumn(); const finalRows = await db.select().from(schema.settings).all(); const finalMap = toSettingsMap(finalRows); applyRuntimeSettings(finalMap); config.logCleanupConfigured = hasExplicitLogCleanupSettings(finalMap); if (!config.logCleanupConfigured && config.proxyLogRetentionDays > 0) { config.logCleanupUsageLogsEnabled = true; config.logCleanupProgramLogsEnabled = false; config.logCleanupRetentionDays = normalizeLogCleanupRetentionDays(config.proxyLogRetentionDays); } await ensureProxyLogBillingDetailsColumn(); await repairStoredCreatedAtValues(); await migrateSiteApiKeysToAccounts(); await ensureDefaultSitesSeeded(); await rebuildTokenRoutesFromAvailability(); console.log('Loaded runtime settings overrides'); } catch (error) { console.warn(`Failed to load runtime settings overrides: ${(error as Error)?.message || 'unknown error'}`); } const app = Fastify(buildFastifyOptions(config)); await app.register(cors); // Auth middleware for /api routes app.addHook('onRequest', async (request, reply) => { if (request.url.startsWith('/api/') && !isPublicApiRoute(request.url)) { await authMiddleware(request, reply); } }); // Register API routes await app.register(registerDesktopRoutes); await app.register(sitesRoutes); await app.register(accountsRoutes); await app.register(checkinRoutes); await app.register(tokensRoutes); await app.register(statsRoutes); await app.register(authRoutes); await app.register(settingsRoutes); await app.register(accountTokensRoutes); await app.register(searchRoutes); await app.register(eventsRoutes); await app.register(siteAnnouncementsRoutes); await app.register(taskRoutes); await app.register(testRoutes); await app.register(monitorRoutes); await app.register(downstreamApiKeysRoutes); await app.register(oauthRoutes); // Register OpenAI-compatible proxy routes await app.register(proxyRoutes); // Serve static web frontend in production const webDir = resolve(dirname(fileURLToPath(import.meta.url)), '../web'); if (existsSync(webDir)) { await app.register(fastifyStatic, { root: webDir, prefix: '/', wildcard: false, setHeaders: (res, filePath) => { const normalizedPath = normalize(filePath); if (normalizedPath.includes(`${sep}assets${sep}`)) { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); return; } if (normalizedPath.endsWith(`${sep}index.html`)) { res.setHeader('Cache-Control', 'no-cache'); } }, }); // SPA fallback app.setNotFoundHandler(async (request, reply) => { if (!request.url.startsWith('/api/') && !request.url.startsWith('/v1/')) { return reply.sendFile('index.html'); } reply.code(404).send({ error: 'Not found' }); }); } // Start scheduler await startScheduler(); await reloadBackupWebdavScheduler(); startSiteAnnouncementPolling(); try { await startOAuthLoopbackCallbackServers(); } catch (error) { console.warn(`Failed to start OAuth callback listeners: ${(error as Error)?.message || 'unknown error'}`); } setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured); startProxyFileRetentionService(); app.addHook('onClose', async () => { stopProxyFileRetentionService(); stopProxyLogRetentionService(); await stopOAuthLoopbackCallbackServers(); }); // Start server try { await app.listen({ port: config.port, host: config.listenHost }); const summaryLines = buildStartupSummaryLines({ port: config.port, host: config.listenHost, authToken: config.authToken, proxyToken: config.proxyToken, }); for (const line of summaryLines) { console.log(line); } } catch (err) { app.log.error(err); process.exit(1); }