/* * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl * Source-Available Code / Dual-Licensed. * * Free for non-commercial and evaluation use under terms of BSL/GPLv3. * Commercial or production use requires a valid paid license. * See LICENSE file in repository root for details. */ pub(crate) mod ldap; mod local; use argon2::{ Argon2, password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, }; use axum::{ Json, extract::{Path as AxumPath, Query, State}, http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; use chrono::{Duration, Utc}; use lettre::{ AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, message::{Mailbox, MultiPart, SinglePart, header::ContentType}, transport::smtp::authentication::Credentials, }; use rand_core::{OsRng, RngCore}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use sqlx::{Row, any::AnyRow}; use tracing::{debug, info, warn}; use crate::{ queries, state::{SharedState, SmtpConfig, SmtpSecurity}, }; const MIN_PASSWORD: usize = 8; const MAX_PASSWORD: usize = 128; const MAX_NICKNAME: usize = 40; const MAX_SHARE_LINK_LABEL: usize = 120; #[derive(Debug, Clone)] pub struct User { pub id: i64, pub nickname: String, pub email: String, pub password_hash: String, pub confirmed_at: Option, pub is_active: i64, pub theme: String, } #[derive(Deserialize)] pub struct IdentityRequest { nickname: String, } #[derive(Deserialize)] pub struct RegisterRequest { nickname: String, email: String, password: String, } #[derive(Deserialize)] pub struct LoginRequest { email: String, password: String, } #[derive(Deserialize)] pub struct ConfirmAccountRequest { token: String, } #[derive(Deserialize)] pub struct ResendConfirmationRequest { email: String, } #[derive(Deserialize)] pub struct ResetRequest { email: String, } #[derive(Deserialize)] pub struct ResetConfirmRequest { token: String, password: String, } #[derive(Deserialize)] pub struct ProfileUpdateRequest { #[serde(default)] nickname: Option, #[serde(default)] new_email: Option, #[serde(default)] new_password: Option, #[serde(default)] password: String, #[serde(default)] editor_color: Option, #[serde(default)] theme: Option, } #[derive(Deserialize)] pub struct DeleteAccountRequest { password: String, } #[derive(Deserialize)] pub struct AccountActionConfirmRequest { token: String, } #[derive(Deserialize)] pub struct ResourceActionRequest { kind: String, slug: String, #[serde(default)] password: Option, } #[derive(Serialize)] pub struct ResourceItem { #[serde(default)] kind: String, slug: String, title: String, protected: i64, updated_at: String, #[serde(rename = "private")] private_resource: i64, owned: i64, permission: String, shared_by: String, } #[derive(Debug)] struct SharingUserRow { email: String, nickname: String, permission: String, } impl<'r> sqlx::FromRow<'r, AnyRow> for SharingUserRow { fn from_row(row: &'r AnyRow) -> Result { Ok(Self { email: crate::row_decode::text(row, 0)?, nickname: crate::row_decode::text(row, 1)?, permission: crate::row_decode::text(row, 2)?, }) } } #[derive(Debug)] struct SharingLinkRow { token_hash: String, label: Option, permission: String, expires_at: Option, created_at: String, } impl<'r> sqlx::FromRow<'r, AnyRow> for SharingLinkRow { fn from_row(row: &'r AnyRow) -> Result { Ok(Self { token_hash: crate::row_decode::text(row, 0)?, label: crate::row_decode::optional_text(row, 1)?, permission: crate::row_decode::text(row, 2)?, expires_at: crate::row_decode::optional_text(row, 3)?, created_at: crate::row_decode::text(row, 4)?, }) } } #[derive(Debug)] struct ShareLinkSessionSource { token_hash: String, permission: String, expires_at: Option, } impl<'r> sqlx::FromRow<'r, AnyRow> for ShareLinkSessionSource { fn from_row(row: &'r AnyRow) -> Result { Ok(Self { token_hash: crate::row_decode::text(row, 0)?, permission: crate::row_decode::text(row, 1)?, expires_at: crate::row_decode::optional_text(row, 2)?, }) } } #[derive(Debug)] struct ShareLinkPermissionRow { permission: String, expires_at: Option, } impl<'r> sqlx::FromRow<'r, AnyRow> for ShareLinkPermissionRow { fn from_row(row: &'r AnyRow) -> Result { Ok(Self { permission: crate::row_decode::text(row, 0)?, expires_at: crate::row_decode::optional_text(row, 1)?, }) } } #[derive(Debug)] struct ShareSessionPermissionRow { permission: String, session_expires_at: String, link_expires_at: Option, } impl<'r> sqlx::FromRow<'r, AnyRow> for ShareSessionPermissionRow { fn from_row(row: &'r AnyRow) -> Result { Ok(Self { permission: crate::row_decode::text(row, 0)?, session_expires_at: crate::row_decode::text(row, 1)?, link_expires_at: crate::row_decode::optional_text(row, 2)?, }) } } #[derive(Debug)] pub struct ShareSession { pub token: String, pub max_age_seconds: i64, } #[derive(Debug)] struct PendingShareRow { email: String, nickname: String, permission: String, expires_at: Option, } impl<'r> sqlx::FromRow<'r, AnyRow> for PendingShareRow { fn from_row(row: &'r AnyRow) -> Result { Ok(Self { email: crate::row_decode::text(row, 0)?, nickname: crate::row_decode::text(row, 1)?, permission: crate::row_decode::text(row, 2)?, expires_at: crate::row_decode::optional_text(row, 3)?, }) } } impl<'r> sqlx::FromRow<'r, AnyRow> for User { fn from_row(row: &'r AnyRow) -> Result { Ok(Self { id: row.try_get("id")?, nickname: crate::row_decode::text(row, "nickname")?, email: crate::row_decode::text(row, "email")?, password_hash: crate::row_decode::text(row, "password_hash")?, confirmed_at: crate::row_decode::optional_text(row, "confirmed_at")?, is_active: row.try_get("is_active")?, theme: crate::row_decode::text(row, "theme")?, }) } } impl<'r> sqlx::FromRow<'r, AnyRow> for ResourceItem { fn from_row(row: &'r AnyRow) -> Result { Ok(Self { kind: String::new(), slug: crate::row_decode::text(row, "slug")?, title: crate::row_decode::text(row, "title")?, protected: row.try_get("protected")?, updated_at: crate::row_decode::text(row, "updated_at")?, private_resource: row.try_get("private")?, owned: row.try_get("owned")?, permission: crate::row_decode::text(row, "permission")?, shared_by: crate::row_decode::text(row, "shared_by")?, }) } } #[derive(Deserialize)] pub struct PrivacyRequest { kind: String, slug: String, private: bool, } #[derive(Deserialize)] pub struct ShareUsersRequest { kind: String, slug: String, #[serde(alias = "emails")] recipients: String, permission: String, } #[derive(Deserialize)] pub struct RemoveShareRequest { kind: String, slug: String, email: String, } #[derive(Deserialize)] pub struct CreateShareLinkRequest { kind: String, slug: String, #[serde(default)] label: Option, permission: String, expires_at: Option, } #[derive(Deserialize)] pub struct UpdateShareLinkRequest { kind: String, slug: String, token_hash: String, #[serde(default)] label: Option, permission: String, expires_at: Option, } #[derive(Deserialize)] pub struct RevokeShareLinkRequest { kind: String, slug: String, token_hash: String, } #[derive(Serialize)] pub struct ResourceList { items: Vec, pagination: PaginationMeta, } #[derive(Debug, Deserialize)] pub struct ResourceListQuery { #[serde(default)] q: String, #[serde(default = "default_page")] page: usize, #[serde(default = "default_per_page")] per_page: usize, } #[derive(Debug, Serialize)] pub struct PaginationMeta { page: usize, per_page: usize, total: usize, total_pages: usize, } fn default_page() -> usize { 1 } fn default_per_page() -> usize { 25 } fn normalized_per_page(value: usize) -> usize { match value { 25 | 50 | 100 => value, _ => 25, } } #[derive(Serialize)] pub struct SessionResponse { #[serde(skip_serializing)] token: String, nickname: String, email: String, expires_at: String, directory_managed: bool, directory_display_name: Option, directory_organization: Option, suggested_nickname: Option, editor_color: Option, theme: String, } #[derive(Serialize)] pub struct IdentityResponse { nickname: String, registered: bool, } #[derive(Serialize)] pub struct RegisterResponse { nickname: String, email: String, expires_at: Option, confirmation_required: bool, theme: String, message: String, } pub async fn identity( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { let nickname = validate_nickname(&req.nickname)?; let request_token = crate::security::session_token(&headers); debug!(nickname = %nickname, has_session = request_token.is_some(), "identity check requested"); match find_user_by_nickname(&state, &nickname).await? { None => { debug!(nickname = %nickname, "nickname is available for guest use"); Ok(Json(IdentityResponse { nickname, registered: false, })) } Some(user) => { let token = request_token.ok_or_else(|| { AuthError::unauthorized("This nickname is registered. Log in to use it.") })?; let current = user_from_token(&state, token).await?.ok_or_else(|| { AuthError::unauthorized("Your session has expired. Log in again.") })?; if current.id != user.id { return Err(AuthError::unauthorized( "This nickname belongs to another account.", )); } info!(user_id = user.id, nickname = %user.nickname, "registered identity authorized"); Ok(Json(IdentityResponse { nickname: user.nickname, registered: true, })) } } } pub async fn register( State(state): State, Json(req): Json, ) -> Result { if state.ldap.is_some() { return Err(AuthError::forbidden( "Local registration is disabled while LDAP authentication is enabled.", )); } if !state.registration_enabled { warn!("registration attempt rejected because registration is disabled"); return Err(AuthError::forbidden("Registration is disabled.")); } if state.account_confirmation_required && state.smtp.is_none() { return Err(AuthError::service_unavailable( "Account confirmation requires SMTP configuration.", )); } let nickname = validate_nickname(&req.nickname)?; let email = validate_email(&req.email)?; info!(nickname = %nickname, email_domain = %email_domain(&email), "registration requested"); validate_password(&req.password)?; let nickname_key = normalize(&nickname); let email_key = normalize(&email); if find_user_by_nickname(&state, &nickname).await?.is_some() { return Err(AuthError::conflict("This nickname is already registered.")); } if find_user_by_email(&state, &email).await?.is_some() { return Err(AuthError::conflict( "This e-mail address is already registered.", )); } let hash = hash_password(&req.password)?; let confirmed_at = (!state.account_confirmation_required).then(|| Utc::now().to_rfc3339()); sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_USER)) .bind(&nickname) .bind(nickname_key) .bind(&email) .bind(email_key) .bind(hash) .bind(confirmed_at) .execute(state.db.pool()) .await .map_err(AuthError::database)?; let user = find_user_by_nickname(&state, &nickname) .await? .ok_or_else(|| AuthError::internal("Failed to create the account."))?; let mut confirmation_token = None; if state.smtp.is_some() { let token = random_confirmation_token(); if state.account_confirmation_required { let expires = (Utc::now() + Duration::hours(24)).to_rfc3339(); let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER, )) .bind(user.id) .execute(&mut *tx) .await .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::AUTH_INSERT_CONFIRMATION_TOKEN, )) .bind(hash_token(&token)) .bind(user.id) .bind(expires) .execute(&mut *tx) .await .map_err(AuthError::database)?; tx.commit().await.map_err(AuthError::database)?; confirmation_token = Some(token.as_str()); } if let Err(error) = send_registration_email(state.smtp.as_ref().unwrap(), &user, confirmation_token).await { if state.account_confirmation_required { if let Err(delete_error) = sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_USER)) .bind(user.id) .execute(state.db.pool()) .await { tracing::error!(error=%delete_error, user_id=user.id, "failed to roll back account after confirmation e-mail error"); } return Err(error); } warn!( user_id = user.id, "account created, but registration e-mail could not be sent" ); } } else { warn!( user_id = user.id, "account created without registration e-mail because SMTP is not configured" ); } if state.account_confirmation_required { info!(user_id = user.id, nickname = %user.nickname, "account registered; confirmation required"); return Ok(( StatusCode::CREATED, Json(RegisterResponse { nickname: user.nickname, email: user.email, expires_at: None, confirmation_required: true, theme: user.theme, message: "Account created. Check your e-mail and confirm the account before logging in." .into(), }), ) .into_response()); } let session = create_session(&state, &user).await?; info!(user_id = user.id, nickname = %user.nickname, "account registered and session created"); let cookie = crate::security::session_cookie(&session.token, state.user_session_ttl_days); let mut response = ( StatusCode::CREATED, Json(RegisterResponse { nickname: session.nickname, email: session.email, expires_at: Some(session.expires_at), confirmation_required: false, theme: session.theme, message: "Account created.".into(), }), ) .into_response(); response.headers_mut().insert(header::SET_COOKIE, cookie); Ok(response) } pub async fn login( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result { let client_key = crate::security::client_key(&headers); let login_key: String = normalize(&req.email).chars().take(320).collect(); let client_limit_key = format!("login-client:{client_key}"); let limit_key = format!("login:{client_key}:{login_key}"); let window = std::time::Duration::from_secs(15 * 60); state .check_rate_limit(client_limit_key.clone(), 30, window) .await .map_err(|seconds| { AuthError::rate_limited(&format!( "Too many login attempts. Try again in {seconds} seconds." )) })?; state .check_rate_limit(limit_key.clone(), 5, window) .await .map_err(|seconds| { AuthError::rate_limited(&format!( "Too many login attempts. Try again in {seconds} seconds." )) })?; let session = if state.ldap.is_some() { ldap::login(&state, &req.email, &req.password).await? } else { local::login(&state, &req.email, &req.password).await? }; state.clear_rate_limit(&limit_key).await; Ok(session_json_response( StatusCode::OK, session, state.user_session_ttl_days, )) } pub async fn resend_confirmation( State(state): State, Json(req): Json, ) -> Result, AuthError> { if !state.account_confirmation_required { return Err(AuthError::bad_request( "Account confirmation is not enabled.", )); } let smtp = state .smtp .as_ref() .ok_or_else(|| AuthError::service_unavailable("SMTP is not configured."))?; let email = validate_email(&req.email)?; let user = find_user_by_email(&state, &email).await?.ok_or_else(|| { AuthError::bad_request("No unconfirmed account exists for this e-mail address.") })?; if user.confirmed_at.is_some() { return Err(AuthError::bad_request("This account is already confirmed.")); } let last_created: Option = sqlx::query_scalar(queries::get( state.db.kind(), queries::AUTH_LATEST_CONFIRMATION_CREATED_AT, )) .bind(user.id) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; if let Some(value) = last_created { if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&value) { let available = created.with_timezone(&Utc) + Duration::minutes(10); if available > Utc::now() { let seconds = (available - Utc::now()).num_seconds().max(1); return Err(AuthError::bad_request(&format!( "A new confirmation e-mail can be sent in {} minute(s).", (seconds + 59) / 60 ))); } } } sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER, )) .bind(user.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; let token = random_confirmation_token(); let token_hash = hash_token(&token); let expires_at = (Utc::now() + Duration::hours(24)).to_rfc3339(); sqlx::query(queries::get( state.db.kind(), queries::AUTH_INSERT_CONFIRMATION_TOKEN, )) .bind(token_hash) .bind(user.id) .bind(expires_at) .execute(state.db.pool()) .await .map_err(AuthError::database)?; send_registration_email(smtp, &user, Some(&token)).await?; Ok(Json( serde_json::json!({"ok":true,"message":"A new confirmation e-mail has been sent."}), )) } pub async fn confirm_account( State(state): State, Json(req): Json, ) -> Result, AuthError> { let now_time = Utc::now(); let now = now_time.to_rfc3339(); let token_hash = hash_token(req.token.trim()); let row = sqlx::query(queries::get( state.db.kind(), queries::AUTH_FIND_CONFIRMATION_TOKEN, )) .bind(&token_hash) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; let row = row.ok_or_else(|| { AuthError::bad_request("The confirmation link is invalid or has expired.") })?; let user_id: i64 = row.try_get(0).map_err(AuthError::database)?; let expires_at = crate::row_decode::text(&row, 1).map_err(AuthError::database)?; let used_at = crate::row_decode::optional_text(&row, 2).map_err(AuthError::database)?; let expires_at = chrono::DateTime::parse_from_rfc3339(&expires_at) .map_err(|_| AuthError::bad_request("The confirmation link is invalid or has expired."))? .with_timezone(&Utc); if used_at.is_some() || expires_at <= now_time { return Err(AuthError::bad_request( "The confirmation link is invalid or has expired.", )); } let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; let consumed = sqlx::query(queries::get( state.db.kind(), queries::AUTH_CONSUME_CONFIRMATION_TOKEN, )) .bind(&now) .bind(&token_hash) .bind(&now) .execute(&mut *tx) .await .map_err(AuthError::database)?; if consumed.rows_affected() != 1 { return Err(AuthError::bad_request( "The confirmation link is invalid or has expired.", )); } let confirmed = sqlx::query(queries::get(state.db.kind(), queries::AUTH_CONFIRM_USER)) .bind(&now) .bind(&now) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; if confirmed.rows_affected() != 1 { return Err(AuthError::internal("The account could not be confirmed.")); } sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER, )) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; tx.commit().await.map_err(AuthError::database)?; info!(user_id, "account confirmed"); Ok(Json( serde_json::json!({"ok": true, "message": "Account confirmed. You can now log in."}), )) } async fn directory_profile_metadata( state: &SharedState, user: &User, ) -> Result<(bool, Option, Option, Option), AuthError> { let row = sqlx::query(queries::get( state.db.kind(), queries::AUTH_DIRECTORY_PROFILE_BY_USER, )) .bind(user.id) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; let Some(row) = row else { return Ok((false, None, None, None)); }; let provider = crate::row_decode::text(&row, 0).map_err(AuthError::database)?; let display_name = crate::row_decode::optional_text(&row, 1).map_err(AuthError::database)?; if provider == "local" { return Ok((false, None, None, None)); } let display_name = display_name.filter(|value| !value.trim().is_empty()); let organization = state .ldap .as_ref() .map(|config| config.organization.trim().to_owned()) .filter(|value| !value.is_empty()); let suggested = suggested_directory_nickname(display_name.as_deref(), &user.email); Ok((true, display_name, organization, suggested)) } fn suggested_directory_nickname(display_name: Option<&str>, email: &str) -> Option { if let Some(display_name) = display_name { let words: Vec<&str> = display_name .split_whitespace() .filter(|word| !word.is_empty()) .collect(); if words.len() >= 2 { let first = words .first() .and_then(|word| word.chars().next()) .unwrap_or('u'); let last = words.last().copied().unwrap_or_default(); let candidate: String = format!("{first}.{last}") .to_lowercase() .chars() .take(MAX_NICKNAME) .collect(); if let Ok(value) = validate_nickname(&candidate) { return Some(value); } } } email .split('@') .next() .filter(|value| !value.trim().is_empty()) .map(|value| { value .to_lowercase() .chars() .take(MAX_NICKNAME) .collect::() }) .and_then(|value| validate_nickname(&value).ok()) } pub async fn me( State(state): State, headers: HeaderMap, ) -> Result { let token = crate::security::session_token(&headers) .ok_or_else(|| AuthError::unauthorized("Not logged in."))?; let Some(user) = user_from_token(&state, token).await? else { let mut response = AuthError::unauthorized("Your session has expired.").into_response(); response .headers_mut() .insert(header::SET_COOKIE, crate::security::clear_session_cookie()); return Ok(response); }; debug!(user_id = user.id, "session validation successful"); let expires_at: String = sqlx::query_scalar(queries::get( state.db.kind(), queries::AUTH_SESSION_EXPIRES_AT, )) .bind(token) .fetch_one(state.db.pool()) .await .map_err(AuthError::database)?; let (directory_managed, directory_display_name, directory_organization, suggested_nickname) = directory_profile_metadata(&state, &user).await?; let editor_color: Option = sqlx::query_scalar(queries::get( state.db.kind(), queries::AUTH_EDITOR_COLOR_BY_USER, )) .bind(user.id) .fetch_one(state.db.pool()) .await .map_err(AuthError::database)?; Ok(session_json_response( StatusCode::OK, SessionResponse { token: token.into(), nickname: user.nickname, email: user.email, expires_at, directory_managed, directory_display_name, directory_organization, suggested_nickname, editor_color, theme: user.theme, }, state.user_session_ttl_days, )) } pub async fn update_profile( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { let user = require_user(&state, &headers).await?; let (directory_managed, _, _, _) = directory_profile_metadata(&state, &user).await?; if directory_managed { if req .new_email .as_deref() .is_some_and(|value| !value.trim().is_empty()) || req .new_password .as_deref() .is_some_and(|value| !value.is_empty()) { return Err(AuthError::forbidden( "E-mail and password are managed by LDAP/AD.", )); } } else { let changes_credentials = req .new_email .as_deref() .is_some_and(|value| !value.trim().is_empty()) || req .new_password .as_deref() .is_some_and(|value| !value.is_empty()); if changes_credentials && !verify_password(&user.password_hash, &req.password) { return Err(AuthError::unauthorized( "The current password is incorrect.", )); } } let mut nickname = user.nickname.clone(); if let Some(value) = req.nickname.as_deref() { nickname = validate_nickname(value)?; if normalize(&nickname) != normalize(&user.nickname) && find_user_by_nickname(&state, &nickname).await?.is_some() { return Err(AuthError::conflict("This nickname is already registered.")); } sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_NICKNAME)) .bind(&nickname) .bind(normalize(&nickname)) .bind(Utc::now().to_rfc3339()) .bind(user.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; } let mut email_pending = false; if !directory_managed { if let Some(value) = req .new_password .as_deref() .filter(|value| !value.is_empty()) { validate_password(value)?; let hash = hash_password(value)?; sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_PASSWORD)) .bind(hash) .bind(Utc::now().to_rfc3339()) .bind(user.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; } if let Some(value) = req .new_email .as_deref() .filter(|value| !value.trim().is_empty()) { let email = validate_email(value)?; if normalize(&email) != normalize(&user.email) { if find_user_by_email(&state, &email).await?.is_some() { return Err(AuthError::conflict( "This e-mail address is already registered.", )); } let smtp = state .smtp .as_ref() .ok_or_else(|| AuthError::service_unavailable("SMTP is not configured."))?; create_account_action(&state, &user, "email", Some(&email), smtp).await?; email_pending = true; } } } if let Some(value) = req.editor_color.as_deref() { let color = validate_editor_color(value)?; sqlx::query(queries::get( state.db.kind(), queries::AUTH_UPDATE_EDITOR_COLOR, )) .bind(color) .bind(Utc::now().to_rfc3339()) .bind(user.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; } let mut theme = user.theme.clone(); if let Some(value) = req.theme.as_deref() { theme = validate_theme(value)?.to_owned(); sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_THEME)) .bind(&theme) .bind(Utc::now().to_rfc3339()) .bind(user.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; } Ok(Json(serde_json::json!({ "ok": true, "nickname": nickname, "editor_color": req.editor_color.as_deref(), "theme": theme, "email_pending": email_pending, "message": if email_pending { "Profile updated. Confirm the new e-mail address using the link sent to it." } else { "Profile updated." } }))) } pub async fn request_account_deletion( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { if state.ldap.is_some() { return Err(AuthError::forbidden( "LDAP accounts cannot be deleted here.", )); } let user = require_user(&state, &headers).await?; if !verify_password(&user.password_hash, &req.password) { return Err(AuthError::unauthorized( "The current password is incorrect.", )); } let smtp = state .smtp .as_ref() .ok_or_else(|| AuthError::service_unavailable("SMTP is not configured."))?; create_account_action(&state, &user, "delete", None, smtp).await?; Ok(Json( serde_json::json!({"ok":true,"message":"A confirmation link has been sent to your e-mail address."}), )) } pub async fn confirm_account_action( State(state): State, Json(req): Json, ) -> Result, AuthError> { let now = Utc::now(); let hash = hash_token(req.token.trim()); let row = sqlx::query(queries::get( state.db.kind(), queries::AUTH_ACCOUNT_ACTION_BY_TOKEN, )) .bind(&hash) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; let row = row.ok_or_else(|| { AuthError::bad_request("The confirmation link is invalid or has expired.") })?; let user_id: i64 = row.try_get(0).map_err(AuthError::database)?; let action = crate::row_decode::text(&row, 1).map_err(AuthError::database)?; let payload = crate::row_decode::optional_text(&row, 2).map_err(AuthError::database)?; let expires_at = crate::row_decode::text(&row, 3).map_err(AuthError::database)?; let used_at = crate::row_decode::optional_text(&row, 4).map_err(AuthError::database)?; let expires = chrono::DateTime::parse_from_rfc3339(&expires_at) .map_err(|_| AuthError::bad_request("The confirmation link is invalid or has expired."))? .with_timezone(&Utc); if used_at.is_some() || expires <= now { return Err(AuthError::bad_request( "The confirmation link is invalid or has expired.", )); } let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::AUTH_CONSUME_ACCOUNT_ACTION, )) .bind(now.to_rfc3339()) .bind(&hash) .execute(&mut *tx) .await .map_err(AuthError::database)?; let message = if action == "email" { let email = payload.ok_or_else(|| AuthError::internal("Missing e-mail change payload."))?; if find_user_by_email(&state, &email).await?.is_some() { return Err(AuthError::conflict( "This e-mail address is already registered.", )); } sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_EMAIL)) .bind(&email) .bind(normalize(&email)) .bind(now.to_rfc3339()) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; "E-mail address changed." } else if action == "delete" { let original_nickname: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::AUTH_NICKNAME_BY_ID)) .bind(user_id) .fetch_optional(&mut *tx) .await .map_err(AuthError::database)?; let deleted_nickname = format!("Deleted user #{user_id}"); if let Some(original_nickname) = original_nickname { sqlx::query(queries::get( state.db.kind(), queries::AUTH_ANONYMIZE_NOTE_CREATORS, )) .bind(&deleted_nickname) .bind(original_nickname) .execute(&mut *tx) .await .map_err(AuthError::database)?; } let deleted_email = format!("deleted-user-{user_id}@invalid.local"); let deleted_password = hash_password(&random_token())?; sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_SESSIONS_BY_USER, )) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_COLORS_DELETE_BY_USER, )) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::EDITOR_PREFERENCES_DELETE_BY_USER, )) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; sqlx::query(queries::get(state.db.kind(), queries::AUTH_ANONYMIZE_USER)) .bind(&deleted_nickname) .bind(normalize(&deleted_nickname)) .bind(&deleted_email) .bind(normalize(&deleted_email)) .bind(deleted_password) .bind(now.to_rfc3339()) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; "Account deleted. Content has been preserved under an anonymized owner." } else { return Err(AuthError::bad_request("Unknown account action.")); }; tx.commit().await.map_err(AuthError::database)?; Ok(Json(serde_json::json!({"ok":true,"message":message}))) } async fn create_account_action( state: &SharedState, user: &User, action: &str, payload: Option<&str>, smtp: &SmtpConfig, ) -> Result<(), AuthError> { sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_ACCOUNT_ACTIONS, )) .bind(user.id) .bind(action) .execute(state.db.pool()) .await .map_err(AuthError::database)?; let token = random_token(); let expires = (Utc::now() + Duration::hours(1)).to_rfc3339(); sqlx::query(queries::get( state.db.kind(), queries::AUTH_INSERT_ACCOUNT_ACTION, )) .bind(hash_token(&token)) .bind(user.id) .bind(action) .bind(payload) .bind(expires) .bind(Utc::now().to_rfc3339()) .execute(state.db.pool()) .await .map_err(AuthError::database)?; send_account_action(smtp, user, action, payload, &token).await } async fn send_account_action( smtp: &SmtpConfig, user: &User, action: &str, payload: Option<&str>, token: &str, ) -> Result<(), AuthError> { let site = smtp.public_url.trim_end_matches('/'); let url = format!("{site}/auth/account-action/{token}"); let sender = smtp .from .parse::() .map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?; let target = if action == "email" { payload.unwrap_or(&user.email) } else { &user.email }; let recipient = target .parse::() .map_err(|_| AuthError::internal("Invalid recipient address."))?; let (subject, title, copy) = if action == "email" { ( "Confirm your new RustPad e-mail", "Confirm e-mail change", "Confirm the new e-mail address within one hour.", ) } else { ( "Confirm RustPad account deletion", "Confirm account deletion", "Confirm permanent account deletion within one hour.", ) }; let text = format!( "Hello {},\n\n{}\n{}\n\nIf you did not request this, ignore this message.", user.nickname, copy, url ); let html = format!( r#"

{}

Hello {},

{}

Confirm action

{}

If you did not request this, ignore this message.

"#, title, user.nickname, copy, url, url, url ); let message = Message::builder() .from(sender) .to(recipient) .subject(subject) .multipart( MultiPart::alternative() .singlepart( SinglePart::builder() .header(ContentType::TEXT_PLAIN) .body(text), ) .singlepart( SinglePart::builder() .header(ContentType::TEXT_HTML) .body(html), ), ) .map_err(|_| AuthError::internal("Failed to build account confirmation e-mail."))?; send_message(smtp, message, "account action e-mail").await } pub async fn resources( State(state): State, headers: HeaderMap, Query(query): Query, ) -> Result, AuthError> { let user = require_user(&state, &headers).await?; let workspaces = sqlx::query_as::<_, ResourceItem>(queries::get( state.db.kind(), queries::USER_LIST_WORKSPACES, )) .bind(user.id) .bind(user.id) .fetch_all(state.db.pool()) .await .map_err(AuthError::database)?; let pads = sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_PADS)) .bind(user.id) .bind(user.id) .fetch_all(state.db.pool()) .await .map_err(AuthError::database)?; let search = query.q.trim().to_lowercase(); let mut items = workspaces .into_iter() .map(|item| (item, "workspace")) .chain(pads.into_iter().map(|item| (item, "pad"))) .filter(|(item, kind)| { search.is_empty() || item.title.to_lowercase().contains(&search) || item.slug.to_lowercase().contains(&search) || kind.contains(&search) }) .collect::>(); items.sort_by(|(left, _), (right, _)| right.updated_at.cmp(&left.updated_at)); let page = query.page.max(1); let per_page = normalized_per_page(query.per_page); let total = items.len(); let total_pages = ((total + per_page - 1) / per_page).max(1); let page = page.min(total_pages); let start = (page - 1) * per_page; let items = items .into_iter() .skip(start) .take(per_page) .map(|(mut item, kind)| { item.kind = kind.to_string(); item }) .collect(); Ok(Json(ResourceList { items, pagination: PaginationMeta { page, per_page, total, total_pages, }, })) } pub async fn update_resource( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { let user = require_user(&state, &headers).await?; let kind = req.kind.trim(); let slug = req.slug.trim(); let hash = match req .password .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) { Some(v) => { validate_password(v)?; Some(hash_password(v)?) } None => None, }; let password_enabled = hash.is_some(); ensure_owner(&state, user.id, kind, slug).await?; let query = match kind { "workspace" => queries::USER_SET_WORKSPACE_PASSWORD, "pad" => queries::USER_SET_PAD_PASSWORD, _ => return Err(AuthError::bad_request("Unknown resource type.")), }; sqlx::query(queries::get(state.db.kind(), query)) .bind(hash) .bind(slug) .execute(state.db.pool()) .await .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE, )) .bind(kind) .bind(slug) .execute(state.db.pool()) .await .map_err(AuthError::database)?; if password_enabled { match kind { "workspace" => state.notify_workspace_password_required(slug, None).await, "pad" => state.notify_pad_password_required(slug, None).await, _ => {} } } Ok(Json( serde_json::json!({"ok":true,"protected":password_enabled}), )) } pub async fn delete_resource( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { let user = require_user(&state, &headers).await?; ensure_owner(&state, user.id, &req.kind, &req.slug).await?; let query = match req.kind.as_str() { "workspace" => { if let Some(workspace) = crate::db::find_workspace(&state.db, req.slug.trim()) .await .map_err(AuthError::database)? { let notes = crate::db::list_notes(&state.db, workspace.id) .await .map_err(AuthError::database)?; for note in notes { crate::db::delete_resource_editor_state( &state.db, "note", &format!("{}/{}", req.slug.trim(), note.slug), ) .await .map_err(AuthError::database)?; let files = crate::db::list_note_files(&state.db, note.id) .await .map_err(AuthError::database)?; for file in files { crate::storage::delete_url_file( &state.storage, "notes", note.id, &file.url, ) .await .map_err(|_| AuthError::internal("Failed to delete workspace files."))?; } } } queries::USER_DELETE_WORKSPACE } "pad" => { crate::db::delete_resource_editor_state(&state.db, "pad", req.slug.trim()) .await .map_err(AuthError::database)?; if let Some(pad) = crate::db::find_pad(&state.db, req.slug.trim()) .await .map_err(AuthError::database)? { let files = crate::db::list_pad_files(&state.db, pad.id) .await .map_err(AuthError::database)?; for file in files { crate::storage::delete_url_file(&state.storage, "pads", pad.id, &file.url) .await .map_err(|_| AuthError::internal("Failed to delete pad files."))?; } } queries::USER_DELETE_PAD } _ => return Err(AuthError::bad_request("Unknown resource type.")), }; sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE, )) .bind(req.kind.as_str()) .bind(req.slug.trim()) .execute(state.db.pool()) .await .map_err(AuthError::database)?; sqlx::query(queries::get(state.db.kind(), query)) .bind(req.slug.trim()) .execute(state.db.pool()) .await .map_err(AuthError::database)?; Ok(Json(serde_json::json!({"ok":true}))) } pub async fn optional_user( state: &SharedState, headers: &HeaderMap, ) -> Result, AuthError> { match crate::security::session_token(headers) { Some(token) => user_from_token(state, token).await, None => Ok(None), } } async fn require_user(state: &SharedState, headers: &HeaderMap) -> Result { optional_user(state, headers) .await? .ok_or_else(|| AuthError::unauthorized("Log in first.")) } async fn ensure_owner( state: &SharedState, user_id: i64, kind: &str, slug: &str, ) -> Result<(), AuthError> { let query = match kind { "workspace" => queries::USER_OWNS_WORKSPACE, "pad" => queries::USER_OWNS_PAD, _ => return Err(AuthError::bad_request("Unknown resource type.")), }; let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), query)) .bind(user_id) .bind(slug.trim()) .fetch_one(state.db.pool()) .await .map_err(AuthError::database)?; if count == 0 { return Err(AuthError::forbidden( "This item does not belong to your account.", )); } Ok(()) } pub(crate) async fn resource_is_public_unprotected( state: &SharedState, kind: &str, slug: &str, ) -> Result { let slug = slug.trim(); match kind { "workspace" => Ok(crate::db::find_workspace(&state.db, slug) .await .map_err(AuthError::database)? .is_some_and(|workspace| { workspace.is_private == 0 && workspace.password_hash.is_none() })), "pad" => Ok(crate::db::find_pad(&state.db, slug) .await .map_err(AuthError::database)? .is_some_and(|pad| pad.is_private == 0 && pad.password_hash.is_none())), _ => Ok(false), } } async fn ensure_share_links_enabled( state: &SharedState, kind: &str, slug: &str, ) -> Result<(), AuthError> { if resource_is_public_unprotected(state, kind, slug).await? { return Err(AuthError::conflict( "Direct share links are disabled for public resources without a password.", )); } Ok(()) } pub async fn set_resource_privacy( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { let user = require_user(&state, &headers).await?; ensure_owner(&state, user.id, &req.kind, &req.slug).await?; let query = match req.kind.as_str() { "workspace" => queries::USER_SET_WORKSPACE_PRIVACY, "pad" => queries::USER_SET_PAD_PRIVACY, _ => return Err(AuthError::bad_request("Unknown resource type.")), }; sqlx::query(queries::get(state.db.kind(), query)) .bind(req.private) .bind(req.slug.trim()) .execute(state.db.pool()) .await .map_err(AuthError::database)?; Ok(Json(serde_json::json!({"ok":true}))) } pub async fn share_resource_users( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; let permission = validate_permission(&req.permission)?; if state.share_confirmation_required && state.smtp.is_none() { return Err(AuthError::service_unavailable( "Share confirmation requires SMTP configuration.", )); } let recipients: Vec = req .recipients .split([',', ';', '\n']) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .collect::>() .into_iter() .collect(); if recipients.is_empty() || recipients.len() > 100 { return Err(AuthError::bad_request( "Enter between 1 and 100 e-mail addresses or user names.", )); } let mut missing = Vec::new(); for recipient in recipients { let user = find_user_by_share_identifier(&state, &recipient).await?; let Some(user) = user else { missing.push(recipient); continue; }; if user.confirmed_at.is_none() { missing.push(format!("{} (account not activated)", recipient)); continue; } if user.id == owner.id { continue; } sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER, )) .bind(&req.kind) .bind(req.slug.trim()) .bind(user.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::SHARE_INVITATION_DELETE_USER, )) .bind(&req.kind) .bind(req.slug.trim()) .bind(user.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; if state.share_confirmation_required { let token = random_token(); let token_hash = hash_token(&token); let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339(); sqlx::query(queries::get( state.db.kind(), queries::SHARE_INVITATION_INSERT, )) .bind(&token_hash) .bind(&req.kind) .bind(req.slug.trim()) .bind(user.id) .bind(permission) .bind(owner.id) .bind(&expires_at) .execute(state.db.pool()) .await .map_err(AuthError::database)?; if let Err(error) = send_share_invitation( state.smtp.as_ref().unwrap(), &owner, &user, &req.kind, req.slug.trim(), permission, &token, ) .await { let _ = sqlx::query(queries::get( state.db.kind(), queries::SHARE_INVITATION_DELETE_TOKEN, )) .bind(&token_hash) .execute(state.db.pool()) .await; return Err(error); } } else { sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_PERMISSION_INSERT, )) .bind(&req.kind) .bind(req.slug.trim()) .bind(user.id) .bind(permission) .execute(state.db.pool()) .await .map_err(AuthError::database)?; } } if !missing.is_empty() { return Err(AuthError::bad_request(&format!( "No active registered account for: {}", missing.join(", ") ))); } Ok(Json( serde_json::json!({"ok":true,"confirmation_required":state.share_confirmation_required}), )) } pub async fn accept_share_invitation( State(state): State, AxumPath(token): AxumPath, ) -> Result, AuthError> { let token_hash = hash_token(token.trim()); let row = sqlx::query(queries::get( state.db.kind(), queries::SHARE_INVITATION_FIND_TOKEN, )) .bind(&token_hash) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; let row = row.ok_or_else(|| { AuthError::bad_request("The sharing invitation is invalid or has expired.") })?; let kind = crate::row_decode::text(&row, 0).map_err(AuthError::database)?; let slug = crate::row_decode::text(&row, 1).map_err(AuthError::database)?; let user_id: i64 = row.try_get(2).map_err(AuthError::database)?; let permission = crate::row_decode::text(&row, 3).map_err(AuthError::database)?; let expires_at = crate::row_decode::text(&row, 4).map_err(AuthError::database)?; let accepted_at = crate::row_decode::optional_text(&row, 5).map_err(AuthError::database)?; let expires = chrono::DateTime::parse_from_rfc3339(&expires_at) .map_err(|_| AuthError::bad_request("The sharing invitation is invalid or has expired."))? .with_timezone(&Utc); if accepted_at.is_none() { if expires <= Utc::now() { return Err(AuthError::bad_request( "The sharing invitation is invalid or has expired.", )); } let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER, )) .bind(&kind) .bind(&slug) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_PERMISSION_INSERT, )) .bind(&kind) .bind(&slug) .bind(user_id) .bind(&permission) .execute(&mut *tx) .await .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::SHARE_INVITATION_ACCEPT, )) .bind(Utc::now().to_rfc3339()) .bind(&token_hash) .execute(&mut *tx) .await .map_err(AuthError::database)?; tx.commit().await.map_err(AuthError::database)?; } let target = if kind == "workspace" { format!("/w/{slug}") } else { format!("/p/{slug}") }; Ok(Json(serde_json::json!({"ok": true, "url": target}))) } pub async fn remove_resource_user( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; let email = normalize(&req.email); if let Some(user) = find_user_by_email(&state, &email).await? { sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER, )) .bind(&req.kind) .bind(req.slug.trim()) .bind(user.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::SHARE_INVITATION_DELETE_USER, )) .bind(&req.kind) .bind(req.slug.trim()) .bind(user.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; } Ok(Json(serde_json::json!({"ok":true}))) } pub async fn resource_sharing( State(state): State, headers: HeaderMap, axum::extract::Query(params): axum::extract::Query>, ) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; let kind = params .get("kind") .ok_or_else(|| AuthError::bad_request("Missing kind."))?; let slug = params .get("slug") .ok_or_else(|| AuthError::bad_request("Missing slug."))?; ensure_owner(&state, owner.id, kind, slug).await?; let share_links_enabled = !resource_is_public_unprotected(&state, kind, slug).await?; let users: Vec = sqlx::query_as(queries::get( state.db.kind(), queries::RESOURCE_SHARING_USERS, )) .bind(kind) .bind(slug) .fetch_all(state.db.pool()) .await .map_err(AuthError::database)?; let links: Vec = if share_links_enabled { sqlx::query_as(queries::get( state.db.kind(), queries::RESOURCE_SHARING_LINKS, )) .bind(kind) .bind(slug) .fetch_all(state.db.pool()) .await .map_err(AuthError::database)? } else { Vec::new() }; let pending: Vec = sqlx::query_as(queries::get( state.db.kind(), queries::RESOURCE_SHARING_PENDING, )) .bind(kind) .bind(slug) .fetch_all(state.db.pool()) .await .map_err(AuthError::database)?; Ok(Json( serde_json::json!({"users":users.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission})).collect::>(), "pending":pending.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission,"expires_at":row.expires_at})).collect::>(), "links":links.into_iter().map(|row|serde_json::json!({"token_hash":row.token_hash,"label":row.label,"permission":row.permission,"expires_at":row.expires_at,"created_at":row.created_at})).collect::>(), "share_links_enabled":share_links_enabled }), )) } pub async fn create_share_link( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; ensure_share_links_enabled(&state, &req.kind, &req.slug).await?; let permission = validate_permission(&req.permission)?; let label = normalize_share_link_label(req.label.as_deref())?; let expires_at = normalize_share_expiration(req.expires_at.as_deref())?; let token = random_token(); let token_hash = hash_token(&token); sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT)) .bind(&token_hash) .bind(&label) .bind(&req.kind) .bind(req.slug.trim()) .bind(permission) .bind(&expires_at) .bind(owner.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; let base = if req.kind == "workspace" { format!("/w/{}", req.slug.trim()) } else { format!("/p/{}", req.slug.trim()) }; let mut response = Json( serde_json::json!({"token_hash":token_hash,"url":format!("{base}?share={token}"),"label":label,"permission":permission,"expires_at":expires_at}), ) .into_response(); response.headers_mut().insert( header::CACHE_CONTROL, "no-cache, no-store, max-age=0" .parse() .expect("valid cache-control"), ); response .headers_mut() .insert(header::PRAGMA, "no-cache".parse().expect("valid pragma")); Ok(response) } pub async fn update_share_link( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; ensure_share_links_enabled(&state, &req.kind, &req.slug).await?; let permission = validate_permission(&req.permission)?; let label = normalize_share_link_label(req.label.as_deref())?; let expires_at = normalize_share_expiration(req.expires_at.as_deref())?; let result = sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_UPDATE)) .bind(&label) .bind(permission) .bind(&expires_at) .bind(req.token_hash.trim()) .bind(&req.kind) .bind(req.slug.trim()) .execute(state.db.pool()) .await .map_err(AuthError::database)?; if result.rows_affected() == 0 { return Err(AuthError::bad_request( "Share link was not found or is already revoked.", )); } Ok(Json( serde_json::json!({"ok":true,"label":label,"permission":permission,"expires_at":expires_at}), )) } pub async fn revoke_share_link( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; ensure_share_links_enabled(&state, &req.kind, &req.slug).await?; sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_REVOKE)) .bind(Utc::now().to_rfc3339()) .bind(req.token_hash.trim()) .bind(&req.kind) .bind(req.slug.trim()) .execute(state.db.pool()) .await .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::SHARE_SESSIONS_DELETE_BY_LINK, )) .bind(req.token_hash.trim()) .bind(&req.kind) .bind(req.slug.trim()) .execute(state.db.pool()) .await .map_err(AuthError::database)?; Ok(Json(serde_json::json!({"ok":true}))) } fn validate_permission(value: &str) -> Result<&str, AuthError> { match value { "ro" | "rw" => Ok(value), _ => Err(AuthError::bad_request("Permission must be ro or rw.")), } } fn normalize_share_link_label(value: Option<&str>) -> Result, AuthError> { let Some(value) = value else { return Ok(None); }; let value = value.trim(); if value.is_empty() { return Ok(None); } if value.chars().count() > MAX_SHARE_LINK_LABEL || value.chars().any(|character| character.is_control()) { return Err(AuthError::bad_request( "Link label must contain at most 120 printable characters.", )); } Ok(Some(value.to_owned())) } fn normalize_share_expiration(value: Option<&str>) -> Result, AuthError> { let Some(value) = value else { return Ok(None); }; let expires = chrono::DateTime::parse_from_rfc3339(value) .map_err(|_| AuthError::bad_request("Invalid expiration date."))? .with_timezone(&Utc); if expires <= Utc::now() { return Err(AuthError::bad_request("Expiration must be in the future.")); } Ok(Some(expires.to_rfc3339())) } pub async fn is_resource_owner( state: &SharedState, kind: &str, slug: &str, token: Option<&str>, ) -> Result { let Some(token) = token.filter(|v| !v.is_empty()) else { return Ok(false); }; let Some(user) = user_from_token(state, token).await? else { return Ok(false); }; Ok(ensure_owner(state, user.id, kind, slug).await.is_ok()) } pub async fn account_resource_permission( state: &SharedState, kind: &str, slug: &str, token: Option<&str>, ) -> Result, AuthError> { let Some(token) = token.filter(|value| !value.is_empty()) else { return Ok(None); }; let Some(user) = user_from_token(state, token).await? else { return Ok(None); }; if ensure_owner(state, user.id, kind, slug).await.is_ok() { return Ok(Some("rw".into())); } let permission: Option = sqlx::query_scalar(queries::get( state.db.kind(), queries::RESOURCE_PERMISSION_BY_USER, )) .bind(kind) .bind(slug) .bind(user.id) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; Ok(permission) } pub async fn create_share_session( state: &SharedState, kind: &str, slug: &str, share_token: &str, client_key: &str, ) -> Result, AuthError> { if resource_is_public_unprotected(state, kind, slug).await? { return Ok(None); } let share_token = share_token.trim(); if !valid_share_token(share_token) || !matches!(kind, "workspace" | "pad") { return Ok(None); } let now = Utc::now(); let now_text = now.to_rfc3339(); let window = std::time::Duration::from_secs(15 * 60); state .check_rate_limit(format!("share-session-client:{client_key}"), 120, window) .await .map_err(|seconds| { AuthError::rate_limited(&format!( "Too many share-link attempts. Try again in {seconds} seconds." )) })?; let source = sqlx::query_as::<_, ShareLinkSessionSource>(queries::get( state.db.kind(), queries::SHARE_LINK_SESSION_SOURCE, )) .bind(hash_token(share_token)) .bind(kind) .bind(slug) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; let Some(source) = source else { return Ok(None); }; if !matches!(source.permission.as_str(), "ro" | "rw") { warn!(kind, slug, "invalid share link permission in database"); return Ok(None); } let session_limit = now + Duration::days(state.anonymous_access_token_ttl_days); let expires_at = match source.expires_at.as_deref() { Some(value) => match chrono::DateTime::parse_from_rfc3339(value) { Ok(value) => std::cmp::min(value.with_timezone(&Utc), session_limit), Err(error) => { warn!(%error, kind, slug, "invalid share link expiration in database"); return Ok(None); } }, None => session_limit, }; let max_age_seconds = (expires_at - now).num_seconds(); if max_age_seconds <= 0 { return Ok(None); } state .check_rate_limit( format!("share-session-client:{client_key}:{}", source.token_hash), 60, window, ) .await .map_err(|seconds| { AuthError::rate_limited(&format!( "Too many share-link sessions. Try again in {seconds} seconds." )) })?; state .check_rate_limit( format!("share-session-link:{}", source.token_hash), 2_000, std::time::Duration::from_secs(60 * 60), ) .await .map_err(|seconds| { AuthError::rate_limited(&format!( "Too many share-link sessions. Try again in {seconds} seconds." )) })?; sqlx::query(queries::get( state.db.kind(), queries::SHARE_SESSIONS_DELETE_EXPIRED, )) .bind(&now_text) .execute(state.db.pool()) .await .map_err(AuthError::database)?; let token = random_token(); sqlx::query(queries::get(state.db.kind(), queries::SHARE_SESSION_INSERT)) .bind(hash_token(&token)) .bind(source.token_hash) .bind(kind) .bind(slug) .bind(expires_at.to_rfc3339()) .execute(state.db.pool()) .await .map_err(AuthError::database)?; Ok(Some(ShareSession { token, max_age_seconds, })) } async fn share_session_permission( state: &SharedState, kind: &str, slug: &str, token: Option<&str>, ) -> Result, AuthError> { let Some(token) = token.filter(|value| !value.is_empty()) else { return Ok(None); }; if !valid_share_token(token) { return Ok(None); } let row = sqlx::query_as::<_, ShareSessionPermissionRow>(queries::get( state.db.kind(), queries::SHARE_SESSION_PERMISSION, )) .bind(hash_token(token)) .bind(kind) .bind(slug) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; let Some(row) = row else { return Ok(None); }; if !matches!(row.permission.as_str(), "ro" | "rw") { warn!(kind, slug, "invalid share session permission in database"); return Ok(None); } let now = Utc::now(); let session_expires = match chrono::DateTime::parse_from_rfc3339(&row.session_expires_at) { Ok(value) => value.with_timezone(&Utc), Err(error) => { warn!(%error, kind, slug, "invalid share session expiration in database"); return Ok(None); } }; if session_expires <= now { return Ok(None); } if let Some(value) = row.link_expires_at.as_deref() { let link_expires = match chrono::DateTime::parse_from_rfc3339(value) { Ok(value) => value.with_timezone(&Utc), Err(error) => { warn!(%error, kind, slug, "invalid share link expiration in database"); return Ok(None); } }; if link_expires <= now { return Ok(None); } } Ok(Some(row.permission)) } pub async fn share_access_permission( state: &SharedState, kind: &str, slug: &str, token: Option<&str>, ) -> Result, AuthError> { if resource_is_public_unprotected(state, kind, slug).await? { return Ok(None); } let permission = share_session_permission(state, kind, slug, token).await?; if permission.is_some() { return Ok(permission); } share_link_permission(state, kind, slug, token).await } pub async fn share_link_permission( state: &SharedState, kind: &str, slug: &str, token: Option<&str>, ) -> Result, AuthError> { let Some(token) = token.filter(|value| !value.is_empty()) else { return Ok(None); }; if !valid_share_token(token) { return Ok(None); } let row = sqlx::query_as::<_, ShareLinkPermissionRow>(queries::get( state.db.kind(), queries::SHARE_LINK_PERMISSION, )) .bind(hash_token(token)) .bind(kind) .bind(slug) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; let Some(row) = row else { return Ok(None); }; if !matches!(row.permission.as_str(), "ro" | "rw") { warn!(kind, slug, "invalid share link permission in database"); return Ok(None); } if let Some(value) = row.expires_at.as_deref() { let expires = match chrono::DateTime::parse_from_rfc3339(value) { Ok(value) => value.with_timezone(&Utc), Err(error) => { warn!(%error, kind, slug, "invalid share link expiration in database"); return Ok(None); } }; if expires <= Utc::now() { return Ok(None); } } Ok(Some(row.permission)) } pub async fn logout( State(state): State, headers: HeaderMap, ) -> Result { let session_token = crate::security::session_token(&headers).map(str::to_owned); let access_cookies = crate::security::resource_access_cookies(&headers); let has_access_tokens = access_cookies.iter().any(|(_, token)| token.is_some()); let mut revoked_access_tokens = 0_u64; if session_token.is_some() || has_access_tokens { let mut tx = state .db .pool() .begin() .await .map_err(AuthError::database)?; if let Some(token) = session_token.as_deref() { let result = sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_SESSION_BY_TOKEN, )) .bind(token) .execute(&mut *tx) .await .map_err(AuthError::database)?; info!(rows_affected = result.rows_affected(), "logout processed"); } else { debug!("logout requested without an active session"); } for (_, token) in &access_cookies { let Some(token) = token.as_deref() else { continue; }; let result = sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH, )) .bind(hash_token(token)) .execute(&mut *tx) .await .map_err(AuthError::database)?; revoked_access_tokens += result.rows_affected(); } tx.commit().await.map_err(AuthError::database)?; } else { debug!("logout requested without an active session or resource access cookies"); } debug!( revoked_access_tokens, cleared_access_cookies = access_cookies.len(), "password-derived resource access cleared during logout" ); let mut response = Json(serde_json::json!({"ok": true})).into_response(); response .headers_mut() .append(header::SET_COOKIE, crate::security::clear_session_cookie()); for (name, _) in access_cookies { if let Some(cookie) = crate::security::clear_resource_access_cookie(&name) { response.headers_mut().append(header::SET_COOKIE, cookie); } } Ok(response) } pub async fn request_reset( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { let email = validate_email(&req.email)?; let client_key = crate::security::client_key(&headers); let window = std::time::Duration::from_secs(60 * 60); state .check_rate_limit(format!("password-reset-client:{client_key}"), 10, window) .await .map_err(|seconds| { AuthError::rate_limited(&format!( "Too many password reset requests. Try again in {seconds} seconds." )) })?; state .check_rate_limit( format!("password-reset:{client_key}:{}", normalize(&email)), 3, window, ) .await .map_err(|seconds| { AuthError::rate_limited(&format!( "Too many password reset requests. Try again in {seconds} seconds." )) })?; info!(email_domain = %email_domain(&email), "password reset requested"); let smtp = state.smtp.as_ref().ok_or_else(|| { AuthError::service_unavailable("Password reset is not configured on this server.") })?; if let Some(user) = find_user_by_email(&state, &email).await? { let token = random_token(); let expires = (Utc::now() + Duration::minutes(30)).to_rfc3339(); let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_RESET_TOKENS_BY_USER, )) .bind(user.id) .execute(&mut *tx) .await .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::AUTH_INSERT_RESET_TOKEN, )) .bind(hash_token(&token)) .bind(user.id) .bind(expires) .execute(&mut *tx) .await .map_err(AuthError::database)?; tx.commit().await.map_err(AuthError::database)?; send_reset(smtp, &user, &token).await?; info!(user_id = user.id, "password reset e-mail sent"); } else { debug!(email_domain = %email_domain(&email), "password reset requested for unknown account"); } Ok(Json( serde_json::json!({"ok": true, "message": "If the account exists, a reset link has been sent."}), )) } pub async fn confirm_reset( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { validate_password(&req.password)?; let token_fingerprint = hash_token(req.token.trim()); let client_key = crate::security::client_key(&headers); let client_limit_key = format!("password-reset-confirm-client:{client_key}"); let limit_key = format!( "password-reset-confirm:{client_key}:{}", &token_fingerprint[..16.min(token_fingerprint.len())] ); let window = std::time::Duration::from_secs(15 * 60); state .check_rate_limit(client_limit_key.clone(), 20, window) .await .map_err(|seconds| { AuthError::rate_limited(&format!( "Too many reset attempts. Try again in {seconds} seconds." )) })?; state .check_rate_limit(limit_key.clone(), 10, window) .await .map_err(|seconds| { AuthError::rate_limited(&format!( "Too many reset attempts. Try again in {seconds} seconds." )) })?; info!("password reset confirmation requested"); let now_time = Utc::now(); let now = now_time.to_rfc3339(); let token_hash = token_fingerprint; let token_row = sqlx::query(queries::get( state.db.kind(), queries::AUTH_FIND_RESET_TOKEN, )) .bind(&token_hash) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; let token_row = token_row .ok_or_else(|| AuthError::bad_request("The reset link is invalid or has expired."))?; let user_id: i64 = token_row.try_get(0).map_err(AuthError::database)?; let expires_at = crate::row_decode::text(&token_row, 1).map_err(AuthError::database)?; let used_at = crate::row_decode::optional_text(&token_row, 2).map_err(AuthError::database)?; let expires_at = chrono::DateTime::parse_from_rfc3339(&expires_at) .map_err(|_| AuthError::bad_request("The reset link is invalid or has expired."))? .with_timezone(&Utc); if used_at.is_some() || expires_at <= now_time { warn!( user_id, used = used_at.is_some(), expired = expires_at <= now_time, "password reset token rejected" ); return Err(AuthError::bad_request( "The reset link is invalid or has expired.", )); } let password_hash = hash_password(&req.password)?; let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; let consumed = sqlx::query(queries::get( state.db.kind(), queries::AUTH_CONSUME_RESET_TOKEN, )) .bind(&now) .bind(&token_hash) .bind(&now) .execute(&mut *tx) .await .map_err(AuthError::database)?; if consumed.rows_affected() != 1 { return Err(AuthError::bad_request( "The reset link is invalid or has expired.", )); } let updated = sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_PASSWORD)) .bind(password_hash) .bind(&now) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; if updated.rows_affected() != 1 { return Err(AuthError::internal("The account could not be updated.")); } sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_RESET_TOKENS_BY_USER, )) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_SESSIONS_BY_USER, )) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; tx.commit().await.map_err(AuthError::database)?; state.clear_rate_limit(&limit_key).await; info!( user_id, "password reset completed and existing sessions revoked" ); Ok(Json(serde_json::json!({"ok": true}))) } pub async fn user_from_token(state: &SharedState, token: &str) -> Result, AuthError> { let now = Utc::now(); let now_rfc3339 = now.to_rfc3339(); let user = sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_SESSION)) .bind(token) .bind(&now_rfc3339) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; if let Some(user) = user { let expires_at = (now + Duration::days(state.user_session_ttl_days)).to_rfc3339(); let refreshed = sqlx::query(queries::get(state.db.kind(), queries::AUTH_REFRESH_SESSION)) .bind(&expires_at) .bind(token) .bind(&now_rfc3339) .execute(state.db.pool()) .await .map_err(AuthError::database)?; if refreshed.rows_affected() == 1 { debug!(user_id = user.id, expires_at = %expires_at, "authentication session extended"); Ok(Some(user)) } else { Ok(None) } } else { sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_SESSION_BY_TOKEN, )) .bind(token) .execute(state.db.pool()) .await .map_err(AuthError::database)?; Ok(None) } } pub async fn authorize_nickname( state: &SharedState, nickname: Option, token: Option, ) -> Result, String> { let Some(nickname) = nickname else { return Ok(None); }; let nickname = validate_nickname(&nickname).map_err(|e| e.message)?; let registered = find_user_by_nickname(state, &nickname) .await .map_err(|_| "Database error".to_string())?; match registered { None => Ok(Some(nickname)), Some(owner) => { let Some(token) = token else { return Err("This nickname is registered. Log in to use it.".into()); }; let current = user_from_token(state, &token) .await .map_err(|_| "Database error".to_string())?; match current { Some(user) if user.id == owner.id => Ok(Some(owner.nickname)), _ => Err("This nickname belongs to another account or the session expired.".into()), } } } } async fn create_session(state: &SharedState, user: &User) -> Result { let token = random_token(); let expires_at = (Utc::now() + Duration::days(state.user_session_ttl_days)).to_rfc3339(); sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_SESSION)) .bind(&token) .bind(user.id) .bind(&expires_at) .execute(state.db.pool()) .await .map_err(AuthError::database)?; debug!(user_id = user.id, expires_at = %expires_at, "authentication session created"); let (directory_managed, directory_display_name, directory_organization, suggested_nickname) = directory_profile_metadata(state, user).await?; let editor_color: Option = sqlx::query_scalar(queries::get( state.db.kind(), queries::AUTH_EDITOR_COLOR_BY_USER, )) .bind(user.id) .fetch_one(state.db.pool()) .await .map_err(AuthError::database)?; Ok(SessionResponse { token, nickname: user.nickname.clone(), email: user.email.clone(), expires_at, directory_managed, directory_display_name, directory_organization, suggested_nickname, editor_color, theme: user.theme.clone(), }) } async fn find_user_by_nickname( state: &SharedState, nickname: &str, ) -> Result, AuthError> { sqlx::query_as::<_, User>(queries::get( state.db.kind(), queries::AUTH_USER_BY_NICKNAME, )) .bind(normalize(nickname)) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database) } async fn find_user_by_external_id( state: &SharedState, provider: &str, external_id: &str, ) -> Result, AuthError> { sqlx::query_as::<_, User>(queries::get( state.db.kind(), queries::AUTH_USER_BY_EXTERNAL_ID, )) .bind(provider) .bind(external_id) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database) } async fn find_user_by_share_identifier( state: &SharedState, identifier: &str, ) -> Result, AuthError> { sqlx::query_as::<_, User>(queries::get( state.db.kind(), queries::AUTH_USER_BY_SHARE_IDENTIFIER, )) .bind(normalize(identifier)) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database) } async fn find_user_by_email(state: &SharedState, email: &str) -> Result, AuthError> { sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_EMAIL)) .bind(normalize(email)) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database) } fn validate_theme(value: &str) -> Result<&str, AuthError> { match value.trim() { "dark" => Ok("dark"), "light" => Ok("light"), _ => Err(AuthError::bad_request("Unknown interface theme.")), } } fn validate_editor_color(value: &str) -> Result { let value = value.trim(); if value.len() == 7 && value.starts_with('#') && value[1..].chars().all(|c| c.is_ascii_hexdigit()) { Ok(value.to_ascii_lowercase()) } else { Err(AuthError::bad_request("Invalid editor color.")) } } fn validate_nickname(v: &str) -> Result { let v = v.trim(); if v.is_empty() || v.chars().count() > MAX_NICKNAME { return Err(AuthError::bad_request( "Nickname must contain 1 to 40 characters.", )); } if v.chars().any(|c| c.is_control()) { return Err(AuthError::bad_request( "Nickname contains invalid characters.", )); } Ok(v.into()) } fn validate_email(value: &str) -> Result { let value = value.trim(); if value.len() > 320 || !value.contains('@') || value.starts_with('@') || value.ends_with('@') { return Err(AuthError::bad_request("Enter a valid e-mail address.")); } Ok(value.into()) } fn validate_password(value: &str) -> Result<(), AuthError> { if value.len() < MIN_PASSWORD || value.len() > MAX_PASSWORD { return Err(AuthError::bad_request( "Password must contain 8 to 128 characters.", )); } Ok(()) } fn normalize(value: &str) -> String { value.trim().to_lowercase() } fn hash_password(value: &str) -> Result { let salt = SaltString::generate(&mut OsRng); Argon2::default() .hash_password(value.as_bytes(), &salt) .map(|hash| hash.to_string()) .map_err(|_| AuthError::internal("Failed to secure the password.")) } fn verify_password(hash: &str, value: &str) -> bool { PasswordHash::new(hash) .ok() .and_then(|hash| { Argon2::default() .verify_password(value.as_bytes(), &hash) .ok() }) .is_some() } fn random_token() -> String { random_hex_token::<32>() } fn valid_share_token(value: &str) -> bool { value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) } fn random_confirmation_token() -> String { random_hex_token::<32>() } fn random_hex_token() -> String { let mut bytes = [0_u8; N]; let mut rng = OsRng; rng.fill_bytes(&mut bytes); bytes.iter().map(|byte| format!("{byte:02x}")).collect() } fn hash_token(value: &str) -> String { format!("{:x}", Sha256::digest(value.as_bytes())) } async fn send_registration_email( smtp: &SmtpConfig, user: &User, token: Option<&str>, ) -> Result<(), AuthError> { let site = smtp.public_url.trim_end_matches('/'); let sender = smtp .from .parse::() .map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?; let recipient = user .email .parse::() .map_err(|_| AuthError::internal("Invalid recipient address."))?; let builder = Message::builder().from(sender).to(recipient); let message = match token { Some(token) => { let confirmation_url = format!("{site}/auth/confirm/{token}"); let subject = "Confirm your RustPad account"; let text_body = format!( "Hello {},\n\nYour RustPad account has been created.\nNickname: {}\nSite: {}\n\nConfirm the account within 24 hours by opening this link:\n{}\n", user.nickname, user.nickname, site, confirmation_url ); let html_body = format!( r#"

Confirm your RustPad account

Hello {},

Your RustPad account has been created.

Nickname: {}
Site: {}

Confirm the account within 24 hours:

Confirm account

If the button does not work, open this address:

{}

"#, user.nickname, user.nickname, site, confirmation_url, confirmation_url, confirmation_url ); builder .subject(subject) .multipart( MultiPart::alternative() .singlepart( SinglePart::builder() .header(ContentType::TEXT_PLAIN) .body(text_body), ) .singlepart( SinglePart::builder() .header(ContentType::TEXT_HTML) .body(html_body), ), ) .map_err(|_| AuthError::internal("Failed to build registration e-mail."))? } None => { let subject = "Your RustPad account has been created"; let text_body = format!( "Hello {},\n\nYour RustPad account has been created.\nNickname: {}\nSite: {}\n", user.nickname, user.nickname, site ); let html_body = format!( r#"

Your RustPad account is ready

Hello {},

Your RustPad account has been created.

Nickname: {}

Open RustPad

"#, user.nickname, user.nickname, site ); builder .subject(subject) .multipart( MultiPart::alternative() .singlepart( SinglePart::builder() .header(ContentType::TEXT_PLAIN) .body(text_body), ) .singlepart( SinglePart::builder() .header(ContentType::TEXT_HTML) .body(html_body), ), ) .map_err(|_| AuthError::internal("Failed to build registration e-mail."))? } }; send_message(smtp, message, "registration e-mail").await } async fn send_share_invitation( smtp: &SmtpConfig, owner: &User, recipient_user: &User, kind: &str, slug: &str, permission: &str, token: &str, ) -> Result<(), AuthError> { let site = smtp.public_url.trim_end_matches('/'); let accept_url = format!("{site}/share-invitations/{token}/accept"); let resource_label = if kind == "workspace" { "workspace" } else { "note" }; let access_label = if permission == "rw" { "view and edit" } else { "view" }; let sender = smtp .from .parse::() .map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?; let recipient = recipient_user .email .parse::() .map_err(|_| AuthError::internal("Invalid recipient address."))?; let subject = format!( "{} shared a RustPad {} with you", owner.nickname, resource_label ); let text_body = format!( "Hello {},\n\n{} shared the {} '{}' with you ({access_label}).\nAccept the invitation within 7 days:\n{}\n\nIf you were not expecting this invitation, ignore this message.", recipient_user.nickname, owner.nickname, resource_label, slug, accept_url ); let html_body = format!( r#"

A RustPad {resource_label} was shared with you

Hello {},

{} shared {} with you. Permission: {access_label}.

Accept invitation

This link expires in 7 days.

If the button does not work, copy and paste this link into your browser:
{}

"#, recipient_user.nickname, owner.nickname, slug, accept_url, accept_url, accept_url ); let message = Message::builder() .from(sender) .to(recipient) .subject(subject) .multipart( MultiPart::alternative() .singlepart( SinglePart::builder() .header(ContentType::TEXT_PLAIN) .body(text_body), ) .singlepart( SinglePart::builder() .header(ContentType::TEXT_HTML) .body(html_body), ), ) .map_err(|_| AuthError::internal("Failed to build sharing invitation e-mail."))?; send_message(smtp, message, "sharing invitation e-mail").await } async fn send_message(smtp: &SmtpConfig, message: Message, label: &str) -> Result<(), AuthError> { let mut builder = match smtp.security { SmtpSecurity::None => AsyncSmtpTransport::::builder_dangerous(&smtp.host), SmtpSecurity::StartTls => AsyncSmtpTransport::::starttls_relay(&smtp.host) .map_err(|error| { tracing::error!(error = %error, host = %smtp.host, port = smtp.port, security = ?smtp.security, "invalid SMTP configuration"); AuthError::internal("Invalid SMTP configuration.") })?, SmtpSecurity::Tls => AsyncSmtpTransport::::relay(&smtp.host) .map_err(|error| { tracing::error!(error = %error, host = %smtp.host, port = smtp.port, security = ?smtp.security, "invalid SMTP configuration"); AuthError::internal("Invalid SMTP configuration.") })?, } .port(smtp.port); if !smtp.username.trim().is_empty() && !smtp.password.is_empty() { builder = builder.credentials(Credentials::new( smtp.username.clone(), smtp.password.clone(), )); } builder.build().send(message).await.map_err(|error| { tracing::error!(error = %error, host = %smtp.host, port = smtp.port, message_type = label, "SMTP delivery failed"); AuthError::service_unavailable( "The e-mail could not be sent. Check the SMTP configuration.", ) })?; Ok(()) } async fn send_reset(smtp: &SmtpConfig, user: &User, token: &str) -> Result<(), AuthError> { let site = smtp.public_url.trim_end_matches('/'); let reset_url = format!("{site}/auth/reset-password/{token}"); let sender = smtp .from .parse::() .map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?; let recipient = user .email .parse::() .map_err(|_| AuthError::internal("Invalid recipient address."))?; let text_body = format!( "Hello {},\n\nUse this link within 30 minutes to set a new password:\n{}\n\nIf you did not request this, ignore this message.", user.nickname, reset_url ); let html_body = format!( r#"

Reset your RustPad password

Hello {},

Use the button below within 30 minutes to set a new password.

Reset password

If the button does not work, open this address:

{}

If you did not request a password reset, ignore this message.

"#, user.nickname, reset_url, reset_url, reset_url ); let message = Message::builder() .from(sender) .to(recipient) .subject("RustPad password reset") .multipart( MultiPart::alternative() .singlepart( SinglePart::builder() .header(ContentType::TEXT_PLAIN) .body(text_body), ) .singlepart( SinglePart::builder() .header(ContentType::TEXT_HTML) .body(html_body), ), ) .map_err(|_| AuthError::internal("Failed to build reset e-mail."))?; send_message(smtp, message, "password reset e-mail").await } pub struct AuthError { status: StatusCode, pub message: String, } impl AuthError { fn bad_request(m: &str) -> Self { Self { status: StatusCode::BAD_REQUEST, message: m.into(), } } fn unauthorized(m: &str) -> Self { Self { status: StatusCode::UNAUTHORIZED, message: m.into(), } } fn forbidden(m: &str) -> Self { Self { status: StatusCode::FORBIDDEN, message: m.into(), } } fn conflict(m: &str) -> Self { Self { status: StatusCode::CONFLICT, message: m.into(), } } fn internal(m: &str) -> Self { Self { status: StatusCode::INTERNAL_SERVER_ERROR, message: m.into(), } } fn rate_limited(m: &str) -> Self { Self { status: StatusCode::TOO_MANY_REQUESTS, message: m.into(), } } fn service_unavailable(m: &str) -> Self { Self { status: StatusCode::SERVICE_UNAVAILABLE, message: m.into(), } } fn database(e: sqlx::Error) -> Self { tracing::error!(error=%e,"authentication database error"); Self::internal("Database error.") } } fn session_json_response(status: StatusCode, session: SessionResponse, ttl_days: i64) -> Response { let cookie = crate::security::session_cookie(&session.token, ttl_days); let mut response = (status, Json(session)).into_response(); response.headers_mut().insert(header::SET_COOKIE, cookie); response } fn email_domain(email: &str) -> &str { email .rsplit_once('@') .map(|(_, domain)| domain) .unwrap_or("invalid") } impl axum::response::IntoResponse for AuthError { fn into_response(self) -> axum::response::Response { ( self.status, Json(serde_json::json!( { "error":self.message } )), ) .into_response() } } #[cfg(test)] #[path = "../tests/auth.rs"] mod logout_tests;