mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(admin): add bulk delete user messages tool (#2472)
This commit is contained in:
@@ -2335,6 +2335,63 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/bulk/delete-user-messages": {
|
||||
"post": {
|
||||
"operationId": "bulk_delete_user_messages",
|
||||
"summary": "Bulk delete user messages",
|
||||
"tags": ["Admin"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkJobResponse"}}}
|
||||
},
|
||||
"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/Error"}}},
|
||||
"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": "Enqueue a background job that deletes every message authored by each of the given users across all channels. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkDeleteUserMessagesRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/bulk/schedule-user-deletion": {
|
||||
"post": {
|
||||
"operationId": "schedule_bulk_user_deletion",
|
||||
@@ -8808,7 +8865,7 @@
|
||||
"acls": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"maxItems": 111,
|
||||
"maxItems": 112,
|
||||
"description": "List of access control permissions for the key"
|
||||
}
|
||||
},
|
||||
@@ -9162,7 +9219,7 @@
|
||||
"acls": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"maxItems": 111,
|
||||
"maxItems": 112,
|
||||
"description": "List of access control permissions for the key"
|
||||
}
|
||||
},
|
||||
@@ -9192,7 +9249,7 @@
|
||||
"acls": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"maxItems": 111,
|
||||
"maxItems": 112,
|
||||
"description": "List of access control permissions for the key"
|
||||
}
|
||||
},
|
||||
@@ -9892,6 +9949,18 @@
|
||||
},
|
||||
"required": ["guild_id", "user_ids"]
|
||||
},
|
||||
"BulkDeleteUserMessagesRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_ids": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/SnowflakeType"},
|
||||
"maxItems": 1000,
|
||||
"description": "List of user IDs whose messages will be deleted"
|
||||
}
|
||||
},
|
||||
"required": ["user_ids"]
|
||||
},
|
||||
"BulkScheduleUserDeletionRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13581,7 +13650,7 @@
|
||||
"pending_bulk_message_deletion_at": {"nullable": true, "type": "string"},
|
||||
"deletion_reason_code": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
|
||||
"deletion_public_reason": {"nullable": true, "type": "string"},
|
||||
"acls": {"type": "array", "items": {"type": "string"}, "maxItems": 111},
|
||||
"acls": {"type": "array", "items": {"type": "string"}, "maxItems": 112},
|
||||
"traits": {"type": "array", "items": {"type": "string"}, "maxItems": 100},
|
||||
"has_totp": {"type": "boolean"},
|
||||
"authenticator_types": {"type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}, "maxItems": 10},
|
||||
@@ -14149,7 +14218,7 @@
|
||||
"acls": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"maxItems": 111,
|
||||
"maxItems": 112,
|
||||
"description": "List of access control permissions to assign"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -43,6 +43,7 @@ pub const BAN_PROFILE_SUBSTRING_CHECK: &str = "ban:profile_substring:check";
|
||||
pub const BAN_PROFILE_SUBSTRING_REMOVE: &str = "ban:profile_substring:remove";
|
||||
pub const BULK_ADD_GUILD_MEMBERS: &str = "bulk:add:guild_members";
|
||||
pub const BULK_DELETE_USERS: &str = "bulk:delete:users";
|
||||
pub const BULK_DELETE_USER_MESSAGES: &str = "bulk:delete:user_messages";
|
||||
pub const BULK_UPDATE_GUILD_FEATURES: &str = "bulk:update:guild_features";
|
||||
pub const BULK_UPDATE_SUSPICIOUS_ACTIVITY: &str = "bulk:update:suspicious_activity";
|
||||
pub const BULK_UPDATE_USER_FLAGS: &str = "bulk:update:user_flags";
|
||||
@@ -154,6 +155,7 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
BAN_PROFILE_SUBSTRING_REMOVE,
|
||||
BULK_ADD_GUILD_MEMBERS,
|
||||
BULK_DELETE_USERS,
|
||||
BULK_DELETE_USER_MESSAGES,
|
||||
BULK_UPDATE_GUILD_FEATURES,
|
||||
BULK_UPDATE_SUSPICIOUS_ACTIVITY,
|
||||
BULK_UPDATE_USER_FLAGS,
|
||||
|
||||
@@ -72,6 +72,18 @@ impl AdminApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_delete_user_messages(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::BulkDeleteUserMessagesRequest {
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk/delete-user-messages", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_schedule_user_deletion(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
|
||||
@@ -262,6 +262,12 @@ pub(crate) async fn bulk_actions_post(
|
||||
)
|
||||
.await
|
||||
}
|
||||
"bulk-delete-user-messages" => {
|
||||
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
|
||||
client
|
||||
.bulk_delete_user_messages(&user_ids, audit_log_reason.as_deref())
|
||||
.await
|
||||
}
|
||||
"bulk_delete_users" => {
|
||||
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
|
||||
client
|
||||
|
||||
@@ -57,6 +57,7 @@ pub const NAV_SECTIONS: &[NavSection] = &[
|
||||
acl::BULK_UPDATE_GUILD_FEATURES,
|
||||
acl::BULK_ADD_GUILD_MEMBERS,
|
||||
acl::BULK_DELETE_USERS,
|
||||
acl::BULK_DELETE_USER_MESSAGES,
|
||||
]
|
||||
),
|
||||
],
|
||||
|
||||
@@ -201,6 +201,9 @@ pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &
|
||||
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USERS) {
|
||||
(bulk_schedule_deletion_section(base, csrf_token))
|
||||
}
|
||||
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USER_MESSAGES) {
|
||||
(bulk_delete_user_messages_section(base, csrf_token))
|
||||
}
|
||||
}
|
||||
};
|
||||
admin_layout(config, auth, "Bulk Actions", "bulk-actions", None, content)
|
||||
@@ -384,3 +387,24 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn bulk_delete_user_messages_section(base: &str, csrf_token: &str) -> Markup {
|
||||
section_card_simple(
|
||||
"Bulk Delete User Messages",
|
||||
html! {
|
||||
form method="post" action={(base) "/bulk-actions?action=bulk-delete-user-messages"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
p class="text-neutral-500 text-sm" {
|
||||
"Deletes every message authored by each user across all channels. This cannot be undone."
|
||||
}
|
||||
(textarea_input("user_ids", "User IDs (one per line)", "123456789\n987654321", "", 5, true))
|
||||
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
|
||||
(form_actions(html! {
|
||||
(danger_button("Delete All Messages"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
BulkAddGuildMembersRequest,
|
||||
BulkUpdateGuildFeaturesRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
import {BulkDeleteUserMessagesRequest} from '@fluxer/schema/src/domains/admin/AdminMessageSchemas';
|
||||
import {BulkJobResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {
|
||||
BulkScheduleUserDeletionRequest,
|
||||
@@ -185,4 +186,35 @@ export function BulkAdminController(app: HonoApp) {
|
||||
return ctx.json({job_id: jobId.toString()});
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/bulk/delete-user-messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
|
||||
requireAdminACL(AdminACLs.BULK_DELETE_USER_MESSAGES),
|
||||
Validator('json', BulkDeleteUserMessagesRequest),
|
||||
OpenAPI({
|
||||
operationId: 'bulk_delete_user_messages',
|
||||
summary: 'Bulk delete user messages',
|
||||
description:
|
||||
'Enqueue a background job that deletes every message authored by each of the given users across all channels. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.',
|
||||
responseSchema: BulkJobResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
const jobId = await getWorkerService().addJob(
|
||||
'bulkDeleteMessagesForUsers',
|
||||
{
|
||||
user_ids: body.user_ids.map((id) => id.toString()),
|
||||
admin_user_id: adminUserId.toString(),
|
||||
audit_log_reason: auditLogReason,
|
||||
},
|
||||
{requestedByUserId: adminUserId, ...(auditLogReason && {auditLogReason})},
|
||||
);
|
||||
return ctx.json({job_id: jobId.toString()});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ const LANE_CONFIG = {
|
||||
'bulkUpdateGuildFeatures',
|
||||
'bulkAddGuildMembers',
|
||||
'bulkBanFileShas',
|
||||
'bulkDeleteMessagesForUsers',
|
||||
] as const,
|
||||
retiredTasks: ['sendScheduledMessage'],
|
||||
concurrency: 8,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import applicationProcessDeletion from './tasks/ApplicationProcessDeletion';
|
||||
import bulkAddGuildMembers from './tasks/admin_bulk/BulkAddGuildMembers';
|
||||
import bulkBanFileShas from './tasks/admin_bulk/BulkBanFileShas';
|
||||
import bulkDeleteMessagesForUsers from './tasks/admin_bulk/BulkDeleteMessagesForUsers';
|
||||
import bulkScheduleUserDeletion from './tasks/admin_bulk/BulkScheduleUserDeletion';
|
||||
import bulkUpdateGuildFeatures from './tasks/admin_bulk/BulkUpdateGuildFeatures';
|
||||
import bulkUpdateSuspiciousActivityFlags from './tasks/admin_bulk/BulkUpdateSuspiciousActivityFlags';
|
||||
@@ -49,6 +50,7 @@ export const workerTasks: Record<WorkerTaskName, WorkerTaskHandler> = {
|
||||
batchGuildAuditLogMessageDeletes,
|
||||
bulkAddGuildMembers: bulkAddGuildMembers,
|
||||
bulkBanFileShas: bulkBanFileShas,
|
||||
bulkDeleteMessagesForUsers: bulkDeleteMessagesForUsers,
|
||||
bulkDeleteSelfMessagesImmediate,
|
||||
bulkDeleteUserMessages,
|
||||
bulkDeleteUserMessagesScoped,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {JobCancelledError} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {AdminAuditService} from '../../../admin/services/AdminAuditService';
|
||||
import {createUserID} from '../../../BrandedTypes';
|
||||
import {UserMessageDeletionService} from '../../../channel/services/message/UserMessageDeletionService';
|
||||
import {getWorkerDependencies} from '../../WorkerContext';
|
||||
|
||||
interface Payload {
|
||||
user_ids: Array<string>;
|
||||
admin_user_id: string;
|
||||
audit_log_reason: string | null;
|
||||
}
|
||||
|
||||
const handler: WorkerTaskHandler = async (rawPayload, helpers) => {
|
||||
const payload: Payload = {
|
||||
user_ids: rawPayload.user_ids as Array<string>,
|
||||
admin_user_id: rawPayload.admin_user_id as string,
|
||||
audit_log_reason: (rawPayload.audit_log_reason as string | null) ?? null,
|
||||
};
|
||||
const deps = getWorkerDependencies();
|
||||
const auditService = new AdminAuditService(deps.adminRepository, deps.snowflakeService);
|
||||
const deletionService = new UserMessageDeletionService({
|
||||
channelRepository: deps.channelRepository,
|
||||
gatewayService: deps.gatewayService,
|
||||
storageService: deps.storageService,
|
||||
purgeQueue: deps.purgeQueue,
|
||||
});
|
||||
const adminUserId = createUserID(BigInt(payload.admin_user_id));
|
||||
const total = payload.user_ids.length;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}> = [];
|
||||
let deletedMessages = 0;
|
||||
await helpers.setContextLink(`/users?ids=${payload.user_ids.slice(0, 50).join(',')}`);
|
||||
await helpers.reportProgress(0, total, `Deleting all messages from ${total} users`);
|
||||
for (let i = 0; i < payload.user_ids.length; i++) {
|
||||
if (await helpers.shouldCancel()) throw new JobCancelledError();
|
||||
const rawUserId = payload.user_ids[i]!;
|
||||
try {
|
||||
const userId = createUserID(BigInt(rawUserId));
|
||||
const deleted = await deletionService.deleteUserMessagesBulk(userId);
|
||||
deletedMessages += deleted;
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'message_deletion',
|
||||
targetId: BigInt(userId),
|
||||
action: 'delete_all_user_messages',
|
||||
auditLogReason: null,
|
||||
metadata: new Map([['message_count', deleted.toString()]]),
|
||||
});
|
||||
successful.push(rawUserId);
|
||||
} catch (err) {
|
||||
failed.push({id: rawUserId, error: err instanceof Error ? err.message : String(err)});
|
||||
}
|
||||
await helpers.reportProgress(i + 1, total, `${deletedMessages} messages deleted`);
|
||||
}
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'message_deletion',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_delete_user_messages',
|
||||
auditLogReason: payload.audit_log_reason,
|
||||
metadata: new Map([
|
||||
['user_count', total.toString()],
|
||||
['message_count', deletedMessages.toString()],
|
||||
['successful', successful.length.toString()],
|
||||
['failed', failed.length.toString()],
|
||||
]),
|
||||
});
|
||||
await helpers.reportProgress(total, total, `${deletedMessages} messages deleted, ${failed.length} users failed`);
|
||||
helpers.logger.info(
|
||||
{successful: successful.length, failed: failed.length, deletedMessages},
|
||||
'bulkDeleteMessagesForUsers complete',
|
||||
);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -44,6 +44,7 @@ export const AdminACLs = {
|
||||
BAN_PROFILE_SUBSTRING_REMOVE: 'ban:profile_substring:remove',
|
||||
BULK_ADD_GUILD_MEMBERS: 'bulk:add:guild_members',
|
||||
BULK_DELETE_USERS: 'bulk:delete:users',
|
||||
BULK_DELETE_USER_MESSAGES: 'bulk:delete:user_messages',
|
||||
BULK_UPDATE_GUILD_FEATURES: 'bulk:update:guild_features',
|
||||
BULK_UPDATE_SUSPICIOUS_ACTIVITY: 'bulk:update:suspicious_activity',
|
||||
BULK_UPDATE_USER_FLAGS: 'bulk:update:user_flags',
|
||||
|
||||
@@ -81,3 +81,9 @@ export const DeleteAllUserMessagesResponse = z.object({
|
||||
});
|
||||
|
||||
export type DeleteAllUserMessagesResponse = z.infer<typeof DeleteAllUserMessagesResponse>;
|
||||
|
||||
export const BulkDeleteUserMessagesRequest = z.object({
|
||||
user_ids: z.array(SnowflakeType).max(1000).describe('List of user IDs whose messages will be deleted'),
|
||||
});
|
||||
|
||||
export type BulkDeleteUserMessagesRequest = z.infer<typeof BulkDeleteUserMessagesRequest>;
|
||||
|
||||
Reference in New Issue
Block a user