// SPDX-License-Identifier: AGPL-3.0-or-later import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions'; import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder'; import {createGuildID} from '@app/api/BrandedTypes'; import {requireAdminACL, requireAnyAdminACL} from '@app/api/middleware/AdminMiddleware'; import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; import {AdminRateLimitConfigs} from '@app/api/rate_limit_configs/AdminRateLimitConfig'; import type {HonoApp} from '@app/api/types/HonoEnv'; import {Validator} from '@app/api/Validator'; import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError'; import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError'; import { BanGuildMemberBody, ListGuildAuditLogsResponse, ListGuildMembersQuery, ListGuildsQuery, UpdateGuildRequest, } from '@fluxer/schema/src/domains/admin/AdminGuildSchemas'; import { GuildUpdateResponse, ListGuildEmojisResponse, ListGuildMembersResponse, ListGuildStickersResponse, LookupGuildResponse, SearchGuildsResponse, } from '@fluxer/schema/src/domains/admin/AdminSchemas'; import {GuildIdParam, GuildIdUserIdParam, SuccessResponse} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; import {GuildAuditLogListQuery} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas'; const GUILD_UPDATE_ACLS = [ AdminACLs.GUILD_UPDATE_NAME, AdminACLs.GUILD_UPDATE_SETTINGS, AdminACLs.GUILD_UPDATE_FEATURES, AdminACLs.GUILD_UPDATE_VANITY, AdminACLs.GUILD_TRANSFER_OWNERSHIP, ]; function hasGuildSettingsUpdate(body: UpdateGuildRequest): boolean { return ( body.verification_level !== undefined || body.mfa_level !== undefined || body.nsfw_level !== undefined || body.nsfw !== undefined || body.content_warning_level !== undefined || body.content_warning_text !== undefined || body.explicit_content_filter !== undefined || body.default_message_notifications !== undefined || body.disabled_operations !== undefined ); } function hasGuildFeatureUpdate(body: UpdateGuildRequest): boolean { return body.add_features !== undefined || body.remove_features !== undefined; } function selectGuildUpdateACLs(body: UpdateGuildRequest): Array { const required: Array = []; if (body.name !== undefined) { required.push(AdminACLs.GUILD_UPDATE_NAME); } if (body.fields !== undefined || hasGuildSettingsUpdate(body)) { required.push(AdminACLs.GUILD_UPDATE_SETTINGS); } if (hasGuildFeatureUpdate(body)) { required.push(AdminACLs.GUILD_UPDATE_FEATURES); } if (body.vanity_url_code !== undefined) { required.push(AdminACLs.GUILD_UPDATE_VANITY); } if (body.new_owner_id !== undefined) { required.push(AdminACLs.GUILD_TRANSFER_OWNERSHIP); } if (required.length > 0) { return required; } return Object.keys(body).length === 0 ? [] : [AdminACLs.WILDCARD]; } function requireAllAdminACLs(granted: ReadonlySet, required: ReadonlyArray): void { if (granted.has(AdminACLs.WILDCARD)) { return; } for (const acl of required) { if (!granted.has(acl)) { throw new MissingACLError(acl); } } } export function GuildAdminController(app: HonoApp) { app.get( '/admin/guilds', RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.GUILD_LOOKUP), Validator('query', ListGuildsQuery), OpenAPI({ operationId: 'list_admin_guilds', summary: 'List guilds', description: 'Searches guilds by name, ID, and other criteria. Supports full-text search and pagination through limit and offset. Requires GUILD_LOOKUP permission.', responseSchema: SearchGuildsResponse, statusCode: 200, security: 'adminApiKey', tags: 'Admin', }), async (ctx) => { const adminService = ctx.get('adminService'); const query = ctx.req.valid('query'); const response = await adminService.searchService.searchGuilds({ query: query.q, limit: query.limit, offset: query.offset, }); await recordAdminRead(ctx, { targetType: 'guild', targetId: 0n, action: AdminAuditReadActions.SEARCH_GUILDS, metadata: { has_query: query.q === undefined ? undefined : true, limit: query.limit, offset: query.offset, result_count: response.guilds.length, total: response.total, }, }); return ctx.json(response); }, ); app.get( '/admin/guilds/:guild_id', RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.GUILD_LOOKUP), Validator('param', GuildIdParam), OpenAPI({ operationId: 'get_admin_guild', summary: 'Get guild', description: 'Retrieves complete guild details including metadata, settings, channels, roles, and statistics. Requires GUILD_LOOKUP permission.', responseSchema: LookupGuildResponse, statusCode: 200, security: 'adminApiKey', tags: 'Admin', }), async (ctx) => { const adminService = ctx.get('adminService'); const {guild_id} = ctx.req.valid('param'); const response = await adminService.guildServiceAggregate.lookupService.lookupGuild({guild_id}); await recordAdminRead(ctx, { targetType: 'guild', targetId: guild_id, action: AdminAuditReadActions.GET_GUILD, metadata: {found: response.guild !== null}, }); return ctx.json(response); }, ); app.patch( '/admin/guilds/:guild_id', RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY), requireAnyAdminACL(GUILD_UPDATE_ACLS), Validator('param', GuildIdParam), Validator('json', UpdateGuildRequest), OpenAPI({ operationId: 'update_admin_guild', summary: 'Update guild', description: 'Partially updates a guild. The permissions required are selected by the fields present in the body and are evaluated with all-of semantics: name requires GUILD_UPDATE_NAME, vanity_url_code requires GUILD_UPDATE_VANITY, new_owner_id requires GUILD_TRANSFER_OWNERSHIP, add_features and remove_features require GUILD_UPDATE_FEATURES, and fields together with every other setting requires GUILD_UPDATE_SETTINGS. A body with no fields applies no change. Every applied change is logged to the audit log.', 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 guildIdRaw = ctx.req.valid('param').guild_id; const body = ctx.req.valid('json'); requireAllAdminACLs(ctx.get('adminUserAcls'), selectGuildUpdateACLs(body)); const {updateService, vanityService, lookupService} = adminService.guildServiceAggregate; if (body.fields !== undefined) { await updateService.clearGuildFields({guild_id: guildIdRaw, fields: body.fields}, adminUserId, auditLogReason); } if (hasGuildSettingsUpdate(body)) { await updateService.updateGuildSettings( { guild_id: guildIdRaw, verification_level: body.verification_level, mfa_level: body.mfa_level, nsfw_level: body.nsfw_level, nsfw: body.nsfw, content_warning_level: body.content_warning_level, content_warning_text: body.content_warning_text, explicit_content_filter: body.explicit_content_filter, default_message_notifications: body.default_message_notifications, disabled_operations: body.disabled_operations, }, adminUserId, auditLogReason, ); } if (hasGuildFeatureUpdate(body)) { await updateService.updateGuildFeatures({ guildId: createGuildID(guildIdRaw), addFeatures: body.add_features ?? [], removeFeatures: body.remove_features ?? [], adminUserId, auditLogReason, }); } if (body.name !== undefined) { await updateService.updateGuildName({guild_id: guildIdRaw, name: body.name}, adminUserId, auditLogReason); } if (body.vanity_url_code !== undefined) { await vanityService.updateGuildVanity( {guild_id: guildIdRaw, vanity_url_code: body.vanity_url_code}, adminUserId, auditLogReason, ); } if (body.new_owner_id !== undefined) { await updateService.transferGuildOwnership( {guild_id: guildIdRaw, new_owner_id: body.new_owner_id}, adminUserId, auditLogReason, ); } const {guild} = await lookupService.lookupGuild({guild_id: guildIdRaw}); if (!guild) { throw new UnknownGuildError(); } const appliedFieldGroup = body.fields !== undefined || hasGuildSettingsUpdate(body) || hasGuildFeatureUpdate(body) || body.name !== undefined || body.vanity_url_code !== undefined || body.new_owner_id !== undefined; if (!appliedFieldGroup) { await recordAdminWrite(ctx, {targetType: 'guild', targetId: guildIdRaw, action: 'update_guild'}); } return ctx.json({ guild: { id: guild.id, name: guild.name, features: guild.features, owner_id: guild.owner_id, icon: guild.icon, banner: guild.banner, member_count: guild.member_count, nsfw_level: guild.nsfw_level, }, }); }, ); app.delete( '/admin/guilds/:guild_id', RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY), requireAdminACL(AdminACLs.GUILD_DELETE), Validator('param', GuildIdParam), OpenAPI({ operationId: 'delete_admin_guild', summary: 'Delete guild', description: 'Permanently deletes a guild. Deletes all channels, messages, and settings. Irreversible operation with no recovery window. 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'); return ctx.json( await adminService.guildServiceAggregate.managementService.deleteGuild( ctx.req.valid('param').guild_id, adminUserId, auditLogReason, ), ); }, ); app.get( '/admin/guilds/:guild_id/members', RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.GUILD_LIST_MEMBERS), Validator('param', GuildIdParam), Validator('query', ListGuildMembersQuery), OpenAPI({ operationId: 'list_admin_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'); const {guild_id} = ctx.req.valid('param'); const query = ctx.req.valid('query'); const response = await adminService.guildServiceAggregate.lookupService.listGuildMembers({ guild_id, limit: query.limit, offset: query.offset, }); await recordAdminRead(ctx, { targetType: 'guild', targetId: guild_id, action: AdminAuditReadActions.LIST_GUILD_MEMBERS, metadata: { limit: response.limit, offset: response.offset, result_count: response.members.length, total: response.total, }, }); return ctx.json(response); }, ); app.put( '/admin/guilds/:guild_id/members/:user_id', RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY), requireAdminACL(AdminACLs.GUILD_FORCE_ADD_MEMBER), Validator('param', GuildIdUserIdParam), OpenAPI({ operationId: 'add_admin_guild_member', summary: 'Add guild member', 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'); const params = ctx.req.valid('param'); return ctx.json( await adminService.guildServiceAggregate.membershipService.forceAddUserToGuild({ data: {guild_id: params.guild_id, user_id: params.user_id}, requestCache, adminUserId, auditLogReason, }), ); }, ); app.delete( '/admin/guilds/:guild_id/members/:user_id', RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY), requireAdminACL(AdminACLs.GUILD_KICK_MEMBER), Validator('param', GuildIdUserIdParam), OpenAPI({ operationId: 'kick_admin_guild_member', summary: 'Remove 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'); const params = ctx.req.valid('param'); await adminService.guildServiceAggregate.membershipService.kickMember( {guild_id: params.guild_id, user_id: params.user_id}, adminUserId, auditLogReason, ); return ctx.body(null, 204); }, ); app.put( '/admin/guilds/:guild_id/bans/:user_id', RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY), requireAdminACL(AdminACLs.GUILD_BAN_MEMBER), Validator('param', GuildIdUserIdParam), Validator('json', BanGuildMemberBody), OpenAPI({ operationId: 'ban_admin_guild_member', summary: 'Ban guild member', description: 'Bans a user from a guild, optionally deleting their recent messages. Prevents the user from joining until the ban expires or is removed. 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'); const params = ctx.req.valid('param'); await adminService.guildServiceAggregate.membershipService.banMember( {...ctx.req.valid('json'), guild_id: params.guild_id, user_id: params.user_id}, adminUserId, auditLogReason, ); return ctx.body(null, 204); }, ); app.get( '/admin/guilds/:guild_id/emojis', RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.ASSET_PURGE), Validator('param', GuildIdParam), OpenAPI({ operationId: 'list_admin_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); const response = await adminService.guildServiceAggregate.lookupService.listGuildEmojis(guildId); await recordAdminRead(ctx, { targetType: 'guild', targetId: guildId, action: AdminAuditReadActions.LIST_GUILD_EMOJIS, metadata: {result_count: response.emojis.length}, }); return ctx.json(response); }, ); app.get( '/admin/guilds/:guild_id/stickers', RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.ASSET_PURGE), Validator('param', GuildIdParam), OpenAPI({ operationId: 'list_admin_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); const response = await adminService.guildServiceAggregate.lookupService.listGuildStickers(guildId); await recordAdminRead(ctx, { targetType: 'guild', targetId: guildId, action: AdminAuditReadActions.LIST_GUILD_STICKERS, metadata: {result_count: response.stickers.length}, }); return ctx.json(response); }, ); app.get( '/admin/guilds/:guild_id/audit-logs', RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP), requireAdminACL(AdminACLs.GUILD_AUDIT_LOG_VIEW), Validator('param', GuildIdParam), Validator('query', GuildAuditLogListQuery), OpenAPI({ operationId: 'list_admin_guild_audit_logs', summary: 'List guild audit logs', description: 'Returns in-app guild audit log entries for a guild without requiring VIEW_AUDIT_LOG membership permission. Supports pagination via before/after log IDs and filtering by user_id or action_type. Requires GUILD_AUDIT_LOG_VIEW permission.', responseSchema: ListGuildAuditLogsResponse, statusCode: 200, security: 'adminApiKey', tags: 'Admin', }), async (ctx) => { const adminService = ctx.get('adminService'); const {guild_id} = ctx.req.valid('param'); const query = ctx.req.valid('query'); const response = await adminService.guildServiceAggregate.listGuildAuditLogs({ guild_id, limit: query.limit, before: query.before, after: query.after, user_id: query.user_id, action_type: query.action_type, }); await recordAdminRead(ctx, { targetType: 'guild', targetId: guild_id, action: AdminAuditReadActions.LIST_GUILD_AUDIT_LOGS, metadata: { limit: query.limit, before: query.before, after: query.after, filter_user_id: query.user_id, action_type: query.action_type, result_count: response.audit_log_entries.length, }, }); return ctx.json(response); }, ); app.post( '/admin/guilds/:guild_id/reloads', RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY), requireAdminACL(AdminACLs.GUILD_RELOAD), Validator('param', GuildIdParam), OpenAPI({ operationId: 'create_admin_guild_reload', 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'); return ctx.json( await adminService.guildServiceAggregate.managementService.reloadGuild( ctx.req.valid('param').guild_id, adminUserId, auditLogReason, ), ); }, ); app.post( '/admin/guilds/:guild_id/shutdowns', RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY), requireAdminACL(AdminACLs.GUILD_SHUTDOWN), Validator('param', GuildIdParam), OpenAPI({ operationId: 'create_admin_guild_shutdown', summary: 'Shut down 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'); return ctx.json( await adminService.guildServiceAggregate.managementService.shutdownGuild( ctx.req.valid('param').guild_id, adminUserId, auditLogReason, ), ); }, ); }