Files
fluxer/fluxer_api/src/api/admin/controllers/GuildAdminController.ts
T

507 lines
17 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
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,
SuccessResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {GuildIdParam, GuildIdUserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {GuildAuditLogListQuery} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas';
import {createGuildID} from '../../BrandedTypes';
import {requireAdminACL, requireAnyAdminACL} from '../../middleware/AdminMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
import {AdminRateLimitConfigs} from '../../rate_limit_configs/AdminRateLimitConfig';
import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
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<string> {
const required: Array<string> = [];
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<string>, required: ReadonlyArray<string>): 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');
return ctx.json(
await adminService.searchService.searchGuilds({
query: query.q,
limit: query.limit,
offset: query.offset,
}),
);
},
);
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');
return ctx.json(
await adminService.guildServiceAggregate.lookupService.lookupGuild({
guild_id: ctx.req.valid('param').guild_id,
}),
);
},
);
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();
}
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 query = ctx.req.valid('query');
return ctx.json(
await adminService.guildServiceAggregate.lookupService.listGuildMembers({
guild_id: ctx.req.valid('param').guild_id,
limit: query.limit,
offset: query.offset,
}),
);
},
);
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);
return ctx.json(await adminService.guildServiceAggregate.lookupService.listGuildEmojis(guildId));
},
);
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);
return ctx.json(await adminService.guildServiceAggregate.lookupService.listGuildStickers(guildId));
},
);
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 query = ctx.req.valid('query');
return ctx.json(
await adminService.guildServiceAggregate.listGuildAuditLogs({
guild_id: ctx.req.valid('param').guild_id,
limit: query.limit,
before: query.before,
after: query.after,
user_id: query.user_id,
action_type: query.action_type,
}),
);
},
);
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,
),
);
},
);
}