Files
fluxer/fluxer_api/src/api/admin/services/AdminBanManagementService.ts
T

825 lines
24 KiB
TypeScript

// 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<AdminBlocklistEntry>;
has_more: boolean;
next_after: string | null;
}
function createBlocklistEntry(
listType: AdminBlocklistListType,
value: string,
overrides: Partial<Omit<AdminBlocklistEntry, 'list_type' | 'value'>> = {},
): 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<string, string>;
}
function stripAvatarAnimationPrefix(hash: string): string {
return hash.startsWith('a_') ? hash.substring(2) : hash;
}
function normalizeAvatarHashes(hashes: Array<string>): Array<string> {
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<string, string> {
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<void> {
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<string>;
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<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.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<string>}): 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<string>;
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<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.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<string>}): 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<AdminBlocklistEntryPage> {
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<Array<AdminBlocklistEntry>> {
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<void> {
await this.deps.auditService.createAuditLog({
adminUserId,
targetType,
targetId: BigInt(0),
action,
auditLogReason,
metadata,
});
}
}