This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453// SPDX-License-Identifier: AGPL-3.0-or-later
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';import {createUserID} from '../BrandedTypes';import {deleteOneOrMany, fetchMany, fetchOne, fetchPage, upsertOne} from '../database/CassandraQueryExecution';import type { AdminAuditLogRow, BannedAvatarHashRow, BannedFileShaRow, BannedProfileSubstringRow, BannedProfileSubstringScope, BannedUrlDomainRow, BannedUrlRow,} from '../database/types/AdminArchiveTypes';import {isAccountPolicyContactDomainReputationExempt} from '../risk/AccountPolicyService';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, DisposableEmailDomainPage, 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 createLoadSuspiciousEmailDomainsQuery = (limit?: number) => limit ? SuspiciousEmailDomains.select({limit}) : SuspiciousEmailDomains.select();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<AdminAuditLog> { await upsertOne(AdminAuditLogs.insert(log)); return this.mapRowToAuditLog(log); }
async getAuditLog(logId: bigint): Promise<AdminAuditLog | null> { const row = await fetchOne<AdminAuditLogRow>(FETCH_AUDIT_LOG_BY_ID_QUERY.bind({log_id: logId})); return row ? this.mapRowToAuditLog(row) : null; }
async listAuditLogsByIds(logIds: Array<bigint>): Promise<Array<AdminAuditLog>> { if (logIds.length === 0) { return []; } const rows = await fetchMany<AdminAuditLogRow>(FETCH_AUDIT_LOGS_BY_IDS_QUERY.bind({log_ids: logIds})); return rows.map((row) => this.mapRowToAuditLog(row)); }
async listAllAuditLogsPaginated(limit: number, lastLogId?: bigint): Promise<Array<AdminAuditLog>> { let rows: Array<AdminAuditLogRow>; if (lastLogId) { const query = createListAllAuditLogsPaginatedQuery(limit); rows = await fetchMany<AdminAuditLogRow>(query.bind({last_log_id: lastLogId})); } else { const query = createListAllAuditLogsFirstPageQuery(limit); rows = await fetchMany<AdminAuditLogRow>(query.bind({})); } return rows.map((row) => this.mapRowToAuditLog(row)); }
async isIpBanned(ip: string): Promise<boolean> { 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<void> { 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<void> { if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) { throw new RangeError('Temporary IP ban TTL must be a positive integer'); } 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<void> { const deleteKeys = new Set<string>([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<Set<string>> { const entries = await this.loadAllBannedIpEntries(); return new Set(entries.map((row) => row.ip)); }
async loadAllBannedIpEntries(): Promise<Array<BannedIpEntry>> { 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<boolean> { 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<void> { const emailLower = email.toLowerCase(); await upsertOne(BannedEmails.insert({email_lower: emailLower})); }
async unbanEmail(email: string): Promise<void> { const emailLower = email.toLowerCase(); await deleteOneOrMany(BannedEmails.deleteByPk({email_lower: emailLower})); }
async isEmailDomainSuspicious(domain: string): Promise<boolean> { 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<void> { const domainLower = domain.toLowerCase(); await upsertOne(SuspiciousEmailDomains.insert({domain: domainLower})); }
async removeSuspiciousEmailDomain(domain: string): Promise<void> { const domainLower = domain.toLowerCase(); await deleteOneOrMany(SuspiciousEmailDomains.deleteByPk({domain: domainLower})); }
async listSuspiciousEmailDomains(limit?: number): Promise<Array<string>> { const rows = await fetchMany<{ domain: string; }>(createLoadSuspiciousEmailDomainsQuery(limit).bind({})); return rows.map((row) => row.domain); }
async isEmailDomainDisposable(domain: string): Promise<boolean> { 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<void> { const domainLower = domain.toLowerCase(); await upsertOne(DisposableEmailDomains.insert({domain: domainLower})); }
async removeDisposableEmailDomain(domain: string): Promise<void> { const domainLower = domain.toLowerCase(); await deleteOneOrMany(DisposableEmailDomains.deleteByPk({domain: domainLower})); }
async listDisposableEmailDomains(limit?: number): Promise<Array<string>> { const rows = await fetchMany<{ domain: string; }>(createLoadDisposableEmailDomainsQuery(limit).bind({})); return rows.map((row) => row.domain); }
async listDisposableEmailDomainsPage(limit: number, pageState?: string | null): Promise<DisposableEmailDomainPage> { const page = await fetchPage<{ domain: string; }>(createLoadDisposableEmailDomainsQuery().bind({}), undefined, { pageSize: limit, pageState, }); return { domains: page.rows.map((row) => row.domain), pageState: page.pageState, }; }
async isPhraseBanned(phrase: string): Promise<boolean> { const phraseLower = canonicalizeStoredPhrase(phrase); const result = await fetchOne<{ phrase: string; }>(IS_PHRASE_BANNED_QUERY.bind({phrase: phraseLower})); return !!result; }
async banPhrase(phrase: string): Promise<void> { const phraseLower = canonicalizeStoredPhrase(phrase); await upsertOne(BannedPhrases.insert({phrase: phraseLower})); }
async unbanPhrase(phrase: string): Promise<void> { const phraseLower = canonicalizeStoredPhrase(phrase); await deleteOneOrMany(BannedPhrases.deleteByPk({phrase: phraseLower})); }
async loadAllBannedPhrases(): Promise<Array<string>> { const rows = await fetchMany<{ phrase: string; }>(LOAD_ALL_BANNED_PHRASES_QUERY.bind({})); return rows.map((row) => row.phrase); }
async loadAllBannedPhonePrefixes(): Promise<Array<string>> { const rows = await fetchMany<{ prefix: string; }>(LOAD_ALL_BANNED_PHONE_PREFIXES_QUERY.bind({})); return rows.map((row) => row.prefix); }
async isUrlBanned(url: string): Promise<boolean> { 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<void> { await upsertOne(BannedUrls.insert({...row, url_canonical: row.url_canonical.toLowerCase()})); }
async unbanUrl(url: string): Promise<void> { await deleteOneOrMany(BannedUrls.deleteByPk({url_canonical: url.toLowerCase()})); }
async loadAllBannedUrls(): Promise<Array<BannedUrlRow>> { return fetchMany<BannedUrlRow>(LOAD_ALL_BANNED_URLS_QUERY.bind({})); }
async isUrlDomainBanned(domain: string): Promise<boolean> { 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<void> { await upsertOne(BannedUrlDomains.insert({...row, domain: row.domain.toLowerCase()})); }
async unbanUrlDomain(domain: string): Promise<void> { await deleteOneOrMany(BannedUrlDomains.deleteByPk({domain: domain.toLowerCase()})); }
async loadAllBannedUrlDomains(): Promise<Array<BannedUrlDomainRow>> { return fetchMany<BannedUrlDomainRow>(LOAD_ALL_BANNED_URL_DOMAINS_QUERY.bind({})); }
async isFileShaBanned(sha256Hex: string): Promise<boolean> { 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<void> { await upsertOne(BannedFileShas.insert({...row, sha256_hex: row.sha256_hex.toLowerCase()})); }
async unbanFileSha(sha256Hex: string): Promise<void> { await deleteOneOrMany(BannedFileShas.deleteByPk({sha256_hex: sha256Hex.toLowerCase()})); }
async loadAllBannedFileShas(): Promise<Array<BannedFileShaRow>> { return fetchMany<BannedFileShaRow>(LOAD_ALL_BANNED_FILE_SHAS_QUERY.bind({})); }
async isAvatarHashBanned(hashShort: string): Promise<boolean> { 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<void> { await upsertOne(BannedAvatarHashes.insert({...row, hash_short: row.hash_short.toLowerCase()})); }
async unbanAvatarHash(hashShort: string): Promise<void> { await deleteOneOrMany(BannedAvatarHashes.deleteByPk({hash_short: hashShort.toLowerCase()})); }
async loadAllBannedAvatarHashes(): Promise<Array<BannedAvatarHashRow>> { return fetchMany<BannedAvatarHashRow>(LOAD_ALL_BANNED_AVATAR_HASHES_QUERY.bind({})); }
async banProfileSubstring(row: BannedProfileSubstringRow): Promise<void> { const canonical = canonicalizeStoredPhrase(row.substring); if (!canonical) return; await upsertOne(BannedProfileSubstrings.insert({...row, substring: canonical})); }
async unbanProfileSubstring(scope: BannedProfileSubstringScope, substring: string): Promise<void> { const canonical = canonicalizeStoredPhrase(substring); if (!canonical) return; await deleteOneOrMany(BannedProfileSubstrings.deleteByPk({scope, substring: canonical})); }
async loadAllBannedProfileSubstrings(): Promise<Array<BannedProfileSubstringRow>> { return fetchMany<BannedProfileSubstringRow>(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, }; }}