Compare commits

..

2 commits

Author SHA1 Message Date
Jade Ellis
9bef2972ac
feat: Add command to purge sync tokens for empty rooms 2025-05-21 22:44:15 +01:00
Jade Ellis
002c64ca88
feat: Add admin command to delete sync tokens from a room 2025-05-21 22:24:33 +01:00
8 changed files with 74 additions and 140 deletions

View file

@ -19,20 +19,11 @@ outputs:
rustc_version: rustc_version:
description: The rustc version installed description: The rustc version installed
value: ${{ steps.rustc-version.outputs.version }} value: ${{ steps.rustc-version.outputs.version }}
rustup_version:
description: The rustup version installed
value: ${{ steps.rustup-version.outputs.version }}
runs: runs:
using: composite using: composite
steps: steps:
- name: Check if rustup is already installed
shell: bash
id: rustup-version
run: |
echo "version=$(rustup --version)" >> $GITHUB_OUTPUT
- name: Cache rustup toolchains - name: Cache rustup toolchains
if: steps.rustup-version.outputs.version == ''
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
path: | path: |
@ -42,7 +33,6 @@ runs:
# Requires repo to be cloned if toolchain is not specified # Requires repo to be cloned if toolchain is not specified
key: ${{ runner.os }}-rustup-${{ inputs.toolchain || hashFiles('**/rust-toolchain.toml') }} key: ${{ runner.os }}-rustup-${{ inputs.toolchain || hashFiles('**/rust-toolchain.toml') }}
- name: Install Rust toolchain - name: Install Rust toolchain
if: steps.rustup-version.outputs.version == ''
shell: bash shell: bash
run: | run: |
if ! command -v rustup &> /dev/null ; then if ! command -v rustup &> /dev/null ; then

View file

@ -57,6 +57,7 @@ jobs:
build-image: build-image:
runs-on: dind runs-on: dind
container: ghcr.io/catthehacker/ubuntu:act-latest
needs: define-variables needs: define-variables
permissions: permissions:
contents: read contents: read
@ -210,6 +211,7 @@ jobs:
merge: merge:
runs-on: dind runs-on: dind
container: ghcr.io/catthehacker/ubuntu:act-latest
needs: [define-variables, build-image] needs: [define-variables, build-image]
steps: steps:
- name: Download digests - name: Download digests

View file

@ -1641,29 +1641,19 @@
# #
#server = #server =
# URL to a support page for the server, which will be served as part of # This item is undocumented. Please contribute documentation for it.
# the MSC1929 server support endpoint at /.well-known/matrix/support.
# Will be included alongside any contact information
# #
#support_page = #support_page =
# Role string for server support contacts, to be served as part of the # This item is undocumented. Please contribute documentation for it.
# MSC1929 server support endpoint at /.well-known/matrix/support.
# #
#support_role = "m.role.admin" #support_role =
# Email address for server support contacts, to be served as part of the # This item is undocumented. Please contribute documentation for it.
# MSC1929 server support endpoint.
# This will be used along with support_mxid if specified.
# #
#support_email = #support_email =
# Matrix ID for server support contacts, to be served as part of the # This item is undocumented. Please contribute documentation for it.
# MSC1929 server support endpoint.
# This will be used along with support_email if specified.
#
# If no email or mxid is specified, all of the server's admins will be
# listed.
# #
#support_mxid = #support_mxid =

View file

@ -73,8 +73,9 @@ pub(super) async fn purge_sync_tokens(&self, room: OwnedRoomOrAliasId) -> Result
let room_id = self.services.rooms.alias.resolve(&room).await?; let room_id = self.services.rooms.alias.resolve(&room).await?;
// Delete all tokens for this room using the service method // Delete all tokens for this room using the service method
let Ok(deleted_count) = self.services.rooms.user.delete_room_tokens(&room_id).await else { let deleted_count = match self.services.rooms.user.delete_room_tokens(&room_id).await {
return Err!("Failed to delete sync tokens for room {}", room_id); | Ok(count) => count,
| Err(_) => return Err!("Failed to delete sync tokens for room {}", room_id),
}; };
self.write_str(&format!( self.write_str(&format!(
@ -83,23 +84,12 @@ pub(super) async fn purge_sync_tokens(&self, room: OwnedRoomOrAliasId) -> Result
.await .await
} }
/// Target options for room purging
#[derive(Default, Debug, clap::ValueEnum, Clone)]
pub(crate) enum RoomTargetOption {
#[default]
/// Target all rooms
All,
/// Target only disabled rooms
DisabledOnly,
/// Target only banned rooms
BannedOnly,
}
#[admin_command] #[admin_command]
pub(super) async fn purge_empty_room_tokens( pub(super) async fn purge_empty_room_tokens(
&self, &self,
yes: bool, yes: bool,
target_option: Option<RoomTargetOption>, target_disabled: bool,
target_banned: bool,
dry_run: bool, dry_run: bool,
) -> Result { ) -> Result {
use conduwuit::{debug, info}; use conduwuit::{debug, info};
@ -113,13 +103,11 @@ pub(super) async fn purge_empty_room_tokens(
let mode = if dry_run { "Simulating" } else { "Starting" }; let mode = if dry_run { "Simulating" } else { "Starting" };
// strictly, we should check if these reach the max value after the loop and let mut total_rooms_processed = 0;
// warn the user that the count is too large let mut empty_rooms_processed = 0;
let mut total_rooms_processed: usize = 0; let mut total_tokens_deleted = 0;
let mut empty_rooms_processed: u32 = 0; let mut error_count = 0;
let mut total_tokens_deleted: usize = 0; let mut skipped_rooms = 0;
let mut error_count: u32 = 0;
let mut skipped_rooms: u32 = 0;
info!("{} purge of sync tokens for rooms with no local users", mode); info!("{} purge of sync tokens for rooms with no local users", mode);
@ -137,24 +125,17 @@ pub(super) async fn purge_empty_room_tokens(
// Filter rooms based on options // Filter rooms based on options
let mut rooms = Vec::new(); let mut rooms = Vec::new();
for room_id in all_rooms { for room_id in all_rooms {
if let Some(target) = &target_option { // Filter rooms based on targeting options
match target { let is_disabled = self.services.rooms.metadata.is_disabled(room_id).await;
| RoomTargetOption::DisabledOnly => { let is_banned = self.services.rooms.metadata.is_banned(room_id).await;
if !self.services.rooms.metadata.is_disabled(room_id).await {
debug!("Skipping room {} as it's not disabled", room_id); // If targeting specific types of rooms, only include matching rooms
skipped_rooms = skipped_rooms.saturating_add(1); if (target_disabled || target_banned)
continue; && !((target_disabled && is_disabled) || (target_banned && is_banned))
} {
}, debug!("Skipping room {} as it doesn't match targeting criteria", room_id);
| RoomTargetOption::BannedOnly => { skipped_rooms += 1;
if !self.services.rooms.metadata.is_banned(room_id).await { continue;
debug!("Skipping room {} as it's not banned", room_id);
skipped_rooms = skipped_rooms.saturating_add(1);
continue;
}
},
| RoomTargetOption::All => {},
}
} }
rooms.push(room_id); rooms.push(room_id);
@ -169,7 +150,7 @@ pub(super) async fn purge_empty_room_tokens(
// Process each room // Process each room
for room_id in rooms { for room_id in rooms {
total_rooms_processed = total_rooms_processed.saturating_add(1); total_rooms_processed += 1;
// Count local users in this room // Count local users in this room
let local_users_count = self let local_users_count = self
@ -182,7 +163,7 @@ pub(super) async fn purge_empty_room_tokens(
// Only process rooms with no local users // Only process rooms with no local users
if local_users_count == 0 { if local_users_count == 0 {
empty_rooms_processed = empty_rooms_processed.saturating_add(1); empty_rooms_processed += 1;
// In dry run mode, just count what would be deleted, don't actually delete // In dry run mode, just count what would be deleted, don't actually delete
debug!( debug!(
@ -201,13 +182,13 @@ pub(super) async fn purge_empty_room_tokens(
| Ok(count) => | Ok(count) =>
if count > 0 { if count > 0 {
debug!("Would delete {} sync tokens for room {}", count, room_id); debug!("Would delete {} sync tokens for room {}", count, room_id);
total_tokens_deleted = total_tokens_deleted.saturating_add(count); total_tokens_deleted += count;
} else { } else {
debug!("No sync tokens found for room {}", room_id); debug!("No sync tokens found for room {}", room_id);
}, },
| Err(e) => { | Err(e) => {
debug!("Error counting sync tokens for room {}: {:?}", room_id, e); debug!("Error counting sync tokens for room {}: {:?}", room_id, e);
error_count = error_count.saturating_add(1); error_count += 1;
}, },
} }
} else { } else {
@ -216,13 +197,13 @@ pub(super) async fn purge_empty_room_tokens(
| Ok(count) => | Ok(count) =>
if count > 0 { if count > 0 {
debug!("Deleted {} sync tokens for room {}", count, room_id); debug!("Deleted {} sync tokens for room {}", count, room_id);
total_tokens_deleted = total_tokens_deleted.saturating_add(count); total_tokens_deleted += count;
} else { } else {
debug!("No sync tokens found for room {}", room_id); debug!("No sync tokens found for room {}", room_id);
}, },
| Err(e) => { | Err(e) => {
debug!("Error purging sync tokens for room {}: {:?}", room_id, e); debug!("Error purging sync tokens for room {}: {:?}", room_id, e);
error_count = error_count.saturating_add(1); error_count += 1;
}, },
} }
} }

View file

@ -5,7 +5,6 @@ mod info;
mod moderation; mod moderation;
use clap::Subcommand; use clap::Subcommand;
use commands::RoomTargetOption;
use conduwuit::Result; use conduwuit::Result;
use ruma::{OwnedRoomId, OwnedRoomOrAliasId}; use ruma::{OwnedRoomId, OwnedRoomOrAliasId};
@ -23,13 +22,13 @@ pub(super) enum RoomCommand {
ListRooms { ListRooms {
page: Option<usize>, page: Option<usize>,
/// Excludes rooms that we have federation disabled with /// Only purge rooms that have federation disabled
#[arg(long)] #[arg(long)]
exclude_disabled: bool, only_disabled: bool,
/// Excludes rooms that we have banned /// Only purge rooms that have been banned
#[arg(long)] #[arg(long)]
exclude_banned: bool, only_banned: bool,
#[arg(long)] #[arg(long)]
/// Whether to only output room IDs without supplementary room /// Whether to only output room IDs without supplementary room
@ -67,15 +66,21 @@ pub(super) enum RoomCommand {
/// - Delete sync tokens for all rooms that have no local users /// - Delete sync tokens for all rooms that have no local users
/// ///
/// By default, processes all empty rooms. /// By default, processes all empty rooms. You can use --target-disabled
/// and/or --target-banned to exclusively process rooms matching those
/// conditions.
PurgeEmptyRoomTokens { PurgeEmptyRoomTokens {
/// Confirm you want to delete tokens from potentially many rooms /// Confirm you want to delete tokens from potentially many rooms
#[arg(long)] #[arg(long)]
yes: bool, yes: bool,
/// Target specific room types /// Only purge rooms that have federation disabled
#[arg(long, value_enum)] #[arg(long)]
target_option: Option<RoomTargetOption>, target_disabled: bool,
/// Only purge rooms that have been banned
#[arg(long)]
target_banned: bool,
/// Perform a dry run without actually deleting any tokens /// Perform a dry run without actually deleting any tokens
#[arg(long)] #[arg(long)]

View file

@ -1,6 +1,5 @@
use axum::{Json, extract::State, response::IntoResponse}; use axum::{Json, extract::State, response::IntoResponse};
use conduwuit::{Error, Result}; use conduwuit::{Error, Result};
use futures::StreamExt;
use ruma::api::client::{ use ruma::api::client::{
discovery::{ discovery::{
discover_homeserver::{self, HomeserverInfo, SlidingSyncProxyInfo}, discover_homeserver::{self, HomeserverInfo, SlidingSyncProxyInfo},
@ -18,7 +17,7 @@ pub(crate) async fn well_known_client(
State(services): State<crate::State>, State(services): State<crate::State>,
_body: Ruma<discover_homeserver::Request>, _body: Ruma<discover_homeserver::Request>,
) -> Result<discover_homeserver::Response> { ) -> Result<discover_homeserver::Response> {
let client_url = match services.config.well_known.client.as_ref() { let client_url = match services.server.config.well_known.client.as_ref() {
| Some(url) => url.to_string(), | Some(url) => url.to_string(),
| None => return Err(Error::BadRequest(ErrorKind::NotFound, "Not found.")), | None => return Err(Error::BadRequest(ErrorKind::NotFound, "Not found.")),
}; };
@ -34,63 +33,44 @@ pub(crate) async fn well_known_client(
/// # `GET /.well-known/matrix/support` /// # `GET /.well-known/matrix/support`
/// ///
/// Server support contact and support page of a homeserver's domain. /// Server support contact and support page of a homeserver's domain.
/// Implements MSC1929 for server discovery.
/// If no configuration is set, uses admin users as contacts.
pub(crate) async fn well_known_support( pub(crate) async fn well_known_support(
State(services): State<crate::State>, State(services): State<crate::State>,
_body: Ruma<discover_support::Request>, _body: Ruma<discover_support::Request>,
) -> Result<discover_support::Response> { ) -> Result<discover_support::Response> {
let support_page = services let support_page = services
.server
.config .config
.well_known .well_known
.support_page .support_page
.as_ref() .as_ref()
.map(ToString::to_string); .map(ToString::to_string);
let email_address = services.config.well_known.support_email.clone(); let role = services.server.config.well_known.support_role.clone();
let matrix_id = services.config.well_known.support_mxid.clone();
// support page or role must be either defined for this to be valid
if support_page.is_none() && role.is_none() {
return Err(Error::BadRequest(ErrorKind::NotFound, "Not found."));
}
let email_address = services.server.config.well_known.support_email.clone();
let matrix_id = services.server.config.well_known.support_mxid.clone();
// if a role is specified, an email address or matrix id is required
if role.is_some() && (email_address.is_none() && matrix_id.is_none()) {
return Err(Error::BadRequest(ErrorKind::NotFound, "Not found."));
}
// TODO: support defining multiple contacts in the config // TODO: support defining multiple contacts in the config
let mut contacts: Vec<Contact> = vec![]; let mut contacts: Vec<Contact> = vec![];
let role_value = services if let Some(role) = role {
.config let contact = Contact { role, email_address, matrix_id };
.well_known
.support_role
.clone()
.unwrap_or_else(|| "m.role.admin".to_owned().into());
// Add configured contact if at least one contact method is specified contacts.push(contact);
if email_address.is_some() || matrix_id.is_some() {
contacts.push(Contact {
role: role_value.clone(),
email_address: email_address.clone(),
matrix_id: matrix_id.clone(),
});
}
// Try to add admin users as contacts if no contacts are configured
if contacts.is_empty() {
if let Ok(admin_room) = services.admin.get_admin_room().await {
let admin_users = services.rooms.state_cache.room_members(&admin_room);
let mut stream = admin_users;
while let Some(user_id) = stream.next().await {
// Skip server user
if *user_id == services.globals.server_user {
break;
}
contacts.push(Contact {
role: role_value.clone(),
email_address: None,
matrix_id: Some(user_id.to_owned()),
});
}
}
} }
// support page or role+contacts must be either defined for this to be valid
if contacts.is_empty() && support_page.is_none() { if contacts.is_empty() && support_page.is_none() {
// No admin room, no configured contacts, and no support page
return Err(Error::BadRequest(ErrorKind::NotFound, "Not found.")); return Err(Error::BadRequest(ErrorKind::NotFound, "Not found."));
} }
@ -104,9 +84,9 @@ pub(crate) async fn well_known_support(
pub(crate) async fn syncv3_client_server_json( pub(crate) async fn syncv3_client_server_json(
State(services): State<crate::State>, State(services): State<crate::State>,
) -> Result<impl IntoResponse> { ) -> Result<impl IntoResponse> {
let server_url = match services.config.well_known.client.as_ref() { let server_url = match services.server.config.well_known.client.as_ref() {
| Some(url) => url.to_string(), | Some(url) => url.to_string(),
| None => match services.config.well_known.server.as_ref() { | None => match services.server.config.well_known.server.as_ref() {
| Some(url) => url.to_string(), | Some(url) => url.to_string(),
| None => return Err(Error::BadRequest(ErrorKind::NotFound, "Not found.")), | None => return Err(Error::BadRequest(ErrorKind::NotFound, "Not found.")),
}, },

View file

@ -1897,28 +1897,12 @@ pub struct WellKnownConfig {
/// example: "matrix.example.com:443" /// example: "matrix.example.com:443"
pub server: Option<OwnedServerName>, pub server: Option<OwnedServerName>,
/// URL to a support page for the server, which will be served as part of
/// the MSC1929 server support endpoint at /.well-known/matrix/support.
/// Will be included alongside any contact information
pub support_page: Option<Url>, pub support_page: Option<Url>,
/// Role string for server support contacts, to be served as part of the
/// MSC1929 server support endpoint at /.well-known/matrix/support.
///
/// default: "m.role.admin"
pub support_role: Option<ContactRole>, pub support_role: Option<ContactRole>,
/// Email address for server support contacts, to be served as part of the
/// MSC1929 server support endpoint.
/// This will be used along with support_mxid if specified.
pub support_email: Option<String>, pub support_email: Option<String>,
/// Matrix ID for server support contacts, to be served as part of the
/// MSC1929 server support endpoint.
/// This will be used along with support_email if specified.
///
/// If no email or mxid is specified, all of the server's admins will be
/// listed.
pub support_mxid: Option<OwnedUserId>, pub support_mxid: Option<OwnedUserId>,
} }

View file

@ -166,6 +166,9 @@ pub async fn delete_room_tokens(&self, room_id: &RoomId) -> Result<usize> {
// short ID // short ID
let prefix = &[shortroomid]; let prefix = &[shortroomid];
// Get all keys with this room prefix
let mut count = 0;
// Collect all keys into a Vec first, then delete them // Collect all keys into a Vec first, then delete them
let keys = self let keys = self
.db .db
@ -181,9 +184,8 @@ pub async fn delete_room_tokens(&self, room_id: &RoomId) -> Result<usize> {
// Delete each key individually // Delete each key individually
for key in &keys { for key in &keys {
self.db.roomsynctoken_shortstatehash.del(key); self.db.roomsynctoken_shortstatehash.del(key);
count += 1;
} }
let count = keys.len();
Ok(count) Ok(count)
} }