mails
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.0.6"
|
version = "0.0.7"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.94"
|
rust-version = "1.94"
|
||||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||||
|
|||||||
+1
-1
@@ -94,7 +94,7 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize
|
|||||||
ServiceBuilder::new()
|
ServiceBuilder::new()
|
||||||
.layer(SetResponseHeaderLayer::overriding(
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
header::CACHE_CONTROL,
|
header::CACHE_CONTROL,
|
||||||
HeaderValue::from_static("private, must-revalidate"),
|
HeaderValue::from_static("public, max-age=600"),
|
||||||
))
|
))
|
||||||
.service(ServeDir::new(static_dir).not_found_service(asset_not_found)),
|
.service(ServeDir::new(static_dir).not_found_service(asset_not_found)),
|
||||||
)
|
)
|
||||||
|
|||||||
+349
-47
@@ -1,7 +1,11 @@
|
|||||||
use argon2::{password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2};
|
use argon2::{password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2};
|
||||||
use axum::{extract::State, http::{HeaderMap, StatusCode}, Json};
|
use axum::{extract::State, http::{HeaderMap, StatusCode}, Json};
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use lettre::{message::Mailbox, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, transport::smtp::authentication::Credentials};
|
use lettre::{
|
||||||
|
message::{header::ContentType, Mailbox, MultiPart, SinglePart},
|
||||||
|
transport::smtp::authentication::Credentials,
|
||||||
|
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
|
||||||
|
};
|
||||||
use rand_core::{OsRng, RngCore};
|
use rand_core::{OsRng, RngCore};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@@ -15,20 +19,36 @@ const MAX_PASSWORD: usize = 128;
|
|||||||
const MAX_NICKNAME: usize = 40;
|
const MAX_NICKNAME: usize = 40;
|
||||||
|
|
||||||
#[derive(Debug, Clone, FromRow)]
|
#[derive(Debug, Clone, FromRow)]
|
||||||
pub struct User { pub id: i64, pub nickname: String, pub email: String, pub password_hash: String, pub confirmed_at: Option<String> }
|
pub struct User {
|
||||||
|
pub id: i64, pub nickname: String, pub email: String, pub password_hash: String, pub confirmed_at: Option<String>
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)] pub struct IdentityRequest { nickname: String, #[serde(default)] session_token: Option<String> }
|
#[derive(Deserialize)] pub struct IdentityRequest {
|
||||||
#[derive(Deserialize)] pub struct RegisterRequest { nickname: String, email: String, password: String }
|
nickname: String, #[serde(default)] session_token: Option<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 LoginRequest { email: String, password: String }
|
||||||
#[derive(Deserialize)] pub struct ConfirmAccountRequest { token: String }
|
#[derive(Deserialize)] pub struct ConfirmAccountRequest { token: String }
|
||||||
#[derive(Deserialize)] pub struct ResetRequest { email: String }
|
#[derive(Deserialize)] pub struct ResetRequest { email: String }
|
||||||
#[derive(Deserialize)] pub struct ResetConfirmRequest { token: String, password: String }
|
#[derive(Deserialize)] pub struct ResetConfirmRequest { token: String, password: String }
|
||||||
#[derive(Deserialize)] pub struct ResourceActionRequest { kind: String, slug: String, #[serde(default)] password: Option<String> }
|
#[derive(Deserialize)] pub struct ResourceActionRequest {
|
||||||
#[derive(Serialize, FromRow)] pub struct ResourceItem { slug: String, title: String, protected: i64, updated_at: String }
|
kind: String, slug: String, #[serde(default)] password: Option<String>
|
||||||
#[derive(Serialize)] pub struct ResourceList { workspaces: Vec<ResourceItem>, pads: Vec<ResourceItem> }
|
}
|
||||||
#[derive(Serialize)] pub struct SessionResponse { token: String, nickname: String, email: String, expires_at: String }
|
#[derive(Serialize, FromRow)] pub struct ResourceItem {
|
||||||
|
slug: String, title: String, protected: i64, updated_at: String
|
||||||
|
}
|
||||||
|
#[derive(Serialize)] pub struct ResourceList {
|
||||||
|
workspaces: Vec<ResourceItem>, pads: Vec<ResourceItem>
|
||||||
|
}
|
||||||
|
#[derive(Serialize)] pub struct SessionResponse {
|
||||||
|
token: String, nickname: String, email: String, expires_at: String
|
||||||
|
}
|
||||||
#[derive(Serialize)] pub struct IdentityResponse { nickname: String, registered: bool }
|
#[derive(Serialize)] pub struct IdentityResponse { nickname: String, registered: bool }
|
||||||
#[derive(Serialize)] pub struct RegisterResponse { token: Option<String>, nickname: String, email: String, expires_at: Option<String>, confirmation_required: bool, message: String }
|
#[derive(Serialize)] pub struct RegisterResponse {
|
||||||
|
token: Option<String>, nickname: String, email: String, expires_at: Option<String>, confirmation_required: bool, message: String
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn identity(State(state): State<SharedState>, Json(req): Json<IdentityRequest>) -> Result<Json<IdentityResponse>, AuthError> {
|
pub async fn identity(State(state): State<SharedState>, Json(req): Json<IdentityRequest>) -> Result<Json<IdentityResponse>, AuthError> {
|
||||||
let nickname = validate_nickname(&req.nickname)?;
|
let nickname = validate_nickname(&req.nickname)?;
|
||||||
@@ -65,7 +85,7 @@ pub async fn register(State(state): State<SharedState>, Json(req): Json<Register
|
|||||||
|
|
||||||
let mut confirmation_token = None;
|
let mut confirmation_token = None;
|
||||||
if state.smtp.is_some() {
|
if state.smtp.is_some() {
|
||||||
let token = random_token();
|
let token = random_confirmation_token();
|
||||||
if state.account_confirmation_required {
|
if state.account_confirmation_required {
|
||||||
let expires = (Utc::now() + Duration::hours(24)).to_rfc3339();
|
let expires = (Utc::now() + Duration::hours(24)).to_rfc3339();
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_CONFIRMATION_TOKEN))
|
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_CONFIRMATION_TOKEN))
|
||||||
@@ -270,59 +290,341 @@ async fn find_user_by_email(state: &SharedState, email: &str) -> Result<Option<U
|
|||||||
sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_EMAIL))
|
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)
|
.bind(normalize(email)).fetch_optional(state.db.pool()).await.map_err(AuthError::database)
|
||||||
}
|
}
|
||||||
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_nickname(v: &str) -> Result<String, AuthError> {
|
||||||
fn validate_email(v: &str) -> Result<String, AuthError> { let v=v.trim(); if v.len()>320 || !v.contains('@') || v.starts_with('@') || v.ends_with('@') { return Err(AuthError::bad_request("Enter a valid e-mail address.")); } Ok(v.into()) }
|
let v=v.trim();
|
||||||
fn validate_password(v: &str) -> Result<(), AuthError> { if v.len()<MIN_PASSWORD || v.len()>MAX_PASSWORD { Err(AuthError::bad_request("Password must contain 8 to 128 characters.")) } else { Ok(()) } }
|
if v.is_empty() || v.chars().count()>MAX_NICKNAME {
|
||||||
fn normalize(v: &str)->String { v.trim().to_lowercase() }
|
return Err(AuthError::bad_request("Nickname must contain 1 to 40 characters."));
|
||||||
fn hash_password(v:&str)->Result<String,AuthError>{let salt=SaltString::generate(&mut OsRng);Argon2::default().hash_password(v.as_bytes(),&salt).map(|h|h.to_string()).map_err(|_|AuthError::internal("Failed to secure the password."))}
|
}
|
||||||
fn verify_password(hash:&str,v:&str)->bool{PasswordHash::new(hash).ok().and_then(|h|Argon2::default().verify_password(v.as_bytes(),&h).ok()).is_some()}
|
if v.chars().any(|c| c.is_control()) {
|
||||||
fn random_token()->String{let mut bytes=[0u8;32];let mut rng=OsRng;rng.fill_bytes(&mut bytes);bytes.iter().map(|b|format!("{b:02x}")).collect()}
|
return Err(AuthError::bad_request("Nickname contains invalid characters."));
|
||||||
fn hash_token(v:&str)->String{format!("{:x}",Sha256::digest(v.as_bytes()))}
|
}
|
||||||
fn bearer(headers:&HeaderMap)->Option<&str>{headers.get("authorization")?.to_str().ok()?.strip_prefix("Bearer ")}
|
Ok(v.into())
|
||||||
async fn send_registration_email(smtp: &SmtpConfig, user: &User, token: Option<&str>) -> Result<(), AuthError> {
|
}
|
||||||
let site = smtp.public_url.trim_end_matches('/');
|
fn validate_email(value: &str) -> Result<String, AuthError> {
|
||||||
let (subject, body) = match token {
|
let value = value.trim();
|
||||||
Some(token) => ("Confirm your RustPad account", format!("Hello {},\n\nYour RustPad account has been created.\nNickname: {}\nSite: {}\n\nConfirm the account within 24 hours:\n{}/?confirm_token={}\n", user.nickname, user.nickname, site, site, token)),
|
|
||||||
None => ("Your RustPad account has been created", format!("Hello {},\n\nYour RustPad account has been created.\nNickname: {}\nSite: {}\n", user.nickname, user.nickname, site)),
|
if value.len() > 320
|
||||||
};
|
|| !value.contains('@')
|
||||||
let message = Message::builder().from(smtp.from.parse::<Mailbox>().map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?).to(user.email.parse::<Mailbox>().map_err(|_| AuthError::internal("Invalid recipient address."))?).subject(subject).body(body).map_err(|_| AuthError::internal("Failed to build registration e-mail."))?;
|
|| value.starts_with('@')
|
||||||
send_message(smtp, message, "registration e-mail").await
|
|| value.ends_with('@')
|
||||||
|
{
|
||||||
|
return Err(AuthError::bad_request("Enter a valid e-mail address."));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(value.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_message(smtp: &SmtpConfig, message: Message, label: &str) -> Result<(), AuthError> {
|
fn validate_password(value: &str) -> Result<(), AuthError> {
|
||||||
let mut builder = if smtp.port == 465 { AsyncSmtpTransport::<Tokio1Executor>::relay(&smtp.host) } else { AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&smtp.host) }
|
if value.len() < MIN_PASSWORD || value.len() > MAX_PASSWORD {
|
||||||
.map_err(|error| { tracing::error!(error=%error, host=%smtp.host, port=smtp.port, "invalid SMTP configuration"); AuthError::internal("Invalid SMTP configuration.") })?.port(smtp.port);
|
return Err(AuthError::bad_request(
|
||||||
if !smtp.username.is_empty() { builder = builder.credentials(Credentials::new(smtp.username.clone(), smtp.password.clone())); }
|
"Password must contain 8 to 128 characters.",
|
||||||
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_reset(smtp:&SmtpConfig,user:&User,token:&str)->Result<(),AuthError>{
|
fn normalize(value: &str) -> String {
|
||||||
let url=format!("{}/?reset_token={}",smtp.public_url.trim_end_matches('/'),token);
|
value.trim().to_lowercase()
|
||||||
let message=Message::builder().from(smtp.from.parse::<Mailbox>().map_err(|_|AuthError::internal("Invalid SMTP_FROM."))?).to(user.email.parse::<Mailbox>().map_err(|_|AuthError::internal("Invalid recipient address."))?).subject("RustPad password reset").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,url)).map_err(|_|AuthError::internal("Failed to build reset e-mail."))?;
|
}
|
||||||
// Port 465 uses implicit TLS. Standard submission ports (usually 587)
|
|
||||||
// require STARTTLS; using implicit TLS there causes an immediate SMTP failure.
|
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 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()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bearer(headers: &HeaderMap) -> Option<&str> {
|
||||||
|
headers
|
||||||
|
.get("authorization")?
|
||||||
|
.to_str()
|
||||||
|
.ok()?
|
||||||
|
.strip_prefix("Bearer ")
|
||||||
|
}
|
||||||
|
|
||||||
|
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}/?confirm_token={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_message(
|
||||||
|
smtp: &SmtpConfig,
|
||||||
|
message: Message,
|
||||||
|
label: &str,
|
||||||
|
) -> Result<(), AuthError> {
|
||||||
let mut builder = if smtp.port == 465 {
|
let mut builder = if smtp.port == 465 {
|
||||||
AsyncSmtpTransport::<Tokio1Executor>::relay(&smtp.host)
|
AsyncSmtpTransport::<Tokio1Executor>::relay(&smtp.host)
|
||||||
} else {
|
} else {
|
||||||
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&smtp.host)
|
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&smtp.host)
|
||||||
}
|
}
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
tracing::error!(error=%error, host=%smtp.host, port=smtp.port, "invalid SMTP configuration");
|
tracing::error!(error = %error, host = %smtp.host, port = smtp.port, "invalid SMTP configuration");
|
||||||
AuthError::internal("Invalid SMTP configuration.")
|
AuthError::internal("Invalid SMTP configuration.")
|
||||||
})?
|
})?
|
||||||
.port(smtp.port);
|
.port(smtp.port);
|
||||||
if !smtp.username.is_empty() { builder=builder.credentials(Credentials::new(smtp.username.clone(),smtp.password.clone())); }
|
|
||||||
let mailer=builder.build();
|
if !smtp.username.is_empty() {
|
||||||
mailer.send(message).await.map_err(|error| {
|
builder = builder.credentials(Credentials::new(
|
||||||
tracing::error!(error=%error, host=%smtp.host, port=smtp.port, "password reset e-mail failed");
|
smtp.username.clone(),
|
||||||
AuthError::service_unavailable("The reset e-mail could not be sent. Check the SMTP configuration.")
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AuthError { status: StatusCode, pub message: String }
|
async fn send_reset(
|
||||||
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 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.")} }
|
smtp: &SmtpConfig,
|
||||||
|
user: &User,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<(), AuthError> {
|
||||||
|
let site = smtp.public_url.trim_end_matches('/');
|
||||||
|
let reset_url = format!("{site}/?reset_token={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."))?;
|
||||||
|
|
||||||
fn email_domain(email: &str) -> &str { email.rsplit_once('@').map(|(_, domain)| domain).unwrap_or("invalid") }
|
let text_body = format!(
|
||||||
impl axum::response::IntoResponse for AuthError { fn into_response(self)->axum::response::Response{(self.status,Json(serde_json::json!({"error":self.message}))).into_response()} }
|
"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 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 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+6
-2
@@ -34,8 +34,12 @@ impl Database {
|
|||||||
Ok(Self { pool, kind })
|
Ok(Self { pool, kind })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pool(&self) -> &AnyPool { &self.pool }
|
pub fn pool(&self) -> &AnyPool {
|
||||||
pub fn kind(&self) -> DatabaseKind { self.kind }
|
&self.pool
|
||||||
|
}
|
||||||
|
pub fn kind(&self) -> DatabaseKind {
|
||||||
|
self.kind
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DatabaseKind {
|
impl DatabaseKind {
|
||||||
|
|||||||
+3
-1
@@ -5,7 +5,9 @@ use tokio::sync::{broadcast, RwLock};
|
|||||||
const CHANNEL_CAPACITY: usize = 256;
|
const CHANNEL_CAPACITY: usize = 256;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SmtpConfig { pub host: String, pub port: u16, pub username: String, pub password: String, pub from: String, pub public_url: String }
|
pub struct SmtpConfig {
|
||||||
|
pub host: String, pub port: u16, pub username: String, pub password: String, pub from: String, pub public_url: String
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NoteUpdate {
|
pub struct NoteUpdate {
|
||||||
|
|||||||
+21
-6
@@ -63,10 +63,21 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug
|
|||||||
}}
|
}}
|
||||||
info!(workspace_id = workspace.id, note_id = note.id, "note websocket disconnected");
|
info!(workspace_id = workspace.id, note_id = note.id, "note websocket disconnected");
|
||||||
}
|
}
|
||||||
fn clean_nickname(value: Option<String>)->Option<String>{value.map(|v|v.trim().chars().take(40).collect::<String>()).filter(|v|!v.is_empty())}
|
fn clean_nickname(value: Option<String>)->Option<String> {
|
||||||
async fn send_error(socket:&mut WebSocket,message:&str)->Result<(),axum::Error>{send(socket,&ServerMessage::Error{message:message.into()}).await}
|
value.map(|v|v.trim().chars().take(40).collect::<String>()).filter(|v|!v.is_empty())
|
||||||
async fn send(socket:&mut WebSocket,message:&ServerMessage)->Result<(),axum::Error>{socket.send(Message::Text(serde_json::to_string(message).unwrap().into())).await}
|
}
|
||||||
async fn send_split(sender:&mut futures_util::stream::SplitSink<WebSocket,Message>,message:&ServerMessage)->Result<(),axum::Error>{sender.send(Message::Text(serde_json::to_string(message).unwrap().into())).await}
|
async fn send_error(socket:&mut WebSocket,message:&str)->Result<(),axum::Error> {
|
||||||
|
send(socket,&ServerMessage::Error {
|
||||||
|
message:message.into()
|
||||||
|
}
|
||||||
|
).await
|
||||||
|
}
|
||||||
|
async fn send(socket:&mut WebSocket,message:&ServerMessage)->Result<(),axum::Error> {
|
||||||
|
socket.send(Message::Text(serde_json::to_string(message).unwrap().into())).await
|
||||||
|
}
|
||||||
|
async fn send_split(sender:&mut futures_util::stream::SplitSink<WebSocket,Message>,message:&ServerMessage)->Result<(),axum::Error> {
|
||||||
|
sender.send(Message::Text(serde_json::to_string(message).unwrap().into())).await
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(tag="type",rename_all="snake_case")]
|
#[serde(tag="type",rename_all="snake_case")]
|
||||||
@@ -119,5 +130,9 @@ async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){
|
|||||||
}}
|
}}
|
||||||
info!(pad_id = pad.id, "pad websocket disconnected");
|
info!(pad_id = pad.id, "pad websocket disconnected");
|
||||||
}
|
}
|
||||||
async fn send_pad(socket:&mut WebSocket,message:&PadServerMessage)->Result<(),axum::Error>{socket.send(Message::Text(serde_json::to_string(message).unwrap().into())).await}
|
async fn send_pad(socket:&mut WebSocket,message:&PadServerMessage)->Result<(),axum::Error> {
|
||||||
async fn send_pad_split(sender:&mut futures_util::stream::SplitSink<WebSocket,Message>,message:&PadServerMessage)->Result<(),axum::Error>{sender.send(Message::Text(serde_json::to_string(message).unwrap().into())).await}
|
socket.send(Message::Text(serde_json::to_string(message).unwrap().into())).await
|
||||||
|
}
|
||||||
|
async fn send_pad_split(sender:&mut futures_util::stream::SplitSink<WebSocket,Message>,message:&PadServerMessage)->Result<(),axum::Error> {
|
||||||
|
sender.send(Message::Text(serde_json::to_string(message).unwrap().into())).await
|
||||||
|
}
|
||||||
|
|||||||
+20
-3
@@ -2,14 +2,30 @@ export function passwordKey(workspaceSlug) { return `rustpad:workspace:${workspa
|
|||||||
export function getPassword(workspaceSlug) { return sessionStorage.getItem(passwordKey(workspaceSlug)) || ""; }
|
export function getPassword(workspaceSlug) { return sessionStorage.getItem(passwordKey(workspaceSlug)) || ""; }
|
||||||
export function setPassword(workspaceSlug, password) { if (password) sessionStorage.setItem(passwordKey(workspaceSlug), password); else sessionStorage.removeItem(passwordKey(workspaceSlug)); }
|
export function setPassword(workspaceSlug, password) { if (password) sessionStorage.setItem(passwordKey(workspaceSlug), password); else sessionStorage.removeItem(passwordKey(workspaceSlug)); }
|
||||||
const NICKNAME_KEY = "rustpad:nickname";
|
const NICKNAME_KEY = "rustpad:nickname";
|
||||||
// Nicknames belong to the current browser session. Remove the old persistent
|
const NICKNAME_COOKIE = "rustpad_nickname";
|
||||||
// value so deleting/logging out of a session cannot leave an identity behind.
|
// A session cookie is shared by tabs, but disappears when the browser session
|
||||||
|
// ends. The nickname is not an authentication secret.
|
||||||
|
function getCookie(name) {
|
||||||
|
const prefix = `${name}=`;
|
||||||
|
const item = document.cookie.split("; ").find(value => value.startsWith(prefix));
|
||||||
|
if (!item) return "";
|
||||||
|
try { return decodeURIComponent(item.slice(prefix.length)); } catch { return ""; }
|
||||||
|
}
|
||||||
|
function setNicknameCookie(value) {
|
||||||
|
if (value) document.cookie = `${NICKNAME_COOKIE}=${encodeURIComponent(value)}; Path=/; SameSite=Lax`;
|
||||||
|
else document.cookie = `${NICKNAME_COOKIE}=; Path=/; SameSite=Lax; Max-Age=0`;
|
||||||
|
}
|
||||||
localStorage.removeItem(NICKNAME_KEY);
|
localStorage.removeItem(NICKNAME_KEY);
|
||||||
export function getNickname() { return sessionStorage.getItem(NICKNAME_KEY) || ""; }
|
export function getNickname() {
|
||||||
|
const nickname = sessionStorage.getItem(NICKNAME_KEY) || getCookie(NICKNAME_COOKIE);
|
||||||
|
if (nickname) sessionStorage.setItem(NICKNAME_KEY, nickname);
|
||||||
|
return nickname;
|
||||||
|
}
|
||||||
export function setNickname(value) {
|
export function setNickname(value) {
|
||||||
const nickname = value.trim();
|
const nickname = value.trim();
|
||||||
if (nickname) sessionStorage.setItem(NICKNAME_KEY, nickname);
|
if (nickname) sessionStorage.setItem(NICKNAME_KEY, nickname);
|
||||||
else sessionStorage.removeItem(NICKNAME_KEY);
|
else sessionStorage.removeItem(NICKNAME_KEY);
|
||||||
|
setNicknameCookie(nickname);
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTH_TOKEN_KEY = "rustpad:auth-token";
|
const AUTH_TOKEN_KEY = "rustpad:auth-token";
|
||||||
@@ -23,6 +39,7 @@ export function clearAuthSession() {
|
|||||||
sessionStorage.removeItem(AUTH_TOKEN_KEY);
|
sessionStorage.removeItem(AUTH_TOKEN_KEY);
|
||||||
localStorage.removeItem(NICKNAME_KEY);
|
localStorage.removeItem(NICKNAME_KEY);
|
||||||
sessionStorage.removeItem(NICKNAME_KEY);
|
sessionStorage.removeItem(NICKNAME_KEY);
|
||||||
|
setNicknameCookie("");
|
||||||
}
|
}
|
||||||
export async function resolveIdentity(api, nickname) {
|
export async function resolveIdentity(api, nickname) {
|
||||||
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname, session_token: getAuthToken() || null }) });
|
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname, session_token: getAuthToken() || null }) });
|
||||||
|
|||||||
Reference in New Issue
Block a user