2991 lines
99 KiB
Rust
2991 lines
99 KiB
Rust
/*
|
|
* 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<String>,
|
|
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<String>,
|
|
#[serde(default)]
|
|
new_email: Option<String>,
|
|
#[serde(default)]
|
|
new_password: Option<String>,
|
|
#[serde(default)]
|
|
password: String,
|
|
#[serde(default)]
|
|
editor_color: Option<String>,
|
|
#[serde(default)]
|
|
theme: Option<String>,
|
|
}
|
|
#[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<String>,
|
|
}
|
|
#[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<Self, sqlx::Error> {
|
|
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<String>,
|
|
permission: String,
|
|
expires_at: Option<String>,
|
|
created_at: String,
|
|
}
|
|
|
|
impl<'r> sqlx::FromRow<'r, AnyRow> for SharingLinkRow {
|
|
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
|
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<String>,
|
|
}
|
|
|
|
impl<'r> sqlx::FromRow<'r, AnyRow> for ShareLinkSessionSource {
|
|
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
|
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<String>,
|
|
}
|
|
|
|
impl<'r> sqlx::FromRow<'r, AnyRow> for ShareLinkPermissionRow {
|
|
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
|
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<String>,
|
|
}
|
|
|
|
impl<'r> sqlx::FromRow<'r, AnyRow> for ShareSessionPermissionRow {
|
|
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
|
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<String>,
|
|
}
|
|
|
|
impl<'r> sqlx::FromRow<'r, AnyRow> for PendingShareRow {
|
|
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
|
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<Self, sqlx::Error> {
|
|
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<Self, sqlx::Error> {
|
|
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<String>,
|
|
permission: String,
|
|
expires_at: Option<String>,
|
|
}
|
|
#[derive(Deserialize)]
|
|
pub struct UpdateShareLinkRequest {
|
|
kind: String,
|
|
slug: String,
|
|
token_hash: String,
|
|
#[serde(default)]
|
|
label: Option<String>,
|
|
permission: String,
|
|
expires_at: Option<String>,
|
|
}
|
|
#[derive(Deserialize)]
|
|
pub struct RevokeShareLinkRequest {
|
|
kind: String,
|
|
slug: String,
|
|
token_hash: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ResourceList {
|
|
items: Vec<ResourceItem>,
|
|
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<String>,
|
|
directory_organization: Option<String>,
|
|
suggested_nickname: Option<String>,
|
|
editor_color: Option<String>,
|
|
theme: String,
|
|
}
|
|
#[derive(Serialize)]
|
|
pub struct IdentityResponse {
|
|
nickname: String,
|
|
registered: bool,
|
|
}
|
|
#[derive(Serialize)]
|
|
pub struct RegisterResponse {
|
|
nickname: String,
|
|
email: String,
|
|
expires_at: Option<String>,
|
|
confirmation_required: bool,
|
|
theme: String,
|
|
message: String,
|
|
}
|
|
|
|
pub async fn identity(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<IdentityRequest>,
|
|
) -> Result<Json<IdentityResponse>, 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<SharedState>,
|
|
Json(req): Json<RegisterRequest>,
|
|
) -> Result<Response, AuthError> {
|
|
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<SharedState>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<LoginRequest>,
|
|
) -> Result<Response, AuthError> {
|
|
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<SharedState>,
|
|
Json(req): Json<ResendConfirmationRequest>,
|
|
) -> Result<Json<serde_json::Value>, 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<String> = 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<SharedState>,
|
|
Json(req): Json<ConfirmAccountRequest>,
|
|
) -> Result<Json<serde_json::Value>, 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<String>, Option<String>, Option<String>), 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<String> {
|
|
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::<String>()
|
|
})
|
|
.and_then(|value| validate_nickname(&value).ok())
|
|
}
|
|
|
|
pub async fn me(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
) -> Result<Response, AuthError> {
|
|
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<String> = 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<SharedState>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<ProfileUpdateRequest>,
|
|
) -> Result<Json<serde_json::Value>, 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<SharedState>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<DeleteAccountRequest>,
|
|
) -> Result<Json<serde_json::Value>, 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<SharedState>,
|
|
Json(req): Json<AccountActionConfirmRequest>,
|
|
) -> Result<Json<serde_json::Value>, 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<String> =
|
|
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::<Mailbox>()
|
|
.map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?;
|
|
let target = if action == "email" {
|
|
payload.unwrap_or(&user.email)
|
|
} else {
|
|
&user.email
|
|
};
|
|
let recipient = target
|
|
.parse::<Mailbox>()
|
|
.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#"<!doctype html><html lang="en"><body style="margin:0;padding:24px;background:#f4f4f5;font-family:Arial,sans-serif;color:#18181b"><div style="max-width:560px;margin:0 auto;padding:24px;background:#fff;border-radius:10px"><h1 style="margin-top:0;font-size:22px">{}</h1><p>Hello {},</p><p>{}</p><p><a href="{}" style="display:inline-block;padding:11px 18px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px">Confirm action</a></p><p style="font-size:13px;overflow-wrap:anywhere"><a href="{}">{}</a></p><p>If you did not request this, ignore this message.</p></div></body></html>"#,
|
|
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<SharedState>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<ResourceListQuery>,
|
|
) -> Result<Json<ResourceList>, 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::<Vec<_>>();
|
|
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<SharedState>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<ResourceActionRequest>,
|
|
) -> Result<Json<serde_json::Value>, AuthError> {
|
|
let user = require_user(&state, &headers).await?;
|
|
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,
|
|
};
|
|
ensure_owner(&state, user.id, &req.kind, &req.slug).await?;
|
|
let query = match req.kind.as_str() {
|
|
"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(req.slug.trim())
|
|
.execute(state.db.pool())
|
|
.await
|
|
.map_err(AuthError::database)?;
|
|
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)?;
|
|
Ok(Json(serde_json::json!({"ok":true})))
|
|
}
|
|
|
|
pub async fn delete_resource(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<ResourceActionRequest>,
|
|
) -> Result<Json<serde_json::Value>, 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<Option<User>, 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<User, AuthError> {
|
|
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<bool, AuthError> {
|
|
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<SharedState>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<PrivacyRequest>,
|
|
) -> Result<Json<serde_json::Value>, 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<SharedState>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<ShareUsersRequest>,
|
|
) -> Result<Json<serde_json::Value>, 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<String> = req
|
|
.recipients
|
|
.split([',', ';', '\n'])
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
.collect::<std::collections::HashSet<_>>()
|
|
.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<SharedState>,
|
|
AxumPath(token): AxumPath<String>,
|
|
) -> Result<Json<serde_json::Value>, 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<SharedState>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<RemoveShareRequest>,
|
|
) -> Result<Json<serde_json::Value>, 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<SharedState>,
|
|
headers: HeaderMap,
|
|
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
|
) -> Result<Json<serde_json::Value>, 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<SharingUserRow> = 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<SharingLinkRow> = 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<PendingShareRow> = 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::<Vec<_>>(), "pending":pending.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission,"expires_at":row.expires_at})).collect::<Vec<_>>(), "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::<Vec<_>>(), "share_links_enabled":share_links_enabled }),
|
|
))
|
|
}
|
|
|
|
pub async fn create_share_link(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<CreateShareLinkRequest>,
|
|
) -> Result<Response, 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 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-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<SharedState>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<UpdateShareLinkRequest>,
|
|
) -> Result<Json<serde_json::Value>, 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<SharedState>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<RevokeShareLinkRequest>,
|
|
) -> Result<Json<serde_json::Value>, 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<Option<String>, 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<Option<String>, 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<bool, AuthError> {
|
|
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<Option<String>, 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<String> = 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<Option<ShareSession>, 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<Option<String>, 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<Option<String>, 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<Option<String>, 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<SharedState>,
|
|
headers: HeaderMap,
|
|
) -> Result<Response, AuthError> {
|
|
if let Some(token) = crate::security::session_token(&headers) {
|
|
let result = sqlx::query(queries::get(
|
|
state.db.kind(),
|
|
queries::AUTH_DELETE_SESSION_BY_TOKEN,
|
|
))
|
|
.bind(token)
|
|
.execute(state.db.pool())
|
|
.await
|
|
.map_err(AuthError::database)?;
|
|
info!(rows_affected = result.rows_affected(), "logout processed");
|
|
} else {
|
|
debug!("logout requested without an active session");
|
|
}
|
|
let mut response = Json(serde_json::json!({"ok": true})).into_response();
|
|
response
|
|
.headers_mut()
|
|
.insert(header::SET_COOKIE, crate::security::clear_session_cookie());
|
|
Ok(response)
|
|
}
|
|
|
|
pub async fn request_reset(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<ResetRequest>,
|
|
) -> Result<Json<serde_json::Value>, 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<SharedState>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<ResetConfirmRequest>,
|
|
) -> Result<Json<serde_json::Value>, 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<Option<User>, 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<String>,
|
|
token: Option<String>,
|
|
) -> Result<Option<String>, 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<SessionResponse, AuthError> {
|
|
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<String> = 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<Option<User>, 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<Option<User>, 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<Option<User>, 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<Option<User>, 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<String, AuthError> {
|
|
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<String, AuthError> {
|
|
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<String, AuthError> {
|
|
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<String, AuthError> {
|
|
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<const N: usize>() -> 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::<Mailbox>()
|
|
.map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?;
|
|
let recipient = user
|
|
.email
|
|
.parse::<Mailbox>()
|
|
.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#"<!doctype html>
|
|
<html lang="en">
|
|
<body style="margin: 0; padding: 24px; background: #f4f4f5; font-family: Arial, sans-serif; color: #18181b;">
|
|
<div style="max-width: 560px; margin: 0 auto; padding: 24px; background: #ffffff; border-radius: 10px;">
|
|
<h1 style="margin-top: 0; font-size: 22px;">Confirm your RustPad account</h1>
|
|
<p>Hello {},</p>
|
|
<p>Your RustPad account has been created.</p>
|
|
<p><strong>Nickname:</strong> {}<br><strong>Site:</strong> {}</p>
|
|
<p>Confirm the account within 24 hours:</p>
|
|
<p><a href="{}" style="display: inline-block; padding: 11px 18px; background: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px;">Confirm account</a></p>
|
|
<p style="font-size: 13px; color: #52525b;">If the button does not work, open this address:</p>
|
|
<p style="font-size: 13px; overflow-wrap: anywhere;"><a href="{}">{}</a></p>
|
|
</div>
|
|
</body>
|
|
</html>"#,
|
|
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#"<!doctype html>
|
|
<html lang="en">
|
|
<body style="margin: 0; padding: 24px; background: #f4f4f5; font-family: Arial, sans-serif; color: #18181b;">
|
|
<div style="max-width: 560px; margin: 0 auto; padding: 24px; background: #ffffff; border-radius: 10px;">
|
|
<h1 style="margin-top: 0; font-size: 22px;">Your RustPad account is ready</h1>
|
|
<p>Hello {},</p>
|
|
<p>Your RustPad account has been created.</p>
|
|
<p><strong>Nickname:</strong> {}</p>
|
|
<p><a href="{}" style="display: inline-block; padding: 11px 18px; background: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px;">Open RustPad</a></p>
|
|
</div>
|
|
</body>
|
|
</html>"#,
|
|
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::<Mailbox>()
|
|
.map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?;
|
|
let recipient = recipient_user
|
|
.email
|
|
.parse::<Mailbox>()
|
|
.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#"<!doctype html><html lang="en"><body style="margin:0;padding:24px;background:#f4f4f5;font-family:Arial,sans-serif;color:#18181b"><div style="max-width:560px;margin:0 auto;padding:24px;background:#fff;border-radius:10px"><h1 style="margin-top:0;font-size:22px">A RustPad {resource_label} was shared with you</h1><p>Hello {},</p><p><strong>{}</strong> shared <strong>{}</strong> with you. Permission: <strong>{access_label}</strong>.</p><p><a href="{}" style="display:inline-block;padding:11px 18px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px">Accept invitation</a></p><p style="font-size:13px;color:#52525b">This link expires in 7 days.</p><p style="font-size:13px;color:#52525b;word-break:break-all">If the button does not work, copy and paste this link into your browser:<br><a href="{}" style="color:#2563eb">{}</a></p></div></body></html>"#,
|
|
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::<Tokio1Executor>::builder_dangerous(&smtp.host),
|
|
SmtpSecurity::StartTls => AsyncSmtpTransport::<Tokio1Executor>::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::<Tokio1Executor>::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::<Mailbox>()
|
|
.map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?;
|
|
let recipient = user
|
|
.email
|
|
.parse::<Mailbox>()
|
|
.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#"<!doctype html>
|
|
<html lang="en">
|
|
<body style="margin: 0; padding: 24px; background: #f4f4f5; font-family: Arial, sans-serif; color: #18181b;">
|
|
<div style="max-width: 560px; margin: 0 auto; padding: 24px; background: #ffffff; border-radius: 10px;">
|
|
<h1 style="margin-top: 0; font-size: 22px;">Reset your RustPad password</h1>
|
|
<p>Hello {},</p>
|
|
<p>Use the button below within 30 minutes to set a new password.</p>
|
|
<p><a href="{}" style="display: inline-block; padding: 11px 18px; background: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px;">Reset password</a></p>
|
|
<p style="font-size: 13px; color: #52525b;">If the button does not work, open this address:</p>
|
|
<p style="font-size: 13px; overflow-wrap: anywhere;"><a href="{}">{}</a></p>
|
|
<p>If you did not request a password reset, ignore this message.</p>
|
|
</div>
|
|
</body>
|
|
</html>"#,
|
|
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()
|
|
}
|
|
}
|