mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {randomUUID} from 'node:crypto';
|
|
import {initCassandra, shutdownCassandra} from '@pkgs/cassandra/src/Client';
|
|
import {ensureGeoipDatabaseOnStartup} from '@pkgs/geoip/src/GeoipStartup';
|
|
import {JetStreamConnectionManager} from '@pkgs/nats/src/JetStreamConnectionManager';
|
|
import {getDefaultPostgresClient, initPostgres, shutdownPostgres} from '@pkgs/postgres/src/Client';
|
|
import type {APIConfig} from '../config/APIConfig';
|
|
import {hasDatabaseQueryExecutor, setDatabaseQueryExecutor} from '../database/CassandraQueryExecution';
|
|
import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from '../database/PostgresKvQueryExecutor';
|
|
import {GuildDataRepository} from '../guild/repositories/GuildDataRepository';
|
|
import type {ILogger} from '../ILogger';
|
|
import {JobLedgerRepository} from '../jobs/JobLedgerRepository';
|
|
import {startAbuseReplicationSubscriber, stopAbuseReplicationSubscriber} from '../middleware/AbusiveIpAutoBanner';
|
|
import {ipBanCache} from '../middleware/IpBanMiddleware';
|
|
import {initializeServiceSingletons, shutdownReportService} from '../middleware/ServiceMiddleware';
|
|
import {
|
|
ensureVoiceResourcesInitialized,
|
|
getKVClient,
|
|
getSnowflakeService,
|
|
setInjectedWorkerService,
|
|
} from '../middleware/ServiceRegistry';
|
|
import {
|
|
getCacheService,
|
|
getInstanceConfigRepository,
|
|
getKVAccountDeletionQueue,
|
|
getReportRepository,
|
|
getUserRepository,
|
|
} from '../middleware/ServiceSingletons';
|
|
import {torExitListCache} from '../middleware/TorExitListCache';
|
|
import {initializeSearch, shutdownSearch} from '../SearchFactory';
|
|
import {warmupAdminSearchIndexes} from '../search/SearchWarmup';
|
|
import {VisionarySlotInitializer} from '../stripe/VisionarySlotInitializer';
|
|
import {VoiceDataInitializer} from '../voice/VoiceDataInitializer';
|
|
import {JetStreamWorkerQueue} from '../worker/JetStreamWorkerQueue';
|
|
import {WorkerService} from '../worker/WorkerService';
|
|
|
|
let jsConnectionManager: JetStreamConnectionManager | null = null;
|
|
|
|
function unsupportedDatabaseBackend(backend: never): never {
|
|
throw new Error(`Unsupported database backend during shutdown: ${String(backend)}`);
|
|
}
|
|
|
|
export function createInitializer(config: APIConfig, logger: ILogger): () => Promise<void> {
|
|
return async (): Promise<void> => {
|
|
try {
|
|
logger.info('Initializing API service...');
|
|
const geoipStartupResult = await ensureGeoipDatabaseOnStartup({
|
|
geoip: config.geoip,
|
|
s3Config: {
|
|
endpoint: config.s3.endpoint,
|
|
region: config.s3.region,
|
|
accessKeyId: config.s3.accessKeyId,
|
|
secretAccessKey: config.s3.secretAccessKey,
|
|
},
|
|
});
|
|
if (geoipStartupResult.mode === 's3') {
|
|
logger.info(
|
|
{
|
|
maxmind_db_path: geoipStartupResult.maxmindDbPath,
|
|
city: geoipStartupResult.city,
|
|
asn: geoipStartupResult.asn,
|
|
s3_bucket: geoipStartupResult.bucket,
|
|
s3_key: geoipStartupResult.key,
|
|
},
|
|
'GeoIP databases downloaded from S3',
|
|
);
|
|
}
|
|
if (config.database.backend === 'postgres' && !hasDatabaseQueryExecutor()) {
|
|
await initPostgres(config.postgres);
|
|
const postgres = getDefaultPostgresClient();
|
|
await ensurePostgresKvSchema(postgres);
|
|
setDatabaseQueryExecutor(new PostgresKvQueryExecutor(postgres));
|
|
logger.info('Postgres KV client initialized');
|
|
} else if (config.database.backend === 'postgres') {
|
|
logger.info('Using injected database query executor for tests');
|
|
}
|
|
if (config.database.backend === 'cassandra' && !hasDatabaseQueryExecutor()) {
|
|
await initCassandra({
|
|
hosts: config.cassandra.hosts.split(',').filter(Boolean),
|
|
port: config.cassandra.port,
|
|
keyspace: config.cassandra.keyspace,
|
|
localDc: config.cassandra.localDc,
|
|
username: config.cassandra.username || undefined,
|
|
password: config.cassandra.password || undefined,
|
|
});
|
|
logger.info('Cassandra client initialized');
|
|
} else if (config.database.backend === 'cassandra') {
|
|
logger.info('Using injected database query executor for tests');
|
|
}
|
|
const kvClient = getKVClient();
|
|
ipBanCache.setRefreshSubscriber(kvClient);
|
|
await ipBanCache.initialize();
|
|
logger.info('IP ban cache initialized');
|
|
await startAbuseReplicationSubscriber(kvClient);
|
|
logger.info('Abusive-IP auto-banner replication started');
|
|
torExitListCache.setKvClient(kvClient);
|
|
await torExitListCache.initialize();
|
|
logger.info('Tor exit list cache initialized');
|
|
const {urlBlocklistCache} = await import('../middleware/UrlBlocklistCache');
|
|
urlBlocklistCache.setRefreshSubscriber(kvClient);
|
|
const {getStorageService} = await import('../middleware/ServiceSingletons');
|
|
urlBlocklistCache.setStorageService(getStorageService());
|
|
await urlBlocklistCache.initialize();
|
|
logger.info('URL blocklist cache initialized');
|
|
const {fileShaCache} = await import('../middleware/FileShaCache');
|
|
fileShaCache.setRefreshSubscriber(kvClient);
|
|
await fileShaCache.initialize();
|
|
logger.info('File SHA blocklist cache initialized');
|
|
const {phraseBlocklistCache} = await import('../middleware/PhraseBlocklistCache');
|
|
phraseBlocklistCache.setRefreshSubscriber(kvClient);
|
|
await phraseBlocklistCache.initialize();
|
|
logger.info('Phrase blocklist cache initialized');
|
|
const {bannedAvatarHashCache} = await import('../middleware/BannedAvatarHashCache');
|
|
bannedAvatarHashCache.setRefreshSubscriber(kvClient);
|
|
await bannedAvatarHashCache.initialize();
|
|
logger.info('Banned avatar hash cache initialized');
|
|
const {profileSubstringBlocklistCache} = await import('../middleware/ProfileSubstringBlocklistCache');
|
|
profileSubstringBlocklistCache.setRefreshSubscriber(kvClient);
|
|
await profileSubstringBlocklistCache.initialize();
|
|
logger.info('Profile substring blocklist cache initialized');
|
|
await initializeServiceSingletons();
|
|
logger.info('Service singletons initialized');
|
|
if (!config.dev.testModeEnabled) {
|
|
jsConnectionManager = new JetStreamConnectionManager({
|
|
url: config.nats.jetStreamUrl,
|
|
token: config.nats.authToken || undefined,
|
|
name: 'api-worker',
|
|
});
|
|
await jsConnectionManager.connect();
|
|
const workerQueue = new JetStreamWorkerQueue(jsConnectionManager);
|
|
await workerQueue.ensureStream();
|
|
setInjectedWorkerService(new WorkerService(workerQueue, getSnowflakeService(), new JobLedgerRepository()));
|
|
logger.info('JetStream worker service initialized');
|
|
}
|
|
try {
|
|
const kvDeletionQueue = getKVAccountDeletionQueue();
|
|
if (await kvDeletionQueue.needsRebuild()) {
|
|
logger.info('KV deletion queue needs rebuild, rebuilding...');
|
|
await kvDeletionQueue.rebuildState();
|
|
} else {
|
|
logger.info('KV deletion queue state is healthy');
|
|
}
|
|
} catch (error) {
|
|
logger.error({error}, 'Failed to verify KV deletion queue state');
|
|
throw error;
|
|
}
|
|
logger.info('Initializing search indexes...');
|
|
let searchInitialized = false;
|
|
try {
|
|
await initializeSearch(getCacheService());
|
|
searchInitialized = true;
|
|
logger.info('Search initialized');
|
|
} catch (error) {
|
|
logger.error({error}, 'Search initialisation failed');
|
|
throw error;
|
|
}
|
|
if (searchInitialized) {
|
|
const warmupLockKey = 'fluxer:search:warmup:admin';
|
|
const warmupLockToken = randomUUID();
|
|
const warmupLockTtlSeconds = 60 * 60;
|
|
const acquiredWarmupLock = await kvClient.acquireLock(warmupLockKey, warmupLockToken, warmupLockTtlSeconds);
|
|
if (!acquiredWarmupLock) {
|
|
logger.info('Another API instance is warming search indexes, skipping warmup');
|
|
} else {
|
|
try {
|
|
await warmupAdminSearchIndexes({
|
|
userRepository: getUserRepository(),
|
|
guildRepository: new GuildDataRepository(),
|
|
reportRepository: getReportRepository(),
|
|
logger,
|
|
});
|
|
} catch (error) {
|
|
logger.error({error}, 'Admin search warmup failed (continuing startup)');
|
|
} finally {
|
|
try {
|
|
await kvClient.releaseLock(warmupLockKey, warmupLockToken);
|
|
} catch (error) {
|
|
logger.warn({error}, 'Failed to release admin search warmup lock');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (config.voice.enabled && config.voice.defaultRegion) {
|
|
const voiceDataInitializer = new VoiceDataInitializer();
|
|
await voiceDataInitializer.initialize();
|
|
await ensureVoiceResourcesInitialized();
|
|
logger.info('Voice data initialized');
|
|
}
|
|
if (config.dev.testModeEnabled && config.stripe.enabled) {
|
|
const visionarySlotInitializer = new VisionarySlotInitializer();
|
|
await visionarySlotInitializer.initialize();
|
|
logger.info('Stripe visionary slots initialized');
|
|
}
|
|
if (config.dev.testModeEnabled) {
|
|
const instanceConfigRepository = getInstanceConfigRepository();
|
|
try {
|
|
await instanceConfigRepository.setSsoConfig({
|
|
enabled: false,
|
|
authorizationUrl: null,
|
|
tokenUrl: null,
|
|
clientId: null,
|
|
});
|
|
logger.info('Reset SSO config to disabled for test mode');
|
|
} catch (error) {
|
|
logger.warn({error}, 'Failed to reset SSO config for test mode');
|
|
}
|
|
}
|
|
logger.info('API service initialization complete');
|
|
} catch (error) {
|
|
logger.error({error}, 'API service initialization failed');
|
|
await createShutdown(config, logger)();
|
|
throw error;
|
|
}
|
|
};
|
|
}
|
|
|
|
export function createShutdown(config: APIConfig, logger: ILogger): () => Promise<void> {
|
|
return async (): Promise<void> => {
|
|
logger.info('Shutting down API service...');
|
|
if (jsConnectionManager) {
|
|
try {
|
|
await jsConnectionManager.drain();
|
|
jsConnectionManager = null;
|
|
} catch (error) {
|
|
logger.error({error}, 'Error draining JetStream worker connection');
|
|
}
|
|
}
|
|
setInjectedWorkerService(undefined);
|
|
try {
|
|
await shutdownSearch();
|
|
logger.info('Search service shut down');
|
|
} catch (error) {
|
|
logger.error({error}, 'Error shutting down search service');
|
|
}
|
|
try {
|
|
ipBanCache.shutdown();
|
|
logger.info('IP ban cache shut down');
|
|
} catch (error) {
|
|
logger.error({error}, 'Error shutting down IP ban cache');
|
|
}
|
|
try {
|
|
await stopAbuseReplicationSubscriber();
|
|
logger.info('Abusive-IP auto-banner replication stopped');
|
|
} catch (error) {
|
|
logger.error({error}, 'Error stopping abusive-IP auto-banner replication');
|
|
}
|
|
try {
|
|
torExitListCache.shutdown();
|
|
logger.info('Tor exit list cache shut down');
|
|
} catch (error) {
|
|
logger.error({error}, 'Error shutting down Tor exit list cache');
|
|
}
|
|
try {
|
|
getInstanceConfigRepository().shutdown();
|
|
logger.info('Instance config repository shut down');
|
|
} catch (error) {
|
|
logger.error({error}, 'Error shutting down instance config repository');
|
|
}
|
|
try {
|
|
shutdownReportService();
|
|
logger.info('Report service shut down');
|
|
} catch (error) {
|
|
logger.error({error}, 'Error shutting down report service');
|
|
}
|
|
setDatabaseQueryExecutor(null);
|
|
switch (config.database.backend) {
|
|
case 'postgres':
|
|
try {
|
|
await shutdownPostgres();
|
|
logger.info('Postgres client shut down');
|
|
} catch (error) {
|
|
logger.error({error}, 'Error shutting down Postgres client');
|
|
}
|
|
break;
|
|
case 'cassandra':
|
|
try {
|
|
await shutdownCassandra();
|
|
logger.info('Cassandra client shut down');
|
|
} catch (error) {
|
|
logger.error({error}, 'Error shutting down Cassandra client');
|
|
}
|
|
break;
|
|
default:
|
|
unsupportedDatabaseBackend(config.database.backend);
|
|
}
|
|
logger.info('API service shutdown complete');
|
|
};
|
|
}
|