mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-02 21:04:06 +03:00
435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
// 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<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> {
|
|
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<void> {
|
|
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<void> {
|
|
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<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 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 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,
|
|
};
|
|
}
|
|
}
|