mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-26 20:24:15 +00:00
refactor(api): update delegation, notification prefs, email, meta, and age assurance endpoints
This commit is contained in:
@@ -12,8 +12,11 @@ use subtle::ConstantTimeEq;
|
||||
use tracing::{error, info, warn};
|
||||
use tranquil_db_traits::CommsChannel;
|
||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::api::{EmptyResponse, TokenRequiredResponse, VerifiedResponse};
|
||||
use tranquil_pds::api::{
|
||||
EmailUpdateStatusOutput, EmptyResponse, InUseOutput, TokenRequiredResponse, VerifiedResponse,
|
||||
};
|
||||
use tranquil_pds::auth::{Auth, NotTakendown};
|
||||
use tranquil_pds::oauth::scopes::{AccountAction, AccountAttr};
|
||||
use tranquil_pds::rate_limit::{EmailUpdateLimit, RateLimited, VerificationCheckLimit};
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
@@ -48,15 +51,8 @@ pub async fn request_email_update(
|
||||
_rate_limit: RateLimited<EmailUpdateLimit>,
|
||||
auth: Auth<NotTakendown>,
|
||||
input: Option<Json<RequestEmailUpdateInput>>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Err(e) = tranquil_pds::auth::scope_check::check_account_scope(
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
tranquil_pds::oauth::scopes::AccountAttr::Email,
|
||||
tranquil_pds::oauth::scopes::AccountAction::Manage,
|
||||
) {
|
||||
return Ok(e);
|
||||
}
|
||||
) -> Result<Json<TokenRequiredResponse>, ApiError> {
|
||||
auth.check_account_scope(AccountAttr::Email, AccountAction::Manage)?;
|
||||
|
||||
let user = state
|
||||
.user_repo
|
||||
@@ -119,7 +115,7 @@ pub async fn request_email_update(
|
||||
}
|
||||
|
||||
info!("Email update requested for user {}", user.id);
|
||||
Ok(TokenRequiredResponse::response(token_required).into_response())
|
||||
Ok(Json(TokenRequiredResponse { token_required }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -134,15 +130,8 @@ pub async fn confirm_email(
|
||||
_rate_limit: RateLimited<EmailUpdateLimit>,
|
||||
auth: Auth<NotTakendown>,
|
||||
Json(input): Json<ConfirmEmailInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Err(e) = tranquil_pds::auth::scope_check::check_account_scope(
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
tranquil_pds::oauth::scopes::AccountAttr::Email,
|
||||
tranquil_pds::oauth::scopes::AccountAction::Manage,
|
||||
) {
|
||||
return Ok(e);
|
||||
}
|
||||
) -> Result<Json<EmptyResponse>, ApiError> {
|
||||
auth.check_account_scope(AccountAttr::Email, AccountAction::Manage)?;
|
||||
|
||||
let did = &auth.did;
|
||||
let user = state
|
||||
@@ -163,7 +152,7 @@ pub async fn confirm_email(
|
||||
}
|
||||
|
||||
if user.email_verified {
|
||||
return Ok(EmptyResponse::ok().into_response());
|
||||
return Ok(Json(EmptyResponse {}));
|
||||
}
|
||||
|
||||
let confirmation_code =
|
||||
@@ -196,7 +185,7 @@ pub async fn confirm_email(
|
||||
.log_db_err("confirming email")?;
|
||||
|
||||
info!("Email confirmed for user {}", user.id);
|
||||
Ok(EmptyResponse::ok().into_response())
|
||||
Ok(Json(EmptyResponse {}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -212,15 +201,8 @@ pub async fn update_email(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<NotTakendown>,
|
||||
Json(input): Json<UpdateEmailInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Err(e) = tranquil_pds::auth::scope_check::check_account_scope(
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
tranquil_pds::oauth::scopes::AccountAttr::Email,
|
||||
tranquil_pds::oauth::scopes::AccountAction::Manage,
|
||||
) {
|
||||
return Ok(e);
|
||||
}
|
||||
) -> Result<Json<EmptyResponse>, ApiError> {
|
||||
auth.check_account_scope(AccountAttr::Email, AccountAction::Manage)?;
|
||||
|
||||
let did = &auth.did;
|
||||
let user = state
|
||||
@@ -279,7 +261,7 @@ pub async fn update_email(
|
||||
ApiError::InternalError(Some("Failed to update 2FA setting".into()))
|
||||
})?;
|
||||
}
|
||||
return Ok(EmptyResponse::ok().into_response());
|
||||
return Ok(Json(EmptyResponse {}));
|
||||
}
|
||||
|
||||
if email_verified {
|
||||
@@ -394,7 +376,7 @@ pub async fn update_email(
|
||||
}
|
||||
|
||||
info!("Email updated for user {}", user_id);
|
||||
Ok(EmptyResponse::ok().into_response())
|
||||
Ok(Json(EmptyResponse {}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -406,19 +388,18 @@ pub async fn check_email_verified(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: RateLimited<VerificationCheckLimit>,
|
||||
Json(input): Json<CheckEmailVerifiedInput>,
|
||||
) -> Response {
|
||||
match state
|
||||
) -> Result<Json<VerifiedResponse>, ApiError> {
|
||||
let verified = state
|
||||
.user_repo
|
||||
.check_email_verified_by_identifier(&input.identifier)
|
||||
.await
|
||||
{
|
||||
Ok(Some(verified)) => VerifiedResponse::response(verified).into_response(),
|
||||
Ok(None) => ApiError::AccountNotFound.into_response(),
|
||||
Err(e) => {
|
||||
.map_err(|e| {
|
||||
error!("DB error checking email verified: {:?}", e);
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
Ok(Json(VerifiedResponse { verified }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -431,19 +412,18 @@ pub async fn check_channel_verified(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: RateLimited<VerificationCheckLimit>,
|
||||
Json(input): Json<CheckChannelVerifiedInput>,
|
||||
) -> Response {
|
||||
match state
|
||||
) -> Result<Json<VerifiedResponse>, ApiError> {
|
||||
let verified = state
|
||||
.user_repo
|
||||
.check_channel_verified_by_did(&input.did, input.channel)
|
||||
.await
|
||||
{
|
||||
Ok(Some(verified)) => VerifiedResponse::response(verified).into_response(),
|
||||
Ok(None) => ApiError::AccountNotFound.into_response(),
|
||||
Err(e) => {
|
||||
.map_err(|e| {
|
||||
error!("DB error checking channel verified: {:?}", e);
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
Ok(Json(VerifiedResponse { verified }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -545,37 +525,37 @@ pub async fn check_email_update_status(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: RateLimited<VerificationCheckLimit>,
|
||||
auth: Auth<NotTakendown>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Err(e) = tranquil_pds::auth::scope_check::check_account_scope(
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
tranquil_pds::oauth::scopes::AccountAttr::Email,
|
||||
tranquil_pds::oauth::scopes::AccountAction::Read,
|
||||
) {
|
||||
return Ok(e);
|
||||
}
|
||||
) -> Result<Json<EmailUpdateStatusOutput>, ApiError> {
|
||||
auth.check_account_scope(AccountAttr::Email, AccountAction::Read)?;
|
||||
|
||||
let cache_key = email_update_cache_key(&auth.did);
|
||||
let pending_json = match state.cache.get(&cache_key).await {
|
||||
Some(json) => json,
|
||||
None => {
|
||||
return Ok(Json(json!({ "pending": false, "authorized": false })).into_response());
|
||||
return Ok(Json(EmailUpdateStatusOutput {
|
||||
pending: false,
|
||||
authorized: false,
|
||||
new_email: None,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let pending: PendingEmailUpdate = match serde_json::from_str(&pending_json) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
return Ok(Json(json!({ "pending": false, "authorized": false })).into_response());
|
||||
return Ok(Json(EmailUpdateStatusOutput {
|
||||
pending: false,
|
||||
authorized: false,
|
||||
new_email: None,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"pending": true,
|
||||
"authorized": pending.authorized,
|
||||
"newEmail": pending.new_email,
|
||||
Ok(Json(EmailUpdateStatusOutput {
|
||||
pending: true,
|
||||
authorized: pending.authorized,
|
||||
new_email: Some(pending.new_email),
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -587,22 +567,20 @@ pub async fn check_email_in_use(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: RateLimited<VerificationCheckLimit>,
|
||||
Json(input): Json<CheckEmailInUseInput>,
|
||||
) -> Response {
|
||||
) -> Result<Json<InUseOutput>, ApiError> {
|
||||
let email = input.email.trim().to_lowercase();
|
||||
if email.is_empty() {
|
||||
return ApiError::InvalidRequest("email is required".into()).into_response();
|
||||
return Err(ApiError::InvalidRequest("email is required".into()));
|
||||
}
|
||||
|
||||
let count = match state.user_repo.count_accounts_by_email(&email).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let count = state
|
||||
.user_repo
|
||||
.count_accounts_by_email(&email)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error checking email usage: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
Json(json!({
|
||||
"inUse": count > 0,
|
||||
}))
|
||||
.into_response()
|
||||
Ok(Json(InUseOutput { in_use: count > 0 }))
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||
use serde_json::json;
|
||||
use serde::Serialize;
|
||||
use tranquil_db_traits::CommsChannel;
|
||||
use tranquil_pds::BUILD_VERSION;
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::util::{discord_app_id, discord_bot_username, telegram_bot_username};
|
||||
|
||||
fn get_available_comms_channels() -> Vec<tranquil_db_traits::CommsChannel> {
|
||||
use tranquil_db_traits::CommsChannel;
|
||||
fn get_available_comms_channels() -> Vec<CommsChannel> {
|
||||
let cfg = tranquil_config::get();
|
||||
let mut channels = vec![CommsChannel::Email];
|
||||
if cfg.discord.bot_token.is_some() {
|
||||
@@ -31,59 +31,87 @@ pub fn is_self_hosted_did_web_enabled() -> bool {
|
||||
tranquil_config::get().server.enable_pds_hosted_did_web
|
||||
}
|
||||
|
||||
pub async fn describe_server() -> impl IntoResponse {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DescribeServerLinks {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub privacy_policy: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub terms_of_service: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DescribeServerContact {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DescribeServerOutput {
|
||||
pub available_user_domains: Vec<String>,
|
||||
pub invite_code_required: bool,
|
||||
pub did: String,
|
||||
pub links: DescribeServerLinks,
|
||||
pub contact: DescribeServerContact,
|
||||
pub version: &'static str,
|
||||
pub available_comms_channels: Vec<CommsChannel>,
|
||||
pub self_hosted_did_web_enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub discord_bot_username: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub discord_app_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub telegram_bot_username: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn describe_server() -> Json<DescribeServerOutput> {
|
||||
let cfg = tranquil_config::get();
|
||||
let pds_hostname = &cfg.server.hostname;
|
||||
let domains = cfg.server.user_handle_domain_list();
|
||||
let invite_code_required = cfg.server.invite_code_required;
|
||||
let privacy_policy = cfg.server.privacy_policy_url.clone();
|
||||
let terms_of_service = cfg.server.terms_of_service_url.clone();
|
||||
let contact_email = cfg.server.contact_email.clone();
|
||||
let mut links = serde_json::Map::new();
|
||||
if let Some(pp) = privacy_policy {
|
||||
links.insert("privacyPolicy".to_string(), json!(pp));
|
||||
}
|
||||
if let Some(tos) = terms_of_service {
|
||||
links.insert("termsOfService".to_string(), json!(tos));
|
||||
}
|
||||
let mut contact = serde_json::Map::new();
|
||||
if let Some(email) = contact_email {
|
||||
contact.insert("email".to_string(), json!(email));
|
||||
}
|
||||
let mut response = json!({
|
||||
"availableUserDomains": domains,
|
||||
"inviteCodeRequired": invite_code_required,
|
||||
"did": format!("did:web:{}", pds_hostname),
|
||||
"links": links,
|
||||
"contact": contact,
|
||||
"version": BUILD_VERSION,
|
||||
"availableCommsChannels": get_available_comms_channels(),
|
||||
"selfHostedDidWebEnabled": is_self_hosted_did_web_enabled()
|
||||
});
|
||||
if let Some(bot_username) = discord_bot_username() {
|
||||
response["discordBotUsername"] = json!(bot_username);
|
||||
}
|
||||
if let Some(app_id) = discord_app_id() {
|
||||
response["discordAppId"] = json!(app_id);
|
||||
}
|
||||
if let Some(bot_username) = telegram_bot_username() {
|
||||
response["telegramBotUsername"] = json!(bot_username);
|
||||
}
|
||||
Json(response)
|
||||
|
||||
Json(DescribeServerOutput {
|
||||
available_user_domains: cfg.server.user_handle_domain_list(),
|
||||
invite_code_required: cfg.server.invite_code_required,
|
||||
did: format!("did:web:{}", pds_hostname),
|
||||
links: DescribeServerLinks {
|
||||
privacy_policy: cfg.server.privacy_policy_url.clone(),
|
||||
terms_of_service: cfg.server.terms_of_service_url.clone(),
|
||||
},
|
||||
contact: DescribeServerContact {
|
||||
email: cfg.server.contact_email.clone(),
|
||||
},
|
||||
version: BUILD_VERSION,
|
||||
available_comms_channels: get_available_comms_channels(),
|
||||
self_hosted_did_web_enabled: is_self_hosted_did_web_enabled(),
|
||||
discord_bot_username: discord_bot_username().map(String::from),
|
||||
discord_app_id: discord_app_id().map(String::from),
|
||||
telegram_bot_username: telegram_bot_username().map(String::from),
|
||||
})
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
pub struct HealthOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<&'static str>,
|
||||
}
|
||||
|
||||
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
|
||||
match state.infra_repo.health_check().await {
|
||||
Ok(true) => (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"version": format!("tranquil {}", BUILD_VERSION)
|
||||
})),
|
||||
Json(HealthOutput {
|
||||
version: Some(format!("tranquil {}", BUILD_VERSION)),
|
||||
error: None,
|
||||
}),
|
||||
),
|
||||
_ => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({
|
||||
"error": "Service Unavailable"
|
||||
})),
|
||||
Json(HealthOutput {
|
||||
version: None,
|
||||
error: Some("Service Unavailable"),
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user