refactor progress
This commit is contained in:
74
packages/api/src/App.tsx
Normal file
74
packages/api/src/App.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createInitializer, createShutdown} from '@fluxer/api/src/app/APILifecycle';
|
||||
import {registerControllers} from '@fluxer/api/src/app/ControllerRegistry';
|
||||
import {configureMiddleware} from '@fluxer/api/src/app/MiddlewarePipeline';
|
||||
import type {APIConfig} from '@fluxer/api/src/config/APIConfig';
|
||||
import type {ILogger} from '@fluxer/api/src/ILogger';
|
||||
import type {HonoApp, HonoEnv} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {AppErrorHandler, AppNotFoundHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
|
||||
import {setIsDevelopment} from '@fluxer/schema/src/primitives/UrlValidators';
|
||||
import {Hono} from 'hono';
|
||||
|
||||
export interface CreateAPIAppOptions {
|
||||
config: APIConfig;
|
||||
logger: ILogger;
|
||||
setSentryUser?: (user: {id?: string; username?: string; email?: string; ip_address?: string}) => void;
|
||||
isTelemetryActive?: () => boolean;
|
||||
}
|
||||
|
||||
export interface APIAppResult {
|
||||
app: HonoApp;
|
||||
initialize: () => Promise<void>;
|
||||
shutdown: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIAppResult> {
|
||||
const {config, logger, setSentryUser, isTelemetryActive} = options;
|
||||
|
||||
setIsDevelopment(config.nodeEnv === 'development');
|
||||
|
||||
const routes = new Hono<HonoEnv>({strict: true});
|
||||
|
||||
configureMiddleware(routes, {
|
||||
logger,
|
||||
nodeEnv: config.nodeEnv,
|
||||
setSentryUser,
|
||||
isTelemetryActive,
|
||||
});
|
||||
|
||||
routes.onError(AppErrorHandler);
|
||||
routes.notFound(AppNotFoundHandler);
|
||||
|
||||
registerControllers(routes, config);
|
||||
|
||||
const app = new Hono<HonoEnv>({strict: true});
|
||||
app.route('/v1', routes);
|
||||
app.route('/', routes);
|
||||
|
||||
app.onError(AppErrorHandler);
|
||||
app.notFound(AppNotFoundHandler);
|
||||
|
||||
return {
|
||||
app,
|
||||
initialize: createInitializer(config, logger),
|
||||
shutdown: createShutdown(logger),
|
||||
};
|
||||
}
|
||||
166
packages/api/src/BrandedTypes.tsx
Normal file
166
packages/api/src/BrandedTypes.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare const __brand: unique symbol;
|
||||
|
||||
type Brand<T, TBrand extends string> = T & {readonly [__brand]: TBrand};
|
||||
interface BrandedValue {
|
||||
readonly [__brand]: unknown;
|
||||
}
|
||||
|
||||
const brand = <TValue extends string | bigint, TBrand extends string>(value: TValue): Brand<TValue, TBrand> =>
|
||||
value as Brand<TValue, TBrand>;
|
||||
|
||||
const rebrand = <TValue extends string | bigint, TBrand extends string>(
|
||||
value: Brand<TValue, string>,
|
||||
): Brand<TValue, TBrand> => value as Brand<TValue, TBrand>;
|
||||
|
||||
export type UserID = Brand<bigint, 'UserID'>;
|
||||
export type GuildID = Brand<bigint, 'GuildID'>;
|
||||
export type ChannelID = Brand<bigint, 'ChannelID'>;
|
||||
export type MessageID = Brand<bigint, 'MessageID'>;
|
||||
export type RoleID = Brand<bigint, 'RoleID'>;
|
||||
export type EmojiID = Brand<bigint, 'EmojiID'>;
|
||||
export type WebhookID = Brand<bigint, 'WebhookID'>;
|
||||
export type AttachmentID = Brand<bigint, 'AttachmentID'>;
|
||||
export type StickerID = Brand<bigint, 'StickerID'>;
|
||||
export type ReportID = Brand<bigint, 'ReportID'>;
|
||||
export type MemeID = Brand<bigint, 'MemeID'>;
|
||||
export type ApplicationID = Brand<bigint, 'ApplicationID'>;
|
||||
|
||||
export type InviteCode = Brand<string, 'InviteCode'>;
|
||||
export type VanityURLCode = Brand<string, 'VanityURLCode'>;
|
||||
export type EmailVerificationToken = Brand<string, 'EmailVerificationToken'>;
|
||||
export type PasswordResetToken = Brand<string, 'PasswordResetToken'>;
|
||||
export type EmailRevertToken = Brand<string, 'EmailRevertToken'>;
|
||||
export type IpAuthorizationToken = Brand<string, 'IpAuthorizationToken'>;
|
||||
export type IpAuthorizationTicket = Brand<string, 'IpAuthorizationTicket'>;
|
||||
type MfaTicket = Brand<string, 'MfaTicket'>;
|
||||
export type WebhookToken = Brand<string, 'WebhookToken'>;
|
||||
export type MfaBackupCode = Brand<string, 'MfaBackupCode'>;
|
||||
export type PhoneVerificationToken = Brand<string, 'PhoneVerificationToken'>;
|
||||
|
||||
export function createUserID<T extends bigint>(id: T extends BrandedValue ? never : T): UserID {
|
||||
return brand<T, 'UserID'>(id);
|
||||
}
|
||||
export function createGuildID<T extends bigint>(id: T extends BrandedValue ? never : T): GuildID {
|
||||
return brand<T, 'GuildID'>(id);
|
||||
}
|
||||
export function createChannelID<T extends bigint>(id: T extends BrandedValue ? never : T): ChannelID {
|
||||
return brand<T, 'ChannelID'>(id);
|
||||
}
|
||||
export function createMessageID<T extends bigint>(id: T extends BrandedValue ? never : T): MessageID {
|
||||
return brand<T, 'MessageID'>(id);
|
||||
}
|
||||
export function createRoleID<T extends bigint>(id: T extends BrandedValue ? never : T): RoleID {
|
||||
return brand<T, 'RoleID'>(id);
|
||||
}
|
||||
export function createEmojiID<T extends bigint>(id: T extends BrandedValue ? never : T): EmojiID {
|
||||
return brand<T, 'EmojiID'>(id);
|
||||
}
|
||||
export function createWebhookID<T extends bigint>(id: T extends BrandedValue ? never : T): WebhookID {
|
||||
return brand<T, 'WebhookID'>(id);
|
||||
}
|
||||
export function createAttachmentID<T extends bigint>(id: T extends BrandedValue ? never : T): AttachmentID {
|
||||
return brand<T, 'AttachmentID'>(id);
|
||||
}
|
||||
export function createStickerID<T extends bigint>(id: T extends BrandedValue ? never : T): StickerID {
|
||||
return brand<T, 'StickerID'>(id);
|
||||
}
|
||||
export function createReportID<T extends bigint>(id: T extends BrandedValue ? never : T): ReportID {
|
||||
return brand<T, 'ReportID'>(id);
|
||||
}
|
||||
export function createMemeID<T extends bigint>(id: T extends BrandedValue ? never : T): MemeID {
|
||||
return brand<T, 'MemeID'>(id);
|
||||
}
|
||||
export function createApplicationID<T extends bigint>(id: T extends BrandedValue ? never : T): ApplicationID {
|
||||
return brand<T, 'ApplicationID'>(id);
|
||||
}
|
||||
|
||||
export function createInviteCode<T extends string>(code: T extends BrandedValue ? never : T): InviteCode {
|
||||
return brand<T, 'InviteCode'>(code);
|
||||
}
|
||||
export function createVanityURLCode<T extends string>(code: T extends BrandedValue ? never : T): VanityURLCode {
|
||||
return brand<T, 'VanityURLCode'>(code);
|
||||
}
|
||||
export function createEmailVerificationToken<T extends string>(
|
||||
token: T extends BrandedValue ? never : T,
|
||||
): EmailVerificationToken {
|
||||
return brand<T, 'EmailVerificationToken'>(token);
|
||||
}
|
||||
export function createPasswordResetToken<T extends string>(
|
||||
token: T extends BrandedValue ? never : T,
|
||||
): PasswordResetToken {
|
||||
return brand<T, 'PasswordResetToken'>(token);
|
||||
}
|
||||
export function createEmailRevertToken<T extends string>(token: T extends BrandedValue ? never : T): EmailRevertToken {
|
||||
return brand<T, 'EmailRevertToken'>(token);
|
||||
}
|
||||
export function createIpAuthorizationToken<T extends string>(
|
||||
token: T extends BrandedValue ? never : T,
|
||||
): IpAuthorizationToken {
|
||||
return brand<T, 'IpAuthorizationToken'>(token);
|
||||
}
|
||||
export function createIpAuthorizationTicket<T extends string>(
|
||||
ticket: T extends BrandedValue ? never : T,
|
||||
): IpAuthorizationTicket {
|
||||
return brand<T, 'IpAuthorizationTicket'>(ticket);
|
||||
}
|
||||
export function createMfaTicket<T extends string>(ticket: T extends BrandedValue ? never : T): MfaTicket {
|
||||
return brand<T, 'MfaTicket'>(ticket);
|
||||
}
|
||||
export function createWebhookToken<T extends string>(token: T extends BrandedValue ? never : T): WebhookToken {
|
||||
return brand<T, 'WebhookToken'>(token);
|
||||
}
|
||||
export function createMfaBackupCode<T extends string>(code: T extends BrandedValue ? never : T): MfaBackupCode {
|
||||
return brand<T, 'MfaBackupCode'>(code);
|
||||
}
|
||||
export function createPhoneVerificationToken<T extends string>(
|
||||
token: T extends BrandedValue ? never : T,
|
||||
): PhoneVerificationToken {
|
||||
return brand<T, 'PhoneVerificationToken'>(token);
|
||||
}
|
||||
|
||||
export function createUserIDSet(ids: Set<bigint>): Set<UserID> {
|
||||
return ids as Set<UserID>;
|
||||
}
|
||||
export function createGuildIDSet(ids: Set<bigint>): Set<GuildID> {
|
||||
return ids as Set<GuildID>;
|
||||
}
|
||||
export function createRoleIDSet(ids: Set<bigint>): Set<RoleID> {
|
||||
return ids as Set<RoleID>;
|
||||
}
|
||||
export function guildIdToRoleId(guildId: GuildID): RoleID {
|
||||
return rebrand<bigint, 'RoleID'>(guildId);
|
||||
}
|
||||
export function channelIdToUserId(channelId: ChannelID): UserID {
|
||||
return rebrand<bigint, 'UserID'>(channelId);
|
||||
}
|
||||
export function userIdToChannelId(userId: UserID): ChannelID {
|
||||
return rebrand<bigint, 'ChannelID'>(userId);
|
||||
}
|
||||
export function vanityCodeToInviteCode(vanityCode: VanityURLCode): InviteCode {
|
||||
return rebrand<string, 'InviteCode'>(vanityCode);
|
||||
}
|
||||
export function channelIdToMessageId(channelId: ChannelID): MessageID {
|
||||
return rebrand<bigint, 'MessageID'>(channelId);
|
||||
}
|
||||
export function applicationIdToUserId(applicationId: ApplicationID): UserID {
|
||||
return rebrand<bigint, 'UserID'>(applicationId);
|
||||
}
|
||||
427
packages/api/src/Config.tsx
Normal file
427
packages/api/src/Config.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {APIConfig, BlueskyOAuthConfig} from '@fluxer/api/src/config/APIConfig';
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterZodSchema.generated';
|
||||
|
||||
function extractHostname(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
throw new Error(`Invalid URL: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface CsamIntegrationRaw {
|
||||
enabled?: boolean;
|
||||
provider?: 'photo_dna' | 'arachnid_shield';
|
||||
photo_dna?: {
|
||||
hash_service_url?: string;
|
||||
hash_service_timeout_ms?: number;
|
||||
match_endpoint?: string;
|
||||
subscription_key?: string;
|
||||
match_enhance?: boolean;
|
||||
rate_limit_rps?: number;
|
||||
};
|
||||
arachnid_shield?: {
|
||||
endpoint?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
timeout_ms?: number;
|
||||
max_retries?: number;
|
||||
retry_backoff_ms?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface CsamIntegrationInput {
|
||||
photo_dna: MasterConfig['integrations']['photo_dna'];
|
||||
csam_integration?: CsamIntegrationRaw;
|
||||
}
|
||||
|
||||
function buildCsamIntegrationConfig(integrations: CsamIntegrationInput): {
|
||||
enabled: boolean;
|
||||
provider: 'photo_dna' | 'arachnid_shield';
|
||||
photoDna: {
|
||||
hashServiceUrl: string;
|
||||
hashServiceTimeoutMs: number;
|
||||
matchEndpoint: string;
|
||||
subscriptionKey: string;
|
||||
matchEnhance: boolean;
|
||||
rateLimitRps: number;
|
||||
};
|
||||
arachnidShield: {
|
||||
endpoint: string;
|
||||
username: string;
|
||||
password: string;
|
||||
timeoutMs: number;
|
||||
maxRetries: number;
|
||||
retryBackoffMs: number;
|
||||
};
|
||||
} {
|
||||
const csam = integrations.csam_integration;
|
||||
const photoDnaLegacy = integrations.photo_dna;
|
||||
|
||||
return {
|
||||
enabled: csam?.enabled ?? photoDnaLegacy.enabled,
|
||||
provider: csam?.provider ?? 'photo_dna',
|
||||
photoDna: {
|
||||
hashServiceUrl: csam?.photo_dna?.hash_service_url ?? photoDnaLegacy.hash_service_url,
|
||||
hashServiceTimeoutMs: csam?.photo_dna?.hash_service_timeout_ms ?? photoDnaLegacy.hash_service_timeout_ms,
|
||||
matchEndpoint: csam?.photo_dna?.match_endpoint ?? photoDnaLegacy.match_endpoint,
|
||||
subscriptionKey: csam?.photo_dna?.subscription_key ?? photoDnaLegacy.subscription_key,
|
||||
matchEnhance: csam?.photo_dna?.match_enhance ?? photoDnaLegacy.match_enhance,
|
||||
rateLimitRps: csam?.photo_dna?.rate_limit_rps ?? photoDnaLegacy.rate_limit_rps,
|
||||
},
|
||||
arachnidShield: {
|
||||
endpoint: csam?.arachnid_shield?.endpoint ?? 'https://shield.projectarachnid.com/v1/media',
|
||||
username: csam?.arachnid_shield?.username ?? '',
|
||||
password: csam?.arachnid_shield?.password ?? '',
|
||||
timeoutMs: csam?.arachnid_shield?.timeout_ms ?? 30000,
|
||||
maxRetries: csam?.arachnid_shield?.max_retries ?? 3,
|
||||
retryBackoffMs: csam?.arachnid_shield?.retry_backoff_ms ?? 1000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
if (!master.internal) {
|
||||
throw new Error('internal configuration is required for the API');
|
||||
}
|
||||
const cassandraSource = master.database.cassandra;
|
||||
const s3Config = master.s3;
|
||||
if (!s3Config) {
|
||||
throw new Error('S3 configuration is required for the API');
|
||||
}
|
||||
const s3Buckets = s3Config.buckets ?? {
|
||||
cdn: '',
|
||||
uploads: '',
|
||||
downloads: '',
|
||||
reports: '',
|
||||
harvests: '',
|
||||
static: '',
|
||||
};
|
||||
|
||||
if (master.database.backend === 'cassandra' && !cassandraSource) {
|
||||
throw new Error('Cassandra configuration is required when database.backend is "cassandra".');
|
||||
}
|
||||
|
||||
return {
|
||||
nodeEnv: master.env === 'test' ? 'development' : master.env,
|
||||
port: master.services.api.port,
|
||||
|
||||
cassandra: {
|
||||
hosts: cassandraSource?.hosts.join(',') ?? '',
|
||||
keyspace: cassandraSource?.keyspace ?? '',
|
||||
localDc: cassandraSource?.local_dc ?? '',
|
||||
username: cassandraSource?.username ?? '',
|
||||
password: cassandraSource?.password ?? '',
|
||||
},
|
||||
|
||||
database: {
|
||||
backend: master.database.backend,
|
||||
sqlitePath: master.database.sqlite_path,
|
||||
},
|
||||
|
||||
kv: {
|
||||
url: master.internal.kv,
|
||||
},
|
||||
|
||||
gateway: {
|
||||
rpcEndpoint: master.gateway.rpc_endpoint,
|
||||
rpcSecret: master.gateway.rpc_secret,
|
||||
rpcTcpPort: master.services.gateway.rpc_tcp_port,
|
||||
},
|
||||
|
||||
mediaProxy: {
|
||||
host: extractHostname(master.internal.media_proxy),
|
||||
port: new URL(master.internal.media_proxy).port
|
||||
? Number.parseInt(new URL(master.internal.media_proxy).port, 10)
|
||||
: 80,
|
||||
secretKey: master.services.media_proxy.secret_key,
|
||||
},
|
||||
|
||||
geoip: {
|
||||
maxmindDbPath: master.geoip.maxmind_db_path,
|
||||
},
|
||||
|
||||
proxy: {
|
||||
trust_cf_connecting_ip: master.proxy.trust_cf_connecting_ip,
|
||||
},
|
||||
|
||||
endpoints: {
|
||||
apiPublic: master.endpoints.api,
|
||||
apiClient: master.endpoints.api_client,
|
||||
webApp: master.endpoints.app,
|
||||
gateway: master.endpoints.gateway,
|
||||
media: master.endpoints.media,
|
||||
marketing: master.endpoints.marketing,
|
||||
admin: master.endpoints.admin,
|
||||
invite: master.endpoints.invite,
|
||||
gift: master.endpoints.gift,
|
||||
staticCdn: master.endpoints.static_cdn,
|
||||
},
|
||||
|
||||
hosts: {
|
||||
invite: extractHostname(master.endpoints.invite),
|
||||
gift: extractHostname(master.endpoints.gift),
|
||||
marketing: extractHostname(master.endpoints.marketing),
|
||||
unfurlIgnored: master.services.api.unfurl_ignored_hosts,
|
||||
},
|
||||
|
||||
s3: {
|
||||
endpoint: s3Config.endpoint,
|
||||
presignedUrlBase: s3Config.presigned_url_base,
|
||||
region: s3Config.region,
|
||||
accessKeyId: s3Config.access_key_id,
|
||||
secretAccessKey: s3Config.secret_access_key,
|
||||
buckets: s3Buckets,
|
||||
},
|
||||
|
||||
email: {
|
||||
enabled: master.integrations.email.enabled,
|
||||
provider: master.integrations.email.provider,
|
||||
webhookSecret: master.integrations.email.webhook_secret ?? undefined,
|
||||
fromEmail: master.integrations.email.from_email,
|
||||
fromName: master.integrations.email.from_name,
|
||||
smtp: master.integrations.email.smtp
|
||||
? {
|
||||
host: master.integrations.email.smtp.host,
|
||||
port: master.integrations.email.smtp.port,
|
||||
username: master.integrations.email.smtp.username,
|
||||
password: master.integrations.email.smtp.password,
|
||||
secure: master.integrations.email.smtp.secure ?? true,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
sms: {
|
||||
enabled: master.integrations.sms.enabled,
|
||||
accountSid: master.integrations.sms.account_sid,
|
||||
authToken: master.integrations.sms.auth_token,
|
||||
verifyServiceSid: master.integrations.sms.verify_service_sid,
|
||||
},
|
||||
captcha: {
|
||||
enabled: master.integrations.captcha.enabled,
|
||||
provider: master.integrations.captcha.provider,
|
||||
hcaptcha: master.integrations.captcha.hcaptcha
|
||||
? {
|
||||
siteKey: master.integrations.captcha.hcaptcha.site_key,
|
||||
secretKey: master.integrations.captcha.hcaptcha.secret_key,
|
||||
}
|
||||
: undefined,
|
||||
turnstile: master.integrations.captcha.turnstile
|
||||
? {
|
||||
siteKey: master.integrations.captcha.turnstile.site_key,
|
||||
secretKey: master.integrations.captcha.turnstile.secret_key,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
voice: {
|
||||
enabled: master.integrations.voice.enabled,
|
||||
apiKey: master.integrations.voice.api_key,
|
||||
apiSecret: master.integrations.voice.api_secret,
|
||||
webhookUrl: master.integrations.voice.webhook_url,
|
||||
url: master.integrations.voice.url,
|
||||
defaultRegion: master.integrations.voice.default_region,
|
||||
},
|
||||
search: {
|
||||
url: master.integrations.search.url,
|
||||
apiKey: master.integrations.search.api_key,
|
||||
},
|
||||
stripe: {
|
||||
enabled: master.integrations.stripe.enabled,
|
||||
secretKey: master.integrations.stripe.secret_key,
|
||||
webhookSecret: master.integrations.stripe.webhook_secret,
|
||||
prices: master.integrations.stripe.prices
|
||||
? {
|
||||
monthlyUsd: master.integrations.stripe.prices.monthly_usd,
|
||||
monthlyEur: master.integrations.stripe.prices.monthly_eur,
|
||||
yearlyUsd: master.integrations.stripe.prices.yearly_usd,
|
||||
yearlyEur: master.integrations.stripe.prices.yearly_eur,
|
||||
gift1MonthUsd: master.integrations.stripe.prices.gift_1_month_usd,
|
||||
gift1MonthEur: master.integrations.stripe.prices.gift_1_month_eur,
|
||||
gift1YearUsd: master.integrations.stripe.prices.gift_1_year_usd,
|
||||
gift1YearEur: master.integrations.stripe.prices.gift_1_year_eur,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
cloudflare: {
|
||||
purgeEnabled: master.integrations.cloudflare.purge_enabled,
|
||||
zoneId: master.integrations.cloudflare.zone_id,
|
||||
apiToken: master.integrations.cloudflare.api_token,
|
||||
},
|
||||
clamav: {
|
||||
enabled: master.integrations.clamav.enabled,
|
||||
host: master.integrations.clamav.host,
|
||||
port: master.integrations.clamav.port,
|
||||
failOpen: master.integrations.clamav.fail_open,
|
||||
},
|
||||
|
||||
photoDna: {
|
||||
enabled: master.integrations.photo_dna.enabled,
|
||||
hashService: {
|
||||
url: master.integrations.photo_dna.hash_service_url,
|
||||
timeoutMs: master.integrations.photo_dna.hash_service_timeout_ms,
|
||||
},
|
||||
api: {
|
||||
endpoint: master.integrations.photo_dna.match_endpoint,
|
||||
subscriptionKey: master.integrations.photo_dna.subscription_key,
|
||||
enhance: master.integrations.photo_dna.match_enhance,
|
||||
},
|
||||
rateLimit: {
|
||||
requestsPerSecond: master.integrations.photo_dna.rate_limit_rps,
|
||||
},
|
||||
},
|
||||
|
||||
csamIntegration: buildCsamIntegrationConfig(master.integrations as CsamIntegrationInput),
|
||||
|
||||
ncmec: {
|
||||
enabled: master.integrations.ncmec.enabled,
|
||||
baseUrl: master.integrations.ncmec.base_url,
|
||||
username: master.integrations.ncmec.username,
|
||||
password: master.integrations.ncmec.password,
|
||||
},
|
||||
|
||||
alerts: {
|
||||
webhookUrl: master.alerts?.webhook_url,
|
||||
},
|
||||
|
||||
admin: {
|
||||
basePath: master.services.admin.base_path,
|
||||
oauthClientSecret: master.services.admin.oauth_client_secret,
|
||||
},
|
||||
|
||||
appPublic: {
|
||||
sentryDsn: master.app_public.sentry_dsn,
|
||||
sentryProxyPath: master.app_public.sentry_proxy_path,
|
||||
sentryReportHost: master.app_public.sentry_report_host,
|
||||
sentryProjectId: master.app_public.sentry_project_id,
|
||||
sentryPublicKey: master.app_public.sentry_public_key,
|
||||
},
|
||||
|
||||
auth: {
|
||||
sudoModeSecret: master.auth.sudo_mode_secret,
|
||||
connectionInitiationSecret: master.auth.connection_initiation_secret,
|
||||
passkeys: {
|
||||
rpName: master.auth.passkeys.rp_name,
|
||||
rpId: master.auth.passkeys.rp_id,
|
||||
allowedOrigins: master.auth.passkeys.additional_allowed_origins,
|
||||
},
|
||||
vapid: {
|
||||
publicKey: master.auth.vapid.public_key,
|
||||
privateKey: master.auth.vapid.private_key,
|
||||
email: master.auth.vapid.email,
|
||||
},
|
||||
bluesky: master.auth.bluesky as BlueskyOAuthConfig,
|
||||
},
|
||||
|
||||
cookie: master.cookie,
|
||||
|
||||
gif: {
|
||||
provider: master.integrations.gif.provider,
|
||||
},
|
||||
klipy: {
|
||||
apiKey: master.integrations.klipy.api_key,
|
||||
},
|
||||
tenor: {
|
||||
apiKey: master.integrations.tenor.api_key,
|
||||
},
|
||||
youtube: {
|
||||
apiKey: master.integrations.youtube.api_key,
|
||||
},
|
||||
|
||||
instance: {
|
||||
selfHosted: master.instance.self_hosted,
|
||||
autoJoinInviteCode: master.instance.auto_join_invite_code,
|
||||
visionariesGuildId: master.instance.visionaries_guild_id,
|
||||
operatorsGuildId: master.instance.operators_guild_id,
|
||||
privateKeyPath: master.instance.private_key_path,
|
||||
},
|
||||
|
||||
domain: {
|
||||
baseDomain: master.domain.base_domain,
|
||||
},
|
||||
|
||||
federation: master.federation?.enabled
|
||||
? {
|
||||
enabled: master.federation.enabled,
|
||||
}
|
||||
: undefined,
|
||||
dev: {
|
||||
relaxRegistrationRateLimits: master.dev.relax_registration_rate_limits,
|
||||
disableRateLimits: master.dev.disable_rate_limits,
|
||||
testModeEnabled: master.dev.test_mode_enabled,
|
||||
testHarnessToken: master.dev.test_harness_token,
|
||||
},
|
||||
csam: {
|
||||
evidenceRetentionDays: master.csam.evidence_retention_days,
|
||||
jobRetentionDays: master.csam.job_retention_days,
|
||||
cleanupBatchSize: master.csam.cleanup_batch_size,
|
||||
queue: {
|
||||
timeoutMs: master.csam.queue?.timeout_ms ?? 30000,
|
||||
maxEntriesPerBatch: master.csam.queue?.max_entries_per_batch ?? 5,
|
||||
consumerLockTtlSeconds: master.csam.queue?.consumer_lock_ttl_seconds ?? 5,
|
||||
},
|
||||
},
|
||||
|
||||
attachmentDecayEnabled: master.attachment_decay_enabled,
|
||||
deletionGracePeriodHours: master.dev.test_mode_enabled ? 0.01 : master.deletion_grace_period_hours,
|
||||
inactivityDeletionThresholdDays: master.inactivity_deletion_threshold_days,
|
||||
|
||||
push: {
|
||||
publicVapidKey: master.auth.vapid.public_key,
|
||||
},
|
||||
|
||||
queue: {
|
||||
baseUrl: master.internal.queue,
|
||||
authSecret: master.services.queue?.secret,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let _config: APIConfig | null = null;
|
||||
|
||||
export function initializeConfig(config: APIConfig): void {
|
||||
if (_config !== null) {
|
||||
return;
|
||||
}
|
||||
_config = config;
|
||||
}
|
||||
|
||||
export function getConfig(): APIConfig {
|
||||
if (_config === null) {
|
||||
throw new Error('Config has not been initialized. Call initializeConfig() first.');
|
||||
}
|
||||
return _config;
|
||||
}
|
||||
|
||||
export function resetConfig(): void {
|
||||
_config = null;
|
||||
}
|
||||
|
||||
export const Config: APIConfig = new Proxy({} as APIConfig, {
|
||||
get(_target, prop: keyof APIConfig | symbol) {
|
||||
if (_config === null) {
|
||||
throw new Error('Config has not been initialized. Call initializeConfig() first.');
|
||||
}
|
||||
return _config[prop as keyof APIConfig];
|
||||
},
|
||||
set() {
|
||||
throw new Error('Cannot modify Config directly. Use initializeConfig() instead.');
|
||||
},
|
||||
});
|
||||
34
packages/api/src/ILogger.tsx
Normal file
34
packages/api/src/ILogger.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface ILogger {
|
||||
trace(msg: string): void;
|
||||
trace(obj: object, msg?: string): void;
|
||||
debug(msg: string): void;
|
||||
debug(obj: object, msg?: string): void;
|
||||
info(msg: string): void;
|
||||
info(obj: object, msg?: string): void;
|
||||
warn(msg: string): void;
|
||||
warn(obj: object, msg?: string): void;
|
||||
error(msg: string): void;
|
||||
error(obj: object, msg?: string): void;
|
||||
fatal(msg: string): void;
|
||||
fatal(obj: object, msg?: string): void;
|
||||
child(bindings: Record<string, unknown>): ILogger;
|
||||
}
|
||||
56
packages/api/src/Logger.tsx
Normal file
56
packages/api/src/Logger.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ILogger} from '@fluxer/api/src/ILogger';
|
||||
|
||||
let _logger: ILogger | null = null;
|
||||
|
||||
export function initializeLogger(logger: ILogger): void {
|
||||
if (_logger !== null) {
|
||||
return;
|
||||
}
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
export function getLogger(): ILogger {
|
||||
if (_logger === null) {
|
||||
throw new Error('Logger has not been initialized. Call initializeLogger() first.');
|
||||
}
|
||||
return _logger;
|
||||
}
|
||||
|
||||
export function resetLogger(): void {
|
||||
_logger = null;
|
||||
}
|
||||
|
||||
export const Logger: ILogger = new Proxy({} as ILogger, {
|
||||
get(_target, prop: keyof ILogger | symbol) {
|
||||
if (_logger === null) {
|
||||
throw new Error('Logger has not been initialized. Call initializeLogger() first.');
|
||||
}
|
||||
const value = _logger[prop as keyof ILogger];
|
||||
if (typeof value === 'function') {
|
||||
return value.bind(_logger);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
set() {
|
||||
throw new Error('Cannot modify Logger directly. Use initializeLogger() instead.');
|
||||
},
|
||||
});
|
||||
52
packages/api/src/RateLimitConfig.tsx
Normal file
52
packages/api/src/RateLimitConfig.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {AdminRateLimitConfigs} from '@fluxer/api/src/rate_limit_configs/AdminRateLimitConfig';
|
||||
import {AuthRateLimitConfigs} from '@fluxer/api/src/rate_limit_configs/AuthRateLimitConfig';
|
||||
import {ChannelRateLimitConfigs} from '@fluxer/api/src/rate_limit_configs/ChannelRateLimitConfig';
|
||||
import {DiscoveryRateLimitConfigs} from '@fluxer/api/src/rate_limit_configs/DiscoveryRateLimitConfig';
|
||||
import {DonationRateLimitConfigs} from '@fluxer/api/src/rate_limit_configs/DonationRateLimitConfig';
|
||||
import {GuildRateLimitConfigs} from '@fluxer/api/src/rate_limit_configs/GuildRateLimitConfig';
|
||||
import {IntegrationRateLimitConfigs} from '@fluxer/api/src/rate_limit_configs/IntegrationRateLimitConfig';
|
||||
import {InviteRateLimitConfigs} from '@fluxer/api/src/rate_limit_configs/InviteRateLimitConfig';
|
||||
import {MiscRateLimitConfigs} from '@fluxer/api/src/rate_limit_configs/MiscRateLimitConfig';
|
||||
import {OAuthRateLimitConfigs} from '@fluxer/api/src/rate_limit_configs/OAuthRateLimitConfig';
|
||||
import {PackRateLimitConfigs} from '@fluxer/api/src/rate_limit_configs/PackRateLimitConfig';
|
||||
import type {RateLimitSection} from '@fluxer/api/src/rate_limit_configs/RateLimitHelpers';
|
||||
import {mergeRateLimitSections} from '@fluxer/api/src/rate_limit_configs/RateLimitHelpers';
|
||||
import {UserRateLimitConfigs} from '@fluxer/api/src/rate_limit_configs/UserRateLimitConfig';
|
||||
import {WebhookRateLimitConfigs} from '@fluxer/api/src/rate_limit_configs/WebhookRateLimitConfig';
|
||||
|
||||
const rateLimitSections = [
|
||||
AuthRateLimitConfigs,
|
||||
OAuthRateLimitConfigs,
|
||||
UserRateLimitConfigs,
|
||||
ChannelRateLimitConfigs,
|
||||
DiscoveryRateLimitConfigs,
|
||||
DonationRateLimitConfigs,
|
||||
GuildRateLimitConfigs,
|
||||
InviteRateLimitConfigs,
|
||||
WebhookRateLimitConfigs,
|
||||
IntegrationRateLimitConfigs,
|
||||
AdminRateLimitConfigs,
|
||||
MiscRateLimitConfigs,
|
||||
PackRateLimitConfigs,
|
||||
] satisfies ReadonlyArray<RateLimitSection>;
|
||||
|
||||
export const RateLimitConfigs = mergeRateLimitSections(...rateLimitSections);
|
||||
125
packages/api/src/SearchFactory.tsx
Normal file
125
packages/api/src/SearchFactory.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import {MeilisearchSearchProvider} from '@fluxer/api/src/infrastructure/MeilisearchSearchProvider';
|
||||
import {NullSearchProvider} from '@fluxer/api/src/infrastructure/NullSearchProvider';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import {getInjectedSearchProvider} from '@fluxer/api/src/middleware/ServiceRegistry';
|
||||
import type {IAuditLogSearchService} from '@fluxer/api/src/search/IAuditLogSearchService';
|
||||
import type {IGuildMemberSearchService} from '@fluxer/api/src/search/IGuildMemberSearchService';
|
||||
import type {IGuildSearchService} from '@fluxer/api/src/search/IGuildSearchService';
|
||||
import type {IMessageSearchService} from '@fluxer/api/src/search/IMessageSearchService';
|
||||
import type {IReportSearchService} from '@fluxer/api/src/search/IReportSearchService';
|
||||
import type {ISearchProvider} from '@fluxer/api/src/search/ISearchProvider';
|
||||
import type {IUserSearchService} from '@fluxer/api/src/search/IUserSearchService';
|
||||
import {DEFAULT_SEARCH_CLIENT_TIMEOUT_MS} from '@fluxer/constants/src/Timeouts';
|
||||
|
||||
let searchProvider: ISearchProvider | null = null;
|
||||
|
||||
export function createSearchProvider(): ISearchProvider {
|
||||
if (!Config.search.apiKey) {
|
||||
Logger.warn('Search API key is not configured; search will be unavailable');
|
||||
return new NullSearchProvider();
|
||||
}
|
||||
|
||||
Logger.info({url: Config.search.url}, 'Using Meilisearch for search');
|
||||
return new MeilisearchSearchProvider({
|
||||
config: {
|
||||
url: Config.search.url,
|
||||
apiKey: Config.search.apiKey,
|
||||
timeoutMs: DEFAULT_SEARCH_CLIENT_TIMEOUT_MS,
|
||||
taskWaitTimeoutMs: DEFAULT_SEARCH_CLIENT_TIMEOUT_MS,
|
||||
taskPollIntervalMs: 50,
|
||||
},
|
||||
logger: Logger,
|
||||
});
|
||||
}
|
||||
|
||||
export function getSearchProvider(): ISearchProvider | null {
|
||||
return searchProvider;
|
||||
}
|
||||
|
||||
export function setInjectedSearchProvider(provider: ISearchProvider | undefined): void {
|
||||
searchProvider = provider ?? null;
|
||||
}
|
||||
|
||||
export function getMessageSearchService(): IMessageSearchService | null {
|
||||
return searchProvider?.getMessageSearchService() ?? null;
|
||||
}
|
||||
|
||||
export function getGuildSearchService(): IGuildSearchService | null {
|
||||
return searchProvider?.getGuildSearchService() ?? null;
|
||||
}
|
||||
|
||||
export function getUserSearchService(): IUserSearchService | null {
|
||||
return searchProvider?.getUserSearchService() ?? null;
|
||||
}
|
||||
|
||||
export function getReportSearchService(): IReportSearchService | null {
|
||||
return searchProvider?.getReportSearchService() ?? null;
|
||||
}
|
||||
|
||||
export function getAuditLogSearchService(): IAuditLogSearchService | null {
|
||||
return searchProvider?.getAuditLogSearchService() ?? null;
|
||||
}
|
||||
|
||||
export function getGuildMemberSearchService(): IGuildMemberSearchService | null {
|
||||
return searchProvider?.getGuildMemberSearchService() ?? null;
|
||||
}
|
||||
|
||||
export async function initializeSearch(): Promise<void> {
|
||||
if (searchProvider) {
|
||||
await searchProvider.shutdown();
|
||||
}
|
||||
|
||||
const injectedProvider = getInjectedSearchProvider();
|
||||
if (injectedProvider) {
|
||||
searchProvider = injectedProvider;
|
||||
Logger.info('Using injected search provider (in-process mode)');
|
||||
} else {
|
||||
searchProvider = createSearchProvider();
|
||||
}
|
||||
|
||||
try {
|
||||
await searchProvider.initialize();
|
||||
} catch (error) {
|
||||
Logger.error({error}, 'Search backend initialisation failed');
|
||||
try {
|
||||
await searchProvider.shutdown();
|
||||
} catch (shutdownError) {
|
||||
Logger.warn({error: shutdownError}, 'Failed to shut down search provider after initialisation failure');
|
||||
}
|
||||
searchProvider = null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
Logger.info('Search backend initialized successfully');
|
||||
}
|
||||
|
||||
export async function shutdownSearch(): Promise<void> {
|
||||
if (searchProvider) {
|
||||
await searchProvider.shutdown();
|
||||
searchProvider = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resetSearchServices(): void {
|
||||
searchProvider = null;
|
||||
}
|
||||
1045
packages/api/src/Tables.tsx
Normal file
1045
packages/api/src/Tables.tsx
Normal file
File diff suppressed because it is too large
Load Diff
126
packages/api/src/Telemetry.tsx
Normal file
126
packages/api/src/Telemetry.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface OtlpConfig {
|
||||
endpoints: {
|
||||
traces: string;
|
||||
metrics: string;
|
||||
logs: string;
|
||||
};
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CounterMetric {
|
||||
name: string;
|
||||
value?: number;
|
||||
dimensions?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface HistogramMetric {
|
||||
name: string;
|
||||
valueMs: number;
|
||||
dimensions?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface GaugeMetric {
|
||||
name: string;
|
||||
value: number;
|
||||
dimensions?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TelemetryService {
|
||||
isTelemetryActive(): boolean;
|
||||
isTelemetryEnabled(): boolean;
|
||||
shouldInitializeTelemetry(): boolean;
|
||||
recordCounter(metric: CounterMetric): void;
|
||||
recordHistogram(metric: HistogramMetric): void;
|
||||
recordGauge(metric: GaugeMetric): void;
|
||||
getOtlpConfig(): OtlpConfig | null;
|
||||
initializeTelemetry(options: {serviceName: string; serviceVersion?: string; environment?: string}): void;
|
||||
shutdownTelemetry(): Promise<void>;
|
||||
}
|
||||
|
||||
let _telemetryService: TelemetryService | null = null;
|
||||
|
||||
export function initializeTelemetryService(service: TelemetryService): void {
|
||||
if (_telemetryService !== null) {
|
||||
throw new Error('Telemetry service has already been initialized');
|
||||
}
|
||||
_telemetryService = service;
|
||||
}
|
||||
|
||||
export function resetTelemetryService(): void {
|
||||
_telemetryService = null;
|
||||
}
|
||||
|
||||
const noopTelemetryService: TelemetryService = {
|
||||
isTelemetryActive: () => false,
|
||||
isTelemetryEnabled: () => false,
|
||||
shouldInitializeTelemetry: () => false,
|
||||
recordCounter: () => {},
|
||||
recordHistogram: () => {},
|
||||
recordGauge: () => {},
|
||||
getOtlpConfig: () => null,
|
||||
initializeTelemetry: () => {},
|
||||
shutdownTelemetry: async () => {},
|
||||
};
|
||||
|
||||
function getTelemetryService(): TelemetryService {
|
||||
return _telemetryService ?? noopTelemetryService;
|
||||
}
|
||||
|
||||
export function isTelemetryActive(): boolean {
|
||||
return getTelemetryService().isTelemetryActive();
|
||||
}
|
||||
|
||||
export function isTelemetryEnabled(): boolean {
|
||||
return getTelemetryService().isTelemetryEnabled();
|
||||
}
|
||||
|
||||
export function shouldInitializeTelemetry(): boolean {
|
||||
return getTelemetryService().shouldInitializeTelemetry();
|
||||
}
|
||||
|
||||
export function recordCounter(metric: CounterMetric): void {
|
||||
getTelemetryService().recordCounter(metric);
|
||||
}
|
||||
|
||||
export function recordHistogram(metric: HistogramMetric): void {
|
||||
getTelemetryService().recordHistogram(metric);
|
||||
}
|
||||
|
||||
export function recordGauge(metric: GaugeMetric): void {
|
||||
getTelemetryService().recordGauge(metric);
|
||||
}
|
||||
|
||||
export function getOtlpConfig(): OtlpConfig | null {
|
||||
return getTelemetryService().getOtlpConfig();
|
||||
}
|
||||
|
||||
export function initializeTelemetry(options: {
|
||||
serviceName: string;
|
||||
serviceVersion?: string;
|
||||
environment?: string;
|
||||
}): void {
|
||||
getTelemetryService().initializeTelemetry(options);
|
||||
}
|
||||
|
||||
export async function shutdownTelemetry(): Promise<void> {
|
||||
return getTelemetryService().shutdownTelemetry();
|
||||
}
|
||||
285
packages/api/src/Validator.tsx
Normal file
285
packages/api/src/Validator.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {parseJsonPreservingLargeIntegers} from '@fluxer/api/src/utils/LosslessJsonParser';
|
||||
import {initializeFluxerErrorMap} from '@fluxer/api/src/ZodErrorMap';
|
||||
import type {ValidationErrorCode} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {
|
||||
InputValidationError,
|
||||
type LocalizedValidationError,
|
||||
} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {ValidationError} from '@fluxer/errors/src/domains/core/ValidationError';
|
||||
import type {Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets} from 'hono';
|
||||
import {getCookie} from 'hono/cookie';
|
||||
import type {ZodError, ZodTypeAny} from 'zod';
|
||||
|
||||
initializeFluxerErrorMap();
|
||||
|
||||
function isEmptyObject(obj: object): boolean {
|
||||
return Object.keys(obj).length === 0;
|
||||
}
|
||||
|
||||
const validationErrorCodeSet = new Set<string>(Object.values(ValidationErrorCodes));
|
||||
|
||||
function isValidationErrorCode(value: string): value is ValidationErrorCode {
|
||||
return validationErrorCodeSet.has(value);
|
||||
}
|
||||
|
||||
function getValidationErrorCode(message: string): ValidationErrorCode {
|
||||
if (isValidationErrorCode(message)) {
|
||||
return message;
|
||||
}
|
||||
return ValidationErrorCodes.INVALID_FORMAT;
|
||||
}
|
||||
|
||||
interface ZodTooSmallIssue {
|
||||
code: 'too_small';
|
||||
minimum: number | bigint;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface ZodTooBigIssue {
|
||||
code: 'too_big';
|
||||
maximum: number | bigint;
|
||||
type: string;
|
||||
}
|
||||
|
||||
function isTooSmallIssue(issue: ZodError['issues'][number]): issue is ZodError['issues'][number] & ZodTooSmallIssue {
|
||||
return issue.code === 'too_small' && 'minimum' in issue && 'type' in issue;
|
||||
}
|
||||
|
||||
function isTooBigIssue(issue: ZodError['issues'][number]): issue is ZodError['issues'][number] & ZodTooBigIssue {
|
||||
return issue.code === 'too_big' && 'maximum' in issue && 'type' in issue;
|
||||
}
|
||||
|
||||
interface ZodInvalidTypeIssue {
|
||||
code: 'invalid_type';
|
||||
expected: string;
|
||||
received: string;
|
||||
}
|
||||
|
||||
interface ZodCustomIssue {
|
||||
code: 'custom';
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function isInvalidTypeIssue(
|
||||
issue: ZodError['issues'][number],
|
||||
): issue is ZodError['issues'][number] & ZodInvalidTypeIssue {
|
||||
return issue.code === 'invalid_type' && 'expected' in issue && 'received' in issue;
|
||||
}
|
||||
|
||||
function isCustomIssue(issue: ZodError['issues'][number]): issue is ZodError['issues'][number] & ZodCustomIssue {
|
||||
return issue.code === 'custom';
|
||||
}
|
||||
|
||||
function extractVariablesFromIssue(issue: ZodError['issues'][number]): Record<string, unknown> | undefined {
|
||||
const path = issue.path;
|
||||
const fieldName = path.length > 0 ? String(path[path.length - 1]) : 'field';
|
||||
|
||||
if (isTooSmallIssue(issue)) {
|
||||
return {name: fieldName, min: issue.minimum, minValue: issue.minimum};
|
||||
}
|
||||
|
||||
if (isTooBigIssue(issue)) {
|
||||
return {name: fieldName, max: issue.maximum, maxLength: issue.maximum, maxValue: issue.maximum};
|
||||
}
|
||||
|
||||
if (isInvalidTypeIssue(issue)) {
|
||||
return {name: fieldName, expected: issue.expected, received: issue.received};
|
||||
}
|
||||
|
||||
if (isCustomIssue(issue) && issue.params) {
|
||||
return {name: fieldName, ...issue.params};
|
||||
}
|
||||
|
||||
return {name: fieldName};
|
||||
}
|
||||
|
||||
function convertEmptyValuesToNull(obj: unknown, isRoot = true): unknown {
|
||||
if (typeof obj === 'string' && obj === '') return null;
|
||||
if (Array.isArray(obj)) return obj.map((item) => convertEmptyValuesToNull(item, false));
|
||||
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
if (isEmptyObject(obj) && !isRoot) return null;
|
||||
|
||||
const processed = Object.fromEntries(
|
||||
Object.entries(obj).map(([key, value]) => [key, convertEmptyValuesToNull(value, false)]),
|
||||
);
|
||||
|
||||
if (!isRoot && Object.values(processed).every((value) => value === null)) return null;
|
||||
return processed;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
type HasUndefined<T> = undefined extends T ? true : false;
|
||||
type SafeParseResult<T extends ZodTypeAny> =
|
||||
| {success: true; data: T['_output']}
|
||||
| {success: false; error: ZodError<T['_input']>};
|
||||
|
||||
type Hook<
|
||||
T extends ZodTypeAny,
|
||||
E extends Env,
|
||||
P extends string,
|
||||
Target extends keyof ValidationTargets = keyof ValidationTargets,
|
||||
V extends Input = Input,
|
||||
O = Record<string, unknown>,
|
||||
> = (
|
||||
result: SafeParseResult<T> & {target: Target},
|
||||
c: Context<E, P, V>,
|
||||
) => Response | undefined | TypedResponse<O> | Promise<Response | undefined | TypedResponse<O>>;
|
||||
|
||||
type PreHook<E extends Env, P extends string, Target extends keyof ValidationTargets, V extends Input> = (
|
||||
value: unknown,
|
||||
c: Context<E, P, V>,
|
||||
target: Target,
|
||||
) => unknown | Promise<unknown>;
|
||||
|
||||
type ValidatorOptions<
|
||||
T extends ZodTypeAny,
|
||||
E extends Env,
|
||||
P extends string,
|
||||
Target extends keyof ValidationTargets,
|
||||
V extends Input,
|
||||
> = {
|
||||
pre?: PreHook<E, P, Target, V>;
|
||||
post?: Hook<T, E, P, Target, V>;
|
||||
};
|
||||
|
||||
export const Validator = <
|
||||
T extends ZodTypeAny,
|
||||
Target extends keyof ValidationTargets,
|
||||
E extends Env,
|
||||
P extends string,
|
||||
In = T['_input'],
|
||||
Out = T['_output'],
|
||||
I extends Input = {
|
||||
in: HasUndefined<In> extends true
|
||||
? {[K in Target]?: In extends ValidationTargets[K] ? In : {[K2 in keyof In]?: ValidationTargets[K][K2]}}
|
||||
: {[K in Target]: In extends ValidationTargets[K] ? In : {[K2 in keyof In]: ValidationTargets[K][K2]}};
|
||||
out: {[K in Target]: Out};
|
||||
},
|
||||
V extends I = I,
|
||||
>(
|
||||
target: Target,
|
||||
schema: T,
|
||||
hookOrOptions?: Hook<T, E, P, Target, V> | ValidatorOptions<T, E, P, Target, V>,
|
||||
): MiddlewareHandler<E, P, V> => {
|
||||
const options: ValidatorOptions<T, E, P, Target, V> =
|
||||
typeof hookOrOptions === 'function' ? {post: hookOrOptions} : (hookOrOptions ?? {});
|
||||
|
||||
return async (c, next): Promise<Response | undefined> => {
|
||||
let value: unknown;
|
||||
switch (target) {
|
||||
case 'json':
|
||||
try {
|
||||
const raw = await c.req.text();
|
||||
value = raw.trim().length === 0 ? {} : parseJsonPreservingLargeIntegers(raw);
|
||||
} catch {
|
||||
value = {};
|
||||
}
|
||||
break;
|
||||
case 'form': {
|
||||
const formData = await c.req.formData();
|
||||
type FormDataEntry = File | string;
|
||||
type FormValue = FormDataEntry | Array<FormDataEntry>;
|
||||
const form: Record<string, FormValue> = {};
|
||||
formData.forEach((formValue, key) => {
|
||||
const existingValue = form[key];
|
||||
if (key.endsWith('[]')) {
|
||||
const list = Array.isArray(existingValue)
|
||||
? existingValue
|
||||
: existingValue !== undefined
|
||||
? [existingValue]
|
||||
: [];
|
||||
list.push(formValue);
|
||||
form[key] = list;
|
||||
} else if (Array.isArray(existingValue)) {
|
||||
existingValue.push(formValue);
|
||||
} else if (existingValue !== undefined) {
|
||||
form[key] = [existingValue, formValue];
|
||||
} else {
|
||||
form[key] = formValue;
|
||||
}
|
||||
});
|
||||
value = form;
|
||||
break;
|
||||
}
|
||||
case 'query':
|
||||
value = Object.fromEntries(
|
||||
Object.entries(c.req.queries()).map(([k, v]) => (v.length === 1 ? [k, v[0]] : [k, v])),
|
||||
);
|
||||
break;
|
||||
case 'param':
|
||||
value = c.req.param();
|
||||
break;
|
||||
case 'header':
|
||||
value = c.req.header();
|
||||
break;
|
||||
case 'cookie':
|
||||
value = getCookie(c);
|
||||
break;
|
||||
default:
|
||||
value = {};
|
||||
}
|
||||
|
||||
if (options.pre) {
|
||||
value = await options.pre(value, c, target);
|
||||
}
|
||||
|
||||
const transformedValue = convertEmptyValuesToNull(value);
|
||||
|
||||
const result = await schema.safeParseAsync(transformedValue);
|
||||
|
||||
if (options.post) {
|
||||
const hookResult = await options.post({...result, target}, c);
|
||||
if (hookResult) {
|
||||
if (hookResult instanceof Response) return hookResult;
|
||||
if ('response' in hookResult && hookResult.response instanceof Response) return hookResult.response;
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
const errors: Array<ValidationError> = [];
|
||||
const localizedErrors: Array<LocalizedValidationError> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const issue of result.error.issues) {
|
||||
const path = issue.path.length > 0 ? issue.path.map(String).join('.') : 'root';
|
||||
const code = getValidationErrorCode(issue.message);
|
||||
const key = `${path}|${code}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const variables = extractVariablesFromIssue(issue);
|
||||
errors.push({path, message: code, code});
|
||||
localizedErrors.push({path, code, variables});
|
||||
}
|
||||
|
||||
throw new InputValidationError(errors, localizedErrors);
|
||||
}
|
||||
|
||||
c.req.addValidatedData(target, result.data as ValidationTargets[Target]);
|
||||
await next();
|
||||
return;
|
||||
};
|
||||
};
|
||||
134
packages/api/src/ZodErrorMap.tsx
Normal file
134
packages/api/src/ZodErrorMap.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ValidationErrorCode} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {z} from 'zod';
|
||||
|
||||
const validationErrorCodeSet = new Set<string>(Object.values(ValidationErrorCodes));
|
||||
|
||||
function isValidationErrorCode(value: string): value is ValidationErrorCode {
|
||||
return validationErrorCodeSet.has(value);
|
||||
}
|
||||
|
||||
function getParamsProperty(obj: object): Record<string, unknown> | undefined {
|
||||
if ('params' in obj) {
|
||||
const value = (obj as {params?: unknown}).params;
|
||||
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function fluxerZodErrorMap(
|
||||
issue: Parameters<z.core.$ZodErrorMap>[0],
|
||||
): {message: string} | string | undefined | null {
|
||||
if (issue.message && isValidationErrorCode(issue.message)) {
|
||||
return {message: issue.message};
|
||||
}
|
||||
|
||||
let errorCode: ValidationErrorCode;
|
||||
|
||||
switch (issue.code) {
|
||||
case 'invalid_type': {
|
||||
errorCode = ValidationErrorCodes.INVALID_FORMAT;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'unrecognized_keys': {
|
||||
errorCode = ValidationErrorCodes.INVALID_FORMAT;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'invalid_union': {
|
||||
errorCode = ValidationErrorCodes.INVALID_FORMAT;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'invalid_value': {
|
||||
errorCode = ValidationErrorCodes.INVALID_FORMAT;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'too_small': {
|
||||
const origin = 'origin' in issue ? String(issue.origin) : undefined;
|
||||
if (origin === 'date') {
|
||||
errorCode = ValidationErrorCodes.INVALID_DATE_OF_BIRTH_FORMAT;
|
||||
} else {
|
||||
errorCode = ValidationErrorCodes.INVALID_FORMAT;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'too_big': {
|
||||
const origin = 'origin' in issue ? String(issue.origin) : undefined;
|
||||
if (origin === 'string') {
|
||||
errorCode = ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH;
|
||||
} else if (origin === 'date') {
|
||||
errorCode = ValidationErrorCodes.SCHEDULED_TIME_MUST_BE_FUTURE;
|
||||
} else {
|
||||
errorCode = ValidationErrorCodes.INVALID_FORMAT;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'invalid_format': {
|
||||
const format = 'format' in issue ? String(issue.format) : undefined;
|
||||
if (format === 'email') {
|
||||
errorCode = ValidationErrorCodes.INVALID_EMAIL_ADDRESS;
|
||||
} else if (format === 'uuid') {
|
||||
errorCode = ValidationErrorCodes.INVALID_SNOWFLAKE;
|
||||
} else {
|
||||
errorCode = ValidationErrorCodes.INVALID_FORMAT;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'not_multiple_of': {
|
||||
errorCode = ValidationErrorCodes.INVALID_FORMAT;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'custom': {
|
||||
const params = getParamsProperty(issue);
|
||||
const customErrorCode = params?.['error_code'];
|
||||
errorCode =
|
||||
typeof customErrorCode === 'string' && isValidationErrorCode(customErrorCode)
|
||||
? customErrorCode
|
||||
: ValidationErrorCodes.INVALID_FORMAT;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'invalid_key':
|
||||
case 'invalid_element': {
|
||||
errorCode = ValidationErrorCodes.INVALID_FORMAT;
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
errorCode = ValidationErrorCodes.INVALID_FORMAT;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {message: errorCode};
|
||||
}
|
||||
|
||||
export function initializeFluxerErrorMap(): void {
|
||||
z.config({customError: fluxerZodErrorMap});
|
||||
}
|
||||
172
packages/api/src/admin/AdminRepository.tsx
Normal file
172
packages/api/src/admin/AdminRepository.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AdminAuditLog, IAdminRepository} from '@fluxer/api/src/admin/IAdminRepository';
|
||||
import {createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {AdminAuditLogRow} from '@fluxer/api/src/database/types/AdminArchiveTypes';
|
||||
import {AdminAuditLogs, BannedEmails, BannedIps, BannedPhones} from '@fluxer/api/src/Tables';
|
||||
|
||||
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 IS_IP_BANNED_QUERY = BannedIps.select({
|
||||
where: BannedIps.where.eq('ip'),
|
||||
});
|
||||
|
||||
const LOAD_ALL_BANNED_IPS_QUERY = BannedIps.select();
|
||||
const createLoadBannedIpsQuery = (limit?: number) => (limit ? BannedIps.select({limit}) : LOAD_ALL_BANNED_IPS_QUERY);
|
||||
|
||||
const IS_EMAIL_BANNED_QUERY = BannedEmails.select({
|
||||
where: BannedEmails.where.eq('email_lower'),
|
||||
});
|
||||
|
||||
const createLoadBannedEmailsQuery = (limit?: number) => (limit ? BannedEmails.select({limit}) : BannedEmails.select());
|
||||
|
||||
const IS_PHONE_BANNED_QUERY = BannedPhones.select({
|
||||
where: BannedPhones.where.eq('phone'),
|
||||
});
|
||||
|
||||
const createLoadBannedPhonesQuery = (limit?: number) => (limit ? BannedPhones.select({limit}) : BannedPhones.select());
|
||||
|
||||
const createListAllAuditLogsPaginatedQuery = (limit: number) =>
|
||||
AdminAuditLogs.select({
|
||||
where: AdminAuditLogs.where.tokenGt('log_id', 'last_log_id'),
|
||||
limit,
|
||||
});
|
||||
|
||||
const createListAllAuditLogsFirstPageQuery = (limit: number) =>
|
||||
AdminAuditLogs.select({
|
||||
limit,
|
||||
});
|
||||
|
||||
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 result = await fetchOne<{ip: string}>(IS_IP_BANNED_QUERY.bind({ip}));
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async banIp(ip: string): Promise<void> {
|
||||
await upsertOne(BannedIps.insert({ip}));
|
||||
}
|
||||
|
||||
async unbanIp(ip: string): Promise<void> {
|
||||
await deleteOneOrMany(BannedIps.deleteByPk({ip}));
|
||||
}
|
||||
|
||||
async listBannedIps(limit?: number): Promise<Array<string>> {
|
||||
const rows = await fetchMany<{ip: string}>(createLoadBannedIpsQuery(limit).bind({}));
|
||||
return rows.map((row) => row.ip);
|
||||
}
|
||||
|
||||
async loadAllBannedIps(): Promise<Set<string>> {
|
||||
const rows = await fetchMany<{ip: string}>(LOAD_ALL_BANNED_IPS_QUERY.bind({}));
|
||||
return new Set(rows.map((row) => row.ip));
|
||||
}
|
||||
|
||||
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 listBannedEmails(limit?: number): Promise<Array<string>> {
|
||||
const rows = await fetchMany<{email_lower: string}>(createLoadBannedEmailsQuery(limit).bind({}));
|
||||
return rows.map((row) => row.email_lower);
|
||||
}
|
||||
|
||||
async isPhoneBanned(phone: string): Promise<boolean> {
|
||||
const result = await fetchOne<{phone: string}>(IS_PHONE_BANNED_QUERY.bind({phone}));
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async banPhone(phone: string): Promise<void> {
|
||||
await upsertOne(BannedPhones.insert({phone}));
|
||||
}
|
||||
|
||||
async unbanPhone(phone: string): Promise<void> {
|
||||
await deleteOneOrMany(BannedPhones.deleteByPk({phone}));
|
||||
}
|
||||
|
||||
async listBannedPhones(limit?: number): Promise<Array<string>> {
|
||||
const rows = await fetchMany<{phone: string}>(createLoadBannedPhonesQuery(limit).bind({}));
|
||||
return rows.map((row) => row.phone);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
839
packages/api/src/admin/AdminService.tsx
Normal file
839
packages/api/src/admin/AdminService.tsx
Normal file
@@ -0,0 +1,839 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {IAdminRepository} from '@fluxer/api/src/admin/IAdminRepository';
|
||||
import {SystemDmJobRepository} from '@fluxer/api/src/admin/repositories/SystemDmJobRepository';
|
||||
import {AdminAssetPurgeService} from '@fluxer/api/src/admin/services/AdminAssetPurgeService';
|
||||
import {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import {AdminCodeGenerationService} from '@fluxer/api/src/admin/services/AdminCodeGenerationService';
|
||||
import {AdminGuildService} from '@fluxer/api/src/admin/services/AdminGuildService';
|
||||
import {AdminMessageDeletionService} from '@fluxer/api/src/admin/services/AdminMessageDeletionService';
|
||||
import {AdminMessageService} from '@fluxer/api/src/admin/services/AdminMessageService';
|
||||
import {AdminMessageShredService} from '@fluxer/api/src/admin/services/AdminMessageShredService';
|
||||
import {AdminReportService} from '@fluxer/api/src/admin/services/AdminReportService';
|
||||
import {AdminSearchService} from '@fluxer/api/src/admin/services/AdminSearchService';
|
||||
import {AdminSnowflakeReservationService} from '@fluxer/api/src/admin/services/AdminSnowflakeReservationService';
|
||||
import {AdminUserService} from '@fluxer/api/src/admin/services/AdminUserService';
|
||||
import {AdminVisionarySlotService} from '@fluxer/api/src/admin/services/AdminVisionarySlotService';
|
||||
import {AdminVoiceService} from '@fluxer/api/src/admin/services/AdminVoiceService';
|
||||
import {SystemDmService} from '@fluxer/api/src/admin/services/SystemDmService';
|
||||
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
|
||||
import type {AttachmentID, ChannelID, GuildID, ReportID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
|
||||
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
|
||||
import type {EntityAssetService} from '@fluxer/api/src/infrastructure/EntityAssetService';
|
||||
import type {IAssetDeletionQueue} from '@fluxer/api/src/infrastructure/IAssetDeletionQueue';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import type {KVBulkMessageDeletionQueueService} from '@fluxer/api/src/infrastructure/KVBulkMessageDeletionQueueService';
|
||||
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import {SnowflakeReservationRepository} from '@fluxer/api/src/instance/SnowflakeReservationRepository';
|
||||
import type {InviteRepository} from '@fluxer/api/src/invite/InviteRepository';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {BotMfaMirrorService} from '@fluxer/api/src/oauth/BotMfaMirrorService';
|
||||
import type {ReportService} from '@fluxer/api/src/report/ReportService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {VisionarySlotRepository} from '@fluxer/api/src/user/repositories/VisionarySlotRepository';
|
||||
import type {UserContactChangeLogService} from '@fluxer/api/src/user/services/UserContactChangeLogService';
|
||||
import type {VoiceRepository} from '@fluxer/api/src/voice/VoiceRepository';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
import type {
|
||||
BanGuildMemberRequest,
|
||||
BulkAddGuildMembersRequest,
|
||||
BulkUpdateGuildFeaturesRequest,
|
||||
ClearGuildFieldsRequest,
|
||||
ForceAddUserToGuildRequest,
|
||||
KickGuildMemberRequest,
|
||||
ListGuildMembersRequest,
|
||||
ListUserGuildsRequest,
|
||||
LookupGuildRequest,
|
||||
TransferGuildOwnershipRequest,
|
||||
UpdateGuildNameRequest,
|
||||
UpdateGuildSettingsRequest,
|
||||
UpdateGuildVanityRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
import type {
|
||||
DeleteAllUserMessagesRequest,
|
||||
DeleteMessageRequest,
|
||||
LookupMessageByAttachmentRequest,
|
||||
LookupMessageRequest,
|
||||
MessageShredRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminMessageSchemas';
|
||||
import type {
|
||||
ListAuditLogsRequest,
|
||||
ListGuildEmojisResponse,
|
||||
ListGuildStickersResponse,
|
||||
PurgeGuildAssetsRequest,
|
||||
PurgeGuildAssetsResponse,
|
||||
SearchReportsRequest,
|
||||
SystemDmJobResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {
|
||||
BulkScheduleUserDeletionRequest,
|
||||
BulkUpdateUserFlagsRequest,
|
||||
CancelBulkMessageDeletionRequest,
|
||||
ChangeDobRequest,
|
||||
ChangeEmailRequest,
|
||||
ChangeUsernameRequest,
|
||||
ClearUserFieldsRequest,
|
||||
DisableForSuspiciousActivityRequest,
|
||||
DisableMfaRequest,
|
||||
ListUserChangeLogRequest,
|
||||
ListUserDmChannelsRequest,
|
||||
LookupUserRequest,
|
||||
SendPasswordResetRequest,
|
||||
SetUserAclsRequest,
|
||||
SetUserBotStatusRequest,
|
||||
SetUserSystemStatusRequest,
|
||||
SetUserTraitsRequest,
|
||||
TempBanUserRequest,
|
||||
TerminateSessionsRequest,
|
||||
UnlinkPhoneRequest,
|
||||
UpdateSuspiciousActivityFlagsRequest,
|
||||
VerifyUserEmailRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import type {
|
||||
CreateVoiceRegionRequest,
|
||||
CreateVoiceServerRequest,
|
||||
DeleteVoiceRegionRequest,
|
||||
DeleteVoiceServerRequest,
|
||||
GetVoiceRegionRequest,
|
||||
GetVoiceServerRequest,
|
||||
ListVoiceRegionsRequest,
|
||||
ListVoiceServersRequest,
|
||||
UpdateVoiceRegionRequest,
|
||||
UpdateVoiceServerRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminVoiceSchemas';
|
||||
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
|
||||
|
||||
interface ForceAddUserToGuildParams {
|
||||
data: ForceAddUserToGuildRequest;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
interface LookupAttachmentParams {
|
||||
channelId: ChannelID;
|
||||
attachmentId: AttachmentID;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export class AdminService {
|
||||
private readonly auditService: AdminAuditService;
|
||||
private readonly userService: AdminUserService;
|
||||
private readonly guildServiceAggregate: AdminGuildService;
|
||||
private readonly messageService: AdminMessageService;
|
||||
private readonly messageShredService: AdminMessageShredService;
|
||||
private readonly messageDeletionService: AdminMessageDeletionService;
|
||||
private readonly reportServiceAggregate: AdminReportService;
|
||||
private readonly voiceService: AdminVoiceService;
|
||||
private readonly searchService: AdminSearchService;
|
||||
private readonly codeGenerationService: AdminCodeGenerationService;
|
||||
private readonly systemDmService: SystemDmService;
|
||||
private readonly assetPurgeService: AdminAssetPurgeService;
|
||||
private readonly snowflakeReservationService: AdminSnowflakeReservationService;
|
||||
private readonly visionarySlotService: AdminVisionarySlotService;
|
||||
|
||||
constructor(
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly guildRepository: IGuildRepositoryAggregate,
|
||||
private readonly channelRepository: IChannelRepository,
|
||||
private readonly adminRepository: IAdminRepository,
|
||||
private readonly inviteRepository: InviteRepository,
|
||||
private readonly discriminatorService: IDiscriminatorService,
|
||||
private readonly snowflakeService: SnowflakeService,
|
||||
private readonly guildService: GuildService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly userCacheService: UserCacheService,
|
||||
private readonly entityAssetService: EntityAssetService,
|
||||
private readonly assetDeletionQueue: IAssetDeletionQueue,
|
||||
private readonly emailService: IEmailService,
|
||||
private readonly mediaService: IMediaService,
|
||||
private readonly storageService: IStorageService,
|
||||
private readonly reportService: ReportService,
|
||||
private readonly workerService: IWorkerService,
|
||||
private readonly cacheService: ICacheService,
|
||||
private readonly voiceRepository: VoiceRepository,
|
||||
private readonly botMfaMirrorService: BotMfaMirrorService,
|
||||
private readonly contactChangeLogService: UserContactChangeLogService,
|
||||
private readonly bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService,
|
||||
) {
|
||||
this.auditService = new AdminAuditService(this.adminRepository, this.snowflakeService);
|
||||
this.userService = new AdminUserService({
|
||||
userRepository: this.userRepository,
|
||||
guildRepository: this.guildRepository,
|
||||
discriminatorService: this.discriminatorService,
|
||||
authService: this.authService,
|
||||
emailService: this.emailService,
|
||||
entityAssetService: this.entityAssetService,
|
||||
auditService: this.auditService,
|
||||
gatewayService: this.gatewayService,
|
||||
userCacheService: this.userCacheService,
|
||||
adminRepository: this.adminRepository,
|
||||
botMfaMirrorService: this.botMfaMirrorService,
|
||||
contactChangeLogService: this.contactChangeLogService,
|
||||
bulkMessageDeletionQueue: this.bulkMessageDeletionQueue,
|
||||
cacheService: this.cacheService,
|
||||
});
|
||||
this.guildServiceAggregate = new AdminGuildService({
|
||||
guildRepository: this.guildRepository,
|
||||
userRepository: this.userRepository,
|
||||
channelRepository: this.channelRepository,
|
||||
inviteRepository: this.inviteRepository,
|
||||
guildService: this.guildService,
|
||||
gatewayService: this.gatewayService,
|
||||
entityAssetService: this.entityAssetService,
|
||||
auditService: this.auditService,
|
||||
});
|
||||
this.assetPurgeService = new AdminAssetPurgeService({
|
||||
guildRepository: this.guildRepository,
|
||||
gatewayService: this.gatewayService,
|
||||
assetDeletionQueue: this.assetDeletionQueue,
|
||||
auditService: this.auditService,
|
||||
});
|
||||
this.messageService = new AdminMessageService({
|
||||
channelRepository: this.channelRepository,
|
||||
userCacheService: this.userCacheService,
|
||||
mediaService: this.mediaService,
|
||||
gatewayService: this.gatewayService,
|
||||
auditService: this.auditService,
|
||||
});
|
||||
this.messageShredService = new AdminMessageShredService({
|
||||
workerService: this.workerService,
|
||||
cacheService: this.cacheService,
|
||||
snowflakeService: this.snowflakeService,
|
||||
auditService: this.auditService,
|
||||
});
|
||||
this.messageDeletionService = new AdminMessageDeletionService({
|
||||
channelRepository: this.channelRepository,
|
||||
messageShredService: this.messageShredService,
|
||||
auditService: this.auditService,
|
||||
});
|
||||
this.reportServiceAggregate = new AdminReportService({
|
||||
reportService: this.reportService,
|
||||
userRepository: this.userRepository,
|
||||
emailService: this.emailService,
|
||||
storageService: this.storageService,
|
||||
auditService: this.auditService,
|
||||
userCacheService: this.userCacheService,
|
||||
});
|
||||
this.voiceService = new AdminVoiceService({
|
||||
voiceRepository: this.voiceRepository,
|
||||
cacheService: this.cacheService,
|
||||
auditService: this.auditService,
|
||||
});
|
||||
this.searchService = new AdminSearchService({
|
||||
guildRepository: this.guildRepository,
|
||||
userRepository: this.userRepository,
|
||||
workerService: this.workerService,
|
||||
cacheService: this.cacheService,
|
||||
snowflakeService: this.snowflakeService,
|
||||
auditService: this.auditService,
|
||||
});
|
||||
const systemDmJobRepository = new SystemDmJobRepository();
|
||||
this.systemDmService = new SystemDmService(
|
||||
this.userRepository,
|
||||
systemDmJobRepository,
|
||||
this.auditService,
|
||||
this.workerService,
|
||||
this.snowflakeService,
|
||||
);
|
||||
|
||||
this.snowflakeReservationService = new AdminSnowflakeReservationService({
|
||||
repository: new SnowflakeReservationRepository(),
|
||||
cacheService: this.cacheService,
|
||||
auditService: this.auditService,
|
||||
});
|
||||
this.visionarySlotService = new AdminVisionarySlotService({
|
||||
repository: new VisionarySlotRepository(),
|
||||
auditService: this.auditService,
|
||||
});
|
||||
this.codeGenerationService = new AdminCodeGenerationService(this.userRepository);
|
||||
}
|
||||
|
||||
async lookupUser(data: LookupUserRequest) {
|
||||
return this.userService.lookupUser(data);
|
||||
}
|
||||
|
||||
async listSnowflakeReservations() {
|
||||
return this.snowflakeReservationService.listReservations();
|
||||
}
|
||||
|
||||
async setSnowflakeReservation(
|
||||
data: {email: string; snowflake: string},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.snowflakeReservationService.setReservation(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async deleteSnowflakeReservation(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.snowflakeReservationService.deleteReservation(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async expandVisionarySlots(data: {count: number}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.visionarySlotService.expandSlots(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async shrinkVisionarySlots(data: {targetCount: number}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.visionarySlotService.shrinkSlots(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async setVisionarySlotReservation(
|
||||
data: {slotIndex: number; userId: UserID | null},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.visionarySlotService.setSlotReservation(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async swapVisionarySlots(
|
||||
data: {slotIndexA: number; slotIndexB: number},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.visionarySlotService.swapSlots(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async createSystemDmJob(
|
||||
data: {
|
||||
content: string;
|
||||
registrationStart?: Date;
|
||||
registrationEnd?: Date;
|
||||
excludedGuildIds: Array<GuildID>;
|
||||
},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<SystemDmJobResponse> {
|
||||
return this.systemDmService.createJob(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async listSystemDmJobs(
|
||||
limit: number,
|
||||
beforeJobId?: bigint,
|
||||
): Promise<{jobs: Array<SystemDmJobResponse>; next_cursor: string | null}> {
|
||||
return this.systemDmService.listJobs(limit, beforeJobId);
|
||||
}
|
||||
|
||||
async approveSystemDmJob(
|
||||
jobId: bigint,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<SystemDmJobResponse> {
|
||||
return this.systemDmService.approveJob(jobId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateUserFlags(args: {
|
||||
userId: UserID;
|
||||
data: {addFlags: Array<bigint>; removeFlags: Array<bigint>};
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
return this.userService.updateUserFlags(args);
|
||||
}
|
||||
|
||||
async disableMfa(data: DisableMfaRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.disableMfa(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async clearUserFields(data: ClearUserFieldsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.clearUserFields(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async setUserBotStatus(data: SetUserBotStatusRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.setUserBotStatus(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async setUserSystemStatus(data: SetUserSystemStatusRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.setUserSystemStatus(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async verifyUserEmail(data: VerifyUserEmailRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.verifyUserEmail(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async sendPasswordReset(data: SendPasswordResetRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.sendPasswordReset(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async changeUsername(data: ChangeUsernameRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.changeUsername(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async changeEmail(data: ChangeEmailRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.changeEmail(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async terminateSessions(data: TerminateSessionsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.terminateSessions(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async tempBanUser(data: TempBanUserRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.tempBanUser(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unbanUser(data: {user_id: bigint}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.unbanUser(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async scheduleAccountDeletion(
|
||||
data:
|
||||
| BulkScheduleUserDeletionRequest
|
||||
| {user_id: bigint; reason_code: number; public_reason?: string | null; days_until_deletion: number},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
if ('user_ids' in data) {
|
||||
return this.userService.bulkScheduleUserDeletion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
return this.userService.scheduleAccountDeletion(
|
||||
{
|
||||
user_id: data.user_id,
|
||||
reason_code: data.reason_code,
|
||||
public_reason: data.public_reason ?? undefined,
|
||||
days_until_deletion: data.days_until_deletion,
|
||||
},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
);
|
||||
}
|
||||
|
||||
async cancelAccountDeletion(data: {user_id: bigint}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.cancelAccountDeletion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async cancelBulkMessageDeletion(
|
||||
data: CancelBulkMessageDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.userService.cancelBulkMessageDeletion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async banIp(data: {ip: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const result = await this.userService.banIp(data, adminUserId, auditLogReason);
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.admin_bans.ip.created',
|
||||
dimensions: {
|
||||
reason_type: auditLogReason ? 'provided' : 'none',
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async unbanIp(data: {ip: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const result = await this.userService.unbanIp(data, adminUserId, auditLogReason);
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.admin_bans.ip.deleted',
|
||||
dimensions: {
|
||||
reason_type: auditLogReason ? 'provided' : 'none',
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async checkIpBan(data: {ip: string}) {
|
||||
return this.userService.checkIpBan(data);
|
||||
}
|
||||
|
||||
async listIpBans(data: {limit: number}) {
|
||||
return this.userService.listIpBans(data);
|
||||
}
|
||||
|
||||
async banEmail(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const result = await this.userService.banEmail(data, adminUserId, auditLogReason);
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.admin_bans.email.created',
|
||||
dimensions: {
|
||||
reason_type: auditLogReason ? 'provided' : 'none',
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async unbanEmail(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const result = await this.userService.unbanEmail(data, adminUserId, auditLogReason);
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.admin_bans.email.deleted',
|
||||
dimensions: {
|
||||
reason_type: auditLogReason ? 'provided' : 'none',
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async checkEmailBan(data: {email: string}) {
|
||||
return this.userService.checkEmailBan(data);
|
||||
}
|
||||
|
||||
async listEmailBans(data: {limit: number}) {
|
||||
return this.userService.listEmailBans(data);
|
||||
}
|
||||
|
||||
async banPhone(data: {phone: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const result = await this.userService.banPhone(data, adminUserId, auditLogReason);
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.admin_bans.phone.created',
|
||||
dimensions: {
|
||||
reason_type: auditLogReason ? 'provided' : 'none',
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async unbanPhone(data: {phone: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const result = await this.userService.unbanPhone(data, adminUserId, auditLogReason);
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.admin_bans.phone.deleted',
|
||||
dimensions: {
|
||||
reason_type: auditLogReason ? 'provided' : 'none',
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async checkPhoneBan(data: {phone: string}) {
|
||||
return this.userService.checkPhoneBan(data);
|
||||
}
|
||||
|
||||
async listPhoneBans(data: {limit: number}) {
|
||||
return this.userService.listPhoneBans(data);
|
||||
}
|
||||
|
||||
async setUserAcls(data: SetUserAclsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.setUserAcls(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async setUserTraits(data: SetUserTraitsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.setUserTraits(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unlinkPhone(data: UnlinkPhoneRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.unlinkPhone(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async changeDob(data: ChangeDobRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.changeDob(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateSuspiciousActivityFlags(
|
||||
data: UpdateSuspiciousActivityFlagsRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.userService.updateSuspiciousActivityFlags(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async disableForSuspiciousActivity(
|
||||
data: DisableForSuspiciousActivityRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.userService.disableForSuspiciousActivity(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkUpdateUserFlags(data: BulkUpdateUserFlagsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.bulkUpdateUserFlags(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkScheduleUserDeletion(
|
||||
data: BulkScheduleUserDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.userService.bulkScheduleUserDeletion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async listUserSessions(userId: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.listUserSessions(userId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async listUserDmChannels(data: ListUserDmChannelsRequest) {
|
||||
return this.userService.listUserDmChannels(data);
|
||||
}
|
||||
|
||||
async listUserChangeLog(data: ListUserChangeLogRequest) {
|
||||
return this.userService.listUserChangeLog(data);
|
||||
}
|
||||
|
||||
async updateGuildFeatures(args: {
|
||||
guildId: GuildID;
|
||||
addFeatures: Array<string>;
|
||||
removeFeatures: Array<string>;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
return this.guildServiceAggregate.updateGuildFeatures(args);
|
||||
}
|
||||
|
||||
async forceAddUserToGuild({
|
||||
data,
|
||||
requestCache,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: ForceAddUserToGuildParams & {adminUserId: UserID; auditLogReason: string | null}) {
|
||||
return this.guildServiceAggregate.forceAddUserToGuild({data, requestCache, adminUserId, auditLogReason});
|
||||
}
|
||||
|
||||
async lookupGuild(data: LookupGuildRequest) {
|
||||
return this.guildServiceAggregate.lookupGuild(data);
|
||||
}
|
||||
|
||||
async listUserGuilds(data: ListUserGuildsRequest) {
|
||||
return this.guildServiceAggregate.listUserGuilds(data);
|
||||
}
|
||||
|
||||
async listGuildMembers(data: ListGuildMembersRequest) {
|
||||
return this.guildServiceAggregate.listGuildMembers(data);
|
||||
}
|
||||
|
||||
async banGuildMember(data: BanGuildMemberRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.guildServiceAggregate.banGuildMember(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async kickGuildMember(data: KickGuildMemberRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.guildServiceAggregate.kickGuildMember(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async listGuildEmojis(guildId: GuildID): Promise<ListGuildEmojisResponse> {
|
||||
return this.guildServiceAggregate.listGuildEmojis(guildId);
|
||||
}
|
||||
|
||||
async listGuildStickers(guildId: GuildID): Promise<ListGuildStickersResponse> {
|
||||
return this.guildServiceAggregate.listGuildStickers(guildId);
|
||||
}
|
||||
|
||||
async purgeGuildAssets(
|
||||
data: PurgeGuildAssetsRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<PurgeGuildAssetsResponse> {
|
||||
return this.assetPurgeService.purgeGuildAssets({
|
||||
ids: data.ids,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
});
|
||||
}
|
||||
|
||||
async clearGuildFields(data: ClearGuildFieldsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.guildServiceAggregate.clearGuildFields(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateGuildName(data: UpdateGuildNameRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.guildServiceAggregate.updateGuildName(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateGuildSettings(data: UpdateGuildSettingsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.guildServiceAggregate.updateGuildSettings(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateGuildVanity(data: UpdateGuildVanityRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.guildServiceAggregate.updateGuildVanity(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async transferGuildOwnership(
|
||||
data: TransferGuildOwnershipRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.guildServiceAggregate.transferGuildOwnership(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkUpdateGuildFeatures(
|
||||
data: BulkUpdateGuildFeaturesRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.guildServiceAggregate.bulkUpdateGuildFeatures(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkAddGuildMembers(data: BulkAddGuildMembersRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.guildServiceAggregate.bulkAddGuildMembers(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async reloadGuild(guildId: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.guildServiceAggregate.reloadGuild(guildId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async shutdownGuild(guildId: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.guildServiceAggregate.shutdownGuild(guildId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async deleteGuild(guildId: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.guildServiceAggregate.deleteGuild(guildId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async getGuildMemoryStats(limit: number) {
|
||||
return this.guildServiceAggregate.getGuildMemoryStats(limit);
|
||||
}
|
||||
|
||||
async reloadAllGuilds(guildIds: Array<GuildID>) {
|
||||
return this.guildServiceAggregate.reloadAllGuilds(guildIds);
|
||||
}
|
||||
|
||||
async getNodeStats() {
|
||||
return this.guildServiceAggregate.getNodeStats();
|
||||
}
|
||||
|
||||
async lookupAttachment(params: LookupAttachmentParams) {
|
||||
return this.messageService.lookupAttachment(params);
|
||||
}
|
||||
|
||||
async lookupMessage(data: LookupMessageRequest) {
|
||||
return this.messageService.lookupMessage(data);
|
||||
}
|
||||
|
||||
async lookupMessageByAttachment(data: LookupMessageByAttachmentRequest) {
|
||||
return this.messageService.lookupMessageByAttachment(data);
|
||||
}
|
||||
|
||||
async deleteMessage(data: DeleteMessageRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.messageService.deleteMessage(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async deleteAllUserMessages(data: DeleteAllUserMessagesRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.messageDeletionService.deleteAllUserMessages(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async queueMessageShred(data: MessageShredRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.messageShredService.queueMessageShred(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async getMessageShredStatus(jobId: string) {
|
||||
return this.messageShredService.getMessageShredStatus(jobId);
|
||||
}
|
||||
|
||||
async listAuditLogs(data: ListAuditLogsRequest) {
|
||||
return this.auditService.listAuditLogs(data);
|
||||
}
|
||||
|
||||
async searchAuditLogs(data: {
|
||||
query?: string;
|
||||
admin_user_id?: bigint;
|
||||
target_id?: string;
|
||||
sort_by?: 'createdAt' | 'relevance';
|
||||
sort_order?: 'asc' | 'desc';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
return this.auditService.searchAuditLogs(data);
|
||||
}
|
||||
|
||||
async listReports(status: number, limit?: number, offset?: number) {
|
||||
return this.reportServiceAggregate.listReports(status, limit, offset);
|
||||
}
|
||||
|
||||
async getReport(reportId: ReportID) {
|
||||
return this.reportServiceAggregate.getReport(reportId);
|
||||
}
|
||||
|
||||
async resolveReport(
|
||||
reportId: ReportID,
|
||||
adminUserId: UserID,
|
||||
publicComment: string | null,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.reportServiceAggregate.resolveReport(reportId, adminUserId, publicComment, auditLogReason);
|
||||
}
|
||||
|
||||
async searchReports(data: SearchReportsRequest) {
|
||||
return this.reportServiceAggregate.searchReports(data);
|
||||
}
|
||||
|
||||
async searchGuilds(data: {query?: string; limit: number; offset: number}) {
|
||||
return this.searchService.searchGuilds(data);
|
||||
}
|
||||
|
||||
async searchUsers(data: {query?: string; limit: number; offset: number}) {
|
||||
return this.searchService.searchUsers(data);
|
||||
}
|
||||
|
||||
async refreshSearchIndex(
|
||||
data: {
|
||||
index_type:
|
||||
| 'guilds'
|
||||
| 'users'
|
||||
| 'reports'
|
||||
| 'audit_logs'
|
||||
| 'channel_messages'
|
||||
| 'guild_members'
|
||||
| 'favorite_memes';
|
||||
guild_id?: bigint;
|
||||
user_id?: bigint;
|
||||
},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.searchService.refreshSearchIndex(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async getIndexRefreshStatus(jobId: string) {
|
||||
return this.searchService.getIndexRefreshStatus(jobId);
|
||||
}
|
||||
|
||||
async listVoiceRegions(data: ListVoiceRegionsRequest, _adminUserId: UserID, _auditLogReason: string | null) {
|
||||
return this.voiceService.listVoiceRegions(data);
|
||||
}
|
||||
|
||||
async getVoiceRegion(data: GetVoiceRegionRequest, _adminUserId: UserID, _auditLogReason: string | null) {
|
||||
return this.voiceService.getVoiceRegion(data);
|
||||
}
|
||||
|
||||
async createVoiceRegion(data: CreateVoiceRegionRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.voiceService.createVoiceRegion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateVoiceRegion(data: UpdateVoiceRegionRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.voiceService.updateVoiceRegion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async deleteVoiceRegion(data: DeleteVoiceRegionRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.voiceService.deleteVoiceRegion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async listVoiceServers(data: ListVoiceServersRequest, _adminUserId: UserID, _auditLogReason: string | null) {
|
||||
return this.voiceService.listVoiceServers(data);
|
||||
}
|
||||
|
||||
async getVoiceServer(data: GetVoiceServerRequest, _adminUserId: UserID, _auditLogReason: string | null) {
|
||||
return this.voiceService.getVoiceServer(data);
|
||||
}
|
||||
|
||||
async createVoiceServer(data: CreateVoiceServerRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.voiceService.createVoiceServer(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateVoiceServer(data: UpdateVoiceServerRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.voiceService.updateVoiceServer(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async deleteVoiceServer(data: DeleteVoiceServerRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.voiceService.deleteVoiceServer(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async generateGiftCodes(count: number, durationMonths: number) {
|
||||
return this.codeGenerationService.generateGiftCodes(count, durationMonths);
|
||||
}
|
||||
}
|
||||
56
packages/api/src/admin/IAdminRepository.tsx
Normal file
56
packages/api/src/admin/IAdminRepository.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {AdminAuditLogRow} from '@fluxer/api/src/database/types/AdminArchiveTypes';
|
||||
|
||||
export interface AdminAuditLog {
|
||||
logId: bigint;
|
||||
adminUserId: UserID;
|
||||
targetType: string;
|
||||
targetId: bigint;
|
||||
action: string;
|
||||
auditLogReason: string | null;
|
||||
metadata: Map<string, string>;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export abstract class IAdminRepository {
|
||||
abstract createAuditLog(log: AdminAuditLogRow): Promise<AdminAuditLog>;
|
||||
abstract getAuditLog(logId: bigint): Promise<AdminAuditLog | null>;
|
||||
abstract listAuditLogsByIds(logIds: Array<bigint>): Promise<Array<AdminAuditLog>>;
|
||||
abstract listAllAuditLogsPaginated(limit: number, lastLogId?: bigint): Promise<Array<AdminAuditLog>>;
|
||||
|
||||
abstract isIpBanned(ip: string): Promise<boolean>;
|
||||
abstract banIp(ip: string): Promise<void>;
|
||||
abstract unbanIp(ip: string): Promise<void>;
|
||||
abstract listBannedIps(limit?: number): Promise<Array<string>>;
|
||||
|
||||
abstract isEmailBanned(email: string): Promise<boolean>;
|
||||
abstract banEmail(email: string): Promise<void>;
|
||||
abstract unbanEmail(email: string): Promise<void>;
|
||||
abstract listBannedEmails(limit?: number): Promise<Array<string>>;
|
||||
|
||||
abstract isPhoneBanned(phone: string): Promise<boolean>;
|
||||
abstract banPhone(phone: string): Promise<void>;
|
||||
abstract unbanPhone(phone: string): Promise<void>;
|
||||
abstract listBannedPhones(limit?: number): Promise<Array<string>>;
|
||||
|
||||
abstract loadAllBannedIps(): Promise<Set<string>>;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
CreateAdminApiKeyRequest,
|
||||
CreateAdminApiKeyResponse,
|
||||
type CreateAdminApiKeyResponse as CreateAdminApiKeyResponseType,
|
||||
DeleteApiKeyResponse,
|
||||
ListAdminApiKeyResponse,
|
||||
type ListAdminApiKeyResponse as ListAdminApiKeyResponseType,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {KeyIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {z} from 'zod';
|
||||
|
||||
export function AdminApiKeyAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/api-keys',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_CODE_GENERATION),
|
||||
requireAdminACL(AdminACLs.ADMIN_API_KEY_MANAGE),
|
||||
Validator('json', CreateAdminApiKeyRequest),
|
||||
OpenAPI({
|
||||
operationId: 'create_admin_api_key',
|
||||
summary: 'Create admin API key',
|
||||
responseSchema: CreateAdminApiKeyResponse,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
"Generates a new API key for administrative operations. The key is returned only once at creation time. Includes expiration settings and access control lists (ACLs) to limit the key's permissions.",
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminApiKeyService = ctx.get('adminApiKeyService');
|
||||
const user = ctx.get('user');
|
||||
const adminUserAcls = ctx.get('adminUserAcls');
|
||||
const request = ctx.req.valid('json');
|
||||
|
||||
const result = await adminApiKeyService.createApiKey(request, user.id, adminUserAcls);
|
||||
|
||||
const response: CreateAdminApiKeyResponseType = {
|
||||
key_id: result.apiKey.keyId,
|
||||
key: result.key,
|
||||
name: result.apiKey.name,
|
||||
created_at: result.apiKey.createdAt.toISOString(),
|
||||
expires_at: result.apiKey.expiresAt?.toISOString() ?? null,
|
||||
acls: Array.from(result.apiKey.acls),
|
||||
};
|
||||
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/admin/api-keys',
|
||||
requireAdminACL(AdminACLs.ADMIN_API_KEY_MANAGE),
|
||||
OpenAPI({
|
||||
operationId: 'list_admin_api_keys',
|
||||
summary: 'List admin API keys',
|
||||
responseSchema: z.array(ListAdminApiKeyResponse),
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Retrieve all API keys created by the authenticated admin. Returns metadata including creation time, last used time, and assigned permissions. The actual key material is not returned.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminApiKeyService = ctx.get('adminApiKeyService');
|
||||
const user = ctx.get('user');
|
||||
|
||||
const keys = await adminApiKeyService.listKeys(user.id);
|
||||
|
||||
const response: Array<ListAdminApiKeyResponseType> = keys.map((key) => ({
|
||||
key_id: key.keyId,
|
||||
name: key.name,
|
||||
created_at: key.createdAt.toISOString(),
|
||||
last_used_at: key.lastUsedAt?.toISOString() ?? null,
|
||||
expires_at: key.expiresAt?.toISOString() ?? null,
|
||||
created_by_user_id: String(key.createdById),
|
||||
acls: Array.from(key.acls),
|
||||
}));
|
||||
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/admin/api-keys/:keyId',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.ADMIN_API_KEY_MANAGE),
|
||||
Validator('param', KeyIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'delete_admin_api_key',
|
||||
summary: 'Delete admin API key',
|
||||
responseSchema: DeleteApiKeyResponse,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Revokes an API key, immediately invalidating it for all future operations. This action cannot be undone.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminApiKeyService = ctx.get('adminApiKeyService');
|
||||
const user = ctx.get('user');
|
||||
const keyId = ctx.req.valid('param').keyId;
|
||||
|
||||
await adminApiKeyService.revokeKey(keyId, user.id);
|
||||
|
||||
return ctx.json({success: true}, 200);
|
||||
},
|
||||
);
|
||||
}
|
||||
214
packages/api/src/admin/controllers/ArchiveAdminController.tsx
Normal file
214
packages/api/src/admin/controllers/ArchiveAdminController.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createGuildID, createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {requireAdminACL, requireAnyAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError';
|
||||
import {
|
||||
AdminArchiveResponseSchema,
|
||||
DownloadUrlResponseSchema,
|
||||
GetArchiveResponseSchema,
|
||||
ListArchivesRequest,
|
||||
ListArchivesResponseSchema,
|
||||
TriggerGuildArchiveRequest,
|
||||
TriggerUserArchiveRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {ArchivePathParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
|
||||
function canViewArchive(adminAcls: Set<string>, subjectType: 'user' | 'guild'): boolean {
|
||||
if (adminAcls.has(AdminACLs.WILDCARD) || adminAcls.has(AdminACLs.ARCHIVE_VIEW_ALL)) return true;
|
||||
if (subjectType === 'user') return adminAcls.has(AdminACLs.ARCHIVE_TRIGGER_USER);
|
||||
return adminAcls.has(AdminACLs.ARCHIVE_TRIGGER_GUILD);
|
||||
}
|
||||
|
||||
export function ArchiveAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/archives/user',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.ARCHIVE_TRIGGER_USER),
|
||||
Validator('json', TriggerUserArchiveRequest),
|
||||
OpenAPI({
|
||||
operationId: 'trigger_user_archive',
|
||||
summary: 'Trigger user archive',
|
||||
responseSchema: AdminArchiveResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
"Initiates a data export for a user. Creates an archive containing all the user's data (messages, server memberships, preferences, etc.) for export or compliance purposes.",
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminArchiveService = ctx.get('adminArchiveService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const body = ctx.req.valid('json');
|
||||
const result = await adminArchiveService.triggerUserArchive(createUserID(body.user_id), adminUserId);
|
||||
return ctx.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/archives/guild',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.ARCHIVE_TRIGGER_GUILD),
|
||||
Validator('json', TriggerGuildArchiveRequest),
|
||||
OpenAPI({
|
||||
operationId: 'trigger_guild_archive',
|
||||
summary: 'Trigger guild archive',
|
||||
responseSchema: AdminArchiveResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Initiates a data export for a guild (server). Creates an archive containing all guild data including channels, messages, members, roles, and settings.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminArchiveService = ctx.get('adminArchiveService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const body = ctx.req.valid('json');
|
||||
const result = await adminArchiveService.triggerGuildArchive(createGuildID(body.guild_id), adminUserId);
|
||||
return ctx.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/archives/list',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAnyAdminACL([AdminACLs.ARCHIVE_VIEW_ALL, AdminACLs.ARCHIVE_TRIGGER_USER, AdminACLs.ARCHIVE_TRIGGER_GUILD]),
|
||||
Validator('json', ListArchivesRequest),
|
||||
OpenAPI({
|
||||
operationId: 'list_archives',
|
||||
summary: 'List archives',
|
||||
responseSchema: ListArchivesResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Query and filter created archives by type (user or guild), subject ID, requestor, and expiration status. Admins with limited ACLs see only archives matching their permissions.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminArchiveService = ctx.get('adminArchiveService');
|
||||
const adminAcls = ctx.get('adminUserAcls');
|
||||
const body = ctx.req.valid('json') as ListArchivesRequest;
|
||||
|
||||
if (
|
||||
body.subject_type === 'all' &&
|
||||
!adminAcls.has(AdminACLs.ARCHIVE_VIEW_ALL) &&
|
||||
!adminAcls.has(AdminACLs.WILDCARD)
|
||||
) {
|
||||
throw new MissingACLError(AdminACLs.ARCHIVE_VIEW_ALL);
|
||||
}
|
||||
|
||||
if (
|
||||
body.subject_type !== 'all' &&
|
||||
!canViewArchive(adminAcls, body.subject_type) &&
|
||||
!adminAcls.has(AdminACLs.WILDCARD)
|
||||
) {
|
||||
throw new MissingACLError(
|
||||
body.subject_type === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await adminArchiveService.listArchives({
|
||||
subjectType: body.subject_type as 'user' | 'guild' | 'all',
|
||||
subjectId: body.subject_id ?? undefined,
|
||||
requestedBy: body.requested_by ?? undefined,
|
||||
limit: body.limit,
|
||||
includeExpired: body.include_expired,
|
||||
});
|
||||
|
||||
return ctx.json({archives: result}, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/admin/archives/:subjectType/:subjectId/:archiveId',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAnyAdminACL([AdminACLs.ARCHIVE_VIEW_ALL, AdminACLs.ARCHIVE_TRIGGER_USER, AdminACLs.ARCHIVE_TRIGGER_GUILD]),
|
||||
Validator('param', ArchivePathParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_archive_details',
|
||||
summary: 'Get archive details',
|
||||
responseSchema: GetArchiveResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Retrieve metadata for a specific archive including its status, creation time, expiration, and file location. Does not return the archive contents themselves.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminArchiveService = ctx.get('adminArchiveService');
|
||||
const adminAcls = ctx.get('adminUserAcls');
|
||||
const params = ctx.req.valid('param');
|
||||
const subjectType = params.subjectType;
|
||||
|
||||
if (!canViewArchive(adminAcls, subjectType) && !adminAcls.has(AdminACLs.WILDCARD)) {
|
||||
throw new MissingACLError(
|
||||
subjectType === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD,
|
||||
);
|
||||
}
|
||||
|
||||
const subjectId = params.subjectId;
|
||||
const archiveId = params.archiveId;
|
||||
|
||||
const archive = await adminArchiveService.getArchive(subjectType, subjectId, archiveId);
|
||||
return ctx.json({archive}, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/admin/archives/:subjectType/:subjectId/:archiveId/download',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAnyAdminACL([AdminACLs.ARCHIVE_VIEW_ALL, AdminACLs.ARCHIVE_TRIGGER_USER, AdminACLs.ARCHIVE_TRIGGER_GUILD]),
|
||||
Validator('param', ArchivePathParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_archive_download_url',
|
||||
summary: 'Get archive download URL',
|
||||
responseSchema: DownloadUrlResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Generate a time-limited download link to the archive file. The URL provides direct access to download the compressed archive contents.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminArchiveService = ctx.get('adminArchiveService');
|
||||
const adminAcls = ctx.get('adminUserAcls');
|
||||
const params = ctx.req.valid('param');
|
||||
const subjectType = params.subjectType;
|
||||
|
||||
if (!canViewArchive(adminAcls, subjectType) && !adminAcls.has(AdminACLs.WILDCARD)) {
|
||||
throw new MissingACLError(
|
||||
subjectType === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD,
|
||||
);
|
||||
}
|
||||
|
||||
const subjectId = params.subjectId;
|
||||
const archiveId = params.archiveId;
|
||||
|
||||
const result = await adminArchiveService.getDownloadUrl(subjectType, subjectId, archiveId);
|
||||
return ctx.json(result, 200);
|
||||
},
|
||||
);
|
||||
}
|
||||
52
packages/api/src/admin/controllers/AssetAdminController.tsx
Normal file
52
packages/api/src/admin/controllers/AssetAdminController.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {AdminRateLimitConfigs} from '@fluxer/api/src/rate_limit_configs/AdminRateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {PurgeGuildAssetsRequest, PurgeGuildAssetsResponseSchema} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
|
||||
export function AssetAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/assets/purge',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.ASSET_PURGE),
|
||||
Validator('json', PurgeGuildAssetsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'purge_guild_assets',
|
||||
summary: 'Purge guild assets',
|
||||
responseSchema: PurgeGuildAssetsResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Delete and clean up all assets belonging to a guild, including icons, banners, and other media. This is a destructive operation used for cleanup during guild management or compliance actions.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.purgeGuildAssets(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
AuditLogsListResponseSchema,
|
||||
ListAuditLogsRequest,
|
||||
SearchAuditLogsRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
|
||||
export function AuditLogAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/audit-logs',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_AUDIT_LOG),
|
||||
requireAdminACL(AdminACLs.AUDIT_LOG_VIEW),
|
||||
Validator('json', ListAuditLogsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'list_audit_logs',
|
||||
summary: 'List audit logs',
|
||||
responseSchema: AuditLogsListResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Retrieve a paginated list of audit logs with optional filtering by date range, action type, or actor. Used for tracking administrative operations and compliance auditing.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.listAuditLogs(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/audit-logs/search',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_AUDIT_LOG),
|
||||
requireAdminACL(AdminACLs.AUDIT_LOG_VIEW),
|
||||
Validator('json', SearchAuditLogsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'search_audit_logs',
|
||||
summary: 'Search audit logs',
|
||||
responseSchema: AuditLogsListResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Perform a full-text search across audit logs for specific events or changes. Allows targeted queries for compliance investigations or incident response.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.searchAuditLogs(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
}
|
||||
314
packages/api/src/admin/controllers/BanAdminController.tsx
Normal file
314
packages/api/src/admin/controllers/BanAdminController.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
BanCheckResponseSchema,
|
||||
BanEmailRequest,
|
||||
BanIpRequest,
|
||||
BanPhoneRequest,
|
||||
ListBansRequest,
|
||||
ListEmailBansResponseSchema,
|
||||
ListIpBansResponseSchema,
|
||||
ListPhoneBansResponseSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
|
||||
export function BanAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/bans/ip/add',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_IP_ADD),
|
||||
Validator('json', BanIpRequest),
|
||||
OpenAPI({
|
||||
operationId: 'add_ip_ban',
|
||||
summary: 'Add IP ban',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Ban one or more IP addresses from accessing the platform. Users connecting from banned IPs will be denied service. Can be applied retroactively.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const result = await adminService.banIp(ctx.req.valid('json'), adminUserId, auditLogReason);
|
||||
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bans/ip/remove',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_IP_REMOVE),
|
||||
Validator('json', BanIpRequest),
|
||||
OpenAPI({
|
||||
operationId: 'remove_ip_ban',
|
||||
summary: 'Remove IP ban',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Lift a previously applied IP ban, allowing traffic from those addresses again. Used for appeals or when bans were applied in error.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const result = await adminService.unbanIp(ctx.req.valid('json'), adminUserId, auditLogReason);
|
||||
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bans/ip/check',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_IP_CHECK),
|
||||
Validator('json', BanIpRequest),
|
||||
OpenAPI({
|
||||
operationId: 'check_ip_ban_status',
|
||||
summary: 'Check IP ban status',
|
||||
responseSchema: BanCheckResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Query whether one or more IP addresses are currently banned. Returns the ban status and any associated metadata like reason or expiration.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.checkIpBan(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bans/ip/list',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_IP_CHECK),
|
||||
Validator('json', ListBansRequest),
|
||||
OpenAPI({
|
||||
operationId: 'list_ip_bans',
|
||||
summary: 'List IP bans',
|
||||
responseSchema: ListIpBansResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description: 'List currently banned IPs/CIDR ranges. Includes reverse DNS where available.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.listIpBans(body));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bans/email/add',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_EMAIL_ADD),
|
||||
Validator('json', BanEmailRequest),
|
||||
OpenAPI({
|
||||
operationId: 'add_email_ban',
|
||||
summary: 'Add email ban',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Ban one or more email addresses from registering or creating accounts. Users attempting to use banned emails will be blocked.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const result = await adminService.banEmail(ctx.req.valid('json'), adminUserId, auditLogReason);
|
||||
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bans/email/remove',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_EMAIL_REMOVE),
|
||||
Validator('json', BanEmailRequest),
|
||||
OpenAPI({
|
||||
operationId: 'remove_email_ban',
|
||||
summary: 'Remove email ban',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Lift a previously applied email ban, allowing the address to be used for new registrations. Used for appeals or error correction.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const result = await adminService.unbanEmail(ctx.req.valid('json'), adminUserId, auditLogReason);
|
||||
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bans/email/check',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_EMAIL_CHECK),
|
||||
Validator('json', BanEmailRequest),
|
||||
OpenAPI({
|
||||
operationId: 'check_email_ban_status',
|
||||
summary: 'Check email ban status',
|
||||
responseSchema: BanCheckResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Query whether one or more email addresses are currently banned from registration. Returns the ban status and metadata.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.checkEmailBan(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bans/email/list',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_EMAIL_CHECK),
|
||||
Validator('json', ListBansRequest),
|
||||
OpenAPI({
|
||||
operationId: 'list_email_bans',
|
||||
summary: 'List email bans',
|
||||
responseSchema: ListEmailBansResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description: 'List currently banned email addresses.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.listEmailBans(body));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bans/phone/add',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_PHONE_ADD),
|
||||
Validator('json', BanPhoneRequest),
|
||||
OpenAPI({
|
||||
operationId: 'add_phone_ban',
|
||||
summary: 'Add phone ban',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Ban one or more phone numbers from account verification or SMS operations. Users attempting to use banned numbers will be blocked.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const result = await adminService.banPhone(ctx.req.valid('json'), adminUserId, auditLogReason);
|
||||
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bans/phone/remove',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_PHONE_REMOVE),
|
||||
Validator('json', BanPhoneRequest),
|
||||
OpenAPI({
|
||||
operationId: 'remove_phone_ban',
|
||||
summary: 'Remove phone ban',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Lift a previously applied phone ban, allowing the number to be used for verification again. Used for appeals or error correction.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const result = await adminService.unbanPhone(ctx.req.valid('json'), adminUserId, auditLogReason);
|
||||
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bans/phone/check',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_PHONE_CHECK),
|
||||
Validator('json', BanPhoneRequest),
|
||||
OpenAPI({
|
||||
operationId: 'check_phone_ban_status',
|
||||
summary: 'Check phone ban status',
|
||||
responseSchema: BanCheckResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Query whether one or more phone numbers are currently banned. Returns the ban status and metadata for verification or appeal purposes.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.checkPhoneBan(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bans/phone/list',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_PHONE_CHECK),
|
||||
Validator('json', ListBansRequest),
|
||||
OpenAPI({
|
||||
operationId: 'list_phone_bans',
|
||||
summary: 'List phone bans',
|
||||
responseSchema: ListPhoneBansResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description: 'List currently banned phone numbers.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.listPhoneBans(body));
|
||||
},
|
||||
);
|
||||
}
|
||||
129
packages/api/src/admin/controllers/BulkAdminController.tsx
Normal file
129
packages/api/src/admin/controllers/BulkAdminController.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
BulkAddGuildMembersRequest,
|
||||
BulkUpdateGuildFeaturesRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
import {BulkOperationResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {
|
||||
BulkScheduleUserDeletionRequest,
|
||||
BulkUpdateUserFlagsRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
|
||||
export function BulkAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/bulk/update-user-flags',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
|
||||
requireAdminACL(AdminACLs.BULK_UPDATE_USER_FLAGS),
|
||||
Validator('json', BulkUpdateUserFlagsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'bulk_update_user_flags',
|
||||
summary: 'Bulk update user flags',
|
||||
description:
|
||||
'Modify user flags (e.g., verified, bot, system) for multiple users in a single operation. Used for mass account updates or corrections.',
|
||||
responseSchema: BulkOperationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.bulkUpdateUserFlags(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bulk/update-guild-features',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
|
||||
requireAdminACL(AdminACLs.BULK_UPDATE_GUILD_FEATURES),
|
||||
Validator('json', BulkUpdateGuildFeaturesRequest),
|
||||
OpenAPI({
|
||||
operationId: 'bulk_update_guild_features',
|
||||
summary: 'Bulk update guild features',
|
||||
description:
|
||||
'Modify guild configuration and capabilities across multiple servers in a single operation. Includes feature flags, boost levels, and other guild attributes.',
|
||||
responseSchema: BulkOperationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.bulkUpdateGuildFeatures(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bulk/add-guild-members',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
|
||||
requireAdminACL(AdminACLs.BULK_ADD_GUILD_MEMBERS),
|
||||
Validator('json', BulkAddGuildMembersRequest),
|
||||
OpenAPI({
|
||||
operationId: 'bulk_add_guild_members',
|
||||
summary: 'Bulk add guild members',
|
||||
description:
|
||||
'Add multiple users to guilds in a batch operation. Bypasses normal invitation flow for administrative account setup.',
|
||||
responseSchema: BulkOperationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.bulkAddGuildMembers(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bulk/schedule-user-deletion',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
|
||||
requireAdminACL(AdminACLs.BULK_DELETE_USERS),
|
||||
Validator('json', BulkScheduleUserDeletionRequest),
|
||||
OpenAPI({
|
||||
operationId: 'schedule_bulk_user_deletion',
|
||||
summary: 'Schedule bulk user deletion',
|
||||
description:
|
||||
'Queue multiple users for deactivation/deletion with an optional grace period. Deletions are processed asynchronously according to retention policies.',
|
||||
responseSchema: BulkOperationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.bulkScheduleUserDeletion(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createReportID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {CsamEvidenceRetentionService} from '@fluxer/api/src/csam/CsamEvidenceRetentionService';
|
||||
import type {CsamLegalHoldService} from '@fluxer/api/src/csam/CsamLegalHoldService';
|
||||
import type {NcmecSubmissionService} from '@fluxer/api/src/csam/NcmecSubmissionService';
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {MS_PER_DAY} from '@fluxer/date_utils/src/DateConstants';
|
||||
import {
|
||||
LegalHoldRequest,
|
||||
LegalHoldResponse,
|
||||
NcmecSubmissionStatusResponse,
|
||||
NcmecSubmitResultResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {ReportIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
|
||||
export function ChildSafetyAdminController(app: HonoApp) {
|
||||
app.get(
|
||||
'/admin/reports/:report_id/legal-hold',
|
||||
requireAdminACL(AdminACLs.REPORT_VIEW),
|
||||
Validator('param', ReportIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_legal_hold_status',
|
||||
summary: 'Get legal hold status',
|
||||
description:
|
||||
'Retrieve the current legal hold status of a report. Indicates whether evidence is preserved for legal proceedings and the hold expiration date if set.',
|
||||
responseSchema: LegalHoldResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const service = ctx.get('csamLegalHoldService') as CsamLegalHoldService;
|
||||
const {report_id} = ctx.req.valid('param');
|
||||
const held = await service.isHeld(report_id.toString());
|
||||
return ctx.json({held});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/reports/:report_id/legal-hold',
|
||||
requireAdminACL(AdminACLs.REPORT_RESOLVE),
|
||||
Validator('param', ReportIdParam),
|
||||
Validator('json', LegalHoldRequest),
|
||||
OpenAPI({
|
||||
operationId: 'set_legal_hold_on_evidence',
|
||||
summary: 'Set legal hold on evidence',
|
||||
description:
|
||||
'Place a legal hold on report evidence to prevent automatic deletion. Used for compliance with legal investigations or regulatory requirements. Optionally specify an expiration date.',
|
||||
responseSchema: LegalHoldResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const service = ctx.get('csamLegalHoldService') as CsamLegalHoldService;
|
||||
const retentionService = ctx.get('csamEvidenceRetentionService') as CsamEvidenceRetentionService;
|
||||
const {report_id} = ctx.req.valid('param');
|
||||
const reportIdString = report_id.toString();
|
||||
const {expires_at} = ctx.req.valid('json');
|
||||
const expiresAt = expires_at ? new Date(expires_at) : undefined;
|
||||
await service.hold(reportIdString, expiresAt);
|
||||
const expiresParam = expiresAt ?? null;
|
||||
await retentionService.rescheduleForHold(report_id, expiresParam);
|
||||
return ctx.json({held: true});
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/admin/reports/:report_id/legal-hold',
|
||||
requireAdminACL(AdminACLs.REPORT_RESOLVE),
|
||||
Validator('param', ReportIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'release_legal_hold_on_evidence',
|
||||
summary: 'Release legal hold on evidence',
|
||||
description:
|
||||
'Remove a legal hold on a report. Evidence becomes eligible for automatic deletion per the retention policy. Used after legal matters are resolved.',
|
||||
responseSchema: LegalHoldResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const service = ctx.get('csamLegalHoldService') as CsamLegalHoldService;
|
||||
const retentionService = ctx.get('csamEvidenceRetentionService') as CsamEvidenceRetentionService;
|
||||
const {report_id} = ctx.req.valid('param');
|
||||
const reportIdString = report_id.toString();
|
||||
await service.release(reportIdString);
|
||||
const retentionMs = Math.max(1, Config.csam.evidenceRetentionDays) * MS_PER_DAY;
|
||||
await retentionService.rescheduleExpiration(report_id, new Date(Date.now() + retentionMs));
|
||||
return ctx.json({held: false});
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/admin/reports/:report_id/ncmec-status',
|
||||
requireAdminACL(AdminACLs.REPORT_VIEW),
|
||||
Validator('param', ReportIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_ncmec_submission_status',
|
||||
summary: 'Get NCMEC submission status',
|
||||
description:
|
||||
'Retrieve the submission status of a report to the National Center for Missing & Exploited Children. Shows whether the report has been submitted and the current status with NCMEC.',
|
||||
responseSchema: NcmecSubmissionStatusResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const service = ctx.get('ncmecSubmissionService') as NcmecSubmissionService;
|
||||
const {report_id} = ctx.req.valid('param');
|
||||
const reportId = createReportID(report_id);
|
||||
const status = await service.getSubmissionStatus(reportId);
|
||||
return ctx.json(status);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/reports/:report_id/ncmec-submit',
|
||||
requireAdminACL(AdminACLs.CSAM_SUBMIT_NCMEC),
|
||||
Validator('param', ReportIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'submit_report_to_ncmec',
|
||||
summary: 'Submit report to NCMEC',
|
||||
description:
|
||||
'Manually submit a child safety report to the National Center for Missing & Exploited Children. Requires explicit authorization and includes evidence packaging. Can only be done once per report.',
|
||||
responseSchema: NcmecSubmitResultResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const service = ctx.get('ncmecSubmissionService') as NcmecSubmissionService;
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const {report_id} = ctx.req.valid('param');
|
||||
const reportId = createReportID(report_id);
|
||||
const result = await service.submitToNcmec(reportId, adminUserId);
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
}
|
||||
75
packages/api/src/admin/controllers/CodesAdminController.tsx
Normal file
75
packages/api/src/admin/controllers/CodesAdminController.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import {ProductType} from '@fluxer/api/src/stripe/ProductRegistry';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {FeatureNotAvailableSelfHostedError} from '@fluxer/errors/src/domains/core/FeatureNotAvailableSelfHostedError';
|
||||
import {
|
||||
CodesResponse,
|
||||
GenerateGiftCodesRequest,
|
||||
type GiftProductType,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
|
||||
function trimTrailingSlash(value: string): string {
|
||||
return value.endsWith('/') ? value.slice(0, -1) : value;
|
||||
}
|
||||
|
||||
const giftDurations: Record<GiftProductType, number> = {
|
||||
[ProductType.GIFT_1_MONTH]: 1,
|
||||
[ProductType.GIFT_1_YEAR]: 12,
|
||||
};
|
||||
|
||||
export function CodesAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/codes/gift',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_CODE_GENERATION),
|
||||
requireAdminACL(AdminACLs.GIFT_CODES_GENERATE),
|
||||
Validator('json', GenerateGiftCodesRequest),
|
||||
OpenAPI({
|
||||
operationId: 'generate_gift_subscription_codes',
|
||||
summary: 'Generate gift subscription codes',
|
||||
description:
|
||||
'Create redeemable gift codes that grant subscription benefits (e.g. 1 month, 1 year, lifetime). Each code can be used once to activate benefits.',
|
||||
responseSchema: CodesResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
if (Config.instance.selfHosted) {
|
||||
throw new FeatureNotAvailableSelfHostedError();
|
||||
}
|
||||
|
||||
const {count, product_type} = ctx.req.valid('json');
|
||||
const durationMonths = giftDurations[product_type];
|
||||
const codes = await ctx.get('adminService').generateGiftCodes(count, durationMonths);
|
||||
const baseUrl = trimTrailingSlash(Config.endpoints.gift);
|
||||
return ctx.json({
|
||||
codes: codes.map((code) => `${baseUrl}/${code}`),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
174
packages/api/src/admin/controllers/DiscoveryAdminController.tsx
Normal file
174
packages/api/src/admin/controllers/DiscoveryAdminController.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createGuildID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {GuildDiscoveryRow} from '@fluxer/api/src/database/types/GuildDiscoveryTypes';
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {
|
||||
DiscoveryAdminListQuery,
|
||||
DiscoveryAdminRejectRequest,
|
||||
DiscoveryAdminRemoveRequest,
|
||||
DiscoveryAdminReviewRequest,
|
||||
DiscoveryApplicationResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
import {z} from 'zod';
|
||||
|
||||
function mapDiscoveryRowToResponse(row: GuildDiscoveryRow) {
|
||||
return {
|
||||
guild_id: row.guild_id.toString(),
|
||||
status: row.status,
|
||||
description: row.description,
|
||||
category_id: row.category_id,
|
||||
applied_at: row.applied_at.toISOString(),
|
||||
reviewed_at: row.reviewed_at?.toISOString() ?? null,
|
||||
review_reason: row.review_reason ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function DiscoveryAdminController(app: HonoApp) {
|
||||
app.get(
|
||||
'/admin/discovery/applications',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_LIST),
|
||||
requireAdminACL(AdminACLs.DISCOVERY_REVIEW),
|
||||
Validator('query', DiscoveryAdminListQuery),
|
||||
OpenAPI({
|
||||
operationId: 'list_discovery_applications',
|
||||
summary: 'List discovery applications',
|
||||
description: 'List discovery applications filtered by status. Requires DISCOVERY_REVIEW permission.',
|
||||
responseSchema: z.array(DiscoveryApplicationResponse),
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const query = ctx.req.valid('query');
|
||||
const discoveryService = ctx.get('discoveryService');
|
||||
|
||||
const rows = await discoveryService.listByStatus({
|
||||
status: query.status,
|
||||
limit: query.limit,
|
||||
});
|
||||
|
||||
return ctx.json(rows.map(mapDiscoveryRowToResponse));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/discovery/applications/:guild_id/approve',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_ACTION),
|
||||
requireAdminACL(AdminACLs.DISCOVERY_REVIEW),
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', DiscoveryAdminReviewRequest),
|
||||
OpenAPI({
|
||||
operationId: 'approve_discovery_application',
|
||||
summary: 'Approve discovery application',
|
||||
description: 'Approve a pending discovery application. Requires DISCOVERY_REVIEW permission.',
|
||||
responseSchema: DiscoveryApplicationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id} = ctx.req.valid('param');
|
||||
const guildId = createGuildID(guild_id);
|
||||
const data = ctx.req.valid('json');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const discoveryService = ctx.get('discoveryService');
|
||||
|
||||
const row = await discoveryService.approve({
|
||||
guildId,
|
||||
adminUserId,
|
||||
reason: data.reason,
|
||||
});
|
||||
|
||||
return ctx.json(mapDiscoveryRowToResponse(row));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/discovery/applications/:guild_id/reject',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_ACTION),
|
||||
requireAdminACL(AdminACLs.DISCOVERY_REVIEW),
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', DiscoveryAdminRejectRequest),
|
||||
OpenAPI({
|
||||
operationId: 'reject_discovery_application',
|
||||
summary: 'Reject discovery application',
|
||||
description: 'Reject a pending discovery application. Requires DISCOVERY_REVIEW permission.',
|
||||
responseSchema: DiscoveryApplicationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id} = ctx.req.valid('param');
|
||||
const guildId = createGuildID(guild_id);
|
||||
const data = ctx.req.valid('json');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const discoveryService = ctx.get('discoveryService');
|
||||
|
||||
const row = await discoveryService.reject({
|
||||
guildId,
|
||||
adminUserId,
|
||||
reason: data.reason,
|
||||
});
|
||||
|
||||
return ctx.json(mapDiscoveryRowToResponse(row));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/discovery/guilds/:guild_id/remove',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_ACTION),
|
||||
requireAdminACL(AdminACLs.DISCOVERY_REMOVE),
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', DiscoveryAdminRemoveRequest),
|
||||
OpenAPI({
|
||||
operationId: 'remove_from_discovery',
|
||||
summary: 'Remove guild from discovery',
|
||||
description: 'Remove an approved guild from discovery. Requires DISCOVERY_REMOVE permission.',
|
||||
responseSchema: DiscoveryApplicationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id} = ctx.req.valid('param');
|
||||
const guildId = createGuildID(guild_id);
|
||||
const data = ctx.req.valid('json');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const discoveryService = ctx.get('discoveryService');
|
||||
|
||||
const row = await discoveryService.remove({
|
||||
guildId,
|
||||
adminUserId,
|
||||
reason: data.reason,
|
||||
});
|
||||
|
||||
return ctx.json(mapDiscoveryRowToResponse(row));
|
||||
},
|
||||
);
|
||||
}
|
||||
100
packages/api/src/admin/controllers/GatewayAdminController.tsx
Normal file
100
packages/api/src/admin/controllers/GatewayAdminController.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createGuildID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {GetProcessMemoryStatsRequest} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
import {
|
||||
GuildMemoryStatsResponse,
|
||||
NodeStatsResponse,
|
||||
ReloadAllGuildsResponse,
|
||||
ReloadGuildsRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
|
||||
export function GatewayAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/gateway/memory-stats',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GATEWAY_MEMORY_STATS),
|
||||
Validator('json', GetProcessMemoryStatsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'get_guild_memory_statistics',
|
||||
summary: 'Get guild memory statistics',
|
||||
description: 'Returns heap and resident memory usage per guild. Requires GATEWAY_MEMORY_STATS permission.',
|
||||
responseSchema: GuildMemoryStatsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.getGuildMemoryStats(body.limit));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/gateway/reload-all',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GATEWAY_RELOAD),
|
||||
requireAdminACL(AdminACLs.GATEWAY_RELOAD_ALL),
|
||||
Validator('json', ReloadGuildsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'reload_all_specified_guilds',
|
||||
summary: 'Reload specified guilds',
|
||||
description:
|
||||
'Reconnects to the database and re-syncs guild state. Used for recovery after data inconsistencies. Requires GATEWAY_RELOAD_ALL permission.',
|
||||
responseSchema: ReloadAllGuildsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
const guildIds = body.guild_ids.map((id) => createGuildID(id));
|
||||
return ctx.json(await adminService.reloadAllGuilds(guildIds));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/admin/gateway/stats',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GATEWAY_MEMORY_STATS),
|
||||
OpenAPI({
|
||||
operationId: 'get_gateway_node_statistics',
|
||||
summary: 'Get gateway node statistics',
|
||||
description:
|
||||
'Returns uptime, process memory, and guild count. Used to monitor gateway health and performance. Requires GATEWAY_MEMORY_STATS permission.',
|
||||
responseSchema: NodeStatsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.getNodeStats());
|
||||
},
|
||||
);
|
||||
}
|
||||
441
packages/api/src/admin/controllers/GuildAdminController.tsx
Normal file
441
packages/api/src/admin/controllers/GuildAdminController.tsx
Normal file
@@ -0,0 +1,441 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createGuildID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import {AdminRateLimitConfigs} from '@fluxer/api/src/rate_limit_configs/AdminRateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
BanGuildMemberRequest,
|
||||
ClearGuildFieldsRequest,
|
||||
DeleteGuildRequest,
|
||||
ForceAddUserToGuildRequest,
|
||||
KickGuildMemberRequest,
|
||||
ListGuildMembersRequest,
|
||||
LookupGuildRequest,
|
||||
ReloadGuildRequest,
|
||||
ShutdownGuildRequest,
|
||||
TransferGuildOwnershipRequest,
|
||||
UpdateGuildFeaturesRequest,
|
||||
UpdateGuildNameRequest,
|
||||
UpdateGuildSettingsRequest,
|
||||
UpdateGuildVanityRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
import {
|
||||
GuildUpdateResponse,
|
||||
ListGuildEmojisResponse,
|
||||
ListGuildMembersResponse,
|
||||
ListGuildStickersResponse,
|
||||
LookupGuildResponse,
|
||||
SuccessResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
|
||||
export function GuildAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/guilds/lookup',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_LOOKUP),
|
||||
Validator('json', LookupGuildRequest),
|
||||
OpenAPI({
|
||||
operationId: 'lookup_guild',
|
||||
summary: 'Look up guild',
|
||||
description:
|
||||
'Retrieves complete guild details including metadata, settings, and statistics. Look up by guild ID or vanity slug. Requires GUILD_LOOKUP permission.',
|
||||
responseSchema: LookupGuildResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.lookupGuild(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/list-members',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_LIST_MEMBERS),
|
||||
Validator('json', ListGuildMembersRequest),
|
||||
OpenAPI({
|
||||
operationId: 'list_guild_members',
|
||||
summary: 'List guild members',
|
||||
description:
|
||||
'Lists all guild members with pagination. Returns member IDs, join dates, and roles. Requires GUILD_LIST_MEMBERS permission.',
|
||||
responseSchema: ListGuildMembersResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.listGuildMembers(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/admin/guilds/:guild_id/emojis',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.ASSET_PURGE),
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'list_guild_emojis',
|
||||
summary: 'List guild emojis',
|
||||
description:
|
||||
'Lists all custom emojis in a guild. Returns ID, name, and creation date. Used for asset inventory and purge operations. Requires ASSET_PURGE permission.',
|
||||
responseSchema: ListGuildEmojisResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
return ctx.json(await adminService.listGuildEmojis(guildId));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/admin/guilds/:guild_id/stickers',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.ASSET_PURGE),
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'list_guild_stickers',
|
||||
summary: 'List guild stickers',
|
||||
description:
|
||||
'Lists all stickers in a guild. Returns ID, name, and asset information. Used for asset inventory and purge operations. Requires ASSET_PURGE permission.',
|
||||
responseSchema: ListGuildStickersResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
return ctx.json(await adminService.listGuildStickers(guildId));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/clear-fields',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_UPDATE_SETTINGS),
|
||||
Validator('json', ClearGuildFieldsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'clear_guild_fields',
|
||||
summary: 'Clear guild fields',
|
||||
description:
|
||||
'Clears specified optional guild fields such as icon, banner, or description. Logged to audit log. Requires GUILD_UPDATE_SETTINGS permission.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
await adminService.clearGuildFields(ctx.req.valid('json'), adminUserId, auditLogReason);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/update-features',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_UPDATE_FEATURES),
|
||||
Validator('json', UpdateGuildFeaturesRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_guild_features',
|
||||
summary: 'Update guild features',
|
||||
description:
|
||||
'Enables or disables guild feature flags. Modifies verification levels and community settings. Changes are logged to audit log. Requires GUILD_UPDATE_FEATURES permission.',
|
||||
responseSchema: GuildUpdateResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
const guildId = createGuildID(body.guild_id);
|
||||
return ctx.json(
|
||||
await adminService.updateGuildFeatures({
|
||||
guildId,
|
||||
addFeatures: body.add_features,
|
||||
removeFeatures: body.remove_features,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/update-name',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_UPDATE_NAME),
|
||||
Validator('json', UpdateGuildNameRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_guild_name',
|
||||
summary: 'Update guild name',
|
||||
description:
|
||||
'Changes a guild name. Used for removing inappropriate names or correcting display issues. Logged to audit log. Requires GUILD_UPDATE_NAME permission.',
|
||||
responseSchema: GuildUpdateResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.updateGuildName(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/update-settings',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_UPDATE_SETTINGS),
|
||||
Validator('json', UpdateGuildSettingsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_guild_settings',
|
||||
summary: 'Update guild settings',
|
||||
description:
|
||||
'Modifies guild configuration including description, region, language and other settings. Logged to audit log. Requires GUILD_UPDATE_SETTINGS permission.',
|
||||
responseSchema: GuildUpdateResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.updateGuildSettings(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/transfer-ownership',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_TRANSFER_OWNERSHIP),
|
||||
Validator('json', TransferGuildOwnershipRequest),
|
||||
OpenAPI({
|
||||
operationId: 'transfer_guild_ownership',
|
||||
summary: 'Transfer guild ownership',
|
||||
description:
|
||||
'Transfers guild ownership to another user. Used when owner is inactive or for administrative recovery. Logged to audit log. Requires GUILD_TRANSFER_OWNERSHIP permission.',
|
||||
responseSchema: GuildUpdateResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.transferGuildOwnership(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/update-vanity',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_UPDATE_VANITY),
|
||||
Validator('json', UpdateGuildVanityRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_guild_vanity',
|
||||
summary: 'Update guild vanity',
|
||||
description:
|
||||
'Updates a guild vanity URL slug. Sets custom short URL and prevents duplicate slugs. Logged to audit log. Requires GUILD_UPDATE_VANITY permission.',
|
||||
responseSchema: GuildUpdateResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.updateGuildVanity(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/force-add-user',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_FORCE_ADD_MEMBER),
|
||||
Validator('json', ForceAddUserToGuildRequest),
|
||||
OpenAPI({
|
||||
operationId: 'force_add_user_to_guild',
|
||||
summary: 'Force add user to guild',
|
||||
description:
|
||||
'Forcefully adds a user to a guild. Bypasses normal invite flow for administrative account recovery. Logged to audit log. Requires GUILD_FORCE_ADD_MEMBER permission.',
|
||||
responseSchema: SuccessResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
return ctx.json(
|
||||
await adminService.forceAddUserToGuild({
|
||||
data: ctx.req.valid('json'),
|
||||
requestCache,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/ban-member',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_BAN_MEMBER),
|
||||
Validator('json', BanGuildMemberRequest),
|
||||
OpenAPI({
|
||||
operationId: 'ban_guild_member',
|
||||
summary: 'Ban guild member',
|
||||
description:
|
||||
'Permanently bans a user from a guild. Prevents user from joining. Logged to audit log. Requires GUILD_BAN_MEMBER permission.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
await adminService.banGuildMember(ctx.req.valid('json'), adminUserId, auditLogReason);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/kick-member',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_KICK_MEMBER),
|
||||
Validator('json', KickGuildMemberRequest),
|
||||
OpenAPI({
|
||||
operationId: 'kick_guild_member',
|
||||
summary: 'Kick guild member',
|
||||
description:
|
||||
'Temporarily removes a user from a guild. User can rejoin. Logged to audit log. Requires GUILD_KICK_MEMBER permission.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
await adminService.kickGuildMember(ctx.req.valid('json'), adminUserId, auditLogReason);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/reload',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_RELOAD),
|
||||
Validator('json', ReloadGuildRequest),
|
||||
OpenAPI({
|
||||
operationId: 'reload_guild',
|
||||
summary: 'Reload guild',
|
||||
description:
|
||||
'Reloads a single guild state from database. Used to recover from corruption or sync issues. Logged to audit log. Requires GUILD_RELOAD permission.',
|
||||
responseSchema: SuccessResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.reloadGuild(body.guild_id, adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/shutdown',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_SHUTDOWN),
|
||||
Validator('json', ShutdownGuildRequest),
|
||||
OpenAPI({
|
||||
operationId: 'shutdown_guild',
|
||||
summary: 'Shutdown guild',
|
||||
description:
|
||||
'Shuts down and unloads a guild from the gateway. Guild data remains in database. Used for emergency resource cleanup. Logged to audit log. Requires GUILD_SHUTDOWN permission.',
|
||||
responseSchema: SuccessResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.shutdownGuild(body.guild_id, adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/delete',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_DELETE),
|
||||
Validator('json', DeleteGuildRequest),
|
||||
OpenAPI({
|
||||
operationId: 'delete_guild',
|
||||
summary: 'Delete guild',
|
||||
description:
|
||||
'Permanently deletes a guild. Deletes all channels, messages, and settings. Irreversible operation. Logged to audit log. Requires GUILD_DELETE permission.',
|
||||
responseSchema: SuccessResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.deleteGuild(body.guild_id, adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import {InstanceConfigRepository} from '@fluxer/api/src/instance/InstanceConfigRepository';
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {InstanceConfigResponse, InstanceConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
|
||||
const instanceConfigRepository = new InstanceConfigRepository();
|
||||
|
||||
export function InstanceConfigAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/instance-config/get',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.INSTANCE_CONFIG_VIEW),
|
||||
OpenAPI({
|
||||
operationId: 'get_instance_config',
|
||||
summary: 'Get instance configuration',
|
||||
description:
|
||||
'Retrieves instance-wide configuration including manual review settings, webhooks, and SSO configuration. Returns current state and schedule information. Requires INSTANCE_CONFIG_VIEW permission.',
|
||||
responseSchema: InstanceConfigResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const config = await instanceConfigRepository.getInstanceConfig();
|
||||
const isActiveNow = instanceConfigRepository.isManualReviewActiveNow(config);
|
||||
const ssoConfig = await instanceConfigRepository.getSsoConfig();
|
||||
|
||||
return ctx.json({
|
||||
manual_review_enabled: config.manualReviewEnabled,
|
||||
manual_review_schedule_enabled: config.manualReviewScheduleEnabled,
|
||||
manual_review_schedule_start_hour_utc: config.manualReviewScheduleStartHourUtc,
|
||||
manual_review_schedule_end_hour_utc: config.manualReviewScheduleEndHourUtc,
|
||||
manual_review_active_now: isActiveNow,
|
||||
registration_alerts_webhook_url: config.registrationAlertsWebhookUrl,
|
||||
system_alerts_webhook_url: config.systemAlertsWebhookUrl,
|
||||
sso: {
|
||||
enabled: ssoConfig.enabled,
|
||||
display_name: ssoConfig.displayName,
|
||||
issuer: ssoConfig.issuer,
|
||||
authorization_url: ssoConfig.authorizationUrl,
|
||||
token_url: ssoConfig.tokenUrl,
|
||||
userinfo_url: ssoConfig.userInfoUrl,
|
||||
jwks_url: ssoConfig.jwksUrl,
|
||||
client_id: ssoConfig.clientId,
|
||||
client_secret_set: ssoConfig.clientSecretSet ?? false,
|
||||
scope: ssoConfig.scope,
|
||||
allowed_domains: ssoConfig.allowedEmailDomains,
|
||||
auto_provision: ssoConfig.autoProvision,
|
||||
redirect_uri: ssoConfig.redirectUri,
|
||||
},
|
||||
self_hosted: Config.instance.selfHosted,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/instance-config/update',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
|
||||
Validator('json', InstanceConfigUpdateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_instance_config',
|
||||
summary: 'Update instance configuration',
|
||||
description:
|
||||
'Updates instance configuration settings including manual review mode, webhook URLs, and SSO parameters. Changes apply immediately. Requires INSTANCE_CONFIG_UPDATE permission.',
|
||||
responseSchema: InstanceConfigResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const data = ctx.req.valid('json');
|
||||
|
||||
if (data.manual_review_enabled !== undefined) {
|
||||
await instanceConfigRepository.setManualReviewEnabled(data.manual_review_enabled);
|
||||
}
|
||||
|
||||
if (
|
||||
data.manual_review_schedule_enabled !== undefined ||
|
||||
data.manual_review_schedule_start_hour_utc !== undefined ||
|
||||
data.manual_review_schedule_end_hour_utc !== undefined
|
||||
) {
|
||||
const currentConfig = await instanceConfigRepository.getInstanceConfig();
|
||||
|
||||
const scheduleEnabled = data.manual_review_schedule_enabled ?? currentConfig.manualReviewScheduleEnabled;
|
||||
const startHour = data.manual_review_schedule_start_hour_utc ?? currentConfig.manualReviewScheduleStartHourUtc;
|
||||
const endHour = data.manual_review_schedule_end_hour_utc ?? currentConfig.manualReviewScheduleEndHourUtc;
|
||||
|
||||
await instanceConfigRepository.setManualReviewSchedule(scheduleEnabled, startHour, endHour);
|
||||
}
|
||||
|
||||
if (data.registration_alerts_webhook_url !== undefined) {
|
||||
await instanceConfigRepository.setRegistrationAlertsWebhookUrl(data.registration_alerts_webhook_url);
|
||||
}
|
||||
|
||||
if (data.system_alerts_webhook_url !== undefined) {
|
||||
await instanceConfigRepository.setSystemAlertsWebhookUrl(data.system_alerts_webhook_url);
|
||||
}
|
||||
|
||||
if (data.sso) {
|
||||
const sso = data.sso;
|
||||
await instanceConfigRepository.setSsoConfig({
|
||||
enabled: sso.enabled ?? undefined,
|
||||
displayName: sso.display_name ?? undefined,
|
||||
issuer: sso.issuer ?? undefined,
|
||||
authorizationUrl: sso.authorization_url ?? undefined,
|
||||
tokenUrl: sso.token_url ?? undefined,
|
||||
userInfoUrl: sso.userinfo_url ?? undefined,
|
||||
jwksUrl: sso.jwks_url ?? undefined,
|
||||
clientId: sso.client_id ?? undefined,
|
||||
clientSecret: sso.client_secret ?? undefined,
|
||||
scope: sso.scope ?? undefined,
|
||||
allowedEmailDomains: sso.allowed_domains ?? undefined,
|
||||
autoProvision: sso.auto_provision ?? undefined,
|
||||
redirectUri: sso.redirect_uri ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedConfig = await instanceConfigRepository.getInstanceConfig();
|
||||
const isActiveNow = instanceConfigRepository.isManualReviewActiveNow(updatedConfig);
|
||||
const updatedSso = await instanceConfigRepository.getSsoConfig();
|
||||
|
||||
return ctx.json({
|
||||
manual_review_enabled: updatedConfig.manualReviewEnabled,
|
||||
manual_review_schedule_enabled: updatedConfig.manualReviewScheduleEnabled,
|
||||
manual_review_schedule_start_hour_utc: updatedConfig.manualReviewScheduleStartHourUtc,
|
||||
manual_review_schedule_end_hour_utc: updatedConfig.manualReviewScheduleEndHourUtc,
|
||||
manual_review_active_now: isActiveNow,
|
||||
registration_alerts_webhook_url: updatedConfig.registrationAlertsWebhookUrl,
|
||||
system_alerts_webhook_url: updatedConfig.systemAlertsWebhookUrl,
|
||||
sso: {
|
||||
enabled: updatedSso.enabled,
|
||||
display_name: updatedSso.displayName,
|
||||
issuer: updatedSso.issuer,
|
||||
authorization_url: updatedSso.authorizationUrl,
|
||||
token_url: updatedSso.tokenUrl,
|
||||
userinfo_url: updatedSso.userInfoUrl,
|
||||
jwks_url: updatedSso.jwksUrl,
|
||||
client_id: updatedSso.clientId,
|
||||
client_secret_set: updatedSso.clientSecretSet ?? false,
|
||||
scope: updatedSso.scope,
|
||||
allowed_domains: updatedSso.allowedEmailDomains,
|
||||
auto_provision: updatedSso.autoProvision,
|
||||
redirect_uri: updatedSso.redirectUri,
|
||||
},
|
||||
self_hosted: Config.instance.selfHosted,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import {createDefaultLimitConfig} from '@fluxer/api/src/constants/LimitConfig';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
LIMIT_CATEGORY_LABELS,
|
||||
LIMIT_KEY_METADATA,
|
||||
LIMIT_KEYS,
|
||||
type LimitKey,
|
||||
} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||
import type {LimitConfigSnapshot, LimitRule} from '@fluxer/limits/src/LimitTypes';
|
||||
import {LimitConfigGetResponse, LimitConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
|
||||
function formatConfig(config: LimitConfigSnapshot) {
|
||||
const defaults = createDefaultLimitConfig({selfHosted: Config.instance.selfHosted});
|
||||
const defaultLimitsMap: Record<string, Record<LimitKey, number>> = {};
|
||||
for (const rule of defaults.rules) {
|
||||
defaultLimitsMap[rule.id] = rule.limits as Record<LimitKey, number>;
|
||||
}
|
||||
|
||||
return {
|
||||
limit_config: config,
|
||||
limit_config_json: JSON.stringify(config, null, 2),
|
||||
self_hosted: Config.instance.selfHosted,
|
||||
defaults: defaultLimitsMap,
|
||||
metadata: LIMIT_KEY_METADATA,
|
||||
categories: LIMIT_CATEGORY_LABELS,
|
||||
limit_keys: LIMIT_KEYS,
|
||||
};
|
||||
}
|
||||
|
||||
function trackModifiedFields(config: LimitConfigSnapshot): LimitConfigSnapshot {
|
||||
const defaults = createDefaultLimitConfig({selfHosted: Config.instance.selfHosted});
|
||||
const defaultRulesMap = buildRulesMap(defaults.rules);
|
||||
|
||||
const rulesWithTracking = config.rules.map((rule) => trackRuleModifiedFields(rule, defaultRulesMap));
|
||||
|
||||
return {
|
||||
...config,
|
||||
rules: rulesWithTracking,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRulesMap(rules: Array<LimitRule>): Map<string, LimitRule> {
|
||||
const map = new Map<string, LimitRule>();
|
||||
for (const rule of rules) {
|
||||
map.set(rule.id, rule);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function trackRuleModifiedFields(
|
||||
rule: LimitRule,
|
||||
defaultRulesMap: Map<string, LimitRule>,
|
||||
): LimitRule & {modifiedFields?: Array<LimitKey>} {
|
||||
const defaultRule = defaultRulesMap.get(rule.id);
|
||||
const fallbackDefault = defaultRule ?? defaultRulesMap.get('default');
|
||||
|
||||
if (!fallbackDefault) {
|
||||
return {
|
||||
...rule,
|
||||
modifiedFields: Object.keys(rule.limits) as Array<LimitKey>,
|
||||
};
|
||||
}
|
||||
|
||||
const modifiedFields = findModifiedLimits(rule.limits, fallbackDefault.limits);
|
||||
|
||||
return {
|
||||
...rule,
|
||||
modifiedFields: modifiedFields.length > 0 ? modifiedFields : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function findModifiedLimits(
|
||||
currentLimits: Partial<Record<LimitKey, number>>,
|
||||
defaultLimits: Partial<Record<LimitKey, number>>,
|
||||
): Array<LimitKey> {
|
||||
const modified: Array<LimitKey> = [];
|
||||
|
||||
for (const key of LIMIT_KEYS) {
|
||||
const currentValue = currentLimits[key];
|
||||
const defaultValue = defaultLimits[key];
|
||||
|
||||
if (currentValue !== undefined && currentValue !== defaultValue) {
|
||||
modified.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
export function LimitConfigAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/limit-config/get',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW),
|
||||
OpenAPI({
|
||||
operationId: 'get_limit_config',
|
||||
summary: 'Get limit configuration',
|
||||
description:
|
||||
'Retrieves rate limit configuration including message limits, upload limits, and request throttles. Shows defaults, metadata, and any modifications from defaults. Requires INSTANCE_LIMIT_CONFIG_VIEW permission.',
|
||||
responseSchema: LimitConfigGetResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const limitConfigService = ctx.get('limitConfigService') as LimitConfigService;
|
||||
const snapshot = limitConfigService.getConfigSnapshot();
|
||||
return ctx.json(formatConfig(snapshot));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/limit-config/update',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE),
|
||||
Validator('json', LimitConfigUpdateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_limit_config',
|
||||
summary: 'Update limit configuration',
|
||||
description:
|
||||
'Updates rate limit configuration including message throughput, upload sizes, and request throttles. Changes apply immediately to all new operations. Requires INSTANCE_LIMIT_CONFIG_UPDATE permission.',
|
||||
responseSchema: LimitConfigGetResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const limitConfigService = ctx.get('limitConfigService') as LimitConfigService;
|
||||
const data = ctx.req.valid('json');
|
||||
const normalized: LimitConfigSnapshot = {
|
||||
...data.limit_config,
|
||||
traitDefinitions: data.limit_config.traitDefinitions ?? [],
|
||||
};
|
||||
|
||||
const withTracking = trackModifiedFields(normalized);
|
||||
await limitConfigService.updateConfig(withTracking);
|
||||
return ctx.json(formatConfig(limitConfigService.getConfigSnapshot()));
|
||||
},
|
||||
);
|
||||
}
|
||||
176
packages/api/src/admin/controllers/MessageAdminController.tsx
Normal file
176
packages/api/src/admin/controllers/MessageAdminController.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
DeleteAllUserMessagesRequest,
|
||||
DeleteAllUserMessagesResponse,
|
||||
DeleteMessageRequest,
|
||||
LookupMessageByAttachmentRequest,
|
||||
LookupMessageRequest,
|
||||
MessageShredRequest,
|
||||
MessageShredResponse,
|
||||
MessageShredStatusRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminMessageSchemas';
|
||||
import {
|
||||
DeleteMessageResponse,
|
||||
LookupMessageResponse,
|
||||
MessageShredStatusResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
|
||||
export function MessageAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/messages/lookup',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_LOOKUP),
|
||||
Validator('json', LookupMessageRequest),
|
||||
OpenAPI({
|
||||
operationId: 'lookup_message',
|
||||
summary: 'Look up message details',
|
||||
description:
|
||||
'Retrieves complete message details including content, attachments, edits, and metadata. Look up by message ID and channel. Requires MESSAGE_LOOKUP permission.',
|
||||
responseSchema: LookupMessageResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.lookupMessage(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/messages/lookup-by-attachment',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_LOOKUP),
|
||||
Validator('json', LookupMessageByAttachmentRequest),
|
||||
OpenAPI({
|
||||
operationId: 'lookup_message_by_attachment',
|
||||
summary: 'Look up message by attachment',
|
||||
description:
|
||||
'Finds and retrieves message containing a specific attachment by ID. Used to locate messages with sensitive or illegal content. Requires MESSAGE_LOOKUP permission.',
|
||||
responseSchema: LookupMessageResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.lookupMessageByAttachment(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/messages/delete',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_DELETE),
|
||||
Validator('json', DeleteMessageRequest),
|
||||
OpenAPI({
|
||||
operationId: 'delete_message',
|
||||
summary: 'Delete single message',
|
||||
description:
|
||||
'Deletes a single message permanently. Used for removing inappropriate or harmful content. Logged to audit log. Requires MESSAGE_DELETE permission.',
|
||||
responseSchema: DeleteMessageResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.deleteMessage(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/messages/shred',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_SHRED),
|
||||
Validator('json', MessageShredRequest),
|
||||
OpenAPI({
|
||||
operationId: 'queue_message_shred',
|
||||
summary: 'Queue message shred operation',
|
||||
description:
|
||||
'Queues bulk message shredding with attachment deletion. Returns job ID to track progress asynchronously. Used for large-scale content removal. Requires MESSAGE_SHRED permission.',
|
||||
responseSchema: MessageShredResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.queueMessageShred(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/messages/delete-all',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_DELETE_ALL),
|
||||
Validator('json', DeleteAllUserMessagesRequest),
|
||||
OpenAPI({
|
||||
operationId: 'delete_all_user_messages',
|
||||
summary: 'Delete all user messages',
|
||||
description:
|
||||
'Deletes all messages from a specific user across all channels. Permanent operation used for account suspension or policy violation. Requires MESSAGE_DELETE_ALL permission.',
|
||||
responseSchema: DeleteAllUserMessagesResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.deleteAllUserMessages(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/messages/shred-status',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_SHRED),
|
||||
Validator('json', MessageShredStatusRequest),
|
||||
OpenAPI({
|
||||
operationId: 'get_message_shred_status',
|
||||
summary: 'Get message shred status',
|
||||
description:
|
||||
'Polls status of a queued message shred operation. Returns progress percentage and whether the job is complete. Requires MESSAGE_SHRED permission.',
|
||||
responseSchema: MessageShredStatusResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.getMessageShredStatus(body.job_id));
|
||||
},
|
||||
);
|
||||
}
|
||||
137
packages/api/src/admin/controllers/ReportAdminController.tsx
Normal file
137
packages/api/src/admin/controllers/ReportAdminController.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createReportID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
ListReportsRequest,
|
||||
ListReportsResponse,
|
||||
ReportAdminResponseSchema,
|
||||
ResolveReportRequest,
|
||||
ResolveReportResponse,
|
||||
SearchReportsRequest,
|
||||
SearchReportsResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {ReportIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
|
||||
export function ReportAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/reports/list',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.REPORT_VIEW),
|
||||
Validator('json', ListReportsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'list_reports',
|
||||
summary: 'List reports',
|
||||
description:
|
||||
'Lists user and content reports with optional status filtering and pagination. Requires REPORT_VIEW permission.',
|
||||
responseSchema: ListReportsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {status, limit, offset} = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.listReports(status ?? 0, limit, offset));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/admin/reports/:report_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.REPORT_VIEW),
|
||||
Validator('param', ReportIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_report',
|
||||
summary: 'Get report details',
|
||||
description:
|
||||
'Retrieves detailed information about a specific report including content, reporter, and reason. Requires REPORT_VIEW permission.',
|
||||
responseSchema: ReportAdminResponseSchema,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {report_id} = ctx.req.valid('param');
|
||||
const report = await adminService.getReport(createReportID(report_id));
|
||||
return ctx.json(report);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/reports/resolve',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.REPORT_RESOLVE),
|
||||
Validator('json', ResolveReportRequest),
|
||||
OpenAPI({
|
||||
operationId: 'resolve_report',
|
||||
summary: 'Resolve report',
|
||||
description:
|
||||
'Closes and resolves a report with optional public comment. Marks report as handled and creates audit log entry. Requires REPORT_RESOLVE permission.',
|
||||
responseSchema: ResolveReportResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const {report_id, public_comment} = ctx.req.valid('json');
|
||||
return ctx.json(
|
||||
await adminService.resolveReport(
|
||||
createReportID(report_id),
|
||||
adminUserId,
|
||||
public_comment || null,
|
||||
auditLogReason,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/reports/search',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.REPORT_VIEW),
|
||||
Validator('json', SearchReportsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'search_reports',
|
||||
summary: 'Search reports',
|
||||
description:
|
||||
'Searches and filters reports by user, content, reason, and status criteria. Supports full-text search and advanced filtering. Requires REPORT_VIEW permission.',
|
||||
responseSchema: SearchReportsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.searchReports(body));
|
||||
},
|
||||
);
|
||||
}
|
||||
128
packages/api/src/admin/controllers/SearchAdminController.tsx
Normal file
128
packages/api/src/admin/controllers/SearchAdminController.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {SearchGuildsRequest} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
import {
|
||||
GetIndexRefreshStatusRequest,
|
||||
IndexRefreshStatusResponse,
|
||||
RefreshSearchIndexRequest,
|
||||
RefreshSearchIndexResponse,
|
||||
SearchGuildsResponse,
|
||||
SearchUsersResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {SearchUsersRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
|
||||
export function SearchAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/guilds/search',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_LOOKUP),
|
||||
Validator('json', SearchGuildsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'search_guilds',
|
||||
summary: 'Search guilds',
|
||||
description:
|
||||
'Searches guilds by name, ID, and other criteria. Supports full-text search and filtering. Requires GUILD_LOOKUP permission.',
|
||||
responseSchema: SearchGuildsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.searchGuilds(body));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/search',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.USER_LOOKUP),
|
||||
Validator('json', SearchUsersRequest),
|
||||
OpenAPI({
|
||||
operationId: 'search_users',
|
||||
summary: 'Search users',
|
||||
description:
|
||||
'Searches users by username, email, ID, and other criteria. Supports full-text search and filtering by account status. Requires USER_LOOKUP permission.',
|
||||
responseSchema: SearchUsersResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.searchUsers(body));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/search/refresh-index',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_LOOKUP),
|
||||
Validator('json', RefreshSearchIndexRequest),
|
||||
OpenAPI({
|
||||
operationId: 'refresh_search_index',
|
||||
summary: 'Refresh search index',
|
||||
description:
|
||||
'Trigger full or partial search index rebuild. Creates background job to reindex guilds and users. Returns job ID for status tracking. Requires GUILD_LOOKUP permission.',
|
||||
responseSchema: RefreshSearchIndexResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.refreshSearchIndex(body, adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/search/refresh-status',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_LOOKUP),
|
||||
Validator('json', GetIndexRefreshStatusRequest),
|
||||
OpenAPI({
|
||||
operationId: 'get_search_index_refresh_status',
|
||||
summary: 'Get search index refresh status',
|
||||
description:
|
||||
'Polls status of a search index refresh job. Returns completion percentage and current phase. Requires GUILD_LOOKUP permission.',
|
||||
responseSchema: IndexRefreshStatusResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.getIndexRefreshStatus(body.job_id));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {FeatureNotAvailableSelfHostedError} from '@fluxer/errors/src/domains/core/FeatureNotAvailableSelfHostedError';
|
||||
import {
|
||||
AddSnowflakeReservationRequest,
|
||||
DeleteSnowflakeReservationRequest,
|
||||
ListSnowflakeReservationsResponse,
|
||||
SuccessResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
|
||||
export function SnowflakeReservationAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/snowflake-reservations/list',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.INSTANCE_SNOWFLAKE_RESERVATION_VIEW),
|
||||
OpenAPI({
|
||||
operationId: 'list_snowflake_reservations',
|
||||
summary: 'List snowflake reservations',
|
||||
responseSchema: ListSnowflakeReservationsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Lists all reserved snowflake ID ranges. Shows ranges reserved for future entities and their allocation status. Requires INSTANCE_SNOWFLAKE_RESERVATION_VIEW permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
if (Config.instance.selfHosted) {
|
||||
throw new FeatureNotAvailableSelfHostedError();
|
||||
}
|
||||
|
||||
const adminService = ctx.get('adminService');
|
||||
const reservations = await adminService.listSnowflakeReservations();
|
||||
|
||||
return ctx.json({
|
||||
reservations,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/snowflake-reservations/add',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.INSTANCE_SNOWFLAKE_RESERVATION_MANAGE),
|
||||
Validator('json', AddSnowflakeReservationRequest),
|
||||
OpenAPI({
|
||||
operationId: 'add_snowflake_reservation',
|
||||
summary: 'Add snowflake reservation',
|
||||
responseSchema: SuccessResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Reserves a snowflake ID range for future allocation. Creates audit log entry. Requires INSTANCE_SNOWFLAKE_RESERVATION_MANAGE permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
if (Config.instance.selfHosted) {
|
||||
throw new FeatureNotAvailableSelfHostedError();
|
||||
}
|
||||
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const data = ctx.req.valid('json');
|
||||
|
||||
await adminService.setSnowflakeReservation(data, adminUserId, auditLogReason);
|
||||
return ctx.json({success: true});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/snowflake-reservations/delete',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.INSTANCE_SNOWFLAKE_RESERVATION_MANAGE),
|
||||
Validator('json', DeleteSnowflakeReservationRequest),
|
||||
OpenAPI({
|
||||
operationId: 'delete_snowflake_reservation',
|
||||
summary: 'Delete snowflake reservation',
|
||||
responseSchema: SuccessResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Removes a snowflake ID reservation range. Creates audit log entry. Requires INSTANCE_SNOWFLAKE_RESERVATION_MANAGE permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
if (Config.instance.selfHosted) {
|
||||
throw new FeatureNotAvailableSelfHostedError();
|
||||
}
|
||||
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const data = ctx.req.valid('json');
|
||||
|
||||
await adminService.deleteSnowflakeReservation(data, adminUserId, auditLogReason);
|
||||
return ctx.json({success: true});
|
||||
},
|
||||
);
|
||||
}
|
||||
129
packages/api/src/admin/controllers/SystemDmAdminController.tsx
Normal file
129
packages/api/src/admin/controllers/SystemDmAdminController.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AdminService} from '@fluxer/api/src/admin/AdminService';
|
||||
import {createGuildID, type GuildID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
CreateSystemDmJobRequest,
|
||||
ListSystemDmJobsResponse,
|
||||
SystemDmJobResponse,
|
||||
SystemDmJobsQueryRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {JobIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
|
||||
function parseGuildIds(ids: Array<bigint> | undefined): Array<GuildID> {
|
||||
if (!ids) {
|
||||
return [];
|
||||
}
|
||||
return ids.map((id) => createGuildID(id));
|
||||
}
|
||||
|
||||
export function SystemDmAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/system-dm-jobs',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.SYSTEM_DM_SEND),
|
||||
Validator('json', CreateSystemDmJobRequest),
|
||||
OpenAPI({
|
||||
operationId: 'create_system_dm_job',
|
||||
summary: 'Create system DM job',
|
||||
responseSchema: SystemDmJobResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Creates a system DM broadcast job to send messages to users matching registration date criteria. Supports date range filtering and guild exclusions. Requires SYSTEM_DM_SEND permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService') as AdminService;
|
||||
const adminUserId = ctx.get('adminUserId') as UserID;
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const payload = ctx.req.valid('json');
|
||||
|
||||
const job = await adminService.createSystemDmJob(
|
||||
{
|
||||
content: payload.content,
|
||||
registrationStart: payload.registration_start ? new Date(payload.registration_start) : undefined,
|
||||
registrationEnd: payload.registration_end ? new Date(payload.registration_end) : undefined,
|
||||
excludedGuildIds: parseGuildIds(payload.excluded_guild_ids ?? []),
|
||||
},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
);
|
||||
return ctx.json(job);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/admin/system-dm-jobs',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.SYSTEM_DM_SEND),
|
||||
Validator('query', SystemDmJobsQueryRequest),
|
||||
OpenAPI({
|
||||
operationId: 'list_system_dm_jobs',
|
||||
summary: 'List system DM jobs',
|
||||
responseSchema: ListSystemDmJobsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Lists system DM broadcast jobs with pagination. Shows job status, creation time, and content preview. Requires SYSTEM_DM_SEND permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService') as AdminService;
|
||||
const {limit, before_job_id} = ctx.req.valid('query');
|
||||
|
||||
const result = await adminService.listSystemDmJobs(limit, before_job_id ?? undefined);
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/system-dm-jobs/:job_id/approve',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.SYSTEM_DM_SEND),
|
||||
Validator('param', JobIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'approve_system_dm_job',
|
||||
summary: 'Approve system DM job',
|
||||
responseSchema: SystemDmJobResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Approves and queues a system DM job for immediate execution. Creates audit log entry. Job will begin sending messages to target users. Requires SYSTEM_DM_SEND permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService') as AdminService;
|
||||
const adminUserId = ctx.get('adminUserId') as UserID;
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const {job_id: jobId} = ctx.req.valid('param');
|
||||
|
||||
const job = await adminService.approveSystemDmJob(jobId, adminUserId, auditLogReason);
|
||||
return ctx.json(job);
|
||||
},
|
||||
);
|
||||
}
|
||||
693
packages/api/src/admin/controllers/UserAdminController.tsx
Normal file
693
packages/api/src/admin/controllers/UserAdminController.tsx
Normal file
@@ -0,0 +1,693 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mapUserToAdminResponse} from '@fluxer/api/src/admin/models/UserTypes';
|
||||
import {createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {ListUserGuildsRequest, ListUserGuildsResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
import {
|
||||
AdminUsersMeResponse,
|
||||
CancelBulkMessageDeletionRequest,
|
||||
ChangeDobRequest,
|
||||
ChangeEmailRequest,
|
||||
ChangeUsernameRequest,
|
||||
ClearUserFieldsRequest,
|
||||
DisableForSuspiciousActivityRequest,
|
||||
DisableMfaRequest,
|
||||
ListUserChangeLogRequest,
|
||||
ListUserChangeLogResponseSchema,
|
||||
ListUserDmChannelsRequest,
|
||||
ListUserDmChannelsResponse,
|
||||
ListUserSessionsRequest,
|
||||
ListUserSessionsResponse,
|
||||
LookupUserRequest,
|
||||
LookupUserResponse,
|
||||
ScheduleAccountDeletionRequest,
|
||||
SendPasswordResetRequest,
|
||||
SetUserAclsRequest,
|
||||
SetUserBotStatusRequest,
|
||||
SetUserSystemStatusRequest,
|
||||
SetUserTraitsRequest,
|
||||
TempBanUserRequest,
|
||||
TerminateSessionsRequest,
|
||||
TerminateSessionsResponse,
|
||||
UnlinkPhoneRequest,
|
||||
UpdateSuspiciousActivityFlagsRequest,
|
||||
UpdateUserFlagsRequest,
|
||||
UserMutationResponse,
|
||||
VerifyUserEmailRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
|
||||
export function UserAdminController(app: HonoApp) {
|
||||
app.get(
|
||||
'/admin/users/me',
|
||||
requireAdminACL(AdminACLs.AUTHENTICATE),
|
||||
OpenAPI({
|
||||
operationId: 'get_authenticated_admin_user',
|
||||
summary: 'Get authenticated admin user',
|
||||
responseSchema: AdminUsersMeResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Get profile of currently authenticated admin user. Returns admin permissions, roles, and metadata. Requires AUTHENTICATE permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminUser = ctx.get('user');
|
||||
const cacheService = ctx.get('cacheService');
|
||||
return ctx.json({
|
||||
user: await mapUserToAdminResponse(adminUser, cacheService),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/lookup',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.USER_LOOKUP),
|
||||
Validator('json', LookupUserRequest),
|
||||
OpenAPI({
|
||||
operationId: 'lookup_user',
|
||||
summary: 'Lookup user',
|
||||
responseSchema: LookupUserResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Look up detailed user profile by ID, username, email, or phone. Returns account status, permissions, and metadata. Requires USER_LOOKUP permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.lookupUser(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/list-guilds',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.USER_LIST_GUILDS),
|
||||
Validator('json', ListUserGuildsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'list_user_guilds',
|
||||
summary: 'List user guilds',
|
||||
responseSchema: ListUserGuildsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'List all guilds a user is a member of. Shows roles and join dates. Requires USER_LIST_GUILDS permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.listUserGuilds(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/list-dm-channels',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.USER_LIST_DM_CHANNELS),
|
||||
Validator('json', ListUserDmChannelsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'list_user_dm_channels',
|
||||
summary: 'List user DM channels',
|
||||
responseSchema: ListUserDmChannelsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'List historical one-to-one DM channels for a user with cursor pagination. Requires USER_LIST_DM_CHANNELS permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.listUserDmChannels(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/disable-mfa',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_MFA),
|
||||
Validator('json', DisableMfaRequest),
|
||||
OpenAPI({
|
||||
operationId: 'disable_user_mfa',
|
||||
summary: 'Disable user MFA',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Disable two-factor authentication for user account. Removes all authenticators. Creates audit log entry. Requires USER_UPDATE_MFA permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
await adminService.disableMfa(ctx.req.valid('json'), adminUserId, auditLogReason);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/clear-fields',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_PROFILE),
|
||||
Validator('json', ClearUserFieldsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'clear_user_fields',
|
||||
summary: 'Clear user fields',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Clear or reset user profile fields such as bio, avatar, or status. Creates audit log entry. Requires USER_UPDATE_PROFILE permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.clearUserFields(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/set-bot-status',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
|
||||
Validator('json', SetUserBotStatusRequest),
|
||||
OpenAPI({
|
||||
operationId: 'set_user_bot_status',
|
||||
summary: 'Set user bot status',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Mark or unmark a user account as a bot. Controls bot badge visibility and API permissions. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.setUserBotStatus(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/set-system-status',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
|
||||
Validator('json', SetUserSystemStatusRequest),
|
||||
OpenAPI({
|
||||
operationId: 'set_user_system_status',
|
||||
summary: 'Set user system status',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Mark or unmark a user as a system account. System accounts have special permissions for automated operations. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.setUserSystemStatus(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/verify-email',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
|
||||
Validator('json', VerifyUserEmailRequest),
|
||||
OpenAPI({
|
||||
operationId: 'verify_user_email',
|
||||
summary: 'Verify user email',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Manually verify user email address without requiring confirmation link. Bypasses email verification requirement. Creates audit log entry. Requires USER_UPDATE_EMAIL permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.verifyUserEmail(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/send-password-reset',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
|
||||
Validator('json', SendPasswordResetRequest),
|
||||
OpenAPI({
|
||||
operationId: 'send_password_reset',
|
||||
summary: 'Send password reset',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Send password reset email to user with reset link. User must use link within expiry window. Creates audit log entry. Requires USER_UPDATE_EMAIL permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
await adminService.sendPasswordReset(ctx.req.valid('json'), adminUserId, auditLogReason);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/change-username',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_USERNAME),
|
||||
Validator('json', ChangeUsernameRequest),
|
||||
OpenAPI({
|
||||
operationId: 'change_user_username',
|
||||
summary: 'Change user username',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Change user username. New username must meet requirements and be unique. Creates audit log entry. Requires USER_UPDATE_USERNAME permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.changeUsername(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/change-email',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
|
||||
Validator('json', ChangeEmailRequest),
|
||||
OpenAPI({
|
||||
operationId: 'change_user_email',
|
||||
summary: 'Change user email',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Change user email address. New email must be valid and unique. Marks email as verified. Creates audit log entry. Requires USER_UPDATE_EMAIL permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.changeEmail(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/terminate-sessions',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
|
||||
Validator('json', TerminateSessionsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'terminate_user_sessions',
|
||||
summary: 'Terminate user sessions',
|
||||
responseSchema: TerminateSessionsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Terminate all active user sessions across devices. Forces user to re-authenticate on next connection. Creates audit log entry. Requires USER_UPDATE_FLAGS permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.terminateSessions(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/temp-ban',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_TEMP_BAN),
|
||||
Validator('json', TempBanUserRequest),
|
||||
OpenAPI({
|
||||
operationId: 'temp_ban_user',
|
||||
summary: 'Temp ban user',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Apply temporary ban to user account for specified duration. Prevents login and guild operations. Automatically lifts after expiry. Creates audit log entry. Requires USER_TEMP_BAN permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.tempBanUser(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/unban',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_TEMP_BAN),
|
||||
Validator('json', DisableMfaRequest),
|
||||
OpenAPI({
|
||||
operationId: 'unban_user',
|
||||
summary: 'Unban user',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Immediately remove temporary ban from user account. User can log in and access guilds again. Creates audit log entry. Requires USER_TEMP_BAN permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.unbanUser(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/schedule-deletion',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_DELETE),
|
||||
Validator('json', ScheduleAccountDeletionRequest),
|
||||
OpenAPI({
|
||||
operationId: 'schedule_account_deletion',
|
||||
summary: 'Schedule account deletion',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Schedule user account for deletion after grace period. Account will be fully deleted with all content unless cancellation is executed. Creates audit log entry. Requires USER_DELETE permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.scheduleAccountDeletion(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/cancel-deletion',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_DELETE),
|
||||
Validator('json', DisableMfaRequest),
|
||||
OpenAPI({
|
||||
operationId: 'cancel_account_deletion',
|
||||
summary: 'Cancel account deletion',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Cancel a scheduled account deletion. User account restoration prevents data loss. Creates audit log entry. Requires USER_DELETE permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.cancelAccountDeletion(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/cancel-bulk-message-deletion',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_CANCEL_BULK_MESSAGE_DELETION),
|
||||
Validator('json', CancelBulkMessageDeletionRequest),
|
||||
OpenAPI({
|
||||
operationId: 'cancel_bulk_message_deletion',
|
||||
summary: 'Cancel bulk message deletion',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Cancel a scheduled bulk message deletion job for a user. Prevents deletion of user messages across guilds. Creates audit log entry. Requires USER_CANCEL_BULK_MESSAGE_DELETION permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.cancelBulkMessageDeletion(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/set-acls',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.ACL_SET_USER),
|
||||
Validator('json', SetUserAclsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'set_user_acls',
|
||||
summary: 'Set user ACLs',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Grant or revoke admin ACL permissions to user. Controls admin capabilities and panel access. Creates audit log entry. Requires ACL_SET_USER permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.setUserAcls(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/set-traits',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_TRAITS),
|
||||
Validator('json', SetUserTraitsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'set_user_traits',
|
||||
summary: 'Set user traits',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Set or update user trait attributes and profile metadata. Traits customize user display and features. Creates audit log entry. Requires USER_UPDATE_TRAITS permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.setUserTraits(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/update-flags',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
|
||||
Validator('json', UpdateUserFlagsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_user_flags',
|
||||
summary: 'Update user flags',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Add or remove user flags to control account features and restrictions. Flags determine verification status and special properties. Creates audit log entry. Requires USER_UPDATE_FLAGS permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
const userId = createUserID(body.user_id);
|
||||
const addFlags = body.add_flags.map((flag) => BigInt(flag));
|
||||
const removeFlags = body.remove_flags.map((flag) => BigInt(flag));
|
||||
return ctx.json(
|
||||
await adminService.updateUserFlags({
|
||||
userId,
|
||||
data: {addFlags, removeFlags},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/unlink-phone',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_PHONE),
|
||||
Validator('json', UnlinkPhoneRequest),
|
||||
OpenAPI({
|
||||
operationId: 'unlink_user_phone',
|
||||
summary: 'Unlink user phone',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Remove phone number from user account. Unlinks any two-factor authentication that depends on phone. Creates audit log entry. Requires USER_UPDATE_PHONE permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.unlinkPhone(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/change-dob',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_DOB),
|
||||
Validator('json', ChangeDobRequest),
|
||||
OpenAPI({
|
||||
operationId: 'change_user_dob',
|
||||
summary: 'Change user DOB',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Update user date of birth. May affect age-restricted content access. Creates audit log entry. Requires USER_UPDATE_DOB permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.changeDob(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/update-suspicious-activity-flags',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_SUSPICIOUS_ACTIVITY),
|
||||
Validator('json', UpdateSuspiciousActivityFlagsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_suspicious_activity_flags',
|
||||
summary: 'Update suspicious activity flags',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Flag user as suspicious for account abuse, fraud, or policy violations. Enables enforcement actions and rate limiting. Creates audit log entry. Requires USER_UPDATE_SUSPICIOUS_ACTIVITY permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(
|
||||
await adminService.updateSuspiciousActivityFlags(ctx.req.valid('json'), adminUserId, auditLogReason),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/disable-suspicious',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_DISABLE_SUSPICIOUS),
|
||||
Validator('json', DisableForSuspiciousActivityRequest),
|
||||
OpenAPI({
|
||||
operationId: 'disable_user_suspicious',
|
||||
summary: 'Disable user for suspicious activity',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Disable user account due to suspicious activity or abuse. Account is locked pending review. User cannot access services. Creates audit log entry. Requires USER_DISABLE_SUSPICIOUS permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(
|
||||
await adminService.disableForSuspiciousActivity(ctx.req.valid('json'), adminUserId, auditLogReason),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/list-sessions',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_LIST_SESSIONS),
|
||||
Validator('json', ListUserSessionsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'list_user_sessions',
|
||||
summary: 'List user sessions',
|
||||
responseSchema: ListUserSessionsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'List all active user sessions across devices. Shows device info, IP, last activity, and creation time. Requires USER_LIST_SESSIONS permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.listUserSessions(body.user_id, adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/change-log',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.USER_LOOKUP),
|
||||
Validator('json', ListUserChangeLogRequest),
|
||||
OpenAPI({
|
||||
operationId: 'get_user_change_log',
|
||||
summary: 'Get user change log',
|
||||
responseSchema: ListUserChangeLogResponseSchema,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Retrieve complete change log history for a user. Shows all profile modifications, admin actions, and account changes with timestamps. Requires USER_LOOKUP permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.listUserChangeLog(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {VisionarySlotRepository} from '@fluxer/api/src/user/repositories/VisionarySlotRepository';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {FeatureNotAvailableSelfHostedError} from '@fluxer/errors/src/domains/core/FeatureNotAvailableSelfHostedError';
|
||||
import {
|
||||
ExpandVisionarySlotsRequest,
|
||||
ListVisionarySlotsResponse,
|
||||
ReserveVisionarySlotRequest,
|
||||
ShrinkVisionarySlotsRequest,
|
||||
SwapVisionarySlotsRequest,
|
||||
VisionarySlotOperationResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
|
||||
export function VisionarySlotAdminController(app: HonoApp) {
|
||||
app.get(
|
||||
'/admin/visionary-slots',
|
||||
RateLimitMiddleware(RateLimitConfigs.VISIONARY_SLOT_OPERATION),
|
||||
requireAdminACL(AdminACLs.VISIONARY_SLOT_VIEW),
|
||||
OpenAPI({
|
||||
operationId: 'list_visionary_slots',
|
||||
summary: 'List all visionary slots',
|
||||
description: 'Retrieve the complete list of visionary slots with their reservation status.',
|
||||
responseSchema: ListVisionarySlotsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
if (Config.instance.selfHosted) {
|
||||
throw new FeatureNotAvailableSelfHostedError();
|
||||
}
|
||||
|
||||
const repository = new VisionarySlotRepository();
|
||||
const slots = await repository.listVisionarySlots();
|
||||
|
||||
const reservedCount = slots.filter((slot) => slot.userId !== null).length;
|
||||
|
||||
return ctx.json({
|
||||
slots: slots.map((slot) => ({
|
||||
slot_index: slot.slotIndex,
|
||||
user_id: slot.userId ? slot.userId.toString() : null,
|
||||
})),
|
||||
total_count: slots.length,
|
||||
reserved_count: reservedCount,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/visionary-slots/expand',
|
||||
RateLimitMiddleware(RateLimitConfigs.VISIONARY_SLOT_OPERATION),
|
||||
requireAdminACL(AdminACLs.VISIONARY_SLOT_EXPAND),
|
||||
Validator('json', ExpandVisionarySlotsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'expand_visionary_slots',
|
||||
summary: 'Expand visionary slots',
|
||||
description: 'Create additional visionary slots. New slots are added at the next available indices.',
|
||||
responseSchema: VisionarySlotOperationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
if (Config.instance.selfHosted) {
|
||||
throw new FeatureNotAvailableSelfHostedError();
|
||||
}
|
||||
|
||||
const {count} = ctx.req.valid('json');
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
|
||||
await adminService.expandVisionarySlots({count}, adminUserId, auditLogReason);
|
||||
|
||||
return ctx.json({success: true});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/visionary-slots/shrink',
|
||||
RateLimitMiddleware(RateLimitConfigs.VISIONARY_SLOT_OPERATION),
|
||||
requireAdminACL(AdminACLs.VISIONARY_SLOT_SHRINK),
|
||||
Validator('json', ShrinkVisionarySlotsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'shrink_visionary_slots',
|
||||
summary: 'Shrink visionary slots',
|
||||
description:
|
||||
'Reduce the total number of visionary slots. Only unreserved slots from the highest indices can be removed. Fails if reserved slots would be deleted.',
|
||||
responseSchema: VisionarySlotOperationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
if (Config.instance.selfHosted) {
|
||||
throw new FeatureNotAvailableSelfHostedError();
|
||||
}
|
||||
|
||||
const {target_count} = ctx.req.valid('json');
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
|
||||
await adminService.shrinkVisionarySlots({targetCount: target_count}, adminUserId, auditLogReason);
|
||||
|
||||
return ctx.json({success: true});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/visionary-slots/reserve',
|
||||
RateLimitMiddleware(RateLimitConfigs.VISIONARY_SLOT_OPERATION),
|
||||
requireAdminACL(AdminACLs.VISIONARY_SLOT_RESERVE),
|
||||
Validator('json', ReserveVisionarySlotRequest),
|
||||
OpenAPI({
|
||||
operationId: 'reserve_visionary_slot',
|
||||
summary: 'Reserve or unreserve a visionary slot',
|
||||
description:
|
||||
'Reserve a specific slot index for a user ID, or unreserve it by setting user_id to null. Special value -1 is also valid for user_id.',
|
||||
responseSchema: VisionarySlotOperationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
if (Config.instance.selfHosted) {
|
||||
throw new FeatureNotAvailableSelfHostedError();
|
||||
}
|
||||
|
||||
const {slot_index, user_id} = ctx.req.valid('json');
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
|
||||
if (user_id === null) {
|
||||
await adminService.setVisionarySlotReservation(
|
||||
{slotIndex: slot_index, userId: null},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
);
|
||||
} else {
|
||||
const userId = createUserID(BigInt(user_id));
|
||||
await adminService.setVisionarySlotReservation({slotIndex: slot_index, userId}, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
return ctx.json({success: true});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/visionary-slots/swap',
|
||||
RateLimitMiddleware(RateLimitConfigs.VISIONARY_SLOT_OPERATION),
|
||||
requireAdminACL(AdminACLs.VISIONARY_SLOT_SWAP),
|
||||
Validator('json', SwapVisionarySlotsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'swap_visionary_slots',
|
||||
summary: 'Swap visionary slot reservations',
|
||||
description: 'Swap the reserved user IDs between two slot indices.',
|
||||
responseSchema: VisionarySlotOperationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
if (Config.instance.selfHosted) {
|
||||
throw new FeatureNotAvailableSelfHostedError();
|
||||
}
|
||||
|
||||
const {slot_index_a, slot_index_b} = ctx.req.valid('json');
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
|
||||
await adminService.swapVisionarySlots(
|
||||
{slotIndexA: slot_index_a, slotIndexB: slot_index_b},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
);
|
||||
|
||||
return ctx.json({success: true});
|
||||
},
|
||||
);
|
||||
}
|
||||
279
packages/api/src/admin/controllers/VoiceAdminController.tsx
Normal file
279
packages/api/src/admin/controllers/VoiceAdminController.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {requireAdminACL} from '@fluxer/api/src/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
CreateVoiceRegionRequest,
|
||||
CreateVoiceRegionResponse,
|
||||
CreateVoiceServerRequest,
|
||||
CreateVoiceServerResponse,
|
||||
DeleteVoiceRegionRequest,
|
||||
DeleteVoiceResponse,
|
||||
DeleteVoiceServerRequest,
|
||||
GetVoiceRegionRequest,
|
||||
GetVoiceRegionResponse,
|
||||
GetVoiceServerRequest,
|
||||
GetVoiceServerResponse,
|
||||
ListVoiceRegionsRequest,
|
||||
ListVoiceRegionsResponse,
|
||||
ListVoiceServersRequest,
|
||||
ListVoiceServersResponse,
|
||||
UpdateVoiceRegionRequest,
|
||||
UpdateVoiceRegionResponse,
|
||||
UpdateVoiceServerRequest,
|
||||
UpdateVoiceServerResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminVoiceSchemas';
|
||||
|
||||
export function VoiceAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/voice/regions/list',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.VOICE_REGION_LIST),
|
||||
Validator('json', ListVoiceRegionsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'list_voice_regions',
|
||||
summary: 'List voice regions',
|
||||
responseSchema: ListVoiceRegionsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Lists all configured voice server regions with status and server count. Shows region names, latency info, and availability. Requires VOICE_REGION_LIST permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.listVoiceRegions(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/voice/regions/get',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.VOICE_REGION_LIST),
|
||||
Validator('json', GetVoiceRegionRequest),
|
||||
OpenAPI({
|
||||
operationId: 'get_voice_region',
|
||||
summary: 'Get voice region',
|
||||
responseSchema: GetVoiceRegionResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Gets detailed information about a voice region including assigned servers and capacity. Shows performance metrics and server details. Requires VOICE_REGION_LIST permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.getVoiceRegion(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/voice/regions/create',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.VOICE_REGION_CREATE),
|
||||
Validator('json', CreateVoiceRegionRequest),
|
||||
OpenAPI({
|
||||
operationId: 'create_voice_region',
|
||||
summary: 'Create voice region',
|
||||
responseSchema: CreateVoiceRegionResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Creates a new voice server region. Defines geographic location and performance characteristics for voice routing. Creates audit log entry. Requires VOICE_REGION_CREATE permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.createVoiceRegion(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/voice/regions/update',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.VOICE_REGION_UPDATE),
|
||||
Validator('json', UpdateVoiceRegionRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_voice_region',
|
||||
summary: 'Update voice region',
|
||||
responseSchema: UpdateVoiceRegionResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Updates voice region settings such as latency thresholds or priority. Changes affect voice routing for new sessions. Creates audit log entry. Requires VOICE_REGION_UPDATE permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.updateVoiceRegion(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/voice/regions/delete',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.VOICE_REGION_DELETE),
|
||||
Validator('json', DeleteVoiceRegionRequest),
|
||||
OpenAPI({
|
||||
operationId: 'delete_voice_region',
|
||||
summary: 'Delete voice region',
|
||||
responseSchema: DeleteVoiceResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Deletes a voice region. Removes region from routing and reassigns active connections. Creates audit log entry. Requires VOICE_REGION_DELETE permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.deleteVoiceRegion(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/voice/servers/list',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.VOICE_SERVER_LIST),
|
||||
Validator('json', ListVoiceServersRequest),
|
||||
OpenAPI({
|
||||
operationId: 'list_voice_servers',
|
||||
summary: 'List voice servers',
|
||||
responseSchema: ListVoiceServersResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Lists all voice servers with connection counts and capacity. Shows server status, region assignment, and load metrics. Supports filtering and pagination. Requires VOICE_SERVER_LIST permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.listVoiceServers(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/voice/servers/get',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.VOICE_SERVER_LIST),
|
||||
Validator('json', GetVoiceServerRequest),
|
||||
OpenAPI({
|
||||
operationId: 'get_voice_server',
|
||||
summary: 'Get voice server',
|
||||
responseSchema: GetVoiceServerResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Gets detailed voice server information including active connections, configuration, and performance metrics. Requires VOICE_SERVER_LIST permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.getVoiceServer(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/voice/servers/create',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.VOICE_SERVER_CREATE),
|
||||
Validator('json', CreateVoiceServerRequest),
|
||||
OpenAPI({
|
||||
operationId: 'create_voice_server',
|
||||
summary: 'Create voice server',
|
||||
responseSchema: CreateVoiceServerResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Creates and provisions a new voice server instance in a region. Configures capacity, codecs, and encryption. Creates audit log entry. Requires VOICE_SERVER_CREATE permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.createVoiceServer(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/voice/servers/update',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.VOICE_SERVER_UPDATE),
|
||||
Validator('json', UpdateVoiceServerRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_voice_server',
|
||||
summary: 'Update voice server',
|
||||
responseSchema: UpdateVoiceServerResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Updates voice server configuration including capacity, region assignment, and quality settings. Changes apply to new connections. Creates audit log entry. Requires VOICE_SERVER_UPDATE permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.updateVoiceServer(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/voice/servers/delete',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.VOICE_SERVER_DELETE),
|
||||
Validator('json', DeleteVoiceServerRequest),
|
||||
OpenAPI({
|
||||
operationId: 'delete_voice_server',
|
||||
summary: 'Delete voice server',
|
||||
responseSchema: DeleteVoiceResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Decommissions and removes a voice server instance. Disconnects active sessions and migrates to other servers. Creates audit log entry. Requires VOICE_SERVER_DELETE permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.deleteVoiceServer(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
}
|
||||
65
packages/api/src/admin/controllers/index.tsx
Normal file
65
packages/api/src/admin/controllers/index.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {AdminApiKeyAdminController} from '@fluxer/api/src/admin/controllers/AdminApiKeyAdminController';
|
||||
import {ArchiveAdminController} from '@fluxer/api/src/admin/controllers/ArchiveAdminController';
|
||||
import {AssetAdminController} from '@fluxer/api/src/admin/controllers/AssetAdminController';
|
||||
import {AuditLogAdminController} from '@fluxer/api/src/admin/controllers/AuditLogAdminController';
|
||||
import {BanAdminController} from '@fluxer/api/src/admin/controllers/BanAdminController';
|
||||
import {BulkAdminController} from '@fluxer/api/src/admin/controllers/BulkAdminController';
|
||||
import {ChildSafetyAdminController} from '@fluxer/api/src/admin/controllers/ChildSafetyAdminController';
|
||||
import {CodesAdminController} from '@fluxer/api/src/admin/controllers/CodesAdminController';
|
||||
import {DiscoveryAdminController} from '@fluxer/api/src/admin/controllers/DiscoveryAdminController';
|
||||
import {GatewayAdminController} from '@fluxer/api/src/admin/controllers/GatewayAdminController';
|
||||
import {GuildAdminController} from '@fluxer/api/src/admin/controllers/GuildAdminController';
|
||||
import {InstanceConfigAdminController} from '@fluxer/api/src/admin/controllers/InstanceConfigAdminController';
|
||||
import {LimitConfigAdminController} from '@fluxer/api/src/admin/controllers/LimitConfigAdminController';
|
||||
import {MessageAdminController} from '@fluxer/api/src/admin/controllers/MessageAdminController';
|
||||
import {ReportAdminController} from '@fluxer/api/src/admin/controllers/ReportAdminController';
|
||||
import {SearchAdminController} from '@fluxer/api/src/admin/controllers/SearchAdminController';
|
||||
import {SnowflakeReservationAdminController} from '@fluxer/api/src/admin/controllers/SnowflakeReservationAdminController';
|
||||
import {SystemDmAdminController} from '@fluxer/api/src/admin/controllers/SystemDmAdminController';
|
||||
import {UserAdminController} from '@fluxer/api/src/admin/controllers/UserAdminController';
|
||||
import {VisionarySlotAdminController} from '@fluxer/api/src/admin/controllers/VisionarySlotAdminController';
|
||||
import {VoiceAdminController} from '@fluxer/api/src/admin/controllers/VoiceAdminController';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
|
||||
export function registerAdminControllers(app: HonoApp) {
|
||||
AdminApiKeyAdminController(app);
|
||||
UserAdminController(app);
|
||||
CodesAdminController(app);
|
||||
GuildAdminController(app);
|
||||
AssetAdminController(app);
|
||||
BanAdminController(app);
|
||||
InstanceConfigAdminController(app);
|
||||
LimitConfigAdminController(app);
|
||||
SnowflakeReservationAdminController(app);
|
||||
MessageAdminController(app);
|
||||
BulkAdminController(app);
|
||||
AuditLogAdminController(app);
|
||||
ArchiveAdminController(app);
|
||||
ReportAdminController(app);
|
||||
ChildSafetyAdminController(app);
|
||||
VoiceAdminController(app);
|
||||
GatewayAdminController(app);
|
||||
SearchAdminController(app);
|
||||
DiscoveryAdminController(app);
|
||||
VisionarySlotAdminController(app);
|
||||
SystemDmAdminController(app);
|
||||
}
|
||||
115
packages/api/src/admin/models/AdminArchiveModel.tsx
Normal file
115
packages/api/src/admin/models/AdminArchiveModel.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AdminArchiveRow} from '@fluxer/api/src/database/types/AdminArchiveTypes';
|
||||
import type {AdminArchiveResponseSchema} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {z} from 'zod';
|
||||
|
||||
export type ArchiveSubjectType = 'user' | 'guild';
|
||||
export class AdminArchive {
|
||||
subjectType: ArchiveSubjectType;
|
||||
subjectId: bigint;
|
||||
archiveId: bigint;
|
||||
requestedBy: bigint;
|
||||
requestedAt: Date;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
failedAt: Date | null;
|
||||
storageKey: string | null;
|
||||
fileSize: bigint | null;
|
||||
progressPercent: number;
|
||||
progressStep: string | null;
|
||||
errorMessage: string | null;
|
||||
downloadUrlExpiresAt: Date | null;
|
||||
expiresAt: Date | null;
|
||||
|
||||
constructor(row: AdminArchiveRow) {
|
||||
this.subjectType = row.subject_type;
|
||||
this.subjectId = row.subject_id;
|
||||
this.archiveId = row.archive_id;
|
||||
this.requestedBy = row.requested_by;
|
||||
this.requestedAt = row.requested_at;
|
||||
this.startedAt = row.started_at ?? null;
|
||||
this.completedAt = row.completed_at ?? null;
|
||||
this.failedAt = row.failed_at ?? null;
|
||||
this.storageKey = row.storage_key ?? null;
|
||||
this.fileSize = row.file_size ?? null;
|
||||
this.progressPercent = row.progress_percent;
|
||||
this.progressStep = row.progress_step ?? null;
|
||||
this.errorMessage = row.error_message ?? null;
|
||||
this.downloadUrlExpiresAt = row.download_url_expires_at ?? null;
|
||||
this.expiresAt = row.expires_at ?? null;
|
||||
}
|
||||
|
||||
toRow(): AdminArchiveRow {
|
||||
return {
|
||||
subject_type: this.subjectType,
|
||||
subject_id: this.subjectId,
|
||||
archive_id: this.archiveId,
|
||||
requested_by: this.requestedBy,
|
||||
requested_at: this.requestedAt,
|
||||
started_at: this.startedAt,
|
||||
completed_at: this.completedAt,
|
||||
failed_at: this.failedAt,
|
||||
storage_key: this.storageKey,
|
||||
file_size: this.fileSize,
|
||||
progress_percent: this.progressPercent,
|
||||
progress_step: this.progressStep,
|
||||
error_message: this.errorMessage,
|
||||
download_url_expires_at: this.downloadUrlExpiresAt,
|
||||
expires_at: this.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
toResponse(): {
|
||||
archive_id: string;
|
||||
subject_type: ArchiveSubjectType;
|
||||
subject_id: string;
|
||||
requested_by: string;
|
||||
requested_at: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
failed_at: string | null;
|
||||
file_size: string | null;
|
||||
progress_percent: number;
|
||||
progress_step: string | null;
|
||||
error_message: string | null;
|
||||
download_url_expires_at: string | null;
|
||||
expires_at: string | null;
|
||||
} {
|
||||
return {
|
||||
archive_id: this.archiveId.toString(),
|
||||
subject_type: this.subjectType,
|
||||
subject_id: this.subjectId.toString(),
|
||||
requested_by: this.requestedBy.toString(),
|
||||
requested_at: this.requestedAt.toISOString(),
|
||||
started_at: this.startedAt?.toISOString() ?? null,
|
||||
completed_at: this.completedAt?.toISOString() ?? null,
|
||||
failed_at: this.failedAt?.toISOString() ?? null,
|
||||
file_size: this.fileSize?.toString() ?? null,
|
||||
progress_percent: this.progressPercent,
|
||||
progress_step: this.progressStep,
|
||||
error_message: this.errorMessage,
|
||||
download_url_expires_at: this.downloadUrlExpiresAt?.toISOString() ?? null,
|
||||
expires_at: this.expiresAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type AdminArchiveResponse = z.infer<typeof AdminArchiveResponseSchema>;
|
||||
52
packages/api/src/admin/models/GuildTypes.tsx
Normal file
52
packages/api/src/admin/models/GuildTypes.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mapGuildFeatures} from '@fluxer/api/src/guild/GuildFeatureUtils';
|
||||
import type {Guild} from '@fluxer/api/src/models/Guild';
|
||||
import type {GuildAdminResponse, ListUserGuildsResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
|
||||
export function mapGuildToAdminResponse(guild: Guild): GuildAdminResponse {
|
||||
return {
|
||||
id: guild.id.toString(),
|
||||
name: guild.name,
|
||||
features: mapGuildFeatures(guild.features),
|
||||
owner_id: guild.ownerId.toString(),
|
||||
icon: guild.iconHash,
|
||||
banner: guild.bannerHash,
|
||||
member_count: guild.memberCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapGuildsToAdminResponse(guilds: Array<Guild>): ListUserGuildsResponse {
|
||||
return {
|
||||
guilds: [
|
||||
...guilds.map((guild) => {
|
||||
return {
|
||||
id: guild.id.toString(),
|
||||
name: guild.name,
|
||||
features: mapGuildFeatures(guild.features),
|
||||
owner_id: guild.ownerId.toString(),
|
||||
icon: guild.iconHash,
|
||||
banner: guild.bannerHash,
|
||||
member_count: guild.memberCount,
|
||||
};
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
104
packages/api/src/admin/models/UserTypes.tsx
Normal file
104
packages/api/src/admin/models/UserTypes.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import dns from 'node:dns';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {formatGeoipLocation, lookupGeoip} from '@fluxer/api/src/utils/IpUtils';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
const REVERSE_DNS_CACHE_TTL_SECONDS = seconds('1 day');
|
||||
|
||||
async function reverseDnsLookup(ip: string, cacheService?: ICacheService): Promise<string | null> {
|
||||
const cacheKey = `reverse-dns:${ip}`;
|
||||
|
||||
if (cacheService) {
|
||||
const cached = await cacheService.get<string | null>(cacheKey);
|
||||
if (cached !== null) {
|
||||
return cached === '' ? null : cached;
|
||||
}
|
||||
}
|
||||
|
||||
let result: string | null = null;
|
||||
try {
|
||||
const hostnames = await dns.promises.reverse(ip);
|
||||
result = hostnames[0] ?? null;
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (cacheService) {
|
||||
await cacheService.set(cacheKey, result ?? '', REVERSE_DNS_CACHE_TTL_SECONDS);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function mapUserToAdminResponse(user: User, cacheService?: ICacheService): Promise<UserAdminResponse> {
|
||||
const lastActiveIpReverse = user.lastActiveIp ? await reverseDnsLookup(user.lastActiveIp, cacheService) : null;
|
||||
let lastActiveLocation: string | null = null;
|
||||
if (user.lastActiveIp) {
|
||||
try {
|
||||
const geoip = await lookupGeoip(user.lastActiveIp);
|
||||
const formattedLocation = formatGeoipLocation(geoip);
|
||||
lastActiveLocation = formattedLocation;
|
||||
} catch {
|
||||
lastActiveLocation = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
username: user.username,
|
||||
discriminator: user.discriminator,
|
||||
global_name: user.globalName,
|
||||
bot: user.isBot,
|
||||
system: user.isSystem,
|
||||
flags: user.flags.toString(),
|
||||
avatar: user.avatarHash,
|
||||
banner: user.bannerHash,
|
||||
bio: user.bio,
|
||||
pronouns: user.pronouns,
|
||||
accent_color: user.accentColor,
|
||||
email: user.email,
|
||||
email_verified: user.emailVerified,
|
||||
email_bounced: user.emailBounced,
|
||||
phone: user.phone,
|
||||
date_of_birth: user.dateOfBirth,
|
||||
locale: user.locale,
|
||||
premium_type: user.premiumType,
|
||||
premium_since: user.premiumSince?.toISOString() ?? null,
|
||||
premium_until: user.premiumUntil?.toISOString() ?? null,
|
||||
suspicious_activity_flags: user.suspiciousActivityFlags,
|
||||
temp_banned_until: user.tempBannedUntil?.toISOString() ?? null,
|
||||
pending_deletion_at: user.pendingDeletionAt?.toISOString() ?? null,
|
||||
pending_bulk_message_deletion_at: user.pendingBulkMessageDeletionAt?.toISOString() ?? null,
|
||||
deletion_reason_code: user.deletionReasonCode,
|
||||
deletion_public_reason: user.deletionPublicReason,
|
||||
acls: user.acls ? Array.from(user.acls) : [],
|
||||
traits: Array.from(user.traits).sort(),
|
||||
has_totp: user.totpSecret !== null,
|
||||
authenticator_types: user.authenticatorTypes ? Array.from(user.authenticatorTypes) : [],
|
||||
last_active_at: user.lastActiveAt?.toISOString() ?? null,
|
||||
last_active_ip: user.lastActiveIp,
|
||||
last_active_ip_reverse: lastActiveIpReverse,
|
||||
last_active_location: lastActiveLocation,
|
||||
};
|
||||
}
|
||||
142
packages/api/src/admin/repositories/AdminApiKeyRepository.tsx
Normal file
142
packages/api/src/admin/repositories/AdminApiKeyRepository.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CreateAdminApiKeyData,
|
||||
IAdminApiKeyRepository,
|
||||
} from '@fluxer/api/src/admin/repositories/IAdminApiKeyRepository';
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {BatchBuilder, Db, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {AdminApiKeyRow} from '@fluxer/api/src/database/types/AdminAuthTypes';
|
||||
import {AdminApiKey} from '@fluxer/api/src/models/AdminApiKey';
|
||||
import {AdminApiKeys, AdminApiKeysByCreator} from '@fluxer/api/src/Tables';
|
||||
import {hashPassword} from '@fluxer/api/src/utils/PasswordUtils';
|
||||
|
||||
function computeTtlSeconds(expiresAt: Date): number {
|
||||
const diffSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
|
||||
return Math.max(diffSeconds, 1);
|
||||
}
|
||||
|
||||
export class AdminApiKeyRepository implements IAdminApiKeyRepository {
|
||||
async create(data: CreateAdminApiKeyData, createdBy: UserID, keyId: bigint, rawKey: string): Promise<AdminApiKey> {
|
||||
const keyHash = await hashPassword(rawKey);
|
||||
const createdAt = new Date();
|
||||
|
||||
const row: AdminApiKeyRow = {
|
||||
key_id: keyId,
|
||||
key_hash: keyHash,
|
||||
name: data.name,
|
||||
created_by_user_id: createdBy,
|
||||
created_at: createdAt,
|
||||
last_used_at: null,
|
||||
expires_at: data.expiresAt,
|
||||
version: 1,
|
||||
acls: data.acls,
|
||||
};
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
if (data.expiresAt) {
|
||||
const ttlSeconds = computeTtlSeconds(data.expiresAt);
|
||||
batch.addPrepared(AdminApiKeys.insertWithTtl(row, ttlSeconds));
|
||||
batch.addPrepared(
|
||||
AdminApiKeysByCreator.insertWithTtl(
|
||||
{
|
||||
created_by_user_id: row.created_by_user_id,
|
||||
key_id: row.key_id,
|
||||
created_at: row.created_at,
|
||||
name: row.name,
|
||||
expires_at: row.expires_at,
|
||||
last_used_at: row.last_used_at,
|
||||
version: row.version,
|
||||
acls: row.acls,
|
||||
},
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
batch.addPrepared(AdminApiKeys.upsertAll(row));
|
||||
batch.addPrepared(
|
||||
AdminApiKeysByCreator.upsertAll({
|
||||
created_by_user_id: row.created_by_user_id,
|
||||
key_id: row.key_id,
|
||||
created_at: row.created_at,
|
||||
name: row.name,
|
||||
expires_at: row.expires_at,
|
||||
last_used_at: row.last_used_at,
|
||||
version: row.version,
|
||||
acls: row.acls,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
|
||||
return new AdminApiKey(row);
|
||||
}
|
||||
|
||||
async findById(keyId: bigint): Promise<AdminApiKey | null> {
|
||||
const query = AdminApiKeys.select({
|
||||
where: AdminApiKeys.where.eq('key_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const row = await fetchOne<AdminApiKeyRow>(query.bind({key_id: keyId}));
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AdminApiKey(row);
|
||||
}
|
||||
|
||||
async listByCreator(createdBy: UserID): Promise<Array<AdminApiKey>> {
|
||||
const query = AdminApiKeysByCreator.select({
|
||||
where: AdminApiKeysByCreator.where.eq('created_by_user_id'),
|
||||
});
|
||||
|
||||
const indexRows = await fetchMany<{
|
||||
created_by_user_id: UserID;
|
||||
key_id: bigint;
|
||||
}>(query.bind({created_by_user_id: createdBy}));
|
||||
|
||||
if (indexRows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const apiKeys = await Promise.all(indexRows.map((row) => this.findById(row.key_id)));
|
||||
|
||||
return apiKeys.filter((key) => key !== null) as Array<AdminApiKey>;
|
||||
}
|
||||
|
||||
async updateLastUsed(keyId: bigint): Promise<void> {
|
||||
const patchQuery = AdminApiKeys.patchByPk({key_id: keyId}, {last_used_at: Db.set(new Date())});
|
||||
|
||||
await upsertOne(patchQuery);
|
||||
}
|
||||
|
||||
async revoke(keyId: bigint, createdBy: UserID): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
batch.addPrepared(AdminApiKeys.deleteByPk({key_id: keyId}));
|
||||
batch.addPrepared(AdminApiKeysByCreator.deleteByPk({created_by_user_id: createdBy, key_id: keyId}));
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
392
packages/api/src/admin/repositories/AdminArchiveRepository.tsx
Normal file
392
packages/api/src/admin/repositories/AdminArchiveRepository.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ArchiveSubjectType} from '@fluxer/api/src/admin/models/AdminArchiveModel';
|
||||
import {AdminArchive} from '@fluxer/api/src/admin/models/AdminArchiveModel';
|
||||
import {BatchBuilder, Db, fetchMany, fetchOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {AdminArchiveRow} from '@fluxer/api/src/database/types/AdminArchiveTypes';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import {AdminArchivesByRequester, AdminArchivesBySubject, AdminArchivesByType} from '@fluxer/api/src/Tables';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
const RETENTION_DAYS = 365;
|
||||
const DEFAULT_RETENTION_MS = ms(`${RETENTION_DAYS} days`);
|
||||
|
||||
function computeTtlSeconds(expiresAt: Date): number {
|
||||
const diffSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
|
||||
return Math.max(diffSeconds, 1);
|
||||
}
|
||||
|
||||
function filterExpired(rows: Array<AdminArchiveRow>, includeExpired: boolean): Array<AdminArchiveRow> {
|
||||
if (includeExpired) return rows;
|
||||
const now = Date.now();
|
||||
return rows.filter((row) => !row.expires_at || row.expires_at.getTime() > now);
|
||||
}
|
||||
|
||||
export class AdminArchiveRepository {
|
||||
private ensureExpiry(archive: AdminArchive): AdminArchive {
|
||||
if (!archive.expiresAt) {
|
||||
archive.expiresAt = new Date(Date.now() + DEFAULT_RETENTION_MS);
|
||||
}
|
||||
return archive;
|
||||
}
|
||||
|
||||
async create(archive: AdminArchive): Promise<void> {
|
||||
const withExpiry = this.ensureExpiry(archive);
|
||||
const row = withExpiry.toRow();
|
||||
const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
AdminArchivesBySubject.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByRequester.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByType.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'),
|
||||
);
|
||||
await batch.execute();
|
||||
|
||||
Logger.debug(
|
||||
{subjectType: withExpiry.subjectType, subjectId: withExpiry.subjectId, archiveId: withExpiry.archiveId},
|
||||
'Created admin archive record',
|
||||
);
|
||||
}
|
||||
|
||||
async update(archive: AdminArchive): Promise<void> {
|
||||
const withExpiry = this.ensureExpiry(archive);
|
||||
const row = withExpiry.toRow();
|
||||
const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
AdminArchivesBySubject.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByRequester.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByType.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'),
|
||||
);
|
||||
await batch.execute();
|
||||
|
||||
Logger.debug(
|
||||
{subjectType: withExpiry.subjectType, subjectId: withExpiry.subjectId, archiveId: withExpiry.archiveId},
|
||||
'Updated admin archive record',
|
||||
);
|
||||
}
|
||||
|
||||
async markAsStarted(archive: AdminArchive, progressStep = 'Starting archive'): Promise<void> {
|
||||
const withExpiry = this.ensureExpiry(archive);
|
||||
const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
AdminArchivesBySubject.patchByPkWithTtlParam(
|
||||
{
|
||||
subject_type: withExpiry.subjectType,
|
||||
subject_id: withExpiry.subjectId,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
started_at: Db.set(new Date()),
|
||||
progress_percent: Db.set(0),
|
||||
progress_step: Db.set(progressStep),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByRequester.patchByPkWithTtlParam(
|
||||
{
|
||||
requested_by: withExpiry.requestedBy,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
started_at: Db.set(new Date()),
|
||||
progress_percent: Db.set(0),
|
||||
progress_step: Db.set(progressStep),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByType.patchByPkWithTtlParam(
|
||||
{
|
||||
subject_type: withExpiry.subjectType,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
started_at: Db.set(new Date()),
|
||||
progress_percent: Db.set(0),
|
||||
progress_step: Db.set(progressStep),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async updateProgress(archive: AdminArchive, progressPercent: number, progressStep: string): Promise<void> {
|
||||
const withExpiry = this.ensureExpiry(archive);
|
||||
const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
AdminArchivesBySubject.patchByPkWithTtlParam(
|
||||
{
|
||||
subject_type: withExpiry.subjectType,
|
||||
subject_id: withExpiry.subjectId,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
progress_percent: Db.set(progressPercent),
|
||||
progress_step: Db.set(progressStep),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByRequester.patchByPkWithTtlParam(
|
||||
{
|
||||
requested_by: withExpiry.requestedBy,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
progress_percent: Db.set(progressPercent),
|
||||
progress_step: Db.set(progressStep),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByType.patchByPkWithTtlParam(
|
||||
{
|
||||
subject_type: withExpiry.subjectType,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
progress_percent: Db.set(progressPercent),
|
||||
progress_step: Db.set(progressStep),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
await batch.execute();
|
||||
|
||||
Logger.debug({archiveId: withExpiry.archiveId, progressPercent, progressStep}, 'Updated admin archive progress');
|
||||
}
|
||||
|
||||
async markAsCompleted(
|
||||
archive: AdminArchive,
|
||||
storageKey: string,
|
||||
fileSize: bigint,
|
||||
downloadUrlExpiresAt: Date,
|
||||
): Promise<void> {
|
||||
const withExpiry = this.ensureExpiry(archive);
|
||||
const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
AdminArchivesBySubject.patchByPkWithTtlParam(
|
||||
{
|
||||
subject_type: withExpiry.subjectType,
|
||||
subject_id: withExpiry.subjectId,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
completed_at: Db.set(new Date()),
|
||||
storage_key: Db.set(storageKey),
|
||||
file_size: Db.set(fileSize),
|
||||
download_url_expires_at: Db.set(downloadUrlExpiresAt),
|
||||
progress_percent: Db.set(100),
|
||||
progress_step: Db.set('Completed'),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByRequester.patchByPkWithTtlParam(
|
||||
{
|
||||
requested_by: withExpiry.requestedBy,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
completed_at: Db.set(new Date()),
|
||||
storage_key: Db.set(storageKey),
|
||||
file_size: Db.set(fileSize),
|
||||
download_url_expires_at: Db.set(downloadUrlExpiresAt),
|
||||
progress_percent: Db.set(100),
|
||||
progress_step: Db.set('Completed'),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByType.patchByPkWithTtlParam(
|
||||
{
|
||||
subject_type: withExpiry.subjectType,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
completed_at: Db.set(new Date()),
|
||||
storage_key: Db.set(storageKey),
|
||||
file_size: Db.set(fileSize),
|
||||
download_url_expires_at: Db.set(downloadUrlExpiresAt),
|
||||
progress_percent: Db.set(100),
|
||||
progress_step: Db.set('Completed'),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async markAsFailed(archive: AdminArchive, errorMessage: string): Promise<void> {
|
||||
const withExpiry = this.ensureExpiry(archive);
|
||||
const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
AdminArchivesBySubject.patchByPkWithTtlParam(
|
||||
{
|
||||
subject_type: withExpiry.subjectType,
|
||||
subject_id: withExpiry.subjectId,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
failed_at: Db.set(new Date()),
|
||||
error_message: Db.set(errorMessage),
|
||||
progress_step: Db.set('Failed'),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByRequester.patchByPkWithTtlParam(
|
||||
{
|
||||
requested_by: withExpiry.requestedBy,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
failed_at: Db.set(new Date()),
|
||||
error_message: Db.set(errorMessage),
|
||||
progress_step: Db.set('Failed'),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByType.patchByPkWithTtlParam(
|
||||
{
|
||||
subject_type: withExpiry.subjectType,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
failed_at: Db.set(new Date()),
|
||||
error_message: Db.set(errorMessage),
|
||||
progress_step: Db.set('Failed'),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async findBySubjectAndArchiveId(
|
||||
subjectType: ArchiveSubjectType,
|
||||
subjectId: bigint,
|
||||
archiveId: bigint,
|
||||
): Promise<AdminArchive | null> {
|
||||
const query = AdminArchivesBySubject.select({
|
||||
where: [
|
||||
AdminArchivesBySubject.where.eq('subject_type'),
|
||||
AdminArchivesBySubject.where.eq('subject_id'),
|
||||
AdminArchivesBySubject.where.eq('archive_id'),
|
||||
],
|
||||
limit: 1,
|
||||
});
|
||||
const row = await fetchOne<AdminArchiveRow>(
|
||||
query.bind({
|
||||
subject_type: subjectType,
|
||||
subject_id: subjectId,
|
||||
archive_id: archiveId,
|
||||
}),
|
||||
);
|
||||
return row ? new AdminArchive(row) : null;
|
||||
}
|
||||
|
||||
async listBySubject(
|
||||
subjectType: ArchiveSubjectType,
|
||||
subjectId: bigint,
|
||||
limit = 20,
|
||||
includeExpired = false,
|
||||
): Promise<Array<AdminArchive>> {
|
||||
const query = AdminArchivesBySubject.select({
|
||||
where: [AdminArchivesBySubject.where.eq('subject_type'), AdminArchivesBySubject.where.eq('subject_id')],
|
||||
limit,
|
||||
});
|
||||
const rows = await fetchMany<AdminArchiveRow>(
|
||||
query.bind({
|
||||
subject_type: subjectType,
|
||||
subject_id: subjectId,
|
||||
}),
|
||||
);
|
||||
return filterExpired(rows, includeExpired).map((row) => new AdminArchive(row));
|
||||
}
|
||||
|
||||
async listByType(subjectType: ArchiveSubjectType, limit = 50, includeExpired = false): Promise<Array<AdminArchive>> {
|
||||
const query = AdminArchivesByType.select({
|
||||
where: AdminArchivesByType.where.eq('subject_type'),
|
||||
limit,
|
||||
});
|
||||
const rows = await fetchMany<AdminArchiveRow>(
|
||||
query.bind({
|
||||
subject_type: subjectType,
|
||||
}),
|
||||
);
|
||||
return filterExpired(rows, includeExpired).map((row) => new AdminArchive(row));
|
||||
}
|
||||
|
||||
async listByRequester(requestedBy: bigint, limit = 50, includeExpired = false): Promise<Array<AdminArchive>> {
|
||||
const query = AdminArchivesByRequester.select({
|
||||
where: AdminArchivesByRequester.where.eq('requested_by'),
|
||||
limit,
|
||||
});
|
||||
const rows = await fetchMany<AdminArchiveRow>(
|
||||
query.bind({
|
||||
requested_by: requestedBy,
|
||||
}),
|
||||
);
|
||||
return filterExpired(rows, includeExpired).map((row) => new AdminArchive(row));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {AdminApiKey} from '@fluxer/api/src/models/AdminApiKey';
|
||||
|
||||
export interface CreateAdminApiKeyData {
|
||||
name: string;
|
||||
expiresAt: Date | null;
|
||||
acls: Set<string>;
|
||||
}
|
||||
|
||||
export interface IAdminApiKeyRepository {
|
||||
create(data: CreateAdminApiKeyData, createdBy: UserID, keyId: bigint, rawKey: string): Promise<AdminApiKey>;
|
||||
findById(keyId: bigint): Promise<AdminApiKey | null>;
|
||||
listByCreator(createdBy: UserID): Promise<Array<AdminApiKey>>;
|
||||
updateLastUsed(keyId: bigint): Promise<void>;
|
||||
revoke(keyId: bigint, createdBy: UserID): Promise<void>;
|
||||
}
|
||||
106
packages/api/src/admin/repositories/SystemDmJobRepository.tsx
Normal file
106
packages/api/src/admin/repositories/SystemDmJobRepository.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {CassandraParam, DbOp} from '@fluxer/api/src/database/Cassandra';
|
||||
import {fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {SystemDmJobRow} from '@fluxer/api/src/database/types/SystemDmJobTypes';
|
||||
import {SystemDmJobs} from '@fluxer/api/src/Tables';
|
||||
|
||||
const JOB_TYPE = 'system_dm';
|
||||
|
||||
const FETCH_JOB_BY_ID = SystemDmJobs.select({
|
||||
where: [SystemDmJobs.where.eq('job_type'), SystemDmJobs.where.eq('job_id')],
|
||||
});
|
||||
|
||||
export class SystemDmJobRepository {
|
||||
async createJob(job: SystemDmJobRow): Promise<void> {
|
||||
await upsertOne(SystemDmJobs.insert(job));
|
||||
}
|
||||
|
||||
async getJob(jobId: bigint): Promise<SystemDmJobRow | null> {
|
||||
return fetchOne<SystemDmJobRow>(
|
||||
FETCH_JOB_BY_ID.bind({
|
||||
job_type: JOB_TYPE,
|
||||
job_id: jobId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async listJobs(limit: number, beforeJobId?: bigint): Promise<Array<SystemDmJobRow>> {
|
||||
const whereClauses = [SystemDmJobs.where.eq('job_type')];
|
||||
const params: Record<string, CassandraParam> = {job_type: JOB_TYPE};
|
||||
|
||||
if (beforeJobId) {
|
||||
whereClauses.push(SystemDmJobs.where.lt('job_id', 'before_job_id'));
|
||||
params['before_job_id'] = beforeJobId;
|
||||
}
|
||||
|
||||
const stmt = SystemDmJobs.select({
|
||||
where: whereClauses,
|
||||
orderBy: {col: 'job_id', direction: 'DESC'},
|
||||
limit,
|
||||
});
|
||||
|
||||
return fetchMany<SystemDmJobRow>(stmt.bind(params));
|
||||
}
|
||||
|
||||
async patchJob(jobId: bigint, patch: Partial<SystemDmJobRow>): Promise<void> {
|
||||
const patchOps = SystemDmJobRepository.buildPatch(patch);
|
||||
if (Object.keys(patchOps).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = SystemDmJobs.patchByPk(
|
||||
{job_type: JOB_TYPE, job_id: jobId},
|
||||
patchOps as Partial<{[K in Exclude<keyof SystemDmJobRow, 'job_type' | 'job_id'>]: DbOp<SystemDmJobRow[K]>}>,
|
||||
);
|
||||
await upsertOne(query);
|
||||
}
|
||||
|
||||
private static buildPatch(patch: Partial<SystemDmJobRow>): Partial<{
|
||||
[K in Exclude<keyof SystemDmJobRow, 'job_type' | 'job_id'>]: {
|
||||
kind: 'set';
|
||||
value: SystemDmJobRow[K];
|
||||
};
|
||||
}> {
|
||||
const patchOps: Partial<{
|
||||
[K in Exclude<keyof SystemDmJobRow, 'job_type' | 'job_id'>]: {
|
||||
kind: 'set';
|
||||
value: SystemDmJobRow[K];
|
||||
};
|
||||
}> = {};
|
||||
|
||||
for (const key of Object.keys(patch) as Array<keyof SystemDmJobRow>) {
|
||||
if (key === 'job_type' || key === 'job_id') {
|
||||
continue;
|
||||
}
|
||||
const value = patch[key];
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
const fieldKey = key as Exclude<keyof SystemDmJobRow, 'job_type' | 'job_id'>;
|
||||
(patchOps as Record<string, {kind: 'set'; value: SystemDmJobRow[keyof SystemDmJobRow]}>)[fieldKey] = {
|
||||
kind: 'set',
|
||||
value: value as SystemDmJobRow[typeof fieldKey],
|
||||
};
|
||||
}
|
||||
|
||||
return patchOps;
|
||||
}
|
||||
}
|
||||
198
packages/api/src/admin/services/AdminApiKeyService.tsx
Normal file
198
packages/api/src/admin/services/AdminApiKeyService.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {IAdminApiKeyRepository} from '@fluxer/api/src/admin/repositories/IAdminApiKeyRepository';
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import {verifyPassword} from '@fluxer/api/src/utils/PasswordUtils';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {AdminApiKeyNotFoundError} from '@fluxer/errors/src/domains/admin/AdminApiKeyNotFoundError';
|
||||
import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError';
|
||||
import type {CreateAdminApiKeyRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
const ADMIN_KEY_PREFIX = 'fa_';
|
||||
const RANDOM_KEY_LENGTH = 32;
|
||||
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
interface CreateApiKeyResult {
|
||||
key: string;
|
||||
apiKey: {
|
||||
keyId: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
acls: Set<string>;
|
||||
};
|
||||
}
|
||||
|
||||
export class AdminApiKeyService {
|
||||
constructor(
|
||||
private readonly adminApiKeyRepository: IAdminApiKeyRepository,
|
||||
private readonly snowflakeService: SnowflakeService,
|
||||
) {}
|
||||
|
||||
private generateRawKey(keyId: bigint): string {
|
||||
const randomBytes = new Uint8Array(RANDOM_KEY_LENGTH);
|
||||
crypto.getRandomValues(randomBytes);
|
||||
|
||||
const randomChars = Array.from(randomBytes)
|
||||
.map((byte) => CHARSET[byte % CHARSET.length])
|
||||
.join('');
|
||||
|
||||
return `${ADMIN_KEY_PREFIX}${keyId.toString()}_${randomChars}`;
|
||||
}
|
||||
|
||||
private extractKeyId(rawKey: string): bigint | null {
|
||||
if (!rawKey.startsWith(ADMIN_KEY_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const remainder = rawKey.slice(ADMIN_KEY_PREFIX.length);
|
||||
const underscoreIdx = remainder.indexOf('_');
|
||||
if (underscoreIdx <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyIdStr = remainder.slice(0, underscoreIdx);
|
||||
if (!/^\d+$/.test(keyIdStr)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return BigInt(keyIdStr);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createApiKey(
|
||||
request: CreateAdminApiKeyRequest,
|
||||
createdBy: UserID,
|
||||
creatorAcls?: Set<string>,
|
||||
): Promise<CreateApiKeyResult> {
|
||||
const keyId = await this.snowflakeService.generate();
|
||||
const rawKey = this.generateRawKey(keyId);
|
||||
|
||||
const expiresAt = request.expires_in_days ? new Date(Date.now() + request.expires_in_days * ms('1 day')) : null;
|
||||
|
||||
if (creatorAcls) {
|
||||
const invalidACLs = request.acls.filter((acl) => !creatorAcls.has(acl) && !creatorAcls.has(AdminACLs.WILDCARD));
|
||||
if (invalidACLs.length > 0) {
|
||||
throw new MissingACLError(invalidACLs[0]);
|
||||
}
|
||||
}
|
||||
|
||||
const aclsSet = new Set(request.acls);
|
||||
|
||||
const apiKey = await this.adminApiKeyRepository.create(
|
||||
{
|
||||
name: request.name,
|
||||
expiresAt,
|
||||
acls: aclsSet,
|
||||
},
|
||||
createdBy,
|
||||
keyId,
|
||||
rawKey,
|
||||
);
|
||||
|
||||
return {
|
||||
key: rawKey,
|
||||
apiKey: {
|
||||
keyId: apiKey.keyId.toString(),
|
||||
name: apiKey.name,
|
||||
createdAt: apiKey.createdAt,
|
||||
expiresAt: apiKey.expiresAt,
|
||||
acls: apiKey.acls,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async validateApiKey(rawKey: string): Promise<{keyId: bigint; createdById: UserID; acls: Set<string> | null} | null> {
|
||||
const keyId = this.extractKeyId(rawKey);
|
||||
if (keyId === null) return null;
|
||||
|
||||
const apiKey = await this.adminApiKeyRepository.findById(keyId);
|
||||
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (apiKey.isExpired()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valid = await verifyPassword({password: rawKey, passwordHash: apiKey.keyHash});
|
||||
if (!valid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.adminApiKeyRepository.updateLastUsed(apiKey.keyId);
|
||||
|
||||
return {
|
||||
keyId: apiKey.keyId,
|
||||
createdById: apiKey.createdById,
|
||||
acls: apiKey.acls,
|
||||
};
|
||||
}
|
||||
|
||||
async listKeys(createdBy: UserID): Promise<
|
||||
Array<{
|
||||
keyId: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
lastUsedAt: Date | null;
|
||||
expiresAt: Date | null;
|
||||
createdById: UserID;
|
||||
acls: Set<string>;
|
||||
}>
|
||||
> {
|
||||
const apiKeys = await this.adminApiKeyRepository.listByCreator(createdBy);
|
||||
|
||||
return apiKeys.map((key) => ({
|
||||
keyId: key.keyId.toString(),
|
||||
name: key.name,
|
||||
createdAt: key.createdAt,
|
||||
lastUsedAt: key.lastUsedAt,
|
||||
expiresAt: key.expiresAt,
|
||||
createdById: key.createdById,
|
||||
acls: key.acls ?? new Set(),
|
||||
}));
|
||||
}
|
||||
|
||||
async revokeKey(keyId: string, createdBy: UserID): Promise<void> {
|
||||
if (!/^\d+$/.test(keyId)) {
|
||||
throw new AdminApiKeyNotFoundError();
|
||||
}
|
||||
|
||||
const keyIdBigInt = BigInt(keyId);
|
||||
|
||||
const apiKey = await this.adminApiKeyRepository.findById(keyIdBigInt);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new AdminApiKeyNotFoundError();
|
||||
}
|
||||
|
||||
if (apiKey.createdById !== createdBy) {
|
||||
throw new AdminApiKeyNotFoundError();
|
||||
}
|
||||
|
||||
await this.adminApiKeyRepository.revoke(keyIdBigInt, createdBy);
|
||||
}
|
||||
}
|
||||
221
packages/api/src/admin/services/AdminArchiveService.tsx
Normal file
221
packages/api/src/admin/services/AdminArchiveService.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
AdminArchive,
|
||||
type AdminArchiveResponse,
|
||||
type ArchiveSubjectType,
|
||||
} from '@fluxer/api/src/admin/models/AdminArchiveModel';
|
||||
import type {AdminArchiveRepository} from '@fluxer/api/src/admin/repositories/AdminArchiveRepository';
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {HarvestExpiredError} from '@fluxer/errors/src/domains/moderation/HarvestExpiredError';
|
||||
import {HarvestFailedError} from '@fluxer/errors/src/domains/moderation/HarvestFailedError';
|
||||
import {HarvestNotReadyError} from '@fluxer/errors/src/domains/moderation/HarvestNotReadyError';
|
||||
import {UnknownHarvestError} from '@fluxer/errors/src/domains/moderation/UnknownHarvestError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
|
||||
const ARCHIVE_RETENTION_DAYS = 365;
|
||||
const DOWNLOAD_LINK_DAYS = 7;
|
||||
const DOWNLOAD_LINK_SECONDS = DOWNLOAD_LINK_DAYS * seconds('1 day');
|
||||
|
||||
interface ListArchivesParams {
|
||||
subjectType?: ArchiveSubjectType | 'all';
|
||||
subjectId?: bigint;
|
||||
requestedBy?: bigint;
|
||||
limit?: number;
|
||||
includeExpired?: boolean;
|
||||
}
|
||||
|
||||
export class AdminArchiveService {
|
||||
constructor(
|
||||
private readonly adminArchiveRepository: AdminArchiveRepository,
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly guildRepository: IGuildRepositoryAggregate,
|
||||
private readonly storageService: IStorageService,
|
||||
private readonly snowflakeService: SnowflakeService,
|
||||
private readonly workerService: IWorkerService,
|
||||
) {}
|
||||
|
||||
private computeExpiry(): Date {
|
||||
return new Date(Date.now() + ARCHIVE_RETENTION_DAYS * ms('1 day'));
|
||||
}
|
||||
|
||||
async triggerUserArchive(targetUserId: UserID, requestedBy: UserID): Promise<AdminArchiveResponse> {
|
||||
const user = await this.userRepository.findUnique(targetUserId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const archiveId = await this.snowflakeService.generate();
|
||||
const archive = new AdminArchive({
|
||||
subject_type: 'user',
|
||||
subject_id: targetUserId,
|
||||
archive_id: archiveId,
|
||||
requested_by: requestedBy,
|
||||
requested_at: new Date(),
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
failed_at: null,
|
||||
storage_key: null,
|
||||
file_size: null,
|
||||
progress_percent: 0,
|
||||
progress_step: 'Queued',
|
||||
error_message: null,
|
||||
download_url_expires_at: null,
|
||||
expires_at: this.computeExpiry(),
|
||||
});
|
||||
|
||||
await this.adminArchiveRepository.create(archive);
|
||||
|
||||
await this.workerService.addJob('harvestUserData', {
|
||||
userId: targetUserId.toString(),
|
||||
harvestId: archive.archiveId.toString(),
|
||||
adminRequestedBy: requestedBy.toString(),
|
||||
});
|
||||
|
||||
return archive.toResponse();
|
||||
}
|
||||
|
||||
async triggerGuildArchive(targetGuildId: GuildID, requestedBy: UserID): Promise<AdminArchiveResponse> {
|
||||
const guild = await this.guildRepository.findUnique(targetGuildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const archiveId = await this.snowflakeService.generate();
|
||||
const archive = new AdminArchive({
|
||||
subject_type: 'guild',
|
||||
subject_id: targetGuildId,
|
||||
archive_id: archiveId,
|
||||
requested_by: requestedBy,
|
||||
requested_at: new Date(),
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
failed_at: null,
|
||||
storage_key: null,
|
||||
file_size: null,
|
||||
progress_percent: 0,
|
||||
progress_step: 'Queued',
|
||||
error_message: null,
|
||||
download_url_expires_at: null,
|
||||
expires_at: this.computeExpiry(),
|
||||
});
|
||||
|
||||
await this.adminArchiveRepository.create(archive);
|
||||
|
||||
await this.workerService.addJob('harvestGuildData', {
|
||||
guildId: targetGuildId.toString(),
|
||||
archiveId: archive.archiveId.toString(),
|
||||
requestedBy: requestedBy.toString(),
|
||||
});
|
||||
|
||||
return archive.toResponse();
|
||||
}
|
||||
|
||||
async getArchive(
|
||||
subjectType: ArchiveSubjectType,
|
||||
subjectId: bigint,
|
||||
archiveId: bigint,
|
||||
): Promise<AdminArchiveResponse | null> {
|
||||
const archive = await this.adminArchiveRepository.findBySubjectAndArchiveId(subjectType, subjectId, archiveId);
|
||||
return archive ? archive.toResponse() : null;
|
||||
}
|
||||
|
||||
async listArchives(params: ListArchivesParams): Promise<Array<AdminArchiveResponse>> {
|
||||
const {subjectType = 'all', subjectId, requestedBy, limit = 50, includeExpired = false} = params;
|
||||
|
||||
if (subjectId !== undefined && subjectType === 'all') {
|
||||
throw new Error('subject_type must be specified when subject_id is provided');
|
||||
}
|
||||
|
||||
if (subjectId !== undefined) {
|
||||
const archives = await this.adminArchiveRepository.listBySubject(
|
||||
subjectType as ArchiveSubjectType,
|
||||
subjectId,
|
||||
limit,
|
||||
includeExpired,
|
||||
);
|
||||
return archives.map((a) => a.toResponse());
|
||||
}
|
||||
|
||||
if (requestedBy !== undefined) {
|
||||
const archives = await this.adminArchiveRepository.listByRequester(requestedBy, limit, includeExpired);
|
||||
return archives.map((a) => a.toResponse());
|
||||
}
|
||||
|
||||
if (subjectType === 'all') {
|
||||
const [users, guilds] = await Promise.all([
|
||||
this.adminArchiveRepository.listByType('user', limit, includeExpired),
|
||||
this.adminArchiveRepository.listByType('guild', limit, includeExpired),
|
||||
]);
|
||||
|
||||
return [...users, ...guilds]
|
||||
.sort((a, b) => b.requestedAt.getTime() - a.requestedAt.getTime())
|
||||
.slice(0, limit)
|
||||
.map((a) => a.toResponse());
|
||||
}
|
||||
|
||||
const archives = await this.adminArchiveRepository.listByType(
|
||||
subjectType as ArchiveSubjectType,
|
||||
limit,
|
||||
includeExpired,
|
||||
);
|
||||
return archives.map((a) => a.toResponse());
|
||||
}
|
||||
|
||||
async getDownloadUrl(
|
||||
subjectType: ArchiveSubjectType,
|
||||
subjectId: bigint,
|
||||
archiveId: bigint,
|
||||
): Promise<{downloadUrl: string; expiresAt: string}> {
|
||||
const archive = await this.adminArchiveRepository.findBySubjectAndArchiveId(subjectType, subjectId, archiveId);
|
||||
if (!archive) {
|
||||
throw new UnknownHarvestError();
|
||||
}
|
||||
|
||||
if (!archive.completedAt || !archive.storageKey) {
|
||||
throw new HarvestNotReadyError();
|
||||
}
|
||||
|
||||
if (archive.failedAt) {
|
||||
throw new HarvestFailedError();
|
||||
}
|
||||
|
||||
if (archive.expiresAt && archive.expiresAt < new Date()) {
|
||||
throw new HarvestExpiredError();
|
||||
}
|
||||
|
||||
const downloadUrl = await this.storageService.getPresignedDownloadURL({
|
||||
bucket: Config.s3.buckets.harvests,
|
||||
key: archive.storageKey,
|
||||
expiresIn: DOWNLOAD_LINK_SECONDS,
|
||||
});
|
||||
|
||||
const expiresAt = new Date(Date.now() + ms('7 days'));
|
||||
return {downloadUrl, expiresAt: expiresAt.toISOString()};
|
||||
}
|
||||
}
|
||||
201
packages/api/src/admin/services/AdminAssetPurgeService.tsx
Normal file
201
packages/api/src/admin/services/AdminAssetPurgeService.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import {createEmojiID, createStickerID, type GuildID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {mapGuildEmojiToResponse, mapGuildStickerToResponse} from '@fluxer/api/src/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {ExpressionAssetPurger} from '@fluxer/api/src/guild/services/content/ExpressionAssetPurger';
|
||||
import type {IAssetDeletionQueue} from '@fluxer/api/src/infrastructure/IAssetDeletionQueue';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {
|
||||
PurgeGuildAssetError,
|
||||
PurgeGuildAssetResult,
|
||||
PurgeGuildAssetsResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
|
||||
interface AdminAssetPurgeServiceDeps {
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
gatewayService: IGatewayService;
|
||||
assetDeletionQueue: IAssetDeletionQueue;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminAssetPurgeService {
|
||||
private readonly assetPurger: ExpressionAssetPurger;
|
||||
|
||||
constructor(private readonly deps: AdminAssetPurgeServiceDeps) {
|
||||
this.assetPurger = new ExpressionAssetPurger(deps.assetDeletionQueue);
|
||||
}
|
||||
|
||||
async purgeGuildAssets(args: {
|
||||
ids: Array<string>;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}): Promise<PurgeGuildAssetsResponse> {
|
||||
const {ids, adminUserId, auditLogReason} = args;
|
||||
const processed: Array<PurgeGuildAssetResult> = [];
|
||||
const errors: Array<PurgeGuildAssetError> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const rawId of ids) {
|
||||
const trimmedId = rawId.trim();
|
||||
if (trimmedId === '' || seen.has(trimmedId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmedId);
|
||||
|
||||
let numericId: bigint;
|
||||
try {
|
||||
numericId = BigInt(trimmedId);
|
||||
} catch {
|
||||
errors.push({id: trimmedId, error: 'Invalid numeric ID'});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.processAssetId(numericId, trimmedId, adminUserId, auditLogReason);
|
||||
processed.push(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.message !== '' ? error.message : 'Failed to purge asset';
|
||||
errors.push({id: trimmedId, error: message});
|
||||
}
|
||||
}
|
||||
|
||||
return {processed, errors};
|
||||
}
|
||||
|
||||
private async processAssetId(
|
||||
numericId: bigint,
|
||||
idString: string,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<PurgeGuildAssetResult> {
|
||||
const {guildRepository} = this.deps;
|
||||
|
||||
const emojiId = createEmojiID(numericId);
|
||||
const emoji = await guildRepository.getEmojiById(emojiId);
|
||||
if (emoji) {
|
||||
await guildRepository.deleteEmoji(emoji.guildId, emojiId);
|
||||
await this.dispatchGuildEmojisUpdate(emoji.guildId);
|
||||
await this.assetPurger.purgeEmoji(idString);
|
||||
await this.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild_emoji',
|
||||
targetId: numericId,
|
||||
action: 'purge_guild_emoji_asset',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['asset_type', 'emoji'],
|
||||
['guild_id', emoji.guildId.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
id: idString,
|
||||
asset_type: 'emoji',
|
||||
found_in_db: true,
|
||||
guild_id: emoji.guildId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
const stickerId = createStickerID(numericId);
|
||||
const sticker = await guildRepository.getStickerById(stickerId);
|
||||
if (sticker) {
|
||||
await guildRepository.deleteSticker(sticker.guildId, stickerId);
|
||||
await this.dispatchGuildStickersUpdate(sticker.guildId);
|
||||
await this.assetPurger.purgeSticker(idString);
|
||||
await this.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild_sticker',
|
||||
targetId: numericId,
|
||||
action: 'purge_guild_sticker_asset',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['asset_type', 'sticker'],
|
||||
['guild_id', sticker.guildId.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
id: idString,
|
||||
asset_type: 'sticker',
|
||||
found_in_db: true,
|
||||
guild_id: sticker.guildId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
await this.assetPurger.purgeEmoji(idString);
|
||||
await this.assetPurger.purgeSticker(idString);
|
||||
await this.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'asset',
|
||||
targetId: numericId,
|
||||
action: 'purge_asset',
|
||||
auditLogReason,
|
||||
metadata: new Map([['asset_type', 'unknown']]),
|
||||
});
|
||||
|
||||
return {
|
||||
id: idString,
|
||||
asset_type: 'unknown',
|
||||
found_in_db: false,
|
||||
guild_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async dispatchGuildEmojisUpdate(guildId: GuildID): Promise<void> {
|
||||
const {guildRepository, gatewayService} = this.deps;
|
||||
const emojis = await guildRepository.listEmojis(guildId);
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_EMOJIS_UPDATE',
|
||||
data: {emojis: emojis.map(mapGuildEmojiToResponse)},
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchGuildStickersUpdate(guildId: GuildID): Promise<void> {
|
||||
const {guildRepository, gatewayService} = this.deps;
|
||||
const stickers = await guildRepository.listStickers(guildId);
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_STICKERS_UPDATE',
|
||||
data: {stickers: stickers.map(mapGuildStickerToResponse)},
|
||||
});
|
||||
}
|
||||
|
||||
private async createAuditLog(params: {
|
||||
adminUserId: UserID;
|
||||
targetType: string;
|
||||
targetId: bigint;
|
||||
action: string;
|
||||
auditLogReason: string | null;
|
||||
metadata: Map<string, string>;
|
||||
}): Promise<void> {
|
||||
const {auditService} = this.deps;
|
||||
await auditService.createAuditLog({
|
||||
adminUserId: params.adminUserId,
|
||||
targetType: params.targetType,
|
||||
targetId: params.targetId,
|
||||
action: params.action,
|
||||
auditLogReason: params.auditLogReason,
|
||||
metadata: params.metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
234
packages/api/src/admin/services/AdminAuditService.tsx
Normal file
234
packages/api/src/admin/services/AdminAuditService.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AdminAuditLog, IAdminRepository} from '@fluxer/api/src/admin/IAdminRepository';
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import {getAuditLogSearchService} from '@fluxer/api/src/SearchFactory';
|
||||
|
||||
interface CreateAdminAuditLogParams {
|
||||
adminUserId: UserID;
|
||||
targetType: string;
|
||||
targetId: bigint;
|
||||
action: string;
|
||||
auditLogReason: string | null;
|
||||
metadata?: Map<string, string>;
|
||||
}
|
||||
|
||||
export class AdminAuditService {
|
||||
constructor(
|
||||
private readonly adminRepository: IAdminRepository,
|
||||
private readonly snowflakeService: SnowflakeService,
|
||||
) {}
|
||||
|
||||
async createAuditLog({
|
||||
adminUserId,
|
||||
targetType,
|
||||
targetId,
|
||||
action,
|
||||
auditLogReason,
|
||||
metadata = new Map(),
|
||||
}: CreateAdminAuditLogParams): Promise<void> {
|
||||
const log = await this.adminRepository.createAuditLog({
|
||||
log_id: await this.snowflakeService.generate(),
|
||||
admin_user_id: adminUserId,
|
||||
target_type: targetType,
|
||||
target_id: targetId,
|
||||
action,
|
||||
audit_log_reason: auditLogReason ?? null,
|
||||
metadata,
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const auditLogSearchService = getAuditLogSearchService();
|
||||
if (auditLogSearchService && 'indexAuditLog' in auditLogSearchService) {
|
||||
auditLogSearchService.indexAuditLog(log).catch((error) => {
|
||||
Logger.error({error, logId: log.logId}, 'Failed to index audit log to search');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async listAuditLogs(data: {
|
||||
admin_user_id?: bigint;
|
||||
target_type?: string;
|
||||
target_id?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{logs: Array<AdminAuditLogResponse>; total: number}> {
|
||||
const auditLogSearchService = getAuditLogSearchService();
|
||||
|
||||
const targetIdBigInt = data.target_id ? BigInt(data.target_id) : undefined;
|
||||
|
||||
if (!auditLogSearchService || !auditLogSearchService.isAvailable()) {
|
||||
return this.listAuditLogsFromDatabase({
|
||||
adminUserId: data.admin_user_id,
|
||||
targetType: data.target_type,
|
||||
targetId: targetIdBigInt,
|
||||
limit: data.limit,
|
||||
offset: data.offset,
|
||||
});
|
||||
}
|
||||
|
||||
const limit = data.limit || 50;
|
||||
const filters: Record<string, string> = {};
|
||||
|
||||
if (data.admin_user_id) {
|
||||
filters['adminUserId'] = data.admin_user_id.toString();
|
||||
}
|
||||
if (data.target_type) {
|
||||
filters['targetType'] = data.target_type;
|
||||
}
|
||||
if (data.target_id) {
|
||||
filters['targetId'] = data.target_id;
|
||||
}
|
||||
|
||||
const {hits, total} = await auditLogSearchService.searchAuditLogs('', filters, {
|
||||
limit,
|
||||
offset: data.offset || 0,
|
||||
});
|
||||
|
||||
const orderedLogs = await this.loadLogsInSearchOrder(hits.map((hit) => BigInt(hit.logId)));
|
||||
|
||||
return {
|
||||
logs: orderedLogs.map((log) => this.toResponse(log)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async searchAuditLogs(data: {
|
||||
query?: string;
|
||||
admin_user_id?: bigint;
|
||||
target_id?: string;
|
||||
sort_by?: 'createdAt' | 'relevance';
|
||||
sort_order?: 'asc' | 'desc';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{logs: Array<AdminAuditLogResponse>; total: number}> {
|
||||
const auditLogSearchService = getAuditLogSearchService();
|
||||
|
||||
const targetIdBigInt = data.target_id ? BigInt(data.target_id) : undefined;
|
||||
|
||||
if (!auditLogSearchService || !auditLogSearchService.isAvailable()) {
|
||||
return this.listAuditLogsFromDatabase({
|
||||
adminUserId: data.admin_user_id,
|
||||
targetId: targetIdBigInt,
|
||||
limit: data.limit,
|
||||
offset: data.offset,
|
||||
});
|
||||
}
|
||||
|
||||
const filters: Record<string, string> = {};
|
||||
|
||||
if (data.admin_user_id) {
|
||||
filters['adminUserId'] = data.admin_user_id.toString();
|
||||
}
|
||||
if (data.target_id) {
|
||||
filters['targetId'] = data.target_id;
|
||||
}
|
||||
if (data.sort_by) {
|
||||
filters['sortBy'] = data.sort_by;
|
||||
}
|
||||
if (data.sort_order) {
|
||||
filters['sortOrder'] = data.sort_order;
|
||||
}
|
||||
|
||||
const {hits, total} = await auditLogSearchService.searchAuditLogs(data.query || '', filters, {
|
||||
limit: data.limit || 50,
|
||||
offset: data.offset || 0,
|
||||
});
|
||||
|
||||
const orderedLogs = await this.loadLogsInSearchOrder(hits.map((hit) => BigInt(hit.logId)));
|
||||
|
||||
return {
|
||||
logs: orderedLogs.map((log) => this.toResponse(log)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
private async listAuditLogsFromDatabase(data: {
|
||||
adminUserId?: bigint;
|
||||
targetType?: string;
|
||||
targetId?: bigint;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{logs: Array<AdminAuditLogResponse>; total: number}> {
|
||||
const limit = data.limit || 50;
|
||||
const allLogs = await this.adminRepository.listAllAuditLogsPaginated(limit + (data.offset || 0));
|
||||
|
||||
let filteredLogs = allLogs;
|
||||
|
||||
if (data.adminUserId) {
|
||||
filteredLogs = filteredLogs.filter((log) => log.adminUserId === data.adminUserId);
|
||||
}
|
||||
|
||||
if (data.targetType) {
|
||||
filteredLogs = filteredLogs.filter((log) => log.targetType === data.targetType);
|
||||
}
|
||||
|
||||
if (data.targetId) {
|
||||
filteredLogs = filteredLogs.filter((log) => log.targetId === data.targetId);
|
||||
}
|
||||
|
||||
const offset = data.offset || 0;
|
||||
const paginatedLogs = filteredLogs.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
logs: paginatedLogs.map((log) => this.toResponse(log)),
|
||||
total: filteredLogs.length,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadLogsInSearchOrder(logIds: Array<bigint>): Promise<Array<AdminAuditLog>> {
|
||||
const logs = await this.adminRepository.listAuditLogsByIds(logIds);
|
||||
const logMap = new Map(logs.map((log) => [log.logId.toString(), log]));
|
||||
const result: Array<AdminAuditLog> = [];
|
||||
for (const logId of logIds) {
|
||||
const log = logMap.get(logId.toString());
|
||||
if (log) {
|
||||
result.push(log);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private toResponse(log: AdminAuditLog): AdminAuditLogResponse {
|
||||
return {
|
||||
log_id: log.logId.toString(),
|
||||
admin_user_id: log.adminUserId.toString(),
|
||||
target_type: log.targetType,
|
||||
target_id: log.targetId.toString(),
|
||||
action: log.action,
|
||||
audit_log_reason: log.auditLogReason,
|
||||
metadata: Object.fromEntries(log.metadata),
|
||||
created_at: log.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface AdminAuditLogResponse {
|
||||
log_id: string;
|
||||
admin_user_id: string;
|
||||
target_type: string;
|
||||
target_id: string;
|
||||
action: string;
|
||||
audit_log_reason: string | null;
|
||||
metadata: Record<string, string>;
|
||||
created_at: string;
|
||||
}
|
||||
174
packages/api/src/admin/services/AdminBanManagementService.tsx
Normal file
174
packages/api/src/admin/services/AdminBanManagementService.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {IAdminRepository} from '@fluxer/api/src/admin/IAdminRepository';
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {IP_BAN_REFRESH_CHANNEL} from '@fluxer/api/src/constants/IpBan';
|
||||
import {ipBanCache} from '@fluxer/api/src/middleware/IpBanMiddleware';
|
||||
import {getIpAddressReverse} from '@fluxer/api/src/utils/IpUtils';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
|
||||
interface AdminBanManagementServiceDeps {
|
||||
adminRepository: IAdminRepository;
|
||||
auditService: AdminAuditService;
|
||||
cacheService: ICacheService;
|
||||
}
|
||||
|
||||
export class AdminBanManagementService {
|
||||
constructor(private readonly deps: AdminBanManagementServiceDeps) {}
|
||||
|
||||
async banIp(data: {ip: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {adminRepository, auditService, cacheService} = this.deps;
|
||||
await adminRepository.banIp(data.ip);
|
||||
ipBanCache.ban(data.ip);
|
||||
await cacheService.publish(IP_BAN_REFRESH_CHANNEL, 'refresh');
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'ip',
|
||||
targetId: BigInt(0),
|
||||
action: 'ban_ip',
|
||||
auditLogReason,
|
||||
metadata: new Map([['ip', data.ip]]),
|
||||
});
|
||||
}
|
||||
|
||||
async unbanIp(data: {ip: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {adminRepository, auditService, cacheService} = this.deps;
|
||||
await adminRepository.unbanIp(data.ip);
|
||||
ipBanCache.unban(data.ip);
|
||||
await cacheService.publish(IP_BAN_REFRESH_CHANNEL, 'refresh');
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'ip',
|
||||
targetId: BigInt(0),
|
||||
action: 'unban_ip',
|
||||
auditLogReason,
|
||||
metadata: new Map([['ip', data.ip]]),
|
||||
});
|
||||
}
|
||||
|
||||
async checkIpBan(data: {ip: string}): Promise<{banned: boolean}> {
|
||||
const banned = ipBanCache.isBanned(data.ip);
|
||||
return {banned};
|
||||
}
|
||||
|
||||
async banEmail(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {adminRepository, auditService} = this.deps;
|
||||
await adminRepository.banEmail(data.email);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'email',
|
||||
targetId: BigInt(0),
|
||||
action: 'ban_email',
|
||||
auditLogReason,
|
||||
metadata: new Map([['email', data.email]]),
|
||||
});
|
||||
}
|
||||
|
||||
async unbanEmail(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {adminRepository, auditService} = this.deps;
|
||||
await adminRepository.unbanEmail(data.email);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'email',
|
||||
targetId: BigInt(0),
|
||||
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 banPhone(data: {phone: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {adminRepository, auditService} = this.deps;
|
||||
await adminRepository.banPhone(data.phone);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'phone',
|
||||
targetId: BigInt(0),
|
||||
action: 'ban_phone',
|
||||
auditLogReason,
|
||||
metadata: new Map([['phone', data.phone]]),
|
||||
});
|
||||
}
|
||||
|
||||
async unbanPhone(data: {phone: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {adminRepository, auditService} = this.deps;
|
||||
await adminRepository.unbanPhone(data.phone);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'phone',
|
||||
targetId: BigInt(0),
|
||||
action: 'unban_phone',
|
||||
auditLogReason,
|
||||
metadata: new Map([['phone', data.phone]]),
|
||||
});
|
||||
}
|
||||
|
||||
async checkPhoneBan(data: {phone: string}): Promise<{banned: boolean}> {
|
||||
const {adminRepository} = this.deps;
|
||||
const banned = await adminRepository.isPhoneBanned(data.phone);
|
||||
return {banned};
|
||||
}
|
||||
|
||||
async listIpBans(data: {limit: number}): Promise<{bans: Array<{ip: string; reverse_dns: string | null}>}> {
|
||||
const {adminRepository, cacheService} = this.deps;
|
||||
const ips = await adminRepository.listBannedIps(data.limit);
|
||||
|
||||
const reverseResults = await Promise.allSettled(
|
||||
ips.map((ip) => {
|
||||
// CIDR ranges can't be reverse-resolved.
|
||||
if (ip.includes('/')) return Promise.resolve(null);
|
||||
return getIpAddressReverse(ip, cacheService);
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
bans: ips.map((ip, index) => {
|
||||
const reverseResult = reverseResults[index];
|
||||
return {
|
||||
ip,
|
||||
reverse_dns: reverseResult?.status === 'fulfilled' ? reverseResult.value : null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async listEmailBans(data: {limit: number}): Promise<{bans: Array<string>}> {
|
||||
const {adminRepository} = this.deps;
|
||||
return {bans: await adminRepository.listBannedEmails(data.limit)};
|
||||
}
|
||||
|
||||
async listPhoneBans(data: {limit: number}): Promise<{bans: Array<string>}> {
|
||||
const {adminRepository} = this.deps;
|
||||
return {bans: await adminRepository.listBannedPhones(data.limit)};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {SYSTEM_USER_ID} from '@fluxer/api/src/constants/Core';
|
||||
import type {GiftCodeRow} from '@fluxer/api/src/database/types/PaymentTypes';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import * as RandomUtils from '@fluxer/api/src/utils/RandomUtils';
|
||||
|
||||
const CODE_LENGTH = 32;
|
||||
const MAX_GENERATION_ATTEMPTS = 100;
|
||||
|
||||
export class AdminCodeGenerationService {
|
||||
constructor(private readonly userRepository: IUserRepository) {}
|
||||
|
||||
async generateGiftCodes(count: number, durationMonths: number): Promise<Array<string>> {
|
||||
const codes: Array<string> = [];
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const code = await this.generateUniqueGiftCode();
|
||||
const giftCodeRow: GiftCodeRow = {
|
||||
code,
|
||||
duration_months: durationMonths,
|
||||
created_at: new Date(),
|
||||
created_by_user_id: SYSTEM_USER_ID,
|
||||
redeemed_at: null,
|
||||
redeemed_by_user_id: null,
|
||||
stripe_payment_intent_id: null,
|
||||
visionary_sequence_number: null,
|
||||
checkout_session_id: null,
|
||||
version: 1,
|
||||
};
|
||||
await this.userRepository.createGiftCode(giftCodeRow);
|
||||
codes.push(code);
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
private async generateUniqueGiftCode(): Promise<string> {
|
||||
for (let attempt = 0; attempt < MAX_GENERATION_ATTEMPTS; attempt++) {
|
||||
const candidate = RandomUtils.randomString(CODE_LENGTH);
|
||||
const exists = await this.userRepository.findGiftCode(candidate);
|
||||
if (!exists) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to generate unique gift code after ${MAX_GENERATION_ATTEMPTS} attempts. ` +
|
||||
'This may indicate a high collision rate or database issues.',
|
||||
);
|
||||
}
|
||||
}
|
||||
242
packages/api/src/admin/services/AdminGuildService.tsx
Normal file
242
packages/api/src/admin/services/AdminGuildService.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import {AdminGuildBulkService} from '@fluxer/api/src/admin/services/guild/AdminGuildBulkService';
|
||||
import {AdminGuildLookupService} from '@fluxer/api/src/admin/services/guild/AdminGuildLookupService';
|
||||
import {AdminGuildManagementService} from '@fluxer/api/src/admin/services/guild/AdminGuildManagementService';
|
||||
import {AdminGuildMembershipService} from '@fluxer/api/src/admin/services/guild/AdminGuildMembershipService';
|
||||
import {AdminGuildUpdatePropagator} from '@fluxer/api/src/admin/services/guild/AdminGuildUpdatePropagator';
|
||||
import {AdminGuildUpdateService} from '@fluxer/api/src/admin/services/guild/AdminGuildUpdateService';
|
||||
import {AdminGuildVanityService} from '@fluxer/api/src/admin/services/guild/AdminGuildVanityService';
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
|
||||
import type {EntityAssetService} from '@fluxer/api/src/infrastructure/EntityAssetService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {InviteRepository} from '@fluxer/api/src/invite/InviteRepository';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {
|
||||
BanGuildMemberRequest,
|
||||
BulkAddGuildMembersRequest,
|
||||
BulkUpdateGuildFeaturesRequest,
|
||||
ClearGuildFieldsRequest,
|
||||
ForceAddUserToGuildRequest,
|
||||
KickGuildMemberRequest,
|
||||
ListGuildMembersRequest,
|
||||
ListUserGuildsRequest,
|
||||
LookupGuildRequest,
|
||||
TransferGuildOwnershipRequest,
|
||||
UpdateGuildNameRequest,
|
||||
UpdateGuildSettingsRequest,
|
||||
UpdateGuildVanityRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
import type {ListGuildEmojisResponse, ListGuildStickersResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
|
||||
interface AdminGuildServiceDeps {
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
userRepository: IUserRepository;
|
||||
channelRepository: IChannelRepository;
|
||||
inviteRepository: InviteRepository;
|
||||
guildService: GuildService;
|
||||
gatewayService: IGatewayService;
|
||||
entityAssetService: EntityAssetService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminGuildService {
|
||||
private readonly lookupService: AdminGuildLookupService;
|
||||
private readonly updateService: AdminGuildUpdateService;
|
||||
private readonly vanityService: AdminGuildVanityService;
|
||||
private readonly membershipService: AdminGuildMembershipService;
|
||||
private readonly bulkService: AdminGuildBulkService;
|
||||
private readonly managementService: AdminGuildManagementService;
|
||||
private readonly updatePropagator: AdminGuildUpdatePropagator;
|
||||
|
||||
constructor(deps: AdminGuildServiceDeps) {
|
||||
this.updatePropagator = new AdminGuildUpdatePropagator({
|
||||
gatewayService: deps.gatewayService,
|
||||
});
|
||||
|
||||
this.lookupService = new AdminGuildLookupService({
|
||||
guildRepository: deps.guildRepository,
|
||||
userRepository: deps.userRepository,
|
||||
channelRepository: deps.channelRepository,
|
||||
gatewayService: deps.gatewayService,
|
||||
});
|
||||
|
||||
this.updateService = new AdminGuildUpdateService({
|
||||
guildRepository: deps.guildRepository,
|
||||
entityAssetService: deps.entityAssetService,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
});
|
||||
|
||||
this.vanityService = new AdminGuildVanityService({
|
||||
guildRepository: deps.guildRepository,
|
||||
inviteRepository: deps.inviteRepository,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
});
|
||||
|
||||
this.membershipService = new AdminGuildMembershipService({
|
||||
userRepository: deps.userRepository,
|
||||
guildService: deps.guildService,
|
||||
auditService: deps.auditService,
|
||||
});
|
||||
|
||||
this.bulkService = new AdminGuildBulkService({
|
||||
guildUpdateService: this.updateService,
|
||||
auditService: deps.auditService,
|
||||
});
|
||||
|
||||
this.managementService = new AdminGuildManagementService({
|
||||
guildRepository: deps.guildRepository,
|
||||
gatewayService: deps.gatewayService,
|
||||
guildService: deps.guildService,
|
||||
auditService: deps.auditService,
|
||||
});
|
||||
}
|
||||
|
||||
async lookupGuild(data: LookupGuildRequest) {
|
||||
return this.lookupService.lookupGuild(data);
|
||||
}
|
||||
|
||||
async listUserGuilds(data: ListUserGuildsRequest) {
|
||||
return this.lookupService.listUserGuilds(data);
|
||||
}
|
||||
|
||||
async listGuildMembers(data: ListGuildMembersRequest) {
|
||||
return this.lookupService.listGuildMembers(data);
|
||||
}
|
||||
|
||||
async listGuildEmojis(guildId: GuildID): Promise<ListGuildEmojisResponse> {
|
||||
return this.lookupService.listGuildEmojis(guildId);
|
||||
}
|
||||
|
||||
async listGuildStickers(guildId: GuildID): Promise<ListGuildStickersResponse> {
|
||||
return this.lookupService.listGuildStickers(guildId);
|
||||
}
|
||||
|
||||
async updateGuildFeatures({
|
||||
guildId,
|
||||
addFeatures,
|
||||
removeFeatures,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: {
|
||||
guildId: GuildID;
|
||||
addFeatures: Array<string>;
|
||||
removeFeatures: Array<string>;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
return this.updateService.updateGuildFeatures({
|
||||
guildId,
|
||||
addFeatures,
|
||||
removeFeatures,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
});
|
||||
}
|
||||
|
||||
async clearGuildFields(data: ClearGuildFieldsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.updateService.clearGuildFields(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateGuildName(data: UpdateGuildNameRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.updateService.updateGuildName(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateGuildSettings(data: UpdateGuildSettingsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.updateService.updateGuildSettings(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async transferGuildOwnership(
|
||||
data: TransferGuildOwnershipRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.updateService.transferGuildOwnership(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateGuildVanity(data: UpdateGuildVanityRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.vanityService.updateGuildVanity(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async forceAddUserToGuild({
|
||||
data,
|
||||
requestCache,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: {
|
||||
data: ForceAddUserToGuildRequest;
|
||||
requestCache: RequestCache;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
return this.membershipService.forceAddUserToGuild({data, requestCache, adminUserId, auditLogReason});
|
||||
}
|
||||
|
||||
async bulkAddGuildMembers(data: BulkAddGuildMembersRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.membershipService.bulkAddGuildMembers(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async banGuildMember(data: BanGuildMemberRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.membershipService.banMember(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async kickGuildMember(data: KickGuildMemberRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.membershipService.kickMember(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkUpdateGuildFeatures(
|
||||
data: BulkUpdateGuildFeaturesRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.bulkService.bulkUpdateGuildFeatures(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async reloadGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.managementService.reloadGuild(guildIdRaw, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async shutdownGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.managementService.shutdownGuild(guildIdRaw, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async deleteGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.managementService.deleteGuild(guildIdRaw, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async getGuildMemoryStats(limit: number) {
|
||||
return this.managementService.getGuildMemoryStats(limit);
|
||||
}
|
||||
|
||||
async reloadAllGuilds(guildIds: Array<GuildID>) {
|
||||
return this.managementService.reloadAllGuilds(guildIds);
|
||||
}
|
||||
|
||||
async getNodeStats() {
|
||||
return this.managementService.getNodeStats();
|
||||
}
|
||||
}
|
||||
165
packages/api/src/admin/services/AdminMessageDeletionService.tsx
Normal file
165
packages/api/src/admin/services/AdminMessageDeletionService.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import type {AdminMessageShredService} from '@fluxer/api/src/admin/services/AdminMessageShredService';
|
||||
import type {ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
|
||||
import type {
|
||||
DeleteAllUserMessagesRequest,
|
||||
DeleteAllUserMessagesResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminMessageSchemas';
|
||||
import {recordCounter} from '@fluxer/telemetry/src/Metrics';
|
||||
|
||||
interface AdminMessageDeletionServiceDeps {
|
||||
channelRepository: IChannelRepository;
|
||||
messageShredService: AdminMessageShredService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
const FETCH_CHUNK_SIZE = 200;
|
||||
|
||||
export class AdminMessageDeletionService {
|
||||
constructor(private readonly deps: AdminMessageDeletionServiceDeps) {}
|
||||
|
||||
async deleteAllUserMessages(
|
||||
data: DeleteAllUserMessagesRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<DeleteAllUserMessagesResponse> {
|
||||
const authorId = createUserID(data.user_id);
|
||||
|
||||
return await withBusinessSpan(
|
||||
'fluxer.admin.message_bulk_delete',
|
||||
'fluxer.admin.messages.bulk_deleted',
|
||||
{
|
||||
user_id: data.user_id.toString(),
|
||||
dry_run: data.dry_run ? 'true' : 'false',
|
||||
},
|
||||
async () => {
|
||||
const {entries, channelCount, messageCount} = await this.collectMessageRefs(authorId, !data.dry_run);
|
||||
|
||||
const metadata = new Map<string, string>([
|
||||
['user_id', data.user_id.toString()],
|
||||
['channel_count', channelCount.toString()],
|
||||
['message_count', messageCount.toString()],
|
||||
['dry_run', data.dry_run ? 'true' : 'false'],
|
||||
]);
|
||||
|
||||
const action = data.dry_run ? 'delete_all_user_messages_dry_run' : 'delete_all_user_messages';
|
||||
|
||||
await this.deps.auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'message_deletion',
|
||||
targetId: data.user_id,
|
||||
action,
|
||||
auditLogReason,
|
||||
metadata,
|
||||
});
|
||||
|
||||
Logger.debug(
|
||||
{user_id: data.user_id, channel_count: channelCount, message_count: messageCount, dry_run: data.dry_run},
|
||||
'Computed delete-all-messages stats',
|
||||
);
|
||||
|
||||
const response: DeleteAllUserMessagesResponse = {
|
||||
success: true,
|
||||
dry_run: data.dry_run,
|
||||
channel_count: channelCount,
|
||||
message_count: messageCount,
|
||||
};
|
||||
|
||||
if (data.dry_run || messageCount === 0) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const shredResult = await this.deps.messageShredService.queueMessageShred(
|
||||
{
|
||||
user_id: data.user_id,
|
||||
entries,
|
||||
},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
);
|
||||
|
||||
response.job_id = shredResult.job_id;
|
||||
|
||||
recordCounter({
|
||||
name: 'fluxer.admin.messages.bulk_deleted_count',
|
||||
value: messageCount,
|
||||
dimensions: {
|
||||
user_id: data.user_id.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async collectMessageRefs(authorId: UserID, includeEntries: boolean) {
|
||||
let lastMessageId: MessageID | undefined;
|
||||
const entries: Array<{channel_id: ChannelID; message_id: MessageID}> = [];
|
||||
let messageCount = 0;
|
||||
let channelCount = 0;
|
||||
|
||||
while (true) {
|
||||
const messageRefs = await this.deps.channelRepository.listMessagesByAuthor(
|
||||
authorId,
|
||||
FETCH_CHUNK_SIZE,
|
||||
lastMessageId,
|
||||
);
|
||||
|
||||
if (messageRefs.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const channelsInChunk = new Set<string>();
|
||||
|
||||
for (const {channelId, messageId} of messageRefs) {
|
||||
channelsInChunk.add(channelId.toString());
|
||||
messageCount += 1;
|
||||
|
||||
if (includeEntries) {
|
||||
entries.push({
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
channelCount += channelsInChunk.size;
|
||||
|
||||
lastMessageId = messageRefs[messageRefs.length - 1].messageId;
|
||||
|
||||
if (messageRefs.length < FETCH_CHUNK_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
channelCount,
|
||||
messageCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
218
packages/api/src/admin/services/AdminMessageService.tsx
Normal file
218
packages/api/src/admin/services/AdminMessageService.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import {
|
||||
type AttachmentID,
|
||||
type ChannelID,
|
||||
createChannelID,
|
||||
createMessageID,
|
||||
createUserID,
|
||||
type MessageID,
|
||||
type UserID,
|
||||
} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import {createRequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
|
||||
import type {
|
||||
DeleteMessageRequest,
|
||||
LookupMessageByAttachmentRequest,
|
||||
LookupMessageRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminMessageSchemas';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
|
||||
interface AdminMessageServiceDeps {
|
||||
channelRepository: IChannelRepository;
|
||||
userCacheService: UserCacheService;
|
||||
mediaService: IMediaService;
|
||||
gatewayService: IGatewayService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminMessageService {
|
||||
constructor(private readonly deps: AdminMessageServiceDeps) {}
|
||||
|
||||
async lookupAttachment({
|
||||
channelId,
|
||||
attachmentId,
|
||||
filename,
|
||||
}: {
|
||||
channelId: ChannelID;
|
||||
attachmentId: AttachmentID;
|
||||
filename: string;
|
||||
}): Promise<{message_id: MessageID | null}> {
|
||||
const {channelRepository} = this.deps;
|
||||
const messageId = await channelRepository.lookupAttachmentByChannelAndFilename(channelId, attachmentId, filename);
|
||||
return {
|
||||
message_id: messageId,
|
||||
};
|
||||
}
|
||||
|
||||
async lookupMessage(data: LookupMessageRequest) {
|
||||
const {channelRepository, userCacheService, mediaService} = this.deps;
|
||||
const channelId = createChannelID(data.channel_id);
|
||||
const messageId = createMessageID(data.message_id);
|
||||
const contextPerSide = Math.floor(data.context_limit / 2);
|
||||
|
||||
const [targetMessage, messagesBefore, messagesAfter] = await Promise.all([
|
||||
channelRepository.getMessage(channelId, messageId),
|
||||
channelRepository.listMessages(channelId, messageId, contextPerSide),
|
||||
channelRepository.listMessages(channelId, undefined, contextPerSide, messageId),
|
||||
]);
|
||||
|
||||
const allMessages = [...messagesBefore.reverse(), ...(targetMessage ? [targetMessage] : []), ...messagesAfter];
|
||||
|
||||
const requestCache = createRequestCache();
|
||||
|
||||
const messageResponses = await Promise.all(
|
||||
allMessages.map((message) =>
|
||||
mapMessageToResponse({
|
||||
message,
|
||||
currentUserId: undefined,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
mediaService,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
messages: messageResponses.map((message) => this.mapMessageResponseToAdminMessage(message)),
|
||||
message_id: messageId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async lookupMessageByAttachment(data: LookupMessageByAttachmentRequest) {
|
||||
const channelId = createChannelID(data.channel_id);
|
||||
const attachmentId = data.attachment_id as AttachmentID;
|
||||
|
||||
const messageId = await this.deps.channelRepository.lookupAttachmentByChannelAndFilename(
|
||||
channelId,
|
||||
attachmentId,
|
||||
data.filename,
|
||||
);
|
||||
|
||||
if (!messageId) {
|
||||
return {
|
||||
messages: [],
|
||||
message_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.lookupMessage({
|
||||
channel_id: data.channel_id,
|
||||
message_id: BigInt(messageId),
|
||||
context_limit: data.context_limit,
|
||||
});
|
||||
|
||||
return {
|
||||
messages: result.messages,
|
||||
message_id: messageId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteMessage(data: DeleteMessageRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {channelRepository, gatewayService, auditService} = this.deps;
|
||||
const channelId = createChannelID(data.channel_id);
|
||||
const messageId = createMessageID(data.message_id);
|
||||
|
||||
return await withBusinessSpan(
|
||||
'fluxer.admin.message_delete',
|
||||
'fluxer.admin.messages.deleted',
|
||||
{
|
||||
channel_id: channelId.toString(),
|
||||
reason: auditLogReason ?? 'none',
|
||||
},
|
||||
async () => {
|
||||
const channel = await channelRepository.findUnique(channelId);
|
||||
const message = await channelRepository.getMessage(channelId, messageId);
|
||||
|
||||
if (message) {
|
||||
await channelRepository.deleteMessage(
|
||||
channelId,
|
||||
messageId,
|
||||
message.authorId || createUserID(0n),
|
||||
message.pinnedTimestamp || undefined,
|
||||
);
|
||||
|
||||
if (channel) {
|
||||
if (channel.guildId) {
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId: channel.guildId,
|
||||
event: 'MESSAGE_DELETE',
|
||||
data: {
|
||||
channel_id: channelId.toString(),
|
||||
id: messageId.toString(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
for (const recipientId of channel.recipientIds) {
|
||||
await gatewayService.dispatchPresence({
|
||||
userId: recipientId,
|
||||
event: 'MESSAGE_DELETE',
|
||||
data: {
|
||||
channel_id: channelId.toString(),
|
||||
id: messageId.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'message',
|
||||
targetId: BigInt(messageId),
|
||||
action: 'delete_message',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['channel_id', channelId.toString()],
|
||||
['message_id', messageId.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private mapMessageResponseToAdminMessage(message: MessageResponse) {
|
||||
return {
|
||||
id: message.id,
|
||||
channel_id: message.channel_id ?? '',
|
||||
author_id: message.author.id,
|
||||
author_username: message.author.username,
|
||||
author_discriminator: message.author.discriminator,
|
||||
content: message.content ?? '',
|
||||
timestamp: message.timestamp,
|
||||
attachments:
|
||||
message.attachments?.map((attachment) => ({
|
||||
filename: attachment.filename,
|
||||
url: attachment.url,
|
||||
})) ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
147
packages/api/src/admin/services/AdminMessageShredService.tsx
Normal file
147
packages/api/src/admin/services/AdminMessageShredService.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {MessageShredRequest} from '@fluxer/schema/src/domains/admin/AdminMessageSchemas';
|
||||
import {recordCounter} from '@fluxer/telemetry/src/Metrics';
|
||||
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
|
||||
import type {WorkerJobPayload} from '@fluxer/worker/src/contracts/WorkerTypes';
|
||||
|
||||
export type MessageShredStatusCacheEntry = {
|
||||
status: 'in_progress' | 'completed' | 'failed';
|
||||
requested: number;
|
||||
total: number;
|
||||
processed: number;
|
||||
skipped: number;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
failed_at?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type MessageShredStatusResult =
|
||||
| MessageShredStatusCacheEntry
|
||||
| {
|
||||
status: 'not_found';
|
||||
};
|
||||
|
||||
interface AdminMessageShredServiceDeps {
|
||||
workerService: IWorkerService;
|
||||
cacheService: ICacheService;
|
||||
snowflakeService: SnowflakeService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
interface QueueMessageShredJobPayload extends WorkerJobPayload {
|
||||
job_id: string;
|
||||
admin_user_id: string;
|
||||
target_user_id: string;
|
||||
entries: Array<{channel_id: string; message_id: string}>;
|
||||
}
|
||||
|
||||
export class AdminMessageShredService {
|
||||
constructor(private readonly deps: AdminMessageShredServiceDeps) {}
|
||||
|
||||
async queueMessageShred(
|
||||
data: MessageShredRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<{success: true; job_id: string; requested: number}> {
|
||||
return await withBusinessSpan(
|
||||
'fluxer.admin.message_shred_queue',
|
||||
'fluxer.admin.messages.shred_queued',
|
||||
{
|
||||
user_id: data.user_id.toString(),
|
||||
entry_count: data.entries.length.toString(),
|
||||
},
|
||||
async () => {
|
||||
if (data.entries.length === 0) {
|
||||
throw InputValidationError.fromCode('entries', ValidationErrorCodes.AT_LEAST_ONE_ENTRY_IS_REQUIRED);
|
||||
}
|
||||
|
||||
const jobId = (await this.deps.snowflakeService.generate()).toString();
|
||||
const payload: QueueMessageShredJobPayload = {
|
||||
job_id: jobId,
|
||||
admin_user_id: adminUserId.toString(),
|
||||
target_user_id: data.user_id.toString(),
|
||||
entries: data.entries.map((entry) => ({
|
||||
channel_id: entry.channel_id.toString(),
|
||||
message_id: entry.message_id.toString(),
|
||||
})),
|
||||
};
|
||||
|
||||
await this.deps.workerService.addJob('messageShred', payload, {
|
||||
jobKey: `message_shred_${data.user_id.toString()}_${jobId}`,
|
||||
maxAttempts: 1,
|
||||
});
|
||||
|
||||
Logger.debug({target_user_id: data.user_id, job_id: jobId}, 'Queued message shred job');
|
||||
|
||||
const metadata = new Map<string, string>([
|
||||
['user_id', data.user_id.toString()],
|
||||
['job_id', jobId],
|
||||
['requested_entries', data.entries.length.toString()],
|
||||
]);
|
||||
|
||||
await this.deps.auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'message_shred',
|
||||
targetId: data.user_id,
|
||||
action: 'queue_message_shred',
|
||||
auditLogReason,
|
||||
metadata,
|
||||
});
|
||||
|
||||
recordCounter({
|
||||
name: 'fluxer.admin.messages.shred_queued_count',
|
||||
value: data.entries.length,
|
||||
dimensions: {
|
||||
user_id: data.user_id.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
job_id: jobId,
|
||||
requested: data.entries.length,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getMessageShredStatus(jobId: string): Promise<MessageShredStatusResult> {
|
||||
const statusKey = `message_shred_status:${jobId}`;
|
||||
const status = await this.deps.cacheService.get<MessageShredStatusCacheEntry>(statusKey);
|
||||
|
||||
if (!status) {
|
||||
return {
|
||||
status: 'not_found',
|
||||
};
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
}
|
||||
337
packages/api/src/admin/services/AdminReportService.tsx
Normal file
337
packages/api/src/admin/services/AdminReportService.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import type {ChannelID, GuildID, ReportID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createReportID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import {makeAttachmentCdnKey} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import type {MessageAttachment} from '@fluxer/api/src/database/types/MessageTypes';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import {createRequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {IARMessageContext, IARSubmission} from '@fluxer/api/src/report/IReportRepository';
|
||||
import type {ReportService} from '@fluxer/api/src/report/ReportService';
|
||||
import {getReportSearchService} from '@fluxer/api/src/SearchFactory';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
import type {SearchReportsRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
interface AdminReportServiceDeps {
|
||||
reportService: ReportService;
|
||||
userRepository: IUserRepository;
|
||||
emailService: IEmailService;
|
||||
storageService: IStorageService;
|
||||
auditService: AdminAuditService;
|
||||
userCacheService: UserCacheService;
|
||||
}
|
||||
|
||||
export class AdminReportService {
|
||||
constructor(private readonly deps: AdminReportServiceDeps) {}
|
||||
|
||||
async listReports(status: number, limit?: number, offset?: number) {
|
||||
const {reportService} = this.deps;
|
||||
const requestedLimit = limit || 50;
|
||||
const currentOffset = offset || 0;
|
||||
|
||||
const reports = await reportService.listReportsByStatus(status, requestedLimit, currentOffset);
|
||||
const requestCache = createRequestCache();
|
||||
const reportResponses = await Promise.all(
|
||||
reports.map((report: IARSubmission) => this.mapReportToResponse(report, false, requestCache)),
|
||||
);
|
||||
|
||||
return {
|
||||
reports: reportResponses,
|
||||
};
|
||||
}
|
||||
|
||||
async getReport(reportId: ReportID) {
|
||||
const {reportService} = this.deps;
|
||||
const report = await reportService.getReport(reportId);
|
||||
const requestCache = createRequestCache();
|
||||
return this.mapReportToResponse(report, true, requestCache);
|
||||
}
|
||||
|
||||
async resolveReport(
|
||||
reportId: ReportID,
|
||||
adminUserId: UserID,
|
||||
publicComment: string | null,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {reportService, userRepository, emailService, auditService} = this.deps;
|
||||
const resolvedReport = await reportService.resolveReport(reportId, adminUserId, publicComment, auditLogReason);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'report',
|
||||
targetId: BigInt(reportId),
|
||||
action: 'resolve_report',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['report_id', reportId.toString()],
|
||||
['report_type', resolvedReport.reportType.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
if (resolvedReport.reporterId && publicComment) {
|
||||
const reporter = await userRepository.findUnique(resolvedReport.reporterId);
|
||||
if (reporter?.email) {
|
||||
await emailService.sendReportResolvedEmail(
|
||||
reporter.email,
|
||||
reporter.username,
|
||||
reportId.toString(),
|
||||
publicComment,
|
||||
reporter.locale,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
report_id: resolvedReport.reportId.toString(),
|
||||
status: resolvedReport.status,
|
||||
resolved_at: resolvedReport.resolvedAt?.toISOString() ?? null,
|
||||
public_comment: resolvedReport.publicComment,
|
||||
};
|
||||
}
|
||||
|
||||
async searchReports(data: SearchReportsRequest) {
|
||||
const reportSearchService = getReportSearchService();
|
||||
if (!reportSearchService) {
|
||||
throw new Error('Search is not enabled');
|
||||
}
|
||||
|
||||
const filters: Record<string, string | number> = {};
|
||||
if (data.reporter_id !== undefined) {
|
||||
filters['reporterId'] = data.reporter_id.toString();
|
||||
}
|
||||
if (data.status !== undefined) {
|
||||
filters['status'] = data.status;
|
||||
}
|
||||
if (data.report_type !== undefined) {
|
||||
filters['reportType'] = data.report_type;
|
||||
}
|
||||
if (data.category !== undefined) {
|
||||
filters['category'] = data.category;
|
||||
}
|
||||
if (data.reported_user_id !== undefined) {
|
||||
filters['reportedUserId'] = data.reported_user_id.toString();
|
||||
}
|
||||
if (data.reported_guild_id !== undefined) {
|
||||
filters['reportedGuildId'] = data.reported_guild_id.toString();
|
||||
}
|
||||
if (data.reported_channel_id !== undefined) {
|
||||
filters['reportedChannelId'] = data.reported_channel_id.toString();
|
||||
}
|
||||
if (data.guild_context_id !== undefined) {
|
||||
filters['guildContextId'] = data.guild_context_id.toString();
|
||||
}
|
||||
if (data.resolved_by_admin_id !== undefined) {
|
||||
filters['resolvedByAdminId'] = data.resolved_by_admin_id.toString();
|
||||
}
|
||||
if (data.sort_by) {
|
||||
filters['sortBy'] = data.sort_by;
|
||||
}
|
||||
if (data.sort_order) {
|
||||
filters['sortOrder'] = data.sort_order;
|
||||
}
|
||||
|
||||
const {hits, total} = await reportSearchService.searchReports(data.query || '', filters, {
|
||||
limit: data.limit,
|
||||
offset: data.offset,
|
||||
});
|
||||
|
||||
const requestCache = createRequestCache();
|
||||
const orderedReports = await this.loadReportsInSearchOrder(hits.map((hit) => createReportID(BigInt(hit.id))));
|
||||
const reports = await Promise.all(
|
||||
orderedReports.map((report) => this.mapReportToResponse(report, false, requestCache)),
|
||||
);
|
||||
|
||||
return {
|
||||
reports,
|
||||
total,
|
||||
offset: data.offset,
|
||||
limit: data.limit,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadReportsInSearchOrder(reportIds: Array<ReportID>): Promise<Array<IARSubmission>> {
|
||||
const reports = await Promise.all(
|
||||
reportIds.map(async (reportId) => {
|
||||
try {
|
||||
return await this.deps.reportService.getReport(reportId);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return reports.filter((report): report is IARSubmission => report !== null);
|
||||
}
|
||||
|
||||
private async mapReportToResponse(report: IARSubmission, includeContext: boolean, requestCache: RequestCache) {
|
||||
const reporterInfo = await this.buildUserTag(report.reporterId, requestCache);
|
||||
const reportedUserInfo = await this.buildUserTag(report.reportedUserId, requestCache);
|
||||
|
||||
const baseResponse = {
|
||||
report_id: report.reportId.toString(),
|
||||
reporter_id: report.reporterId?.toString() ?? null,
|
||||
reporter_tag: reporterInfo?.tag ?? null,
|
||||
reporter_username: reporterInfo?.username ?? null,
|
||||
reporter_discriminator: reporterInfo?.discriminator ?? null,
|
||||
reporter_email: report.reporterEmail,
|
||||
reporter_full_legal_name: report.reporterFullLegalName,
|
||||
reporter_country_of_residence: report.reporterCountryOfResidence,
|
||||
reported_at: report.reportedAt.toISOString(),
|
||||
status: report.status,
|
||||
report_type: report.reportType,
|
||||
category: report.category,
|
||||
additional_info: report.additionalInfo,
|
||||
reported_user_id: report.reportedUserId?.toString() ?? null,
|
||||
reported_user_tag: reportedUserInfo?.tag ?? null,
|
||||
reported_user_username: reportedUserInfo?.username ?? null,
|
||||
reported_user_discriminator: reportedUserInfo?.discriminator ?? null,
|
||||
reported_user_avatar_hash: report.reportedUserAvatarHash,
|
||||
reported_guild_id: report.reportedGuildId?.toString() ?? null,
|
||||
reported_guild_name: report.reportedGuildName,
|
||||
reported_message_id: report.reportedMessageId?.toString() ?? null,
|
||||
reported_channel_id: report.reportedChannelId?.toString() ?? null,
|
||||
reported_channel_name: report.reportedChannelName,
|
||||
reported_guild_invite_code: report.reportedGuildInviteCode,
|
||||
resolved_at: report.resolvedAt?.toISOString() ?? null,
|
||||
resolved_by_admin_id: report.resolvedByAdminId?.toString() ?? null,
|
||||
public_comment: report.publicComment,
|
||||
};
|
||||
|
||||
if (!includeContext) {
|
||||
return baseResponse;
|
||||
}
|
||||
|
||||
const messageContext =
|
||||
report.messageContext && report.messageContext.length > 0
|
||||
? await Promise.all(
|
||||
report.messageContext.map((message) =>
|
||||
this.mapReportMessageContextToResponse(
|
||||
message,
|
||||
report.reportedChannelId ?? null,
|
||||
report.reportedGuildId ?? report.guildContextId ?? null,
|
||||
),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const mutualDmChannelId = await this.getMutualDmChannelId(report);
|
||||
|
||||
return {
|
||||
...baseResponse,
|
||||
mutual_dm_channel_id: mutualDmChannelId,
|
||||
message_context: messageContext,
|
||||
};
|
||||
}
|
||||
|
||||
private async getMutualDmChannelId(report: IARSubmission): Promise<string | null> {
|
||||
if (report.reportType !== 1 || !report.reporterId || !report.reportedUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mutualDmChannel = await this.deps.userRepository.findExistingDmState(
|
||||
report.reporterId,
|
||||
report.reportedUserId,
|
||||
);
|
||||
return mutualDmChannel ? mutualDmChannel.id.toString() : null;
|
||||
}
|
||||
|
||||
private async mapReportMessageContextToResponse(
|
||||
message: IARMessageContext,
|
||||
fallbackChannelId: ChannelID | null,
|
||||
fallbackGuildId: GuildID | null,
|
||||
) {
|
||||
const channelId = message.channelId ?? fallbackChannelId;
|
||||
const attachments =
|
||||
message.attachments && message.attachments.length > 0
|
||||
? (
|
||||
await Promise.all(
|
||||
message.attachments.map((attachment) => this.mapReportAttachmentToResponse(attachment, channelId)),
|
||||
)
|
||||
).filter((attachment): attachment is {filename: string; url: string} => attachment !== null)
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: message.messageId.toString(),
|
||||
channel_id: channelId ? channelId.toString() : '',
|
||||
guild_id: fallbackGuildId ? fallbackGuildId.toString() : null,
|
||||
content: message.content ?? '',
|
||||
timestamp: message.timestamp.toISOString(),
|
||||
attachments,
|
||||
author_id: message.authorId.toString(),
|
||||
author_username: message.authorUsername,
|
||||
author_discriminator: message.authorDiscriminator.toString().padStart(4, '0'),
|
||||
};
|
||||
}
|
||||
|
||||
private async mapReportAttachmentToResponse(
|
||||
attachment: MessageAttachment,
|
||||
channelId: ChannelID | null,
|
||||
): Promise<{filename: string; url: string} | null> {
|
||||
if (!attachment || attachment.attachment_id == null || !attachment.filename || !channelId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {storageService} = this.deps;
|
||||
const attachmentId = attachment.attachment_id;
|
||||
const filename = String(attachment.filename);
|
||||
const key = makeAttachmentCdnKey(channelId, attachmentId, filename);
|
||||
|
||||
try {
|
||||
const url = await storageService.getPresignedDownloadURL({
|
||||
bucket: Config.s3.buckets.reports,
|
||||
key,
|
||||
expiresIn: seconds('5 minutes'),
|
||||
});
|
||||
return {filename, url};
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, attachmentId, filename, channelId},
|
||||
'Failed to generate presigned URL for report attachment',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async buildUserTag(userId: UserID | null, requestCache: RequestCache): Promise<UserTagInfo | null> {
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await this.deps.userCacheService.getUserPartialResponse(userId, requestCache);
|
||||
const discriminator = user.discriminator?.padStart(4, '0') ?? '0000';
|
||||
return {tag: `${user.username}#${discriminator}`, username: user.username, discriminator};
|
||||
} catch (error) {
|
||||
Logger.warn({userId: userId.toString(), error}, 'Failed to resolve user tag for report');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface UserTagInfo {
|
||||
tag: string;
|
||||
username: string;
|
||||
discriminator: string;
|
||||
}
|
||||
243
packages/api/src/admin/services/AdminSearchService.tsx
Normal file
243
packages/api/src/admin/services/AdminSearchService.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mapGuildToAdminResponse} from '@fluxer/api/src/admin/models/GuildTypes';
|
||||
import {mapUserToAdminResponse} from '@fluxer/api/src/admin/models/UserTypes';
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import {createGuildID, createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import {getGuildSearchService, getUserSearchService} from '@fluxer/api/src/SearchFactory';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
|
||||
import type {WorkerJobPayload} from '@fluxer/worker/src/contracts/WorkerTypes';
|
||||
|
||||
interface RefreshSearchIndexJobPayload extends WorkerJobPayload {
|
||||
index_type: 'guilds' | 'users' | 'reports' | 'audit_logs' | 'channel_messages' | 'favorite_memes' | 'guild_members';
|
||||
admin_user_id: string;
|
||||
audit_log_reason: string | null;
|
||||
job_id: string;
|
||||
guild_id?: string;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
interface AdminSearchServiceDeps {
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
userRepository: IUserRepository;
|
||||
workerService: IWorkerService;
|
||||
cacheService: ICacheService;
|
||||
snowflakeService: SnowflakeService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminSearchService {
|
||||
constructor(private readonly deps: AdminSearchServiceDeps) {}
|
||||
|
||||
async searchGuilds(data: {query?: string; limit: number; offset: number}) {
|
||||
const {guildRepository} = this.deps;
|
||||
Logger.debug(
|
||||
{query: data.query, limit: data.limit, offset: data.offset},
|
||||
'[AdminSearchService] searchGuilds called',
|
||||
);
|
||||
|
||||
const guildSearchService = getGuildSearchService();
|
||||
if (!guildSearchService) {
|
||||
Logger.error('[AdminSearchService] searchGuilds - Search service not enabled');
|
||||
throw new Error('Search is not enabled');
|
||||
}
|
||||
|
||||
const query = data.query?.trim() || '';
|
||||
const isIdQuery = /^\d+$/.test(query);
|
||||
|
||||
Logger.debug('[AdminSearchService] searchGuilds - Calling search service');
|
||||
const [searchResult, directGuild] = await Promise.all([
|
||||
guildSearchService.searchGuilds(query, {}, {limit: data.limit, offset: data.offset}),
|
||||
isIdQuery && data.offset === 0
|
||||
? guildRepository.findUnique(createGuildID(BigInt(query))).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const {hits, total} = searchResult;
|
||||
const guildIds = hits.map((hit) => createGuildID(BigInt(hit.id)));
|
||||
Logger.debug(
|
||||
{guild_ids: guildIds.map((id) => id.toString())},
|
||||
'[AdminSearchService] searchGuilds - Fetching from DB',
|
||||
);
|
||||
|
||||
const guilds = await guildRepository.listGuilds(guildIds);
|
||||
Logger.debug({guilds_count: guilds.length}, '[AdminSearchService] searchGuilds - Got guilds from DB');
|
||||
|
||||
const response = guilds.map((guild) => mapGuildToAdminResponse(guild));
|
||||
|
||||
if (directGuild && data.offset === 0) {
|
||||
const directId = directGuild.id.toString();
|
||||
if (!response.some((g) => g.id === directId)) {
|
||||
response.unshift(mapGuildToAdminResponse(directGuild));
|
||||
}
|
||||
}
|
||||
|
||||
Logger.debug({response_count: response.length}, '[AdminSearchService] searchGuilds - Mapped to response');
|
||||
|
||||
return {
|
||||
guilds: response,
|
||||
total: directGuild && !hits.some((h) => h.id === query) ? total + 1 : total,
|
||||
};
|
||||
}
|
||||
|
||||
async searchUsers(data: {query?: string; limit: number; offset: number}) {
|
||||
const {userRepository, cacheService} = this.deps;
|
||||
const userSearchService = getUserSearchService();
|
||||
if (!userSearchService) {
|
||||
throw new Error('Search is not enabled');
|
||||
}
|
||||
|
||||
const query = data.query?.trim() || '';
|
||||
const isIdQuery = /^\d+$/.test(query);
|
||||
|
||||
const [searchResult, directUser] = await Promise.all([
|
||||
userSearchService.search(query, {}, {limit: data.limit, offset: data.offset}),
|
||||
isIdQuery && data.offset === 0
|
||||
? userRepository.findUnique(createUserID(BigInt(query))).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const {hits, total} = searchResult;
|
||||
const userIds = hits.map((hit) => createUserID(BigInt(hit.id)));
|
||||
const users = await userRepository.listUsers(userIds);
|
||||
|
||||
const response = await Promise.all(users.map((user) => mapUserToAdminResponse(user, cacheService)));
|
||||
|
||||
if (directUser && data.offset === 0) {
|
||||
const directId = directUser.id.toString();
|
||||
if (!response.some((u) => u.id === directId)) {
|
||||
response.unshift(await mapUserToAdminResponse(directUser, cacheService));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
users: response,
|
||||
total: directUser && !hits.some((h) => h.id === query) ? total + 1 : total,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshSearchIndex(
|
||||
data: {
|
||||
index_type:
|
||||
| 'guilds'
|
||||
| 'users'
|
||||
| 'reports'
|
||||
| 'audit_logs'
|
||||
| 'channel_messages'
|
||||
| 'guild_members'
|
||||
| 'favorite_memes';
|
||||
guild_id?: bigint;
|
||||
user_id?: bigint;
|
||||
},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {workerService, snowflakeService, auditService} = this.deps;
|
||||
const jobId = (await snowflakeService.generate()).toString();
|
||||
|
||||
const payload: RefreshSearchIndexJobPayload = {
|
||||
index_type: data.index_type,
|
||||
admin_user_id: adminUserId.toString(),
|
||||
audit_log_reason: auditLogReason,
|
||||
job_id: jobId,
|
||||
};
|
||||
|
||||
if (data.index_type === 'channel_messages') {
|
||||
if (!data.guild_id) {
|
||||
throw new Error('guild_id is required for the channel_messages index type');
|
||||
}
|
||||
payload.guild_id = data.guild_id.toString();
|
||||
}
|
||||
|
||||
if (data.index_type === 'guild_members') {
|
||||
if (!data.guild_id) {
|
||||
throw new Error('guild_id is required for the guild_members index type');
|
||||
}
|
||||
payload.guild_id = data.guild_id.toString();
|
||||
}
|
||||
|
||||
if (data.index_type === 'favorite_memes') {
|
||||
if (!data.user_id) {
|
||||
throw new Error('user_id is required for favorite_memes index type');
|
||||
}
|
||||
payload.user_id = data.user_id.toString();
|
||||
}
|
||||
|
||||
await workerService.addJob('refreshSearchIndex', payload, {
|
||||
jobKey: `refreshSearchIndex_${data.index_type}_${jobId}`,
|
||||
maxAttempts: 1,
|
||||
});
|
||||
|
||||
Logger.debug({index_type: data.index_type, job_id: jobId}, 'Queued search index refresh job');
|
||||
|
||||
const metadata = new Map([
|
||||
['index_type', data.index_type],
|
||||
['job_id', jobId],
|
||||
]);
|
||||
if (data.guild_id) {
|
||||
metadata.set('guild_id', data.guild_id.toString());
|
||||
}
|
||||
if (data.user_id) {
|
||||
metadata.set('user_id', data.user_id.toString());
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'search_index',
|
||||
targetId: BigInt(0),
|
||||
action: 'queue_refresh_index',
|
||||
auditLogReason,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
job_id: jobId,
|
||||
};
|
||||
}
|
||||
|
||||
async getIndexRefreshStatus(jobId: string) {
|
||||
const {cacheService} = this.deps;
|
||||
const statusKey = `index_refresh_status:${jobId}`;
|
||||
const status = await cacheService.get<{
|
||||
status: 'in_progress' | 'completed' | 'failed';
|
||||
index_type: string;
|
||||
total?: number;
|
||||
indexed?: number;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
failed_at?: string;
|
||||
error?: string;
|
||||
}>(statusKey);
|
||||
|
||||
if (!status) {
|
||||
return {
|
||||
status: 'not_found' as const,
|
||||
};
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {SNOWFLAKE_RESERVATION_REFRESH_CHANNEL} from '@fluxer/api/src/constants/InstanceConfig';
|
||||
import type {SnowflakeReservationRepository} from '@fluxer/api/src/instance/SnowflakeReservationRepository';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
|
||||
interface AdminSnowflakeReservationServiceDeps {
|
||||
repository: SnowflakeReservationRepository;
|
||||
cacheService: ICacheService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminSnowflakeReservationService {
|
||||
constructor(private readonly deps: AdminSnowflakeReservationServiceDeps) {}
|
||||
|
||||
async listReservations() {
|
||||
const {repository} = this.deps;
|
||||
const entries = await repository.listReservations();
|
||||
return entries.map((entry) => ({
|
||||
email: entry.emailKey,
|
||||
snowflake: entry.snowflake.toString(),
|
||||
updated_at: entry.updatedAt ? entry.updatedAt.toISOString() : null,
|
||||
}));
|
||||
}
|
||||
|
||||
async setReservation(data: {email: string; snowflake: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {repository, cacheService, auditService} = this.deps;
|
||||
const emailLower = data.email.toLowerCase();
|
||||
|
||||
if (!emailLower) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
}
|
||||
|
||||
let snowflakeValue: bigint;
|
||||
try {
|
||||
snowflakeValue = BigInt(data.snowflake);
|
||||
} catch {
|
||||
throw InputValidationError.fromCode('snowflake', ValidationErrorCodes.INVALID_SNOWFLAKE);
|
||||
}
|
||||
|
||||
await repository.setReservation(emailLower, snowflakeValue);
|
||||
await cacheService.publish(SNOWFLAKE_RESERVATION_REFRESH_CHANNEL, 'refresh');
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'snowflake_reservation',
|
||||
targetId: BigInt(0),
|
||||
action: 'set_snowflake_reservation',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['email', emailLower],
|
||||
['snowflake', snowflakeValue.toString()],
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteReservation(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {repository, cacheService, auditService} = this.deps;
|
||||
const emailLower = data.email.toLowerCase();
|
||||
|
||||
if (!emailLower) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
}
|
||||
|
||||
await repository.deleteReservation(emailLower);
|
||||
await cacheService.publish(SNOWFLAKE_RESERVATION_REFRESH_CHANNEL, 'refresh');
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'snowflake_reservation',
|
||||
targetId: BigInt(0),
|
||||
action: 'delete_snowflake_reservation',
|
||||
auditLogReason,
|
||||
metadata: new Map([['email', emailLower]]),
|
||||
});
|
||||
}
|
||||
}
|
||||
142
packages/api/src/admin/services/AdminUserBanService.tsx
Normal file
142
packages/api/src/admin/services/AdminUserBanService.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mapUserToAdminResponse} from '@fluxer/api/src/admin/models/UserTypes';
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import type {AdminUserUpdatePropagator} from '@fluxer/api/src/admin/services/AdminUserUpdatePropagator';
|
||||
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
|
||||
import {createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {TempBanUserRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
|
||||
interface AdminUserBanServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
authService: AuthService;
|
||||
emailService: IEmailService;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminUserUpdatePropagator;
|
||||
cacheService: ICacheService;
|
||||
}
|
||||
|
||||
export class AdminUserBanService {
|
||||
constructor(private readonly deps: AdminUserBanServiceDeps) {}
|
||||
|
||||
async tempBanUser(data: TempBanUserRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, authService, emailService, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const tempBannedUntil = new Date();
|
||||
if (data.duration_hours <= 0) {
|
||||
// "Permanent" bans are represented as a very long temp ban that requires manual unban.
|
||||
// We intentionally skip sending the "temporary suspension" email for this case.
|
||||
tempBannedUntil.setFullYear(tempBannedUntil.getFullYear() + 100);
|
||||
} else {
|
||||
tempBannedUntil.setHours(tempBannedUntil.getHours() + data.duration_hours);
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
temp_banned_until: tempBannedUntil,
|
||||
flags: user.flags | UserFlags.DISABLED,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
|
||||
await authService.terminateAllUserSessions(userId);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
|
||||
if (user.email && data.duration_hours > 0) {
|
||||
await emailService.sendAccountTempBannedEmail(
|
||||
user.email,
|
||||
user.username,
|
||||
data.reason ?? null,
|
||||
data.duration_hours,
|
||||
tempBannedUntil,
|
||||
user.locale,
|
||||
);
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'temp_ban',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['duration_hours', data.duration_hours.toString()],
|
||||
['reason', data.reason ?? 'null'],
|
||||
['banned_until', tempBannedUntil.toISOString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService),
|
||||
};
|
||||
}
|
||||
|
||||
async unbanUser(data: {user_id: bigint}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, emailService, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
temp_banned_until: null,
|
||||
flags: user.flags & ~UserFlags.DISABLED,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
|
||||
if (user.email) {
|
||||
await emailService.sendUnbanNotification(
|
||||
user.email,
|
||||
user.username,
|
||||
auditLogReason || 'administrative action',
|
||||
user.locale,
|
||||
);
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'unban',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService),
|
||||
};
|
||||
}
|
||||
}
|
||||
210
packages/api/src/admin/services/AdminUserDeletionService.tsx
Normal file
210
packages/api/src/admin/services/AdminUserDeletionService.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mapUserToAdminResponse} from '@fluxer/api/src/admin/models/UserTypes';
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import type {AdminUserUpdatePropagator} from '@fluxer/api/src/admin/services/AdminUserUpdatePropagator';
|
||||
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
|
||||
import {createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
BulkScheduleUserDeletionRequest,
|
||||
ScheduleAccountDeletionRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
|
||||
interface AdminUserDeletionServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
authService: AuthService;
|
||||
emailService: IEmailService;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminUserUpdatePropagator;
|
||||
cacheService: ICacheService;
|
||||
}
|
||||
|
||||
const minUserRequestedDeletionDays = 14;
|
||||
const minStandardDeletionDays = 60;
|
||||
|
||||
export class AdminUserDeletionService {
|
||||
constructor(private readonly deps: AdminUserDeletionServiceDeps) {}
|
||||
|
||||
async scheduleAccountDeletion(
|
||||
data: ScheduleAccountDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {userRepository, authService, emailService, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const minDays =
|
||||
data.reason_code === DeletionReasons.USER_REQUESTED ? minUserRequestedDeletionDays : minStandardDeletionDays;
|
||||
const daysUntilDeletion = Math.max(data.days_until_deletion, minDays);
|
||||
const pendingDeletionAt = new Date();
|
||||
pendingDeletionAt.setDate(pendingDeletionAt.getDate() + daysUntilDeletion);
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
flags: user.flags | UserFlags.DELETED,
|
||||
pending_deletion_at: pendingDeletionAt,
|
||||
deletion_reason_code: data.reason_code,
|
||||
deletion_public_reason: data.public_reason ?? null,
|
||||
deletion_audit_log_reason: auditLogReason,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
|
||||
await userRepository.addPendingDeletion(userId, pendingDeletionAt, data.reason_code);
|
||||
|
||||
await authService.terminateAllUserSessions(userId);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
|
||||
if (user.email) {
|
||||
await emailService.sendAccountScheduledForDeletionEmail(
|
||||
user.email,
|
||||
user.username,
|
||||
data.public_reason ?? null,
|
||||
pendingDeletionAt,
|
||||
user.locale,
|
||||
);
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: data.user_id,
|
||||
action: 'schedule_deletion',
|
||||
auditLogReason,
|
||||
metadata: new Map([['days', daysUntilDeletion.toString()]]),
|
||||
});
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService),
|
||||
};
|
||||
}
|
||||
|
||||
async cancelAccountDeletion(data: {user_id: bigint}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, emailService, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if (user.pendingDeletionAt) {
|
||||
await userRepository.removePendingDeletion(userId, user.pendingDeletionAt);
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
flags: user.flags & ~UserFlags.DELETED & ~UserFlags.SELF_DELETED,
|
||||
pending_deletion_at: null,
|
||||
deletion_reason_code: null,
|
||||
deletion_public_reason: null,
|
||||
deletion_audit_log_reason: null,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
|
||||
if (user.email) {
|
||||
await emailService.sendUnbanNotification(
|
||||
user.email,
|
||||
user.username,
|
||||
auditLogReason || 'deletion canceled',
|
||||
user.locale,
|
||||
);
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'cancel_deletion',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService),
|
||||
};
|
||||
}
|
||||
|
||||
async bulkScheduleUserDeletion(
|
||||
data: BulkScheduleUserDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {auditService} = this.deps;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{id: string; error: string}> = [];
|
||||
|
||||
for (const userIdBigInt of data.user_ids) {
|
||||
try {
|
||||
await this.scheduleAccountDeletion(
|
||||
{
|
||||
user_id: userIdBigInt,
|
||||
reason_code: data.reason_code,
|
||||
public_reason: data.public_reason,
|
||||
days_until_deletion: data.days_until_deletion,
|
||||
},
|
||||
adminUserId,
|
||||
null,
|
||||
);
|
||||
successful.push(userIdBigInt.toString());
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
id: userIdBigInt.toString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const bulkMinDays =
|
||||
data.reason_code === DeletionReasons.USER_REQUESTED ? minUserRequestedDeletionDays : minStandardDeletionDays;
|
||||
const bulkDaysUntilDeletion = Math.max(data.days_until_deletion, bulkMinDays);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_schedule_deletion',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['user_count', data.user_ids.length.toString()],
|
||||
['reason_code', data.reason_code.toString()],
|
||||
['days', bulkDaysUntilDeletion.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
successful,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
}
|
||||
74
packages/api/src/admin/services/AdminUserLookupService.tsx
Normal file
74
packages/api/src/admin/services/AdminUserLookupService.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mapUserToAdminResponse} from '@fluxer/api/src/admin/models/UserTypes';
|
||||
import {createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import type {LookupUserRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
|
||||
interface AdminUserLookupServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
cacheService: ICacheService;
|
||||
}
|
||||
|
||||
export class AdminUserLookupService {
|
||||
constructor(private readonly deps: AdminUserLookupServiceDeps) {}
|
||||
|
||||
async lookupUser(data: LookupUserRequest) {
|
||||
const {userRepository, cacheService} = this.deps;
|
||||
|
||||
if ('user_ids' in data) {
|
||||
const userIds = data.user_ids.map((id) => createUserID(id));
|
||||
const users = await userRepository.listUsers(userIds);
|
||||
return {
|
||||
users: await Promise.all(users.map((user) => mapUserToAdminResponse(user, cacheService))),
|
||||
};
|
||||
}
|
||||
|
||||
let user = null;
|
||||
const query = data.query.trim();
|
||||
|
||||
const fluxerTagMatch = query.match(/^(.+)#(\d{1,4})$/);
|
||||
if (fluxerTagMatch) {
|
||||
const username = fluxerTagMatch[1];
|
||||
const discriminator = parseInt(fluxerTagMatch[2], 10);
|
||||
user = await userRepository.findByUsernameDiscriminator(username, discriminator);
|
||||
} else if (/^\d+$/.test(query)) {
|
||||
try {
|
||||
const userId = createUserID(BigInt(query));
|
||||
user = await userRepository.findUnique(userId);
|
||||
} catch (error) {
|
||||
Logger.debug({query, error}, 'Failed to lookup user by numeric ID, invalid ID format');
|
||||
user = null;
|
||||
}
|
||||
} else if (/^\+\d{1,15}$/.test(query)) {
|
||||
user = await userRepository.findByPhone(query);
|
||||
} else if (query.includes('@')) {
|
||||
user = await userRepository.findByEmail(query);
|
||||
} else {
|
||||
user = await userRepository.findByStripeSubscriptionId(query);
|
||||
}
|
||||
|
||||
return {
|
||||
users: user ? [await mapUserToAdminResponse(user, cacheService)] : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
373
packages/api/src/admin/services/AdminUserProfileService.tsx
Normal file
373
packages/api/src/admin/services/AdminUserProfileService.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mapUserToAdminResponse} from '@fluxer/api/src/admin/models/UserTypes';
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import type {AdminUserUpdatePropagator} from '@fluxer/api/src/admin/services/AdminUserUpdatePropagator';
|
||||
import {createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {GuildMemberSearchIndexService} from '@fluxer/api/src/guild/services/member/GuildMemberSearchIndexService';
|
||||
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
|
||||
import type {EntityAssetService, PreparedAssetUpload} from '@fluxer/api/src/infrastructure/EntityAssetService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {UserContactChangeLogService} from '@fluxer/api/src/user/services/UserContactChangeLogService';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {TagAlreadyTakenError} from '@fluxer/errors/src/domains/user/TagAlreadyTakenError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
ChangeDobRequest,
|
||||
ChangeEmailRequest,
|
||||
ChangeUsernameRequest,
|
||||
ClearUserFieldsRequest,
|
||||
SetUserBotStatusRequest,
|
||||
SetUserSystemStatusRequest,
|
||||
VerifyUserEmailRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {types} from 'cassandra-driver';
|
||||
|
||||
interface AdminUserProfileServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
discriminatorService: IDiscriminatorService;
|
||||
entityAssetService: EntityAssetService;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminUserUpdatePropagator;
|
||||
contactChangeLogService: UserContactChangeLogService;
|
||||
cacheService: ICacheService;
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
}
|
||||
|
||||
export class AdminUserProfileService {
|
||||
private readonly searchIndexService: GuildMemberSearchIndexService;
|
||||
|
||||
constructor(private readonly deps: AdminUserProfileServiceDeps) {
|
||||
this.searchIndexService = new GuildMemberSearchIndexService();
|
||||
}
|
||||
|
||||
async clearUserFields(data: ClearUserFieldsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, entityAssetService, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updates: Record<string, null | string> = {};
|
||||
const preparedAssets: Array<PreparedAssetUpload> = [];
|
||||
|
||||
for (const field of data.fields) {
|
||||
if (field === 'avatar') {
|
||||
const prepared = await entityAssetService.prepareAssetUpload({
|
||||
assetType: 'avatar',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
previousHash: user.avatarHash,
|
||||
base64Image: null,
|
||||
errorPath: 'avatar',
|
||||
});
|
||||
preparedAssets.push(prepared);
|
||||
updates['avatar_hash'] = prepared.newHash;
|
||||
} else if (field === 'banner') {
|
||||
const prepared = await entityAssetService.prepareAssetUpload({
|
||||
assetType: 'banner',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
previousHash: user.bannerHash,
|
||||
base64Image: null,
|
||||
errorPath: 'banner',
|
||||
});
|
||||
preparedAssets.push(prepared);
|
||||
updates['banner_hash'] = prepared.newHash;
|
||||
} else if (field === 'bio') {
|
||||
updates['bio'] = null;
|
||||
} else if (field === 'pronouns') {
|
||||
updates['pronouns'] = null;
|
||||
} else if (field === 'global_name') {
|
||||
updates['global_name'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
let updatedUser: User;
|
||||
try {
|
||||
updatedUser = await userRepository.patchUpsert(userId, updates, user.toRow());
|
||||
} catch (error) {
|
||||
await Promise.all(preparedAssets.map((p) => entityAssetService.rollbackAssetUpload(p)));
|
||||
throw error;
|
||||
}
|
||||
|
||||
await Promise.all(preparedAssets.map((p) => entityAssetService.commitAssetChange({prepared: p})));
|
||||
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'clear_fields',
|
||||
auditLogReason,
|
||||
metadata: new Map([['fields', data.fields.join(',')]]),
|
||||
});
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService),
|
||||
};
|
||||
}
|
||||
|
||||
async setUserBotStatus(data: SetUserBotStatusRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updates: Record<string, boolean> = {bot: data.bot};
|
||||
if (!data.bot) {
|
||||
updates['system'] = false;
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, updates, user.toRow());
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'set_bot_status',
|
||||
auditLogReason,
|
||||
metadata: new Map([['bot', data.bot.toString()]]),
|
||||
});
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService),
|
||||
};
|
||||
}
|
||||
|
||||
async setUserSystemStatus(data: SetUserSystemStatusRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if (data.system && !user.isBot) {
|
||||
throw InputValidationError.fromCode(
|
||||
'system',
|
||||
ValidationErrorCodes.USER_MUST_BE_A_BOT_TO_BE_MARKED_AS_A_SYSTEM_USER,
|
||||
);
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {system: data.system}, user.toRow());
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'set_system_status',
|
||||
auditLogReason,
|
||||
metadata: new Map([['system', data.system.toString()]]),
|
||||
});
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService),
|
||||
};
|
||||
}
|
||||
|
||||
async verifyUserEmail(data: VerifyUserEmailRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {email_verified: true}, user.toRow());
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'verify_email',
|
||||
auditLogReason,
|
||||
metadata: new Map([['email', user.email ?? 'null']]),
|
||||
});
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService),
|
||||
};
|
||||
}
|
||||
|
||||
async changeUsername(data: ChangeUsernameRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, discriminatorService, auditService, updatePropagator, contactChangeLogService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const discriminatorResult = await discriminatorService.generateDiscriminator({
|
||||
username: data.username,
|
||||
requestedDiscriminator: data.discriminator,
|
||||
user,
|
||||
});
|
||||
|
||||
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
|
||||
throw new TagAlreadyTakenError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
username: data.username,
|
||||
discriminator: discriminatorResult.discriminator,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
|
||||
await contactChangeLogService.recordDiff({
|
||||
oldUser: user,
|
||||
newUser: updatedUser,
|
||||
reason: 'admin_action',
|
||||
actorUserId: adminUserId,
|
||||
});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'change_username',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['old_username', user.username],
|
||||
['new_username', data.username],
|
||||
['discriminator', discriminatorResult.discriminator.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
void this.reindexGuildMembersForUser(updatedUser);
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService),
|
||||
};
|
||||
}
|
||||
|
||||
async changeEmail(data: ChangeEmailRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator, contactChangeLogService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
email: data.email,
|
||||
email_verified: false,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
|
||||
await contactChangeLogService.recordDiff({
|
||||
oldUser: user,
|
||||
newUser: updatedUser,
|
||||
reason: 'admin_action',
|
||||
actorUserId: adminUserId,
|
||||
});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'change_email',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['old_email', user.email ?? 'null'],
|
||||
['new_email', data.email],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService),
|
||||
};
|
||||
}
|
||||
|
||||
private async reindexGuildMembersForUser(updatedUser: User): Promise<void> {
|
||||
try {
|
||||
const guildIds = await this.deps.userRepository.getUserGuildIds(updatedUser.id);
|
||||
for (const guildId of guildIds) {
|
||||
const guild = await this.deps.guildRepository.findUnique(guildId);
|
||||
if (!guild?.membersIndexedAt) {
|
||||
continue;
|
||||
}
|
||||
const member = await this.deps.guildRepository.getMember(guildId, updatedUser.id);
|
||||
if (member) {
|
||||
void this.searchIndexService.updateMember(member, updatedUser);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{userId: updatedUser.id.toString(), error},
|
||||
'Failed to reindex guild members after admin user update',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async changeDob(data: ChangeDobRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
date_of_birth: types.LocalDate.fromString(data.date_of_birth),
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'change_dob',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['old_dob', user.dateOfBirth ?? 'null'],
|
||||
['new_dob', data.date_of_birth],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService),
|
||||
};
|
||||
}
|
||||
}
|
||||
496
packages/api/src/admin/services/AdminUserSecurityService.tsx
Normal file
496
packages/api/src/admin/services/AdminUserSecurityService.tsx
Normal file
@@ -0,0 +1,496 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mapUserToAdminResponse} from '@fluxer/api/src/admin/models/UserTypes';
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import type {AdminUserUpdatePropagator} from '@fluxer/api/src/admin/services/AdminUserUpdatePropagator';
|
||||
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
|
||||
import {createPasswordResetToken, createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {BotMfaMirrorService} from '@fluxer/api/src/oauth/BotMfaMirrorService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {UserContactChangeLogService} from '@fluxer/api/src/user/services/UserContactChangeLogService';
|
||||
import {getIpAddressReverse, getLocationLabelFromIp} from '@fluxer/api/src/utils/IpUtils';
|
||||
import {resolveSessionClientInfo} from '@fluxer/api/src/utils/UserAgentUtils';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
BulkUpdateUserFlagsRequest,
|
||||
DisableForSuspiciousActivityRequest,
|
||||
DisableMfaRequest,
|
||||
SendPasswordResetRequest,
|
||||
SetUserAclsRequest,
|
||||
SetUserTraitsRequest,
|
||||
TerminateSessionsRequest,
|
||||
UnlinkPhoneRequest,
|
||||
UpdateSuspiciousActivityFlagsRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
|
||||
interface AdminUserSecurityServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
authService: AuthService;
|
||||
emailService: IEmailService;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminUserUpdatePropagator;
|
||||
botMfaMirrorService?: BotMfaMirrorService;
|
||||
contactChangeLogService: UserContactChangeLogService;
|
||||
cacheService: ICacheService;
|
||||
}
|
||||
|
||||
export class AdminUserSecurityService {
|
||||
constructor(private readonly deps: AdminUserSecurityServiceDeps) {}
|
||||
|
||||
async updateUserFlags({
|
||||
userId,
|
||||
data,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: {
|
||||
userId: UserID;
|
||||
data: {addFlags: Array<bigint>; removeFlags: Array<bigint>};
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
let newFlags = user.flags;
|
||||
for (const flag of data.addFlags) {
|
||||
newFlags |= flag;
|
||||
}
|
||||
for (const flag of data.removeFlags) {
|
||||
newFlags &= ~flag;
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
flags: newFlags,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'update_flags',
|
||||
auditLogReason,
|
||||
metadata: new Map(
|
||||
(
|
||||
[
|
||||
['add_flags', data.addFlags.map((f) => f.toString()).join(',')],
|
||||
['remove_flags', data.removeFlags.map((f) => f.toString()).join(',')],
|
||||
['new_flags', newFlags.toString()],
|
||||
] as Array<[string, string]>
|
||||
).filter(([_, v]) => v.length > 0),
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService),
|
||||
};
|
||||
}
|
||||
|
||||
async disableMfa(data: DisableMfaRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
totp_secret: null,
|
||||
authenticator_types: null,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
|
||||
if (updatedUser) {
|
||||
await this.deps.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
|
||||
await userRepository.clearMfaBackupCodes(userId);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'disable_mfa',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
async sendPasswordReset(data: SendPasswordResetRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, emailService, authService, auditService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.USER_DOES_NOT_HAVE_AN_EMAIL_ADDRESS);
|
||||
}
|
||||
|
||||
const token = createPasswordResetToken(await authService.generateSecureToken());
|
||||
await userRepository.createPasswordResetToken({
|
||||
token_: token,
|
||||
user_id: userId,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
await emailService.sendPasswordResetEmail(user.email, user.username, token, user.locale);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'send_password_reset',
|
||||
auditLogReason,
|
||||
metadata: new Map([['email', user.email]]),
|
||||
});
|
||||
}
|
||||
|
||||
async terminateSessions(data: TerminateSessionsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, authService, auditService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
await authService.terminateAllUserSessions(userId);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'terminate_sessions',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
async setUserAcls(data: SetUserAclsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
acls: new Set(data.acls),
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'set_acls',
|
||||
auditLogReason,
|
||||
metadata: new Map([['acls', data.acls.join(',')]]),
|
||||
});
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService),
|
||||
};
|
||||
}
|
||||
|
||||
async setUserTraits(data: SetUserTraitsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const traitSet = data.traits.length > 0 ? new Set(data.traits) : null;
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
traits: traitSet,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'set_traits',
|
||||
auditLogReason,
|
||||
metadata: new Map(data.traits.length > 0 ? [['traits', data.traits.join(',')]] : []),
|
||||
});
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService),
|
||||
};
|
||||
}
|
||||
|
||||
async unlinkPhone(data: UnlinkPhoneRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator, contactChangeLogService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
phone: null,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
|
||||
await contactChangeLogService.recordDiff({
|
||||
oldUser: user,
|
||||
newUser: updatedUser,
|
||||
reason: 'admin_action',
|
||||
actorUserId: adminUserId,
|
||||
});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'unlink_phone',
|
||||
auditLogReason,
|
||||
metadata: new Map([['phone', user.phone ?? 'null']]),
|
||||
});
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService),
|
||||
};
|
||||
}
|
||||
|
||||
async updateSuspiciousActivityFlags(
|
||||
data: UpdateSuspiciousActivityFlagsRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
suspicious_activity_flags: data.flags,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'update_suspicious_activity_flags',
|
||||
auditLogReason,
|
||||
metadata: new Map([['flags', data.flags.toString()]]),
|
||||
});
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService),
|
||||
};
|
||||
}
|
||||
|
||||
async disableForSuspiciousActivity(
|
||||
data: DisableForSuspiciousActivityRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {userRepository, authService, emailService, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
flags: user.flags | UserFlags.DISABLED_SUSPICIOUS_ACTIVITY,
|
||||
suspicious_activity_flags: data.flags,
|
||||
password_hash: null,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
|
||||
await authService.terminateAllUserSessions(userId);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
|
||||
if (user.email) {
|
||||
await emailService.sendAccountDisabledForSuspiciousActivityEmail(user.email, user.username, null, user.locale);
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'disable_suspicious_activity',
|
||||
auditLogReason,
|
||||
metadata: new Map([['flags', data.flags.toString()]]),
|
||||
});
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, this.deps.cacheService),
|
||||
};
|
||||
}
|
||||
|
||||
async bulkUpdateUserFlags(data: BulkUpdateUserFlagsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {auditService} = this.deps;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{id: string; error: string}> = [];
|
||||
const addFlags = data.add_flags.map((flag) => BigInt(flag));
|
||||
const removeFlags = data.remove_flags.map((flag) => BigInt(flag));
|
||||
|
||||
for (const userIdBigInt of data.user_ids) {
|
||||
try {
|
||||
const userId = createUserID(userIdBigInt);
|
||||
await this.updateUserFlags({
|
||||
userId,
|
||||
data: {addFlags, removeFlags},
|
||||
adminUserId,
|
||||
auditLogReason: null,
|
||||
});
|
||||
successful.push(userId.toString());
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
id: userIdBigInt.toString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_update_user_flags',
|
||||
auditLogReason,
|
||||
metadata: new Map(
|
||||
(
|
||||
[
|
||||
['user_count', data.user_ids.length.toString()],
|
||||
['add_flags', data.add_flags.map((f) => f.toString()).join(',')],
|
||||
['remove_flags', data.remove_flags.map((f) => f.toString()).join(',')],
|
||||
] as Array<[string, string]>
|
||||
).filter(([_, v]) => v.length > 0),
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
successful,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
|
||||
async listUserSessions(userId: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, cacheService} = this.deps;
|
||||
const userIdTyped = createUserID(userId);
|
||||
const user = await userRepository.findUnique(userIdTyped);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const sessions = await userRepository.listAuthSessions(userIdTyped);
|
||||
const locationResults = await Promise.allSettled(
|
||||
sessions.map((session) => getLocationLabelFromIp(session.clientIp)),
|
||||
);
|
||||
const reverseDnsResults = await Promise.allSettled(
|
||||
sessions.map((session) => getIpAddressReverse(session.clientIp, cacheService)),
|
||||
);
|
||||
|
||||
let failedCount = 0;
|
||||
for (const result of locationResults) {
|
||||
if (result.status === 'rejected') {
|
||||
failedCount++;
|
||||
Logger.warn({error: result.reason, userId: userId.toString()}, 'IP geolocation lookup failed');
|
||||
}
|
||||
}
|
||||
|
||||
if (locationResults.length > 0 && failedCount === locationResults.length) {
|
||||
throw new ServiceUnavailableError({
|
||||
code: 'GEOLOCATION_SERVICE_UNAVAILABLE',
|
||||
message: 'Geolocation service is unavailable. All IP lookups failed.',
|
||||
});
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
action: 'list_user_sessions',
|
||||
auditLogReason,
|
||||
metadata: new Map([['session_count', sessions.length.toString()]]),
|
||||
});
|
||||
|
||||
return {
|
||||
sessions: sessions.map((session, index) => {
|
||||
const locationResult = locationResults[index];
|
||||
const clientLocation = locationResult.status === 'fulfilled' ? locationResult.value : null;
|
||||
const reverseDnsResult = reverseDnsResults[index];
|
||||
const clientIpReverse = reverseDnsResult?.status === 'fulfilled' ? reverseDnsResult.value : null;
|
||||
const {clientOs, clientPlatform} = resolveSessionClientInfo({
|
||||
userAgent: session.clientUserAgent,
|
||||
isDesktopClient: session.clientIsDesktop,
|
||||
});
|
||||
return {
|
||||
session_id_hash: session.sessionIdHash.toString('base64url'),
|
||||
created_at: session.createdAt.toISOString(),
|
||||
approx_last_used_at: session.approximateLastUsedAt.toISOString(),
|
||||
client_ip: session.clientIp,
|
||||
client_ip_reverse: clientIpReverse,
|
||||
client_os: clientOs,
|
||||
client_platform: clientPlatform,
|
||||
client_location: clientLocation,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
422
packages/api/src/admin/services/AdminUserService.tsx
Normal file
422
packages/api/src/admin/services/AdminUserService.tsx
Normal file
@@ -0,0 +1,422 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {IAdminRepository} from '@fluxer/api/src/admin/IAdminRepository';
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import {AdminBanManagementService} from '@fluxer/api/src/admin/services/AdminBanManagementService';
|
||||
import {AdminUserBanService} from '@fluxer/api/src/admin/services/AdminUserBanService';
|
||||
import {AdminUserDeletionService} from '@fluxer/api/src/admin/services/AdminUserDeletionService';
|
||||
import {AdminUserLookupService} from '@fluxer/api/src/admin/services/AdminUserLookupService';
|
||||
import {AdminUserProfileService} from '@fluxer/api/src/admin/services/AdminUserProfileService';
|
||||
import {AdminUserSecurityService} from '@fluxer/api/src/admin/services/AdminUserSecurityService';
|
||||
import {AdminUserUpdatePropagator} from '@fluxer/api/src/admin/services/AdminUserUpdatePropagator';
|
||||
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
|
||||
import {createChannelID, createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
|
||||
import type {EntityAssetService} from '@fluxer/api/src/infrastructure/EntityAssetService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {KVBulkMessageDeletionQueueService} from '@fluxer/api/src/infrastructure/KVBulkMessageDeletionQueueService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {BotMfaMirrorService} from '@fluxer/api/src/oauth/BotMfaMirrorService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {UserContactChangeLogService} from '@fluxer/api/src/user/services/UserContactChangeLogService';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
BulkScheduleUserDeletionRequest,
|
||||
BulkUpdateUserFlagsRequest,
|
||||
CancelBulkMessageDeletionRequest,
|
||||
ChangeDobRequest,
|
||||
ChangeEmailRequest,
|
||||
ChangeUsernameRequest,
|
||||
ClearUserFieldsRequest,
|
||||
DisableForSuspiciousActivityRequest,
|
||||
DisableMfaRequest,
|
||||
ListUserChangeLogRequest,
|
||||
ListUserDmChannelsRequest,
|
||||
LookupUserRequest,
|
||||
ScheduleAccountDeletionRequest,
|
||||
SendPasswordResetRequest,
|
||||
SetUserAclsRequest,
|
||||
SetUserBotStatusRequest,
|
||||
SetUserSystemStatusRequest,
|
||||
SetUserTraitsRequest,
|
||||
TempBanUserRequest,
|
||||
TerminateSessionsRequest,
|
||||
UnlinkPhoneRequest,
|
||||
UpdateSuspiciousActivityFlagsRequest,
|
||||
VerifyUserEmailRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {mapUserToAdminResponse} from '../models/UserTypes';
|
||||
|
||||
interface AdminUserServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
discriminatorService: IDiscriminatorService;
|
||||
authService: AuthService;
|
||||
emailService: IEmailService;
|
||||
entityAssetService: EntityAssetService;
|
||||
auditService: AdminAuditService;
|
||||
gatewayService: IGatewayService;
|
||||
userCacheService: UserCacheService;
|
||||
adminRepository: IAdminRepository;
|
||||
botMfaMirrorService: BotMfaMirrorService;
|
||||
contactChangeLogService: UserContactChangeLogService;
|
||||
bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService;
|
||||
cacheService: ICacheService;
|
||||
}
|
||||
|
||||
export class AdminUserService {
|
||||
private readonly lookupService: AdminUserLookupService;
|
||||
private readonly profileService: AdminUserProfileService;
|
||||
private readonly securityService: AdminUserSecurityService;
|
||||
private readonly banService: AdminUserBanService;
|
||||
private readonly deletionService: AdminUserDeletionService;
|
||||
private readonly banManagementService: AdminBanManagementService;
|
||||
private readonly updatePropagator: AdminUserUpdatePropagator;
|
||||
private readonly contactChangeLogService: UserContactChangeLogService;
|
||||
private readonly auditService: AdminAuditService;
|
||||
private readonly userRepository: IUserRepository;
|
||||
private readonly bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService;
|
||||
private readonly cacheService: ICacheService;
|
||||
|
||||
constructor(deps: AdminUserServiceDeps) {
|
||||
this.updatePropagator = new AdminUserUpdatePropagator({
|
||||
userCacheService: deps.userCacheService,
|
||||
userRepository: deps.userRepository,
|
||||
guildRepository: deps.guildRepository,
|
||||
gatewayService: deps.gatewayService,
|
||||
});
|
||||
|
||||
this.userRepository = deps.userRepository;
|
||||
this.auditService = deps.auditService;
|
||||
this.bulkMessageDeletionQueue = deps.bulkMessageDeletionQueue;
|
||||
this.cacheService = deps.cacheService;
|
||||
|
||||
this.lookupService = new AdminUserLookupService({
|
||||
userRepository: deps.userRepository,
|
||||
cacheService: deps.cacheService,
|
||||
});
|
||||
|
||||
this.profileService = new AdminUserProfileService({
|
||||
userRepository: deps.userRepository,
|
||||
discriminatorService: deps.discriminatorService,
|
||||
entityAssetService: deps.entityAssetService,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
contactChangeLogService: deps.contactChangeLogService,
|
||||
cacheService: deps.cacheService,
|
||||
guildRepository: deps.guildRepository,
|
||||
});
|
||||
|
||||
this.securityService = new AdminUserSecurityService({
|
||||
userRepository: deps.userRepository,
|
||||
authService: deps.authService,
|
||||
emailService: deps.emailService,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
botMfaMirrorService: deps.botMfaMirrorService,
|
||||
contactChangeLogService: deps.contactChangeLogService,
|
||||
cacheService: deps.cacheService,
|
||||
});
|
||||
|
||||
this.banService = new AdminUserBanService({
|
||||
userRepository: deps.userRepository,
|
||||
authService: deps.authService,
|
||||
emailService: deps.emailService,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
cacheService: deps.cacheService,
|
||||
});
|
||||
|
||||
this.deletionService = new AdminUserDeletionService({
|
||||
userRepository: deps.userRepository,
|
||||
authService: deps.authService,
|
||||
emailService: deps.emailService,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
cacheService: deps.cacheService,
|
||||
});
|
||||
|
||||
this.banManagementService = new AdminBanManagementService({
|
||||
adminRepository: deps.adminRepository,
|
||||
auditService: deps.auditService,
|
||||
cacheService: deps.cacheService,
|
||||
});
|
||||
|
||||
this.contactChangeLogService = deps.contactChangeLogService;
|
||||
}
|
||||
|
||||
async lookupUser(data: LookupUserRequest) {
|
||||
return this.lookupService.lookupUser(data);
|
||||
}
|
||||
|
||||
async clearUserFields(data: ClearUserFieldsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.clearUserFields(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async setUserBotStatus(data: SetUserBotStatusRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.setUserBotStatus(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async setUserSystemStatus(data: SetUserSystemStatusRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.setUserSystemStatus(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async verifyUserEmail(data: VerifyUserEmailRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.verifyUserEmail(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async changeUsername(data: ChangeUsernameRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.changeUsername(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async changeEmail(data: ChangeEmailRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.changeEmail(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async changeDob(data: ChangeDobRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.changeDob(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateUserFlags({
|
||||
userId,
|
||||
data,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: {
|
||||
userId: UserID;
|
||||
data: {addFlags: Array<bigint>; removeFlags: Array<bigint>};
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
return this.securityService.updateUserFlags({userId, data, adminUserId, auditLogReason});
|
||||
}
|
||||
|
||||
async disableMfa(data: DisableMfaRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.disableMfa(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async sendPasswordReset(data: SendPasswordResetRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.sendPasswordReset(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async terminateSessions(data: TerminateSessionsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.terminateSessions(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async setUserAcls(data: SetUserAclsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.setUserAcls(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async setUserTraits(data: SetUserTraitsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.setUserTraits(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unlinkPhone(data: UnlinkPhoneRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.unlinkPhone(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateSuspiciousActivityFlags(
|
||||
data: UpdateSuspiciousActivityFlagsRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.securityService.updateSuspiciousActivityFlags(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async disableForSuspiciousActivity(
|
||||
data: DisableForSuspiciousActivityRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.securityService.disableForSuspiciousActivity(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkUpdateUserFlags(data: BulkUpdateUserFlagsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.bulkUpdateUserFlags(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async listUserSessions(userId: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.listUserSessions(userId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async listUserDmChannels(data: ListUserDmChannelsRequest) {
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const channels = await this.userRepository.listHistoricalDmChannelsPaginated(userId, {
|
||||
limit: data.limit,
|
||||
beforeChannelId: data.before ? createChannelID(data.before) : undefined,
|
||||
afterChannelId: data.after ? createChannelID(data.after) : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
channels: channels.map((channel) => ({
|
||||
channel_id: channel.channelId.toString(),
|
||||
channel_type: channel.channelType,
|
||||
recipient_ids: channel.recipientIds.map((recipientId) => recipientId.toString()),
|
||||
last_message_id: channel.lastMessageId?.toString() ?? null,
|
||||
is_open: channel.open,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async listUserChangeLog(data: ListUserChangeLogRequest) {
|
||||
const entries = await this.contactChangeLogService.listLogs({
|
||||
userId: createUserID(data.user_id),
|
||||
limit: data.limit,
|
||||
beforeEventId: data.page_token,
|
||||
});
|
||||
|
||||
const nextPageToken =
|
||||
entries.length === data.limit && entries.length > 0 ? entries.at(-1)!.event_id.toString() : null;
|
||||
|
||||
return {
|
||||
entries: entries.map((entry) => ({
|
||||
event_id: entry.event_id.toString(),
|
||||
field: entry.field,
|
||||
old_value: entry.old_value ?? null,
|
||||
new_value: entry.new_value ?? null,
|
||||
reason: entry.reason,
|
||||
actor_user_id: entry.actor_user_id ? entry.actor_user_id.toString() : null,
|
||||
event_at: entry.event_at.toISOString(),
|
||||
})),
|
||||
next_page_token: nextPageToken,
|
||||
};
|
||||
}
|
||||
|
||||
async tempBanUser(data: TempBanUserRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banService.tempBanUser(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unbanUser(data: {user_id: bigint}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banService.unbanUser(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async scheduleAccountDeletion(
|
||||
data: ScheduleAccountDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.deletionService.scheduleAccountDeletion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async cancelAccountDeletion(data: {user_id: bigint}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.deletionService.cancelAccountDeletion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async cancelBulkMessageDeletion(
|
||||
data: CancelBulkMessageDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await this.userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
pending_bulk_message_deletion_at: null,
|
||||
pending_bulk_message_deletion_channel_count: null,
|
||||
pending_bulk_message_deletion_message_count: null,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
|
||||
await this.bulkMessageDeletionQueue.removeFromQueue(userId);
|
||||
|
||||
await this.auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'cancel_bulk_message_deletion',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, this.cacheService),
|
||||
};
|
||||
}
|
||||
|
||||
async bulkScheduleUserDeletion(
|
||||
data: BulkScheduleUserDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.deletionService.bulkScheduleUserDeletion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async banIp(data: {ip: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banManagementService.banIp(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unbanIp(data: {ip: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banManagementService.unbanIp(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async checkIpBan(data: {ip: string}) {
|
||||
return this.banManagementService.checkIpBan(data);
|
||||
}
|
||||
|
||||
async banEmail(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banManagementService.banEmail(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unbanEmail(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banManagementService.unbanEmail(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async checkEmailBan(data: {email: string}) {
|
||||
return this.banManagementService.checkEmailBan(data);
|
||||
}
|
||||
|
||||
async banPhone(data: {phone: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banManagementService.banPhone(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unbanPhone(data: {phone: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banManagementService.unbanPhone(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async checkPhoneBan(data: {phone: string}) {
|
||||
return this.banManagementService.checkPhoneBan(data);
|
||||
}
|
||||
|
||||
async listIpBans(data: {limit: number}) {
|
||||
return this.banManagementService.listIpBans(data);
|
||||
}
|
||||
|
||||
async listEmailBans(data: {limit: number}) {
|
||||
return this.banManagementService.listEmailBans(data);
|
||||
}
|
||||
|
||||
async listPhoneBans(data: {limit: number}) {
|
||||
return this.banManagementService.listPhoneBans(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {mapGuildMemberToResponse} from '@fluxer/api/src/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import {createRequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {BaseUserUpdatePropagator} from '@fluxer/api/src/user/services/BaseUserUpdatePropagator';
|
||||
import {hasPartialUserFieldsChanged} from '@fluxer/api/src/user/UserMappers';
|
||||
|
||||
interface AdminUserUpdatePropagatorDeps {
|
||||
userCacheService: UserCacheService;
|
||||
userRepository: IUserRepository;
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
gatewayService: IGatewayService;
|
||||
}
|
||||
|
||||
export class AdminUserUpdatePropagator extends BaseUserUpdatePropagator {
|
||||
constructor(private readonly deps: AdminUserUpdatePropagatorDeps) {
|
||||
super({
|
||||
userCacheService: deps.userCacheService,
|
||||
gatewayService: deps.gatewayService,
|
||||
});
|
||||
}
|
||||
|
||||
async propagateUserUpdate({
|
||||
userId,
|
||||
oldUser,
|
||||
updatedUser,
|
||||
}: {
|
||||
userId: UserID;
|
||||
oldUser: User;
|
||||
updatedUser: User;
|
||||
}): Promise<void> {
|
||||
await this.dispatchUserUpdate(updatedUser);
|
||||
|
||||
if (hasPartialUserFieldsChanged(oldUser, updatedUser)) {
|
||||
await this.updateUserCache(updatedUser);
|
||||
await this.propagateToGuilds(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async propagateToGuilds(userId: UserID): Promise<void> {
|
||||
const {userRepository, guildRepository, gatewayService, userCacheService} = this.deps;
|
||||
|
||||
const guildIds = await userRepository.getUserGuildIds(userId);
|
||||
if (guildIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestCache = createRequestCache();
|
||||
|
||||
for (const guildId of guildIds) {
|
||||
const member = await guildRepository.getMember(guildId, userId);
|
||||
if (!member) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const memberResponse = await mapGuildMemberToResponse(member, userCacheService, requestCache);
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_MEMBER_UPDATE',
|
||||
data: memberResponse,
|
||||
});
|
||||
}
|
||||
|
||||
requestCache.clear();
|
||||
}
|
||||
}
|
||||
139
packages/api/src/admin/services/AdminVisionarySlotService.tsx
Normal file
139
packages/api/src/admin/services/AdminVisionarySlotService.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import {createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {VisionarySlotRepository} from '@fluxer/api/src/user/repositories/VisionarySlotRepository';
|
||||
|
||||
interface AdminVisionarySlotServiceDeps {
|
||||
repository: VisionarySlotRepository;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminVisionarySlotService {
|
||||
constructor(private readonly deps: AdminVisionarySlotServiceDeps) {}
|
||||
|
||||
async expandSlots(data: {count: number}, adminUserId: UserID, auditLogReason: string | null): Promise<void> {
|
||||
await this.deps.repository.expandVisionarySlots(data.count);
|
||||
|
||||
await this.deps.auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'visionary_slot',
|
||||
targetId: BigInt(0),
|
||||
action: 'expand_visionary_slots',
|
||||
auditLogReason,
|
||||
metadata: new Map([['count', data.count.toString()]]),
|
||||
});
|
||||
}
|
||||
|
||||
async shrinkSlots(data: {targetCount: number}, adminUserId: UserID, auditLogReason: string | null): Promise<void> {
|
||||
const existingSlots = await this.deps.repository.listVisionarySlots();
|
||||
await this.deps.repository.shrinkVisionarySlots(data.targetCount);
|
||||
|
||||
await this.deps.auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'visionary_slot',
|
||||
targetId: BigInt(0),
|
||||
action: 'shrink_visionary_slots',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['previous_total_count', existingSlots.length.toString()],
|
||||
['target_count', data.targetCount.toString()],
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
async setSlotReservation(
|
||||
data: {slotIndex: number; userId: UserID | null},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<void> {
|
||||
const existing = await this.deps.repository.getVisionarySlot(data.slotIndex);
|
||||
const previousUserId = existing?.userId ?? null;
|
||||
|
||||
if (previousUserId === null && data.userId === null) {
|
||||
return;
|
||||
}
|
||||
if (previousUserId !== null && data.userId !== null && previousUserId === data.userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.userId === null) {
|
||||
const sentinelUserId = createUserID(BigInt(-1));
|
||||
await this.deps.repository.unreserveVisionarySlot(data.slotIndex, sentinelUserId);
|
||||
} else {
|
||||
await this.deps.repository.reserveVisionarySlot(data.slotIndex, data.userId);
|
||||
}
|
||||
|
||||
let action: string;
|
||||
if (data.userId === null) {
|
||||
action = 'unreserve_visionary_slot';
|
||||
} else if (previousUserId === null) {
|
||||
action = 'reserve_visionary_slot';
|
||||
} else {
|
||||
action = 'reassign_visionary_slot';
|
||||
}
|
||||
|
||||
const metadata = new Map<string, string>([
|
||||
['slot_index', data.slotIndex.toString()],
|
||||
['user_id', data.userId ? data.userId.toString() : 'null'],
|
||||
]);
|
||||
if (previousUserId !== null) {
|
||||
metadata.set('previous_user_id', previousUserId.toString());
|
||||
}
|
||||
|
||||
await this.deps.auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'visionary_slot',
|
||||
targetId: BigInt(data.slotIndex),
|
||||
action,
|
||||
auditLogReason,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
async swapSlots(
|
||||
data: {slotIndexA: number; slotIndexB: number},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<void> {
|
||||
const {userIdA, userIdB} = await this.deps.repository.swapVisionarySlotReservations(
|
||||
data.slotIndexA,
|
||||
data.slotIndexB,
|
||||
);
|
||||
|
||||
if (userIdA === userIdB) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.deps.auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'visionary_slot',
|
||||
targetId: BigInt(0),
|
||||
action: 'swap_visionary_slots',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['slot_index_a', data.slotIndexA.toString()],
|
||||
['slot_index_b', data.slotIndexB.toString()],
|
||||
['user_id_a_before', userIdA ? userIdA.toString() : 'null'],
|
||||
['user_id_b_before', userIdB ? userIdB.toString() : 'null'],
|
||||
]),
|
||||
});
|
||||
}
|
||||
}
|
||||
402
packages/api/src/admin/services/AdminVoiceService.tsx
Normal file
402
packages/api/src/admin/services/AdminVoiceService.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import {createGuildIDSet, createUserIDSet, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {VOICE_CONFIGURATION_CHANNEL} from '@fluxer/api/src/voice/VoiceConstants';
|
||||
import type {VoiceRegionRecord, VoiceRegionWithServers, VoiceServerRecord} from '@fluxer/api/src/voice/VoiceModel';
|
||||
import type {VoiceRepository} from '@fluxer/api/src/voice/VoiceRepository';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {UnknownVoiceRegionError} from '@fluxer/errors/src/domains/voice/UnknownVoiceRegionError';
|
||||
import {UnknownVoiceServerError} from '@fluxer/errors/src/domains/voice/UnknownVoiceServerError';
|
||||
import type {
|
||||
CreateVoiceRegionRequest,
|
||||
CreateVoiceServerRequest,
|
||||
DeleteVoiceRegionRequest,
|
||||
DeleteVoiceServerRequest,
|
||||
GetVoiceRegionRequest,
|
||||
GetVoiceServerRequest,
|
||||
ListVoiceRegionsRequest,
|
||||
ListVoiceServersRequest,
|
||||
UpdateVoiceRegionRequest,
|
||||
UpdateVoiceServerRequest,
|
||||
VoiceRegionAdminResponse,
|
||||
VoiceServerAdminResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminVoiceSchemas';
|
||||
|
||||
interface AdminVoiceServiceDeps {
|
||||
voiceRepository: VoiceRepository;
|
||||
cacheService: ICacheService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminVoiceService {
|
||||
constructor(private readonly deps: AdminVoiceServiceDeps) {}
|
||||
|
||||
async listVoiceRegions(data: ListVoiceRegionsRequest) {
|
||||
const {voiceRepository} = this.deps;
|
||||
const regions = data.include_servers
|
||||
? await voiceRepository.listRegionsWithServers()
|
||||
: await voiceRepository.listRegions();
|
||||
|
||||
regions.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
if (data.include_servers) {
|
||||
const regionsWithServers = regions as Array<VoiceRegionWithServers>;
|
||||
return {
|
||||
regions: regionsWithServers.map((region) => ({
|
||||
...this.mapVoiceRegionToAdminResponse(region),
|
||||
servers: region.servers
|
||||
.sort((a, b) => a.serverId.localeCompare(b.serverId))
|
||||
.map((server) => this.mapVoiceServerToAdminResponse(server)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
regions: regions.map((region) => this.mapVoiceRegionToAdminResponse(region)),
|
||||
};
|
||||
}
|
||||
|
||||
async getVoiceRegion(data: GetVoiceRegionRequest) {
|
||||
const {voiceRepository} = this.deps;
|
||||
const region = data.include_servers
|
||||
? await voiceRepository.getRegionWithServers(data.id)
|
||||
: await voiceRepository.getRegion(data.id);
|
||||
|
||||
if (!region) {
|
||||
return {region: null};
|
||||
}
|
||||
|
||||
if (data.include_servers && 'servers' in region) {
|
||||
const regionWithServers = region as VoiceRegionWithServers;
|
||||
return {
|
||||
region: {
|
||||
...this.mapVoiceRegionToAdminResponse(regionWithServers),
|
||||
servers: regionWithServers.servers.map((server) => this.mapVoiceServerToAdminResponse(server)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
region: this.mapVoiceRegionToAdminResponse(region),
|
||||
};
|
||||
}
|
||||
|
||||
async createVoiceRegion(data: CreateVoiceRegionRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {voiceRepository, cacheService, auditService} = this.deps;
|
||||
const region = await voiceRepository.createRegion({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
emoji: data.emoji,
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
isDefault: data.is_default ?? false,
|
||||
restrictions: {
|
||||
vipOnly: data.vip_only ?? false,
|
||||
requiredGuildFeatures: new Set(data.required_guild_features ?? []),
|
||||
allowedGuildIds: createGuildIDSet(new Set((data.allowed_guild_ids ?? []).map(BigInt))),
|
||||
allowedUserIds: createUserIDSet(new Set((data.allowed_user_ids ?? []).map(BigInt))),
|
||||
},
|
||||
});
|
||||
|
||||
await cacheService.publish(
|
||||
VOICE_CONFIGURATION_CHANNEL,
|
||||
JSON.stringify({type: 'region_created', regionId: region.id}),
|
||||
);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'voice_region',
|
||||
targetId: BigInt(0),
|
||||
action: 'create_voice_region',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['region_id', region.id],
|
||||
['name', region.name],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
region: this.mapVoiceRegionToAdminResponse(region),
|
||||
};
|
||||
}
|
||||
|
||||
async updateVoiceRegion(data: UpdateVoiceRegionRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {voiceRepository, cacheService, auditService} = this.deps;
|
||||
const existing = await voiceRepository.getRegion(data.id);
|
||||
if (!existing) {
|
||||
throw new UnknownVoiceRegionError();
|
||||
}
|
||||
|
||||
const updates: VoiceRegionRecord = {...existing};
|
||||
|
||||
if (data.name !== undefined) updates.name = data.name;
|
||||
if (data.emoji !== undefined) updates.emoji = data.emoji;
|
||||
if (data.latitude !== undefined) updates.latitude = data.latitude;
|
||||
if (data.longitude !== undefined) updates.longitude = data.longitude;
|
||||
if (data.is_default !== undefined) updates.isDefault = data.is_default;
|
||||
|
||||
if (
|
||||
data.vip_only !== undefined ||
|
||||
data.required_guild_features !== undefined ||
|
||||
data.allowed_guild_ids !== undefined ||
|
||||
data.allowed_user_ids !== undefined
|
||||
) {
|
||||
updates.restrictions = {...existing.restrictions};
|
||||
if (data.vip_only !== undefined) updates.restrictions.vipOnly = data.vip_only;
|
||||
if (data.required_guild_features !== undefined)
|
||||
updates.restrictions.requiredGuildFeatures = new Set(data.required_guild_features);
|
||||
if (data.allowed_guild_ids !== undefined) {
|
||||
updates.restrictions.allowedGuildIds = createGuildIDSet(new Set(data.allowed_guild_ids.map(BigInt)));
|
||||
}
|
||||
if (data.allowed_user_ids !== undefined) {
|
||||
updates.restrictions.allowedUserIds = createUserIDSet(new Set(data.allowed_user_ids.map(BigInt)));
|
||||
}
|
||||
}
|
||||
|
||||
updates.updatedAt = new Date();
|
||||
|
||||
await voiceRepository.upsertRegion(updates);
|
||||
|
||||
await cacheService.publish(
|
||||
VOICE_CONFIGURATION_CHANNEL,
|
||||
JSON.stringify({type: 'region_updated', regionId: data.id}),
|
||||
);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'voice_region',
|
||||
targetId: BigInt(0),
|
||||
action: 'update_voice_region',
|
||||
auditLogReason,
|
||||
metadata: new Map([['region_id', data.id]]),
|
||||
});
|
||||
|
||||
return {
|
||||
region: this.mapVoiceRegionToAdminResponse(updates),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteVoiceRegion(data: DeleteVoiceRegionRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {voiceRepository, cacheService, auditService} = this.deps;
|
||||
const existing = await voiceRepository.getRegion(data.id);
|
||||
if (!existing) {
|
||||
throw new UnknownVoiceRegionError();
|
||||
}
|
||||
|
||||
await voiceRepository.deleteRegion(data.id);
|
||||
|
||||
await cacheService.publish(
|
||||
VOICE_CONFIGURATION_CHANNEL,
|
||||
JSON.stringify({type: 'region_deleted', regionId: data.id}),
|
||||
);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'voice_region',
|
||||
targetId: BigInt(0),
|
||||
action: 'delete_voice_region',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['region_id', data.id],
|
||||
['name', existing.name],
|
||||
]),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async listVoiceServers(data: ListVoiceServersRequest) {
|
||||
const {voiceRepository} = this.deps;
|
||||
const servers = await voiceRepository.listServers(data.region_id);
|
||||
|
||||
return {
|
||||
servers: servers.map((server) => this.mapVoiceServerToAdminResponse(server)),
|
||||
};
|
||||
}
|
||||
|
||||
async getVoiceServer(data: GetVoiceServerRequest) {
|
||||
const {voiceRepository} = this.deps;
|
||||
const server = await voiceRepository.getServer(data.region_id, data.server_id);
|
||||
|
||||
return {
|
||||
server: server ? this.mapVoiceServerToAdminResponse(server) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async createVoiceServer(data: CreateVoiceServerRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {voiceRepository, cacheService, auditService} = this.deps;
|
||||
const server = await voiceRepository.createServer({
|
||||
regionId: data.region_id,
|
||||
serverId: data.server_id,
|
||||
endpoint: data.endpoint,
|
||||
isActive: data.is_active ?? true,
|
||||
apiKey: data.api_key ?? null,
|
||||
apiSecret: data.api_secret ?? null,
|
||||
restrictions: {
|
||||
vipOnly: data.vip_only ?? false,
|
||||
requiredGuildFeatures: new Set(data.required_guild_features ?? []),
|
||||
allowedGuildIds: createGuildIDSet(new Set((data.allowed_guild_ids ?? []).map(BigInt))),
|
||||
allowedUserIds: createUserIDSet(new Set((data.allowed_user_ids ?? []).map(BigInt))),
|
||||
},
|
||||
});
|
||||
|
||||
await cacheService.publish(
|
||||
VOICE_CONFIGURATION_CHANNEL,
|
||||
JSON.stringify({type: 'server_created', regionId: data.region_id, serverId: data.server_id}),
|
||||
);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'voice_server',
|
||||
targetId: BigInt(0),
|
||||
action: 'create_voice_server',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['region_id', server.regionId],
|
||||
['server_id', server.serverId],
|
||||
['endpoint', server.endpoint],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
server: this.mapVoiceServerToAdminResponse(server),
|
||||
};
|
||||
}
|
||||
|
||||
async updateVoiceServer(data: UpdateVoiceServerRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {voiceRepository, cacheService, auditService} = this.deps;
|
||||
const existing = await voiceRepository.getServer(data.region_id, data.server_id);
|
||||
if (!existing) {
|
||||
throw new UnknownVoiceServerError();
|
||||
}
|
||||
|
||||
const updates: VoiceServerRecord = {...existing};
|
||||
if (data.endpoint !== undefined) updates.endpoint = data.endpoint;
|
||||
if (data.api_key !== undefined && data.api_key !== '') updates.apiKey = data.api_key;
|
||||
if (data.api_secret !== undefined && data.api_secret !== '') updates.apiSecret = data.api_secret;
|
||||
if (data.is_active !== undefined) updates.isActive = data.is_active;
|
||||
|
||||
if (
|
||||
data.vip_only !== undefined ||
|
||||
data.required_guild_features !== undefined ||
|
||||
data.allowed_guild_ids !== undefined ||
|
||||
data.allowed_user_ids !== undefined
|
||||
) {
|
||||
updates.restrictions = {...existing.restrictions};
|
||||
if (data.vip_only !== undefined) updates.restrictions.vipOnly = data.vip_only;
|
||||
if (data.required_guild_features !== undefined)
|
||||
updates.restrictions.requiredGuildFeatures = new Set(data.required_guild_features);
|
||||
if (data.allowed_guild_ids !== undefined) {
|
||||
updates.restrictions.allowedGuildIds = createGuildIDSet(new Set(data.allowed_guild_ids.map(BigInt)));
|
||||
}
|
||||
if (data.allowed_user_ids !== undefined) {
|
||||
updates.restrictions.allowedUserIds = createUserIDSet(new Set(data.allowed_user_ids.map(BigInt)));
|
||||
}
|
||||
}
|
||||
|
||||
updates.updatedAt = new Date();
|
||||
|
||||
await voiceRepository.upsertServer(updates);
|
||||
|
||||
await cacheService.publish(
|
||||
VOICE_CONFIGURATION_CHANNEL,
|
||||
JSON.stringify({type: 'server_updated', regionId: data.region_id, serverId: data.server_id}),
|
||||
);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'voice_server',
|
||||
targetId: BigInt(0),
|
||||
action: 'update_voice_server',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['region_id', data.region_id],
|
||||
['server_id', data.server_id],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
server: this.mapVoiceServerToAdminResponse(updates),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteVoiceServer(data: DeleteVoiceServerRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {voiceRepository, cacheService, auditService} = this.deps;
|
||||
const existing = await voiceRepository.getServer(data.region_id, data.server_id);
|
||||
if (!existing) {
|
||||
throw new UnknownVoiceServerError();
|
||||
}
|
||||
|
||||
await voiceRepository.deleteServer(data.region_id, data.server_id);
|
||||
|
||||
await cacheService.publish(
|
||||
VOICE_CONFIGURATION_CHANNEL,
|
||||
JSON.stringify({type: 'server_deleted', regionId: data.region_id, serverId: data.server_id}),
|
||||
);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'voice_server',
|
||||
targetId: BigInt(0),
|
||||
action: 'delete_voice_server',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['region_id', data.region_id],
|
||||
['server_id', data.server_id],
|
||||
['endpoint', existing.endpoint],
|
||||
]),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
private mapVoiceRegionToAdminResponse(region: VoiceRegionRecord): VoiceRegionAdminResponse {
|
||||
return {
|
||||
id: region.id,
|
||||
name: region.name,
|
||||
emoji: region.emoji,
|
||||
latitude: region.latitude,
|
||||
longitude: region.longitude,
|
||||
is_default: region.isDefault,
|
||||
vip_only: region.restrictions.vipOnly,
|
||||
required_guild_features: Array.from(region.restrictions.requiredGuildFeatures),
|
||||
allowed_guild_ids: Array.from(region.restrictions.allowedGuildIds).map((id) => id.toString()),
|
||||
allowed_user_ids: Array.from(region.restrictions.allowedUserIds).map((id) => id.toString()),
|
||||
created_at: region.createdAt?.toISOString() ?? null,
|
||||
updated_at: region.updatedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private mapVoiceServerToAdminResponse(server: VoiceServerRecord): VoiceServerAdminResponse {
|
||||
return {
|
||||
region_id: server.regionId,
|
||||
server_id: server.serverId,
|
||||
endpoint: server.endpoint,
|
||||
is_active: server.isActive,
|
||||
vip_only: server.restrictions.vipOnly,
|
||||
required_guild_features: Array.from(server.restrictions.requiredGuildFeatures),
|
||||
allowed_guild_ids: Array.from(server.restrictions.allowedGuildIds).map((id) => id.toString()),
|
||||
allowed_user_ids: Array.from(server.restrictions.allowedUserIds).map((id) => id.toString()),
|
||||
created_at: server.createdAt?.toISOString() ?? null,
|
||||
updated_at: server.updatedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
244
packages/api/src/admin/services/SystemDmService.tsx
Normal file
244
packages/api/src/admin/services/SystemDmService.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {SystemDmJobRepository} from '@fluxer/api/src/admin/repositories/SystemDmJobRepository';
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import {createGuildID, type GuildID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {SystemDmJobRow} from '@fluxer/api/src/database/types/SystemDmJobTypes';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import {getUserSearchService} from '@fluxer/api/src/SearchFactory';
|
||||
import {collectSystemDmTargets, type SystemDmTargetFilters} from '@fluxer/api/src/system_dm/TargetFinder';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {SystemDmJobResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
|
||||
|
||||
const JOB_TYPE = 'system_dm';
|
||||
const JOB_KEY_PREFIX = 'system_dm_job_';
|
||||
|
||||
interface CreateJobInput {
|
||||
content: string;
|
||||
registrationStart?: Date;
|
||||
registrationEnd?: Date;
|
||||
excludedGuildIds: Array<GuildID>;
|
||||
}
|
||||
|
||||
export class SystemDmService {
|
||||
constructor(
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly jobRepository: SystemDmJobRepository,
|
||||
private readonly auditService: AdminAuditService,
|
||||
private readonly workerService: IWorkerService,
|
||||
private readonly snowflakeService: SnowflakeService,
|
||||
) {}
|
||||
|
||||
private ensureSearchService() {
|
||||
const service = getUserSearchService();
|
||||
if (!service) {
|
||||
throw new Error('Search service is not enabled');
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
async createJob(
|
||||
data: CreateJobInput,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<SystemDmJobResponse> {
|
||||
const searchService = this.ensureSearchService();
|
||||
const targets = await collectSystemDmTargets(
|
||||
{userRepository: this.userRepository, userSearchService: searchService},
|
||||
{
|
||||
registrationStart: data.registrationStart,
|
||||
registrationEnd: data.registrationEnd,
|
||||
excludedGuildIds: new Set(data.excludedGuildIds),
|
||||
},
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const jobId = await this.snowflakeService.generate();
|
||||
|
||||
const job: SystemDmJobRow = {
|
||||
job_type: JOB_TYPE,
|
||||
job_id: jobId,
|
||||
admin_user_id: adminUserId,
|
||||
status: 'pending',
|
||||
content: data.content,
|
||||
registration_start: data.registrationStart ?? null,
|
||||
registration_end: data.registrationEnd ?? null,
|
||||
excluded_guild_ids: new Set(data.excludedGuildIds.map((id) => id.toString())),
|
||||
target_count: targets.length,
|
||||
sent_count: 0,
|
||||
failed_count: 0,
|
||||
last_error: null,
|
||||
worker_job_key: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
approved_by: null,
|
||||
approved_at: null,
|
||||
};
|
||||
|
||||
await this.jobRepository.createJob(job);
|
||||
|
||||
const metadata = new Map<string, string>([
|
||||
['target_count', targets.length.toString()],
|
||||
['content_length', data.content.length.toString()],
|
||||
]);
|
||||
if (data.registrationStart) {
|
||||
metadata.set('registration_start', data.registrationStart.toISOString());
|
||||
}
|
||||
if (data.registrationEnd) {
|
||||
metadata.set('registration_end', data.registrationEnd.toISOString());
|
||||
}
|
||||
if (data.excludedGuildIds.length > 0) {
|
||||
metadata.set('excluded_guild_ids', data.excludedGuildIds.map((id) => id.toString()).join(','));
|
||||
}
|
||||
|
||||
await this.auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'system_dm_job',
|
||||
targetId: jobId,
|
||||
action: 'system_dm_job.create',
|
||||
auditLogReason,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return this.toResponse(job);
|
||||
}
|
||||
|
||||
async listJobs(
|
||||
limit: number,
|
||||
beforeJobId?: bigint,
|
||||
): Promise<{jobs: Array<SystemDmJobResponse>; next_cursor: string | null}> {
|
||||
const jobs = await this.jobRepository.listJobs(limit, beforeJobId);
|
||||
const responses = jobs.map((job) => this.toResponse(job));
|
||||
const nextCursor =
|
||||
jobs.length === limit && jobs[jobs.length - 1].job_id ? jobs[jobs.length - 1].job_id.toString() : null;
|
||||
return {jobs: responses, next_cursor: nextCursor};
|
||||
}
|
||||
|
||||
async getJob(jobId: bigint): Promise<SystemDmJobResponse | null> {
|
||||
const job = await this.jobRepository.getJob(jobId);
|
||||
return job ? this.toResponse(job) : null;
|
||||
}
|
||||
|
||||
async approveJob(jobId: bigint, adminUserId: UserID, auditLogReason: string | null): Promise<SystemDmJobResponse> {
|
||||
const job = await this.jobRepository.getJob(jobId);
|
||||
if (!job) {
|
||||
throw InputValidationError.fromCode('job_id', ValidationErrorCodes.JOB_NOT_FOUND);
|
||||
}
|
||||
if (job.status !== 'pending') {
|
||||
throw InputValidationError.fromCode('job_id', ValidationErrorCodes.JOB_IS_ALREADY_PROCESSED);
|
||||
}
|
||||
|
||||
const searchService = this.ensureSearchService();
|
||||
const filters: SystemDmTargetFilters = {
|
||||
registrationStart: job.registration_start ?? undefined,
|
||||
registrationEnd: job.registration_end ?? undefined,
|
||||
excludedGuildIds: this.convertExcludedGuildIds(job.excluded_guild_ids),
|
||||
};
|
||||
const targets = await collectSystemDmTargets(
|
||||
{
|
||||
userRepository: this.userRepository,
|
||||
userSearchService: searchService,
|
||||
},
|
||||
filters,
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const jobKey = JOB_KEY_PREFIX + jobId.toString();
|
||||
|
||||
await this.jobRepository.patchJob(jobId, {
|
||||
status: 'approved',
|
||||
approved_by: adminUserId,
|
||||
approved_at: now,
|
||||
target_count: targets.length,
|
||||
worker_job_key: jobKey,
|
||||
updated_at: now,
|
||||
});
|
||||
|
||||
const metadata = new Map<string, string>([
|
||||
['target_count', targets.length.toString()],
|
||||
['worker_job_key', jobKey],
|
||||
]);
|
||||
if (job.excluded_guild_ids && job.excluded_guild_ids.size > 0) {
|
||||
metadata.set('excluded_guild_ids', Array.from(job.excluded_guild_ids).join(','));
|
||||
}
|
||||
|
||||
await this.auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'system_dm_job',
|
||||
targetId: jobId,
|
||||
action: 'system_dm_job.approve',
|
||||
auditLogReason,
|
||||
metadata,
|
||||
});
|
||||
|
||||
await this.workerService.addJob(
|
||||
'sendSystemDm',
|
||||
{job_id: jobId.toString()},
|
||||
{
|
||||
jobKey,
|
||||
maxAttempts: 3,
|
||||
},
|
||||
);
|
||||
|
||||
const updatedJob = await this.jobRepository.getJob(jobId);
|
||||
if (!updatedJob) {
|
||||
throw new Error('Failed to reload job after approval');
|
||||
}
|
||||
return this.toResponse(updatedJob);
|
||||
}
|
||||
|
||||
private toResponse(job: SystemDmJobRow): SystemDmJobResponse {
|
||||
return {
|
||||
job_id: job.job_id.toString(),
|
||||
status: job.status,
|
||||
content: job.content,
|
||||
target_count: job.target_count,
|
||||
sent_count: job.sent_count,
|
||||
failed_count: job.failed_count,
|
||||
created_at: job.created_at.toISOString(),
|
||||
approved_at: job.approved_at?.toISOString() ?? null,
|
||||
registration_start: job.registration_start?.toISOString() ?? null,
|
||||
registration_end: job.registration_end?.toISOString() ?? null,
|
||||
excluded_guild_ids: Array.from(job.excluded_guild_ids ?? []),
|
||||
last_error: job.last_error,
|
||||
};
|
||||
}
|
||||
|
||||
private convertExcludedGuildIds(values?: ReadonlySet<string>): Set<GuildID> {
|
||||
const parsed = new Set<GuildID>();
|
||||
if (!values) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
for (const value of values) {
|
||||
try {
|
||||
parsed.add(createGuildID(BigInt(value)));
|
||||
} catch (error) {
|
||||
Logger.debug({value, error}, 'Failed to parse excluded guild ID, skipping invalid value');
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import type {AdminGuildUpdateService} from '@fluxer/api/src/admin/services/guild/AdminGuildUpdateService';
|
||||
import {createGuildID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {BulkUpdateGuildFeaturesRequest} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
|
||||
interface AdminGuildBulkServiceDeps {
|
||||
guildUpdateService: AdminGuildUpdateService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminGuildBulkService {
|
||||
constructor(private readonly deps: AdminGuildBulkServiceDeps) {}
|
||||
|
||||
async bulkUpdateGuildFeatures(
|
||||
data: BulkUpdateGuildFeaturesRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {guildUpdateService, auditService} = this.deps;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{id: string; error: string}> = [];
|
||||
|
||||
for (const guildIdBigInt of data.guild_ids) {
|
||||
try {
|
||||
const guildId = createGuildID(guildIdBigInt);
|
||||
await guildUpdateService.updateGuildFeatures({
|
||||
guildId,
|
||||
addFeatures: data.add_features,
|
||||
removeFeatures: data.remove_features,
|
||||
adminUserId,
|
||||
auditLogReason: null,
|
||||
});
|
||||
successful.push(guildId.toString());
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
id: guildIdBigInt.toString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_update_guild_features',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['guild_count', data.guild_ids.length.toString()],
|
||||
['add_features', data.add_features.join(',')],
|
||||
['remove_features', data.remove_features.join(',')],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
successful,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mapGuildsToAdminResponse} from '@fluxer/api/src/admin/models/GuildTypes';
|
||||
import type {GuildID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createGuildID, createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import {mapGuildFeatures} from '@fluxer/api/src/guild/GuildFeatureUtils';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {MEDIA_PROXY_ICON_SIZE_DEFAULT} from '@fluxer/constants/src/MediaProxyAssetSizes';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
ListGuildMembersRequest,
|
||||
ListUserGuildsRequest,
|
||||
LookupGuildRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
import type {ListGuildEmojisResponse, ListGuildStickersResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
|
||||
interface AdminGuildLookupServiceDeps {
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
userRepository: IUserRepository;
|
||||
channelRepository: IChannelRepository;
|
||||
gatewayService: IGatewayService;
|
||||
}
|
||||
|
||||
export class AdminGuildLookupService {
|
||||
constructor(private readonly deps: AdminGuildLookupServiceDeps) {}
|
||||
|
||||
async lookupGuild(data: LookupGuildRequest) {
|
||||
const {guildRepository, channelRepository} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
|
||||
if (!guild) {
|
||||
return {guild: null};
|
||||
}
|
||||
|
||||
const channels = await channelRepository.listGuildChannels(guildId);
|
||||
const roles = await guildRepository.listRoles(guildId);
|
||||
|
||||
return {
|
||||
guild: {
|
||||
id: guild.id.toString(),
|
||||
owner_id: guild.ownerId.toString(),
|
||||
name: guild.name,
|
||||
vanity_url_code: guild.vanityUrlCode,
|
||||
icon: guild.iconHash,
|
||||
banner: guild.bannerHash,
|
||||
splash: guild.splashHash,
|
||||
embed_splash: guild.embedSplashHash,
|
||||
features: mapGuildFeatures(guild.features),
|
||||
verification_level: guild.verificationLevel,
|
||||
mfa_level: guild.mfaLevel,
|
||||
nsfw_level: guild.nsfwLevel,
|
||||
explicit_content_filter: guild.explicitContentFilter,
|
||||
default_message_notifications: guild.defaultMessageNotifications,
|
||||
afk_channel_id: guild.afkChannelId?.toString() ?? null,
|
||||
afk_timeout: guild.afkTimeout,
|
||||
system_channel_id: guild.systemChannelId?.toString() ?? null,
|
||||
system_channel_flags: guild.systemChannelFlags,
|
||||
rules_channel_id: guild.rulesChannelId?.toString() ?? null,
|
||||
disabled_operations: guild.disabledOperations,
|
||||
member_count: guild.memberCount,
|
||||
channels: channels.map((c) => ({
|
||||
id: c.id.toString(),
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
position: c.position,
|
||||
parent_id: c.parentId?.toString() ?? null,
|
||||
})),
|
||||
roles: roles.map((r) => ({
|
||||
id: r.id.toString(),
|
||||
name: r.name,
|
||||
color: r.color,
|
||||
position: r.position,
|
||||
permissions: r.permissions.toString(),
|
||||
hoist: r.isHoisted,
|
||||
mentionable: r.isMentionable,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async listUserGuilds(data: ListUserGuildsRequest) {
|
||||
const {userRepository, guildRepository, gatewayService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
let guildIds = await userRepository.getUserGuildIds(userId);
|
||||
|
||||
guildIds.sort((a, b) => {
|
||||
if (a < b) return -1;
|
||||
if (a > b) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (data.after != null) {
|
||||
const afterId = createGuildID(data.after);
|
||||
const afterIndex = guildIds.indexOf(afterId);
|
||||
if (afterIndex !== -1) {
|
||||
guildIds = guildIds.slice(afterIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.before != null) {
|
||||
const beforeId = createGuildID(data.before);
|
||||
const beforeIndex = guildIds.indexOf(beforeId);
|
||||
if (beforeIndex !== -1) {
|
||||
guildIds = guildIds.slice(0, beforeIndex);
|
||||
}
|
||||
}
|
||||
|
||||
const limit = data.limit ?? 200;
|
||||
guildIds = guildIds.slice(0, limit);
|
||||
|
||||
const guilds = await guildRepository.listGuilds(guildIds);
|
||||
const result = mapGuildsToAdminResponse(guilds);
|
||||
|
||||
if (data.with_counts) {
|
||||
const countsPromises = guilds.map((g) => gatewayService.getGuildCounts(g.id));
|
||||
const counts = await Promise.all(countsPromises);
|
||||
return {
|
||||
guilds: result.guilds.map((g, i) => ({
|
||||
...g,
|
||||
approximate_member_count: counts[i].memberCount,
|
||||
approximate_presence_count: counts[i].presenceCount,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async listGuildMembers(data: ListGuildMembersRequest) {
|
||||
const {gatewayService} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const limit = data.limit ?? 50;
|
||||
const offset = data.offset ?? 0;
|
||||
|
||||
const result = await gatewayService.listGuildMembers({
|
||||
guildId,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
return {
|
||||
members: result.members,
|
||||
total: result.total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
async listGuildEmojis(guildId: GuildID): Promise<ListGuildEmojisResponse> {
|
||||
const {guildRepository} = this.deps;
|
||||
const emojis = await guildRepository.listEmojis(guildId);
|
||||
|
||||
return {
|
||||
guild_id: guildId.toString(),
|
||||
emojis: emojis.map((emoji) => {
|
||||
const emojiId = emoji.id.toString();
|
||||
return {
|
||||
id: emojiId,
|
||||
name: emoji.name,
|
||||
animated: emoji.isAnimated,
|
||||
creator_id: emoji.creatorId.toString(),
|
||||
media_url: this.buildEmojiMediaUrl(emojiId, emoji.isAnimated),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async listGuildStickers(guildId: GuildID): Promise<ListGuildStickersResponse> {
|
||||
const {guildRepository} = this.deps;
|
||||
const stickers = await guildRepository.listStickers(guildId);
|
||||
|
||||
return {
|
||||
guild_id: guildId.toString(),
|
||||
stickers: stickers.map((sticker) => {
|
||||
const stickerId = sticker.id.toString();
|
||||
return {
|
||||
id: stickerId,
|
||||
name: sticker.name,
|
||||
animated: sticker.animated,
|
||||
creator_id: sticker.creatorId.toString(),
|
||||
media_url: this.buildStickerMediaUrl(stickerId, sticker.animated),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private buildEmojiMediaUrl(id: string, animated: boolean): string {
|
||||
return `${Config.endpoints.media}/emojis/${id}.webp?size=${MEDIA_PROXY_ICON_SIZE_DEFAULT}${animated ? '&animated=true' : ''}`;
|
||||
}
|
||||
|
||||
private buildStickerMediaUrl(id: string, animated: boolean): string {
|
||||
return `${Config.endpoints.media}/stickers/${id}.webp?size=${MEDIA_PROXY_ICON_SIZE_DEFAULT}${animated ? '&animated=true' : ''}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import {createGuildID, type GuildID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
|
||||
interface AdminGuildManagementServiceDeps {
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
gatewayService: IGatewayService;
|
||||
guildService: GuildService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminGuildManagementService {
|
||||
constructor(private readonly deps: AdminGuildManagementServiceDeps) {}
|
||||
|
||||
async reloadGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildRepository, gatewayService, auditService} = this.deps;
|
||||
const guildId = createGuildID(guildIdRaw);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
await gatewayService.reloadGuild(guildId);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: guildIdRaw,
|
||||
action: 'reload_guild',
|
||||
auditLogReason,
|
||||
metadata: new Map([['guild_id', guildIdRaw.toString()]]),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async shutdownGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildRepository, gatewayService, auditService} = this.deps;
|
||||
const guildId = createGuildID(guildIdRaw);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
await gatewayService.shutdownGuild(guildId);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: guildIdRaw,
|
||||
action: 'shutdown_guild',
|
||||
auditLogReason,
|
||||
metadata: new Map([['guild_id', guildIdRaw.toString()]]),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async deleteGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildService, auditService} = this.deps;
|
||||
const guildId = createGuildID(guildIdRaw);
|
||||
|
||||
await guildService.deleteGuildAsAdmin(guildId, auditLogReason);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: guildIdRaw,
|
||||
action: 'delete_guild',
|
||||
auditLogReason,
|
||||
metadata: new Map([['guild_id', guildIdRaw.toString()]]),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async getGuildMemoryStats(limit: number) {
|
||||
const {gatewayService} = this.deps;
|
||||
return await gatewayService.getGuildMemoryStats(limit);
|
||||
}
|
||||
|
||||
async reloadAllGuilds(guildIds: Array<GuildID>) {
|
||||
const {gatewayService} = this.deps;
|
||||
return await gatewayService.reloadAllGuilds(guildIds);
|
||||
}
|
||||
|
||||
async getNodeStats() {
|
||||
const {gatewayService} = this.deps;
|
||||
return await gatewayService.getNodeStats();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import {createGuildID, createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
|
||||
import {createRequestCache, type RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {JoinSourceTypes} from '@fluxer/constants/src/GuildConstants';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
BanGuildMemberRequest,
|
||||
BulkAddGuildMembersRequest,
|
||||
ForceAddUserToGuildRequest,
|
||||
KickGuildMemberRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
|
||||
interface AdminGuildMembershipServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
guildService: GuildService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminGuildMembershipService {
|
||||
constructor(private readonly deps: AdminGuildMembershipServiceDeps) {}
|
||||
|
||||
async forceAddUserToGuild({
|
||||
data,
|
||||
requestCache,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: {
|
||||
data: ForceAddUserToGuildRequest;
|
||||
requestCache: RequestCache;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
const {userRepository, guildService, auditService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
await guildService.addUserToGuild({
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: true,
|
||||
skipBanCheck: true,
|
||||
joinSourceType: JoinSourceTypes.ADMIN_FORCE_ADD,
|
||||
requestCache,
|
||||
initiatorId: adminUserId,
|
||||
});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'force_add_to_guild',
|
||||
auditLogReason,
|
||||
metadata: new Map([['guild_id', String(guildId)]]),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async bulkAddGuildMembers(data: BulkAddGuildMembersRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildService, auditService} = this.deps;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{id: string; error: string}> = [];
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
|
||||
for (const userIdBigInt of data.user_ids) {
|
||||
try {
|
||||
const userId = createUserID(userIdBigInt);
|
||||
await guildService.addUserToGuild({
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: false,
|
||||
skipBanCheck: true,
|
||||
joinSourceType: JoinSourceTypes.ADMIN_FORCE_ADD,
|
||||
requestCache: createRequestCache(),
|
||||
initiatorId: adminUserId,
|
||||
});
|
||||
successful.push(userId.toString());
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
id: userIdBigInt.toString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'bulk_add_guild_members',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['guild_id', guildId.toString()],
|
||||
['user_count', data.user_ids.length.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
successful,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
|
||||
async banMember(data: BanGuildMemberRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildService, auditService} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const targetId = createUserID(data.user_id);
|
||||
|
||||
await guildService.banMember(
|
||||
{
|
||||
userId: adminUserId,
|
||||
guildId,
|
||||
targetId,
|
||||
deleteMessageDays: data.delete_message_days,
|
||||
reason: data.reason ?? undefined,
|
||||
banDurationSeconds: data.ban_duration_seconds ?? undefined,
|
||||
},
|
||||
auditLogReason,
|
||||
);
|
||||
|
||||
const metadata = new Map([
|
||||
['guild_id', guildId.toString()],
|
||||
['user_id', targetId.toString()],
|
||||
['delete_message_days', data.delete_message_days.toString()],
|
||||
]);
|
||||
|
||||
if (data.reason) {
|
||||
metadata.set('reason', data.reason);
|
||||
}
|
||||
|
||||
if (data.ban_duration_seconds != null) {
|
||||
metadata.set('ban_duration_seconds', data.ban_duration_seconds.toString());
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild_member',
|
||||
targetId,
|
||||
action: 'ban_member',
|
||||
auditLogReason,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
async kickMember(data: KickGuildMemberRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildService, auditService} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const targetId = createUserID(data.user_id);
|
||||
|
||||
await guildService.removeMember(
|
||||
{
|
||||
userId: adminUserId,
|
||||
targetId,
|
||||
guildId,
|
||||
},
|
||||
auditLogReason,
|
||||
);
|
||||
|
||||
const metadata = new Map([
|
||||
['guild_id', guildId.toString()],
|
||||
['user_id', targetId.toString()],
|
||||
]);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild_member',
|
||||
targetId,
|
||||
action: 'kick_member',
|
||||
auditLogReason,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {mapGuildToGuildResponse} from '@fluxer/api/src/guild/GuildModel';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {Guild} from '@fluxer/api/src/models/Guild';
|
||||
|
||||
interface AdminGuildUpdatePropagatorDeps {
|
||||
gatewayService: IGatewayService;
|
||||
}
|
||||
|
||||
export class AdminGuildUpdatePropagator {
|
||||
constructor(private readonly deps: AdminGuildUpdatePropagatorDeps) {}
|
||||
|
||||
async dispatchGuildUpdate(guildId: GuildID, updatedGuild: Guild): Promise<void> {
|
||||
const {gatewayService} = this.deps;
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_UPDATE',
|
||||
data: mapGuildToGuildResponse(updatedGuild),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mapGuildToAdminResponse} from '@fluxer/api/src/admin/models/GuildTypes';
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import type {AdminGuildUpdatePropagator} from '@fluxer/api/src/admin/services/guild/AdminGuildUpdatePropagator';
|
||||
import {createGuildID, createUserID, type GuildID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {EntityAssetService, PreparedAssetUpload} from '@fluxer/api/src/infrastructure/EntityAssetService';
|
||||
import type {Guild} from '@fluxer/api/src/models/Guild';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import type {
|
||||
ClearGuildFieldsRequest,
|
||||
TransferGuildOwnershipRequest,
|
||||
UpdateGuildNameRequest,
|
||||
UpdateGuildSettingsRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
|
||||
interface AdminGuildUpdateServiceDeps {
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
entityAssetService: EntityAssetService;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminGuildUpdatePropagator;
|
||||
}
|
||||
|
||||
export class AdminGuildUpdateService {
|
||||
constructor(private readonly deps: AdminGuildUpdateServiceDeps) {}
|
||||
|
||||
async updateGuildFeatures({
|
||||
guildId,
|
||||
addFeatures,
|
||||
removeFeatures,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: {
|
||||
guildId: GuildID;
|
||||
addFeatures: Array<string>;
|
||||
removeFeatures: Array<string>;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
const {guildRepository, auditService, updatePropagator} = this.deps;
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const newFeatures = new Set(guild.features);
|
||||
for (const feature of addFeatures) {
|
||||
newFeatures.add(feature);
|
||||
}
|
||||
for (const feature of removeFeatures) {
|
||||
newFeatures.delete(feature);
|
||||
}
|
||||
|
||||
const guildRow = guild.toRow();
|
||||
const updatedGuild = await guildRepository.upsert({
|
||||
...guildRow,
|
||||
features: newFeatures,
|
||||
});
|
||||
|
||||
await updatePropagator.dispatchGuildUpdate(guildId, updatedGuild);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'update_features',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['add_features', addFeatures.join(',')],
|
||||
['remove_features', removeFeatures.join(',')],
|
||||
['new_features', Array.from(newFeatures).join(',')],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
guild: mapGuildToAdminResponse(updatedGuild),
|
||||
};
|
||||
}
|
||||
|
||||
async clearGuildFields(data: ClearGuildFieldsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildRepository, entityAssetService, auditService, updatePropagator} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const guildRow = guild.toRow();
|
||||
const updates: Partial<typeof guildRow> = {};
|
||||
const preparedAssets: Array<PreparedAssetUpload> = [];
|
||||
|
||||
for (const field of data.fields) {
|
||||
if (field === 'icon') {
|
||||
const prepared = await entityAssetService.prepareAssetUpload({
|
||||
assetType: 'icon',
|
||||
entityType: 'guild',
|
||||
entityId: guildId,
|
||||
previousHash: guild.iconHash,
|
||||
base64Image: null,
|
||||
errorPath: 'icon',
|
||||
});
|
||||
preparedAssets.push(prepared);
|
||||
updates.icon_hash = prepared.newHash;
|
||||
} else if (field === 'banner') {
|
||||
const prepared = await entityAssetService.prepareAssetUpload({
|
||||
assetType: 'banner',
|
||||
entityType: 'guild',
|
||||
entityId: guildId,
|
||||
previousHash: guild.bannerHash,
|
||||
base64Image: null,
|
||||
errorPath: 'banner',
|
||||
});
|
||||
preparedAssets.push(prepared);
|
||||
updates.banner_hash = prepared.newHash;
|
||||
} else if (field === 'splash') {
|
||||
const prepared = await entityAssetService.prepareAssetUpload({
|
||||
assetType: 'splash',
|
||||
entityType: 'guild',
|
||||
entityId: guildId,
|
||||
previousHash: guild.splashHash,
|
||||
base64Image: null,
|
||||
errorPath: 'splash',
|
||||
});
|
||||
preparedAssets.push(prepared);
|
||||
updates.splash_hash = prepared.newHash;
|
||||
} else if (field === 'embed_splash') {
|
||||
const prepared = await entityAssetService.prepareAssetUpload({
|
||||
assetType: 'embed_splash',
|
||||
entityType: 'guild',
|
||||
entityId: guildId,
|
||||
previousHash: guild.embedSplashHash,
|
||||
base64Image: null,
|
||||
errorPath: 'embed_splash',
|
||||
});
|
||||
preparedAssets.push(prepared);
|
||||
updates.embed_splash_hash = prepared.newHash;
|
||||
}
|
||||
}
|
||||
|
||||
let updatedGuild: Guild;
|
||||
try {
|
||||
updatedGuild = await guildRepository.upsert({
|
||||
...guildRow,
|
||||
...updates,
|
||||
});
|
||||
} catch (error) {
|
||||
await Promise.allSettled(preparedAssets.map((p) => entityAssetService.rollbackAssetUpload(p)));
|
||||
throw error;
|
||||
}
|
||||
|
||||
await Promise.allSettled(preparedAssets.map((p) => entityAssetService.commitAssetChange({prepared: p})));
|
||||
|
||||
await updatePropagator.dispatchGuildUpdate(guildId, updatedGuild);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'clear_fields',
|
||||
auditLogReason,
|
||||
metadata: new Map([['fields', data.fields.join(',')]]),
|
||||
});
|
||||
}
|
||||
|
||||
async updateGuildName(data: UpdateGuildNameRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildRepository, auditService, updatePropagator} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const oldName = guild.name;
|
||||
const guildRow = guild.toRow();
|
||||
const updatedGuild = await guildRepository.upsert({
|
||||
...guildRow,
|
||||
name: data.name,
|
||||
});
|
||||
|
||||
await updatePropagator.dispatchGuildUpdate(guildId, updatedGuild);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'update_name',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['old_name', oldName],
|
||||
['new_name', data.name],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
guild: mapGuildToAdminResponse(updatedGuild),
|
||||
};
|
||||
}
|
||||
|
||||
async updateGuildSettings(data: UpdateGuildSettingsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildRepository, auditService, updatePropagator} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const guildRow = guild.toRow();
|
||||
const updates: Partial<typeof guildRow> = {};
|
||||
const metadata = new Map<string, string>();
|
||||
|
||||
if (data.verification_level !== undefined) {
|
||||
updates.verification_level = data.verification_level;
|
||||
metadata.set('verification_level', data.verification_level.toString());
|
||||
}
|
||||
if (data.mfa_level !== undefined) {
|
||||
updates.mfa_level = data.mfa_level;
|
||||
metadata.set('mfa_level', data.mfa_level.toString());
|
||||
}
|
||||
if (data.nsfw_level !== undefined) {
|
||||
updates.nsfw_level = data.nsfw_level;
|
||||
metadata.set('nsfw_level', data.nsfw_level.toString());
|
||||
}
|
||||
if (data.explicit_content_filter !== undefined) {
|
||||
updates.explicit_content_filter = data.explicit_content_filter;
|
||||
metadata.set('explicit_content_filter', data.explicit_content_filter.toString());
|
||||
}
|
||||
if (data.default_message_notifications !== undefined) {
|
||||
updates.default_message_notifications = data.default_message_notifications;
|
||||
metadata.set('default_message_notifications', data.default_message_notifications.toString());
|
||||
}
|
||||
if (data.disabled_operations !== undefined) {
|
||||
updates.disabled_operations = data.disabled_operations;
|
||||
metadata.set('disabled_operations', data.disabled_operations.toString());
|
||||
}
|
||||
|
||||
const updatedGuild = await guildRepository.upsert({
|
||||
...guildRow,
|
||||
...updates,
|
||||
});
|
||||
|
||||
await updatePropagator.dispatchGuildUpdate(guildId, updatedGuild);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'update_settings',
|
||||
auditLogReason,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
guild: mapGuildToAdminResponse(updatedGuild),
|
||||
};
|
||||
}
|
||||
|
||||
async transferGuildOwnership(
|
||||
data: TransferGuildOwnershipRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {guildRepository, auditService, updatePropagator} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const newOwnerId = createUserID(data.new_owner_id);
|
||||
|
||||
const oldOwnerId = guild.ownerId;
|
||||
const guildRow = guild.toRow();
|
||||
const updatedGuild = await guildRepository.upsert(
|
||||
{
|
||||
...guildRow,
|
||||
owner_id: newOwnerId,
|
||||
},
|
||||
undefined,
|
||||
oldOwnerId,
|
||||
);
|
||||
|
||||
await updatePropagator.dispatchGuildUpdate(guildId, updatedGuild);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'transfer_ownership',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['old_owner_id', oldOwnerId.toString()],
|
||||
['new_owner_id', newOwnerId.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
guild: mapGuildToAdminResponse(updatedGuild),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mapGuildToAdminResponse} from '@fluxer/api/src/admin/models/GuildTypes';
|
||||
import type {AdminAuditService} from '@fluxer/api/src/admin/services/AdminAuditService';
|
||||
import type {AdminGuildUpdatePropagator} from '@fluxer/api/src/admin/services/guild/AdminGuildUpdatePropagator';
|
||||
import {
|
||||
createGuildID,
|
||||
createInviteCode,
|
||||
createVanityURLCode,
|
||||
type UserID,
|
||||
vanityCodeToInviteCode,
|
||||
} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {InviteRepository} from '@fluxer/api/src/invite/InviteRepository';
|
||||
import {InviteTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import type {UpdateGuildVanityRequest} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
|
||||
interface AdminGuildVanityServiceDeps {
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
inviteRepository: InviteRepository;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminGuildUpdatePropagator;
|
||||
}
|
||||
|
||||
export class AdminGuildVanityService {
|
||||
constructor(private readonly deps: AdminGuildVanityServiceDeps) {}
|
||||
|
||||
async updateGuildVanity(data: UpdateGuildVanityRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildRepository, inviteRepository, auditService, updatePropagator} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const oldVanity = guild.vanityUrlCode;
|
||||
const guildRow = guild.toRow();
|
||||
|
||||
if (data.vanity_url_code) {
|
||||
const inviteCode = createInviteCode(data.vanity_url_code);
|
||||
const existingInvite = await inviteRepository.findUnique(inviteCode);
|
||||
if (existingInvite) {
|
||||
throw InputValidationError.fromCode('vanity_url_code', ValidationErrorCodes.THIS_VANITY_URL_IS_ALREADY_TAKEN);
|
||||
}
|
||||
|
||||
if (oldVanity) {
|
||||
await inviteRepository.delete(vanityCodeToInviteCode(oldVanity));
|
||||
}
|
||||
|
||||
await inviteRepository.create({
|
||||
code: inviteCode,
|
||||
type: InviteTypes.GUILD,
|
||||
guild_id: guildId,
|
||||
channel_id: null,
|
||||
inviter_id: null,
|
||||
uses: 0,
|
||||
max_uses: 0,
|
||||
max_age: 0,
|
||||
temporary: false,
|
||||
});
|
||||
} else if (oldVanity) {
|
||||
await inviteRepository.delete(vanityCodeToInviteCode(oldVanity));
|
||||
}
|
||||
|
||||
const updatedGuild = await guildRepository.upsert({
|
||||
...guildRow,
|
||||
vanity_url_code: data.vanity_url_code ? createVanityURLCode(data.vanity_url_code) : null,
|
||||
});
|
||||
|
||||
await updatePropagator.dispatchGuildUpdate(guildId, updatedGuild);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'update_vanity',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['old_vanity', oldVanity ?? ''],
|
||||
['new_vanity', data.vanity_url_code ?? ''],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
guild: mapGuildToAdminResponse(updatedGuild),
|
||||
};
|
||||
}
|
||||
}
|
||||
167
packages/api/src/admin/tests/AdminApiKeyACLValidation.test.tsx
Normal file
167
packages/api/src/admin/tests/AdminApiKeyACLValidation.test.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createAdminApiKey, listAdminApiKeys} from '@fluxer/api/src/admin/tests/AdminTestUtils';
|
||||
import {createTestAccount, setUserACLs} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Admin API Key ACL Validation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
test('user cannot grant ACLs they do not have - grant only ACLs user has', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
acls: ['audit_log:view'],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('user cannot grant ACLs they do not have - grant ACL user does not have', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
acls: ['audit_log:view', 'user:lookup'],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('user with wildcard can grant any ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['*']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Wildcard Test Key',
|
||||
acls: ['audit_log:view', 'user:lookup', 'guild:lookup', 'archive:trigger:user'],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('must have admin_api_key:manage ACL to create keys', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
acls: ['audit_log:view'],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('ACLs are stored correctly', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const requestedACLs = ['audit_log:view', 'user:lookup', 'guild:lookup'];
|
||||
|
||||
await createAdminApiKey(harness, admin, 'ACL Storage Test', requestedACLs, null);
|
||||
|
||||
const keys = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keys).toHaveLength(1);
|
||||
|
||||
const keyACLs = keys[0]!.acls as Array<string>;
|
||||
expect(keyACLs).toHaveLength(requestedACLs.length);
|
||||
expect(keyACLs).toEqual(expect.arrayContaining(requestedACLs));
|
||||
});
|
||||
|
||||
test('cannot grant admin_api_key:manage without having it', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'audit_log:view', 'user:lookup']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
acls: ['audit_log:view', 'admin_api_key:manage'],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('user with limited ACLs can create key with subset', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view', 'user:lookup']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
acls: ['audit_log:view'],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('user with limited ACLs cannot create key with broader ACLs', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
acls: ['audit_log:view', 'user:lookup'],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('empty ACL list is valid', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
acls: [],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
321
packages/api/src/admin/tests/AdminApiKeyAuthentication.test.tsx
Normal file
321
packages/api/src/admin/tests/AdminApiKeyAuthentication.test.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {TestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createTestAccount, setUserACLs} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface AdminApiKey {
|
||||
keyId: string;
|
||||
key: string;
|
||||
name: string;
|
||||
acls: Array<string>;
|
||||
token: string;
|
||||
}
|
||||
|
||||
async function createAdminApiKey(
|
||||
harness: ApiTestHarness,
|
||||
account: TestAccount,
|
||||
name: string,
|
||||
acls: Array<string>,
|
||||
expiresInDays: number | null,
|
||||
): Promise<AdminApiKey> {
|
||||
const data = await createBuilder<{key_id: string; key: string; name: string; acls: Array<string>}>(
|
||||
harness,
|
||||
`Bearer ${account.token}`,
|
||||
)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name,
|
||||
acls,
|
||||
...(expiresInDays !== null ? {expires_in_days: expiresInDays} : {}),
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
return {
|
||||
keyId: data.key_id,
|
||||
key: data.key,
|
||||
name: data.name,
|
||||
acls: data.acls,
|
||||
token: `Admin ${data.key}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function createAdminApiKeyWithDefaultACLs(
|
||||
harness: ApiTestHarness,
|
||||
account: TestAccount,
|
||||
name: string,
|
||||
): Promise<AdminApiKey> {
|
||||
return await createAdminApiKey(harness, account, name, ['audit_log:view', 'user:lookup', 'guild:lookup'], null);
|
||||
}
|
||||
|
||||
async function listAdminApiKeys(harness: ApiTestHarness, token: string): Promise<Array<Record<string, unknown>>> {
|
||||
return await createBuilder<Array<Record<string, unknown>>>(harness, `Bearer ${token}`)
|
||||
.get('/admin/api-keys')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function revokeAdminApiKey(harness: ApiTestHarness, token: string, keyId: string): Promise<void> {
|
||||
await createBuilder(harness, `Bearer ${token}`)
|
||||
.delete(`/admin/api-keys/${keyId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Admin API Key Authentication', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
test('valid key authenticates successfully', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Auth Test Key');
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('invalid key is rejected', async () => {
|
||||
await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, 'Admin invalid_key_12345')
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: ['123456789'],
|
||||
})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('revoked key is rejected', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Revoke Test Key');
|
||||
await revokeAdminApiKey(harness, admin.token, apiKey.keyId);
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('wrong prefix is rejected', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Prefix Test Key');
|
||||
|
||||
await createBuilder(harness, `Bearer ${apiKey.key}`)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('missing prefix is rejected', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'No Prefix Test Key');
|
||||
|
||||
await createBuilder(harness, apiKey.key)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('case sensitive prefix', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Case Test Key');
|
||||
|
||||
await createBuilder(harness, `admin ${apiKey.key}`)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('empty key is rejected', async () => {
|
||||
await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, 'Admin ')
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: ['123456789'],
|
||||
})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('cannot authenticate to user endpoints', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'User Endpoint Test Key');
|
||||
|
||||
await createBuilder(harness, apiKey.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
});
|
||||
|
||||
test('cannot authenticate to bot endpoints', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Bot Endpoint Test Key');
|
||||
|
||||
await createBuilder(harness, `Bot ${apiKey.key}`).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
});
|
||||
|
||||
test('updates last_used_at timestamp', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Last Used Test Key');
|
||||
|
||||
const keys = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keys).toHaveLength(1);
|
||||
expect(keys[0]!.last_used_at).toBeNull();
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.execute();
|
||||
|
||||
const keysAfter = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keysAfter).toHaveLength(1);
|
||||
expect(keysAfter[0]!.last_used_at).not.toBeNull();
|
||||
});
|
||||
|
||||
test('multiple keys for same user all work', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']);
|
||||
|
||||
const key1 = await createAdminApiKey(harness, admin, 'Key 1', ['admin:authenticate', 'user:lookup'], null);
|
||||
const key2 = await createAdminApiKey(harness, admin, 'Key 2', ['admin:authenticate', 'user:lookup'], null);
|
||||
const key3 = await createAdminApiKey(harness, admin, 'Key 3', ['admin:authenticate', 'user:lookup'], null);
|
||||
|
||||
for (const key of [key1, key2, key3]) {
|
||||
await createBuilder(harness, key.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
const keys = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keys).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('different users can use same key if creator has permissions', async () => {
|
||||
const admin1 = await createTestAccount(harness);
|
||||
const admin2 = await createTestAccount(harness);
|
||||
|
||||
await setUserACLs(harness, admin1, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']);
|
||||
await setUserACLs(harness, admin2, ['admin:authenticate', 'user:lookup']);
|
||||
|
||||
const key1 = await createAdminApiKey(harness, admin1, 'Admin 1 Key', ['admin:authenticate', 'user:lookup'], null);
|
||||
|
||||
await createBuilder(harness, key1.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin2.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
212
packages/api/src/admin/tests/AdminApiKeyAuthorization.test.tsx
Normal file
212
packages/api/src/admin/tests/AdminApiKeyAuthorization.test.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createAdminApiKey} from '@fluxer/api/src/admin/tests/AdminTestUtils';
|
||||
import {createTestAccount, setUserACLs} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Admin API Key Authorization', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
test('key can access endpoints with granted ACLs', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view', 'user:lookup']);
|
||||
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'Test Key', ['audit_log:view', 'user:lookup'], null);
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/audit-logs')
|
||||
.body({
|
||||
limit: 10,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('key fails if owner loses admin access', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']);
|
||||
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'Owner Check', ['user:lookup'], null);
|
||||
await setUserACLs(harness, admin, ['admin_api_key:manage', 'user:lookup']);
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('key fails if owner loses required ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']);
|
||||
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'Owner ACL Check', ['user:lookup'], null);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']);
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('key cannot access endpoints without required ACLs', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view', 'user:lookup']);
|
||||
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'Limited Key', ['audit_log:view'], null);
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('key with wildcard can access all endpoints', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['*']);
|
||||
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'Wildcard Key', ['*'], null);
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('multiple keys with different ACLs work independently', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const key1 = await createAdminApiKey(harness, admin, 'Audit Log Key', ['audit_log:view'], null);
|
||||
const key2 = await createAdminApiKey(harness, admin, 'Users Key', ['user:lookup'], null);
|
||||
const key3 = await createAdminApiKey(harness, admin, 'Guilds Key', ['guild:lookup'], null);
|
||||
|
||||
await createBuilder(harness, key1.token)
|
||||
.post('/admin/audit-logs')
|
||||
.body({
|
||||
limit: 10,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, key2.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const guildJson = await createBuilder<{guild: unknown}>(harness, key3.token)
|
||||
.post('/admin/guilds/lookup')
|
||||
.body({
|
||||
guild_id: '123',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(guildJson.guild).toBeNull();
|
||||
|
||||
await createBuilder(harness, key1.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('GET /admin/api-keys requires admin_api_key:manage', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']);
|
||||
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'List Test', ['admin_api_key:manage'], null);
|
||||
|
||||
await createBuilder(harness, apiKey.token).get('/admin/api-keys').expect(HTTP_STATUS.OK).execute();
|
||||
});
|
||||
|
||||
test('GET /admin/api-keys fails without admin_api_key:manage', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']);
|
||||
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'List Test No ACL', [], null);
|
||||
|
||||
await createBuilder(harness, apiKey.token).get('/admin/api-keys').expect(HTTP_STATUS.FORBIDDEN).execute();
|
||||
});
|
||||
|
||||
test('DELETE /admin/api-keys/:keyId requires admin_api_key:manage', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']);
|
||||
|
||||
const keyToDelete = await createAdminApiKey(harness, admin, 'To Delete', [], null);
|
||||
const deleterKey = await createAdminApiKey(harness, admin, 'Deleter', ['admin_api_key:manage'], null);
|
||||
|
||||
await createBuilder(harness, deleterKey.token)
|
||||
.delete(`/admin/api-keys/${keyToDelete.keyId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('DELETE /admin/api-keys/:keyId fails without admin_api_key:manage', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']);
|
||||
|
||||
const keyToDelete = await createAdminApiKey(harness, admin, 'To Delete 2', [], null);
|
||||
const deleterKey = await createAdminApiKey(harness, admin, 'Deleter No ACL', [], null);
|
||||
|
||||
await createBuilder(harness, deleterKey.token)
|
||||
.delete(`/admin/api-keys/${keyToDelete.keyId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
368
packages/api/src/admin/tests/AdminApiKeyLifecycle.test.tsx
Normal file
368
packages/api/src/admin/tests/AdminApiKeyLifecycle.test.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
createAdminApiKey,
|
||||
createAdminApiKeyWithDefaultACLs,
|
||||
listAdminApiKeys,
|
||||
revokeAdminApiKey,
|
||||
} from '@fluxer/api/src/admin/tests/AdminTestUtils';
|
||||
import {createTestAccount, setUserACLs} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface ApiKeyResponse {
|
||||
key_id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
acls: Array<string>;
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
describe('Admin API Key Lifecycle', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('create basic API key', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const data = await createBuilder<ApiKeyResponse>(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test API Key',
|
||||
acls: ['audit_log:view'],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(data.key_id).toBeTruthy();
|
||||
expect(data.key).toBeTruthy();
|
||||
expect(data.key).toMatch(/^fa_/);
|
||||
expect(data.name).toBe('Test API Key');
|
||||
expect(data.acls).toEqual(['audit_log:view']);
|
||||
expect(data.created_at).toBeTruthy();
|
||||
expect(data.expires_at).toBeNull();
|
||||
});
|
||||
|
||||
test('create API key with expiration', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
const data = await createBuilder<ApiKeyResponse>(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Expiring API Key',
|
||||
expires_in_days: 30,
|
||||
acls: ['audit_log:view'],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(data.expires_at).not.toBeNull();
|
||||
expect(data.expires_at).toBeTruthy();
|
||||
});
|
||||
|
||||
test('create API key with multiple ACLs', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const data = await createBuilder<ApiKeyResponse>(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Multi-ACL API Key',
|
||||
acls: ['audit_log:view', 'user:lookup', 'guild:lookup'],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(data.acls).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('name validation - empty name rejected', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: '',
|
||||
acls: ['audit_log:view'],
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.executeWithResponse();
|
||||
});
|
||||
|
||||
test('name validation - spaces only rejected', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: ' ',
|
||||
acls: ['audit_log:view'],
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.executeWithResponse();
|
||||
});
|
||||
|
||||
test('name validation - valid name accepted', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'My API Key',
|
||||
acls: ['audit_log:view'],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
});
|
||||
|
||||
test('name validation - special characters accepted', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Key-123_Test!@#',
|
||||
acls: ['audit_log:view'],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
});
|
||||
|
||||
test('expiration validation - zero days rejected', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
expires_in_days: 0,
|
||||
acls: ['audit_log:view'],
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.executeWithResponse();
|
||||
});
|
||||
|
||||
test('expiration validation - negative days rejected', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
expires_in_days: -1,
|
||||
acls: ['audit_log:view'],
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.executeWithResponse();
|
||||
});
|
||||
|
||||
test('expiration validation - too many days rejected', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
expires_in_days: 366,
|
||||
acls: ['audit_log:view'],
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.executeWithResponse();
|
||||
});
|
||||
|
||||
test('expiration validation - valid minimum accepted', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
expires_in_days: 1,
|
||||
acls: ['audit_log:view'],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
});
|
||||
|
||||
test('expiration validation - valid maximum accepted', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
expires_in_days: 365,
|
||||
acls: ['audit_log:view'],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
});
|
||||
|
||||
test('list empty API keys', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
const keys = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keys).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('list multiple API keys', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
await createAdminApiKeyWithDefaultACLs(harness, admin, 'First Key');
|
||||
await createAdminApiKey(harness, admin, 'Second Key', ['user:lookup'], null);
|
||||
|
||||
const keys = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keys).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('list does not include secret key', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
await createAdminApiKeyWithDefaultACLs(harness, admin, 'Test Key');
|
||||
|
||||
const keys = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keys).toHaveLength(1);
|
||||
expect('key' in keys[0]!).toBe(false);
|
||||
});
|
||||
|
||||
test('revoke API key', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Key to Revoke');
|
||||
|
||||
let keys = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keys).toHaveLength(1);
|
||||
|
||||
await revokeAdminApiKey(harness, admin.token, apiKey.keyId);
|
||||
|
||||
keys = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keys).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('revoke non-existent key', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.delete('/admin/api-keys/nonexistent-key-id')
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.executeWithResponse();
|
||||
});
|
||||
|
||||
test('revoked key cannot be used', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']);
|
||||
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'Key to Test', ['user:lookup'], null);
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
await revokeAdminApiKey(harness, admin.token, apiKey.keyId);
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.executeWithResponse();
|
||||
});
|
||||
|
||||
test('only keys created by user are listed', async () => {
|
||||
const admin1 = await createTestAccount(harness);
|
||||
const admin2 = await createTestAccount(harness);
|
||||
|
||||
await setUserACLs(harness, admin1, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
await setUserACLs(harness, admin2, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
await createAdminApiKey(harness, admin1, 'Admin 1 Key', ['audit_log:view'], null);
|
||||
await createAdminApiKey(harness, admin2, 'Admin 2 Key', ['audit_log:view'], null);
|
||||
|
||||
const keys1 = await listAdminApiKeys(harness, admin1.token);
|
||||
expect(keys1).toHaveLength(1);
|
||||
expect(keys1[0]!.name).toBe('Admin 1 Key');
|
||||
|
||||
const keys2 = await listAdminApiKeys(harness, admin2.token);
|
||||
expect(keys2).toHaveLength(1);
|
||||
expect(keys2[0]!.name).toBe('Admin 2 Key');
|
||||
});
|
||||
});
|
||||
715
packages/api/src/admin/tests/AdminApiKeyManagement.test.tsx
Normal file
715
packages/api/src/admin/tests/AdminApiKeyManagement.test.tsx
Normal file
@@ -0,0 +1,715 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
createAdminApiKey,
|
||||
createAdminApiKeyWithDefaultACLs,
|
||||
listAdminApiKeys,
|
||||
revokeAdminApiKey,
|
||||
} from '@fluxer/api/src/admin/tests/AdminTestUtils';
|
||||
import {createTestAccount, setUserACLs} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Admin API Key Management', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('API Key ACL Validation', () => {
|
||||
test('user can only grant ACLs they possess', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
acls: ['audit_log:view'],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('user cannot grant ACLs they do not possess', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
acls: ['audit_log:view', 'user:lookup'],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('user with wildcard ACL can grant any ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['*']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Wildcard Test Key',
|
||||
acls: ['audit_log:view', 'user:lookup', 'guild:lookup', 'archive:trigger:user'],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('must have admin_api_key:manage ACL to create keys', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
acls: ['audit_log:view'],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('ACLs are stored and retrievable correctly', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const requestedACLs = ['audit_log:view', 'user:lookup', 'guild:lookup'];
|
||||
await createAdminApiKey(harness, admin, 'ACL Storage Test', requestedACLs, null);
|
||||
|
||||
const keys = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keys).toHaveLength(1);
|
||||
|
||||
const keyACLs = keys[0]!.acls as Array<string>;
|
||||
expect(keyACLs).toHaveLength(requestedACLs.length);
|
||||
expect(keyACLs).toEqual(expect.arrayContaining(requestedACLs));
|
||||
});
|
||||
|
||||
test('empty ACL list is valid', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
acls: [],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Key Authentication', () => {
|
||||
test('valid API key authenticates successfully', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Auth Test Key');
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('invalid API key is rejected', async () => {
|
||||
await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, 'Admin invalid_key_12345')
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: ['123456789'],
|
||||
})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('API key requires Admin prefix', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Prefix Test Key');
|
||||
|
||||
await createBuilder(harness, `Bearer ${apiKey.key}`)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('Admin prefix is case sensitive', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Case Test Key');
|
||||
|
||||
await createBuilder(harness, `admin ${apiKey.key}`)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('API key cannot authenticate to user endpoints', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'User Endpoint Test Key');
|
||||
|
||||
await createBuilder(harness, apiKey.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
});
|
||||
|
||||
test('updates last_used_at timestamp on use', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Last Used Test Key');
|
||||
|
||||
const keysBefore = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keysBefore).toHaveLength(1);
|
||||
expect(keysBefore[0]!.last_used_at).toBeNull();
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.execute();
|
||||
|
||||
const keysAfter = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keysAfter).toHaveLength(1);
|
||||
expect(keysAfter[0]!.last_used_at).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Key Authorization (ACL Restrictions)', () => {
|
||||
test('key can access endpoints with granted ACLs', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'Test Key', ['audit_log:view', 'user:lookup'], null);
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('key cannot access endpoints without required ACLs', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'Limited Key', ['audit_log:view'], null);
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('key with wildcard ACL can access all endpoints', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['*']);
|
||||
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'Wildcard Key', ['*'], null);
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('multiple keys with different ACLs work independently', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const auditKey = await createAdminApiKey(harness, admin, 'Audit Log Key', ['audit_log:view'], null);
|
||||
const userKey = await createAdminApiKey(harness, admin, 'Users Key', ['user:lookup'], null);
|
||||
|
||||
await createBuilder(harness, userKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, auditKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('list API keys requires admin_api_key:manage ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']);
|
||||
|
||||
const apiKeyWithACL = await createAdminApiKey(harness, admin, 'List Test', ['admin_api_key:manage'], null);
|
||||
|
||||
await createBuilder(harness, apiKeyWithACL.token).get('/admin/api-keys').expect(HTTP_STATUS.OK).execute();
|
||||
|
||||
const apiKeyWithoutACL = await createAdminApiKey(harness, admin, 'List Test No ACL', [], null);
|
||||
|
||||
await createBuilder(harness, apiKeyWithoutACL.token)
|
||||
.get('/admin/api-keys')
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('delete API key requires admin_api_key:manage ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']);
|
||||
|
||||
const keyToDelete = await createAdminApiKey(harness, admin, 'To Delete', [], null);
|
||||
const deleterKey = await createAdminApiKey(harness, admin, 'Deleter', ['admin_api_key:manage'], null);
|
||||
|
||||
await createBuilder(harness, deleterKey.token)
|
||||
.delete(`/admin/api-keys/${keyToDelete.keyId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('delete API key fails without admin_api_key:manage ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']);
|
||||
|
||||
const keyToDelete = await createAdminApiKey(harness, admin, 'To Delete 2', [], null);
|
||||
const deleterKey = await createAdminApiKey(harness, admin, 'Deleter No ACL', [], null);
|
||||
|
||||
await createBuilder(harness, deleterKey.token)
|
||||
.delete(`/admin/api-keys/${keyToDelete.keyId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Key Revocation', () => {
|
||||
test('basic revocation removes key from list', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Revoke Test');
|
||||
|
||||
let keys = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keys).toHaveLength(1);
|
||||
expect(keys.some((k) => k.key_id === apiKey.keyId)).toBe(true);
|
||||
|
||||
await revokeAdminApiKey(harness, admin.token, apiKey.keyId);
|
||||
|
||||
keys = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keys).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('revoked key cannot be used for authentication', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Revoke Auth Test');
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await revokeAdminApiKey(harness, admin.token, apiKey.keyId);
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('revocation of non-existent key returns 404', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.delete('/admin/api-keys/nonexistent-id')
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('revocation requires admin_api_key:manage ACL', async () => {
|
||||
const admin1 = await createTestAccount(harness);
|
||||
const admin2 = await createTestAccount(harness);
|
||||
|
||||
await setUserACLs(harness, admin1, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
await setUserACLs(harness, admin2, ['admin:authenticate']);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin1, 'Admin1 Key');
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin2.token}`)
|
||||
.delete(`/admin/api-keys/${apiKey.keyId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
|
||||
const keys = await listAdminApiKeys(harness, admin1.token);
|
||||
expect(keys.some((k) => k.key_id === apiKey.keyId)).toBe(true);
|
||||
});
|
||||
|
||||
test('cannot revoke other users keys', async () => {
|
||||
const admin1 = await createTestAccount(harness);
|
||||
const admin2 = await createTestAccount(harness);
|
||||
|
||||
await setUserACLs(harness, admin1, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
await setUserACLs(harness, admin2, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin1, 'Admin1 Key');
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin2.token}`)
|
||||
.delete(`/admin/api-keys/${apiKey.keyId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
|
||||
const keys = await listAdminApiKeys(harness, admin1.token);
|
||||
expect(keys.some((k) => k.key_id === apiKey.keyId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Setting User ACLs Requires Proper ACL', () => {
|
||||
test('setting user ACLs requires acl:set:user ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'acl:set:user']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/set-acls')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
acls: ['admin:authenticate'],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('setting user ACLs fails without acl:set:user ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/set-acls')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
acls: ['admin:authenticate'],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('API key can set user ACLs with acl:set:user', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'acl:set:user']);
|
||||
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'ACL Setter Key', ['acl:set:user'], null);
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/set-acls')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
acls: ['admin:authenticate'],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('API key cannot set user ACLs without acl:set:user', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']);
|
||||
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'No ACL Setter Key', ['user:lookup'], null);
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/set-acls')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
acls: ['admin:authenticate'],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('setting ACLs on non-existent user fails', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'acl:set:user']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/set-acls')
|
||||
.body({
|
||||
user_id: '999999999999999999',
|
||||
acls: ['admin:authenticate'],
|
||||
})
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deletion Schedule Minimum Validation', () => {
|
||||
test('schedule deletion requires user:delete ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/schedule-deletion')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
reason_code: 1,
|
||||
days_until_deletion: 60,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('schedule deletion fails without user:delete ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/schedule-deletion')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
reason_code: 1,
|
||||
days_until_deletion: 60,
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('deletion schedule enforces minimum days for user requested deletion', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/schedule-deletion')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
reason_code: 0,
|
||||
days_until_deletion: 1,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
});
|
||||
|
||||
test('deletion schedule enforces minimum days for standard deletion', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/schedule-deletion')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
reason_code: 1,
|
||||
days_until_deletion: 1,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
});
|
||||
|
||||
test('API key can schedule deletion with user:delete ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:delete']);
|
||||
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'Deletion Key', ['user:delete'], null);
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/schedule-deletion')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
reason_code: 1,
|
||||
days_until_deletion: 60,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('API key cannot schedule deletion without user:delete ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']);
|
||||
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'No Deletion Key', ['user:lookup'], null);
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/schedule-deletion')
|
||||
.body({
|
||||
user_id: targetUser.userId,
|
||||
reason_code: 1,
|
||||
days_until_deletion: 60,
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('schedule deletion on non-existent user fails', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/schedule-deletion')
|
||||
.body({
|
||||
user_id: '999999999999999999',
|
||||
reason_code: 1,
|
||||
days_until_deletion: 60,
|
||||
})
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
148
packages/api/src/admin/tests/AdminApiKeyRevocation.test.tsx
Normal file
148
packages/api/src/admin/tests/AdminApiKeyRevocation.test.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createAdminApiKeyWithDefaultACLs, listAdminApiKeys} from '@fluxer/api/src/admin/tests/AdminTestUtils';
|
||||
import {createTestAccount, setUserACLs} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
async function hasAdminAPIKeyId(harness: ApiTestHarness, token: string, keyId: string): Promise<boolean> {
|
||||
const keys = await listAdminApiKeys(harness, token);
|
||||
return keys.some((k) => k.key_id === keyId);
|
||||
}
|
||||
|
||||
describe('Admin API Key Revocation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
test('basic revocation', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Revoke Test');
|
||||
|
||||
expect(await hasAdminAPIKeyId(harness, admin.token, apiKey.keyId)).toBe(true);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.delete(`/admin/api-keys/${apiKey.keyId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(await hasAdminAPIKeyId(harness, admin.token, apiKey.keyId)).toBe(false);
|
||||
});
|
||||
|
||||
test('revocation by ID', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'ID Test');
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.delete(`/admin/api-keys/${apiKey.keyId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const keys = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keys).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('revocation of non-existent key returns 404', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.delete('/admin/api-keys/nonexistent-id')
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('revocation requires admin_api_key:manage ACL', async () => {
|
||||
const admin1 = await createTestAccount(harness);
|
||||
const admin2 = await createTestAccount(harness);
|
||||
|
||||
await setUserACLs(harness, admin1, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
await setUserACLs(harness, admin2, ['admin:authenticate']);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin1, 'Admin1 Key');
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin2.token}`)
|
||||
.delete(`/admin/api-keys/${apiKey.keyId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
|
||||
expect(await hasAdminAPIKeyId(harness, admin1.token, apiKey.keyId)).toBe(true);
|
||||
});
|
||||
|
||||
test('cannot revoke other users keys', async () => {
|
||||
const admin1 = await createTestAccount(harness);
|
||||
const admin2 = await createTestAccount(harness);
|
||||
|
||||
await setUserACLs(harness, admin1, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
await setUserACLs(harness, admin2, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin1, 'Admin1 Key');
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin2.token}`)
|
||||
.delete(`/admin/api-keys/${apiKey.keyId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
|
||||
expect(await hasAdminAPIKeyId(harness, admin1.token, apiKey.keyId)).toBe(true);
|
||||
});
|
||||
});
|
||||
110
packages/api/src/admin/tests/AdminArchivesList.test.tsx
Normal file
110
packages/api/src/admin/tests/AdminArchivesList.test.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createTestGuild} from '@fluxer/api/src/emoji/tests/EmojiTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface ArchiveResponse {
|
||||
archive_id: string;
|
||||
subject_id: string;
|
||||
subject_type: string;
|
||||
requested_by: string;
|
||||
}
|
||||
|
||||
interface ListArchivesResponse {
|
||||
archives: Array<ArchiveResponse>;
|
||||
}
|
||||
|
||||
async function setAdminArchiveAcls(harness: ApiTestHarness, admin: TestAccount): Promise<TestAccount> {
|
||||
return await setUserACLs(harness, admin, ['admin:authenticate', 'archive:trigger:guild']);
|
||||
}
|
||||
|
||||
async function triggerGuildArchive(
|
||||
harness: ApiTestHarness,
|
||||
adminToken: string,
|
||||
guildId: string,
|
||||
): Promise<ArchiveResponse> {
|
||||
return await createBuilder<ArchiveResponse>(harness, `Bearer ${adminToken}`)
|
||||
.post('/admin/archives/guild')
|
||||
.body({guild_id: guildId})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function listArchivesByRequester(
|
||||
harness: ApiTestHarness,
|
||||
adminToken: string,
|
||||
requestedBy: string,
|
||||
): Promise<ListArchivesResponse> {
|
||||
return await createBuilder<ListArchivesResponse>(harness, `Bearer ${adminToken}`)
|
||||
.post('/admin/archives/list')
|
||||
.body({
|
||||
subject_type: 'guild',
|
||||
requested_by: requestedBy,
|
||||
include_expired: false,
|
||||
limit: 50,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Admin archives list', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
test('lists archives requested by the admin', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const updatedAdmin = await setAdminArchiveAcls(harness, admin);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createTestGuild(harness, owner.token);
|
||||
|
||||
const archive = await triggerGuildArchive(harness, updatedAdmin.token, guild.id);
|
||||
const result = await listArchivesByRequester(harness, updatedAdmin.token, updatedAdmin.userId);
|
||||
|
||||
expect(result.archives.some((entry) => entry.archive_id === archive.archive_id)).toBe(true);
|
||||
expect(result.archives.some((entry) => entry.subject_id === guild.id)).toBe(true);
|
||||
});
|
||||
|
||||
test('requested_by filter isolates archive results', async () => {
|
||||
const adminOne = await createTestAccount(harness);
|
||||
const adminTwo = await createTestAccount(harness);
|
||||
const updatedAdminOne = await setAdminArchiveAcls(harness, adminOne);
|
||||
const updatedAdminTwo = await setAdminArchiveAcls(harness, adminTwo);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createTestGuild(harness, owner.token);
|
||||
|
||||
const archiveOne = await triggerGuildArchive(harness, updatedAdminOne.token, guild.id);
|
||||
const archiveTwo = await triggerGuildArchive(harness, updatedAdminTwo.token, guild.id);
|
||||
|
||||
const resultOne = await listArchivesByRequester(harness, updatedAdminOne.token, updatedAdminOne.userId);
|
||||
const resultTwo = await listArchivesByRequester(harness, updatedAdminTwo.token, updatedAdminTwo.userId);
|
||||
|
||||
expect(resultOne.archives.some((entry) => entry.archive_id === archiveOne.archive_id)).toBe(true);
|
||||
expect(resultOne.archives.some((entry) => entry.archive_id === archiveTwo.archive_id)).toBe(false);
|
||||
expect(resultTwo.archives.some((entry) => entry.archive_id === archiveTwo.archive_id)).toBe(true);
|
||||
expect(resultTwo.archives.some((entry) => entry.archive_id === archiveOne.archive_id)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, setUserACLs} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {beforeEach, describe, test} from 'vitest';
|
||||
|
||||
const adminEndpoints = [
|
||||
{method: 'POST', path: '/admin/reports/list', requiredACL: 'report:manage'},
|
||||
{method: 'GET', path: '/admin/reports/1', requiredACL: 'report:manage'},
|
||||
{method: 'POST', path: '/admin/reports/resolve', requiredACL: 'report:manage'},
|
||||
{method: 'POST', path: '/admin/bulk/update-user-flags', requiredACL: 'bulk:update'},
|
||||
{method: 'POST', path: '/admin/bulk/update-guild-features', requiredACL: 'bulk:update'},
|
||||
{method: 'POST', path: '/admin/bulk/add-guild-members', requiredACL: 'bulk:update'},
|
||||
{method: 'POST', path: '/admin/guilds/search', requiredACL: 'guild:lookup'},
|
||||
{method: 'POST', path: '/admin/users/search', requiredACL: 'user:lookup'},
|
||||
{method: 'POST', path: '/admin/messages/lookup', requiredACL: 'message:lookup'},
|
||||
{method: 'POST', path: '/admin/messages/delete', requiredACL: 'message:delete'},
|
||||
{method: 'POST', path: '/admin/gateway/memory-stats', requiredACL: 'gateway:manage'},
|
||||
{method: 'POST', path: '/admin/gateway/reload-all', requiredACL: 'gateway:manage'},
|
||||
{method: 'GET', path: '/admin/gateway/stats', requiredACL: 'gateway:manage'},
|
||||
{method: 'POST', path: '/admin/audit-logs', requiredACL: 'audit_log:view'},
|
||||
{method: 'POST', path: '/admin/audit-logs/search', requiredACL: 'audit_log:view'},
|
||||
{method: 'POST', path: '/admin/guilds/lookup', requiredACL: 'guild:lookup'},
|
||||
{method: 'POST', path: '/admin/guilds/list-members', requiredACL: 'guild:lookup'},
|
||||
{method: 'POST', path: '/admin/guilds/update-features', requiredACL: 'guild:update'},
|
||||
{method: 'POST', path: '/admin/guilds/update-name', requiredACL: 'guild:update'},
|
||||
{method: 'POST', path: '/admin/guilds/update-settings', requiredACL: 'guild:update'},
|
||||
{method: 'POST', path: '/admin/guilds/transfer-ownership', requiredACL: 'guild:update'},
|
||||
{method: 'POST', path: '/admin/guilds/update-vanity', requiredACL: 'guild:update'},
|
||||
{method: 'POST', path: '/admin/guilds/force-add-user', requiredACL: 'guild:update'},
|
||||
{method: 'POST', path: '/admin/guilds/reload', requiredACL: 'guild:update'},
|
||||
{method: 'POST', path: '/admin/guilds/shutdown', requiredACL: 'guild:update'},
|
||||
{method: 'POST', path: '/admin/users/lookup', requiredACL: 'user:lookup'},
|
||||
{method: 'POST', path: '/admin/users/list-guilds', requiredACL: 'user:lookup'},
|
||||
{method: 'POST', path: '/admin/users/list-dm-channels', requiredACL: 'user:list:dm_channels'},
|
||||
{method: 'POST', path: '/admin/users/disable-mfa', requiredACL: 'user:update'},
|
||||
{method: 'POST', path: '/admin/users/clear-fields', requiredACL: 'user:update'},
|
||||
{method: 'POST', path: '/admin/users/set-bot-status', requiredACL: 'user:update'},
|
||||
{method: 'POST', path: '/admin/users/set-acls', requiredACL: 'acl:set:user'},
|
||||
{method: 'POST', path: '/admin/users/schedule-deletion', requiredACL: 'user:delete'},
|
||||
];
|
||||
|
||||
describe('Admin Endpoints Authorization', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness({search: 'meilisearch'});
|
||||
});
|
||||
|
||||
test('admin endpoints require authentication', async () => {
|
||||
for (const endpoint of adminEndpoints.slice(0, 5)) {
|
||||
if (endpoint.method === 'POST') {
|
||||
await createBuilderWithoutAuth(harness).post(endpoint.path).expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
} else {
|
||||
await createBuilderWithoutAuth(harness).get(endpoint.path).expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('admin endpoints require proper ACLs', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate']);
|
||||
|
||||
for (const endpoint of adminEndpoints.slice(0, 10)) {
|
||||
if (endpoint.method === 'POST') {
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(endpoint.path)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
} else {
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.get(endpoint.path)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('admin endpoints succeed with proper ACLs', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup', 'guild:lookup']);
|
||||
|
||||
const endpointsToTest = [
|
||||
{path: '/admin/users/lookup', body: {user_ids: ['123']}},
|
||||
{path: '/admin/guilds/lookup', body: {guild_id: '123'}},
|
||||
];
|
||||
|
||||
for (const endpoint of endpointsToTest) {
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(endpoint.path)
|
||||
.body(endpoint.body)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
|
||||
test('user lookup endpoint requires user:lookup ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('guild lookup endpoint requires guild:lookup ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/guilds/lookup')
|
||||
.body({
|
||||
guild_ids: ['123456789'],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('audit logs endpoint requires audit_log:view ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/audit-logs')
|
||||
.body({
|
||||
limit: 10,
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createAdminApiKey} from '@fluxer/api/src/admin/tests/AdminTestUtils';
|
||||
import {createTestAccount, setUserACLs} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {beforeEach, describe, test} from 'vitest';
|
||||
|
||||
interface OAuth2TokenResponse {
|
||||
token: string;
|
||||
user_id: string;
|
||||
scopes: Array<string>;
|
||||
application_id: string;
|
||||
}
|
||||
|
||||
async function createOAuth2Token(
|
||||
harness: ApiTestHarness,
|
||||
userId: string,
|
||||
scopes: Array<string>,
|
||||
): Promise<OAuth2TokenResponse> {
|
||||
return createBuilder<OAuth2TokenResponse>(harness, '')
|
||||
.post('/test/oauth2/access-token')
|
||||
.body({
|
||||
user_id: userId,
|
||||
scopes,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Admin OAuth2 Scope Requirement', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
describe('OAuth2 tokens WITHOUT admin scope', () => {
|
||||
test('OAuth2 token with only identify+email scopes CANNOT access admin endpoints even if user has admin ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']);
|
||||
|
||||
const oauth2Token = await createOAuth2Token(harness, admin.userId, ['identify', 'email']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_ADMIN_SCOPE')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('OAuth2 token with identify+email+guilds scopes CANNOT access admin endpoints', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup', 'guild:lookup']);
|
||||
|
||||
const oauth2Token = await createOAuth2Token(harness, admin.userId, ['identify', 'email', 'guilds']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
|
||||
.post('/admin/guilds/lookup')
|
||||
.body({
|
||||
guild_id: '123',
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_ADMIN_SCOPE')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('OAuth2 token without admin scope CANNOT access audit logs endpoint', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'audit_log:view']);
|
||||
|
||||
const oauth2Token = await createOAuth2Token(harness, admin.userId, ['identify', 'email']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
|
||||
.post('/admin/audit-logs')
|
||||
.body({
|
||||
limit: 10,
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_ADMIN_SCOPE')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('OAuth2 token without admin scope CANNOT access admin API keys endpoint', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']);
|
||||
|
||||
const oauth2Token = await createOAuth2Token(harness, admin.userId, ['identify', 'email']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
|
||||
.get('/admin/api-keys')
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_ADMIN_SCOPE')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('OAuth2 token with wildcard user ACL but no admin scope CANNOT access admin endpoints', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['*']);
|
||||
|
||||
const oauth2Token = await createOAuth2Token(harness, admin.userId, ['identify', 'email']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_ADMIN_SCOPE')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth2 tokens WITH admin scope', () => {
|
||||
test('OAuth2 token with admin scope CAN access admin endpoints when user has proper ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']);
|
||||
|
||||
const oauth2Token = await createOAuth2Token(harness, admin.userId, ['identify', 'email', 'admin']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('OAuth2 token with admin scope CAN access audit logs when user has proper ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'audit_log:view']);
|
||||
|
||||
const oauth2Token = await createOAuth2Token(harness, admin.userId, ['identify', 'email', 'admin']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
|
||||
.post('/admin/audit-logs')
|
||||
.body({
|
||||
limit: 10,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('OAuth2 token with admin scope CAN access admin API keys list when user has proper ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']);
|
||||
|
||||
const oauth2Token = await createOAuth2Token(harness, admin.userId, ['identify', 'email', 'admin']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
|
||||
.get('/admin/api-keys')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('OAuth2 token with admin scope still requires proper user ACLs', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate']);
|
||||
|
||||
const oauth2Token = await createOAuth2Token(harness, admin.userId, ['identify', 'email', 'admin']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('OAuth2 token with admin scope without admin:authenticate ACL cannot access admin endpoints', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
const oauth2Token = await createOAuth2Token(harness, user.userId, ['identify', 'email', 'admin']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [user.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session tokens (no scope check needed)', () => {
|
||||
test('session token can access admin endpoints with proper ACLs', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('session token can access audit logs with proper ACLs', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'audit_log:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/audit-logs')
|
||||
.body({
|
||||
limit: 10,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('session token can access admin API keys with proper ACLs', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`).get('/admin/api-keys').expect(HTTP_STATUS.OK).execute();
|
||||
});
|
||||
|
||||
test('session token without admin:authenticate ACL cannot access admin endpoints', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, `Bearer ${user.token}`)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [user.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin API keys (no scope check needed)', () => {
|
||||
test('admin API key can access endpoints with granted ACLs', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']);
|
||||
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'Test Key', ['user:lookup'], null);
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('admin API key can access audit logs with granted ACLs', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'Audit Key', ['audit_log:view'], null);
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/audit-logs')
|
||||
.body({
|
||||
limit: 10,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('admin API key without required ACL cannot access endpoint', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'Limited Key', ['audit_log:view'], null);
|
||||
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
test('OAuth2 token with only admin scope (no other scopes) CAN access admin endpoints', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']);
|
||||
|
||||
const oauth2Token = await createOAuth2Token(harness, admin.userId, ['admin']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('multiple admin endpoints require admin scope consistently', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup', 'guild:lookup', 'audit_log:view']);
|
||||
|
||||
const tokenWithoutAdminScope = await createOAuth2Token(harness, admin.userId, ['identify', 'email']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${tokenWithoutAdminScope.token}`)
|
||||
.post('/admin/users/lookup')
|
||||
.body({user_ids: [admin.userId]})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_ADMIN_SCOPE')
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, `Bearer ${tokenWithoutAdminScope.token}`)
|
||||
.post('/admin/guilds/lookup')
|
||||
.body({guild_id: '123'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_ADMIN_SCOPE')
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, `Bearer ${tokenWithoutAdminScope.token}`)
|
||||
.post('/admin/audit-logs')
|
||||
.body({limit: 10})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_ADMIN_SCOPE')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('OAuth2 token with admin scope allows access to multiple admin endpoints', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup', 'guild:lookup', 'audit_log:view']);
|
||||
|
||||
const tokenWithAdminScope = await createOAuth2Token(harness, admin.userId, ['identify', 'email', 'admin']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${tokenWithAdminScope.token}`)
|
||||
.post('/admin/users/lookup')
|
||||
.body({user_ids: [admin.userId]})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, `Bearer ${tokenWithAdminScope.token}`)
|
||||
.post('/admin/guilds/lookup')
|
||||
.body({guild_id: '123'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, `Bearer ${tokenWithAdminScope.token}`)
|
||||
.post('/admin/audit-logs')
|
||||
.body({limit: 10})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
437
packages/api/src/admin/tests/AdminSearchEndpoints.test.tsx
Normal file
437
packages/api/src/admin/tests/AdminSearchEndpoints.test.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, setUserACLs} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createDmChannel, createFriendship, createGuild} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Admin Search Endpoints', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness({search: 'meilisearch'});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
describe('/admin/users/search', () => {
|
||||
test('requires user:lookup ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/search')
|
||||
.body({query: 'test', limit: 10, offset: 0})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('returns empty results for non-matching query', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']);
|
||||
|
||||
const result = await createBuilder<{users: Array<unknown>; total: number}>(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/search')
|
||||
.body({query: 'nonexistent-user-query-xyz', limit: 10, offset: 0})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.users).toEqual([]);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
|
||||
test('returns matching users when query matches username', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']);
|
||||
|
||||
const targetUser = await createTestAccount(harness, {
|
||||
username: `searchable_user_${Date.now()}`,
|
||||
});
|
||||
|
||||
const result = await createBuilder<{users: Array<{id: string; username: string}>; total: number}>(
|
||||
harness,
|
||||
`Bearer ${admin.token}`,
|
||||
)
|
||||
.post('/admin/users/search')
|
||||
.body({query: targetUser.username, limit: 10, offset: 0})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.total).toBeGreaterThanOrEqual(1);
|
||||
const foundUser = result.users.find((u) => u.id === targetUser.userId);
|
||||
expect(foundUser).toBeDefined();
|
||||
expect(foundUser?.username).toBe(targetUser.username);
|
||||
});
|
||||
|
||||
test('respects limit and offset parameters', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']);
|
||||
|
||||
const result = await createBuilder<{users: Array<unknown>; total: number}>(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/search')
|
||||
.body({limit: 1, offset: 0})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.users.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/users/list-dm-channels', () => {
|
||||
test('requires user:list:dm_channels ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/list-dm-channels')
|
||||
.body({user_id: admin.userId, limit: 10})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('returns paginated historical DM channels', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:list:dm_channels']);
|
||||
|
||||
const subjectUser = await createTestAccount(harness);
|
||||
const recipientA = await createTestAccount(harness);
|
||||
const recipientB = await createTestAccount(harness);
|
||||
const recipientC = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, subjectUser, recipientA);
|
||||
await createFriendship(harness, subjectUser, recipientB);
|
||||
await createFriendship(harness, subjectUser, recipientC);
|
||||
|
||||
const dmA = await createDmChannel(harness, subjectUser.token, recipientA.userId);
|
||||
const dmB = await createDmChannel(harness, subjectUser.token, recipientB.userId);
|
||||
const dmC = await createDmChannel(harness, subjectUser.token, recipientC.userId);
|
||||
|
||||
const firstPage = await createBuilder<{
|
||||
channels: Array<{
|
||||
channel_id: string;
|
||||
channel_type: number | null;
|
||||
recipient_ids: Array<string>;
|
||||
last_message_id: string | null;
|
||||
is_open: boolean;
|
||||
}>;
|
||||
}>(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/list-dm-channels')
|
||||
.body({user_id: subjectUser.userId, limit: 2})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(firstPage.channels).toHaveLength(2);
|
||||
expect(BigInt(firstPage.channels[0]!.channel_id)).toBeGreaterThan(BigInt(firstPage.channels[1]!.channel_id));
|
||||
for (const channel of firstPage.channels) {
|
||||
expect(channel.channel_type).toBe(1);
|
||||
expect(channel.recipient_ids).toContain(subjectUser.userId);
|
||||
expect(channel.is_open).toBe(true);
|
||||
}
|
||||
|
||||
const secondPage = await createBuilder<{
|
||||
channels: Array<{
|
||||
channel_id: string;
|
||||
channel_type: number | null;
|
||||
recipient_ids: Array<string>;
|
||||
last_message_id: string | null;
|
||||
is_open: boolean;
|
||||
}>;
|
||||
}>(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/list-dm-channels')
|
||||
.body({user_id: subjectUser.userId, limit: 2, before: firstPage.channels[1]!.channel_id})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(secondPage.channels).toHaveLength(1);
|
||||
expect(secondPage.channels[0]!.channel_id).not.toBe(firstPage.channels[0]!.channel_id);
|
||||
expect(secondPage.channels[0]!.channel_id).not.toBe(firstPage.channels[1]!.channel_id);
|
||||
|
||||
const previousPage = await createBuilder<{
|
||||
channels: Array<{
|
||||
channel_id: string;
|
||||
channel_type: number | null;
|
||||
recipient_ids: Array<string>;
|
||||
last_message_id: string | null;
|
||||
is_open: boolean;
|
||||
}>;
|
||||
}>(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/list-dm-channels')
|
||||
.body({user_id: subjectUser.userId, limit: 2, after: secondPage.channels[0]!.channel_id})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(previousPage.channels.map((channel) => channel.channel_id)).toEqual(
|
||||
firstPage.channels.map((channel) => channel.channel_id),
|
||||
);
|
||||
expect(new Set([dmA.id, dmB.id, dmC.id]).size).toBe(3);
|
||||
});
|
||||
|
||||
test('rejects requests that specify both before and after cursors', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:list:dm_channels']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/list-dm-channels')
|
||||
.body({user_id: admin.userId, limit: 10, before: '1', after: '2'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/guilds/search', () => {
|
||||
test('requires guild:lookup ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/guilds/search')
|
||||
.body({query: 'test', limit: 10, offset: 0})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('returns empty results for non-matching query', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'guild:lookup']);
|
||||
|
||||
const result = await createBuilder<{guilds: Array<unknown>; total: number}>(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/guilds/search')
|
||||
.body({query: 'nonexistent-guild-query-xyz', limit: 10, offset: 0})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.guilds).toEqual([]);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
|
||||
test('returns matching guilds when query matches guild name', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'guild:lookup']);
|
||||
|
||||
const guildName = `searchable-guild-${Date.now()}`;
|
||||
const guild = await createGuild(harness, admin.token, guildName);
|
||||
|
||||
const result = await createBuilder<{guilds: Array<{id: string; name: string}>; total: number}>(
|
||||
harness,
|
||||
`Bearer ${admin.token}`,
|
||||
)
|
||||
.post('/admin/guilds/search')
|
||||
.body({query: guildName, limit: 10, offset: 0})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.total).toBeGreaterThanOrEqual(1);
|
||||
const foundGuild = result.guilds.find((g) => g.id === guild.id);
|
||||
expect(foundGuild).toBeDefined();
|
||||
expect(foundGuild?.name).toBe(guildName);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/reports/search', () => {
|
||||
test('requires report:view ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/reports/search')
|
||||
.body({limit: 10, offset: 0})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('returns report list response', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'report:view']);
|
||||
|
||||
const result = await createBuilder<{reports: Array<unknown>; total: number}>(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/reports/search')
|
||||
.body({limit: 10, offset: 0})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(Array.isArray(result.reports)).toBe(true);
|
||||
expect(result.total).toBeGreaterThanOrEqual(result.reports.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/audit-logs/search', () => {
|
||||
test('requires audit_log:view ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/audit-logs/search')
|
||||
.body({limit: 10, offset: 0})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('returns results with proper structure', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'audit_log:view']);
|
||||
|
||||
const result = await createBuilder<{logs: Array<unknown>; total: number}>(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/audit-logs/search')
|
||||
.body({limit: 10, offset: 0})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result).toHaveProperty('logs');
|
||||
expect(result).toHaveProperty('total');
|
||||
expect(Array.isArray(result.logs)).toBe(true);
|
||||
expect(typeof result.total).toBe('number');
|
||||
});
|
||||
|
||||
test('supports filtering by admin_user_id', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'audit_log:view', 'user:update_acls', 'acl:set:user']);
|
||||
|
||||
const targetUser = await createTestAccount(harness);
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/set-acls')
|
||||
.body({user_id: targetUser.userId, acls: ['admin:authenticate']})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const result = await createBuilder<{
|
||||
logs: Array<{admin_user_id: string; action: string}>;
|
||||
total: number;
|
||||
}>(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/audit-logs/search')
|
||||
.body({admin_user_id: admin.userId, limit: 50, offset: 0})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.total).toBeGreaterThanOrEqual(1);
|
||||
for (const log of result.logs) {
|
||||
expect(log.admin_user_id).toBe(admin.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test('supports filtering by target_id', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'audit_log:view', 'user:update_acls', 'acl:set:user']);
|
||||
|
||||
const targetUser = await createTestAccount(harness);
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/set-acls')
|
||||
.body({user_id: targetUser.userId, acls: ['admin:authenticate']})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const result = await createBuilder<{
|
||||
logs: Array<{target_id: string; action: string}>;
|
||||
total: number;
|
||||
}>(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/audit-logs/search')
|
||||
.body({target_id: targetUser.userId, limit: 50, offset: 0})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.total).toBeGreaterThanOrEqual(1);
|
||||
for (const log of result.logs) {
|
||||
expect(log.target_id).toBe(targetUser.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test('supports full-text search by query', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'audit_log:view', 'user:update_acls', 'acl:set:user']);
|
||||
|
||||
const targetUser = await createTestAccount(harness);
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/set-acls')
|
||||
.body({user_id: targetUser.userId, acls: ['admin:authenticate']})
|
||||
.header('X-Audit-Log-Reason', 'unique-test-reason-xyz')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const result = await createBuilder<{
|
||||
logs: Array<{audit_log_reason: string | null}>;
|
||||
total: number;
|
||||
}>(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/audit-logs/search')
|
||||
.body({query: 'set_acls', limit: 50, offset: 0})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result).toHaveProperty('logs');
|
||||
expect(result).toHaveProperty('total');
|
||||
});
|
||||
|
||||
test('supports sort_by and sort_order parameters', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'audit_log:view']);
|
||||
|
||||
const resultDesc = await createBuilder<{logs: Array<{created_at: string}>; total: number}>(
|
||||
harness,
|
||||
`Bearer ${admin.token}`,
|
||||
)
|
||||
.post('/admin/audit-logs/search')
|
||||
.body({limit: 10, offset: 0, sort_by: 'createdAt', sort_order: 'desc'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const resultAsc = await createBuilder<{logs: Array<{created_at: string}>; total: number}>(
|
||||
harness,
|
||||
`Bearer ${admin.token}`,
|
||||
)
|
||||
.post('/admin/audit-logs/search')
|
||||
.body({limit: 10, offset: 0, sort_by: 'createdAt', sort_order: 'asc'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(resultDesc).toHaveProperty('logs');
|
||||
expect(resultAsc).toHaveProperty('logs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/audit-logs (list)', () => {
|
||||
test('requires audit_log:view ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/audit-logs')
|
||||
.body({limit: 10, offset: 0})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('returns results with proper structure', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'audit_log:view']);
|
||||
|
||||
const result = await createBuilder<{logs: Array<unknown>; total: number}>(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/audit-logs')
|
||||
.body({limit: 10, offset: 0})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result).toHaveProperty('logs');
|
||||
expect(result).toHaveProperty('total');
|
||||
expect(Array.isArray(result.logs)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
96
packages/api/src/admin/tests/AdminTestUtils.tsx
Normal file
96
packages/api/src/admin/tests/AdminTestUtils.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {TestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
|
||||
export interface AdminApiKey {
|
||||
keyId: string;
|
||||
key: string;
|
||||
name: string;
|
||||
acls: Array<string>;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export async function createAdminApiKey(
|
||||
harness: ApiTestHarness,
|
||||
account: TestAccount,
|
||||
name: string,
|
||||
acls: Array<string>,
|
||||
expiresInDays: number | null,
|
||||
): Promise<AdminApiKey> {
|
||||
const data = await createBuilder<{key_id: string; key: string; name: string; acls: Array<string>}>(
|
||||
harness,
|
||||
`Bearer ${account.token}`,
|
||||
)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name,
|
||||
acls,
|
||||
...(expiresInDays !== null ? {expires_in_days: expiresInDays} : {}),
|
||||
})
|
||||
.execute();
|
||||
|
||||
return {
|
||||
keyId: data.key_id,
|
||||
key: data.key,
|
||||
name: data.name,
|
||||
acls: data.acls,
|
||||
token: `Admin ${data.key}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAdminApiKeyWithDefaultACLs(
|
||||
harness: ApiTestHarness,
|
||||
account: TestAccount,
|
||||
name: string,
|
||||
): Promise<AdminApiKey> {
|
||||
return await createAdminApiKey(harness, account, name, ['audit_log:view', 'user:lookup', 'guild:lookup'], null);
|
||||
}
|
||||
|
||||
export async function listAdminApiKeys(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
return createBuilder<Array<Record<string, unknown>>>(harness, `Bearer ${token}`).get('/admin/api-keys').execute();
|
||||
}
|
||||
|
||||
export async function revokeAdminApiKey(harness: ApiTestHarness, token: string, keyId: string): Promise<void> {
|
||||
await createBuilder<void>(harness, `Bearer ${token}`)
|
||||
.delete(`/admin/api-keys/${keyId}`)
|
||||
.body(null)
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function setAdminUserAcls(
|
||||
harness: ApiTestHarness,
|
||||
adminToken: string,
|
||||
userId: string,
|
||||
acls: Array<string>,
|
||||
): Promise<void> {
|
||||
await createBuilder<void>(harness, `Bearer ${adminToken}`)
|
||||
.post('/admin/users/set-acls')
|
||||
.body({
|
||||
user_id: userId,
|
||||
acls,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
432
packages/api/src/admin/tests/DiscoveryAdminOperations.test.tsx
Normal file
432
packages/api/src/admin/tests/DiscoveryAdminOperations.test.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createGuild, getGuild} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS, TEST_IDS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {DiscoveryCategories} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {DiscoveryApplicationResponse} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> {
|
||||
await createBuilder(harness, '').post(`/test/guilds/${guildId}/member-count`).body({member_count: memberCount}).execute();
|
||||
}
|
||||
|
||||
async function createGuildWithApplication(
|
||||
harness: ApiTestHarness,
|
||||
name: string,
|
||||
description = 'Valid discovery description',
|
||||
categoryId = DiscoveryCategories.GAMING,
|
||||
): Promise<{owner: TestAccount; guild: GuildResponse; application: DiscoveryApplicationResponse}> {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, name);
|
||||
await setGuildMemberCount(harness, guild.id, 10);
|
||||
|
||||
const application = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description, category_id: categoryId})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
return {owner, guild, application};
|
||||
}
|
||||
|
||||
async function createAdminWithACLs(harness: ApiTestHarness, acls: Array<string>): Promise<TestAccount> {
|
||||
const admin = await createTestAccount(harness);
|
||||
return setUserACLs(harness, admin, ['admin:authenticate', ...acls]);
|
||||
}
|
||||
|
||||
describe('Discovery Admin Operations', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('approve', () => {
|
||||
test('should approve a pending application', async () => {
|
||||
const {guild} = await createGuildWithApplication(harness, 'Approve Test Guild');
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:review']);
|
||||
|
||||
const result = await createBuilder<DiscoveryApplicationResponse>(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/approve`)
|
||||
.body({reason: 'Meets all requirements'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.status).toBe('approved');
|
||||
expect(result.reviewed_at).toBeTruthy();
|
||||
expect(result.review_reason).toBe('Meets all requirements');
|
||||
});
|
||||
|
||||
test('should approve without a reason', async () => {
|
||||
const {guild} = await createGuildWithApplication(harness, 'No Reason Approve Guild');
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:review']);
|
||||
|
||||
const result = await createBuilder<DiscoveryApplicationResponse>(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/approve`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.status).toBe('approved');
|
||||
expect(result.review_reason).toBeNull();
|
||||
});
|
||||
|
||||
test('should add DISCOVERABLE feature to guild on approval', async () => {
|
||||
const {owner, guild} = await createGuildWithApplication(harness, 'Feature Add Guild');
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:review']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/approve`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const guildData = await getGuild(harness, owner.token, guild.id);
|
||||
expect(guildData.features).toContain(GuildFeatures.DISCOVERABLE);
|
||||
});
|
||||
|
||||
test('should not allow approving already approved application', async () => {
|
||||
const {guild} = await createGuildWithApplication(harness, 'Double Approve Guild');
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:review']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/approve`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/approve`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.DISCOVERY_APPLICATION_ALREADY_REVIEWED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow approving non-existent application', async () => {
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:review']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${TEST_IDS.NONEXISTENT_GUILD}/approve`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reject', () => {
|
||||
test('should reject a pending application with reason', async () => {
|
||||
const {guild} = await createGuildWithApplication(harness, 'Reject Test Guild');
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:review']);
|
||||
|
||||
const result = await createBuilder<DiscoveryApplicationResponse>(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/reject`)
|
||||
.body({reason: 'Description is too vague'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.status).toBe('rejected');
|
||||
expect(result.reviewed_at).toBeTruthy();
|
||||
expect(result.review_reason).toBe('Description is too vague');
|
||||
});
|
||||
|
||||
test('should require reason for rejection', async () => {
|
||||
const {guild} = await createGuildWithApplication(harness, 'No Reason Reject Guild');
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:review']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/reject`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow rejecting already rejected application', async () => {
|
||||
const {guild} = await createGuildWithApplication(harness, 'Double Reject Guild');
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:review']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/reject`)
|
||||
.body({reason: 'First rejection'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/reject`)
|
||||
.body({reason: 'Second rejection'})
|
||||
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.DISCOVERY_APPLICATION_ALREADY_REVIEWED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow rejecting approved application', async () => {
|
||||
const {guild} = await createGuildWithApplication(harness, 'Approved Then Reject Guild');
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:review']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/approve`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/reject`)
|
||||
.body({reason: 'Changed my mind'})
|
||||
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.DISCOVERY_APPLICATION_ALREADY_REVIEWED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow rejecting non-existent application', async () => {
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:review']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${TEST_IDS.NONEXISTENT_GUILD}/reject`)
|
||||
.body({reason: 'Does not exist'})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
test('should remove an approved guild from discovery', async () => {
|
||||
const {owner, guild} = await createGuildWithApplication(harness, 'Remove Test Guild');
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:remove']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/approve`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const result = await createBuilder<DiscoveryApplicationResponse>(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/guilds/${guild.id}/remove`)
|
||||
.body({reason: 'Violated community guidelines'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.status).toBe('removed');
|
||||
|
||||
const guildData = await getGuild(harness, owner.token, guild.id);
|
||||
expect(guildData.features).not.toContain(GuildFeatures.DISCOVERABLE);
|
||||
});
|
||||
|
||||
test('should require reason for removal', async () => {
|
||||
const {guild} = await createGuildWithApplication(harness, 'No Reason Remove Guild');
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:remove']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/approve`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/guilds/${guild.id}/remove`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow removing a pending application', async () => {
|
||||
const {guild} = await createGuildWithApplication(harness, 'Remove Pending Guild');
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:review', 'discovery:remove']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/guilds/${guild.id}/remove`)
|
||||
.body({reason: 'Not approved yet'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow removing non-existent application', async () => {
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:remove']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/guilds/${TEST_IDS.NONEXISTENT_GUILD}/remove`)
|
||||
.body({reason: 'Does not exist'})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('list applications', () => {
|
||||
test('should list pending applications', async () => {
|
||||
await createGuildWithApplication(harness, 'List Test Guild 1');
|
||||
await createGuildWithApplication(harness, 'List Test Guild 2');
|
||||
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:review']);
|
||||
|
||||
const results = await createBuilder<Array<DiscoveryApplicationResponse>>(harness, `Bearer ${admin.token}`)
|
||||
.get('/admin/discovery/applications?status=pending')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(results.length).toBeGreaterThanOrEqual(2);
|
||||
for (const app of results) {
|
||||
expect(app.status).toBe('pending');
|
||||
}
|
||||
});
|
||||
|
||||
test('should list approved applications', async () => {
|
||||
const {guild} = await createGuildWithApplication(harness, 'Approved List Guild');
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:review']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/approve`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const results = await createBuilder<Array<DiscoveryApplicationResponse>>(harness, `Bearer ${admin.token}`)
|
||||
.get('/admin/discovery/applications?status=approved')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
for (const app of results) {
|
||||
expect(app.status).toBe('approved');
|
||||
}
|
||||
});
|
||||
|
||||
test('should default to pending status', async () => {
|
||||
await createGuildWithApplication(harness, 'Default Status Guild');
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:review']);
|
||||
|
||||
const results = await createBuilder<Array<DiscoveryApplicationResponse>>(harness, `Bearer ${admin.token}`)
|
||||
.get('/admin/discovery/applications')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
for (const app of results) {
|
||||
expect(app.status).toBe('pending');
|
||||
}
|
||||
});
|
||||
|
||||
test('should respect limit parameter', async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await createGuildWithApplication(harness, `Limit Admin Guild ${i}`);
|
||||
}
|
||||
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:review']);
|
||||
|
||||
const results = await createBuilder<Array<DiscoveryApplicationResponse>>(harness, `Bearer ${admin.token}`)
|
||||
.get('/admin/discovery/applications?limit=2')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(results.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('should return empty list for status with no applications', async () => {
|
||||
await harness.reset();
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:review']);
|
||||
|
||||
const results = await createBuilder<Array<DiscoveryApplicationResponse>>(harness, `Bearer ${admin.token}`)
|
||||
.get('/admin/discovery/applications?status=rejected')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ACL requirements', () => {
|
||||
test('should require DISCOVERY_REVIEW ACL to list applications', async () => {
|
||||
const admin = await createAdminWithACLs(harness, ['user:lookup']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.get('/admin/discovery/applications')
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require DISCOVERY_REVIEW ACL to approve', async () => {
|
||||
const {guild} = await createGuildWithApplication(harness, 'ACL Approve Guild');
|
||||
const admin = await createAdminWithACLs(harness, ['user:lookup']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/approve`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require DISCOVERY_REVIEW ACL to reject', async () => {
|
||||
const {guild} = await createGuildWithApplication(harness, 'ACL Reject Guild');
|
||||
const admin = await createAdminWithACLs(harness, ['user:lookup']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/reject`)
|
||||
.body({reason: 'Not allowed'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require DISCOVERY_REMOVE ACL to remove', async () => {
|
||||
const {guild} = await createGuildWithApplication(harness, 'ACL Remove Guild');
|
||||
const admin = await createAdminWithACLs(harness, ['discovery:review']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/approve`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/guilds/${guild.id}/remove`)
|
||||
.body({reason: 'Not allowed to remove'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require authentication for admin endpoints', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.get('/admin/discovery/applications')
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/admin/discovery/applications/${TEST_IDS.NONEXISTENT_GUILD}/approve`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/admin/discovery/applications/${TEST_IDS.NONEXISTENT_GUILD}/reject`)
|
||||
.body({reason: 'test'})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/admin/discovery/guilds/${TEST_IDS.NONEXISTENT_GUILD}/remove`)
|
||||
.body({reason: 'test'})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
193
packages/api/src/admin/tests/SecurityAccessControl.test.tsx
Normal file
193
packages/api/src/admin/tests/SecurityAccessControl.test.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, setUserACLs} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {beforeEach, describe, test} from 'vitest';
|
||||
|
||||
describe('Security Access Control', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
describe('Blocked User Restrictions', () => {
|
||||
test('blocked user cannot send friend request', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, `Bearer ${user1.token}`)
|
||||
.put(`/users/@me/relationships/${user2.userId}`)
|
||||
.body({
|
||||
type: 2,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, `Bearer ${user2.token}`)
|
||||
.post(`/users/@me/relationships/${user1.userId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('blocked user cannot create DM', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, `Bearer ${user1.token}`)
|
||||
.put(`/users/@me/relationships/${user2.userId}`)
|
||||
.body({
|
||||
type: 2,
|
||||
})
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, `Bearer ${user2.token}`)
|
||||
.post('/users/@me/channels')
|
||||
.body({
|
||||
recipients: [user1.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'NOT_FRIENDS_WITH_USER')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unauthorized Access', () => {
|
||||
test('unauthorized user cannot access other users @me endpoint', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, `Bearer ${user1.token}`).get('/users/@me').expect(HTTP_STATUS.OK).execute();
|
||||
});
|
||||
|
||||
test('unauthorized access without token is rejected', async () => {
|
||||
await createBuilderWithoutAuth(harness).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
});
|
||||
|
||||
test('unauthorized access with invalid token is rejected', async () => {
|
||||
await createBuilder(harness, 'Bearer invalid_token_12345')
|
||||
.get('/users/@me')
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('unauthorized guild access is rejected', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const attacker = await createTestAccount(harness);
|
||||
|
||||
const createJson = await createBuilder<{id: string}>(harness, `Bearer ${owner.token}`)
|
||||
.post('/guilds')
|
||||
.body({
|
||||
name: 'Private Guild',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const guildId = createJson.id;
|
||||
|
||||
await createBuilder(harness, `Bearer ${attacker.token}`)
|
||||
.get(`/guilds/${guildId}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'ACCESS_DENIED')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('unauthorized channel access is rejected', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const attacker = await createTestAccount(harness);
|
||||
|
||||
const createJson = await createBuilder<{id: string}>(harness, `Bearer ${owner.token}`)
|
||||
.post('/guilds')
|
||||
.body({
|
||||
name: 'Private Guild',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const guildId = createJson.id;
|
||||
|
||||
await createBuilder(harness, `Bearer ${attacker.token}`)
|
||||
.get(`/guilds/${guildId}/channels`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_GUILD')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin Endpoint Security', () => {
|
||||
test('admin endpoints require admin authentication', async () => {
|
||||
const regularUser = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, `Bearer ${regularUser.token}`)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [regularUser.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('admin endpoints require proper ACLs', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('admin endpoints succeed with proper ACLs', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post('/admin/users/lookup')
|
||||
.body({
|
||||
user_ids: [admin.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Security', () => {
|
||||
test('token must have valid format', async () => {
|
||||
await createBuilder(harness, 'Bearer invalid_format')
|
||||
.get('/users/@me')
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('empty token is rejected', async () => {
|
||||
await createBuilder(harness, 'Bearer ').get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
});
|
||||
|
||||
test('malformed token is rejected', async () => {
|
||||
await createBuilder(harness, 'Bearer flx_invalid_token_format')
|
||||
.get('/users/@me')
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
325
packages/api/src/admin/tests/VisionarySlotManagement.test.tsx
Normal file
325
packages/api/src/admin/tests/VisionarySlotManagement.test.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface VisionarySlot {
|
||||
slot_index: number;
|
||||
user_id: string | null;
|
||||
}
|
||||
|
||||
interface ListVisionarySlotsResponse {
|
||||
slots: Array<VisionarySlot>;
|
||||
total_count: number;
|
||||
reserved_count: number;
|
||||
}
|
||||
|
||||
interface VisionarySlotOperationResponse {
|
||||
success: true;
|
||||
}
|
||||
|
||||
async function setAdminVisionarySlotAcls(harness: ApiTestHarness, admin: TestAccount): Promise<TestAccount> {
|
||||
return await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'audit_log:view',
|
||||
'visionary_slot:view',
|
||||
'visionary_slot:expand',
|
||||
'visionary_slot:shrink',
|
||||
'visionary_slot:reserve',
|
||||
'visionary_slot:swap',
|
||||
]);
|
||||
}
|
||||
|
||||
async function listVisionarySlots(harness: ApiTestHarness, adminToken: string): Promise<ListVisionarySlotsResponse> {
|
||||
return await createBuilder<ListVisionarySlotsResponse>(harness, `Bearer ${adminToken}`)
|
||||
.get('/admin/visionary-slots')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function expandVisionarySlots(
|
||||
harness: ApiTestHarness,
|
||||
adminToken: string,
|
||||
count: number,
|
||||
): Promise<VisionarySlotOperationResponse> {
|
||||
return await createBuilder<VisionarySlotOperationResponse>(harness, `Bearer ${adminToken}`)
|
||||
.post('/admin/visionary-slots/expand')
|
||||
.body({count})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function shrinkVisionarySlots(
|
||||
harness: ApiTestHarness,
|
||||
adminToken: string,
|
||||
targetCount: number,
|
||||
): Promise<VisionarySlotOperationResponse> {
|
||||
return await createBuilder<VisionarySlotOperationResponse>(harness, `Bearer ${adminToken}`)
|
||||
.post('/admin/visionary-slots/shrink')
|
||||
.body({target_count: targetCount})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function reserveVisionarySlot(
|
||||
harness: ApiTestHarness,
|
||||
adminToken: string,
|
||||
slotIndex: number,
|
||||
userId: string | null,
|
||||
): Promise<VisionarySlotOperationResponse> {
|
||||
return await createBuilder<VisionarySlotOperationResponse>(harness, `Bearer ${adminToken}`)
|
||||
.post('/admin/visionary-slots/reserve')
|
||||
.body({slot_index: slotIndex, user_id: userId})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function swapVisionarySlots(
|
||||
harness: ApiTestHarness,
|
||||
adminToken: string,
|
||||
slotIndexA: number,
|
||||
slotIndexB: number,
|
||||
): Promise<VisionarySlotOperationResponse> {
|
||||
return await createBuilder<VisionarySlotOperationResponse>(harness, `Bearer ${adminToken}`)
|
||||
.post('/admin/visionary-slots/swap')
|
||||
.body({slot_index_a: slotIndexA, slot_index_b: slotIndexB})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function listVisionarySlotAuditLogs(harness: ApiTestHarness, adminToken: string) {
|
||||
return await createBuilder<{logs: Array<{action: string}>; total: number}>(harness, `Bearer ${adminToken}`)
|
||||
.post('/admin/audit-logs')
|
||||
.body({target_type: 'visionary_slot', limit: 50, offset: 0})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Visionary slot management', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
test('lists all visionary slots', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const updatedAdmin = await setAdminVisionarySlotAcls(harness, admin);
|
||||
|
||||
const result = await listVisionarySlots(harness, updatedAdmin.token);
|
||||
|
||||
expect(result.total_count).toBeGreaterThan(0);
|
||||
expect(result.slots.length).toBe(result.total_count);
|
||||
expect(result.reserved_count).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('expands visionary slots', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const updatedAdmin = await setAdminVisionarySlotAcls(harness, admin);
|
||||
|
||||
const before = await listVisionarySlots(harness, updatedAdmin.token);
|
||||
await expandVisionarySlots(harness, updatedAdmin.token, 5);
|
||||
const after = await listVisionarySlots(harness, updatedAdmin.token);
|
||||
|
||||
expect(after.total_count).toBe(before.total_count + 5);
|
||||
});
|
||||
|
||||
test('shrinks visionary slots to target count', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const updatedAdmin = await setAdminVisionarySlotAcls(harness, admin);
|
||||
|
||||
await expandVisionarySlots(harness, updatedAdmin.token, 10);
|
||||
const before = await listVisionarySlots(harness, updatedAdmin.token);
|
||||
const targetCount = before.total_count - 3;
|
||||
|
||||
await shrinkVisionarySlots(harness, updatedAdmin.token, targetCount);
|
||||
const after = await listVisionarySlots(harness, updatedAdmin.token);
|
||||
|
||||
expect(after.total_count).toBe(targetCount);
|
||||
});
|
||||
|
||||
test('allows shrinking visionary slots to 0', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const updatedAdmin = await setAdminVisionarySlotAcls(harness, admin);
|
||||
|
||||
const before = await listVisionarySlots(harness, updatedAdmin.token);
|
||||
expect(before.total_count).toBeGreaterThan(0);
|
||||
|
||||
await shrinkVisionarySlots(harness, updatedAdmin.token, 0);
|
||||
const after = await listVisionarySlots(harness, updatedAdmin.token);
|
||||
|
||||
expect(after.total_count).toBe(0);
|
||||
expect(after.reserved_count).toBe(0);
|
||||
});
|
||||
|
||||
test('cannot shrink below highest reserved slot', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const updatedAdmin = await setAdminVisionarySlotAcls(harness, admin);
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
const slots = await listVisionarySlots(harness, updatedAdmin.token);
|
||||
const slotToReserve = slots.slots[slots.slots.length - 1];
|
||||
if (!slotToReserve) throw new Error('No slots available');
|
||||
if (slotToReserve.slot_index <= 1) throw new Error('Slot index is too low to shrink');
|
||||
|
||||
await reserveVisionarySlot(harness, updatedAdmin.token, slotToReserve.slot_index, user.userId);
|
||||
|
||||
await createBuilder(harness, `Bearer ${updatedAdmin.token}`)
|
||||
.post('/admin/visionary-slots/shrink')
|
||||
.body({target_count: slotToReserve.slot_index - 1})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CANNOT_SHRINK_RESERVED_SLOTS)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('reserves a slot for a user', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const updatedAdmin = await setAdminVisionarySlotAcls(harness, admin);
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
const slots = await listVisionarySlots(harness, updatedAdmin.token);
|
||||
const unreservedSlot = slots.slots.find((slot) => slot.user_id === null);
|
||||
if (!unreservedSlot) throw new Error('No unreserved slots available');
|
||||
|
||||
await reserveVisionarySlot(harness, updatedAdmin.token, unreservedSlot.slot_index, user.userId);
|
||||
const after = await listVisionarySlots(harness, updatedAdmin.token);
|
||||
const reserved = after.slots.find((slot) => slot.slot_index === unreservedSlot.slot_index);
|
||||
|
||||
expect(reserved?.user_id).toBe(user.userId);
|
||||
expect(after.reserved_count).toBe(slots.reserved_count + 1);
|
||||
});
|
||||
|
||||
test('unreserves a slot by setting user_id to null', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const updatedAdmin = await setAdminVisionarySlotAcls(harness, admin);
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
const slots = await listVisionarySlots(harness, updatedAdmin.token);
|
||||
const unreservedSlot = slots.slots.find((slot) => slot.user_id === null);
|
||||
if (!unreservedSlot) throw new Error('No unreserved slots available');
|
||||
|
||||
await reserveVisionarySlot(harness, updatedAdmin.token, unreservedSlot.slot_index, user.userId);
|
||||
await reserveVisionarySlot(harness, updatedAdmin.token, unreservedSlot.slot_index, null);
|
||||
const after = await listVisionarySlots(harness, updatedAdmin.token);
|
||||
const unreserved = after.slots.find((slot) => slot.slot_index === unreservedSlot.slot_index);
|
||||
|
||||
expect(unreserved?.user_id).toBeNull();
|
||||
});
|
||||
|
||||
test('accepts -1 as a valid user_id', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const updatedAdmin = await setAdminVisionarySlotAcls(harness, admin);
|
||||
|
||||
const slots = await listVisionarySlots(harness, updatedAdmin.token);
|
||||
const unreservedSlot = slots.slots.find((slot) => slot.user_id === null);
|
||||
if (!unreservedSlot) throw new Error('No unreserved slots available');
|
||||
|
||||
await reserveVisionarySlot(harness, updatedAdmin.token, unreservedSlot.slot_index, '-1');
|
||||
const after = await listVisionarySlots(harness, updatedAdmin.token);
|
||||
const reserved = after.slots.find((slot) => slot.slot_index === unreservedSlot.slot_index);
|
||||
|
||||
expect(reserved?.user_id).toBe('-1');
|
||||
});
|
||||
|
||||
test('swaps two reserved slots between users', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const updatedAdmin = await setAdminVisionarySlotAcls(harness, admin);
|
||||
const userA = await createTestAccount(harness);
|
||||
const userB = await createTestAccount(harness);
|
||||
|
||||
const slots = await listVisionarySlots(harness, updatedAdmin.token);
|
||||
const unreservedSlots = slots.slots.filter((slot) => slot.user_id === null).slice(0, 2);
|
||||
const slotA = unreservedSlots[0];
|
||||
const slotB = unreservedSlots[1];
|
||||
if (!slotA || !slotB) throw new Error('Not enough unreserved slots available');
|
||||
|
||||
await reserveVisionarySlot(harness, updatedAdmin.token, slotA.slot_index, userA.userId);
|
||||
await reserveVisionarySlot(harness, updatedAdmin.token, slotB.slot_index, userB.userId);
|
||||
|
||||
await swapVisionarySlots(harness, updatedAdmin.token, slotA.slot_index, slotB.slot_index);
|
||||
|
||||
const after = await listVisionarySlots(harness, updatedAdmin.token);
|
||||
const afterSlotA = after.slots.find((slot) => slot.slot_index === slotA.slot_index);
|
||||
const afterSlotB = after.slots.find((slot) => slot.slot_index === slotB.slot_index);
|
||||
|
||||
expect(afterSlotA?.user_id).toBe(userB.userId);
|
||||
expect(afterSlotB?.user_id).toBe(userA.userId);
|
||||
|
||||
const auditLogs = await listVisionarySlotAuditLogs(harness, updatedAdmin.token);
|
||||
expect(auditLogs.logs.some((log) => log.action === 'swap_visionary_slots')).toBe(true);
|
||||
});
|
||||
|
||||
test('requires visionary_slot:view permission to list slots', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const limitedAdmin = await setUserACLs(harness, admin, ['admin:authenticate']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${limitedAdmin.token}`)
|
||||
.get('/admin/visionary-slots')
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_ACL)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('requires visionary_slot:expand permission to expand slots', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const limitedAdmin = await setUserACLs(harness, admin, ['admin:authenticate', 'visionary_slot:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${limitedAdmin.token}`)
|
||||
.post('/admin/visionary-slots/expand')
|
||||
.body({count: 5})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_ACL)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('requires visionary_slot:shrink permission to shrink slots', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const limitedAdmin = await setUserACLs(harness, admin, ['admin:authenticate', 'visionary_slot:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${limitedAdmin.token}`)
|
||||
.post('/admin/visionary-slots/shrink')
|
||||
.body({target_count: 50})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_ACL)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('requires visionary_slot:reserve permission to reserve slots', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const limitedAdmin = await setUserACLs(harness, admin, ['admin:authenticate', 'visionary_slot:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${limitedAdmin.token}`)
|
||||
.post('/admin/visionary-slots/reserve')
|
||||
.body({slot_index: 0, user_id: '123456789'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_ACL)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('requires visionary_slot:swap permission to swap slots', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const limitedAdmin = await setUserACLs(harness, admin, ['admin:authenticate', 'visionary_slot:view']);
|
||||
|
||||
await createBuilder(harness, `Bearer ${limitedAdmin.token}`)
|
||||
.post('/admin/visionary-slots/swap')
|
||||
.body({slot_index_a: 1, slot_index_b: 2})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_ACL)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
109
packages/api/src/alert/AlertService.tsx
Normal file
109
packages/api/src/alert/AlertService.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import * as FetchUtils from '@fluxer/api/src/utils/FetchUtils';
|
||||
import {FLUXER_USER_AGENT} from '@fluxer/constants/src/Core';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
export interface GuildCrashAlertParams {
|
||||
guildId: string;
|
||||
stacktrace: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export class AlertService {
|
||||
private readonly webhookUrl: string | null;
|
||||
|
||||
constructor(webhookUrl: string | null | undefined) {
|
||||
this.webhookUrl = webhookUrl ?? null;
|
||||
if (!this.webhookUrl) {
|
||||
Logger.warn('AlertService initialised without a webhook URL – guild crash alerts will be disabled');
|
||||
}
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.webhookUrl !== null;
|
||||
}
|
||||
|
||||
async logGuildCrash(params: GuildCrashAlertParams): Promise<void> {
|
||||
if (!this.webhookUrl) return;
|
||||
|
||||
const timestamp = params.timestamp ?? new Date().toISOString();
|
||||
const boundary = `----FluxerCrash${Date.now()}`;
|
||||
const payload = {
|
||||
content: 'Guild crash detected on the gateway.',
|
||||
embeds: [
|
||||
{
|
||||
title: 'Guild crash',
|
||||
description: 'A guild worker process has terminated unexpectedly.',
|
||||
color: 0xed_42_45,
|
||||
fields: [
|
||||
{name: 'Guild ID', value: `\`${params.guildId}\``, inline: true},
|
||||
{name: 'Timestamp', value: timestamp, inline: true},
|
||||
],
|
||||
footer: {
|
||||
text: 'Fluxer Gateway Alerts',
|
||||
},
|
||||
timestamp,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const stacktraceFilename = `guild-${params.guildId}-stacktrace.txt`;
|
||||
const lines = [
|
||||
`--${boundary}`,
|
||||
'Content-Disposition: form-data; name="payload_json"',
|
||||
'',
|
||||
JSON.stringify(payload),
|
||||
`--${boundary}`,
|
||||
`Content-Disposition: form-data; name="file"; filename="${stacktraceFilename}"`,
|
||||
'Content-Type: text/plain',
|
||||
'',
|
||||
params.stacktrace,
|
||||
`--${boundary}--`,
|
||||
'',
|
||||
];
|
||||
const body = Buffer.from(lines.join('\r\n'), 'utf-8');
|
||||
|
||||
try {
|
||||
const response = await FetchUtils.sendRequest({
|
||||
url: this.webhookUrl,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'User-Agent': FLUXER_USER_AGENT,
|
||||
},
|
||||
body,
|
||||
timeout: ms('15 seconds'),
|
||||
serviceName: 'alert_webhook',
|
||||
});
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const responseBody = await FetchUtils.streamToString(response.stream).catch(() => '');
|
||||
Logger.error(
|
||||
{status: response.status, body: responseBody, guildId: params.guildId},
|
||||
'Guild crash alert webhook responded with non-OK status',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error({error, guildId: params.guildId}, 'Failed to send guild crash alert');
|
||||
}
|
||||
}
|
||||
}
|
||||
149
packages/api/src/app/APILifecycle.tsx
Normal file
149
packages/api/src/app/APILifecycle.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import type {APIConfig} from '@fluxer/api/src/config/APIConfig';
|
||||
import {GuildDataRepository} from '@fluxer/api/src/guild/repositories/GuildDataRepository';
|
||||
import type {ILogger} from '@fluxer/api/src/ILogger';
|
||||
import {KVAccountDeletionQueueService} from '@fluxer/api/src/infrastructure/KVAccountDeletionQueueService';
|
||||
import {initializeMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
|
||||
import {InstanceConfigRepository} from '@fluxer/api/src/instance/InstanceConfigRepository';
|
||||
import {ipBanCache} from '@fluxer/api/src/middleware/IpBanMiddleware';
|
||||
import {initializeServiceSingletons} from '@fluxer/api/src/middleware/ServiceMiddleware';
|
||||
import {ensureVoiceResourcesInitialized, getKVClient} from '@fluxer/api/src/middleware/ServiceRegistry';
|
||||
import {ReportRepository} from '@fluxer/api/src/report/ReportRepository';
|
||||
import {initializeSearch, shutdownSearch} from '@fluxer/api/src/SearchFactory';
|
||||
import {warmupAdminSearchIndexes} from '@fluxer/api/src/search/SearchWarmup';
|
||||
import {VisionarySlotInitializer} from '@fluxer/api/src/stripe/VisionarySlotInitializer';
|
||||
import {UserRepository} from '@fluxer/api/src/user/repositories/UserRepository';
|
||||
import {VoiceDataInitializer} from '@fluxer/api/src/voice/VoiceDataInitializer';
|
||||
|
||||
export function createInitializer(config: APIConfig, logger: ILogger): () => Promise<void> {
|
||||
return async (): Promise<void> => {
|
||||
logger.info('Initializing API service...');
|
||||
|
||||
const kvClient = getKVClient();
|
||||
ipBanCache.setRefreshSubscriber(kvClient);
|
||||
await ipBanCache.initialize();
|
||||
logger.info('IP ban cache initialized');
|
||||
|
||||
initializeMetricsService();
|
||||
logger.info('Metrics service initialized');
|
||||
|
||||
await initializeServiceSingletons();
|
||||
logger.info('Service singletons initialized');
|
||||
|
||||
try {
|
||||
const userRepository = new UserRepository();
|
||||
const kvDeletionQueue = new KVAccountDeletionQueueService(kvClient, userRepository);
|
||||
|
||||
if (await kvDeletionQueue.needsRebuild()) {
|
||||
logger.warn('KV deletion queue needs rebuild, rebuilding...');
|
||||
await kvDeletionQueue.rebuildState();
|
||||
} else {
|
||||
logger.info('KV deletion queue state is healthy');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Failed to verify KV deletion queue state');
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info({search_url: config.search.url}, 'Initializing search...');
|
||||
await initializeSearch();
|
||||
logger.info('Search initialized');
|
||||
|
||||
// All API replicas share the same Meilisearch cluster, so only one should warm it.
|
||||
const warmupLockKey = 'fluxer:search:warmup:admin';
|
||||
const warmupLockToken = randomUUID();
|
||||
const warmupLockTtlSeconds = 60 * 60;
|
||||
const acquiredWarmupLock = await kvClient.setnx(warmupLockKey, warmupLockToken, warmupLockTtlSeconds);
|
||||
if (!acquiredWarmupLock) {
|
||||
logger.info('Another API instance is warming search indexes, skipping warmup');
|
||||
} else {
|
||||
try {
|
||||
await warmupAdminSearchIndexes({
|
||||
userRepository: new UserRepository(),
|
||||
guildRepository: new GuildDataRepository(),
|
||||
reportRepository: new ReportRepository(),
|
||||
logger,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Admin search warmup failed (continuing startup)');
|
||||
} finally {
|
||||
try {
|
||||
await kvClient.releaseLock(warmupLockKey, warmupLockToken);
|
||||
} catch (error) {
|
||||
logger.warn({error}, 'Failed to release admin search warmup lock');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.voice.enabled && config.voice.defaultRegion) {
|
||||
const voiceDataInitializer = new VoiceDataInitializer();
|
||||
await voiceDataInitializer.initialize();
|
||||
await ensureVoiceResourcesInitialized();
|
||||
logger.info('Voice data initialized');
|
||||
}
|
||||
|
||||
if (config.dev.testModeEnabled && config.stripe.enabled) {
|
||||
const visionarySlotInitializer = new VisionarySlotInitializer();
|
||||
await visionarySlotInitializer.initialize();
|
||||
logger.info('Stripe visionary slots initialized');
|
||||
}
|
||||
|
||||
if (config.dev.testModeEnabled) {
|
||||
const instanceConfigRepository = new InstanceConfigRepository();
|
||||
try {
|
||||
await instanceConfigRepository.setSsoConfig({
|
||||
enabled: false,
|
||||
authorizationUrl: null,
|
||||
tokenUrl: null,
|
||||
clientId: null,
|
||||
});
|
||||
logger.info('Reset SSO config to disabled for test mode');
|
||||
} catch (error) {
|
||||
logger.warn({error}, 'Failed to reset SSO config for test mode');
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('API service initialization complete');
|
||||
};
|
||||
}
|
||||
|
||||
export function createShutdown(logger: ILogger): () => Promise<void> {
|
||||
return async (): Promise<void> => {
|
||||
logger.info('Shutting down API service...');
|
||||
|
||||
try {
|
||||
await shutdownSearch();
|
||||
logger.info('Search service shut down');
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Error shutting down search service');
|
||||
}
|
||||
|
||||
try {
|
||||
ipBanCache.shutdown();
|
||||
logger.info('IP ban cache shut down');
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Error shutting down IP ban cache');
|
||||
}
|
||||
|
||||
logger.info('API service shutdown complete');
|
||||
};
|
||||
}
|
||||
83
packages/api/src/app/ControllerRegistry.tsx
Normal file
83
packages/api/src/app/ControllerRegistry.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {registerAdminControllers} from '@fluxer/api/src/admin/controllers';
|
||||
import {AuthController} from '@fluxer/api/src/auth/AuthController';
|
||||
import {BlueskyOAuthController} from '@fluxer/api/src/bluesky/BlueskyOAuthController';
|
||||
import {ChannelController} from '@fluxer/api/src/channel/ChannelController';
|
||||
import type {APIConfig} from '@fluxer/api/src/config/APIConfig';
|
||||
import {ConnectionController} from '@fluxer/api/src/connection/ConnectionController';
|
||||
import {DonationController} from '@fluxer/api/src/donation/DonationController';
|
||||
import {DownloadController} from '@fluxer/api/src/download/DownloadController';
|
||||
import {FavoriteMemeController} from '@fluxer/api/src/favorite_meme/FavoriteMemeController';
|
||||
import {GatewayController} from '@fluxer/api/src/gateway/GatewayController';
|
||||
import {GuildController} from '@fluxer/api/src/guild/GuildController';
|
||||
import {InstanceController} from '@fluxer/api/src/instance/InstanceController';
|
||||
import {InviteController} from '@fluxer/api/src/invite/InviteController';
|
||||
import {KlipyController} from '@fluxer/api/src/klipy/KlipyController';
|
||||
import {OAuth2ApplicationsController} from '@fluxer/api/src/oauth/OAuth2ApplicationsController';
|
||||
import {OAuth2Controller} from '@fluxer/api/src/oauth/OAuth2Controller';
|
||||
import {registerPackControllers} from '@fluxer/api/src/pack/controllers';
|
||||
import {ReadStateController} from '@fluxer/api/src/read_state/ReadStateController';
|
||||
import {ReportController} from '@fluxer/api/src/report/ReportController';
|
||||
import {RpcController} from '@fluxer/api/src/rpc/RpcController';
|
||||
import {SearchController} from '@fluxer/api/src/search/controllers/SearchController';
|
||||
import {StripeController} from '@fluxer/api/src/stripe/StripeController';
|
||||
import {TenorController} from '@fluxer/api/src/tenor/TenorController';
|
||||
import {TestHarnessController} from '@fluxer/api/src/test/TestHarnessController';
|
||||
import {ThemeController} from '@fluxer/api/src/theme/ThemeController';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {UserController} from '@fluxer/api/src/user/controllers/UserController';
|
||||
import {WebhookController} from '@fluxer/api/src/webhook/WebhookController';
|
||||
|
||||
export function registerControllers(routes: HonoApp, config: APIConfig): void {
|
||||
GatewayController(routes);
|
||||
registerAdminControllers(routes);
|
||||
AuthController(routes);
|
||||
ChannelController(routes);
|
||||
ConnectionController(routes);
|
||||
BlueskyOAuthController(routes);
|
||||
InstanceController(routes);
|
||||
DownloadController(routes);
|
||||
FavoriteMemeController(routes);
|
||||
InviteController(routes);
|
||||
registerPackControllers(routes);
|
||||
ReadStateController(routes);
|
||||
ReportController(routes);
|
||||
RpcController(routes);
|
||||
GuildController(routes);
|
||||
SearchController(routes);
|
||||
KlipyController(routes);
|
||||
TenorController(routes);
|
||||
ThemeController(routes);
|
||||
|
||||
if (config.dev.testModeEnabled || config.nodeEnv === 'development') {
|
||||
TestHarnessController(routes);
|
||||
}
|
||||
|
||||
UserController(routes);
|
||||
WebhookController(routes);
|
||||
OAuth2Controller(routes);
|
||||
OAuth2ApplicationsController(routes);
|
||||
|
||||
if (!config.instance.selfHosted) {
|
||||
DonationController(routes);
|
||||
StripeController(routes);
|
||||
}
|
||||
}
|
||||
147
packages/api/src/app/MiddlewarePipeline.tsx
Normal file
147
packages/api/src/app/MiddlewarePipeline.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ILogger} from '@fluxer/api/src/ILogger';
|
||||
import {AuditLogMiddleware} from '@fluxer/api/src/middleware/AuditLogMiddleware';
|
||||
import {ConcurrencyLimitMiddleware} from '@fluxer/api/src/middleware/ConcurrencyLimitMiddleware';
|
||||
import {GuildAvailabilityMiddleware} from '@fluxer/api/src/middleware/GuildAvailabilityMiddleware';
|
||||
import {IpBanMiddleware} from '@fluxer/api/src/middleware/IpBanMiddleware';
|
||||
import {LocaleMiddleware} from '@fluxer/api/src/middleware/LocaleMiddleware';
|
||||
import {MetricsMiddleware} from '@fluxer/api/src/middleware/MetricsMiddleware';
|
||||
import {RequestCacheMiddleware} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import {RequireXForwardedForMiddleware} from '@fluxer/api/src/middleware/RequireXForwardedForMiddleware';
|
||||
import {ServiceMiddleware} from '@fluxer/api/src/middleware/ServiceMiddleware';
|
||||
import {UserMiddleware} from '@fluxer/api/src/middleware/UserMiddleware';
|
||||
import type {HonoApp, HonoEnv} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {InvalidApiOriginError} from '@fluxer/errors/src/domains/core/InvalidApiOriginError';
|
||||
import {applyMiddlewareStack} from '@fluxer/hono/src/middleware/MiddlewareStack';
|
||||
import {createServiceTelemetry} from '@fluxer/hono/src/middleware/TelemetryAdapters';
|
||||
import {formatTraceparent, getActiveSpan} from '@fluxer/telemetry/src/Tracing';
|
||||
import type {Context as HonoContext} from 'hono';
|
||||
|
||||
export interface MiddlewarePipelineOptions {
|
||||
logger: ILogger;
|
||||
nodeEnv: string;
|
||||
setSentryUser?: (user: {id?: string; username?: string; email?: string; ip_address?: string}) => void;
|
||||
isTelemetryActive?: () => boolean;
|
||||
}
|
||||
|
||||
const TRACEPARENT_HEADER = 'traceparent';
|
||||
function attachTraceparentHeader(ctx: HonoContext<HonoEnv>): void {
|
||||
const span = getActiveSpan();
|
||||
if (!span) {
|
||||
return;
|
||||
}
|
||||
|
||||
const traceparent = formatTraceparent(span);
|
||||
if (traceparent) {
|
||||
ctx.header(TRACEPARENT_HEADER, traceparent);
|
||||
}
|
||||
}
|
||||
|
||||
export function configureMiddleware(routes: HonoApp, options: MiddlewarePipelineOptions): void {
|
||||
const {logger, nodeEnv, setSentryUser, isTelemetryActive} = options;
|
||||
|
||||
const requestTelemetry = createServiceTelemetry({
|
||||
serviceName: 'fluxer-api',
|
||||
skipPaths: ['/_health', '/internal/telemetry'],
|
||||
});
|
||||
|
||||
applyMiddlewareStack(routes, {
|
||||
requestId: {},
|
||||
tracing: requestTelemetry.tracing,
|
||||
metrics: {
|
||||
enabled: true,
|
||||
collector: requestTelemetry.metricsCollector,
|
||||
skipPaths: ['/_health', '/internal/telemetry'],
|
||||
},
|
||||
logger: {
|
||||
log: (data) => {
|
||||
logger.info(
|
||||
{
|
||||
method: data.method,
|
||||
path: data.path,
|
||||
status: data.status,
|
||||
durationMs: data.durationMs,
|
||||
},
|
||||
'Request completed',
|
||||
);
|
||||
},
|
||||
skip: ['/_health'],
|
||||
},
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
|
||||
if (nodeEnv === 'production') {
|
||||
routes.use('*', async (ctx, next) => {
|
||||
const host = ctx.req.header('host');
|
||||
if (ctx.req.method !== 'GET' && (host === 'web.fluxer.app' || host === 'web.canary.fluxer.app')) {
|
||||
const origin = ctx.req.header('origin');
|
||||
if (!origin || origin !== `https://${host}`) {
|
||||
throw new InvalidApiOriginError();
|
||||
}
|
||||
}
|
||||
await next();
|
||||
});
|
||||
}
|
||||
|
||||
routes.use(IpBanMiddleware);
|
||||
routes.use(ConcurrencyLimitMiddleware);
|
||||
routes.use(MetricsMiddleware);
|
||||
routes.use(AuditLogMiddleware);
|
||||
routes.use(RequireXForwardedForMiddleware());
|
||||
routes.use(RequestCacheMiddleware);
|
||||
routes.use(ServiceMiddleware);
|
||||
routes.use(UserMiddleware);
|
||||
routes.use(GuildAvailabilityMiddleware);
|
||||
routes.use(LocaleMiddleware);
|
||||
|
||||
routes.use('*', async (ctx, next) => {
|
||||
attachTraceparentHeader(ctx);
|
||||
await next();
|
||||
});
|
||||
|
||||
if (setSentryUser) {
|
||||
routes.use('*', async (ctx, next) => {
|
||||
const user = ctx.get('user');
|
||||
const clientIp = ctx.req.header('X-Forwarded-For')?.split(',')[0]?.trim();
|
||||
|
||||
setSentryUser({
|
||||
id: user?.id.toString(),
|
||||
username: user?.username,
|
||||
email: user?.email ?? undefined,
|
||||
ip_address: clientIp,
|
||||
});
|
||||
|
||||
return next();
|
||||
});
|
||||
}
|
||||
|
||||
routes.get('/_health', async (ctx) => ctx.text('OK'));
|
||||
|
||||
if (isTelemetryActive) {
|
||||
routes.get('/internal/telemetry', async (ctx) => {
|
||||
return ctx.json({
|
||||
telemetry_enabled: isTelemetryActive(),
|
||||
service: 'fluxer_api',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
BIN
packages/api/src/assets/github.webp
Normal file
BIN
packages/api/src/assets/github.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
packages/api/src/assets/sentry.webp
Normal file
BIN
packages/api/src/assets/sentry.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
153
packages/api/src/attachment/AttachmentDecayRepository.tsx
Normal file
153
packages/api/src/attachment/AttachmentDecayRepository.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AttachmentID, ChannelID, MessageID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {BatchBuilder, fetchMany, fetchManyInChunks, fetchOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import {AttachmentDecayByExpiry, AttachmentDecayById} from '@fluxer/api/src/Tables';
|
||||
import type {AttachmentDecayRow} from '@fluxer/api/src/types/AttachmentDecayTypes';
|
||||
|
||||
interface AttachmentDecayExpiryRow {
|
||||
expiry_bucket: number;
|
||||
expires_at: Date;
|
||||
attachment_id: AttachmentID;
|
||||
channel_id: ChannelID;
|
||||
message_id: MessageID;
|
||||
}
|
||||
|
||||
const FETCH_BY_ID_CQL = AttachmentDecayById.selectCql({
|
||||
where: AttachmentDecayById.where.eq('attachment_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_BY_IDS_CQL = AttachmentDecayById.selectCql({
|
||||
where: AttachmentDecayById.where.in('attachment_id', 'attachment_ids'),
|
||||
});
|
||||
|
||||
const createFetchExpiredByBucketQuery = (limit: number) =>
|
||||
AttachmentDecayByExpiry.selectCql({
|
||||
where: [
|
||||
AttachmentDecayByExpiry.where.eq('expiry_bucket'),
|
||||
AttachmentDecayByExpiry.where.lte('expires_at', 'current_time'),
|
||||
],
|
||||
limit,
|
||||
});
|
||||
|
||||
export class AttachmentDecayRepository {
|
||||
async upsert(record: AttachmentDecayRow & {expiry_bucket: number}): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(AttachmentDecayById.upsertAll(record));
|
||||
batch.addPrepared(
|
||||
AttachmentDecayByExpiry.upsertAll({
|
||||
expiry_bucket: record.expiry_bucket,
|
||||
expires_at: record.expires_at,
|
||||
attachment_id: record.attachment_id,
|
||||
channel_id: record.channel_id,
|
||||
message_id: record.message_id,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async fetchById(attachmentId: AttachmentID): Promise<AttachmentDecayRow | null> {
|
||||
const row = await fetchOne<AttachmentDecayRow>(FETCH_BY_ID_CQL, {attachment_id: attachmentId});
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async fetchByIds(attachmentIds: Array<AttachmentID>): Promise<Map<AttachmentID, AttachmentDecayRow>> {
|
||||
if (attachmentIds.length === 0) return new Map();
|
||||
|
||||
const rows = await fetchManyInChunks<AttachmentDecayRow, AttachmentID>(
|
||||
FETCH_BY_IDS_CQL,
|
||||
attachmentIds,
|
||||
(chunk) => ({attachment_ids: new Set(chunk)}),
|
||||
);
|
||||
|
||||
const map = new Map<AttachmentID, AttachmentDecayRow>();
|
||||
for (const row of rows) {
|
||||
map.set(row.attachment_id, row);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async fetchExpiredByBucket(bucket: number, currentTime: Date, limit = 200): Promise<Array<AttachmentDecayExpiryRow>> {
|
||||
const query = createFetchExpiredByBucketQuery(limit);
|
||||
return fetchMany(query, {expiry_bucket: bucket, current_time: currentTime});
|
||||
}
|
||||
|
||||
async deleteRecords(params: {expiry_bucket: number; expires_at: Date; attachment_id: AttachmentID}): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
AttachmentDecayByExpiry.deleteByPk({
|
||||
expiry_bucket: params.expiry_bucket,
|
||||
expires_at: params.expires_at,
|
||||
attachment_id: params.attachment_id,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(AttachmentDecayById.deleteByPk({attachment_id: params.attachment_id}));
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async fetchAllByBucket(bucket: number, limit = 200): Promise<Array<AttachmentDecayExpiryRow>> {
|
||||
const query = AttachmentDecayByExpiry.selectCql({
|
||||
where: [AttachmentDecayByExpiry.where.eq('expiry_bucket')],
|
||||
limit,
|
||||
});
|
||||
return fetchMany<AttachmentDecayExpiryRow>(query, {expiry_bucket: bucket});
|
||||
}
|
||||
|
||||
async deleteAllByBucket(bucket: number): Promise<number> {
|
||||
const records = await this.fetchAllByBucket(bucket);
|
||||
if (records.length === 0) return 0;
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const record of records) {
|
||||
batch.addPrepared(
|
||||
AttachmentDecayByExpiry.deleteByPk({
|
||||
expiry_bucket: record.expiry_bucket,
|
||||
expires_at: record.expires_at,
|
||||
attachment_id: record.attachment_id,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(AttachmentDecayById.deleteByPk({attachment_id: record.attachment_id}));
|
||||
}
|
||||
await batch.execute();
|
||||
return records.length;
|
||||
}
|
||||
|
||||
async clearAll(days = 30): Promise<number> {
|
||||
let totalDeleted = 0;
|
||||
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = new Date();
|
||||
date.setUTCDate(date.getUTCDate() - i);
|
||||
const bucket = parseInt(
|
||||
`${date.getUTCFullYear()}${String(date.getUTCMonth() + 1).padStart(2, '0')}${String(date.getUTCDate()).padStart(
|
||||
2,
|
||||
'0',
|
||||
)}`,
|
||||
10,
|
||||
);
|
||||
|
||||
const deletedInBucket = await this.deleteAllByBucket(bucket);
|
||||
totalDeleted += deletedInBucket;
|
||||
}
|
||||
|
||||
return totalDeleted;
|
||||
}
|
||||
}
|
||||
149
packages/api/src/attachment/AttachmentDecayService.tsx
Normal file
149
packages/api/src/attachment/AttachmentDecayService.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {AttachmentDecayRepository} from '@fluxer/api/src/attachment/AttachmentDecayRepository';
|
||||
import type {AttachmentID, ChannelID, MessageID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {AttachmentDecayRow} from '@fluxer/api/src/types/AttachmentDecayTypes';
|
||||
import {
|
||||
computeCost,
|
||||
computeDecay,
|
||||
DEFAULT_DECAY_CONSTANTS,
|
||||
DEFAULT_RENEWAL_CONSTANTS,
|
||||
extendExpiry,
|
||||
getExpiryBucket,
|
||||
maybeRenewExpiry,
|
||||
} from '@fluxer/api/src/utils/AttachmentDecay';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
export interface AttachmentDecayPayload {
|
||||
attachmentId: AttachmentID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
filename: string;
|
||||
sizeBytes: bigint;
|
||||
uploadedAt: Date;
|
||||
currentExpiresAt?: Date | null;
|
||||
}
|
||||
|
||||
export class AttachmentDecayService {
|
||||
constructor(private readonly repo: AttachmentDecayRepository = new AttachmentDecayRepository()) {}
|
||||
|
||||
async upsertMany(payloads: Array<AttachmentDecayPayload>): Promise<void> {
|
||||
if (!Config.attachmentDecayEnabled) return;
|
||||
|
||||
for (const payload of payloads) {
|
||||
const decay = computeDecay({sizeBytes: payload.sizeBytes, uploadedAt: payload.uploadedAt});
|
||||
if (!decay) continue;
|
||||
|
||||
const expiresAt = extendExpiry(payload.currentExpiresAt ?? null, decay.expiresAt);
|
||||
const record: AttachmentDecayRow & {expiry_bucket: number} = {
|
||||
attachment_id: payload.attachmentId,
|
||||
channel_id: payload.channelId,
|
||||
message_id: payload.messageId,
|
||||
filename: payload.filename,
|
||||
size_bytes: payload.sizeBytes,
|
||||
uploaded_at: payload.uploadedAt,
|
||||
expires_at: expiresAt,
|
||||
last_accessed_at: payload.uploadedAt,
|
||||
cost: decay.cost,
|
||||
lifetime_days: decay.days,
|
||||
status: null,
|
||||
expiry_bucket: getExpiryBucket(expiresAt),
|
||||
};
|
||||
await this.repo.upsert(record);
|
||||
}
|
||||
}
|
||||
|
||||
async extendForAttachments(attachments: Array<AttachmentDecayPayload>): Promise<void> {
|
||||
if (!Config.attachmentDecayEnabled) return;
|
||||
|
||||
if (attachments.length === 0) return;
|
||||
|
||||
const attachmentIds = attachments.map((a) => a.attachmentId);
|
||||
const existingRecords = await this.repo.fetchByIds(attachmentIds);
|
||||
|
||||
const now = new Date();
|
||||
const windowDays = DEFAULT_RENEWAL_CONSTANTS.RENEW_WINDOW_DAYS;
|
||||
const thresholdDays = DEFAULT_RENEWAL_CONSTANTS.RENEW_THRESHOLD_DAYS;
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const existing = existingRecords.get(attachment.attachmentId);
|
||||
if (!existing) continue;
|
||||
if (existing.expires_at.getTime() <= now.getTime()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const uploadedAt = existing.uploaded_at;
|
||||
const decay = computeDecay({sizeBytes: attachment.sizeBytes, uploadedAt});
|
||||
if (!decay) continue;
|
||||
|
||||
let expiresAt = extendExpiry(existing.expires_at ?? null, decay.expiresAt);
|
||||
|
||||
const renewed = maybeRenewExpiry({
|
||||
currentExpiry: expiresAt,
|
||||
now,
|
||||
thresholdDays,
|
||||
windowDays,
|
||||
});
|
||||
|
||||
if (renewed) {
|
||||
expiresAt = renewed;
|
||||
}
|
||||
|
||||
const lifetimeDays = Math.round((expiresAt.getTime() - uploadedAt.getTime()) / ms('1 day'));
|
||||
const cost = computeCost({
|
||||
sizeBytes: attachment.sizeBytes,
|
||||
lifetimeDays,
|
||||
pricePerTBPerMonth: DEFAULT_DECAY_CONSTANTS.PRICE_PER_TB_PER_MONTH,
|
||||
});
|
||||
|
||||
await this.repo.upsert({
|
||||
attachment_id: attachment.attachmentId,
|
||||
channel_id: attachment.channelId,
|
||||
message_id: attachment.messageId,
|
||||
filename: attachment.filename,
|
||||
size_bytes: attachment.sizeBytes,
|
||||
uploaded_at: uploadedAt,
|
||||
expires_at: expiresAt,
|
||||
last_accessed_at: now,
|
||||
cost,
|
||||
lifetime_days: lifetimeDays,
|
||||
status: existing.status ?? null,
|
||||
expiry_bucket: getExpiryBucket(expiresAt),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMetadata(
|
||||
attachments: Array<Pick<AttachmentDecayPayload, 'attachmentId'>>,
|
||||
): Promise<Map<string, AttachmentDecayRow>> {
|
||||
if (!Config.attachmentDecayEnabled) return new Map();
|
||||
if (attachments.length === 0) return new Map();
|
||||
|
||||
const attachmentIds = attachments.map((a) => a.attachmentId);
|
||||
const recordsMap = await this.repo.fetchByIds(attachmentIds);
|
||||
|
||||
const result = new Map<string, AttachmentDecayRow>();
|
||||
for (const [id, row] of recordsMap.entries()) {
|
||||
result.set(id.toString(), row);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
670
packages/api/src/auth/AuthController.tsx
Normal file
670
packages/api/src/auth/AuthController.tsx
Normal file
@@ -0,0 +1,670 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {requireSudoMode} from '@fluxer/api/src/auth/services/SudoVerificationService';
|
||||
import {DefaultUserOnly, LoginRequiredAllowSuspicious} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {CaptchaMiddleware} from '@fluxer/api/src/middleware/CaptchaMiddleware';
|
||||
import {LocalAuthMiddleware} from '@fluxer/api/src/middleware/LocalAuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {SudoModeMiddleware} from '@fluxer/api/src/middleware/SudoModeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {
|
||||
AuthLoginResponse,
|
||||
AuthorizeIpRequest,
|
||||
AuthRegisterResponse,
|
||||
AuthSessionsResponse,
|
||||
AuthTokenWithUserIdResponse,
|
||||
EmailRevertRequest,
|
||||
ForgotPasswordRequest,
|
||||
HandoffCodeParam,
|
||||
HandoffCompleteRequest,
|
||||
HandoffInitiateResponse,
|
||||
HandoffStatusResponse,
|
||||
IpAuthorizationPollQuery,
|
||||
IpAuthorizationPollResponse,
|
||||
LoginRequest,
|
||||
LogoutAuthSessionsRequest,
|
||||
MfaSmsRequest,
|
||||
MfaTicketRequest,
|
||||
MfaTotpRequest,
|
||||
RegisterRequest,
|
||||
ResetPasswordRequest,
|
||||
SsoCompleteRequest,
|
||||
SsoCompleteResponse,
|
||||
SsoStartRequest,
|
||||
SsoStartResponse,
|
||||
SsoStatusResponse,
|
||||
SudoVerificationSchema,
|
||||
UsernameSuggestionsRequest,
|
||||
UsernameSuggestionsResponse,
|
||||
VerifyEmailRequest,
|
||||
WebAuthnAuthenticateRequest,
|
||||
WebAuthnAuthenticationOptionsResponse,
|
||||
WebAuthnMfaRequest,
|
||||
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
|
||||
export function AuthController(app: HonoApp) {
|
||||
app.get(
|
||||
'/auth/sso/status',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_SSO_START),
|
||||
OpenAPI({
|
||||
operationId: 'get_sso_status',
|
||||
summary: 'Get SSO status',
|
||||
responseSchema: SsoStatusResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description: 'Retrieve the current status of the SSO authentication session without authentication required.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const status = await ctx.get('authRequestService').getSsoStatus();
|
||||
return ctx.json(status);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/sso/start',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_SSO_START),
|
||||
Validator('json', SsoStartRequest),
|
||||
OpenAPI({
|
||||
operationId: 'start_sso',
|
||||
summary: 'Start SSO',
|
||||
responseSchema: SsoStartResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Initiate a new Single Sign-On (SSO) session. Returns a session URL to be completed with SSO provider credentials.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const result = await ctx.get('authRequestService').startSso(ctx.req.valid('json'));
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/sso/complete',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_SSO_COMPLETE),
|
||||
Validator('json', SsoCompleteRequest),
|
||||
OpenAPI({
|
||||
operationId: 'complete_sso',
|
||||
summary: 'Complete SSO',
|
||||
responseSchema: SsoCompleteResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Complete the SSO authentication flow with the authorization code from the SSO provider. Returns authentication token and user information.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const result = await ctx.get('authRequestService').completeSso(ctx.req.valid('json'), ctx.req.raw);
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/register',
|
||||
LocalAuthMiddleware,
|
||||
CaptchaMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_REGISTER),
|
||||
Validator('json', RegisterRequest),
|
||||
OpenAPI({
|
||||
operationId: 'register_account',
|
||||
summary: 'Register account',
|
||||
responseSchema: AuthRegisterResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Create a new user account with email and password. Requires CAPTCHA verification. User account is created but must verify email before logging in.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const result = await ctx.get('authRequestService').register({
|
||||
data: ctx.req.valid('json'),
|
||||
request: ctx.req.raw,
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/login',
|
||||
LocalAuthMiddleware,
|
||||
CaptchaMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN),
|
||||
Validator('json', LoginRequest),
|
||||
OpenAPI({
|
||||
operationId: 'login_user',
|
||||
summary: 'Login account',
|
||||
responseSchema: AuthLoginResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Authenticate with email and password. Returns authentication token if credentials are valid and MFA is not required. If MFA is enabled, returns a ticket for MFA verification.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const result = await ctx.get('authRequestService').login({
|
||||
data: ctx.req.valid('json'),
|
||||
request: ctx.req.raw,
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/login/mfa/totp',
|
||||
LocalAuthMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN_MFA),
|
||||
Validator('json', MfaTotpRequest),
|
||||
OpenAPI({
|
||||
operationId: 'login_with_totp',
|
||||
summary: 'Login with TOTP',
|
||||
responseSchema: AuthTokenWithUserIdResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Complete login by verifying TOTP code during multi-factor authentication. Requires the MFA ticket from initial login attempt.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {code, ticket} = ctx.req.valid('json');
|
||||
const result = await ctx.get('authRequestService').loginMfaTotp({code, ticket, request: ctx.req.raw});
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/login/mfa/sms/send',
|
||||
LocalAuthMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN_MFA),
|
||||
Validator('json', MfaTicketRequest),
|
||||
OpenAPI({
|
||||
operationId: 'send_sms_mfa_code',
|
||||
summary: 'Send SMS MFA code',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
"Request an SMS code to be sent to the user's registered phone number during MFA login. Requires the MFA ticket from initial login attempt.",
|
||||
}),
|
||||
async (ctx) => {
|
||||
await ctx.get('authRequestService').sendSmsMfaCodeForTicket(ctx.req.valid('json'));
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/login/mfa/sms',
|
||||
LocalAuthMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN_MFA),
|
||||
Validator('json', MfaSmsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'login_with_sms_mfa',
|
||||
summary: 'Login with SMS MFA',
|
||||
responseSchema: AuthTokenWithUserIdResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Complete login by verifying the SMS code sent during MFA authentication. Requires the MFA ticket from initial login attempt.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {code, ticket} = ctx.req.valid('json');
|
||||
const result = await ctx.get('authRequestService').loginMfaSms({code, ticket, request: ctx.req.raw});
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/logout',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGOUT),
|
||||
OpenAPI({
|
||||
operationId: 'logout_user',
|
||||
summary: 'Logout account',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Invalidate the current authentication token and end the session. The auth token in the Authorization header will no longer be valid.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await ctx.get('authRequestService').logout({
|
||||
authorizationHeader: ctx.req.header('Authorization') ?? undefined,
|
||||
authToken: ctx.get('authToken') ?? undefined,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/verify',
|
||||
LocalAuthMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_VERIFY_EMAIL),
|
||||
Validator('json', VerifyEmailRequest),
|
||||
OpenAPI({
|
||||
operationId: 'verify_email',
|
||||
summary: 'Verify email',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Verify user email address using the code sent during registration. Email verification is required before the account becomes fully usable.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await ctx.get('authRequestService').verifyEmail(ctx.req.valid('json'));
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/verify/resend',
|
||||
LocalAuthMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_RESEND_VERIFICATION),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'resend_verification_email',
|
||||
summary: 'Resend verification email',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Request a new email verification code to be sent. Requires authentication. Use this if the original verification email was lost or expired.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await ctx.get('authRequestService').resendVerificationEmail(ctx.get('user'));
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/forgot',
|
||||
LocalAuthMiddleware,
|
||||
CaptchaMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_FORGOT_PASSWORD),
|
||||
Validator('json', ForgotPasswordRequest),
|
||||
OpenAPI({
|
||||
operationId: 'forgot_password',
|
||||
summary: 'Forgot password',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
"Initiate password reset process by email. A password reset link will be sent to the user's email address. Requires CAPTCHA verification.",
|
||||
}),
|
||||
async (ctx) => {
|
||||
await ctx.get('authRequestService').forgotPassword({
|
||||
data: ctx.req.valid('json'),
|
||||
request: ctx.req.raw,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/reset',
|
||||
LocalAuthMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_RESET_PASSWORD),
|
||||
Validator('json', ResetPasswordRequest),
|
||||
OpenAPI({
|
||||
operationId: 'reset_password',
|
||||
summary: 'Reset password',
|
||||
responseSchema: AuthLoginResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Complete the password reset flow using the token from the reset email. Returns authentication token after successful password reset.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const result = await ctx.get('authRequestService').resetPassword({
|
||||
data: ctx.req.valid('json'),
|
||||
request: ctx.req.raw,
|
||||
});
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/email-revert',
|
||||
LocalAuthMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_EMAIL_REVERT),
|
||||
Validator('json', EmailRevertRequest),
|
||||
OpenAPI({
|
||||
operationId: 'revert_email_change',
|
||||
summary: 'Revert email change',
|
||||
responseSchema: AuthLoginResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Revert a pending email change using the verification token sent to the old email. Returns authentication token after successful revert.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const result = await ctx.get('authRequestService').revertEmailChange({
|
||||
data: ctx.req.valid('json'),
|
||||
request: ctx.req.raw,
|
||||
});
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/auth/sessions',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_SESSIONS_GET),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'list_auth_sessions',
|
||||
summary: 'List auth sessions',
|
||||
responseSchema: AuthSessionsResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Auth'],
|
||||
description: 'Retrieve all active authentication sessions for the current user. Requires authentication.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
return ctx.json(await ctx.get('authRequestService').getAuthSessions(userId));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/sessions/logout',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_SESSIONS_LOGOUT),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', LogoutAuthSessionsRequest.merge(SudoVerificationSchema)),
|
||||
OpenAPI({
|
||||
operationId: 'logout_all_sessions',
|
||||
summary: 'Logout all sessions',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Invalidate all active authentication sessions for the current user. Requires sudo mode verification for security.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('authRequestService').logoutAuthSessions({user, data: body});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/authorize-ip',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_AUTHORIZE_IP),
|
||||
Validator('json', AuthorizeIpRequest),
|
||||
OpenAPI({
|
||||
operationId: 'authorize_ip_address',
|
||||
summary: 'Authorize IP address',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Verify and authorize a new IP address using the confirmation code sent via email. Completes IP authorization flow.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await ctx.get('authRequestService').completeIpAuthorization({data: ctx.req.valid('json')});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/ip-authorization/resend',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_IP_AUTHORIZATION_RESEND),
|
||||
Validator('json', MfaTicketRequest),
|
||||
OpenAPI({
|
||||
operationId: 'resend_ip_authorization',
|
||||
summary: 'Resend IP authorization',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Request a new IP authorization verification code to be sent via email. Use if the original code was lost or expired.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await ctx.get('authRequestService').resendIpAuthorization(ctx.req.valid('json'));
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/auth/ip-authorization/poll',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_IP_AUTHORIZATION_POLL),
|
||||
Validator('query', IpAuthorizationPollQuery),
|
||||
OpenAPI({
|
||||
operationId: 'poll_ip_authorization',
|
||||
summary: 'Poll IP authorization',
|
||||
responseSchema: IpAuthorizationPollResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Poll the status of an IP authorization request. Use the ticket parameter to check if verification has been completed.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {ticket} = ctx.req.valid('query');
|
||||
return ctx.json(await ctx.get('authRequestService').pollIpAuthorization({ticket}));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/webauthn/authentication-options',
|
||||
LocalAuthMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_WEBAUTHN_OPTIONS),
|
||||
OpenAPI({
|
||||
operationId: 'get_webauthn_authentication_options',
|
||||
summary: 'Get WebAuthn authentication options',
|
||||
responseSchema: WebAuthnAuthenticationOptionsResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Retrieve WebAuthn authentication challenge and options for passwordless login with biometrics or security keys.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await ctx.get('authRequestService').getWebAuthnAuthenticationOptions());
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/webauthn/authenticate',
|
||||
LocalAuthMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_WEBAUTHN_AUTHENTICATE),
|
||||
Validator('json', WebAuthnAuthenticateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'authenticate_with_webauthn',
|
||||
summary: 'Authenticate with WebAuthn',
|
||||
responseSchema: AuthTokenWithUserIdResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Complete passwordless login using WebAuthn (biometrics or security key). Returns authentication token on success.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(
|
||||
await ctx.get('authRequestService').authenticateWebAuthnDiscoverable({
|
||||
data: ctx.req.valid('json'),
|
||||
request: ctx.req.raw,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/login/mfa/webauthn/authentication-options',
|
||||
LocalAuthMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN_MFA),
|
||||
Validator('json', MfaTicketRequest),
|
||||
OpenAPI({
|
||||
operationId: 'get_webauthn_mfa_options',
|
||||
summary: 'Get WebAuthn MFA options',
|
||||
responseSchema: WebAuthnAuthenticationOptionsResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Retrieve WebAuthn challenge and options for multi-factor authentication. Requires the MFA ticket from initial login.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await ctx.get('authRequestService').getWebAuthnMfaOptions(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/login/mfa/webauthn',
|
||||
LocalAuthMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN_MFA),
|
||||
Validator('json', WebAuthnMfaRequest),
|
||||
OpenAPI({
|
||||
operationId: 'login_with_webauthn_mfa',
|
||||
summary: 'Login with WebAuthn MFA',
|
||||
responseSchema: AuthTokenWithUserIdResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Complete login by verifying WebAuthn response during MFA. Requires the MFA ticket from initial login attempt.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const result = await ctx.get('authRequestService').loginMfaWebAuthn({
|
||||
data: ctx.req.valid('json'),
|
||||
request: ctx.req.raw,
|
||||
});
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/username-suggestions',
|
||||
LocalAuthMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_REGISTER),
|
||||
Validator('json', UsernameSuggestionsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'get_username_suggestions',
|
||||
summary: 'Get username suggestions',
|
||||
responseSchema: UsernameSuggestionsResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description: 'Generate username suggestions based on a provided global name for new account registration.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const response = ctx.get('authRequestService').getUsernameSuggestions({
|
||||
globalName: ctx.req.valid('json').global_name,
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/handoff/initiate',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_INITIATE),
|
||||
OpenAPI({
|
||||
operationId: 'initiate_handoff',
|
||||
summary: 'Initiate handoff',
|
||||
responseSchema: HandoffInitiateResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await ctx.get('authRequestService').initiateHandoff({userAgent: ctx.req.header('User-Agent')}));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/handoff/complete',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_COMPLETE),
|
||||
Validator('json', HandoffCompleteRequest),
|
||||
OpenAPI({
|
||||
operationId: 'complete_handoff',
|
||||
summary: 'Complete handoff',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description: 'Complete the handoff process and authenticate on the target device using the handoff code.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await ctx.get('authRequestService').completeHandoff({data: ctx.req.valid('json'), request: ctx.req.raw});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/auth/handoff/:code/status',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_STATUS),
|
||||
Validator('param', HandoffCodeParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_handoff_status',
|
||||
summary: 'Get handoff status',
|
||||
responseSchema: HandoffStatusResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Check the status of a handoff session. Returns whether the handoff has been completed or is still pending.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const response = await ctx.get('authRequestService').getHandoffStatus({code: ctx.req.valid('param').code});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/auth/handoff/:code',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_CANCEL),
|
||||
Validator('param', HandoffCodeParam),
|
||||
OpenAPI({
|
||||
operationId: 'cancel_handoff',
|
||||
summary: 'Cancel handoff',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description: 'Cancel an ongoing handoff session. The handoff code will no longer be valid for authentication.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await ctx.get('authRequestService').cancelHandoff({code: ctx.req.valid('param').code});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
}
|
||||
93
packages/api/src/auth/AuthModel.tsx
Normal file
93
packages/api/src/auth/AuthModel.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
|
||||
import {getLocationLabelFromIp} from '@fluxer/api/src/utils/IpUtils';
|
||||
import {resolveSessionClientInfo} from '@fluxer/api/src/utils/UserAgentUtils';
|
||||
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {uint8ArrayToBase64} from 'uint8array-extras';
|
||||
|
||||
async function resolveAuthSessionLocation(session: AuthSession): Promise<string | null> {
|
||||
try {
|
||||
return await getLocationLabelFromIp(session.clientIp);
|
||||
} catch (error) {
|
||||
Logger.warn({error, clientIp: session.clientIp}, 'Failed to resolve location from IP');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function mapAuthSessionsToResponse({
|
||||
authSessions,
|
||||
currentSessionId,
|
||||
}: {
|
||||
authSessions: Array<AuthSession>;
|
||||
currentSessionId?: Uint8Array;
|
||||
}): Promise<Array<AuthSessionResponse>> {
|
||||
const sortedSessions = authSessions.toSorted((a, b) => {
|
||||
const aTime = a.approximateLastUsedAt?.getTime() || 0;
|
||||
const bTime = b.approximateLastUsedAt?.getTime() || 0;
|
||||
return bTime - aTime;
|
||||
});
|
||||
|
||||
const locationResults = await Promise.allSettled(
|
||||
sortedSessions.map((session) => resolveAuthSessionLocation(session)),
|
||||
);
|
||||
|
||||
return sortedSessions.map((authSession, index): AuthSessionResponse => {
|
||||
const locationResult = locationResults[index];
|
||||
const clientLocation = locationResult?.status === 'fulfilled' ? locationResult.value : null;
|
||||
|
||||
let clientOs: string;
|
||||
let clientPlatform: string;
|
||||
|
||||
if (authSession.clientUserAgent) {
|
||||
const parsed = resolveSessionClientInfo({
|
||||
userAgent: authSession.clientUserAgent,
|
||||
isDesktopClient: authSession.clientIsDesktop,
|
||||
});
|
||||
clientOs = parsed.clientOs;
|
||||
clientPlatform = parsed.clientPlatform;
|
||||
} else {
|
||||
clientOs = authSession.clientOs || 'Unknown';
|
||||
clientPlatform = authSession.clientPlatform || 'Unknown';
|
||||
}
|
||||
|
||||
const idHash = uint8ArrayToBase64(authSession.sessionIdHash, {urlSafe: true});
|
||||
const isCurrent = currentSessionId ? Buffer.compare(authSession.sessionIdHash, currentSessionId) === 0 : false;
|
||||
|
||||
return {
|
||||
id_hash: idHash,
|
||||
client_info: {
|
||||
platform: clientPlatform,
|
||||
os: clientOs,
|
||||
browser: undefined,
|
||||
location: clientLocation
|
||||
? {
|
||||
city: clientLocation.split(',').at(0)?.trim() || null,
|
||||
region: clientLocation.split(',').at(1)?.trim() || null,
|
||||
country: clientLocation.split(',').at(2)?.trim() || null,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
approx_last_used_at: authSession.approximateLastUsedAt?.toISOString() || null,
|
||||
current: isCurrent,
|
||||
};
|
||||
});
|
||||
}
|
||||
321
packages/api/src/auth/AuthRequestService.tsx
Normal file
321
packages/api/src/auth/AuthRequestService.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
|
||||
import type {DesktopHandoffService} from '@fluxer/api/src/auth/services/DesktopHandoffService';
|
||||
import type {SsoService} from '@fluxer/api/src/auth/services/SsoService';
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {generateUsernameSuggestions} from '@fluxer/api/src/utils/UsernameSuggestionUtils';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {
|
||||
AuthLoginResponse,
|
||||
AuthorizeIpRequest,
|
||||
AuthRegisterResponse,
|
||||
AuthSessionsResponse,
|
||||
AuthTokenWithUserIdResponse,
|
||||
EmailRevertRequest,
|
||||
ForgotPasswordRequest,
|
||||
HandoffCompleteRequest,
|
||||
HandoffInitiateResponse,
|
||||
HandoffStatusResponse,
|
||||
IpAuthorizationPollResponse,
|
||||
LoginRequest,
|
||||
LogoutAuthSessionsRequest,
|
||||
MfaTicketRequest,
|
||||
RegisterRequest,
|
||||
ResetPasswordRequest,
|
||||
SsoCompleteRequest,
|
||||
SsoStartRequest,
|
||||
UsernameSuggestionsResponse,
|
||||
VerifyEmailRequest,
|
||||
WebAuthnAuthenticateRequest,
|
||||
WebAuthnMfaRequest,
|
||||
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
|
||||
interface AuthRegisterRequest {
|
||||
data: RegisterRequest;
|
||||
request: Request;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
interface AuthLoginRequest {
|
||||
data: LoginRequest;
|
||||
request: Request;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
interface AuthForgotPasswordRequest {
|
||||
data: ForgotPasswordRequest;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface AuthResetPasswordRequest {
|
||||
data: ResetPasswordRequest;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface AuthRevertEmailChangeRequest {
|
||||
data: EmailRevertRequest;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface AuthLoginMfaRequest {
|
||||
code: string;
|
||||
ticket: string;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface AuthLogoutRequest {
|
||||
authorizationHeader?: string;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
interface AuthHandoffCompleteRequest {
|
||||
data: HandoffCompleteRequest;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface AuthAuthorizeIpRequest {
|
||||
data: AuthorizeIpRequest;
|
||||
}
|
||||
|
||||
interface AuthUsernameSuggestionsRequest {
|
||||
globalName: string;
|
||||
}
|
||||
|
||||
interface AuthPollIpRequest {
|
||||
ticket: string;
|
||||
}
|
||||
|
||||
interface AuthWebAuthnAuthenticateRequest {
|
||||
data: WebAuthnAuthenticateRequest;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface AuthWebAuthnMfaRequest {
|
||||
data: WebAuthnMfaRequest;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface AuthLogoutAuthSessionsRequest {
|
||||
user: User;
|
||||
data: LogoutAuthSessionsRequest;
|
||||
}
|
||||
|
||||
interface AuthHandoffInitiateRequest {
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
interface AuthHandoffStatusRequest {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export class AuthRequestService {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private ssoService: SsoService,
|
||||
private cacheService: ICacheService,
|
||||
private desktopHandoffService: DesktopHandoffService,
|
||||
) {}
|
||||
|
||||
getSsoStatus() {
|
||||
return this.ssoService.getPublicStatus();
|
||||
}
|
||||
|
||||
startSso(data: SsoStartRequest) {
|
||||
return this.ssoService.startLogin(data.redirect_to ?? undefined);
|
||||
}
|
||||
|
||||
completeSso(data: SsoCompleteRequest, request: Request) {
|
||||
return this.ssoService.completeLogin({code: data.code, state: data.state, request});
|
||||
}
|
||||
|
||||
async register({data, request, requestCache}: AuthRegisterRequest): Promise<AuthRegisterResponse> {
|
||||
const result = await this.authService.register({data, request, requestCache});
|
||||
return this.toAuthLoginResponse(result);
|
||||
}
|
||||
|
||||
async login({data, request, requestCache}: AuthLoginRequest): Promise<AuthLoginResponse> {
|
||||
const result = await this.authService.login({data, request, requestCache});
|
||||
return this.toAuthLoginResponse(result);
|
||||
}
|
||||
|
||||
loginMfaTotp({code, ticket, request}: AuthLoginMfaRequest): Promise<AuthTokenWithUserIdResponse> {
|
||||
return this.authService.loginMfaTotp({code, ticket, request});
|
||||
}
|
||||
|
||||
async sendSmsMfaCodeForTicket({ticket}: MfaTicketRequest): Promise<void> {
|
||||
await this.authService.sendSmsMfaCodeForTicket(ticket);
|
||||
}
|
||||
|
||||
loginMfaSms({code, ticket, request}: AuthLoginMfaRequest): Promise<AuthTokenWithUserIdResponse> {
|
||||
return this.authService.loginMfaSms({code, ticket, request});
|
||||
}
|
||||
|
||||
async logout({authorizationHeader, authToken}: AuthLogoutRequest): Promise<void> {
|
||||
const token = authorizationHeader ?? authToken;
|
||||
if (token) {
|
||||
await this.authService.revokeToken(token);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyEmail(data: VerifyEmailRequest): Promise<void> {
|
||||
const success = await this.authService.verifyEmail(data);
|
||||
if (!success) {
|
||||
throw InputValidationError.fromCode('token', ValidationErrorCodes.INVALID_OR_EXPIRED_VERIFICATION_TOKEN);
|
||||
}
|
||||
}
|
||||
|
||||
async resendVerificationEmail(user: User): Promise<void> {
|
||||
await this.authService.resendVerificationEmail(user);
|
||||
}
|
||||
|
||||
async forgotPassword({data, request}: AuthForgotPasswordRequest): Promise<void> {
|
||||
await this.authService.forgotPassword({data, request});
|
||||
}
|
||||
|
||||
async resetPassword({data, request}: AuthResetPasswordRequest): Promise<AuthLoginResponse> {
|
||||
const result = await this.authService.resetPassword({data, request});
|
||||
return this.toAuthLoginResponse(result);
|
||||
}
|
||||
|
||||
revertEmailChange({data, request}: AuthRevertEmailChangeRequest): Promise<AuthLoginResponse> {
|
||||
return this.authService.revertEmailChange({data, request});
|
||||
}
|
||||
|
||||
getAuthSessions(userId: UserID): Promise<AuthSessionsResponse> {
|
||||
return this.authService.getAuthSessions(userId);
|
||||
}
|
||||
|
||||
async logoutAuthSessions({user, data}: AuthLogoutAuthSessionsRequest): Promise<void> {
|
||||
await this.authService.logoutAuthSessions({
|
||||
user,
|
||||
sessionIdHashes: data.session_id_hashes,
|
||||
});
|
||||
}
|
||||
|
||||
async completeIpAuthorization({data}: AuthAuthorizeIpRequest): Promise<void> {
|
||||
const result = await this.authService.completeIpAuthorization(data.token);
|
||||
const payload = JSON.stringify({token: result.token, user_id: result.user_id});
|
||||
await this.cacheService.set(`ip-auth-result:${result.ticket}`, payload, 60);
|
||||
}
|
||||
|
||||
async resendIpAuthorization({ticket}: MfaTicketRequest): Promise<void> {
|
||||
await this.authService.resendIpAuthorization(ticket);
|
||||
}
|
||||
|
||||
async pollIpAuthorization({ticket}: AuthPollIpRequest): Promise<IpAuthorizationPollResponse> {
|
||||
const result = await this.cacheService.get<string>(`ip-auth-result:${ticket}`);
|
||||
if (result) {
|
||||
const parsed = JSON.parse(result) as {token: string; user_id: string};
|
||||
return {
|
||||
completed: true,
|
||||
token: parsed.token,
|
||||
user_id: parsed.user_id,
|
||||
};
|
||||
}
|
||||
|
||||
const ticketPayload = await this.cacheService.get(`ip-auth-ticket:${ticket}`);
|
||||
if (!ticketPayload) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.INVALID_OR_EXPIRED_AUTHORIZATION_TICKET);
|
||||
}
|
||||
|
||||
return {completed: false};
|
||||
}
|
||||
|
||||
async getWebAuthnAuthenticationOptions() {
|
||||
return this.authService.generateWebAuthnAuthenticationOptionsDiscoverable();
|
||||
}
|
||||
|
||||
async authenticateWebAuthnDiscoverable({data, request}: AuthWebAuthnAuthenticateRequest) {
|
||||
const user = await this.authService.verifyWebAuthnAuthenticationDiscoverable(data.response, data.challenge);
|
||||
const [token] = await this.authService.createAuthSession({user, request});
|
||||
return {token, user_id: user.id.toString()};
|
||||
}
|
||||
|
||||
async getWebAuthnMfaOptions({ticket}: MfaTicketRequest) {
|
||||
return this.authService.generateWebAuthnAuthenticationOptionsForMfa(ticket);
|
||||
}
|
||||
|
||||
loginMfaWebAuthn({data, request}: AuthWebAuthnMfaRequest): Promise<AuthTokenWithUserIdResponse> {
|
||||
return this.authService.loginMfaWebAuthn({
|
||||
response: data.response,
|
||||
challenge: data.challenge,
|
||||
ticket: data.ticket,
|
||||
request,
|
||||
});
|
||||
}
|
||||
|
||||
getUsernameSuggestions({globalName}: AuthUsernameSuggestionsRequest): UsernameSuggestionsResponse {
|
||||
return {suggestions: generateUsernameSuggestions(globalName)};
|
||||
}
|
||||
|
||||
async initiateHandoff({userAgent}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
|
||||
const result = await this.desktopHandoffService.initiateHandoff(userAgent);
|
||||
return {
|
||||
code: result.code,
|
||||
expires_at: result.expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async completeHandoff({data, request}: AuthHandoffCompleteRequest): Promise<void> {
|
||||
const {token: handoffToken, userId} = await this.authService.createAdditionalAuthSessionFromToken({
|
||||
token: data.token,
|
||||
expectedUserId: data.user_id,
|
||||
request,
|
||||
});
|
||||
|
||||
await this.desktopHandoffService.completeHandoff(data.code, handoffToken, userId);
|
||||
}
|
||||
|
||||
async getHandoffStatus({code}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
|
||||
const result = await this.desktopHandoffService.getHandoffStatus(code);
|
||||
return {
|
||||
status: result.status,
|
||||
token: result.token,
|
||||
user_id: result.userId,
|
||||
};
|
||||
}
|
||||
|
||||
async cancelHandoff({code}: AuthHandoffStatusRequest): Promise<void> {
|
||||
await this.desktopHandoffService.cancelHandoff(code);
|
||||
}
|
||||
|
||||
private toAuthLoginResponse(
|
||||
result:
|
||||
| {user_id: string; token: string}
|
||||
| {mfa: true; ticket: string; allowed_methods: Array<string>; sms_phone_hint: string | null},
|
||||
): AuthLoginResponse {
|
||||
if (!('mfa' in result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const allowedMethods = new Set(result.allowed_methods);
|
||||
return {
|
||||
...result,
|
||||
sms: allowedMethods.has('sms'),
|
||||
totp: allowedMethods.has('totp'),
|
||||
webauthn: allowedMethods.has('webauthn'),
|
||||
};
|
||||
}
|
||||
}
|
||||
602
packages/api/src/auth/AuthService.tsx
Normal file
602
packages/api/src/auth/AuthService.tsx
Normal file
@@ -0,0 +1,602 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {AuthEmailRevertService} from '@fluxer/api/src/auth/services/AuthEmailRevertService';
|
||||
import {AuthEmailService} from '@fluxer/api/src/auth/services/AuthEmailService';
|
||||
import {AuthLoginService} from '@fluxer/api/src/auth/services/AuthLoginService';
|
||||
import {AuthMfaService} from '@fluxer/api/src/auth/services/AuthMfaService';
|
||||
import {AuthPasswordService} from '@fluxer/api/src/auth/services/AuthPasswordService';
|
||||
import {AuthPhoneService} from '@fluxer/api/src/auth/services/AuthPhoneService';
|
||||
import {AuthRegistrationService} from '@fluxer/api/src/auth/services/AuthRegistrationService';
|
||||
import {AuthSessionService} from '@fluxer/api/src/auth/services/AuthSessionService';
|
||||
import {AuthUtilityService} from '@fluxer/api/src/auth/services/AuthUtilityService';
|
||||
import {createMfaTicket, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {KVAccountDeletionQueueService} from '@fluxer/api/src/infrastructure/KVAccountDeletionQueueService';
|
||||
import type {KVActivityTracker} from '@fluxer/api/src/infrastructure/KVActivityTracker';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {SnowflakeReservationService} from '@fluxer/api/src/instance/SnowflakeReservationService';
|
||||
import type {InviteService} from '@fluxer/api/src/invite/InviteService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {BotMfaMirrorService} from '@fluxer/api/src/oauth/BotMfaMirrorService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {UserContactChangeLogService} from '@fluxer/api/src/user/services/UserContactChangeLogService';
|
||||
import {randomString} from '@fluxer/api/src/utils/RandomUtils';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
import {SessionTokenMismatchError} from '@fluxer/errors/src/domains/auth/SessionTokenMismatchError';
|
||||
import {InvalidTokenError} from '@fluxer/errors/src/domains/core/InvalidTokenError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
|
||||
import type {
|
||||
AuthSessionResponse,
|
||||
EmailRevertRequest,
|
||||
ForgotPasswordRequest,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
ResetPasswordRequest,
|
||||
VerifyEmailRequest,
|
||||
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import type {ISmsService} from '@fluxer/sms/src/ISmsService';
|
||||
import type {AuthenticationResponseJSON, RegistrationResponseJSON} from '@simplewebauthn/server';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
interface RegisterParams {
|
||||
data: RegisterRequest;
|
||||
request: Request;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
interface LoginParams {
|
||||
data: LoginRequest;
|
||||
request: Request;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
interface LoginMfaTotpParams {
|
||||
code: string;
|
||||
ticket: string;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface ForgotPasswordParams {
|
||||
data: ForgotPasswordRequest;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface ResetPasswordParams {
|
||||
data: ResetPasswordRequest;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface RevertEmailChangeParams {
|
||||
data: EmailRevertRequest;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface LogoutAuthSessionsParams {
|
||||
user: User;
|
||||
sessionIdHashes: Array<string>;
|
||||
}
|
||||
|
||||
interface CreateAuthSessionParams {
|
||||
user: User;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface DispatchAuthSessionChangeParams {
|
||||
userId: UserID;
|
||||
oldAuthSessionIdHash: string;
|
||||
newAuthSessionIdHash: string;
|
||||
newToken: string;
|
||||
}
|
||||
|
||||
interface VerifyPasswordParams {
|
||||
password: string;
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
interface VerifyMfaCodeParams {
|
||||
userId: UserID;
|
||||
mfaSecret: string;
|
||||
code: string;
|
||||
allowBackup?: boolean;
|
||||
}
|
||||
|
||||
interface UpdateUserActivityParams {
|
||||
userId: UserID;
|
||||
clientIp: string;
|
||||
}
|
||||
|
||||
interface ValidateAgeParams {
|
||||
dateOfBirth: string;
|
||||
minAge: number;
|
||||
}
|
||||
|
||||
interface CheckEmailChangeRateLimitParams {
|
||||
userId: UserID;
|
||||
}
|
||||
|
||||
interface IAuthService {
|
||||
verifyPassword(params: {password: string; passwordHash: string}): Promise<boolean>;
|
||||
getUserSession(requestCache: RequestCache, token: string): Promise<AuthSession>;
|
||||
}
|
||||
|
||||
export class AuthService implements IAuthService {
|
||||
private sessionService: AuthSessionService;
|
||||
private passwordService: AuthPasswordService;
|
||||
private registrationService: AuthRegistrationService;
|
||||
private loginService: AuthLoginService;
|
||||
private emailService: AuthEmailService;
|
||||
private emailRevertService: AuthEmailRevertService;
|
||||
private phoneService: AuthPhoneService;
|
||||
private mfaService: AuthMfaService;
|
||||
private utilityService: AuthUtilityService;
|
||||
|
||||
constructor(
|
||||
private repository: IUserRepository,
|
||||
inviteService: InviteService,
|
||||
private cacheService: ICacheService,
|
||||
gatewayService: IGatewayService,
|
||||
rateLimitService: IRateLimitService,
|
||||
emailServiceDep: IEmailService,
|
||||
smsService: ISmsService,
|
||||
snowflakeService: SnowflakeService,
|
||||
snowflakeReservationService: SnowflakeReservationService,
|
||||
discriminatorService: IDiscriminatorService,
|
||||
kvAccountDeletionQueue: KVAccountDeletionQueueService,
|
||||
kvActivityTracker: KVActivityTracker,
|
||||
private readonly contactChangeLogService: UserContactChangeLogService,
|
||||
botMfaMirrorService?: BotMfaMirrorService,
|
||||
authMfaService?: AuthMfaService,
|
||||
) {
|
||||
this.utilityService = new AuthUtilityService(repository, rateLimitService);
|
||||
|
||||
this.sessionService = new AuthSessionService(
|
||||
repository,
|
||||
gatewayService,
|
||||
this.utilityService.generateAuthToken.bind(this.utilityService),
|
||||
this.utilityService.getTokenIdHash.bind(this.utilityService),
|
||||
);
|
||||
|
||||
this.passwordService = new AuthPasswordService(
|
||||
repository,
|
||||
emailServiceDep,
|
||||
rateLimitService,
|
||||
this.utilityService.generateSecureToken.bind(this.utilityService),
|
||||
this.utilityService.handleBanStatus.bind(this.utilityService),
|
||||
this.utilityService.assertNonBotUser.bind(this.utilityService),
|
||||
this.createMfaTicketResponse.bind(this),
|
||||
this.sessionService.createAuthSession.bind(this.sessionService),
|
||||
);
|
||||
|
||||
this.mfaService =
|
||||
authMfaService ?? new AuthMfaService(repository, cacheService, smsService, gatewayService, botMfaMirrorService);
|
||||
|
||||
this.registrationService = new AuthRegistrationService(
|
||||
repository,
|
||||
inviteService,
|
||||
rateLimitService,
|
||||
emailServiceDep,
|
||||
snowflakeService,
|
||||
snowflakeReservationService,
|
||||
discriminatorService,
|
||||
kvActivityTracker,
|
||||
cacheService,
|
||||
this.passwordService.hashPassword.bind(this.passwordService),
|
||||
this.passwordService.isPasswordPwned.bind(this.passwordService),
|
||||
this.utilityService.validateAge.bind(this.utilityService),
|
||||
this.utilityService.generateSecureToken.bind(this.utilityService),
|
||||
this.sessionService.createAuthSession.bind(this.sessionService),
|
||||
);
|
||||
|
||||
this.loginService = new AuthLoginService(
|
||||
repository,
|
||||
inviteService,
|
||||
cacheService,
|
||||
rateLimitService,
|
||||
emailServiceDep,
|
||||
kvAccountDeletionQueue,
|
||||
this.passwordService.verifyPassword.bind(this.passwordService),
|
||||
this.utilityService.handleBanStatus.bind(this.utilityService),
|
||||
this.utilityService.assertNonBotUser.bind(this.utilityService),
|
||||
this.sessionService.createAuthSession.bind(this.sessionService),
|
||||
this.utilityService.generateSecureToken.bind(this.utilityService),
|
||||
this.mfaService.verifyMfaCode.bind(this.mfaService),
|
||||
this.mfaService.verifySmsMfaCode.bind(this.mfaService),
|
||||
this.mfaService.verifyWebAuthnAuthentication.bind(this.mfaService),
|
||||
);
|
||||
|
||||
this.emailService = new AuthEmailService(
|
||||
repository,
|
||||
emailServiceDep,
|
||||
gatewayService,
|
||||
rateLimitService,
|
||||
this.utilityService.assertNonBotUser.bind(this.utilityService),
|
||||
this.utilityService.generateSecureToken.bind(this.utilityService),
|
||||
);
|
||||
|
||||
this.emailRevertService = new AuthEmailRevertService(
|
||||
repository,
|
||||
emailServiceDep,
|
||||
gatewayService,
|
||||
this.passwordService.hashPassword.bind(this.passwordService),
|
||||
this.passwordService.isPasswordPwned.bind(this.passwordService),
|
||||
this.utilityService.handleBanStatus.bind(this.utilityService),
|
||||
this.utilityService.assertNonBotUser.bind(this.utilityService),
|
||||
this.utilityService.generateSecureToken.bind(this.utilityService),
|
||||
this.sessionService.createAuthSession.bind(this.sessionService),
|
||||
this.sessionService.terminateAllUserSessions.bind(this.sessionService),
|
||||
this.contactChangeLogService!,
|
||||
);
|
||||
|
||||
this.phoneService = new AuthPhoneService(
|
||||
repository,
|
||||
smsService,
|
||||
gatewayService,
|
||||
this.utilityService.assertNonBotUser.bind(this.utilityService),
|
||||
this.utilityService.generateSecureToken.bind(this.utilityService),
|
||||
this.contactChangeLogService!,
|
||||
);
|
||||
}
|
||||
|
||||
async register({data, request, requestCache}: RegisterParams): Promise<{user_id: string; token: string}> {
|
||||
return this.registrationService.register({data, request, requestCache});
|
||||
}
|
||||
|
||||
async login({
|
||||
data,
|
||||
request,
|
||||
requestCache: _requestCache,
|
||||
}: LoginParams): Promise<
|
||||
| {user_id: string; token: string}
|
||||
| {mfa: true; ticket: string; allowed_methods: Array<string>; sms_phone_hint: string | null}
|
||||
> {
|
||||
return this.loginService.login({data, request});
|
||||
}
|
||||
|
||||
async loginMfaTotp({code, ticket, request}: LoginMfaTotpParams): Promise<{user_id: string; token: string}> {
|
||||
return this.loginService.loginMfaTotp({code, ticket, request});
|
||||
}
|
||||
|
||||
async loginMfaSms({
|
||||
code,
|
||||
ticket,
|
||||
request,
|
||||
}: {
|
||||
code: string;
|
||||
ticket: string;
|
||||
request: Request;
|
||||
}): Promise<{user_id: string; token: string}> {
|
||||
return this.loginService.loginMfaSms({code, ticket, request});
|
||||
}
|
||||
|
||||
async loginMfaWebAuthn({
|
||||
response,
|
||||
challenge,
|
||||
ticket,
|
||||
request,
|
||||
}: {
|
||||
response: AuthenticationResponseJSON;
|
||||
challenge: string;
|
||||
ticket: string;
|
||||
request: Request;
|
||||
}): Promise<{user_id: string; token: string}> {
|
||||
return this.loginService.loginMfaWebAuthn({response, challenge, ticket, request});
|
||||
}
|
||||
|
||||
async forgotPassword({data, request}: ForgotPasswordParams): Promise<void> {
|
||||
return this.passwordService.forgotPassword({data, request});
|
||||
}
|
||||
|
||||
async resetPassword({data, request}: ResetPasswordParams): Promise<
|
||||
| {user_id: string; token: string}
|
||||
| {
|
||||
mfa: true;
|
||||
ticket: string;
|
||||
allowed_methods: Array<string>;
|
||||
sms_phone_hint: string | null;
|
||||
sms: boolean;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
}
|
||||
> {
|
||||
return this.passwordService.resetPassword({data, request});
|
||||
}
|
||||
|
||||
async revertEmailChange({data, request}: RevertEmailChangeParams): Promise<{user_id: string; token: string}> {
|
||||
return this.emailRevertService.revertEmailChange({
|
||||
token: data.token,
|
||||
password: data.password,
|
||||
request,
|
||||
});
|
||||
}
|
||||
|
||||
async issueEmailRevertToken(user: User, previousEmail: string, newEmail: string): Promise<void> {
|
||||
return this.emailRevertService.issueRevertToken({user, previousEmail, newEmail});
|
||||
}
|
||||
|
||||
async hashPassword(password: string): Promise<string> {
|
||||
return this.passwordService.hashPassword(password);
|
||||
}
|
||||
|
||||
async verifyPassword({password, passwordHash}: VerifyPasswordParams): Promise<boolean> {
|
||||
return this.passwordService.verifyPassword({password, passwordHash});
|
||||
}
|
||||
|
||||
async isPasswordPwned(password: string): Promise<boolean> {
|
||||
return this.passwordService.isPasswordPwned(password);
|
||||
}
|
||||
|
||||
async verifyEmail(data: VerifyEmailRequest): Promise<boolean> {
|
||||
return this.emailService.verifyEmail(data);
|
||||
}
|
||||
|
||||
async resendVerificationEmail(user: User): Promise<void> {
|
||||
return this.emailService.resendVerificationEmail(user);
|
||||
}
|
||||
|
||||
async getAuthSessionByToken(token: string): Promise<AuthSession | null> {
|
||||
return this.sessionService.getAuthSessionByToken(token);
|
||||
}
|
||||
|
||||
async getAuthSessions(userId: UserID): Promise<Array<AuthSessionResponse>> {
|
||||
return this.sessionService.getAuthSessions(userId);
|
||||
}
|
||||
|
||||
async createAdditionalAuthSessionFromToken({
|
||||
token,
|
||||
expectedUserId,
|
||||
request,
|
||||
}: {
|
||||
token: string;
|
||||
expectedUserId?: string;
|
||||
request: Request;
|
||||
}): Promise<{token: string; userId: string}> {
|
||||
const existingSession = await this.sessionService.getAuthSessionByToken(token);
|
||||
|
||||
if (!existingSession) {
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
|
||||
const user = await this.repository.findUnique(existingSession.userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if (expectedUserId && user.id.toString() !== expectedUserId) {
|
||||
throw new SessionTokenMismatchError();
|
||||
}
|
||||
|
||||
const [newToken] = await this.sessionService.createAuthSession({user, request});
|
||||
|
||||
return {token: newToken, userId: user.id.toString()};
|
||||
}
|
||||
|
||||
async createAuthSession({user, request}: CreateAuthSessionParams): Promise<[token: string, AuthSession]> {
|
||||
return this.sessionService.createAuthSession({user, request});
|
||||
}
|
||||
|
||||
async updateAuthSessionLastUsed(tokenHash: Uint8Array): Promise<void> {
|
||||
return this.sessionService.updateAuthSessionLastUsed(tokenHash);
|
||||
}
|
||||
|
||||
async updateUserActivity({userId, clientIp}: UpdateUserActivityParams): Promise<void> {
|
||||
return this.sessionService.updateUserActivity({userId, clientIp});
|
||||
}
|
||||
|
||||
async revokeToken(token: string): Promise<void> {
|
||||
return this.sessionService.revokeToken(token);
|
||||
}
|
||||
|
||||
async logoutAuthSessions({user, sessionIdHashes}: LogoutAuthSessionsParams): Promise<void> {
|
||||
return this.sessionService.logoutAuthSessions({user, sessionIdHashes});
|
||||
}
|
||||
|
||||
async terminateAllUserSessions(userId: UserID): Promise<void> {
|
||||
return this.sessionService.terminateAllUserSessions(userId);
|
||||
}
|
||||
|
||||
async dispatchAuthSessionChange({
|
||||
userId,
|
||||
oldAuthSessionIdHash,
|
||||
newAuthSessionIdHash,
|
||||
newToken,
|
||||
}: DispatchAuthSessionChangeParams): Promise<void> {
|
||||
return this.sessionService.dispatchAuthSessionChange({
|
||||
userId,
|
||||
oldAuthSessionIdHash,
|
||||
newAuthSessionIdHash,
|
||||
newToken,
|
||||
});
|
||||
}
|
||||
|
||||
async getUserSession(_requestCache: RequestCache, token: string): Promise<AuthSession> {
|
||||
const session = await this.getAuthSessionByToken(token);
|
||||
if (!session) {
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
async sendPhoneVerificationCode(phone: string, userId: UserID | null): Promise<void> {
|
||||
return this.phoneService.sendPhoneVerificationCode(phone, userId);
|
||||
}
|
||||
|
||||
async verifyPhoneCode(phone: string, code: string, userId: UserID | null): Promise<string> {
|
||||
return this.phoneService.verifyPhoneCode(phone, code, userId);
|
||||
}
|
||||
|
||||
async addPhoneToAccount(userId: UserID, phoneToken: string): Promise<void> {
|
||||
return this.phoneService.addPhoneToAccount(userId, phoneToken);
|
||||
}
|
||||
|
||||
async removePhoneFromAccount(userId: UserID): Promise<void> {
|
||||
return this.phoneService.removePhoneFromAccount(userId);
|
||||
}
|
||||
|
||||
async verifyMfaCode({userId, mfaSecret, code, allowBackup = false}: VerifyMfaCodeParams): Promise<boolean> {
|
||||
return this.mfaService.verifyMfaCode({userId, mfaSecret, code, allowBackup});
|
||||
}
|
||||
|
||||
async enableSmsMfa(userId: UserID): Promise<void> {
|
||||
return this.mfaService.enableSmsMfa(userId);
|
||||
}
|
||||
|
||||
async disableSmsMfa(userId: UserID): Promise<void> {
|
||||
return this.mfaService.disableSmsMfa(userId);
|
||||
}
|
||||
|
||||
async sendSmsMfaCode(userId: UserID): Promise<void> {
|
||||
return this.mfaService.sendSmsMfaCode(userId);
|
||||
}
|
||||
|
||||
async sendSmsMfaCodeForTicket(ticket: string): Promise<void> {
|
||||
return this.mfaService.sendSmsMfaCodeForTicket(ticket);
|
||||
}
|
||||
|
||||
async verifySmsMfaCode(userId: UserID, code: string): Promise<boolean> {
|
||||
return this.mfaService.verifySmsMfaCode(userId, code);
|
||||
}
|
||||
|
||||
async generateWebAuthnRegistrationOptions(userId: UserID) {
|
||||
return this.mfaService.generateWebAuthnRegistrationOptions(userId);
|
||||
}
|
||||
|
||||
async verifyWebAuthnRegistration(
|
||||
userId: UserID,
|
||||
response: RegistrationResponseJSON,
|
||||
expectedChallenge: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
return this.mfaService.verifyWebAuthnRegistration(userId, response, expectedChallenge, name);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
|
||||
return this.mfaService.deleteWebAuthnCredential(userId, credentialId);
|
||||
}
|
||||
|
||||
async renameWebAuthnCredential(userId: UserID, credentialId: string, name: string): Promise<void> {
|
||||
return this.mfaService.renameWebAuthnCredential(userId, credentialId, name);
|
||||
}
|
||||
|
||||
async generateWebAuthnAuthenticationOptionsDiscoverable() {
|
||||
return this.mfaService.generateWebAuthnAuthenticationOptionsDiscoverable();
|
||||
}
|
||||
|
||||
async verifyWebAuthnAuthenticationDiscoverable(
|
||||
response: AuthenticationResponseJSON,
|
||||
expectedChallenge: string,
|
||||
): Promise<User> {
|
||||
return this.mfaService.verifyWebAuthnAuthenticationDiscoverable(response, expectedChallenge);
|
||||
}
|
||||
|
||||
async generateWebAuthnAuthenticationOptionsForMfa(ticket: string) {
|
||||
return this.mfaService.generateWebAuthnAuthenticationOptionsForMfa(ticket);
|
||||
}
|
||||
|
||||
async verifyWebAuthnAuthentication(
|
||||
userId: UserID,
|
||||
response: AuthenticationResponseJSON,
|
||||
expectedChallenge: string,
|
||||
): Promise<void> {
|
||||
return this.mfaService.verifyWebAuthnAuthentication(userId, response, expectedChallenge);
|
||||
}
|
||||
|
||||
async generateSecureToken(length = 64): Promise<string> {
|
||||
return this.utilityService.generateSecureToken(length);
|
||||
}
|
||||
|
||||
async generateAuthToken(): Promise<string> {
|
||||
return this.utilityService.generateAuthToken();
|
||||
}
|
||||
|
||||
generateBackupCodes(): Array<string> {
|
||||
return this.utilityService.generateBackupCodes();
|
||||
}
|
||||
|
||||
async checkEmailChangeRateLimit({
|
||||
userId,
|
||||
}: CheckEmailChangeRateLimitParams): Promise<{allowed: boolean; retryAfter?: number}> {
|
||||
return this.utilityService.checkEmailChangeRateLimit({userId});
|
||||
}
|
||||
|
||||
validateAge({dateOfBirth, minAge}: ValidateAgeParams): boolean {
|
||||
return this.utilityService.validateAge({dateOfBirth, minAge});
|
||||
}
|
||||
|
||||
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
|
||||
return this.utilityService.authorizeIpByToken(token);
|
||||
}
|
||||
|
||||
async resendIpAuthorization(ticket: string): Promise<{retryAfter?: number}> {
|
||||
return this.loginService.resendIpAuthorization(ticket);
|
||||
}
|
||||
|
||||
async completeIpAuthorization(token: string): Promise<{token: string; user_id: string; ticket: string}> {
|
||||
return this.loginService.completeIpAuthorization(token);
|
||||
}
|
||||
|
||||
async createAuthSessionForUser(user: User, request: Request): Promise<{token: string; user_id: string}> {
|
||||
const [token] = await this.sessionService.createAuthSession({user, request});
|
||||
return {token, user_id: user.id.toString()};
|
||||
}
|
||||
|
||||
private async createMfaTicketResponse(user: User): Promise<{
|
||||
mfa: true;
|
||||
ticket: string;
|
||||
allowed_methods: Array<string>;
|
||||
sms_phone_hint: string | null;
|
||||
sms: boolean;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
}> {
|
||||
const ticket = createMfaTicket(randomString(64));
|
||||
await this.cacheService.set(`mfa-ticket:${ticket}`, user.id.toString(), seconds('5 minutes'));
|
||||
|
||||
const credentials = await this.repository.listWebAuthnCredentials(user.id);
|
||||
const hasSms = user.authenticatorTypes.has(UserAuthenticatorTypes.SMS);
|
||||
const hasWebauthn = credentials.length > 0;
|
||||
const hasTotp = user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP);
|
||||
|
||||
const allowedMethods: Array<string> = [];
|
||||
if (hasTotp) allowedMethods.push('totp');
|
||||
if (hasSms) allowedMethods.push('sms');
|
||||
if (hasWebauthn) allowedMethods.push('webauthn');
|
||||
|
||||
return {
|
||||
mfa: true,
|
||||
ticket: ticket,
|
||||
allowed_methods: allowedMethods,
|
||||
sms_phone_hint: user.phone ? this.maskPhone(user.phone) : null,
|
||||
sms: hasSms,
|
||||
totp: hasTotp,
|
||||
webauthn: hasWebauthn,
|
||||
};
|
||||
}
|
||||
|
||||
private maskPhone(phone: string): string {
|
||||
if (phone.length < 4) return '****';
|
||||
return `****${phone.slice(-4)}`;
|
||||
}
|
||||
}
|
||||
152
packages/api/src/auth/services/AuthEmailRevertService.tsx
Normal file
152
packages/api/src/auth/services/AuthEmailRevertService.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createEmailRevertToken} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {getUserSearchService} from '@fluxer/api/src/SearchFactory';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {UserContactChangeLogService} from '@fluxer/api/src/user/services/UserContactChangeLogService';
|
||||
import {mapUserToPrivateResponse} from '@fluxer/api/src/user/UserMappers';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
||||
|
||||
interface IssueTokenParams {
|
||||
user: User;
|
||||
previousEmail: string;
|
||||
newEmail: string;
|
||||
}
|
||||
|
||||
interface RevertParams {
|
||||
token: string;
|
||||
password: string;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
export class AuthEmailRevertService {
|
||||
constructor(
|
||||
private readonly repository: IUserRepository,
|
||||
private readonly emailService: IEmailService,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly hashPassword: (password: string) => Promise<string>,
|
||||
private readonly isPasswordPwned: (password: string) => Promise<boolean>,
|
||||
private readonly handleBanStatus: (user: User) => Promise<User>,
|
||||
private readonly assertNonBotUser: (user: User) => void,
|
||||
private readonly generateSecureToken: () => Promise<string>,
|
||||
private readonly createAuthSession: (params: {user: User; request: Request}) => Promise<[string, AuthSession]>,
|
||||
private readonly terminateAllUserSessions: (userId: UserID) => Promise<void>,
|
||||
private readonly contactChangeLogService: UserContactChangeLogService,
|
||||
) {}
|
||||
|
||||
async issueRevertToken(params: IssueTokenParams): Promise<void> {
|
||||
const {user, previousEmail, newEmail} = params;
|
||||
const trimmed = previousEmail.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const token = createEmailRevertToken(await this.generateSecureToken());
|
||||
await this.repository.createEmailRevertToken({
|
||||
token_: token,
|
||||
user_id: user.id,
|
||||
email: trimmed,
|
||||
});
|
||||
|
||||
await this.emailService.sendEmailChangeRevert(trimmed, user.username, newEmail, token, user.locale);
|
||||
}
|
||||
|
||||
async revertEmailChange(params: RevertParams): Promise<{user_id: string; token: string}> {
|
||||
const {token, password, request} = params;
|
||||
const tokenData = await this.repository.getEmailRevertToken(token);
|
||||
if (!tokenData) {
|
||||
throw InputValidationError.fromCode('token', ValidationErrorCodes.INVALID_OR_EXPIRED_REVERT_TOKEN);
|
||||
}
|
||||
|
||||
const user = await this.repository.findUnique(tokenData.userId);
|
||||
if (!user) {
|
||||
throw InputValidationError.fromCode('token', ValidationErrorCodes.INVALID_OR_EXPIRED_REVERT_TOKEN);
|
||||
}
|
||||
|
||||
this.assertNonBotUser(user);
|
||||
await this.handleBanStatus(user);
|
||||
|
||||
if (await this.isPasswordPwned(password)) {
|
||||
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
|
||||
}
|
||||
|
||||
const passwordHash = await this.hashPassword(password);
|
||||
const now = new Date();
|
||||
|
||||
const updatedUser = await this.repository.patchUpsert(
|
||||
user.id,
|
||||
{
|
||||
email: tokenData.email,
|
||||
email_verified: true,
|
||||
phone: null,
|
||||
totp_secret: null,
|
||||
authenticator_types: null,
|
||||
password_hash: passwordHash,
|
||||
password_last_changed_at: now,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
|
||||
await this.repository.deleteEmailRevertToken(token);
|
||||
await this.repository.deleteAllMfaBackupCodes(user.id);
|
||||
await this.repository.deleteAllWebAuthnCredentials(user.id);
|
||||
await this.repository.deleteAllAuthorizedIps(user.id);
|
||||
await this.terminateAllUserSessions(user.id);
|
||||
await this.repository.createAuthorizedIp(
|
||||
user.id,
|
||||
requireClientIp(request, {
|
||||
trustCfConnectingIp: Config.proxy.trust_cf_connecting_ip,
|
||||
}),
|
||||
);
|
||||
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService && updatedUser && 'updateUser' in userSearchService) {
|
||||
await userSearchService
|
||||
.updateUser(updatedUser)
|
||||
.catch((error) =>
|
||||
Logger.debug({error, userId: updatedUser.id}, 'Failed to update search index after email revert'),
|
||||
);
|
||||
}
|
||||
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: updatedUser.id,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(updatedUser),
|
||||
});
|
||||
|
||||
const [authToken] = await this.createAuthSession({user: updatedUser, request});
|
||||
|
||||
await this.contactChangeLogService.recordDiff({
|
||||
oldUser: user,
|
||||
newUser: updatedUser,
|
||||
reason: 'user_requested',
|
||||
actorUserId: user.id,
|
||||
});
|
||||
|
||||
return {user_id: updatedUser.id.toString(), token: authToken};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user