// SPDX-License-Identifier: AGPL-3.0-or-later import type {ApiContext} from '@app/api/ApiContext'; import type {IAdminRepository} from '@app/api/admin/IAdminRepository'; import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService'; import {createUserID, type UserID} from '@app/api/BrandedTypes'; import {isIpBanExempt} from '@app/api/ban/IpBanExemptions'; import { BANNED_AVATAR_HASHES_REFRESH_CHANNEL, BANNED_FILE_SHAS_REFRESH_CHANNEL, BANNED_PHRASES_REFRESH_CHANNEL, BANNED_PROFILE_SUBSTRINGS_REFRESH_CHANNEL, BANNED_URL_DOMAINS_REFRESH_CHANNEL, BANNED_URLS_REFRESH_CHANNEL, ContentBlocklistCategory, ContentBlocklistSeverity, } from '@app/api/constants/ContentModeration'; import {IP_BAN_REFRESH_CHANNEL} from '@app/api/constants/IpBan'; import type {BannedProfileSubstringScope} from '@app/api/database/types/AdminArchiveTypes'; import {bannedAvatarHashCache} from '@app/api/middleware/BannedAvatarHashCache'; import {fileShaCache} from '@app/api/middleware/FileShaCache'; import {ipBanCache} from '@app/api/middleware/IpBanMiddleware'; import {phraseBlocklistCache} from '@app/api/middleware/PhraseBlocklistCache'; import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache'; import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache'; import {canonicalizeStoredPhrase} from '@app/api/utils/PhraseBlocklistNormalization'; import {parseUrlDomainEntry} from '@app/api/utils/UrlHostRules'; import {canonicalizeUrl} from '@app/api/utils/UrlNormalizer'; import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError'; import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError'; import type {AdminBlocklistListType} from '@fluxer/schema/src/domains/admin/AdminBlocklistSchemas'; interface AdminBanManagementServiceDeps { apiContext: ApiContext; adminRepository: IAdminRepository; auditService: AdminAuditService; } interface AdminBlocklistEntry { list_type: AdminBlocklistListType; value: string; scope: string | null; category: string | null; severity: number | null; source_url: string | null; notes: string | null; content_type: string | null; match_subdomains: boolean | null; reason: string | null; expires_at: string | null; created_at: string | null; created_by_user_id: string | null; } interface AdminBlocklistEntryPage { items: Array; has_more: boolean; next_after: string | null; } function createBlocklistEntry( listType: AdminBlocklistListType, value: string, overrides: Partial> = {}, ): AdminBlocklistEntry { return { list_type: listType, value, scope: null, category: null, severity: null, source_url: null, notes: null, content_type: null, match_subdomains: null, reason: null, expires_at: null, created_at: null, created_by_user_id: null, ...overrides, }; } function toIsoString(value: Date | null | undefined): string | null { return value ? value.toISOString() : null; } function toSnowflakeString(value: bigint | null | undefined): string | null { return value == null ? null : value.toString(); } interface AdminBlocklistAuditParams { adminUserId: UserID; auditLogReason: string | null; targetType: string; action: string; metadata: Map; } function stripAvatarAnimationPrefix(hash: string): string { return hash.startsWith('a_') ? hash.substring(2) : hash; } function normalizeAvatarHashes(hashes: Array): Array { return Array.from(new Set(hashes.map((hash) => stripAvatarAnimationPrefix(hash.toLowerCase())))); } function hostFromUrlOrHostname(value: string): string | null { const trimmed = value.trim(); if (!/^https?:\/\//i.test(trimmed)) return trimmed; try { return new URL(trimmed).hostname; } catch { return null; } } function withReasonMetadata(entries: Array<[string, string]>, reason: string | undefined): Map { if (!reason) { return new Map(entries); } const reasonEntry: [string, string] = ['reason', reason]; return new Map([...entries, reasonEntry]); } export class AdminBanManagementService { constructor(private readonly deps: AdminBanManagementServiceDeps) {} async banIp( data: { ip: string; duration_hours?: number; }, adminUserId: UserID, auditLogReason: string | null, ) { const {adminRepository, auditService} = this.deps; const {cache: cacheService} = this.deps.apiContext.services; if (isIpBanExempt(data.ip)) { await auditService.createAuditLog({ adminUserId, targetType: 'ip', targetId: BigInt(0), action: 'ban_ip_skipped_exempt', auditLogReason, metadata: new Map([['ip', data.ip]]), }); throw new BadRequestError({ code: APIErrorCodes.IP_BAN_DECLINED, message: 'This IP address is on the instance exemption list', }); } const durationHours = data.duration_hours ?? 0; const metadata = new Map([['ip', data.ip]]); if (durationHours > 0) { const ttlSeconds = durationHours * 3600; await adminRepository.banIp(data.ip, ttlSeconds); metadata.set('duration_hours', durationHours.toString()); metadata.set('expires_at', new Date(Date.now() + ttlSeconds * 1000).toISOString()); } else { await adminRepository.banIp(data.ip); } await ipBanCache.refresh(); await cacheService.publish(IP_BAN_REFRESH_CHANNEL, 'refresh'); await this.createBlocklistAuditLog({ adminUserId, targetType: 'ip', action: 'ban_ip', auditLogReason, metadata, }); } async unbanIp( data: { ip: string; }, adminUserId: UserID, auditLogReason: string | null, ) { const {adminRepository} = this.deps; const {cache: cacheService} = this.deps.apiContext.services; await adminRepository.unbanIp(data.ip); ipBanCache.unban(data.ip); await cacheService.publish(IP_BAN_REFRESH_CHANNEL, 'refresh'); await this.createBlocklistAuditLog({ adminUserId, targetType: 'ip', action: 'unban_ip', auditLogReason, metadata: new Map([['ip', data.ip]]), }); } async checkIpBan(data: {ip: string}): Promise<{ banned: boolean; expires_at: string | null; }> { const match = ipBanCache.getMatch(data.ip); return {banned: match !== null, expires_at: toIsoString(match?.expiresAt)}; } async banEmail( data: { email: string; }, adminUserId: UserID, auditLogReason: string | null, ) { const {adminRepository} = this.deps; await adminRepository.banEmail(data.email); await this.createBlocklistAuditLog({ adminUserId, targetType: 'email', action: 'ban_email', auditLogReason, metadata: new Map([['email', data.email]]), }); } async unbanEmail( data: { email: string; }, adminUserId: UserID, auditLogReason: string | null, ) { const {adminRepository} = this.deps; await adminRepository.unbanEmail(data.email); await this.createBlocklistAuditLog({ adminUserId, targetType: 'email', action: 'unban_email', auditLogReason, metadata: new Map([['email', data.email]]), }); } async checkEmailBan(data: {email: string}): Promise<{ banned: boolean; }> { const {adminRepository} = this.deps; const banned = await adminRepository.isEmailBanned(data.email); return {banned}; } async banPhrase( data: { phrase: string; }, adminUserId: UserID, auditLogReason: string | null, ) { const {adminRepository} = this.deps; const {cache: cacheService} = this.deps.apiContext.services; await adminRepository.banPhrase(data.phrase); phraseBlocklistCache.add(data.phrase); await cacheService.publish(BANNED_PHRASES_REFRESH_CHANNEL, 'refresh'); await this.createBlocklistAuditLog({ adminUserId, targetType: 'phrase', action: 'ban_phrase', auditLogReason, metadata: new Map([['phrase', data.phrase]]), }); } async unbanPhrase( data: { phrase: string; }, adminUserId: UserID, auditLogReason: string | null, ) { const {adminRepository} = this.deps; const {cache: cacheService} = this.deps.apiContext.services; await adminRepository.unbanPhrase(data.phrase); phraseBlocklistCache.remove(data.phrase); await cacheService.publish(BANNED_PHRASES_REFRESH_CHANNEL, 'refresh'); await this.createBlocklistAuditLog({ adminUserId, targetType: 'phrase', action: 'unban_phrase', auditLogReason, metadata: new Map([['phrase', data.phrase]]), }); } async checkPhraseBan(data: {phrase: string}): Promise<{ banned: boolean; }> { return {banned: phraseBlocklistCache.isPhraseBanned(data.phrase)}; } async banUrl( data: { url: string; category?: string; severity?: number; source_url?: string; notes?: string; }, adminUserId: UserID, auditLogReason: string | null, ) { const {adminRepository} = this.deps; const {cache: cacheService} = this.deps.apiContext.services; const canonical = canonicalizeUrl(data.url); if (!canonical) throw InputValidationError.fromCode('url', ValidationErrorCodes.INVALID_URL_FORMAT); await adminRepository.banUrl({ url_canonical: canonical, category: data.category ?? ContentBlocklistCategory.MANUAL, severity: data.severity ?? ContentBlocklistSeverity.BLOCK, source_url: data.source_url ?? null, added_at: new Date(), added_by: adminUserId, notes: data.notes ?? null, }); urlBlocklistCache.addExactUrl(canonical); await cacheService.publish(BANNED_URLS_REFRESH_CHANNEL, 'refresh'); await this.createBlocklistAuditLog({ adminUserId, targetType: 'url', action: 'ban_url', auditLogReason, metadata: new Map([ ['url', canonical], ['category', data.category ?? ContentBlocklistCategory.MANUAL], ]), }); } async unbanUrl( data: { url: string; }, adminUserId: UserID, auditLogReason: string | null, ) { const {adminRepository} = this.deps; const {cache: cacheService} = this.deps.apiContext.services; const canonical = canonicalizeUrl(data.url); if (!canonical) throw InputValidationError.fromCode('url', ValidationErrorCodes.INVALID_URL_FORMAT); await adminRepository.unbanUrl(canonical); urlBlocklistCache.removeExactUrl(canonical); await cacheService.publish(BANNED_URLS_REFRESH_CHANNEL, 'refresh'); await this.createBlocklistAuditLog({ adminUserId, targetType: 'url', action: 'unban_url', auditLogReason, metadata: new Map([['url', canonical]]), }); } async checkUrlBan(data: {url: string}): Promise<{ banned: boolean; }> { return {banned: urlBlocklistCache.isUrlBanned(data.url)}; } async banUrlDomain( data: { domain: string; match_subdomains?: boolean; category?: string; severity?: number; source_url?: string; notes?: string; }, adminUserId: UserID, auditLogReason: string | null, ) { const {adminRepository} = this.deps; const {cache: cacheService} = this.deps.apiContext.services; const entry = parseUrlDomainEntry(data.domain); if (!entry.ok) throw InputValidationError.create('domain', entry.message); const d = entry.value; const matchSubs = data.match_subdomains ?? true; await adminRepository.banUrlDomain({ domain: d, match_subdomains: matchSubs, category: data.category ?? ContentBlocklistCategory.MANUAL, severity: data.severity ?? ContentBlocklistSeverity.BLOCK, source_url: data.source_url ?? null, added_at: new Date(), added_by: adminUserId, notes: data.notes ?? null, }); urlBlocklistCache.addDomain(d, matchSubs); await cacheService.publish(BANNED_URL_DOMAINS_REFRESH_CHANNEL, 'refresh'); await this.createBlocklistAuditLog({ adminUserId, targetType: 'url_domain', action: 'ban_url_domain', auditLogReason, metadata: new Map([ ['domain', d], ['match_subdomains', String(matchSubs)], ['pattern', String(entry.pattern)], ]), }); } async unbanUrlDomain( data: { domain: string; }, adminUserId: UserID, auditLogReason: string | null, ) { const {adminRepository} = this.deps; const {cache: cacheService} = this.deps.apiContext.services; const entry = parseUrlDomainEntry(data.domain); const d = entry.ok ? entry.value : data.domain.trim().toLowerCase(); await adminRepository.unbanUrlDomain(d); urlBlocklistCache.removeDomain(d); await cacheService.publish(BANNED_URL_DOMAINS_REFRESH_CHANNEL, 'refresh'); await this.createBlocklistAuditLog({ adminUserId, targetType: 'url_domain', action: 'unban_url_domain', auditLogReason, metadata: new Map([['domain', d]]), }); } async checkUrlDomainBan(data: {domain: string}): Promise<{ banned: boolean; }> { const host = hostFromUrlOrHostname(data.domain); return {banned: host != null && urlBlocklistCache.isHostnameBanned(host)}; } async banFileSha( data: { sha256_hex: string; category?: string; severity?: number; content_type?: string; source_url?: string; notes?: string; }, adminUserId: UserID, auditLogReason: string | null, options?: {deferRefresh?: boolean}, ) { const {adminRepository} = this.deps; const hex = data.sha256_hex.toLowerCase(); await adminRepository.banFileSha({ sha256_hex: hex, category: data.category ?? ContentBlocklistCategory.MANUAL, severity: data.severity ?? ContentBlocklistSeverity.BLOCK, content_type: data.content_type ?? null, source_url: data.source_url ?? null, added_at: new Date(), added_by: adminUserId, notes: data.notes ?? null, }); fileShaCache.add(hex); if (!options?.deferRefresh) { await this.publishFileShaRefresh(); } await this.createBlocklistAuditLog({ adminUserId, targetType: 'file_sha', action: 'ban_file_sha', auditLogReason, metadata: new Map([['sha256', hex]]), }); } async publishFileShaRefresh(): Promise { const {cache: cacheService} = this.deps.apiContext.services; await cacheService.publish(BANNED_FILE_SHAS_REFRESH_CHANNEL, 'refresh'); } async unbanFileSha( data: { sha256_hex: string; }, adminUserId: UserID, auditLogReason: string | null, ) { const {adminRepository} = this.deps; const {cache: cacheService} = this.deps.apiContext.services; const hex = data.sha256_hex.toLowerCase(); await adminRepository.unbanFileSha(hex); fileShaCache.remove(hex); await cacheService.publish(BANNED_FILE_SHAS_REFRESH_CHANNEL, 'refresh'); await this.createBlocklistAuditLog({ adminUserId, targetType: 'file_sha', action: 'unban_file_sha', auditLogReason, metadata: new Map([['sha256', hex]]), }); } async checkFileShaBan(data: {sha256_hex: string}): Promise<{ banned: boolean; }> { return {banned: fileShaCache.isBanned(data.sha256_hex)}; } async banAvatarHash( data: { hashes: Array; category?: string; severity?: number; source_url?: string; reason?: string; notes?: string; }, adminUserId: UserID, auditLogReason: string | null, ) { const {adminRepository} = this.deps; const {cache: cacheService} = this.deps.apiContext.services; const normalized = normalizeAvatarHashes(data.hashes); for (const hash of normalized) { await adminRepository.banAvatarHash({ hash_short: hash, category: data.category ?? ContentBlocklistCategory.MANUAL, severity: data.severity ?? ContentBlocklistSeverity.BLOCK, source_url: data.source_url ?? null, added_at: new Date(), added_by: adminUserId, notes: data.notes ?? null, }); bannedAvatarHashCache.add(hash); await this.createBlocklistAuditLog({ adminUserId, targetType: 'avatar_hash', action: 'ban_avatar_hash', auditLogReason, metadata: withReasonMetadata([['hash_short', hash]], data.reason), }); } await cacheService.publish(BANNED_AVATAR_HASHES_REFRESH_CHANNEL, 'refresh'); } async unbanAvatarHash( data: { hashes: Array; }, adminUserId: UserID, auditLogReason: string | null, ) { const {adminRepository} = this.deps; const {cache: cacheService} = this.deps.apiContext.services; const normalized = normalizeAvatarHashes(data.hashes); for (const hash of normalized) { await adminRepository.unbanAvatarHash(hash); bannedAvatarHashCache.remove(hash); await this.createBlocklistAuditLog({ adminUserId, targetType: 'avatar_hash', action: 'unban_avatar_hash', auditLogReason, metadata: new Map([['hash_short', hash]]), }); } await cacheService.publish(BANNED_AVATAR_HASHES_REFRESH_CHANNEL, 'refresh'); } async checkAvatarHashBan(data: {hashes: Array}): Promise<{ banned: boolean; }> { for (const hash of data.hashes) { if (bannedAvatarHashCache.contains(stripAvatarAnimationPrefix(hash.toLowerCase()))) { return {banned: true}; } } return {banned: false}; } async banUserAvatar( data: { user_id: string; reason?: string; notes?: string; }, adminUserId: UserID, auditLogReason: string | null, ): Promise<{ hash_short: string; }> { const {users: userRepository} = this.deps.apiContext.services; const userId = createUserID(BigInt(data.user_id)); const user = await userRepository.findUnique(userId); if (!user) throw new UnknownUserError(); const avatar = user.avatarHash; if (!avatar) { throw new NotFoundError({code: APIErrorCodes.NOT_FOUND}); } const hashShort = stripAvatarAnimationPrefix(avatar.toLowerCase()); await this.banAvatarHash( { hashes: [hashShort], reason: data.reason, notes: data.notes ?? `banned via user shortcut user_id=${data.user_id}`, }, adminUserId, auditLogReason, ); return {hash_short: hashShort}; } async banProfileSubstring( data: { scope: BannedProfileSubstringScope; substrings: Array; reason?: string; notes?: string; }, adminUserId: UserID, auditLogReason: string | null, ) { const {adminRepository} = this.deps; const {cache: cacheService} = this.deps.apiContext.services; for (const raw of data.substrings) { const canonical = canonicalizeStoredPhrase(raw); if (!canonical) continue; await adminRepository.banProfileSubstring({ scope: data.scope, substring: canonical, added_at: new Date(), added_by: adminUserId, notes: data.notes ?? null, }); profileSubstringBlocklistCache.add(data.scope, canonical); await this.createBlocklistAuditLog({ adminUserId, targetType: 'profile_substring', action: 'ban_profile_substring', auditLogReason, metadata: withReasonMetadata( [ ['scope', data.scope], ['substring', canonical], ], data.reason, ), }); } await cacheService.publish(BANNED_PROFILE_SUBSTRINGS_REFRESH_CHANNEL, 'refresh'); } async unbanProfileSubstring( data: { scope: BannedProfileSubstringScope; substrings: Array; }, adminUserId: UserID, auditLogReason: string | null, ) { const {adminRepository} = this.deps; const {cache: cacheService} = this.deps.apiContext.services; for (const raw of data.substrings) { const canonical = canonicalizeStoredPhrase(raw); if (!canonical) continue; await adminRepository.unbanProfileSubstring(data.scope, canonical); profileSubstringBlocklistCache.remove(data.scope, canonical); await this.createBlocklistAuditLog({ adminUserId, targetType: 'profile_substring', action: 'unban_profile_substring', auditLogReason, metadata: new Map([ ['scope', data.scope], ['substring', canonical], ]), }); } await cacheService.publish(BANNED_PROFILE_SUBSTRINGS_REFRESH_CHANNEL, 'refresh'); } async checkProfileSubstringBan(data: {scope: BannedProfileSubstringScope; substrings: Array}): Promise<{ banned: boolean; }> { for (const raw of data.substrings) { if (profileSubstringBlocklistCache.isSubstringBanned(data.scope, raw)) { return {banned: true}; } } return {banned: false}; } async listBlocklistEntries(params: { listType: AdminBlocklistListType; limit: number; after: string | null; scope: BannedProfileSubstringScope | null; }): Promise { const entries = await this.loadBlocklistEntries(params.listType, params.scope); entries.sort((left, right) => (left.value < right.value ? -1 : left.value > right.value ? 1 : 0)); const after = params.after; const remaining = after == null ? entries : entries.filter((entry) => entry.value > after); const page = remaining.slice(0, params.limit); const hasMore = remaining.length > page.length; const lastEntry = page.at(-1); return { items: page, has_more: hasMore, next_after: hasMore && lastEntry ? lastEntry.value : null, }; } private async loadBlocklistEntries( listType: AdminBlocklistListType, scope: BannedProfileSubstringScope | null, ): Promise> { const {adminRepository} = this.deps; switch (listType) { case 'ip': { const rows = await adminRepository.loadAllBannedIpEntries(); return rows.map((row) => createBlocklistEntry(listType, row.ip, { reason: row.reason, expires_at: toIsoString(row.expiresAt), created_at: toIsoString(row.createdAt), }), ); } case 'email': { const rows = await adminRepository.loadAllBannedEmails(); return rows.map((value) => createBlocklistEntry(listType, value)); } case 'phrase': { const rows = await adminRepository.loadAllBannedPhrases(); return rows.map((value) => createBlocklistEntry(listType, value)); } case 'url': { const rows = await adminRepository.loadAllBannedUrls(); return rows.map((row) => createBlocklistEntry(listType, row.url_canonical, { category: row.category, severity: row.severity, source_url: row.source_url, notes: row.notes, created_at: toIsoString(row.added_at), created_by_user_id: toSnowflakeString(row.added_by), }), ); } case 'url-domain': { const rows = await adminRepository.loadAllBannedUrlDomains(); return rows.map((row) => createBlocklistEntry(listType, row.domain, { category: row.category, severity: row.severity, source_url: row.source_url, notes: row.notes, match_subdomains: row.match_subdomains, created_at: toIsoString(row.added_at), created_by_user_id: toSnowflakeString(row.added_by), }), ); } case 'file-sha': { const rows = await adminRepository.loadAllBannedFileShas(); return rows.map((row) => createBlocklistEntry(listType, row.sha256_hex, { category: row.category, severity: row.severity, source_url: row.source_url, notes: row.notes, content_type: row.content_type, created_at: toIsoString(row.added_at), created_by_user_id: toSnowflakeString(row.added_by), }), ); } case 'avatar-hash': { const rows = await adminRepository.loadAllBannedAvatarHashes(); return rows.map((row) => createBlocklistEntry(listType, row.hash_short, { category: row.category, severity: row.severity, source_url: row.source_url, notes: row.notes, created_at: toIsoString(row.added_at), created_by_user_id: toSnowflakeString(row.added_by), }), ); } case 'profile-substring': { const rows = await adminRepository.loadAllBannedProfileSubstrings(); return rows .filter((row) => scope == null || row.scope === scope) .map((row) => createBlocklistEntry(listType, row.substring, { scope: row.scope, notes: row.notes, created_at: toIsoString(row.added_at), created_by_user_id: toSnowflakeString(row.added_by), }), ); } } } private async createBlocklistAuditLog({ adminUserId, auditLogReason, targetType, action, metadata, }: AdminBlocklistAuditParams): Promise { await this.deps.auditService.createAuditLog({ adminUserId, targetType, targetId: BigInt(0), action, auditLogReason, metadata, }); } }