mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 22:15:34 +09:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d09cf566c |
No files matched your search
Generated
+66
-2
@@ -3697,7 +3697,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"description": "Bans a user from a guild as the Fluxer system user, without guild membership or guild permissions, optionally deleting their recent messages. Prevents the user from joining until the ban expires or is removed. Refuses the guild owner. Logged to audit log. Requires GUILD_BAN_MEMBER permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -3719,6 +3719,70 @@
|
||||
"required": false,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/BanGuildMemberBody"}}}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "unban_admin_guild_member",
|
||||
"summary": "Unban guild member",
|
||||
"tags": ["Admin"],
|
||||
"responses": {
|
||||
"204": {"description": "No Content"},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Removes a guild ban as the Fluxer system user, without guild membership or guild permissions. Logged to audit log. Requires GUILD_BAN_MEMBER permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "guild_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"description": "The ID of the guild", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
|
||||
"description": "The ID of the guild"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
|
||||
"description": "The ID of the user"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/guilds/{guild_id}/emojis": {
|
||||
@@ -3980,7 +4044,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Temporarily removes a user from a guild. User can rejoin. Logged to audit log. Requires GUILD_KICK_MEMBER permission.",
|
||||
"description": "Removes a user from a guild as the Fluxer system user, without guild membership or guild permissions. User can rejoin. Refuses the guild owner. Logged to audit log. Requires GUILD_KICK_MEMBER permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
@@ -353,7 +353,7 @@ export function GuildAdminController(app: HonoApp) {
|
||||
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.',
|
||||
'Removes a user from a guild as the Fluxer system user, without guild membership or guild permissions. User can rejoin. Refuses the guild owner. Logged to audit log. Requires GUILD_KICK_MEMBER permission.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: 'adminApiKey',
|
||||
@@ -382,7 +382,7 @@ export function GuildAdminController(app: HonoApp) {
|
||||
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.',
|
||||
'Bans a user from a guild as the Fluxer system user, without guild membership or guild permissions, optionally deleting their recent messages. Prevents the user from joining until the ban expires or is removed. Refuses the guild owner. Logged to audit log. Requires GUILD_BAN_MEMBER permission.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: 'adminApiKey',
|
||||
@@ -401,6 +401,34 @@ export function GuildAdminController(app: HonoApp) {
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
app.delete(
|
||||
'/admin/guilds/:guild_id/bans/:user_id',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_BAN_MEMBER),
|
||||
Validator('param', GuildIdUserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'unban_admin_guild_member',
|
||||
summary: 'Unban guild member',
|
||||
description:
|
||||
'Removes a guild ban as the Fluxer system user, without guild membership or guild permissions. 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.unbanMember(
|
||||
{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),
|
||||
|
||||
@@ -5,12 +5,14 @@ import {createGuildID, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {GuildService} from '@app/api/guild/services/GuildService';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {JoinSourceTypes} from '@fluxer/constants/src/GuildConstants';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
BanGuildMemberRequest,
|
||||
ForceAddUserToGuildRequest,
|
||||
KickGuildMemberRequest,
|
||||
UnbanGuildMemberRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
import type {SuccessResponse} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
|
||||
@@ -68,24 +70,19 @@ export class AdminGuildMembershipService {
|
||||
const {guildService, auditService} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const targetId = createUserID(data.user_id);
|
||||
await guildService.moderation.banMember(
|
||||
{
|
||||
userId: adminUserId,
|
||||
guildId,
|
||||
targetId,
|
||||
deleteMessageDays: data.delete_message_days,
|
||||
deleteMessageSeconds: data.delete_message_seconds,
|
||||
reason: data.reason ?? undefined,
|
||||
banDurationSeconds: data.ban_duration_seconds ?? undefined,
|
||||
skipGuildAuditLog: true,
|
||||
by: 'staff',
|
||||
},
|
||||
auditLogReason,
|
||||
);
|
||||
await guildService.moderation.banMemberAsSystem({
|
||||
guildId,
|
||||
targetId,
|
||||
deleteMessageDays: data.delete_message_days,
|
||||
deleteMessageSeconds: data.delete_message_seconds,
|
||||
reason: data.reason,
|
||||
banDurationSeconds: data.ban_duration_seconds,
|
||||
});
|
||||
const metadata = new Map([
|
||||
['guild_id', guildId.toString()],
|
||||
['user_id', targetId.toString()],
|
||||
['delete_message_days', data.delete_message_days.toString()],
|
||||
['acl', AdminACLs.GUILD_BAN_MEMBER],
|
||||
]);
|
||||
if (data.reason) {
|
||||
metadata.set('reason', data.reason);
|
||||
@@ -103,21 +100,34 @@ export class AdminGuildMembershipService {
|
||||
});
|
||||
}
|
||||
|
||||
async unbanMember(data: UnbanGuildMemberRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildService, auditService} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const targetId = createUserID(data.user_id);
|
||||
await guildService.moderation.unbanMemberAsSystem({guildId, targetId});
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild_member',
|
||||
targetId,
|
||||
action: 'unban_member',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['guild_id', guildId.toString()],
|
||||
['user_id', targetId.toString()],
|
||||
['acl', AdminACLs.GUILD_BAN_MEMBER],
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
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.members.removeMember(
|
||||
{
|
||||
userId: adminUserId,
|
||||
targetId,
|
||||
guildId,
|
||||
},
|
||||
auditLogReason,
|
||||
);
|
||||
await guildService.members.removeMemberAsSystem({targetId, guildId});
|
||||
const metadata = new Map([
|
||||
['guild_id', guildId.toString()],
|
||||
['user_id', targetId.toString()],
|
||||
['acl', AdminACLs.GUILD_KICK_MEMBER],
|
||||
]);
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const SYSTEM_USER_ID = '0';
|
||||
const STAFF_REASON = 'Staff ticket 4410: member is under 18';
|
||||
|
||||
interface GuildAuditLogResponse {
|
||||
audit_log_entries: Array<{action_type: number; user_id: string | null; target_id: string | null; reason?: string}>;
|
||||
users: Array<{id: string}>;
|
||||
}
|
||||
|
||||
interface GuildBanEntry {
|
||||
user: {id: string};
|
||||
reason: string | null;
|
||||
moderator_id: string;
|
||||
}
|
||||
|
||||
async function createStaff(harness: ApiTestHarness, acls: Array<string>): Promise<TestAccount> {
|
||||
return setUserACLs(harness, await createTestAccount(harness), [AdminACLs.AUTHENTICATE, ...acls]);
|
||||
}
|
||||
|
||||
async function listGuildAuditLog(
|
||||
harness: ApiTestHarness,
|
||||
owner: TestAccount,
|
||||
guildId: string,
|
||||
actionType: AuditLogActionType,
|
||||
): Promise<GuildAuditLogResponse> {
|
||||
return createBuilder<GuildAuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guildId}/audit-logs?action_type=${actionType}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function listBans(harness: ApiTestHarness, owner: TestAccount, guildId: string): Promise<Array<GuildBanEntry>> {
|
||||
return createBuilder<Array<GuildBanEntry>>(harness, owner.token)
|
||||
.get(`/guilds/${guildId}/bans`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function latestAdminAuditLog(action: string, targetId: string) {
|
||||
const logs = await getAdminRepository().listAllAuditLogsPaginated(100000);
|
||||
return logs.find((log) => log.action === action && log.targetId.toString() === targetId);
|
||||
}
|
||||
|
||||
function captureGuildDispatches() {
|
||||
const events: Array<{event: string; data: unknown}> = [];
|
||||
vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild').mockImplementation(async (params) => {
|
||||
events.push({event: params.event, data: params.data});
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
describe('Admin guild moderation as the system user', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('kick works without guild membership and is attributed to the system user', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const target = members[0]!;
|
||||
const staff = await createStaff(harness, [AdminACLs.GUILD_KICK_MEMBER]);
|
||||
const dispatches = captureGuildDispatches();
|
||||
|
||||
await createBuilder(harness, staff.token)
|
||||
.delete(`/admin/guilds/${guild.id}/members/${target.userId}`)
|
||||
.header('X-Audit-Log-Reason', STAFF_REASON)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${target.userId}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
|
||||
const auditLog = await listGuildAuditLog(harness, owner, guild.id, AuditLogActionType.MEMBER_KICK);
|
||||
const entry = auditLog.audit_log_entries.find((candidate) => candidate.target_id === target.userId);
|
||||
expect(entry?.user_id).toBe(SYSTEM_USER_ID);
|
||||
expect(entry?.reason).toBeUndefined();
|
||||
expect(auditLog.users.map((user) => user.id)).toContain(SYSTEM_USER_ID);
|
||||
expect(JSON.stringify(auditLog)).not.toContain(staff.userId);
|
||||
|
||||
expect(dispatches.map((dispatch) => dispatch.event)).toContain('GUILD_MEMBER_REMOVE');
|
||||
expect(dispatches.find((dispatch) => dispatch.event === 'GUILD_AUDIT_LOG_ENTRY_CREATE')?.data).toMatchObject({
|
||||
user_id: SYSTEM_USER_ID,
|
||||
});
|
||||
expect(JSON.stringify(dispatches)).not.toContain(staff.userId);
|
||||
expect(JSON.stringify(dispatches)).not.toContain(STAFF_REASON);
|
||||
|
||||
const adminLog = await latestAdminAuditLog('kick_member', target.userId);
|
||||
expect(adminLog?.adminUserId.toString()).toBe(staff.userId);
|
||||
expect(adminLog?.auditLogReason).toBe(STAFF_REASON);
|
||||
expect(Object.fromEntries(adminLog?.metadata ?? [])).toEqual({
|
||||
guild_id: guild.id,
|
||||
user_id: target.userId,
|
||||
acl: AdminACLs.GUILD_KICK_MEMBER,
|
||||
});
|
||||
});
|
||||
|
||||
test('ban works without guild membership and the ban record names the system user', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const target = members[0]!;
|
||||
const staff = await createStaff(harness, [AdminACLs.GUILD_BAN_MEMBER]);
|
||||
const dispatches = captureGuildDispatches();
|
||||
|
||||
await createBuilder(harness, staff.token)
|
||||
.put(`/admin/guilds/${guild.id}/bans/${target.userId}`)
|
||||
.header('X-Audit-Log-Reason', STAFF_REASON)
|
||||
.body({reason: 'Underage member'})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const bans = await listBans(harness, owner, guild.id);
|
||||
expect(bans).toHaveLength(1);
|
||||
expect(bans[0]).toMatchObject({user: {id: target.userId}, moderator_id: SYSTEM_USER_ID, reason: 'Underage member'});
|
||||
|
||||
const auditLog = await listGuildAuditLog(harness, owner, guild.id, AuditLogActionType.MEMBER_BAN_ADD);
|
||||
const entry = auditLog.audit_log_entries.find((candidate) => candidate.target_id === target.userId);
|
||||
expect(entry?.user_id).toBe(SYSTEM_USER_ID);
|
||||
expect(entry?.reason).toBe('Underage member');
|
||||
expect(JSON.stringify(auditLog)).not.toContain(staff.userId);
|
||||
|
||||
expect(dispatches.map((dispatch) => dispatch.event)).toEqual(
|
||||
expect.arrayContaining(['GUILD_BAN_ADD', 'GUILD_MEMBER_REMOVE', 'GUILD_AUDIT_LOG_ENTRY_CREATE']),
|
||||
);
|
||||
expect(JSON.stringify(dispatches)).not.toContain(staff.userId);
|
||||
expect(JSON.stringify(dispatches)).not.toContain(STAFF_REASON);
|
||||
|
||||
const adminLog = await latestAdminAuditLog('ban_member', target.userId);
|
||||
expect(adminLog?.adminUserId.toString()).toBe(staff.userId);
|
||||
expect(adminLog?.auditLogReason).toBe(STAFF_REASON);
|
||||
expect(adminLog?.metadata.get('acl')).toBe(AdminACLs.GUILD_BAN_MEMBER);
|
||||
expect(adminLog?.metadata.get('reason')).toBe('Underage member');
|
||||
});
|
||||
|
||||
test('ban without a reason stores no reason', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const target = members[0]!;
|
||||
const staff = await createStaff(harness, [AdminACLs.GUILD_BAN_MEMBER]);
|
||||
|
||||
await createBuilder(harness, staff.token)
|
||||
.put(`/admin/guilds/${guild.id}/bans/${target.userId}`)
|
||||
.header('X-Audit-Log-Reason', STAFF_REASON)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const bans = await listBans(harness, owner, guild.id);
|
||||
expect(bans[0]).toMatchObject({moderator_id: SYSTEM_USER_ID, reason: null});
|
||||
const auditLog = await listGuildAuditLog(harness, owner, guild.id, AuditLogActionType.MEMBER_BAN_ADD);
|
||||
expect(auditLog.audit_log_entries[0]?.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
test('unban removes the ban as the system user', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const target = members[0]!;
|
||||
const staff = await createStaff(harness, [AdminACLs.GUILD_BAN_MEMBER]);
|
||||
await createBuilder(harness, staff.token)
|
||||
.put(`/admin/guilds/${guild.id}/bans/${target.userId}`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
const dispatches = captureGuildDispatches();
|
||||
|
||||
await createBuilder(harness, staff.token)
|
||||
.delete(`/admin/guilds/${guild.id}/bans/${target.userId}`)
|
||||
.header('X-Audit-Log-Reason', STAFF_REASON)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
expect(await listBans(harness, owner, guild.id)).toEqual([]);
|
||||
const auditLog = await listGuildAuditLog(harness, owner, guild.id, AuditLogActionType.MEMBER_BAN_REMOVE);
|
||||
expect(auditLog.audit_log_entries[0]).toMatchObject({user_id: SYSTEM_USER_ID, target_id: target.userId});
|
||||
expect(auditLog.audit_log_entries[0]?.reason).toBeUndefined();
|
||||
expect(dispatches.map((dispatch) => dispatch.event)).toContain('GUILD_BAN_REMOVE');
|
||||
expect(JSON.stringify(dispatches)).not.toContain(staff.userId);
|
||||
|
||||
const adminLog = await latestAdminAuditLog('unban_member', target.userId);
|
||||
expect(adminLog?.adminUserId.toString()).toBe(staff.userId);
|
||||
expect(adminLog?.auditLogReason).toBe(STAFF_REASON);
|
||||
expect(adminLog?.metadata.get('acl')).toBe(AdminACLs.GUILD_BAN_MEMBER);
|
||||
|
||||
await createBuilder(harness, staff.token)
|
||||
.delete(`/admin/guilds/${guild.id}/bans/${target.userId}`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('kicking or banning the guild owner is refused', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const staff = await createStaff(harness, [AdminACLs.GUILD_KICK_MEMBER, AdminACLs.GUILD_BAN_MEMBER]);
|
||||
|
||||
await createBuilder(harness, staff.token)
|
||||
.delete(`/admin/guilds/${guild.id}/members/${owner.userId}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
|
||||
.execute();
|
||||
await createBuilder(harness, staff.token)
|
||||
.put(`/admin/guilds/${guild.id}/bans/${owner.userId}`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
|
||||
.execute();
|
||||
|
||||
expect(await listBans(harness, owner, guild.id)).toEqual([]);
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${owner.userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(await latestAdminAuditLog('kick_member', owner.userId)).toBeUndefined();
|
||||
expect(await latestAdminAuditLog('ban_member', owner.userId)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('unknown guilds are reported as unknown', async () => {
|
||||
const target = await createTestAccount(harness);
|
||||
const staff = await createStaff(harness, [AdminACLs.GUILD_KICK_MEMBER, AdminACLs.GUILD_BAN_MEMBER]);
|
||||
const missingGuildId = '900000000000000001';
|
||||
|
||||
await createBuilder(harness, staff.token)
|
||||
.delete(`/admin/guilds/${missingGuildId}/members/${target.userId}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_GUILD')
|
||||
.execute();
|
||||
await createBuilder(harness, staff.token)
|
||||
.put(`/admin/guilds/${missingGuildId}/bans/${target.userId}`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_GUILD')
|
||||
.execute();
|
||||
await createBuilder(harness, staff.token)
|
||||
.delete(`/admin/guilds/${missingGuildId}/bans/${target.userId}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_GUILD')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('each route still requires its Admin ACL', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const target = members[0]!;
|
||||
const kicker = await createStaff(harness, [AdminACLs.GUILD_KICK_MEMBER]);
|
||||
const banner = await createStaff(harness, [AdminACLs.GUILD_BAN_MEMBER]);
|
||||
|
||||
await createBuilder(harness, banner.token)
|
||||
.delete(`/admin/guilds/${guild.id}/members/${target.userId}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL')
|
||||
.execute();
|
||||
await createBuilder(harness, kicker.token)
|
||||
.put(`/admin/guilds/${guild.id}/bans/${target.userId}`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL')
|
||||
.execute();
|
||||
await createBuilder(harness, kicker.token)
|
||||
.delete(`/admin/guilds/${guild.id}/bans/${target.userId}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL')
|
||||
.execute();
|
||||
|
||||
expect(await listBans(harness, owner, guild.id)).toEqual([]);
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${target.userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -205,7 +205,8 @@ export const GuildAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
method: 'DELETE',
|
||||
route: '/admin/guilds/:guild_id/members/:user_id',
|
||||
async prepare({harness, admin}) {
|
||||
const guild = await createGuild(harness, admin.token, 'Audit Kick Guild');
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Audit Kick Guild');
|
||||
const target = await createTestAccount(harness);
|
||||
await addMember(harness, admin, guild.id, target);
|
||||
return {
|
||||
@@ -214,7 +215,7 @@ export const GuildAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
action: 'kick_member',
|
||||
targetType: 'guild_member',
|
||||
targetId: target.userId,
|
||||
metadata: {guild_id: guild.id, user_id: target.userId},
|
||||
metadata: {guild_id: guild.id, user_id: target.userId, acl: 'guild:kick_member'},
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -222,8 +223,9 @@ export const GuildAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/guilds/:guild_id/bans/:user_id',
|
||||
async prepare({harness, admin}) {
|
||||
const guild = await createGuild(harness, admin.token, 'Audit Ban Guild');
|
||||
async prepare({harness}) {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Audit Ban Guild');
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/guilds/${guild.id}/bans/${target.userId}`, body: {}, expectStatus: 204},
|
||||
@@ -231,7 +233,35 @@ export const GuildAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
action: 'ban_member',
|
||||
targetType: 'guild_member',
|
||||
targetId: target.userId,
|
||||
metadata: {guild_id: guild.id, user_id: target.userId, delete_message_days: '0'},
|
||||
metadata: {
|
||||
guild_id: guild.id,
|
||||
user_id: target.userId,
|
||||
delete_message_days: '0',
|
||||
acl: 'guild:ban_member',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/guilds/:guild_id/bans/:user_id',
|
||||
async prepare({harness, admin}) {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Audit Unban Guild');
|
||||
const target = await createTestAccount(harness);
|
||||
await createBuilder(harness, admin.token)
|
||||
.put(`/admin/guilds/${guild.id}/bans/${target.userId}`)
|
||||
.body({})
|
||||
.expect(204)
|
||||
.execute();
|
||||
return {
|
||||
request: {path: `/admin/guilds/${guild.id}/bans/${target.userId}`, expectStatus: 204},
|
||||
expected: {
|
||||
action: 'unban_member',
|
||||
targetType: 'guild_member',
|
||||
targetId: target.userId,
|
||||
metadata: {guild_id: guild.id, user_id: target.userId, acl: 'guild:ban_member'},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type {GuildID, InviteCode, RoleID, UserID} from '@app/api/BrandedTypes';
|
||||
import type {ChannelService} from '@app/api/channel/services/ChannelService';
|
||||
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
|
||||
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
|
||||
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
|
||||
import {GuildMemberAuditService} from '@app/api/guild/services/member/GuildMemberAuditService';
|
||||
@@ -244,6 +245,18 @@ export class GuildMemberService {
|
||||
const targetMember = await this.guildRepository.getMember(guildId, targetId);
|
||||
if (!targetMember) throw new UnknownGuildMemberError();
|
||||
await this.operationsService.removeMember(params);
|
||||
await this.finishMemberKick({guildId, targetId, actorId: userId}, auditLogReason ?? null);
|
||||
}
|
||||
|
||||
async removeMemberAsSystem(params: {targetId: UserID; guildId: GuildID}): Promise<void> {
|
||||
await this.operationsService.removeMemberAsSystem(params);
|
||||
await this.finishMemberKick({...params, actorId: SYSTEM_USER_ID}, null);
|
||||
}
|
||||
|
||||
private async finishMemberKick(
|
||||
{guildId, targetId, actorId}: {guildId: GuildID; targetId: UserID; actorId: UserID},
|
||||
auditLogReason: string | null,
|
||||
): Promise<void> {
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
const searchIndexOptions = this.getMemberSearchIndexOptions(guild);
|
||||
if (searchIndexOptions) {
|
||||
@@ -252,11 +265,11 @@ export class GuildMemberService {
|
||||
await this.eventService.dispatchGuildMemberRemove({guildId, userId: targetId});
|
||||
await this.guildAuditLogService.record({
|
||||
guildId,
|
||||
userId,
|
||||
userId: actorId,
|
||||
action: AuditLogActionType.MEMBER_KICK,
|
||||
changes: null,
|
||||
targetId: targetId,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
auditLogReason,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type {GuildID, UserID} from '@app/api/BrandedTypes';
|
||||
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
|
||||
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
|
||||
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
|
||||
import {mapGuildBansToResponse} from '@app/api/guild/GuildModel';
|
||||
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
|
||||
@@ -16,6 +17,8 @@ import {Logger} from '@app/api/Logger';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {Guild} from '@app/api/models/Guild';
|
||||
import type {GuildBan} from '@app/api/models/GuildBan';
|
||||
import type {GuildMember} from '@app/api/models/GuildMember';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
@@ -23,6 +26,7 @@ import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {GuildBanResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
@@ -32,6 +36,13 @@ const SECONDS_PER_DAY = 86_400;
|
||||
const GUILD_MODERATION_PERMISSIONS =
|
||||
Permissions.ADMINISTRATOR | Permissions.BAN_MEMBERS | Permissions.KICK_MEMBERS | Permissions.MANAGE_GUILD;
|
||||
|
||||
interface GuildBanOptions {
|
||||
deleteMessageDays?: number;
|
||||
deleteMessageSeconds?: number;
|
||||
reason?: string | null;
|
||||
banDurationSeconds?: number;
|
||||
}
|
||||
|
||||
export class GuildModerationService {
|
||||
private readonly searchIndexService: GuildMemberSearchIndexService;
|
||||
|
||||
@@ -55,30 +66,14 @@ export class GuildModerationService {
|
||||
}
|
||||
|
||||
async banMember(
|
||||
params: {
|
||||
params: GuildBanOptions & {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
guildId: GuildID;
|
||||
deleteMessageDays?: number;
|
||||
deleteMessageSeconds?: number;
|
||||
reason?: string | null;
|
||||
banDurationSeconds?: number;
|
||||
skipGuildAuditLog?: boolean;
|
||||
by?: BanBy;
|
||||
},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {
|
||||
userId,
|
||||
guildId,
|
||||
targetId,
|
||||
deleteMessageDays,
|
||||
deleteMessageSeconds,
|
||||
reason,
|
||||
banDurationSeconds,
|
||||
skipGuildAuditLog,
|
||||
by = 'moderator',
|
||||
} = params;
|
||||
const {userId, guildId, targetId} = params;
|
||||
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
|
||||
if (userId === targetId) throw new UnknownGuildMemberError();
|
||||
const targetUser = await this.userRepository.findUnique(targetId);
|
||||
@@ -90,6 +85,64 @@ export class GuildModerationService {
|
||||
const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId: targetId});
|
||||
if (!canManage) throw new MissingPermissionsError();
|
||||
}
|
||||
await this.applyBan({
|
||||
...params,
|
||||
actorId: userId,
|
||||
by: 'moderator',
|
||||
targetUser,
|
||||
targetMember,
|
||||
guild: await this.guildRepository.findUnique(guildId),
|
||||
guildAuditLogReason: auditLogReason ?? (params.reason || null),
|
||||
});
|
||||
}
|
||||
|
||||
async banMemberAsSystem(params: GuildBanOptions & {targetId: UserID; guildId: GuildID}): Promise<void> {
|
||||
const {guildId, targetId} = params;
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (!guild) throw new UnknownGuildError();
|
||||
if (targetId === SYSTEM_USER_ID) throw new UnknownGuildMemberError();
|
||||
if (guild.ownerId === targetId) throw new MissingPermissionsError();
|
||||
const targetUser = await this.userRepository.findUnique(targetId);
|
||||
if (!targetUser) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
await this.applyBan({
|
||||
...params,
|
||||
actorId: SYSTEM_USER_ID,
|
||||
by: 'staff',
|
||||
targetUser,
|
||||
targetMember: await this.guildRepository.getMember(guildId, targetId),
|
||||
guild,
|
||||
guildAuditLogReason: params.reason || null,
|
||||
});
|
||||
}
|
||||
|
||||
private async applyBan(
|
||||
params: GuildBanOptions & {
|
||||
targetId: UserID;
|
||||
guildId: GuildID;
|
||||
actorId: UserID;
|
||||
by: BanBy;
|
||||
targetUser: User;
|
||||
targetMember: GuildMember | null;
|
||||
guild: Guild | null;
|
||||
guildAuditLogReason: string | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
const {
|
||||
actorId,
|
||||
guildId,
|
||||
targetId,
|
||||
deleteMessageDays,
|
||||
deleteMessageSeconds,
|
||||
reason,
|
||||
banDurationSeconds,
|
||||
by,
|
||||
targetUser,
|
||||
targetMember,
|
||||
guild: guildBeforeBan,
|
||||
guildAuditLogReason,
|
||||
} = params;
|
||||
const effectiveDeleteMessageSeconds =
|
||||
deleteMessageSeconds ?? (deleteMessageDays !== undefined ? deleteMessageDays * SECONDS_PER_DAY : undefined);
|
||||
if (effectiveDeleteMessageSeconds && effectiveDeleteMessageSeconds > 0) {
|
||||
@@ -105,37 +158,34 @@ export class GuildModerationService {
|
||||
if (banDurationSeconds && banDurationSeconds > 0) {
|
||||
expiresAt = new Date(Date.now() + banDurationSeconds * 1000);
|
||||
}
|
||||
const guildBeforeBan = await this.guildRepository.findUnique(guildId);
|
||||
const targetModerator = await this.isGuildModerator(guildId, targetId, guildBeforeBan, targetMember !== null);
|
||||
const ban = await this.guildRepository.upsertBan({
|
||||
guild_id: guildId,
|
||||
user_id: targetId,
|
||||
moderator_id: userId,
|
||||
moderator_id: actorId,
|
||||
banned_at: new Date(),
|
||||
expires_at: expiresAt,
|
||||
reason: reason || null,
|
||||
ip: targetIp,
|
||||
email: targetEmail,
|
||||
});
|
||||
if (!skipGuildAuditLog) {
|
||||
const metadata: Record<string, string> | undefined =
|
||||
effectiveDeleteMessageSeconds && effectiveDeleteMessageSeconds > 0
|
||||
? {delete_message_seconds: String(effectiveDeleteMessageSeconds)}
|
||||
: undefined;
|
||||
await this.guildAuditLogService.record({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.MEMBER_BAN_ADD,
|
||||
targetId: targetId,
|
||||
auditLogReason: auditLogReason ?? (reason || null),
|
||||
metadata,
|
||||
changes: this.guildAuditLogService.computeChanges(null, this.serializeBanForAudit(ban)),
|
||||
});
|
||||
}
|
||||
const metadata: Record<string, string> | undefined =
|
||||
effectiveDeleteMessageSeconds && effectiveDeleteMessageSeconds > 0
|
||||
? {delete_message_seconds: String(effectiveDeleteMessageSeconds)}
|
||||
: undefined;
|
||||
await this.guildAuditLogService.record({
|
||||
guildId,
|
||||
userId: actorId,
|
||||
action: AuditLogActionType.MEMBER_BAN_ADD,
|
||||
targetId: targetId,
|
||||
auditLogReason: guildAuditLogReason,
|
||||
metadata,
|
||||
changes: this.guildAuditLogService.computeChanges(null, this.serializeBanForAudit(ban)),
|
||||
});
|
||||
await emitGuildMemberBanned({
|
||||
guildId,
|
||||
userId: targetId,
|
||||
moderatorId: userId,
|
||||
moderatorId: actorId,
|
||||
by,
|
||||
memberCount: guildBeforeBan?.memberCount ?? 0,
|
||||
targetModerator,
|
||||
@@ -188,24 +238,36 @@ export class GuildModerationService {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
guildId: GuildID;
|
||||
by?: BanBy;
|
||||
},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId, targetId, by = 'moderator'} = params;
|
||||
const {userId, guildId, targetId} = params;
|
||||
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
|
||||
await this.removeBan({guildId, targetId, actorId: userId, by: 'moderator'}, auditLogReason ?? null);
|
||||
}
|
||||
|
||||
async unbanMemberAsSystem(params: {targetId: UserID; guildId: GuildID}): Promise<void> {
|
||||
const guild = await this.guildRepository.findUnique(params.guildId);
|
||||
if (!guild) throw new UnknownGuildError();
|
||||
await this.removeBan({...params, actorId: SYSTEM_USER_ID, by: 'staff'}, null);
|
||||
}
|
||||
|
||||
private async removeBan(
|
||||
{guildId, targetId, actorId, by}: {guildId: GuildID; targetId: UserID; actorId: UserID; by: BanBy},
|
||||
auditLogReason: string | null,
|
||||
): Promise<void> {
|
||||
const ban = await this.guildRepository.getBan(guildId, targetId);
|
||||
if (!ban) {
|
||||
throw InputValidationError.fromCode('user_id', ValidationErrorCodes.USER_IS_NOT_BANNED);
|
||||
}
|
||||
await this.guildRepository.deleteBan(guildId, targetId);
|
||||
await emitGuildMemberUnbanned({guildId, userId: targetId, moderatorId: userId, by});
|
||||
await emitGuildMemberUnbanned({guildId, userId: targetId, moderatorId: actorId, by});
|
||||
await this.guildAuditLogService.record({
|
||||
guildId,
|
||||
userId,
|
||||
userId: actorId,
|
||||
action: AuditLogActionType.MEMBER_BAN_REMOVE,
|
||||
targetId: targetId,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
auditLogReason,
|
||||
changes: this.guildAuditLogService.computeChanges(this.serializeBanForAudit(ban), null),
|
||||
});
|
||||
await this.gatewayService.dispatchGuild({
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type UserID,
|
||||
} from '@app/api/BrandedTypes';
|
||||
import type {ChannelService} from '@app/api/channel/services/ChannelService';
|
||||
import {assertMutableUserId} from '@app/api/constants/Core';
|
||||
import {assertMutableUserId, SYSTEM_USER_ID} from '@app/api/constants/Core';
|
||||
import type {GuildMemberRow} from '@app/api/database/types/GuildTypes';
|
||||
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
|
||||
import {resolveMaxGuildMembersLimit} from '@app/api/guild/GuildMemberLimitUtils';
|
||||
@@ -378,6 +378,20 @@ export class GuildMemberOperationsService {
|
||||
throw new UnknownGuildMemberError();
|
||||
}
|
||||
await checkTargetMember(targetId);
|
||||
await this.deleteKickedMember(targetMember, userId);
|
||||
}
|
||||
|
||||
async removeMemberAsSystem({targetId, guildId}: {targetId: UserID; guildId: GuildID}): Promise<void> {
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (!guild) throw new UnknownGuildError();
|
||||
const targetMember = await this.guildRepository.getMember(guildId, targetId);
|
||||
if (!targetMember) throw new UnknownGuildMemberError();
|
||||
if (guild.ownerId === targetId) throw new MissingPermissionsError();
|
||||
await this.deleteKickedMember(targetMember, SYSTEM_USER_ID);
|
||||
}
|
||||
|
||||
private async deleteKickedMember(targetMember: GuildMember, actorId: UserID): Promise<void> {
|
||||
const {guildId, userId: targetId} = targetMember;
|
||||
await this.snapshotMembershipMetadata(targetMember);
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
await this.guildRepository.deleteMember(guildId, targetId);
|
||||
@@ -389,7 +403,7 @@ export class GuildMemberOperationsService {
|
||||
);
|
||||
}
|
||||
await this.gatewayService.leaveGuild({userId: targetId, guildId});
|
||||
await emitGuildMemberRemoved({guildId, userId: targetId, actorId: userId, kind: 'kicked'});
|
||||
await emitGuildMemberRemoved({guildId, userId: targetId, actorId, kind: 'kicked'});
|
||||
}
|
||||
|
||||
async addUserToGuild(
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Guild search, detail, mutation, deletion, membership, expressions,
|
||||
|
||||
import RouteHeader from '@/components/RouteHeader.astro';
|
||||
|
||||
Admin guild operations read and change the guilds the public [Guilds](/http-api/guilds/) resource exposes. Most need no membership in the guild and no guild permission. [Remove guild member](#remove-guild-member) and [Ban guild member](#ban-guild-member) are the exceptions.
|
||||
Admin guild operations read and change the guilds the public [Guilds](/http-api/guilds/) resource exposes. None of them need membership in the guild or any guild permission.
|
||||
|
||||
[Archives](/admin-api/archives/) owns the archive lifecycle and the downloads.
|
||||
|
||||
@@ -549,9 +549,7 @@ One Admin audit entry is recorded with the action `force_add_to_guild`, the targ
|
||||
|
||||
Removes a member from a guild without banning them and returns 204 with an empty body. Requires `guild:kick_member`.
|
||||
|
||||
:::caution[Removal also needs KICK_MEMBERS in the guild]
|
||||
The acting account must see the guild, hold [KICK_MEMBERS](/http-api/permissions/) in it, and rank above the target. A guild at [MFA level](/http-api/guilds/#mfa-levels) 1 also requires an enrolled authenticator.
|
||||
:::
|
||||
Fluxer checks only the Admin ACL. The acting account needs no membership, no permission and no role position in the guild. Fluxer removes the member as the Fluxer system user, whose ID is `0`, and the guild owner cannot be removed.
|
||||
|
||||
### Path parameters
|
||||
|
||||
@@ -565,21 +563,18 @@ The acting account must see the guild, hold [KICK_MEMBERS](/http-api/permissions
|
||||
| Status | Body | Condition |
|
||||
| --- | --- | --- |
|
||||
| 204 | empty | Member was removed |
|
||||
| 400 | [error response](/admin-api/#error-response) | `TWO_FACTOR_REQUIRED` in an MFA level 1 guild |
|
||||
| 403<sup>1</sup> | [error response](/admin-api/#error-response) | `MISSING_ACCESS` when the acting account cannot see the guild, or `MISSING_PERMISSIONS` when `KICK_MEMBERS` is absent |
|
||||
| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_MEMBER`, because the target is not a member, is the guild owner, or is the acting account |
|
||||
|
||||
<sup>1</sup> `MISSING_PERMISSIONS` also covers a target who outranks the acting account
|
||||
| 403 | [error response](/admin-api/#error-response) | `MISSING_PERMISSIONS` because the target is the guild owner |
|
||||
| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_GUILD` because the guild does not exist, or `UNKNOWN_MEMBER` because the target is not a member |
|
||||
|
||||
### Side effects
|
||||
|
||||
The user loses membership and disappears from guild member search. Removal from the guild removes every membership in its threads. Fluxer emits a [Thread Members Update](/gateway/threads/#thread-members-update) for each of them, as [Thread member flags](/http-api/thread-members/#thread-member-flags) states. A later rejoin restores a communication timeout that is still in force. The rejoined member starts with no nickname and no roles.
|
||||
|
||||
[Guild Member Remove](/gateway/events/#guild-member-remove) fires to the guild. A `MEMBER_KICK` entry is written to the guild's own [audit log](/http-api/guild-audit-logs/#audit-actions). The entry names the acting Admin account and has the audit reason. The write fires [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) to sessions holding [VIEW_AUDIT_LOG](/http-api/permissions/).
|
||||
[Guild Member Remove](/gateway/events/#guild-member-remove) fires to the guild. A `MEMBER_KICK` entry is written to the guild's own [audit log](/http-api/guild-audit-logs/#audit-actions). The entry gives the Fluxer system user as the actor and has no reason, so the `X-Audit-Log-Reason` header and the acting Admin account never appear in the guild. The write fires [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) to sessions holding [VIEW_AUDIT_LOG](/http-api/permissions/).
|
||||
|
||||
The removal creates no ban record, so the user can rejoin.
|
||||
|
||||
One Admin audit entry is recorded with the action `kick_member`, the target type `guild_member`, the removed user as the target, and the guild and user IDs in the metadata.
|
||||
One Admin audit entry is recorded with the action `kick_member`, the target type `guild_member` and the removed user as the target. It names the acting Admin account and has the audit reason. Its metadata has the guild ID, the user ID and `acl` set to `guild:kick_member`.
|
||||
|
||||
### Rate limit
|
||||
|
||||
@@ -591,9 +586,7 @@ One Admin audit entry is recorded with the action `kick_member`, the target type
|
||||
|
||||
Bans a user from a guild, optionally removes their recent messages, and returns 204 with an empty body. The target need not be a member. Requires `guild:ban_member`.
|
||||
|
||||
:::caution[A ban also needs BAN_MEMBERS in the guild]
|
||||
The acting account must hold [BAN_MEMBERS](/http-api/permissions/) in the guild, and must rank above a target who is a member. A guild at [MFA level](/http-api/guilds/#mfa-levels) 1 also requires an enrolled authenticator.
|
||||
:::
|
||||
Fluxer checks only the Admin ACL. The acting account needs no membership, no permission and no role position in the guild. Fluxer bans the user as the Fluxer system user, whose ID is `0`, and the guild owner cannot be banned.
|
||||
|
||||
### Path parameters
|
||||
|
||||
@@ -608,7 +601,7 @@ The acting account must hold [BAN_MEMBERS](/http-api/permissions/) in the guild,
|
||||
| --- | --- | --- |
|
||||
| delete_message_seconds?<sup>1</sup> | integer | The window of recent messages to delete, in seconds (0-604800) |
|
||||
| delete_message_days?<sup>1</sup> | integer | The window of recent messages to delete, in days (0-7, default 0) |
|
||||
| reason? | ?string | The guild ban reason stored on the ban record (at most 512 characters) |
|
||||
| reason? | ?string | The guild ban reason (at most 512 characters). Omitting it stores no reason |
|
||||
| ban_duration_seconds?<sup>2</sup> | integer | The ban duration in seconds, either exactly 0 or between 60 and 63072000 |
|
||||
|
||||
<sup>1</sup> `delete_message_seconds` wins when both are supplied, and `delete_message_days` is otherwise multiplied by 86400. A resolved window of 0 deletes no message
|
||||
@@ -620,9 +613,9 @@ The acting account must hold [BAN_MEMBERS](/http-api/permissions/) in the guild,
|
||||
| Status | Body | Condition |
|
||||
| --- | --- | --- |
|
||||
| 204 | empty | User was banned |
|
||||
| 400 | [error response](/admin-api/#error-response) | Duration, reason, or message window validation fails, or `TWO_FACTOR_REQUIRED` in an MFA level 1 guild |
|
||||
| 403 | [error response](/admin-api/#error-response) | `MISSING_PERMISSIONS` because `BAN_MEMBERS` is absent in the guild or the target member outranks the acting account |
|
||||
| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_MEMBER` because the target is the acting account, or `UNKNOWN_USER` because the user does not exist |
|
||||
| 400 | [error response](/admin-api/#error-response) | Duration, reason, or message window validation fails |
|
||||
| 403 | [error response](/admin-api/#error-response) | `MISSING_PERMISSIONS` because the target is the guild owner |
|
||||
| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_GUILD` for a missing guild, `UNKNOWN_MEMBER` for the system user, or `UNKNOWN_USER` for a missing user |
|
||||
|
||||
:::caution[The ban can also delete messages]
|
||||
A ban removes current membership and blocks later joins until its expiry or explicit removal. Messages inside the resolved deletion window are permanently deleted and are not restored when the ban ends.
|
||||
@@ -630,13 +623,50 @@ A ban removes current membership and blocks later joins until its expiry or expl
|
||||
|
||||
### Side effects
|
||||
|
||||
The ban record names the acting Admin account as moderator. It has the expiry, the reason, the target's lowercased email address, and the target's last known IP address unless that address is on the ban exemption list. While the ban exists the guild also blocks that address and that email, as described by [Guild moderation](/http-api/guild-moderation/#guild-ban-object), and removing the ban releases both.
|
||||
The ban record gives the Fluxer system user as `moderator_id`. It has the expiry, the reason, the target's lowercased email address, and the target's last known IP address unless that address is on the ban exemption list. While the ban exists the guild also blocks that address and that email, as described by [Guild moderation](/http-api/guild-moderation/#guild-ban-object), and removing the ban releases both.
|
||||
|
||||
A positive deletion window queues a background job that deletes the target's matching messages after the response, which fires [Message Delete Bulk](/gateway/events/#message-delete-bulk) as deletion progresses.
|
||||
|
||||
[Guild Ban Add](/gateway/events/#guild-ban-add) fires to the guild. If the target was a member, they lose access, disappear from member search, and trigger [Guild Member Remove](/gateway/events/#guild-member-remove). Removal from the guild removes every membership in its threads. Fluxer emits a [Thread Members Update](/gateway/threads/#thread-members-update) for each of them, as [Thread member flags](/http-api/thread-members/#thread-member-flags) states. A communication timeout in force at the ban is not restored on a later rejoin. This operation writes no entry to the guild's own audit log and emits no [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create).
|
||||
[Guild Ban Add](/gateway/events/#guild-ban-add) fires to the guild. If the target was a member, they lose access, disappear from member search, and trigger [Guild Member Remove](/gateway/events/#guild-member-remove). Removal from the guild removes every membership in its threads. Fluxer emits a [Thread Members Update](/gateway/threads/#thread-members-update) for each of them, as [Thread member flags](/http-api/thread-members/#thread-member-flags) states. A communication timeout in force at the ban is not restored on a later rejoin.
|
||||
|
||||
One Admin audit entry is recorded with the action `ban_member`, the target type `guild_member`, and the banned user as the target. Its metadata has the guild ID, the user ID, the `delete_message_days` value, and any supplied reason and duration.
|
||||
A `MEMBER_BAN_ADD` entry is written to the guild's own [audit log](/http-api/guild-audit-logs/#audit-actions). The entry gives the Fluxer system user as the actor and has the body `reason` when one is supplied. The `X-Audit-Log-Reason` header and the acting Admin account never appear in the guild. The write fires [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) to sessions holding [VIEW_AUDIT_LOG](/http-api/permissions/).
|
||||
|
||||
One Admin audit entry is recorded with the action `ban_member`, the target type `guild_member` and the banned user as the target. It names the acting Admin account and has the audit reason. Its metadata has the guild ID, the user ID, the `delete_message_days` value, `acl` set to `guild:ban_member`, and any supplied reason and duration.
|
||||
|
||||
### Rate limit
|
||||
|
||||
100 requests per minute for each authenticated user, on the `admin:guild:modify` bucket.
|
||||
|
||||
## Unban guild member
|
||||
|
||||
<RouteHeader method="DELETE" path="/v1/admin/guilds/{guild_id}/bans/{user_id}" auditReason />
|
||||
|
||||
Removes a guild ban and returns 204 with an empty body. Requires `guild:ban_member`. The operation takes no request body.
|
||||
|
||||
Fluxer checks only the Admin ACL. The acting account needs no membership and no permission in the guild. Fluxer removes the ban as the Fluxer system user, whose ID is `0`.
|
||||
|
||||
### Path parameters
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| guild_id | snowflake | The ID of the guild |
|
||||
| user_id | snowflake | The banned user |
|
||||
|
||||
### Response
|
||||
|
||||
| Status | Body | Condition |
|
||||
| --- | --- | --- |
|
||||
| 204 | empty | Ban was removed |
|
||||
| 400 | [error response](/admin-api/#error-response) | `INVALID_FORM_BODY` with `USER_IS_NOT_BANNED` against `user_id`, because the user has no ban |
|
||||
| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_GUILD` because the guild does not exist |
|
||||
|
||||
### Side effects
|
||||
|
||||
The user can join the guild again, and the guild stops blocking the email address and IP address stored on the ban. [Guild Ban Remove](/gateway/events/#guild-ban-remove) fires to the guild.
|
||||
|
||||
A `MEMBER_BAN_REMOVE` entry is written to the guild's own [audit log](/http-api/guild-audit-logs/#audit-actions). The entry gives the Fluxer system user as the actor and has no reason, so the `X-Audit-Log-Reason` header and the acting Admin account never appear in the guild. The write fires [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) to sessions holding [VIEW_AUDIT_LOG](/http-api/permissions/).
|
||||
|
||||
One Admin audit entry is recorded with the action `unban_member`, the target type `guild_member` and the unbanned user as the target. It names the acting Admin account and has the audit reason. Its metadata has the guild ID, the user ID and `acl` set to `guild:ban_member`.
|
||||
|
||||
### Rate limit
|
||||
|
||||
|
||||
@@ -404,6 +404,7 @@ An entry with any other action has `access` set to `write`.
|
||||
| unban_email | An address was removed from the email blocklist |
|
||||
| unban_file_sha | A file hash was removed from the attachment blocklist |
|
||||
| unban_ip | An address was removed from the IP blocklist |
|
||||
| unban_member | A guild ban was removed |
|
||||
| unban_phrase | A phrase was removed from the message phrase blocklist |
|
||||
| unban_profile_substring | A substring was removed from the profile substring blocklist |
|
||||
| unban_url | A URL was removed from the URL blocklist |
|
||||
@@ -568,7 +569,7 @@ The registry is returned in this order by [List ACLs](#list-acls). A value outsi
|
||||
| gateway:reload_all | Reloads guilds across the main Gateway |
|
||||
| gift_codes:generate | Generates [gift codes](/admin-api/gift-codes/) |
|
||||
| guild:audit_log:view | Reads a guild's ordinary audit log |
|
||||
| guild:ban_member | Bans a guild member |
|
||||
| guild:ban_member | Bans a guild member and removes a guild ban |
|
||||
| guild:delete | Deletes a guild |
|
||||
| guild:force_add_member | Adds an account to a guild |
|
||||
| guild:kick_member | Removes a guild member |
|
||||
|
||||
@@ -77,7 +77,7 @@ One recorded guild change. An entry is immutable once written, except for a run
|
||||
| options?<sup>4</sup> | [audit log options](#audit-log-options-object) object | The context fields this [audit action](#audit-actions) records |
|
||||
| changes?<sup>5</sup> | array[[audit log change](#audit-log-change-object) object] | The fields the mutation changed |
|
||||
|
||||
<sup>1</sup> Every entry has the acting user ID, and an entry produced by consolidation has the actor of the consolidated run
|
||||
<sup>1</sup> Every entry has the acting user ID, and an entry produced by consolidation has the actor of the consolidated run. An entry for a member removal, ban or ban removal made by Fluxer staff has `0`, the Fluxer system user
|
||||
|
||||
<sup>2</sup> A decimal snowflake string for every action except [INVITE_CREATE](#audit-actions) and [INVITE_DELETE](#audit-actions), where it is the invite code, and null for [MESSAGE_BULK_DELETE](#audit-actions)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ A guild ban object names the banned account, the moderator, the reason, and any
|
||||
| --- | --- | --- |
|
||||
| user | [partial user](/http-api/users/#partial-user-object) object | The banned account |
|
||||
| reason<sup>1</sup> | ?string | The stored ban reason, or null when none was recorded |
|
||||
| moderator_id | snowflake | The account that issued or most recently replaced the ban |
|
||||
| moderator_id | snowflake | The account that issued or most recently replaced the ban, or `0` for the Fluxer system user when Fluxer staff placed it |
|
||||
| banned_at<sup>2</sup> | ISO8601 timestamp | Time the ban was issued or most recently replaced |
|
||||
| expires_at<sup>3</sup> | ?ISO8601 timestamp | Time a temporary ban stops applying, or null for a permanent ban |
|
||||
|
||||
|
||||
@@ -103,6 +103,13 @@ export const KickGuildMemberRequest = z.object({
|
||||
|
||||
export type KickGuildMemberRequest = z.infer<typeof KickGuildMemberRequest>;
|
||||
|
||||
export const UnbanGuildMemberRequest = z.object({
|
||||
guild_id: SnowflakeType,
|
||||
user_id: SnowflakeType,
|
||||
});
|
||||
|
||||
export type UnbanGuildMemberRequest = z.infer<typeof UnbanGuildMemberRequest>;
|
||||
|
||||
export const GetProcessMemoryStatsQuery = z.object({
|
||||
limit: createQueryIntegerType({defaultValue: 100, minValue: 100, maxValue: 1000}).describe(
|
||||
'Maximum number of guild processes to return (100-1000, default 100)',
|
||||
|
||||
Reference in new issue
Block a user