feat: account deletion request and active-user check

This commit is contained in:
2026-10-26 02:11:44 +01:00
parent 83bb5d9847
commit 0b5d18c4ee
27 changed files with 577 additions and 151 deletions

View File

@@ -9,6 +9,7 @@ pub const NOT_FOUND: &str = "NOT_FOUND";
pub const INVALID_INPUT: &str = "INVALID_INPUT";
pub const UNAUTHORIZED: &str = "UNAUTHORIZED";
pub const FORBIDDEN: &str = "FORBIDDEN";
pub const CONFLICT: &str = "CONFLICT";
pub const RATE_LIMITED: &str = "RATE_LIMITED";
pub const INTERNAL: &str = "INTERNAL";
@@ -32,6 +33,7 @@ pub fn domain_error_status(e: &DomainError) -> StatusCode {
DomainError::NotFound(_) => StatusCode::NOT_FOUND,
DomainError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
DomainError::Forbidden(_) => StatusCode::FORBIDDEN,
DomainError::Conflict(_) => StatusCode::CONFLICT,
DomainError::InfrastructureError(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
@@ -64,6 +66,7 @@ impl IntoResponse for ApiError {
DomainError::NotFound(_) => refuse(status, NOT_FOUND, self.0.to_string()),
DomainError::Unauthorized(_) => refuse(status, UNAUTHORIZED, self.0.to_string()),
DomainError::Forbidden(_) => refuse(status, FORBIDDEN, self.0.to_string()),
DomainError::Conflict(_) => refuse(status, CONFLICT, self.0.to_string()),
DomainError::InfrastructureError(_) => {
tracing::error!("Internal error: {:?}", self.0);
refuse(status, INTERNAL, "internal server error")

View File

@@ -8,6 +8,11 @@ use axum::{
};
use domain::{errors::DomainError, value_objects::UserId};
async fn ensure_active(state: &AppState, user_id: &UserId) -> Result<(), DomainError> {
application::users::ensure_active::execute(&state.app_ctx.deps.users.ensure_active, user_id)
.await
}
use crate::{errors::ApiError, state::AppState};
pub struct AuthenticatedUser(pub UserId);
@@ -37,6 +42,7 @@ where
.auth
.validate_token(token)
.await?;
ensure_active(&app_state, &user_id).await?;
Ok(AuthenticatedUser(user_id))
}
}
@@ -75,6 +81,10 @@ where
.validate_token(&token)
.await
.ok();
let user_id = match user_id {
Some(id) if ensure_active(&app_state, &id).await.is_ok() => Some(id),
_ => None,
};
Ok(OptionalCookieUser(user_id))
}
}
@@ -97,6 +107,14 @@ where
.validate_token(&token)
.await
.map_err(|_| Redirect::to("/login").into_response())?;
ensure_active(&app_state, &user_id)
.await
.map_err(|e| match e {
DomainError::InfrastructureError(_) => {
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
_ => Redirect::to("/login").into_response(),
})?;
Ok(RequiredCookieUser(user_id))
}
}

View File

@@ -26,6 +26,8 @@ pub fn create_image_ref(pool: SqlitePool) -> (Arc<dyn ImageRefCommand>, Arc<dyn
#[async_trait]
impl ImageRefCommand for SqliteImageRefAdapter {
// Tombstoned users (`deleted_at` set) are intentionally included: their
// avatar stays referenced, and so protected from GC, until the purge.
async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> {
let mut tx = self
.pool
@@ -52,6 +54,8 @@ impl ImageRefCommand for SqliteImageRefAdapter {
#[async_trait]
impl ImageRefQuery for SqliteImageRefAdapter {
// Tombstoned users are intentionally visible here until the purge removes
// the row, so their avatar is not garbage-collected early.
async fn list_keys(&self) -> Result<Vec<String>, DomainError> {
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT avatar_path FROM users WHERE avatar_path IS NOT NULL

View File

@@ -171,7 +171,7 @@ impl UserRepository for SqliteUserRepository {
.map_err(crate::shared::map_sqlx_error)?;
let stamped =
sqlx::query("UPDATE users SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL")
.bind(Utc::now().to_rfc3339())
.bind(Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
.bind(&id_str)
.execute(&mut *tx)
.await

View File

@@ -288,3 +288,40 @@ async fn follow_lists_omit_deleted_local_users() {
|a| a.identity == SocialIdentity::Local(domain::value_objects::UserId::from_uuid(gone))
));
}
#[tokio::test]
async fn follower_lists_omit_deleted_local_users() {
let pool = test_pool().await;
let viewer = uuid::Uuid::new_v4();
let gone = uuid::Uuid::new_v4();
let live = uuid::Uuid::new_v4();
sqlx::query(
"INSERT INTO users (id, username, deleted_at) VALUES (?1, 'gone', '2026-01-01T00:00:00Z'), (?2, 'live', NULL)",
)
.bind(gone.to_string())
.bind(live.to_string())
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO ap_followers (local_user_id, remote_actor_url, follow_activity_id, status)
VALUES (?1, 'https://md.example/users/' || ?2, '', 'accepted'),
(?1, 'https://md.example/users/' || ?3, '', 'accepted'),
(?1, 'https://other.example/users/remote', '', 'accepted')",
)
.bind(viewer.to_string())
.bind(gone.to_string())
.bind(live.to_string())
.execute(&pool)
.await
.unwrap();
let actors = FollowQuery::get_followers(&repo(pool), viewer)
.await
.unwrap();
assert_eq!(actors.len(), 2, "deleted local follower must not be listed");
assert!(!actors.iter().any(
|a| a.identity == SocialIdentity::Local(domain::value_objects::UserId::from_uuid(gone))
));
}