mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-11 04:36:05 +00:00
Email conf. vs ref
This commit is contained in:
+204
-179
@@ -1,4 +1,5 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -10,16 +11,10 @@ use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RequestEmailUpdateInput {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
pub async fn request_email_update(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(input): Json<RequestEmailUpdateInput>,
|
||||
auth: BearerAuth,
|
||||
) -> Response {
|
||||
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
|
||||
if !state
|
||||
@@ -37,41 +32,33 @@ pub async fn request_email_update(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
|
||||
let auth_user = match auth_result {
|
||||
Ok(user) => user,
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
};
|
||||
|
||||
if let Err(e) = crate::auth::scope_check::check_account_scope(
|
||||
auth_user.is_oauth,
|
||||
auth_user.scope.as_deref(),
|
||||
auth.0.is_oauth,
|
||||
auth.0.scope.as_deref(),
|
||||
crate::oauth::scopes::AccountAttr::Email,
|
||||
crate::oauth::scopes::AccountAction::Manage,
|
||||
) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let did = auth_user.did.clone();
|
||||
let user = match sqlx::query!("SELECT id, handle, email FROM users WHERE did = $1", did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
let did = auth.0.did.clone();
|
||||
let user = match sqlx::query!(
|
||||
"SELECT id, handle, email, email_verified FROM users WHERE did = $1",
|
||||
did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
_ => {
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
@@ -80,59 +67,44 @@ pub async fn request_email_update(
|
||||
}
|
||||
};
|
||||
|
||||
let user_id = user.id;
|
||||
let handle = user.handle;
|
||||
let current_email = user.email;
|
||||
let email = input.email.trim().to_lowercase();
|
||||
let current_email: String = match user.email {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "account does not have an email address"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if !crate::api::validation::is_valid_email(&email) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
|
||||
let token_required = user.email_verified;
|
||||
|
||||
if token_required {
|
||||
let code = crate::auth::verification_token::generate_channel_update_token(
|
||||
&did,
|
||||
"email_update",
|
||||
¤t_email.to_lowercase(),
|
||||
);
|
||||
let formatted_code =
|
||||
crate::auth::verification_token::format_token_for_display(&code);
|
||||
|
||||
let hostname =
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) = crate::comms::enqueue_email_update_token(
|
||||
&state.db,
|
||||
user.id,
|
||||
&formatted_code,
|
||||
&hostname,
|
||||
)
|
||||
.into_response();
|
||||
.await
|
||||
{
|
||||
warn!("Failed to enqueue email update notification: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
if current_email.as_ref().map(|e| e.to_lowercase()) == Some(email.clone()) {
|
||||
return (StatusCode::OK, Json(json!({ "tokenRequired": false }))).into_response();
|
||||
}
|
||||
|
||||
let exists = sqlx::query!(
|
||||
"SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2",
|
||||
email,
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
if let Ok(Some(_)) = exists {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "EmailTaken", "message": "Email already taken"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = crate::api::notification_prefs::request_channel_verification(
|
||||
&state.db,
|
||||
user_id,
|
||||
&did,
|
||||
"email",
|
||||
&email,
|
||||
Some(&handle),
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Failed to request email verification: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
info!("Email update requested for user {}", user_id);
|
||||
(StatusCode::OK, Json(json!({ "tokenRequired": true }))).into_response()
|
||||
info!("Email update requested for user {}", user.id);
|
||||
(StatusCode::OK, Json(json!({ "tokenRequired": token_required }))).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -145,11 +117,12 @@ pub struct ConfirmEmailInput {
|
||||
pub async fn confirm_email(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<ConfirmEmailInput>,
|
||||
) -> Response {
|
||||
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::AppPassword, &client_ip)
|
||||
.check_rate_limit(RateLimitKind::EmailUpdate, &client_ip)
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Confirm email rate limit exceeded");
|
||||
@@ -163,41 +136,33 @@ pub async fn confirm_email(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
|
||||
let auth_user = match auth_result {
|
||||
Ok(user) => user,
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
};
|
||||
|
||||
if let Err(e) = crate::auth::scope_check::check_account_scope(
|
||||
auth_user.is_oauth,
|
||||
auth_user.scope.as_deref(),
|
||||
auth.0.is_oauth,
|
||||
auth.0.scope.as_deref(),
|
||||
crate::oauth::scopes::AccountAttr::Email,
|
||||
crate::oauth::scopes::AccountAction::Manage,
|
||||
) {
|
||||
return e;
|
||||
}
|
||||
|
||||
let did = auth_user.did;
|
||||
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
let did = auth.0.did;
|
||||
let user = match sqlx::query!(
|
||||
"SELECT id, email, email_verified FROM users WHERE did = $1",
|
||||
did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "AccountNotFound", "message": "user not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
@@ -206,14 +171,37 @@ pub async fn confirm_email(
|
||||
}
|
||||
};
|
||||
|
||||
let email = input.email.trim().to_lowercase();
|
||||
let current_email = match &user.email {
|
||||
Some(e) => e.to_lowercase(),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidEmail", "message": "account does not have an email address"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let provided_email = input.email.trim().to_lowercase();
|
||||
if provided_email != current_email {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidEmail", "message": "invalid email"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if user.email_verified {
|
||||
return (StatusCode::OK, Json(json!({}))).into_response();
|
||||
}
|
||||
|
||||
let confirmation_code =
|
||||
crate::auth::verification_token::normalize_token_input(input.token.trim());
|
||||
|
||||
let verified = crate::auth::verification_token::verify_channel_update_token(
|
||||
let verified = crate::auth::verification_token::verify_signup_token(
|
||||
&confirmation_code,
|
||||
"email",
|
||||
&email,
|
||||
&provided_email,
|
||||
);
|
||||
|
||||
match verified {
|
||||
@@ -245,25 +233,14 @@ pub async fn confirm_email(
|
||||
}
|
||||
|
||||
let update = sqlx::query!(
|
||||
"UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2",
|
||||
email,
|
||||
user_id
|
||||
"UPDATE users SET email_verified = TRUE, updated_at = NOW() WHERE id = $1",
|
||||
user.id
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
if let Err(e) = update {
|
||||
error!("DB error finalizing email update: {:?}", e);
|
||||
if e.as_database_error()
|
||||
.map(|db_err| db_err.is_unique_violation())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "EmailTaken", "message": "Email already taken"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
error!("DB error confirming email: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
@@ -271,7 +248,7 @@ pub async fn confirm_email(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
info!("Email updated for user {}", user_id);
|
||||
info!("Email confirmed for user {}", user.id);
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
@@ -289,7 +266,7 @@ pub async fn update_email(
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(input): Json<UpdateEmailInput>,
|
||||
) -> Response {
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
let bearer_token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
@@ -302,7 +279,7 @@ pub async fn update_email(
|
||||
}
|
||||
};
|
||||
|
||||
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
|
||||
let auth_result = crate::auth::validate_bearer_token(&state.db, &bearer_token).await;
|
||||
let auth_user = match auth_result {
|
||||
Ok(user) => user,
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
@@ -318,12 +295,23 @@ pub async fn update_email(
|
||||
}
|
||||
|
||||
let did = auth_user.did;
|
||||
let user = match sqlx::query!("SELECT id, email FROM users WHERE did = $1", did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
let user = match sqlx::query!(
|
||||
"SELECT id, email, email_verified FROM users WHERE did = $1",
|
||||
did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
_ => {
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
@@ -333,13 +321,17 @@ pub async fn update_email(
|
||||
};
|
||||
|
||||
let user_id = user.id;
|
||||
let current_email = user.email;
|
||||
let current_email = user.email.clone();
|
||||
let email_verified = user.email_verified;
|
||||
let new_email = input.email.trim().to_lowercase();
|
||||
|
||||
if !crate::api::validation::is_valid_email(&new_email) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "This email address is not supported, please use a different email."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
@@ -350,48 +342,58 @@ pub async fn update_email(
|
||||
return (StatusCode::OK, Json(json!({}))).into_response();
|
||||
}
|
||||
|
||||
let confirmation_token = match &input.token {
|
||||
Some(t) => crate::auth::verification_token::normalize_token_input(t.trim()),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "TokenRequired", "message": "Token required. Call requestEmailUpdate first."})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let verified = crate::auth::verification_token::verify_channel_update_token(
|
||||
&confirmation_token,
|
||||
"email",
|
||||
&new_email,
|
||||
);
|
||||
|
||||
match verified {
|
||||
Ok(token_data) => {
|
||||
if token_data.did != did {
|
||||
if email_verified {
|
||||
let confirmation_token = match &input.token {
|
||||
Some(t) => crate::auth::verification_token::normalize_token_input(t.trim()),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({"error": "InvalidToken", "message": "Token does not match account"}),
|
||||
),
|
||||
Json(json!({
|
||||
"error": "TokenRequired",
|
||||
"message": "confirmation token required"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let current_email_lower = current_email
|
||||
.as_ref()
|
||||
.map(|e| e.to_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
let verified = crate::auth::verification_token::verify_channel_update_token(
|
||||
&confirmation_token,
|
||||
"email_update",
|
||||
¤t_email_lower,
|
||||
);
|
||||
|
||||
match verified {
|
||||
Ok(token_data) => {
|
||||
if token_data.did != did {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({"error": "InvalidToken", "message": "Token does not match account"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
Err(crate::auth::verification_token::VerifyError::Expired) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
Err(crate::auth::verification_token::VerifyError::Expired) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,13 +408,16 @@ pub async fn update_email(
|
||||
if let Ok(Some(_)) = exists {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Email already in use"})),
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "This email address is already in use, please use a different email."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let update = sqlx::query!(
|
||||
"UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2",
|
||||
let update: Result<sqlx::postgres::PgQueryResult, sqlx::Error> = sqlx::query!(
|
||||
"UPDATE users SET email = $1, email_verified = FALSE, updated_at = NOW() WHERE id = $2",
|
||||
new_email,
|
||||
user_id
|
||||
)
|
||||
@@ -420,14 +425,17 @@ pub async fn update_email(
|
||||
.await;
|
||||
|
||||
if let Err(e) = update {
|
||||
error!("DB error finalizing email update: {:?}", e);
|
||||
error!("DB error updating email: {:?}", e);
|
||||
if e.as_database_error()
|
||||
.map(|db_err| db_err.is_unique_violation())
|
||||
.map(|db_err: &dyn sqlx::error::DatabaseError| db_err.is_unique_violation())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Email already in use"})),
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "This email address is already in use, please use a different email."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
@@ -438,6 +446,23 @@ pub async fn update_email(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let verification_token =
|
||||
crate::auth::verification_token::generate_signup_token(&did, "email", &new_email);
|
||||
let formatted_token =
|
||||
crate::auth::verification_token::format_token_for_display(&verification_token);
|
||||
if let Err(e) = crate::comms::enqueue_signup_verification(
|
||||
&state.db,
|
||||
user_id,
|
||||
"email",
|
||||
&new_email,
|
||||
&formatted_token,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to send verification email to new address: {:?}", e);
|
||||
}
|
||||
|
||||
match sqlx::query!(
|
||||
"INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, 'email_auth_factor', $2) ON CONFLICT (user_id, name) DO UPDATE SET value_json = $2",
|
||||
user_id,
|
||||
|
||||
+3
-3
@@ -10,9 +10,9 @@ pub use sender::{
|
||||
|
||||
pub use service::{
|
||||
CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms,
|
||||
enqueue_email_update, enqueue_migration_verification, enqueue_passkey_recovery,
|
||||
enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
|
||||
queue_legacy_login_notification,
|
||||
enqueue_email_update, enqueue_email_update_token, enqueue_migration_verification,
|
||||
enqueue_passkey_recovery, enqueue_password_reset, enqueue_plc_operation,
|
||||
enqueue_signup_verification, enqueue_welcome, queue_legacy_login_notification,
|
||||
};
|
||||
|
||||
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
|
||||
|
||||
@@ -380,6 +380,44 @@ pub async fn enqueue_email_update(
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn enqueue_email_update_token(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
code: &str,
|
||||
hostname: &str,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let prefs = get_user_comms_prefs(db, user_id).await?;
|
||||
let strings = get_strings(&prefs.locale);
|
||||
let current_email = prefs.email.clone().unwrap_or_default();
|
||||
let verify_page = format!("https://{}/#/verify?type=email-update", hostname);
|
||||
let verify_link = format!(
|
||||
"https://{}/#/verify?type=email-update&token={}",
|
||||
hostname,
|
||||
urlencoding::encode(code)
|
||||
);
|
||||
let body = format_message(
|
||||
strings.email_update_body,
|
||||
&[
|
||||
("handle", &prefs.handle),
|
||||
("code", code),
|
||||
("verify_page", &verify_page),
|
||||
("verify_link", &verify_link),
|
||||
],
|
||||
);
|
||||
let subject = format_message(strings.email_update_subject, &[("hostname", hostname)]);
|
||||
enqueue_comms(
|
||||
db,
|
||||
NewComms::email(
|
||||
user_id,
|
||||
super::types::CommsType::EmailUpdate,
|
||||
current_email,
|
||||
subject,
|
||||
body,
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn enqueue_account_deletion(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
|
||||
Reference in New Issue
Block a user