// SPDX-FileCopyrightText: 2026 Ololduck <ololduck@vit.am> and contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
//! Moderation operations: user deletion with full cascade and grace-period
//! scheduling, plus defederation enforcement helpers.
//!
//! Handles the complex task of deleting a user and all their associated data,
//! including files on disk and bare git repositories. Deletion is scheduled
//! with a grace period to allow federation of Delete(Person) activities before
//! the user's actor is removed.
//!
//! Also provides helpers for checking instance-wide domain blocks and
//! admin-level remote actor blocks to enforce defederation.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
use tokio::sync::RwLock;
use tracing::{error, info, instrument, warn};
use uuid::Uuid;
use crate::activitypub::ApUrls;
use crate::attachments::file_store::FileStore;
use crate::entities::{
admin_remote_blocks, ap_remote_actors, attachments, comments, instance_blocks,
pending_deletions, repositories, tickets, user_blocks, user_silences, users,
};
/// Poll interval for the background deletion worker (in seconds).
const DELETION_WORKER_POLL_INTERVAL_SECS: u64 = 60;
/// Schedule a user for deletion after a grace period.
///
/// This function:
/// 1. Emits a Delete(Person) activity to all followers (if federation is
/// enabled)
/// 2. Inserts a pending_deletions row with execute_at = now + grace_period
///
/// The actual deletion happens later via the background deletion worker.
/// During the grace period, the user's actor and public key remain queryable
/// so remote servers can verify the Delete signature.
///
/// # Arguments
/// * `db` - Database connection
/// * `public_url` - Public URL for federation (empty if federation disabled)
/// * `user_id` - ID of the user to schedule for deletion
/// * `grace_period_seconds` - How long to wait before executing the deletion
///
/// # Returns
/// Ok(()) on success, or an error if scheduling fails.
#[instrument(skip(db), fields(user_id, grace_period_seconds))]
pub async fn schedule_user_deletion(
db: &DatabaseConnection,
public_url: &str,
user_id: i64,
grace_period_seconds: u64,
) -> Result<(), ScheduleDeletionError> {
info!("Scheduling user deletion with grace period");
// Check if user exists
let user = users::Entity::find_by_id(user_id)
.one(db)
.await
.map_err(ScheduleDeletionError::Database)?
.ok_or(ScheduleDeletionError::UserNotFound)?;
// Try to emit Delete(Person) activity if federation is enabled
if !public_url.is_empty()
&& let Err(e) = emit_delete_activity(db, public_url, user_id, &user.name).await
{
warn!(
user_id = user_id,
error = %e,
"Failed to emit Delete(Person) activity, proceeding with deletion scheduling"
);
}
// Schedule the deletion
let now = Utc::now();
let execute_at = now + chrono::Duration::seconds(grace_period_seconds as i64);
let pending = pending_deletions::ActiveModel {
user_id: Set(user_id),
scheduled_at: Set(now.into()),
execute_at: Set(execute_at.into()),
..Default::default()
};
pending
.insert(db)
.await
.map_err(ScheduleDeletionError::Database)?;
info!(
user_id = user_id,
execute_at = %execute_at,
"User deletion scheduled"
);
Ok(())
}
/// Emit a Delete(Person) activity for the user.
///
/// This creates a Delete activity and enqueues deliveries to all followers.
/// The activity is signed by the user's actor key.
async fn emit_delete_activity(
db: &DatabaseConnection,
public_url: &str,
user_id: i64,
user_name: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use crate::activitypub::actor::get_or_create;
use crate::activitypub::forge::activity::publish_activity;
// Get or create the user's actor
let actor = get_or_create(db, user_id).await?;
let urls = ApUrls::new(public_url);
let actor_url = urls.actor(user_name);
// Build the Delete activity JSON
let activity_id = format!("{}#delete-{}", actor_url, Uuid::new_v4());
let delete_activity = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": activity_id,
"type": "Delete",
"actor": actor_url,
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"object": actor_url
});
let activity_json = serde_json::to_string(&delete_activity)?;
// Publish the activity (enqueues deliveries to followers)
publish_activity(db, actor.id, &activity_json, &actor_url).await?;
info!(user_id = user_id, actor_url = %actor_url, "Emitted Delete(Person) activity");
Ok(())
}
/// Delete a user and all their associated data.
///
/// This performs a full cascade deletion:
/// 1. Delete attachment files from disk via FileStore
/// 2. Let SQLite's CASCADE DELETE handle DB rows (most FKs are CASCADE)
/// 3. Remove bare git repository directories for all user-owned repos
///
/// This function is called by the background deletion worker after the grace
/// period has elapsed.
///
/// # Arguments
/// * `db` - Database connection
/// * `file_store` - File store for deleting attachment files
/// * `repo_root` - Root directory for bare git repositories
/// * `user_id` - ID of the user to delete
///
/// # Returns
/// Ok(()) on success, or an error if a critical operation fails.
#[instrument(skip(db, file_store), fields(user_id))]
pub async fn delete_user(
db: &DatabaseConnection,
file_store: &FileStore,
repo_root: &PathBuf,
user_id: i64,
) -> Result<(), DeleteUserError> {
info!("Starting user deletion cascade");
// Step 1: Get user info before deletion (need name for repo cleanup)
let user = users::Entity::find_by_id(user_id)
.one(db)
.await
.map_err(DeleteUserError::Database)?
.ok_or(DeleteUserError::UserNotFound)?;
let user_name = user.name.clone();
// Step 2: Delete attachment files from disk
delete_user_attachment_files(db, file_store, user_id).await?;
// Step 3: Get list of user's repositories before deletion
let user_repos = repositories::Entity::find()
.filter(repositories::Column::OwnerId.eq(user_id))
.all(db)
.await
.map_err(DeleteUserError::Database)?;
// Step 4: Delete the user - SQLite CASCADE DELETE will handle most rows
users::Entity::delete_by_id(user_id)
.exec(db)
.await
.map_err(DeleteUserError::Database)?;
info!("User deleted from database, cleaning up filesystem");
// Step 5: Remove bare git repository directories
cleanup_user_repos(repo_root, &user_name, &user_repos).await;
info!("User deletion cascade complete");
Ok(())
}
/// Delete all attachment files owned by a user from disk.
async fn delete_user_attachment_files(
db: &DatabaseConnection,
file_store: &FileStore,
user_id: i64,
) -> Result<(), DeleteUserError> {
let repos = repositories::Entity::find()
.filter(repositories::Column::OwnerId.eq(user_id))
.all(db)
.await
.map_err(DeleteUserError::Database)?;
let repo_ids: Vec<i64> = repos.iter().map(|r| r.id).collect();
let user_comments = comments::Entity::find()
.filter(comments::Column::AuthorId.eq(user_id))
.all(db)
.await
.map_err(DeleteUserError::Database)?;
let comment_ids: Vec<i64> = user_comments.iter().map(|c| c.id).collect();
let comment_attachments = attachments::Entity::find()
.filter(attachments::Column::CommentId.is_in(comment_ids.clone()))
.all(db)
.await
.map_err(DeleteUserError::Database)?;
let repo_tickets = tickets::Entity::find()
.filter(tickets::Column::RepositoryId.is_in(repo_ids.clone()))
.all(db)
.await
.map_err(DeleteUserError::Database)?;
let ticket_ids: Vec<i64> = repo_tickets.iter().map(|t| t.id).collect();
let ticket_attachments = attachments::Entity::find()
.filter(attachments::Column::TicketId.is_in(ticket_ids.clone()))
.all(db)
.await
.map_err(DeleteUserError::Database)?;
let all_attachments: Vec<_> = comment_attachments
.into_iter()
.chain(ticket_attachments)
.collect();
for attachment in all_attachments {
if let Err(e) = file_store
.delete(attachment.uuid, &attachment.content_type)
.await
{
warn!(
attachment_uuid = %attachment.uuid,
error = %e,
"Failed to delete attachment file, continuing with deletion"
);
}
}
Ok(())
}
/// Remove bare git repository directories for a deleted user.
async fn cleanup_user_repos(repo_root: &Path, user_name: &str, repos: &[repositories::Model]) {
let user_repo_dir = repo_root.join(user_name);
if user_repo_dir.exists() {
match tokio::fs::remove_dir_all(&user_repo_dir).await {
Ok(_) => {
info!(
user = %user_name,
repo_count = repos.len(),
"Removed user's bare git repository directory"
);
}
Err(e) => {
error!(
user = %user_name,
path = %user_repo_dir.display(),
error = %e,
"Failed to remove user's bare git repository directory"
);
}
}
}
}
/// Process pending deletions that are ready to execute.
///
/// This function is called by the background deletion worker. It queries
/// pending_deletions where execute_at <= now, executes the deletion for each,
/// and removes the pending_deletions row.
pub async fn process_pending_deletions(
db: &DatabaseConnection,
file_store: &FileStore,
repo_root: &PathBuf,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let now = Utc::now();
let pending = pending_deletions::Entity::find()
.filter(pending_deletions::Column::ExecuteAt.lte(now))
.all(db)
.await?;
for deletion in pending {
info!(
pending_deletion_id = deletion.id,
user_id = deletion.user_id,
"Processing pending deletion"
);
match delete_user(db, file_store, repo_root, deletion.user_id).await {
Ok(_) => {
info!(
pending_deletion_id = deletion.id,
user_id = deletion.user_id,
"Deletion completed successfully"
);
}
Err(e) => {
error!(
pending_deletion_id = deletion.id,
user_id = deletion.user_id,
error = %e,
"Deletion failed"
);
}
}
pending_deletions::Entity::delete_by_id(deletion.id)
.exec(db)
.await?;
}
Ok(())
}
/// Spawn the background deletion worker.
///
/// This worker polls pending_deletions every DELETION_WORKER_POLL_INTERVAL_SECS
/// and executes deletions that are ready (execute_at <= now).
pub fn spawn_deletion_worker(state: Arc<RwLock<crate::AppState>>) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(DELETION_WORKER_POLL_INTERVAL_SECS)).await;
let state_guard = state.read().await;
let db = state_guard.db.clone();
let file_store = state_guard.file_store.clone();
let repo_root = state_guard.repo_root.clone();
drop(state_guard);
if let Err(e) = process_pending_deletions(&db, &file_store, &repo_root).await {
error!(error = %e, "deletion worker error");
}
}
})
}
/// Normalize a user-supplied domain or URL to a lowercase host.
///
/// Accepts forms like `Example.ORG`, `https://example.org/users/alice`,
/// `example.org:8443/path`, or `example.org?x=1`. The scheme, port, path,
/// query, and fragment are dropped: instance blocks are host-based, matching
/// what [`is_domain_blocked`] extracts from actor URLs.
///
/// Returns `None` for empty or unparseable input.
pub fn normalize_domain(input: &str) -> Option<String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return None;
}
let candidate = if trimmed.contains("://") {
trimmed.to_string()
} else {
format!("http://{trimmed}")
};
url::Url::parse(&candidate)
.ok()
.and_then(|parsed| parsed.host_str().map(str::to_lowercase))
}
/// Check if a domain is blocked instance-wide.
///
/// The input can be either a full URL or just a domain name. It is normalized
/// to a lowercase host before comparing against the instance_blocks table.
pub async fn is_domain_blocked(
db: &DatabaseConnection,
url_or_domain: &str,
) -> Result<bool, sea_orm::DbErr> {
let Some(domain) = normalize_domain(url_or_domain) else {
return Ok(false);
};
let blocked = instance_blocks::Entity::find()
.filter(instance_blocks::Column::Domain.eq(domain))
.one(db)
.await?;
Ok(blocked.is_some())
}
/// Check if a specific remote actor is blocked by an admin.
///
/// Looks up the actor in ap_remote_actors by their actor_url, then checks
/// if there's a corresponding entry in admin_remote_blocks. Returns false
/// if the actor is not cached or not blocked.
pub async fn is_remote_actor_blocked(
db: &DatabaseConnection,
actor_url: &str,
) -> Result<bool, sea_orm::DbErr> {
let actor = ap_remote_actors::Entity::find()
.filter(ap_remote_actors::Column::ActorUrl.eq(actor_url))
.one(db)
.await?;
let Some(actor) = actor else {
return Ok(false);
};
let blocked = admin_remote_blocks::Entity::find()
.filter(admin_remote_blocks::Column::RemoteActorId.eq(actor.id))
.one(db)
.await?;
Ok(blocked.is_some())
}
/// Combined check: is federation refused for this actor?
///
/// Returns true if either:
/// - The actor's domain is blocked instance-wide, OR
/// - The specific actor is blocked by an admin
///
/// This is the primary entry point for defederation enforcement checks.
pub async fn is_federation_refused(
db: &DatabaseConnection,
actor_url: &str,
) -> Result<bool, sea_orm::DbErr> {
if is_domain_blocked(db, actor_url).await? {
return Ok(true);
}
is_remote_actor_blocked(db, actor_url).await
}
/// Remote actor IDs whose content must be hidden from a viewer.
///
/// Includes admin-blocked actors and actors on instance-blocked domains for
/// every viewer, plus the viewer's own blocked and silenced actors when a
/// viewer ID is supplied. Used by feeds and reaction counts so defederation
/// hides content regardless of who is looking.
pub async fn excluded_remote_actor_ids(
db: &DatabaseConnection,
viewer_id: Option<i64>,
) -> Result<std::collections::HashSet<i64>, sea_orm::DbErr> {
let mut excluded = std::collections::HashSet::new();
for block in admin_remote_blocks::Entity::find().all(db).await? {
excluded.insert(block.remote_actor_id);
}
let blocked_domains: Vec<String> = instance_blocks::Entity::find()
.all(db)
.await?
.into_iter()
.filter_map(|block| normalize_domain(&block.domain))
.collect();
if !blocked_domains.is_empty() {
for actor in ap_remote_actors::Entity::find().all(db).await? {
let blocked = normalize_domain(&actor.actor_url)
.is_some_and(|domain| blocked_domains.contains(&domain));
if blocked {
excluded.insert(actor.id);
}
}
}
if let Some(viewer_id) = viewer_id {
for block in user_blocks::Entity::find()
.filter(user_blocks::Column::BlockerId.eq(viewer_id))
.all(db)
.await?
{
if let Some(remote_actor_id) = block.blocked_remote_actor_id {
excluded.insert(remote_actor_id);
}
}
for silence in user_silences::Entity::find()
.filter(user_silences::Column::BlockerId.eq(viewer_id))
.all(db)
.await?
{
if let Some(remote_actor_id) = silence.blocked_remote_actor_id {
excluded.insert(remote_actor_id);
}
}
}
Ok(excluded)
}
/// Errors that can occur when scheduling user deletion.
#[derive(Debug, thiserror::Error)]
pub enum ScheduleDeletionError {
#[error("User not found")]
UserNotFound,
#[error("Database error: {0}")]
Database(#[from] sea_orm::DbErr),
}
/// Errors that can occur during user deletion.
#[derive(Debug, thiserror::Error)]
pub enum DeleteUserError {
#[error("User not found")]
UserNotFound,
#[error("Database error: {0}")]
Database(#[from] sea_orm::DbErr),
}
#[cfg(test)]
mod tests {
use super::normalize_domain;
#[test]
fn scheme_is_stripped() {
assert_eq!(
normalize_domain("https://example.org/users/alice"),
Some("example.org".to_string())
);
assert_eq!(
normalize_domain("http://example.org"),
Some("example.org".to_string())
);
}
#[test]
fn port_is_stripped() {
assert_eq!(
normalize_domain("example.org:8443"),
Some("example.org".to_string())
);
assert_eq!(
normalize_domain("http://127.0.0.1:8081/~bob"),
Some("127.0.0.1".to_string())
);
}
#[test]
fn case_is_lowered() {
assert_eq!(
normalize_domain("Example.ORG"),
Some("example.org".to_string())
);
assert_eq!(
normalize_domain("HTTPS://EXAMPLE.ORG/"),
Some("example.org".to_string())
);
}
#[test]
fn path_and_query_are_ignored() {
assert_eq!(
normalize_domain("example.org/path?x=1#fragment"),
Some("example.org".to_string())
);
}
#[test]
fn whitespace_is_trimmed() {
assert_eq!(
normalize_domain(" example.org "),
Some("example.org".to_string())
);
}
#[test]
fn empty_or_unparseable_input_is_rejected() {
assert_eq!(normalize_domain(""), None);
assert_eq!(normalize_domain(" "), None);
assert_eq!(normalize_domain("http://"), None);
assert_eq!(normalize_domain("not a domain"), None);
}
}