feat: account deletion endpoints

This commit is contained in:
2026-10-26 02:49:07 +01:00
parent 54c8433c78
commit 0beedfbf0a
14 changed files with 420 additions and 8 deletions

View File

@@ -48,7 +48,11 @@ pub fn router() -> Router<AppState> {
.route("/diary/export", routing::get(diary::export_diary)) .route("/diary/export", routing::get(diary::export_diary))
.route("/activity-feed", routing::get(diary::get_activity_feed)) .route("/activity-feed", routing::get(diary::get_activity_feed))
.route("/users", routing::get(users::list_users)) .route("/users", routing::get(users::list_users))
.route("/users/{id}", routing::get(user_profile::get_user_profile)) .route("/users/me", routing::delete(users::delete_me))
.route(
"/users/{id}",
routing::get(user_profile::get_user_profile).delete(users::delete_user),
)
.route("/import/sessions", routing::post(import::api_post_session)) .route("/import/sessions", routing::post(import::api_post_session))
.route( .route(
"/import/sessions/{id}", "/import/sessions/{id}",

View File

@@ -1,10 +1,12 @@
use crate::extractors::{Body, Upload}; use crate::extractors::{Body, PathId, Upload};
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::{ProfileResponse, UserSummaryDto, UsersResponse}; use api_types::{DeleteAccountRequest, ProfileResponse, UserSummaryDto, UsersResponse};
use application::users::{ use application::users::{
get_users, queries::GetUsersQuery, update_profile, update_profile_fields, get_users, queries::GetUsersQuery, request_account_deletion, update_profile,
update_profile_fields,
}; };
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use domain::value_objects::UserId;
#[utoipa::path( #[utoipa::path(
get, path = "/api/v1/profile", get, path = "/api/v1/profile",
@@ -155,3 +157,61 @@ pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersRespo
.collect(), .collect(),
})) }))
} }
async fn delete_account(
state: &AppState,
actor: UserId,
target: UserId,
password: &str,
) -> Result<StatusCode, ApiError> {
request_account_deletion::execute(
&state.app_ctx.deps.users.request_account_deletion,
actor,
target,
password,
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
delete, path = "/api/v1/users/me",
request_body = DeleteAccountRequest,
responses(
(status = 204, description = "Account deleted"),
(status = 400, description = "Invalid input", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized or incorrect password", body = api_types::ErrorResponse),
(status = 409, description = "The last admin cannot be deleted", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn delete_me(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Body(req): Body<DeleteAccountRequest>,
) -> Result<StatusCode, ApiError> {
delete_account(&state, user_id.clone(), user_id, &req.password).await
}
#[utoipa::path(
delete, path = "/api/v1/users/{id}",
params(("id" = uuid::Uuid, Path, description = "User ID")),
request_body = DeleteAccountRequest,
responses(
(status = 204, description = "Account deleted"),
(status = 400, description = "Invalid input", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized or incorrect password", body = api_types::ErrorResponse),
(status = 403, description = "Admin only", body = api_types::ErrorResponse),
(status = 404, description = "User not found", body = api_types::ErrorResponse),
(status = 409, description = "The last admin cannot be deleted", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn delete_user(
State(state): State<AppState>,
AuthenticatedUser(actor): AuthenticatedUser,
PathId(target): PathId<uuid::Uuid>,
Body(req): Body<DeleteAccountRequest>,
) -> Result<StatusCode, ApiError> {
delete_account(&state, actor, UserId::from_uuid(target), &req.password).await
}

View File

@@ -82,7 +82,7 @@ pub async fn post_login(
} }
} }
pub async fn get_logout(State(state): State<AppState>) -> impl IntoResponse { pub(super) async fn get_logout(State(state): State<AppState>) -> impl IntoResponse {
let val = format!( let val = format!(
"token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0{}", "token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0{}",
secure_flag(state.app_ctx.config.secure_cookies) secure_flag(state.app_ctx.config.secure_cookies)

View File

@@ -62,6 +62,13 @@ pub struct RegisterForm {
pub csrf_token: String, pub csrf_token: String,
} }
#[derive(Deserialize)]
pub struct DeleteAccountForm {
pub password: String,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ErrorQuery { pub struct ErrorQuery {
pub error: Option<String>, pub error: Option<String>,

View File

@@ -73,6 +73,11 @@ pub fn router() -> Router<AppState> {
"/settings/profile", "/settings/profile",
routing::get(users::get_profile_settings).post(users::post_profile_settings), routing::get(users::get_profile_settings).post(users::post_profile_settings),
) )
.route(
"/settings/account/delete",
routing::post(users::post_delete_own_account),
)
.route("/users/{id}/delete", routing::post(users::post_delete_user))
.route("/tags/{tag}", routing::get(tags::get_tag)) .route("/tags/{tag}", routing::get(tags::get_tag))
.route( .route(
"/users/{id}/watchlist", "/users/{id}/watchlist",

View File

@@ -189,5 +189,6 @@ pub struct ProfileSettingsTemplate<'a> {
pub also_known_as: Option<&'a str>, pub also_known_as: Option<&'a str>,
pub profile_fields: &'a [(String, String)], pub profile_fields: &'a [(String, String)],
pub saved: bool, pub saved: bool,
pub error: Option<&'a str>,
pub embed_url: String, pub embed_url: String,
} }

View File

@@ -1,3 +1,4 @@
use crate::html::forms::DeleteAccountForm;
use crate::html::helpers::build_page_context; use crate::html::helpers::build_page_context;
use crate::html::templates::{ use crate::html::templates::{
ProfileSettingsTemplate, RemoteActorDisplay, UserSummaryView, UsersTemplate, ProfileSettingsTemplate, RemoteActorDisplay, UserSummaryView, UsersTemplate,
@@ -13,11 +14,16 @@ use application::users::{
update_profile_fields, update_profile_fields,
}; };
use axum::{ use axum::{
Form,
extract::{Extension, Multipart, Path, Query, State}, extract::{Extension, Multipart, Path, Query, State},
http::StatusCode, http::StatusCode,
response::IntoResponse, response::IntoResponse,
}; };
use domain::{errors::DomainError, value_objects::Username}; use domain::{
errors::DomainError,
value_objects::{UserId, Username},
};
use uuid::Uuid;
pub async fn get_users_list( pub async fn get_users_list(
OptionalCookieUser(user_id): OptionalCookieUser, OptionalCookieUser(user_id): OptionalCookieUser,
@@ -76,6 +82,7 @@ pub async fn get_user_by_username(
#[derive(serde::Deserialize, Default)] #[derive(serde::Deserialize, Default)]
pub struct SavedQuery { pub struct SavedQuery {
pub saved: Option<String>, pub saved: Option<String>,
pub error: Option<String>,
} }
pub async fn get_profile_settings( pub async fn get_profile_settings(
@@ -109,6 +116,7 @@ pub async fn get_profile_settings(
also_known_as: settings.also_known_as.as_deref(), also_known_as: settings.also_known_as.as_deref(),
profile_fields: &settings.fields, profile_fields: &settings.fields,
saved, saved,
error: params.error.as_deref(),
embed_url: format!( embed_url: format!(
"{}/users/{}?embed=true", "{}/users/{}?embed=true",
state.app_ctx.config.base_url, state.app_ctx.config.base_url,
@@ -177,3 +185,61 @@ pub async fn post_profile_settings(
axum::response::Redirect::to("/settings/profile?saved=1").into_response() axum::response::Redirect::to("/settings/profile?saved=1").into_response()
} }
async fn delete_account(
state: &AppState,
actor: UserId,
target: UserId,
password: &str,
) -> Result<(), DomainError> {
application::users::request_account_deletion::execute(
&state.app_ctx.deps.users.request_account_deletion,
actor,
target,
password,
)
.await
}
pub async fn post_delete_own_account(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<DeleteAccountForm>,
) -> impl IntoResponse {
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
match delete_account(&state, user_id.clone(), user_id, &form.password).await {
Ok(()) => super::auth::get_logout(State(state)).await.into_response(),
Err(e) => axum::response::Redirect::to(&format!(
"/settings/profile?error={}",
crate::html::helpers::encode_domain_error(&e)
))
.into_response(),
}
}
pub async fn post_delete_user(
RequiredCookieUser(actor): RequiredCookieUser,
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
Path(target): Path<Uuid>,
Form(form): Form<DeleteAccountForm>,
) -> impl IntoResponse {
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let target_id = UserId::from_uuid(target);
let own = actor == target_id;
match delete_account(&state, actor, target_id, &form.password).await {
// An admin removing their own account ends their own session too.
Ok(()) if own => super::auth::get_logout(State(state)).await.into_response(),
Ok(()) => axum::response::Redirect::to("/users").into_response(),
Err(e) => axum::response::Redirect::to(&format!(
"/users/{target}?error={}",
crate::html::helpers::encode_domain_error(&e)
))
.into_response(),
}
}

View File

@@ -1,6 +1,7 @@
use api_types::{ use api_types::{
ProfileFieldDto, ProfileResponse, ProfileViewData, UpdateProfileFieldsRequest, UserProfileBase, DeleteAccountRequest, ProfileFieldDto, ProfileResponse, ProfileViewData,
UserProfileResponse, UserStatsDto, UserSummaryDto, UsersResponse, UpdateProfileFieldsRequest, UserProfileBase, UserProfileResponse, UserStatsDto, UserSummaryDto,
UsersResponse,
}; };
use utoipa::OpenApi; use utoipa::OpenApi;
@@ -12,6 +13,8 @@ use utoipa::OpenApi;
crate::api::users::get_profile, crate::api::users::get_profile,
crate::api::users::update_profile_handler, crate::api::users::update_profile_handler,
crate::api::users::update_profile_fields_handler, crate::api::users::update_profile_fields_handler,
crate::api::users::delete_me,
crate::api::users::delete_user,
), ),
components(schemas( components(schemas(
UsersResponse, UsersResponse,
@@ -23,6 +26,7 @@ use utoipa::OpenApi;
ProfileResponse, ProfileResponse,
UpdateProfileFieldsRequest, UpdateProfileFieldsRequest,
ProfileFieldDto, ProfileFieldDto,
DeleteAccountRequest,
)) ))
)] )]
pub struct UsersDoc; pub struct UsersDoc;

View File

@@ -97,6 +97,20 @@
</section> </section>
{% endif %} {% endif %}
{% if ctx.is_admin && !is_own_profile %}
<section class="profile-manage">
<h3>Admin</h3>
{% if let Some(err) = error %}
<p class="error">{{ err }}</p>
{% endif %}
<form method="POST" action="/users/{{ profile_user_id }}/delete">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<input type="password" name="password" placeholder="Your password" required autocomplete="current-password">
<button type="submit">Delete user</button>
</form>
</section>
{% endif %}
<div class="view-tabs"> <div class="view-tabs">
<a href="?view=recent" class="view-tab {% if view == "recent" %}active{% endif %}">Recent</a> <a href="?view=recent" class="view-tab {% if view == "recent" %}active{% endif %}">Recent</a>
<a href="?view=ratings" class="view-tab {% if view == "ratings" %}active{% endif %}">Top Rated</a> <a href="?view=ratings" class="view-tab {% if view == "ratings" %}active{% endif %}">Top Rated</a>

View File

@@ -7,6 +7,9 @@
{% if saved %} {% if saved %}
<p class="success">Saved.</p> <p class="success">Saved.</p>
{% endif %} {% endif %}
{% if let Some(err) = error %}
<p class="error">{{ err }}</p>
{% endif %}
<form method="post" action="/settings/profile" enctype="multipart/form-data"> <form method="post" action="/settings/profile" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}"> <input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
@@ -62,4 +65,19 @@
<p style="font-size:.85em;opacity:.7;margin-bottom:.5rem">Add your diary to your blog or website:</p> <p style="font-size:.85em;opacity:.7;margin-bottom:.5rem">Add your diary to your blog or website:</p>
<pre style="background:rgba(255,255,255,0.06);padding:12px;border-radius:8px;font-size:.8em;overflow-x:auto;white-space:pre-wrap;word-break:break-all"><code>&lt;iframe src="{{ embed_url }}" width="100%" height="600" style="border:none;border-radius:8px" loading="lazy"&gt;&lt;/iframe&gt;</code></pre> <pre style="background:rgba(255,255,255,0.06);padding:12px;border-radius:8px;font-size:.8em;overflow-x:auto;white-space:pre-wrap;word-break:break-all"><code>&lt;iframe src="{{ embed_url }}" width="100%" height="600" style="border:none;border-radius:8px" loading="lazy"&gt;&lt;/iframe&gt;</code></pre>
</section> </section>
<section style="margin-top:2rem;padding-top:1rem;border-top:1px solid rgba(255,255,255,0.1)">
<h3>Delete account</h3>
<p style="font-size:.85em;opacity:.7;margin-bottom:.5rem">
Permanently removes your account and everything on it: reviews, watchlist, goals, follows and uploaded images. This cannot be undone.
</p>
<form method="post" action="/settings/account/delete">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<label>
Confirm with your password<br>
<input type="password" name="password" required autocomplete="current-password">
</label>
<button type="submit">Delete my account</button>
</form>
</section>
{% endblock %} {% endblock %}

View File

@@ -153,6 +153,12 @@ pub struct UpdateProfileFieldsRequest {
pub fields: Vec<ProfileFieldDto>, pub fields: Vec<ProfileFieldDto>,
} }
/// Proof of intent for account deletion: the acting user's own password.
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct DeleteAccountRequest {
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserSettingsDto { pub struct UserSettingsDto {
pub federate_goals: bool, pub federate_goals: bool,

View File

@@ -0,0 +1,223 @@
//! Account deletion over HTTP: JSON and classic endpoints on the real wiring.
mod support;
use axum::{
body::Body,
http::{Method, Request, header},
};
use support::{PASSWORD, Session, get, json_request, send};
async fn promote(pool: &sqlx::SqlitePool, user_id: &str) {
sqlx::query("UPDATE users SET role = 'admin' WHERE id = ?")
.bind(user_id)
.execute(pool)
.await
.unwrap();
}
fn delete_json(uri: &str, token: &str, password: &str) -> Request<Body> {
json_request(
Method::DELETE,
uri,
Some(token),
serde_json::json!({ "password": password }),
)
}
fn classic_post(uri: &str, cookie: &str, password: &str) -> Request<Body> {
let csrf = cookie
.split(';')
.find_map(|c| c.trim().strip_prefix("csrf="))
.unwrap();
Request::builder()
.method(Method::POST)
.uri(uri)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, cookie)
.body(Body::from(format!("password={password}&_csrf={csrf}")))
.unwrap()
}
fn location(headers: &axum::http::HeaderMap) -> String {
headers
.get(header::LOCATION)
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_default()
}
async fn two_users() -> (axum::Router, sqlx::SqlitePool, Session, Session) {
let (router, pool) = support::server().await;
let alice = support::register(&router, "alice", "alice@example.com").await;
let bob = support::register(&router, "bob", "bob@example.com").await;
(router, pool, alice, bob)
}
#[tokio::test]
async fn self_delete_with_a_wrong_password_is_refused_and_keeps_the_account() {
let (router, _pool, alice, _bob) = two_users().await;
let (status, _, body) = send(
&router,
delete_json("/api/v1/users/me", &alice.token, "nope"),
)
.await;
assert_eq!(status, 401, "{body}");
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
assert_eq!(status, 200);
}
#[tokio::test]
async fn self_delete_removes_the_account_and_revokes_the_token() {
let (router, _pool, alice, _bob) = two_users().await;
let (status, _, body) = send(
&router,
delete_json("/api/v1/users/me", &alice.token, PASSWORD),
)
.await;
assert_eq!(status, 204, "{body}");
let req = Request::builder()
.uri("/api/v1/profile")
.header(header::AUTHORIZATION, format!("Bearer {}", alice.token))
.body(Body::empty())
.unwrap();
let (status, _, _) = send(&router, req).await;
assert_eq!(status, 401, "token of a deleted account");
for uri in [
format!("/users/{}", alice.user_id),
format!("/users/{}?embed=true", alice.user_id),
] {
let (status, _, _) = send(&router, get(&uri, None)).await;
assert_eq!(status, 404, "{uri}");
}
}
#[tokio::test]
async fn a_standard_user_cannot_delete_someone_else() {
let (router, _pool, alice, bob) = two_users().await;
let uri = format!("/api/v1/users/{}", bob.user_id);
let (status, _, body) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await;
assert_eq!(status, 403, "{body}");
let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await;
assert_eq!(status, 200);
}
#[tokio::test]
async fn an_admin_deletes_another_account() {
let (router, pool, alice, bob) = two_users().await;
promote(&pool, &alice.user_id).await;
let uri = format!("/api/v1/users/{}", bob.user_id);
let (status, _, _) = send(&router, delete_json(&uri, &alice.token, "nope")).await;
assert_eq!(status, 401, "wrong admin password");
let (status, _, body) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await;
assert_eq!(status, 204, "{body}");
for uri in [
format!("/users/{}", bob.user_id),
format!("/users/{}?embed=true", bob.user_id),
] {
let (status, _, _) = send(&router, get(&uri, None)).await;
assert_eq!(status, 404, "{uri}");
}
let (status, _, _) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await;
assert_eq!(status, 404, "already deleted");
#[cfg(feature = "federation")]
{
let req = Request::builder()
.uri(format!("/users/{}", bob.user_id))
.header(header::ACCEPT, "application/activity+json")
.body(Body::empty())
.unwrap();
let (status, _, _) = send(&router, req).await;
assert_eq!(status, 410, "actor endpoint of a deleted account");
}
}
#[tokio::test]
async fn the_last_admin_cannot_be_deleted() {
let (router, pool, alice, _bob) = two_users().await;
promote(&pool, &alice.user_id).await;
let (status, _, body) = send(
&router,
delete_json("/api/v1/users/me", &alice.token, PASSWORD),
)
.await;
assert_eq!(status, 409, "{body}");
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
assert_eq!(status, 200);
}
#[tokio::test]
async fn classic_self_delete_logs_out_and_redirects_home() {
let (router, _pool, alice, _bob) = two_users().await;
let (status, headers, _) = send(
&router,
classic_post("/settings/account/delete", &alice.cookie, "nope"),
)
.await;
assert_eq!(status, 303);
assert!(
location(&headers).starts_with("/settings/profile?error="),
"{headers:?}"
);
assert!(
support::cookie_value(&headers, "token").is_none(),
"a wrong password must not log out"
);
let (status, headers, _) = send(
&router,
classic_post("/settings/account/delete", &alice.cookie, PASSWORD),
)
.await;
assert_eq!(status, 303);
assert_eq!(location(&headers), "/");
assert_eq!(
support::cookie_value(&headers, "token").as_deref(),
Some("")
);
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
assert_eq!(status, 404);
}
#[tokio::test]
async fn classic_self_delete_requires_the_csrf_token() {
let (router, _pool, alice, _bob) = two_users().await;
let req = Request::builder()
.method(Method::POST)
.uri("/settings/account/delete")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, &alice.cookie)
.body(Body::from(format!("password={PASSWORD}&_csrf=wrong")))
.unwrap();
let (status, _, _) = send(&router, req).await;
assert_eq!(status, 403);
}
#[tokio::test]
async fn classic_admin_delete_redirects_to_the_member_list() {
let (router, pool, alice, bob) = two_users().await;
let uri = format!("/users/{}/delete", bob.user_id);
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
assert_eq!(status, 303);
assert!(
location(&headers).contains("error="),
"non-admin: {headers:?}"
);
promote(&pool, &alice.user_id).await;
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
assert_eq!(status, 303);
assert_eq!(location(&headers), "/users");
assert!(support::cookie_value(&headers, "token").is_none());
let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await;
assert_eq!(status, 404);
}

View File

@@ -3,6 +3,8 @@ DELETE /api/v1/goals/{year}
DELETE /api/v1/import/profiles/{id} DELETE /api/v1/import/profiles/{id}
DELETE /api/v1/reviews/{id} DELETE /api/v1/reviews/{id}
DELETE /api/v1/settings/webhook-tokens/{id} DELETE /api/v1/settings/webhook-tokens/{id}
DELETE /api/v1/users/me
DELETE /api/v1/users/{id}
DELETE /api/v1/watchlist/{movie_id} DELETE /api/v1/watchlist/{movie_id}
DELETE /api/v1/wrapups/{id} DELETE /api/v1/wrapups/{id}
GET /api/v1/activity-feed GET /api/v1/activity-feed

View File

@@ -2,6 +2,8 @@ DELETE /api/v1/goals/{year}
DELETE /api/v1/import/profiles/{id} DELETE /api/v1/import/profiles/{id}
DELETE /api/v1/reviews/{id} DELETE /api/v1/reviews/{id}
DELETE /api/v1/settings/webhook-tokens/{id} DELETE /api/v1/settings/webhook-tokens/{id}
DELETE /api/v1/users/me
DELETE /api/v1/users/{id}
DELETE /api/v1/watchlist/{movie_id} DELETE /api/v1/watchlist/{movie_id}
DELETE /api/v1/wrapups/{id} DELETE /api/v1/wrapups/{id}
GET /api/v1/activity-feed GET /api/v1/activity-feed