mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-10 04:32:34 +09:00
825 lines
24 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|