Compare commits

...
874 changed files with 4826 additions and 4194 deletions
+74 -5
View File
@@ -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"
}
},
+2
View File
@@ -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,
+12
View File
@@ -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"))
}))
}
}
},
)
}
-1
View File
@@ -428,7 +428,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
},
bluesky: master.auth.bluesky as BlueskyOAuthConfig,
},
cookie: master.cookie,
klipy: {
apiKey: master.integrations.klipy.api_key,
},
@@ -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()});
},
);
}
@@ -11,7 +11,6 @@ import * as AuthPassword from '../../auth/AuthPassword';
import {SUDO_MODE_HEADER} from '../../middleware/SudoModeMiddleware';
import type {User} from '../../models/User';
import type {HonoEnv} from '../../types/HonoEnv';
import {setSudoCookie} from '../../utils/SudoCookieUtils';
import {getSudoModeService} from './SudoModeService';
export interface SudoVerificationBody {
@@ -127,10 +126,6 @@ function setSudoTokenHeader(
const tokenToSet = result.sudoToken ?? ctx.req.header(SUDO_MODE_HEADER);
if (tokenToSet) {
ctx.header(SUDO_MODE_HEADER, tokenToSet);
const user = ctx.get('user');
if (user) {
setSudoCookie(ctx, tokenToSet, user.id.toString());
}
}
}
-4
View File
@@ -281,10 +281,6 @@ export interface APIConfig {
};
bluesky: BlueskyOAuthConfig;
};
cookie: {
domain: string;
secure: boolean;
};
klipy: {
apiKey?: string;
};
@@ -3,7 +3,6 @@
import {createMiddleware} from 'hono/factory';
import {getSudoModeService} from '../auth/services/SudoModeService';
import type {HonoEnv} from '../types/HonoEnv';
import {getSudoCookie} from '../utils/SudoCookieUtils';
export const SUDO_MODE_HEADER = 'X-Fluxer-Sudo-Mode-JWT';
export const SudoModeMiddleware = createMiddleware<HonoEnv>(async (ctx, next) => {
@@ -19,11 +18,7 @@ export const SudoModeMiddleware = createMiddleware<HonoEnv>(async (ctx, next) =>
await next();
return;
}
const sudoToken = ctx.req.header(SUDO_MODE_HEADER);
let tokenToVerify: string | undefined = sudoToken;
if (!tokenToVerify) {
tokenToVerify = getSudoCookie(ctx, user.id.toString());
}
const tokenToVerify = ctx.req.header(SUDO_MODE_HEADER);
if (!tokenToVerify) {
await next();
return;
@@ -1,58 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Context} from 'hono';
import {getCookie, setCookie} from 'hono/cookie';
import {seconds} from 'itty-time';
import {Config} from '../Config';
import type {HonoEnv} from '../types/HonoEnv';
const SUDO_COOKIE_PREFIX = '__flx_sudo';
const SUDO_COOKIE_MAX_AGE = seconds('5 minutes');
function getCookieDomain(): string {
const domain = Config.cookie.domain;
if (domain) {
return domain;
}
try {
const url = new URL(Config.endpoints.webApp);
const hostname = url.hostname;
const parts = hostname.split('.');
if (parts.length >= 2) {
return `.${parts.slice(-2).join('.')}`;
} else {
return hostname;
}
} catch {
return '';
}
}
function getSudoCookieOptions() {
return {
httpOnly: true,
secure: Config.cookie.secure,
sameSite: 'Strict' as const,
domain: getCookieDomain(),
path: '/',
maxAge: SUDO_COOKIE_MAX_AGE,
};
}
function sudoCookieName(userId?: string | number): string {
if (userId === undefined || userId === null) {
return SUDO_COOKIE_PREFIX;
}
return `${SUDO_COOKIE_PREFIX}_${userId}`;
}
export function setSudoCookie(ctx: Context<HonoEnv>, token: string, userId?: string | number): void {
const cookieName = sudoCookieName(userId);
const options = getSudoCookieOptions();
setCookie(ctx, cookieName, token, options);
}
export function getSudoCookie(ctx: Context<HonoEnv>, userId?: string | number): string | undefined {
const cookieName = sudoCookieName(userId);
return getCookie(ctx, cookieName);
}
@@ -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;
@@ -193,6 +193,20 @@ describe('the cascade the gate is computed from', () => {
expect(chain.indexOf('Fluxer Sans TC')).toBeLessThan(chain.indexOf('Fluxer Sans JP'));
});
it('leaves kana to JP, so the Han tie-break cannot claim text that is only ever Japanese', () => {
for (const sheet of ['script-sc.css', 'script-tc.css']) {
const text = readFileSync(join(FONTS_CSS, sheet), 'utf8');
for (const [, value] of text.matchAll(/unicode-range:([^;]*);/g)) {
for (const part of value.split(',')) {
const [start, end] = part.trim().replace('U+', '').split('-');
const from = Number.parseInt(start, 16);
const to = end === undefined ? from : Number.parseInt(end, 16);
expect(from > 0x30ff || to < 0x3040, `${sheet} claims ${part.trim()}`).toBe(true);
}
}
}
});
it('hoists the family hanChunkForLanguage picks, for every locale rule that exists', () => {
const expected: Record<string, string> = {
ja: 'Fluxer Sans JP',
-4
View File
@@ -181,10 +181,6 @@ function defaultConfig(): MasterConfig {
keys: [],
},
},
cookie: {
domain: '',
secure: false,
},
integrations: {
email: {
enabled: false,
-4
View File
@@ -203,10 +203,6 @@ export interface MasterConfig {
}>;
};
};
cookie: {
domain: string;
secure: boolean;
};
integrations: {
email: {
enabled: boolean;
@@ -371,8 +371,6 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
FLUXER_PUSH_FCM_SERVICE_ACCOUNT_JSON_PATH: {path: ['integrations', 'push', 'fcm', 'service_account_json_path']},
FLUXER_PUSH_FCM_TOKEN_URI: {path: ['integrations', 'push', 'fcm', 'token_uri']},
FLUXER_PUSH_FCM_APPS: {path: ['integrations', 'push', 'fcm', 'apps'], parse: parseEnvValue},
FLUXER_COOKIE_DOMAIN: {path: ['cookie', 'domain']},
FLUXER_COOKIE_SECURE: {path: ['cookie', 'secure'], parse: parseEnvValue},
FLUXER_SELF_HOSTED: {path: ['instance', 'self_hosted'], parse: parseEnvValue},
FLUXER_AUTO_JOIN_INVITE_CODE: {path: ['instance', 'auto_join_invite_code']},
FLUXER_VISIONARIES_GUILD_ID: {path: ['instance', 'visionaries_guild_id']},
+1
View File
@@ -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',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More