Compare commits

...
23 changed files with 769 additions and 149 deletions

No files matched your search

+66 -2
View File
@@ -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(
@@ -189,8 +189,9 @@ export const LexicalChannelTextareaContent = observer(
}) => {
const {i18n} = useLingui();
const composerKey = draftChannelId ?? channel.id;
const editingMessageId = MessageEdit.getEditingMessageId(channel.id);
const editingMobileMessageId = MessageEditMobile.getEditingMobileMessageId(channel.id);
const ownsChannelEdits = draftChannelId == null;
const editingMessageId = ownsChannelEdits ? MessageEdit.getEditingMessageId(channel.id) : null;
const editingMobileMessageId = ownsChannelEdits ? MessageEditMobile.getEditingMobileMessageId(channel.id) : null;
const isEditingMessageInComposer = editingMobileMessageId != null;
let initialDraftDisplay = '';
if (!disabled && !isEditingMessageInComposer && draft !== null) {
@@ -810,8 +811,7 @@ export const LexicalChannelTextareaContent = observer(
}
}, []);
const dismissTopmostComposerAffordance = useCallback((): boolean => {
const editingInline = MessageEdit.getEditingMessageId(channel.id) !== null;
if (editingInline) {
if (editingMessageId !== null) {
MessageCommands.stopEdit(channel.id);
return true;
}
@@ -846,6 +846,7 @@ export const LexicalChannelTextareaContent = observer(
channel.id,
composerKey,
editingMessageForComposer,
editingMessageId,
focusComposer,
handleCancelEdit,
handleClearSlashCommand,
@@ -4,11 +4,14 @@ import {Message as MessageComponent} from '@app/features/channel/components/Chan
import {LexicalChannelTextareaContent} from '@app/features/channel/components/LexicalChannelTextareaContent';
import type {Channel} from '@app/features/channel/models/Channel';
import * as DraftCommands from '@app/features/messaging/commands/DraftCommands';
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
import type {SendMessageFunction} from '@app/features/messaging/hooks/useMessageSubmission';
import Drafts from '@app/features/messaging/state/MessagingDrafts';
import Messages from '@app/features/messaging/state/MessagingMessages';
import {CloudUpload} from '@app/features/messaging/upload/CloudUpload';
import * as MessageSubmitUtils from '@app/features/messaging/utils/MessageSubmitUtils';
import {getParserFlagsForContext} from '@app/features/messaging/utils/markdown/MarkdownParserFlags';
import {parseAndRenderToPlaintext} from '@app/features/messaging/utils/markdown/Plaintext';
import {formatUploadingAttachmentSummary} from '@app/features/messaging/utils/UploadingAttachmentLabelUtils';
import {shouldDisableAutofocusOnMobile} from '@app/features/platform/utils/AutofocusUtils';
import * as ThreadCommands from '@app/features/threads/commands/ThreadCommands';
@@ -35,6 +38,8 @@ import {ChatsIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useRef, useState} from 'react';
const THREAD_NAME_PARSER_FLAGS = getParserFlagsForContext(MarkdownContext.STANDARD_WITHOUT_JUMBO);
interface ThreadCreatePaneProps {
parent: Channel;
messageId: string | null;
@@ -64,7 +69,19 @@ export const ThreadCreatePane = observer(({parent, messageId}: ThreadCreatePaneP
observer.observe(inertRoot, {attributes: true, attributeFilter: ['inert']});
return () => observer.disconnect();
}, []);
void Messages.version;
const sourceMessage = messageId ? Messages.getMessage(parent.id, messageId) : undefined;
const suggestedName = sourceMessage?.content
? parseAndRenderToPlaintext(sourceMessage.content, THREAD_NAME_PARSER_FLAGS, {
channelId: parent.id,
includeEmojiNames: true,
mentionChannels: sourceMessage.mentionChannels,
i18n,
})
.replace(/\s+/g, ' ')
.trim()
.slice(0, THREAD_NAME_MAX_LENGTH)
: '';
const draftKey = `thread-create:${parent.id}`;
const accountKey = Users.viewAccountKey;
const draft = Drafts.getDraft(draftKey);
@@ -78,10 +95,7 @@ export const ThreadCreatePane = observer(({parent, messageId}: ThreadCreatePaneP
const disabled = cooldownSeconds > 0 || (!canPublic && !canPrivate);
const handleSubmit: SendMessageFunction = useCallback(
(content, hasAttachments, stickersOrTts, favoriteMemeIdOrStickers, maybeFavoriteMemeId) => {
const threadName = (nameRef.current.trim() || sourceMessage?.content.trim() || '').slice(
0,
THREAD_NAME_MAX_LENGTH,
);
const threadName = (nameRef.current.trim() || suggestedName).slice(0, THREAD_NAME_MAX_LENGTH);
if (!threadName) {
setNameError(i18n._(D.THREAD_NAME_REQUIRED_DESCRIPTOR));
return false;
@@ -122,7 +136,7 @@ export const ThreadCreatePane = observer(({parent, messageId}: ThreadCreatePaneP
});
return true;
},
[draftKey, i18n, messageId, parent, privateSelected, sourceMessage],
[draftKey, i18n, messageId, parent, privateSelected, suggestedName],
);
return (
<div className={styles.pane} data-flx="threads.thread-create-pane.pane">
@@ -138,7 +152,7 @@ export const ThreadCreatePane = observer(({parent, messageId}: ThreadCreatePaneP
setNameError(undefined);
}}
label={i18n._(D.THREAD_NAME_DESCRIPTOR)}
placeholder={sourceMessage?.content.slice(0, THREAD_NAME_MAX_LENGTH) || i18n._(D.NEW_THREAD_DESCRIPTOR)}
placeholder={suggestedName || i18n._(D.NEW_THREAD_DESCRIPTOR)}
maxLength={THREAD_NAME_MAX_LENGTH}
autoComplete="off"
error={nameError}
+98 -8
View File
@@ -23,14 +23,17 @@ pub async fn proxy_assets(
Path(path): Path<String>,
request: axum::extract::Request,
) -> Response {
let Some(cdn_endpoint) = &state.config.static_cdn_endpoint else {
return serve_local_asset(
&state.local_files,
&format!("assets/{path}"),
request.headers(),
state.csp.asset_header(),
)
.await;
let cdn_endpoint = match &state.config.static_cdn_endpoint {
Some(endpoint) if !state.bundled_app_assets => endpoint,
_ => {
return serve_local_asset(
&state.local_files,
&format!("assets/{path}"),
request.headers(),
state.csp.asset_header(),
)
.await;
}
};
if path
@@ -146,6 +149,18 @@ fn upstream_asset_body(
))
}
pub fn bundles_app_assets(static_dir: &str) -> bool {
let Ok(entries) = std::fs::read_dir(std::path::Path::new(static_dir).join("assets")) else {
return false;
};
entries.flatten().any(|entry| {
entry
.path()
.extension()
.is_some_and(|extension| extension == "js")
})
}
pub(super) async fn serve_local_asset(
files: &LocalFileStore,
relative_path: &str,
@@ -241,6 +256,7 @@ mod tests {
http_client: build_http_client().unwrap(),
spa_index_source: SpaIndexSource::bundled(""),
local_asset_prefixes: None,
bundled_app_assets: false,
budgets,
local_files,
}
@@ -1191,4 +1207,78 @@ mod tests {
"a response served past the read slot count had the wrong bytes"
);
}
async fn spawn_counting_upstream() -> (String, Arc<AtomicU64>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let hits = Arc::new(AtomicU64::new(0));
let counter = Arc::clone(&hits);
let router = Router::new().fallback(move || {
let counter = Arc::clone(&counter);
async move {
counter.fetch_add(1, Ordering::Relaxed);
Response::new(Body::from("cdn-bytes"))
}
});
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
(format!("http://{addr}"), hits)
}
fn cdn_and_local_state(cdn_endpoint: &str, static_dir: &str) -> AppState {
let mut state = upstream_backed_state(cdn_endpoint);
let mut config = (*state.config).clone();
config.static_dir = static_dir.to_owned();
state.local_files =
LocalFileStore::load_blocking(std::path::Path::new(static_dir), &state.budgets)
.unwrap();
state.config = Arc::new(config);
state.bundled_app_assets = bundles_app_assets(static_dir);
state
}
async fn get_asset(state: AppState, asset_path: &str) -> Response {
super::super::build_router(state)
.oneshot(
HttpRequest::builder()
.uri(format!("/assets/{asset_path}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
}
#[tokio::test]
async fn an_image_with_the_app_built_in_serves_assets_itself_when_a_cdn_is_set() {
let fixture = LocalAssetDir::with_asset("356aaade04a117b1.js", b"local-bytes");
let (endpoint, hits) = spawn_counting_upstream().await;
let state = cdn_and_local_state(&endpoint, fixture.dir());
assert!(state.bundled_app_assets);
let response = get_asset(state, "356aaade04a117b1.js").await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(body_bytes(response).await, b"local-bytes");
assert_eq!(
hits.load(Ordering::Relaxed),
0,
"a cdn that pulls from this instance would send the request back here"
);
}
#[tokio::test]
async fn an_image_without_the_app_built_in_still_takes_assets_from_the_cdn() {
let fixture = LocalAssetDir::with_asset("fonts-latin.txt", b"font list");
let (endpoint, hits) = spawn_counting_upstream().await;
let state = cdn_and_local_state(&endpoint, fixture.dir());
assert!(!state.bundled_app_assets);
let response = get_asset(state, "356aaade04a117b1.js").await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(body_bytes(response).await, b"cdn-bytes");
assert_eq!(hits.load(Ordering::Relaxed), 1);
}
}
+1
View File
@@ -34,6 +34,7 @@ mod tests {
http_client: build_http_client().unwrap(),
spa_index_source: SpaIndexSource::bundled(""),
local_asset_prefixes: None,
bundled_app_assets: false,
budgets,
local_files,
}
+1
View File
@@ -8,6 +8,7 @@ mod health;
mod spa_index;
mod spa_static;
pub use assets_proxy::bundles_app_assets;
pub use spa_index::present_local_asset_prefixes;
use crate::state::AppState;
+1
View File
@@ -1346,6 +1346,7 @@ mod tests {
http_client: reqwest::Client::new(),
spa_index_source,
local_asset_prefixes: None,
bundled_app_assets: false,
budgets,
local_files,
}
@@ -402,6 +402,7 @@ mod tests {
http_client: reqwest::Client::new(),
spa_index_source: crate::state::SpaIndexSource::bundled(""),
local_asset_prefixes: None,
bundled_app_assets: false,
budgets,
local_files,
}
+4 -1
View File
@@ -4,7 +4,7 @@ use crate::branding_images::InstanceBranding;
use crate::config::{AppProxyConfig, HttpUrl};
use crate::csp::CompiledCspPolicy;
use crate::local_files::LocalFileStore;
use crate::routes::present_local_asset_prefixes;
use crate::routes::{bundles_app_assets, present_local_asset_prefixes};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncReadExt;
@@ -87,6 +87,7 @@ pub struct AppState {
pub http_client: reqwest::Client,
pub spa_index_source: SpaIndexSource,
pub local_asset_prefixes: Option<Arc<[&'static str]>>,
pub bundled_app_assets: bool,
pub budgets: AppProxyBudgets,
pub(crate) local_files: LocalFileStore,
}
@@ -103,6 +104,7 @@ impl AppState {
let spa_index_source = SpaIndexSource::load(&config).await?;
let local_asset_prefixes = (!spa_index_source.is_upstream())
.then(|| present_local_asset_prefixes(&config.static_dir));
let bundled_app_assets = bundles_app_assets(&config.static_dir);
Ok(Self {
branding: InstanceBranding::from_config(&config),
config,
@@ -110,6 +112,7 @@ impl AppState {
http_client,
spa_index_source,
local_asset_prefixes,
bundled_app_assets,
budgets,
local_files,
})
@@ -144,33 +144,6 @@ function appendUniquePattern(patterns, pattern) {
patterns.push(normalized);
}
function inventoryPatternsForTargetPids(pids) {
if (!(binding && typeof binding.AudioBridge === 'function')) return [];
const wanted = new Set(pids.map((pid) => String(pid)));
const patterns = [];
let bridge = null;
try {
bridge = new binding.AudioBridge();
const inventory = bridge.inventory();
if (!Array.isArray(inventory)) return patterns;
for (const props of inventory) {
if (!props || typeof props !== 'object') continue;
if (props['media.class'] !== 'Stream/Output/Audio') continue;
const processId = props['application.process.id'] || props['pipewire.sec.pid'];
if (!wanted.has(String(processId || ''))) continue;
appendUniquePattern(patterns, {'object.serial': String(props['object.serial'] || '')});
appendUniquePattern(patterns, {'node.name': String(props['node.name'] || '')});
appendUniquePattern(patterns, {'client.id': String(props['client.id'] || '')});
}
} catch {
} finally {
try {
bridge?.release?.();
} catch {}
}
return patterns;
}
function routingRuleFromTarget(target, options) {
if (target && typeof target === 'object') {
return normalizeRoutingRule(target.linuxRule || target);
@@ -179,9 +152,8 @@ function routingRuleFromTarget(target, options) {
if (!Number.isSafeInteger(pid) || pid <= 0) {
throw new TypeError('ProcessLoopback target pid must be a positive integer');
}
const targetPids = targetPidList(pid, Boolean(options?.includeProcessTree));
const include = inventoryPatternsForTargetPids(targetPids);
for (const candidate of targetPids) {
const include = [];
for (const candidate of targetPidList(pid, Boolean(options?.includeProcessTree))) {
appendUniquePattern(include, {'application.process.id': String(candidate)});
appendUniquePattern(include, {'pipewire.sec.pid': String(candidate)});
}
@@ -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 |
@@ -250,6 +250,8 @@ Defaults to the derived endpoint. The public Media Proxy URL. The `gifs` service
Defaults to the derived endpoint. The static asset origin. Read by `api`, `worker`, `admin`, and `app-proxy`. When it is unset, `admin` gets the public origin, and every other service derives it itself.
The web app's JavaScript and CSS are built into the `app-proxy` image, which serves them under `/assets/` on the instance's own address whatever this is set to. In the web app, this setting moves only the favicon and the icons used when someone installs the app. To cache the web app's files on a CDN, put the CDN in front of the instance's own hostname. Every file under `/assets/` has a content hash in its name and is sent with a one-year cache lifetime.
#### `FLUXER_ADMIN_ENDPOINT`
Defaults to the derived endpoint. The admin origin. The API accepts exactly this value plus `/oauth2_callback` as the admin OAuth redirect URI. Compose forwards it from `.env`, and builds it from the public origin and `FLUXER_ADMIN_BASE_PATH` when it is unset.
@@ -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)',