// SPDX-License-Identifier: AGPL-3.0-or-later import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress'; import {createUserID} from '../BrandedTypes'; import {deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '../database/CassandraQueryExecution'; import type { AdminAuditLogRow, BannedAvatarHashRow, BannedFileShaRow, BannedProfileSubstringRow, BannedProfileSubstringScope, BannedUrlDomainRow, BannedUrlRow, } from '../database/types/AdminArchiveTypes'; import {isAccountPolicyContactDomainReputationExempt} from '../risk/AccountPolicyService'; import {isIpBanExempt} from '../risk/IpBanExemptions'; import { AdminAuditLogs, BannedAvatarHashes, BannedEmails, BannedFileShas, BannedIps, BannedPhonePrefixes, BannedPhrases, BannedProfileSubstrings, BannedUrlDomains, BannedUrls, DisposableEmailDomains, SuspiciousEmailDomains, } from '../Tables'; import {parseIpBanEntry, tryParseSingleIp} from '../utils/IpRangeUtils'; import {canonicalizeStoredPhrase} from '../utils/PhraseBlocklistNormalization'; import type {AdminAuditLog, BannedIpEntry, BannedIpKind, IAdminRepository} from './IAdminRepository'; const FETCH_AUDIT_LOG_BY_ID_QUERY = AdminAuditLogs.select({ where: AdminAuditLogs.where.eq('log_id'), }); const FETCH_AUDIT_LOGS_BY_IDS_QUERY = AdminAuditLogs.select({ where: AdminAuditLogs.where.in('log_id', 'log_ids'), }); const LOAD_ALL_BANNED_IPS_QUERY = BannedIps.select(); const IS_EMAIL_BANNED_QUERY = BannedEmails.select({ where: BannedEmails.where.eq('email_lower'), }); const IS_EMAIL_DOMAIN_SUSPICIOUS_QUERY = SuspiciousEmailDomains.select({ where: SuspiciousEmailDomains.where.eq('domain'), }); const IS_EMAIL_DOMAIN_DISPOSABLE_QUERY = DisposableEmailDomains.select({ where: DisposableEmailDomains.where.eq('domain'), }); const createLoadDisposableEmailDomainsQuery = (limit?: number) => limit ? DisposableEmailDomains.select({limit}) : DisposableEmailDomains.select(); const IS_PHRASE_BANNED_QUERY = BannedPhrases.select({ where: BannedPhrases.where.eq('phrase'), }); const LOAD_ALL_BANNED_PHRASES_QUERY = BannedPhrases.select(); const LOAD_ALL_BANNED_PHONE_PREFIXES_QUERY = BannedPhonePrefixes.select(); const IS_URL_BANNED_QUERY = BannedUrls.select({ where: BannedUrls.where.eq('url_canonical'), }); const LOAD_ALL_BANNED_URLS_QUERY = BannedUrls.select(); const IS_URL_DOMAIN_BANNED_QUERY = BannedUrlDomains.select({ where: BannedUrlDomains.where.eq('domain'), }); const LOAD_ALL_BANNED_URL_DOMAINS_QUERY = BannedUrlDomains.select(); const IS_FILE_SHA_BANNED_QUERY = BannedFileShas.select({ where: BannedFileShas.where.eq('sha256_hex'), }); const LOAD_ALL_BANNED_FILE_SHAS_QUERY = BannedFileShas.select(); const IS_AVATAR_HASH_BANNED_QUERY = BannedAvatarHashes.select({ where: BannedAvatarHashes.where.eq('hash_short'), }); const LOAD_ALL_BANNED_AVATAR_HASHES_QUERY = BannedAvatarHashes.select(); const LOAD_ALL_BANNED_PROFILE_SUBSTRINGS_QUERY = BannedProfileSubstrings.select(); const createListAllAuditLogsPaginatedQuery = (limit: number) => AdminAuditLogs.select({ where: AdminAuditLogs.where.tokenGt('log_id', 'last_log_id'), limit, }); const createListAllAuditLogsFirstPageQuery = (limit: number) => AdminAuditLogs.select({ limit, }); function parseBannedIpKind(value: string | null | undefined): BannedIpKind { return value === 'temporary_24h' ? 'temporary_24h' : 'permanent'; } function canonicalizeBannedIpEntry(value: string): string { return parseIpBanEntry(value)?.canonical ?? value; } export class AdminRepository implements IAdminRepository { async createAuditLog(log: AdminAuditLogRow): Promise { await upsertOne(AdminAuditLogs.insert(log)); return this.mapRowToAuditLog(log); } async getAuditLog(logId: bigint): Promise { const row = await fetchOne(FETCH_AUDIT_LOG_BY_ID_QUERY.bind({log_id: logId})); return row ? this.mapRowToAuditLog(row) : null; } async listAuditLogsByIds(logIds: Array): Promise> { if (logIds.length === 0) { return []; } const rows = await fetchMany(FETCH_AUDIT_LOGS_BY_IDS_QUERY.bind({log_ids: logIds})); return rows.map((row) => this.mapRowToAuditLog(row)); } async listAllAuditLogsPaginated(limit: number, lastLogId?: bigint): Promise> { let rows: Array; if (lastLogId) { const query = createListAllAuditLogsPaginatedQuery(limit); rows = await fetchMany(query.bind({last_log_id: lastLogId})); } else { const query = createListAllAuditLogsFirstPageQuery(limit); rows = await fetchMany(query.bind({})); } return rows.map((row) => this.mapRowToAuditLog(row)); } async isIpBanned(ip: string): Promise { if (isIpBanExempt(ip)) { return false; } const candidate = tryParseSingleIp(ip); if (!candidate) { return false; } const sameIpDecisionKey = getSameIpDecisionKey(candidate.canonical); const entries = await this.loadAllBannedIpEntries(); for (const entry of entries) { const parsed = parseIpBanEntry(entry.ip); if (!parsed) { continue; } if (parsed.type === 'single') { if (parsed.family === candidate.family && parsed.canonical === candidate.canonical) { return true; } if (sameIpDecisionKey && getSameIpDecisionKey(parsed.canonical) === sameIpDecisionKey) { return true; } continue; } if (parsed.family === candidate.family && candidate.value >= parsed.start && candidate.value <= parsed.end) { return true; } } return false; } async banIp(ip: string): Promise { if (isIpBanExempt(ip)) { return; } const canonicalIp = canonicalizeBannedIpEntry(ip); await upsertOne( BannedIps.insert({ ip: canonicalIp, ban_kind: 'permanent', reason: 'platform_admin_enforcement', expires_at: null, created_at: new Date(), }), ); } async banIpTemp(ip: string, ttlSeconds: number): Promise { if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) { throw new RangeError('Temporary IP ban TTL must be a positive integer'); } if (isIpBanExempt(ip)) { return; } const canonicalIp = canonicalizeBannedIpEntry(ip); await upsertOne( BannedIps.insertWithTtl( { ip: canonicalIp, ban_kind: 'temporary_24h', reason: 'abusive_api_access_patterns', expires_at: new Date(Date.now() + ttlSeconds * 1000), created_at: new Date(), }, ttlSeconds, ), ); } async unbanIp(ip: string): Promise { const deleteKeys = new Set([ip]); const parsed = parseIpBanEntry(ip); if (parsed) { deleteKeys.add(parsed.canonical); const entries = await this.loadAllBannedIpEntries(); for (const entry of entries) { const stored = parseIpBanEntry(entry.ip); if (stored?.canonical === parsed.canonical) { deleteKeys.add(entry.ip); } } } await Promise.all(Array.from(deleteKeys).map((key) => deleteOneOrMany(BannedIps.deleteByPk({ip: key})))); } async loadAllBannedIps(): Promise> { const entries = await this.loadAllBannedIpEntries(); return new Set(entries.map((row) => row.ip)); } async loadAllBannedIpEntries(): Promise> { const rows = await fetchMany<{ ip: string; ban_kind?: string | null; reason?: string | null; expires_at?: Date | null; created_at?: Date | null; }>(LOAD_ALL_BANNED_IPS_QUERY.bind({})); return rows.map((row) => ({ ip: row.ip, kind: parseBannedIpKind(row.ban_kind), reason: row.reason ?? null, expiresAt: row.expires_at ?? null, createdAt: row.created_at ?? null, })); } async isEmailBanned(email: string): Promise { const emailLower = email.toLowerCase(); const result = await fetchOne<{ email_lower: string; }>(IS_EMAIL_BANNED_QUERY.bind({email_lower: emailLower})); return !!result; } async banEmail(email: string): Promise { const emailLower = email.toLowerCase(); await upsertOne(BannedEmails.insert({email_lower: emailLower})); } async unbanEmail(email: string): Promise { const emailLower = email.toLowerCase(); await deleteOneOrMany(BannedEmails.deleteByPk({email_lower: emailLower})); } async isEmailDomainSuspicious(domain: string): Promise { const domainLower = domain.toLowerCase(); if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false; const result = await fetchOne<{ domain: string; }>(IS_EMAIL_DOMAIN_SUSPICIOUS_QUERY.bind({domain: domainLower})); return !!result; } async addSuspiciousEmailDomain(domain: string): Promise { const domainLower = domain.toLowerCase(); await upsertOne(SuspiciousEmailDomains.insert({domain: domainLower})); } async removeSuspiciousEmailDomain(domain: string): Promise { const domainLower = domain.toLowerCase(); await deleteOneOrMany(SuspiciousEmailDomains.deleteByPk({domain: domainLower})); } async isEmailDomainDisposable(domain: string): Promise { const domainLower = domain.toLowerCase(); if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false; const result = await fetchOne<{ domain: string; }>(IS_EMAIL_DOMAIN_DISPOSABLE_QUERY.bind({domain: domainLower})); return !!result; } async addDisposableEmailDomain(domain: string): Promise { const domainLower = domain.toLowerCase(); await upsertOne(DisposableEmailDomains.insert({domain: domainLower})); } async removeDisposableEmailDomain(domain: string): Promise { const domainLower = domain.toLowerCase(); await deleteOneOrMany(DisposableEmailDomains.deleteByPk({domain: domainLower})); } async listDisposableEmailDomains(limit?: number): Promise> { const rows = await fetchMany<{ domain: string; }>(createLoadDisposableEmailDomainsQuery(limit).bind({})); return rows.map((row) => row.domain); } async isPhraseBanned(phrase: string): Promise { const phraseLower = canonicalizeStoredPhrase(phrase); const result = await fetchOne<{ phrase: string; }>(IS_PHRASE_BANNED_QUERY.bind({phrase: phraseLower})); return !!result; } async banPhrase(phrase: string): Promise { const phraseLower = canonicalizeStoredPhrase(phrase); await upsertOne(BannedPhrases.insert({phrase: phraseLower})); } async unbanPhrase(phrase: string): Promise { const phraseLower = canonicalizeStoredPhrase(phrase); await deleteOneOrMany(BannedPhrases.deleteByPk({phrase: phraseLower})); } async loadAllBannedPhrases(): Promise> { const rows = await fetchMany<{ phrase: string; }>(LOAD_ALL_BANNED_PHRASES_QUERY.bind({})); return rows.map((row) => row.phrase); } async loadAllBannedPhonePrefixes(): Promise> { const rows = await fetchMany<{ prefix: string; }>(LOAD_ALL_BANNED_PHONE_PREFIXES_QUERY.bind({})); return rows.map((row) => row.prefix); } async isUrlBanned(url: string): Promise { const canonical = url.toLowerCase(); const result = await fetchOne<{ url_canonical: string; }>(IS_URL_BANNED_QUERY.bind({url_canonical: canonical})); return !!result; } async banUrl(row: BannedUrlRow): Promise { await upsertOne(BannedUrls.insert({...row, url_canonical: row.url_canonical.toLowerCase()})); } async unbanUrl(url: string): Promise { await deleteOneOrMany(BannedUrls.deleteByPk({url_canonical: url.toLowerCase()})); } async loadAllBannedUrls(): Promise> { return fetchMany(LOAD_ALL_BANNED_URLS_QUERY.bind({})); } async isUrlDomainBanned(domain: string): Promise { const d = domain.toLowerCase(); const result = await fetchOne<{ domain: string; }>(IS_URL_DOMAIN_BANNED_QUERY.bind({domain: d})); return !!result; } async banUrlDomain(row: BannedUrlDomainRow): Promise { await upsertOne(BannedUrlDomains.insert({...row, domain: row.domain.toLowerCase()})); } async unbanUrlDomain(domain: string): Promise { await deleteOneOrMany(BannedUrlDomains.deleteByPk({domain: domain.toLowerCase()})); } async loadAllBannedUrlDomains(): Promise> { return fetchMany(LOAD_ALL_BANNED_URL_DOMAINS_QUERY.bind({})); } async isFileShaBanned(sha256Hex: string): Promise { const h = sha256Hex.toLowerCase(); const result = await fetchOne<{ sha256_hex: string; }>(IS_FILE_SHA_BANNED_QUERY.bind({sha256_hex: h})); return !!result; } async banFileSha(row: BannedFileShaRow): Promise { await upsertOne(BannedFileShas.insert({...row, sha256_hex: row.sha256_hex.toLowerCase()})); } async unbanFileSha(sha256Hex: string): Promise { await deleteOneOrMany(BannedFileShas.deleteByPk({sha256_hex: sha256Hex.toLowerCase()})); } async loadAllBannedFileShas(): Promise> { return fetchMany(LOAD_ALL_BANNED_FILE_SHAS_QUERY.bind({})); } async isAvatarHashBanned(hashShort: string): Promise { const h = hashShort.toLowerCase(); const result = await fetchOne<{ hash_short: string; }>(IS_AVATAR_HASH_BANNED_QUERY.bind({hash_short: h})); return !!result; } async banAvatarHash(row: BannedAvatarHashRow): Promise { await upsertOne(BannedAvatarHashes.insert({...row, hash_short: row.hash_short.toLowerCase()})); } async unbanAvatarHash(hashShort: string): Promise { await deleteOneOrMany(BannedAvatarHashes.deleteByPk({hash_short: hashShort.toLowerCase()})); } async loadAllBannedAvatarHashes(): Promise> { return fetchMany(LOAD_ALL_BANNED_AVATAR_HASHES_QUERY.bind({})); } async banProfileSubstring(row: BannedProfileSubstringRow): Promise { const canonical = canonicalizeStoredPhrase(row.substring); if (!canonical) return; await upsertOne(BannedProfileSubstrings.insert({...row, substring: canonical})); } async unbanProfileSubstring(scope: BannedProfileSubstringScope, substring: string): Promise { const canonical = canonicalizeStoredPhrase(substring); if (!canonical) return; await deleteOneOrMany(BannedProfileSubstrings.deleteByPk({scope, substring: canonical})); } async loadAllBannedProfileSubstrings(): Promise> { return fetchMany(LOAD_ALL_BANNED_PROFILE_SUBSTRINGS_QUERY.bind({})); } private mapRowToAuditLog(row: AdminAuditLogRow): AdminAuditLog { return { logId: row.log_id, adminUserId: createUserID(row.admin_user_id), targetType: row.target_type, targetId: row.target_id, action: row.action, auditLogReason: row.audit_log_reason, metadata: row.metadata || new Map(), createdAt: row.created_at, }; } }