v0.1.0
This commit is contained in:
+45
-1
@@ -1,7 +1,7 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{DefaultBodyLimit, Path, State},
|
||||
http::{HeaderValue, StatusCode, header},
|
||||
http::{HeaderName, HeaderValue, StatusCode, header},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
@@ -46,6 +46,7 @@ pub fn router(
|
||||
.route("/errors/private-workspace", get(private_workspace_error))
|
||||
.route("/health", get(health))
|
||||
.route("/robots.txt", get(robots_txt))
|
||||
.route("/favicon.ico", get(favicon))
|
||||
.route("/f/{token}/{filename}", get(api::download_file))
|
||||
.route(
|
||||
"/files/{directory}/{filename}",
|
||||
@@ -56,7 +57,20 @@ pub fn router(
|
||||
.route("/api/auth/register", post(auth::register))
|
||||
.route("/api/auth/login", post(auth::login))
|
||||
.route("/api/auth/confirm-account", post(auth::confirm_account))
|
||||
.route(
|
||||
"/api/auth/resend-confirmation",
|
||||
post(auth::resend_confirmation),
|
||||
)
|
||||
.route("/api/auth/me", get(auth::me))
|
||||
.route("/api/auth/profile", post(auth::update_profile))
|
||||
.route(
|
||||
"/api/auth/account/delete",
|
||||
post(auth::request_account_deletion),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/account-action/confirm",
|
||||
post(auth::confirm_account_action),
|
||||
)
|
||||
.route("/api/auth/logout", post(auth::logout))
|
||||
.route(
|
||||
"/api/auth/resources",
|
||||
@@ -156,6 +170,32 @@ pub fn router(
|
||||
.layer(DefaultBodyLimit::max(
|
||||
upload_max_size_bytes.saturating_add(1024 * 1024),
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
HeaderName::from_static("x-content-type-options"),
|
||||
HeaderValue::from_static("nosniff"),
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
HeaderName::from_static("x-frame-options"),
|
||||
HeaderValue::from_static("DENY"),
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
HeaderName::from_static("referrer-policy"),
|
||||
HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
HeaderName::from_static("permissions-policy"),
|
||||
HeaderValue::from_static(
|
||||
"camera=(), microphone=(), geolocation=(), payment=(), usb=()",
|
||||
),
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
HeaderName::from_static("cross-origin-opener-policy"),
|
||||
HeaderValue::from_static("same-origin"),
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
HeaderName::from_static("cross-origin-resource-policy"),
|
||||
HeaderValue::from_static("same-origin"),
|
||||
))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -176,6 +216,10 @@ async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
async fn favicon() -> StatusCode {
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
async fn robots_txt() -> Response {
|
||||
let mut response = "User-agent: *\nDisallow: /f/\nDisallow: /files/\n".into_response();
|
||||
response.headers_mut().insert(
|
||||
|
||||
+16
-6
@@ -40,6 +40,7 @@ pub struct LdapIdentity {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub nickname: String,
|
||||
pub display_name: String,
|
||||
pub provider: String,
|
||||
pub external_id: String,
|
||||
pub external_dn: String,
|
||||
@@ -157,23 +158,30 @@ pub async fn authenticate(
|
||||
}
|
||||
let _ = ldap.unbind().await;
|
||||
|
||||
let organization = config.organization.trim();
|
||||
let nickname = if organization.is_empty() {
|
||||
display_name
|
||||
} else {
|
||||
format!("{organization}/{display_name}")
|
||||
};
|
||||
let nickname = directory_nickname(&display_name, &email, &username);
|
||||
|
||||
Ok(Some(LdapIdentity {
|
||||
username,
|
||||
email,
|
||||
nickname,
|
||||
display_name,
|
||||
provider: config.provider.clone(),
|
||||
external_id,
|
||||
external_dn: user_dn,
|
||||
}))
|
||||
}
|
||||
|
||||
fn directory_nickname(display_name: &str, email: &str, username: &str) -> String {
|
||||
let words: Vec<&str> = display_name.split_whitespace().filter(|v| !v.is_empty()).collect();
|
||||
let candidate = if words.len() >= 2 {
|
||||
let first = words[0].chars().next().unwrap_or('u');
|
||||
format!("{}.{}", first, words[words.len() - 1])
|
||||
} else {
|
||||
email.split('@').next().filter(|v| !v.is_empty()).unwrap_or(username).to_owned()
|
||||
};
|
||||
candidate.to_lowercase()
|
||||
}
|
||||
|
||||
fn first_attr(entry: &SearchEntry, name: &str) -> Option<String> {
|
||||
entry
|
||||
.attrs
|
||||
@@ -279,6 +287,7 @@ async fn provision_ldap_user(
|
||||
.bind(&identity.provider)
|
||||
.bind(&identity.external_id)
|
||||
.bind(&identity.external_dn)
|
||||
.bind(&identity.display_name)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
@@ -306,6 +315,7 @@ async fn sync_directory_user(
|
||||
.bind(&identity.provider)
|
||||
.bind(&identity.external_id)
|
||||
.bind(&identity.external_dn)
|
||||
.bind(&identity.display_name)
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.bind(user.id)
|
||||
.execute(state.db.pool())
|
||||
|
||||
+281
@@ -63,6 +63,10 @@ pub struct ConfirmAccountRequest {
|
||||
token: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResendConfirmationRequest {
|
||||
email: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetRequest {
|
||||
email: String,
|
||||
}
|
||||
@@ -72,6 +76,17 @@ pub struct ResetConfirmRequest {
|
||||
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,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteAccountRequest { password: String }
|
||||
#[derive(Deserialize)]
|
||||
pub struct AccountActionConfirmRequest { token: String }
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResourceActionRequest {
|
||||
kind: String,
|
||||
slug: String,
|
||||
@@ -144,6 +159,10 @@ pub struct SessionResponse {
|
||||
nickname: String,
|
||||
email: String,
|
||||
expires_at: String,
|
||||
directory_managed: bool,
|
||||
directory_display_name: Option<String>,
|
||||
directory_organization: Option<String>,
|
||||
suggested_nickname: Option<String>,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
pub struct IdentityResponse {
|
||||
@@ -348,6 +367,46 @@ pub async fn login(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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>,
|
||||
@@ -415,6 +474,53 @@ pub async fn confirm_account(
|
||||
))
|
||||
}
|
||||
|
||||
async fn directory_profile_metadata(
|
||||
state: &SharedState,
|
||||
user: &User,
|
||||
) -> Result<(bool, Option<String>, Option<String>, Option<String>), AuthError> {
|
||||
let row: Option<(String, Option<String>)> = sqlx::query_as(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((provider, display_name)) = row else {
|
||||
return Ok((false, None, None, None));
|
||||
};
|
||||
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().copied().unwrap_or_default();
|
||||
let last = words.last().copied().unwrap_or_default();
|
||||
let candidate = format!("{}.{}", first, last).to_lowercase();
|
||||
if let Ok(value) = validate_nickname(&candidate) {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
email
|
||||
.split('@')
|
||||
.next()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.and_then(|value| validate_nickname(&value.to_lowercase()).ok())
|
||||
}
|
||||
|
||||
pub async fn me(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
@@ -432,14 +538,179 @@ pub async fn me(
|
||||
.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?;
|
||||
Ok(Json(SessionResponse {
|
||||
token: token.into(),
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
expires_at,
|
||||
directory_managed,
|
||||
directory_display_name,
|
||||
directory_organization,
|
||||
suggested_nickname,
|
||||
}))
|
||||
}
|
||||
|
||||
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 if !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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"nickname": nickname,
|
||||
"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: Option<(i64,String,Option<String>,String,Option<String>)> = sqlx::query_as(queries::get(state.db.kind(), queries::AUTH_ACCOUNT_ACTION_BY_TOKEN))
|
||||
.bind(&hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
let (user_id, action, payload, expires_at, used_at) = row.ok_or_else(|| AuthError::bad_request("The confirmation link is invalid or has expired."))?;
|
||||
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" {
|
||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_USER)).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?;
|
||||
"Account deleted."
|
||||
} 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}/?account_action_token={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,
|
||||
@@ -668,6 +939,10 @@ pub async fn share_resource_users(
|
||||
missing.push(email);
|
||||
continue;
|
||||
};
|
||||
if user.confirmed_at.is_none() {
|
||||
missing.push(format!("{} (account not activated)", email));
|
||||
continue;
|
||||
}
|
||||
if user.id == owner.id {
|
||||
continue;
|
||||
}
|
||||
@@ -1276,11 +1551,17 @@ async fn create_session(state: &SharedState, user: &User) -> Result<SessionRespo
|
||||
.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?;
|
||||
Ok(SessionResponse {
|
||||
token,
|
||||
nickname: user.nickname.clone(),
|
||||
email: user.email.clone(),
|
||||
expires_at,
|
||||
directory_managed,
|
||||
directory_display_name,
|
||||
directory_organization,
|
||||
suggested_nickname,
|
||||
})
|
||||
}
|
||||
async fn find_user_by_nickname(
|
||||
|
||||
+235
-128
@@ -1,4 +1,4 @@
|
||||
use std::{env, net::IpAddr};
|
||||
use std::{collections::HashMap, env, net::IpAddr, path::Path};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthorizationType {
|
||||
@@ -8,12 +8,8 @@ pub enum AuthorizationType {
|
||||
}
|
||||
|
||||
impl AuthorizationType {
|
||||
pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
match env_var("AUTHORIZATION_TYPE", "local")
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
fn from_values(values: &ConfigValues) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
match values.get("AUTHORIZATION_TYPE", "local").trim().to_ascii_lowercase().as_str() {
|
||||
"local" => Ok(Self::Local),
|
||||
"ldap" => Ok(Self::Ldap),
|
||||
"ad" => Ok(Self::Ad),
|
||||
@@ -50,36 +46,32 @@ pub struct Config {
|
||||
pub frontend_log_level: String,
|
||||
pub anonymous_access_token_ttl_days: i64,
|
||||
pub user_session_ttl_days: i64,
|
||||
pub unconfirmed_account_ttl_days: i64,
|
||||
pub authorization_type: AuthorizationType,
|
||||
pub ldap: Option<crate::auth::ldap::LdapConfig>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let host = env_var("APP_HOST", "127.0.0.1").parse()?;
|
||||
let port = env_var("APP_PORT", "3000").parse()?;
|
||||
let database_max_connections = env_var("DATABASE_MAX_CONNECTIONS", "8").parse()?;
|
||||
pub fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let values = ConfigValues::load(path)?;
|
||||
let host = values.get("APP_HOST", "127.0.0.1").parse()?;
|
||||
let port = values.get("APP_PORT", "3000").parse()?;
|
||||
let database_max_connections = values.get("DATABASE_MAX_CONNECTIONS", "8").parse()?;
|
||||
let upload_max_size_mb: usize = values.get("UPLOAD_MAX_SIZE_MB", "20").parse()?;
|
||||
let anonymous_access_token_ttl_days = values.positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?;
|
||||
let user_session_ttl_days = values.positive_i64("USER_SESSION_TTL_DAYS", 3)?;
|
||||
let unconfirmed_account_ttl_days = values.positive_i64("UNCONFIRMED_ACCOUNT_TTL_DAYS", 3)?;
|
||||
let files_dir = values.get("FILES_DIR", "data/files");
|
||||
|
||||
let upload_max_size_mb: usize = env_var("UPLOAD_MAX_SIZE_MB", "20").parse()?;
|
||||
let anonymous_access_token_ttl_days =
|
||||
env_positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?;
|
||||
let user_session_ttl_days = env_positive_i64("USER_SESSION_TTL_DAYS", 30)?;
|
||||
let files_dir = env_var("FILES_DIR", "data/files");
|
||||
let storage = match env_var("STORAGE_DRIVER", "local")
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"local" => crate::storage::StorageConfig::Local {
|
||||
root: files_dir.clone().into(),
|
||||
},
|
||||
let storage = match values.get("STORAGE_DRIVER", "local").trim().to_ascii_lowercase().as_str() {
|
||||
"local" => crate::storage::StorageConfig::Local { root: files_dir.clone().into() },
|
||||
"s3" => crate::storage::StorageConfig::S3 {
|
||||
endpoint: env::var("S3_ENDPOINT").ok(),
|
||||
region: env_var("S3_REGION", "us-east-1"),
|
||||
bucket: required_env("S3_BUCKET")?,
|
||||
access_key: required_env("S3_ACCESS_KEY")?,
|
||||
secret_key: required_env("S3_SECRET_KEY")?,
|
||||
force_path_style: env_bool("S3_FORCE_PATH_STYLE", false)?,
|
||||
endpoint: values.optional("S3_ENDPOINT"),
|
||||
region: values.get("S3_REGION", "us-east-1"),
|
||||
bucket: values.required("S3_BUCKET", "STORAGE_DRIVER=s3")?,
|
||||
access_key: values.required("S3_ACCESS_KEY", "STORAGE_DRIVER=s3")?,
|
||||
secret_key: values.required("S3_SECRET_KEY", "STORAGE_DRIVER=s3")?,
|
||||
force_path_style: values.bool("S3_FORCE_PATH_STYLE", false)?,
|
||||
},
|
||||
_ => return Err("STORAGE_DRIVER must be local or s3".into()),
|
||||
};
|
||||
@@ -88,154 +80,269 @@ impl Config {
|
||||
return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into());
|
||||
}
|
||||
|
||||
let authorization_type = AuthorizationType::from_env()?;
|
||||
let authorization_type = AuthorizationType::from_values(&values)?;
|
||||
let ldap = match authorization_type {
|
||||
AuthorizationType::Local => None,
|
||||
AuthorizationType::Ldap | AuthorizationType::Ad => {
|
||||
let context = format!("AUTHORIZATION_TYPE={}", authorization_type.as_str());
|
||||
let (default_filter, default_username_attribute) = match authorization_type {
|
||||
AuthorizationType::Ldap => ("(uid={username})", "uid"),
|
||||
AuthorizationType::Ad => (
|
||||
"(|(sAMAccountName={username})(userPrincipalName={username}))",
|
||||
"sAMAccountName",
|
||||
),
|
||||
AuthorizationType::Ad => ("(|(sAMAccountName={username})(userPrincipalName={username}))", "sAMAccountName"),
|
||||
AuthorizationType::Local => unreachable!(),
|
||||
};
|
||||
Some(crate::auth::ldap::LdapConfig {
|
||||
url: required_nonempty_env("LDAP_URL", &context)?,
|
||||
starttls: env_bool("LDAP_STARTTLS", false)?,
|
||||
bind_dn: env::var("LDAP_BIND_DN").unwrap_or_default(),
|
||||
bind_password: env::var("LDAP_BIND_PASSWORD").unwrap_or_default(),
|
||||
base_dn: required_nonempty_env("LDAP_BASE_DN", &context)?,
|
||||
user_filter: env_var("LDAP_USER_FILTER", default_filter),
|
||||
username_attribute: env_var(
|
||||
"LDAP_USERNAME_ATTRIBUTE",
|
||||
default_username_attribute,
|
||||
),
|
||||
email_attribute: env_var("LDAP_EMAIL_ATTRIBUTE", "mail"),
|
||||
display_name_attribute: env_var("LDAP_DISPLAY_NAME_ATTRIBUTE", "displayName"),
|
||||
external_id_attribute: env_var(
|
||||
"LDAP_EXTERNAL_ID_ATTRIBUTE",
|
||||
match authorization_type {
|
||||
AuthorizationType::Ldap => "entryUUID",
|
||||
AuthorizationType::Ad => "objectGUID",
|
||||
AuthorizationType::Local => unreachable!(),
|
||||
},
|
||||
),
|
||||
organization: env_var("LDAP_ORGANIZATION", "organization"),
|
||||
url: values.required("LDAP_URL", &context)?,
|
||||
starttls: values.bool("LDAP_STARTTLS", false)?,
|
||||
bind_dn: values.get("LDAP_BIND_DN", ""),
|
||||
bind_password: values.get("LDAP_BIND_PASSWORD", ""),
|
||||
base_dn: values.required("LDAP_BASE_DN", &context)?,
|
||||
user_filter: values.get("LDAP_USER_FILTER", default_filter),
|
||||
username_attribute: values.get("LDAP_USERNAME_ATTRIBUTE", default_username_attribute),
|
||||
email_attribute: values.get("LDAP_EMAIL_ATTRIBUTE", "mail"),
|
||||
display_name_attribute: values.get("LDAP_DISPLAY_NAME_ATTRIBUTE", "displayName"),
|
||||
external_id_attribute: values.get("LDAP_EXTERNAL_ID_ATTRIBUTE", match authorization_type {
|
||||
AuthorizationType::Ldap => "entryUUID",
|
||||
AuthorizationType::Ad => "objectGUID",
|
||||
AuthorizationType::Local => unreachable!(),
|
||||
}),
|
||||
organization: values.get("LDAP_ORGANIZATION", "organization"),
|
||||
provider: authorization_type.as_str().to_owned(),
|
||||
email_required: env_bool("LDAP_EMAIL_REQUIRED", true)?,
|
||||
link_existing_by_email: env_bool("LDAP_LINK_EXISTING_BY_EMAIL", false)?,
|
||||
tls_verify: env_bool("LDAP_TLS_VERIFY", true)?,
|
||||
connect_timeout_seconds: env_positive_u64("LDAP_CONNECT_TIMEOUT_SECONDS", 5)?,
|
||||
operation_timeout_seconds: env_positive_u64(
|
||||
"LDAP_OPERATION_TIMEOUT_SECONDS",
|
||||
10,
|
||||
)?,
|
||||
email_required: values.bool("LDAP_EMAIL_REQUIRED", true)?,
|
||||
link_existing_by_email: values.bool("LDAP_LINK_EXISTING_BY_EMAIL", false)?,
|
||||
tls_verify: values.bool("LDAP_TLS_VERIFY", true)?,
|
||||
connect_timeout_seconds: values.positive_u64("LDAP_CONNECT_TIMEOUT_SECONDS", 5)?,
|
||||
operation_timeout_seconds: values.positive_u64("LDAP_OPERATION_TIMEOUT_SECONDS", 10)?,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let smtp_host = std::env::var("SMTP_HOST")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty());
|
||||
let smtp = if let Some(host) = smtp_host {
|
||||
let smtp = if let Some(host) = values.optional("SMTP_HOST") {
|
||||
Some(crate::state::SmtpConfig {
|
||||
host,
|
||||
port: env_var("SMTP_PORT", "587").parse()?,
|
||||
username: std::env::var("SMTP_USERNAME").unwrap_or_default(),
|
||||
password: std::env::var("SMTP_PASSWORD").unwrap_or_default(),
|
||||
from: std::env::var("SMTP_FROM")
|
||||
.map_err(|_| "SMTP_FROM is required when SMTP_HOST is set")?,
|
||||
public_url: std::env::var("PUBLIC_URL")
|
||||
.map_err(|_| "PUBLIC_URL is required when SMTP_HOST is set")?,
|
||||
port: values.get("SMTP_PORT", "587").parse()?,
|
||||
username: values.get("SMTP_USERNAME", ""),
|
||||
password: values.get("SMTP_PASSWORD", ""),
|
||||
from: values.required("SMTP_FROM", "SMTP_HOST is set")?,
|
||||
public_url: values.required("PUBLIC_URL", "SMTP_HOST is set")?,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
let config = Self {
|
||||
host,
|
||||
port,
|
||||
database_url: env_var("DATABASE_URL", "sqlite:///data/db/rustpad.db?mode=rwc"),
|
||||
database_url: values.get("DATABASE_URL", "sqlite:///data/db/rustpad.db?mode=rwc"),
|
||||
database_max_connections,
|
||||
static_dir: env_var("STATIC_DIR", "static"),
|
||||
static_dir: values.get("STATIC_DIR", "static"),
|
||||
files_dir,
|
||||
storage,
|
||||
upload_max_size_bytes: upload_max_size_mb
|
||||
.checked_mul(1024 * 1024)
|
||||
.ok_or("UPLOAD_MAX_SIZE_MB is too large")?,
|
||||
upload_max_size_bytes: upload_max_size_mb.checked_mul(1024 * 1024).ok_or("UPLOAD_MAX_SIZE_MB is too large")?,
|
||||
asset_version: env!("CARGO_PKG_VERSION").to_owned(),
|
||||
asset_cache_max_age_seconds: env_nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
|
||||
file_cache_max_age_seconds: env_nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?,
|
||||
asset_cache_max_age_seconds: values.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
|
||||
file_cache_max_age_seconds: values.nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?,
|
||||
smtp,
|
||||
registration_enabled: env_bool("REGISTRATION_ENABLED", false)?,
|
||||
account_confirmation_required: env_bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?,
|
||||
share_confirmation_required: env_bool("SHARE_CONFIRMATION_REQUIRED", false)?,
|
||||
frontend_log_level: env_log_level("FRONTEND_LOG_LEVEL", "warn")?,
|
||||
registration_enabled: values.bool("REGISTRATION_ENABLED", false)?,
|
||||
account_confirmation_required: values.bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?,
|
||||
share_confirmation_required: values.bool("SHARE_CONFIRMATION_REQUIRED", false)?,
|
||||
frontend_log_level: values.log_level("FRONTEND_LOG_LEVEL", "warn")?,
|
||||
anonymous_access_token_ttl_days,
|
||||
user_session_ttl_days,
|
||||
unconfirmed_account_ttl_days,
|
||||
authorization_type,
|
||||
ldap,
|
||||
})
|
||||
};
|
||||
config.validate()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !(self.database_url.starts_with("sqlite:")
|
||||
|| self.database_url.starts_with("postgres:")
|
||||
|| self.database_url.starts_with("postgresql:")
|
||||
|| self.database_url.starts_with("mysql:"))
|
||||
{
|
||||
return Err("DATABASE_URL must use sqlite, postgres/postgresql, or mysql".into());
|
||||
}
|
||||
if self.database_max_connections == 0 {
|
||||
return Err("DATABASE_MAX_CONNECTIONS must be greater than 0".into());
|
||||
}
|
||||
if self.static_dir.trim().is_empty() || self.files_dir.trim().is_empty() {
|
||||
return Err("STATIC_DIR and FILES_DIR cannot be empty".into());
|
||||
}
|
||||
if let Some(smtp) = &self.smtp {
|
||||
if !(smtp.public_url.starts_with("http://") || smtp.public_url.starts_with("https://")) {
|
||||
return Err("PUBLIC_URL must start with http:// or https://".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn env_var(name: &str, default: &str) -> String {
|
||||
env::var(name).unwrap_or_else(|_| default.to_owned())
|
||||
|
||||
const KNOWN_CONFIG_KEYS: &[&str] = &[
|
||||
"APP_HOST", "APP_PORT", "DATABASE_URL", "DATABASE_MAX_CONNECTIONS",
|
||||
"STATIC_DIR", "FILES_DIR", "STORAGE_DRIVER", "UPLOAD_MAX_SIZE_MB",
|
||||
"ASSET_CACHE_MAX_AGE_SECONDS", "FILE_CACHE_MAX_AGE_SECONDS",
|
||||
"REGISTRATION_ENABLED", "ACCOUNT_CONFIRMATION_REQUIRED", "SHARE_CONFIRMATION_REQUIRED",
|
||||
"FRONTEND_LOG_LEVEL", "ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", "USER_SESSION_TTL_DAYS",
|
||||
"UNCONFIRMED_ACCOUNT_TTL_DAYS", "AUTHORIZATION_TYPE",
|
||||
"S3_ENDPOINT", "S3_REGION", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY",
|
||||
"S3_FORCE_PATH_STYLE", "SMTP_HOST", "SMTP_PORT", "SMTP_USERNAME", "SMTP_PASSWORD",
|
||||
"SMTP_FROM", "PUBLIC_URL", "LDAP_URL", "LDAP_STARTTLS", "LDAP_BIND_DN",
|
||||
"LDAP_BIND_PASSWORD", "LDAP_BASE_DN", "LDAP_USER_FILTER", "LDAP_USERNAME_ATTRIBUTE",
|
||||
"LDAP_EMAIL_ATTRIBUTE", "LDAP_DISPLAY_NAME_ATTRIBUTE", "LDAP_EXTERNAL_ID_ATTRIBUTE",
|
||||
"LDAP_ORGANIZATION", "LDAP_EMAIL_REQUIRED", "LDAP_LINK_EXISTING_BY_EMAIL",
|
||||
"LDAP_TLS_VERIFY", "LDAP_CONNECT_TIMEOUT_SECONDS", "LDAP_OPERATION_TIMEOUT_SECONDS",
|
||||
];
|
||||
|
||||
#[derive(Default)]
|
||||
struct ConfigValues {
|
||||
file: HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn env_bool(name: &str, default: bool) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
match env::var(name) {
|
||||
Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
|
||||
impl ConfigValues {
|
||||
fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let Some(path) = path else { return Ok(Self::default()); };
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|error| format!("cannot read config file {}: {error}", path.display()))?;
|
||||
let file = parse_yaml_config(&content)
|
||||
.map_err(|error| format!("invalid YAML in {}: {error}", path.display()))?;
|
||||
for key in file.keys() {
|
||||
if !KNOWN_CONFIG_KEYS.contains(&key.as_str()) {
|
||||
return Err(format!("unknown configuration key: {key}").into());
|
||||
}
|
||||
}
|
||||
Ok(Self { file })
|
||||
}
|
||||
|
||||
fn get(&self, name: &str, default: &str) -> String {
|
||||
env::var(name).ok().or_else(|| self.file.get(name).cloned()).unwrap_or_else(|| default.to_owned())
|
||||
}
|
||||
|
||||
fn optional(&self, name: &str) -> Option<String> {
|
||||
env::var(name).ok().or_else(|| self.file.get(name).cloned()).filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
fn required(&self, name: &str, context: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
self.optional(name).ok_or_else(|| format!("{name} is required when {context}").into())
|
||||
}
|
||||
|
||||
fn bool(&self, name: &str, default: bool) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
match self.get(name, if default { "true" } else { "false" }).trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => Ok(true),
|
||||
"0" | "false" | "no" | "off" => Ok(false),
|
||||
_ => Err(format!("{name} must be true or false").into()),
|
||||
},
|
||||
Err(_) => Ok(default),
|
||||
}
|
||||
}
|
||||
|
||||
fn log_level(&self, name: &str, default: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let value = self.get(name, default).trim().to_ascii_lowercase();
|
||||
match value.as_str() {
|
||||
"off" | "error" | "warn" | "info" | "debug" => Ok(value),
|
||||
_ => Err(format!("{name} must be one of: off, error, warn, info, debug").into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn positive_i64(&self, name: &str, default: i64) -> Result<i64, Box<dyn std::error::Error>> {
|
||||
let value: i64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be an integer"))?;
|
||||
if value <= 0 { return Err(format!("{name} must be greater than 0").into()); }
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn positive_u64(&self, name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
|
||||
let value: u64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer"))?;
|
||||
if value == 0 { return Err(format!("{name} must be greater than 0").into()); }
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn nonnegative_u64(&self, name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
|
||||
self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer").into())
|
||||
}
|
||||
}
|
||||
|
||||
fn env_log_level(name: &str, default: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let value = env_var(name, default).trim().to_ascii_lowercase();
|
||||
match value.as_str() {
|
||||
"off" | "error" | "warn" | "info" | "debug" => Ok(value),
|
||||
_ => Err(format!("{name} must be one of: off, error, warn, info, debug").into()),
|
||||
fn parse_yaml_config(content: &str) -> Result<HashMap<String, String>, String> {
|
||||
let mut output = HashMap::new();
|
||||
let mut sections: Vec<(usize, String)> = Vec::new();
|
||||
|
||||
for (index, original) in content.lines().enumerate() {
|
||||
let line_number = index + 1;
|
||||
if original.contains('\t') {
|
||||
return Err(format!("line {line_number}: tabs are not allowed for indentation"));
|
||||
}
|
||||
let without_comment = strip_yaml_comment(original);
|
||||
if without_comment.trim().is_empty() || without_comment.trim() == "---" {
|
||||
continue;
|
||||
}
|
||||
let indent = without_comment.len() - without_comment.trim_start().len();
|
||||
let line = without_comment.trim();
|
||||
let (raw_key, raw_value) = line
|
||||
.split_once(':')
|
||||
.ok_or_else(|| format!("line {line_number}: expected key: value"))?;
|
||||
let key = raw_key.trim();
|
||||
if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
|
||||
return Err(format!("line {line_number}: invalid key {key:?}"));
|
||||
}
|
||||
while sections.last().is_some_and(|(section_indent, _)| *section_indent >= indent) {
|
||||
sections.pop();
|
||||
}
|
||||
let normalized = key.to_ascii_uppercase().replace('-', "_");
|
||||
let value = raw_value.trim();
|
||||
if value.is_empty() {
|
||||
sections.push((indent, normalized));
|
||||
continue;
|
||||
}
|
||||
if matches!(value.chars().next(), Some('[' | '{' | '|' | '>' | '&' | '*' | '!')) {
|
||||
return Err(format!("line {line_number}: only scalar values and nested mappings are supported"));
|
||||
}
|
||||
let mut path: Vec<&str> = sections.iter().map(|(_, key)| key.as_str()).collect();
|
||||
path.push(&normalized);
|
||||
let full_key = path.join("_");
|
||||
let parsed_value = parse_yaml_scalar(value)
|
||||
.map_err(|error| format!("line {line_number}: {error}"))?;
|
||||
if output.insert(full_key.clone(), parsed_value).is_some() {
|
||||
return Err(format!("line {line_number}: duplicate key {full_key}"));
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn env_positive_i64(name: &str, default: i64) -> Result<i64, Box<dyn std::error::Error>> {
|
||||
let value: i64 = env_var(name, &default.to_string()).parse()?;
|
||||
if value <= 0 {
|
||||
return Err(format!("{name} must be greater than 0").into());
|
||||
fn strip_yaml_comment(line: &str) -> &str {
|
||||
let mut single = false;
|
||||
let mut double = false;
|
||||
let mut escaped = false;
|
||||
for (index, character) in line.char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
match character {
|
||||
'\\' if double => escaped = true,
|
||||
'\'' if !double => single = !single,
|
||||
'"' if !single => double = !double,
|
||||
'#' if !single && !double => return &line[..index],
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(value)
|
||||
line
|
||||
}
|
||||
|
||||
fn env_positive_u64(name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
|
||||
let value: u64 = env_var(name, &default.to_string()).parse()?;
|
||||
if value == 0 {
|
||||
return Err(format!("{name} must be greater than 0").into());
|
||||
fn parse_yaml_scalar(value: &str) -> Result<String, String> {
|
||||
if value.starts_with('"') {
|
||||
if !value.ends_with('"') || value.len() < 2 {
|
||||
return Err("unterminated double-quoted value".to_owned());
|
||||
}
|
||||
return serde_json::from_str::<String>(value)
|
||||
.map_err(|error| format!("invalid double-quoted value: {error}"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn env_nonnegative_u64(name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
|
||||
Ok(env_var(name, &default.to_string()).parse()?)
|
||||
}
|
||||
|
||||
fn required_env(name: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let value = env::var(name).map_err(|_| format!("{name} is required when STORAGE_DRIVER=s3"))?;
|
||||
if value.trim().is_empty() {
|
||||
return Err(format!("{name} cannot be empty when STORAGE_DRIVER=s3").into());
|
||||
if value.starts_with('\'') {
|
||||
if !value.ends_with('\'') || value.len() < 2 {
|
||||
return Err("unterminated single-quoted value".to_owned());
|
||||
}
|
||||
return Ok(value[1..value.len() - 1].replace("''", "'"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn required_nonempty_env(name: &str, context: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let value = env::var(name).map_err(|_| format!("{name} is required when {context}"))?;
|
||||
if value.trim().is_empty() {
|
||||
return Err(format!("{name} cannot be empty when {context}").into());
|
||||
if value.eq_ignore_ascii_case("null") || value == "~" {
|
||||
return Ok(String::new());
|
||||
}
|
||||
Ok(value)
|
||||
Ok(value.to_owned())
|
||||
}
|
||||
|
||||
+95
-2
@@ -10,7 +10,7 @@ mod state;
|
||||
mod storage;
|
||||
mod websocket;
|
||||
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
|
||||
use config::Config;
|
||||
use database::{Database, DatabaseKind};
|
||||
@@ -19,12 +19,18 @@ use tokio::net::TcpListener;
|
||||
use tracing::{info, warn};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
dotenvy::dotenv().ok();
|
||||
let cli = parse_command()?;
|
||||
init_tracing();
|
||||
|
||||
let config = Config::from_env()?;
|
||||
let config = Config::load(cli.config.as_deref())?;
|
||||
if matches!(cli.command, Command::CheckConfig) {
|
||||
println!("configuration is valid{}", cli.config.as_ref().map(|path| format!(" ({})", path.display())).unwrap_or_default());
|
||||
return Ok(());
|
||||
}
|
||||
info!(
|
||||
host = %config.host,
|
||||
port = config.port,
|
||||
@@ -61,6 +67,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
info!(database_kind = ?db.kind(), "database connection established");
|
||||
run_migrations(&db).await?;
|
||||
info!(database_kind = ?db.kind(), "database migrations completed");
|
||||
if matches!(cli.command, Command::Migrate) {
|
||||
println!("database migrations completed");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let storage = storage::Storage::from_config(config.storage.clone()).await?;
|
||||
info!(
|
||||
@@ -80,8 +90,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
config.frontend_log_level.clone(),
|
||||
config.anonymous_access_token_ttl_days,
|
||||
config.user_session_ttl_days,
|
||||
config.unconfirmed_account_ttl_days,
|
||||
config.ldap.clone(),
|
||||
));
|
||||
let cleanup_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(24 * 60 * 60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let cutoff = (chrono::Utc::now() - chrono::Duration::days(cleanup_state.unconfirmed_account_ttl_days)).to_rfc3339();
|
||||
match sqlx::query(crate::queries::get(cleanup_state.db.kind(), crate::queries::AUTH_DELETE_EXPIRED_UNCONFIRMED_USERS))
|
||||
.bind(cutoff).execute(cleanup_state.db.pool()).await {
|
||||
Ok(result) if result.rows_affected() > 0 => info!(deleted = result.rows_affected(), "removed expired unconfirmed accounts"),
|
||||
Ok(_) => {},
|
||||
Err(error) => tracing::error!(%error, "failed to remove expired unconfirmed accounts"),
|
||||
}
|
||||
}
|
||||
});
|
||||
let app = app::router(
|
||||
state,
|
||||
&config.static_dir,
|
||||
@@ -99,6 +124,74 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Command { Run, CheckConfig, Migrate }
|
||||
|
||||
struct Cli {
|
||||
command: Command,
|
||||
config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn parse_command() -> Result<Cli, Box<dyn std::error::Error>> {
|
||||
let mut command = Command::Run;
|
||||
let mut config = None;
|
||||
let mut args = std::env::args().skip(1);
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"-v" | "--version" => {
|
||||
println!("rustpad {}", env!("CARGO_PKG_VERSION"));
|
||||
std::process::exit(0);
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
print_help();
|
||||
std::process::exit(0);
|
||||
}
|
||||
"-c" | "--config" => {
|
||||
let path = args.next().ok_or("--config requires a file path")?;
|
||||
if config.replace(PathBuf::from(path)).is_some() {
|
||||
return Err("--config can only be specified once".into());
|
||||
}
|
||||
}
|
||||
"check-config" => {
|
||||
if !matches!(command, Command::Run) {
|
||||
return Err("only one command may be specified".into());
|
||||
}
|
||||
command = Command::CheckConfig;
|
||||
}
|
||||
"migrate" => {
|
||||
if !matches!(command, Command::Run) {
|
||||
return Err("only one command may be specified".into());
|
||||
}
|
||||
command = Command::Migrate;
|
||||
}
|
||||
_ if arg.starts_with('-') => return Err(format!("unknown option: {arg}; use --help").into()),
|
||||
_ => return Err(format!("unknown command: {arg}; use --help").into()),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Cli { command, config })
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"rustpad {version}
|
||||
|
||||
USAGE:
|
||||
rustpad [OPTIONS] [COMMAND]
|
||||
|
||||
OPTIONS:
|
||||
-c, --config <FILE> Load YAML configuration file; environment variables override it
|
||||
-h, --help Show help
|
||||
-v, --version Show version
|
||||
|
||||
COMMANDS:
|
||||
check-config Parse and validate configuration, then exit
|
||||
migrate Validate configuration, apply database migrations, then exit",
|
||||
version = env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
|
||||
+11
-2
@@ -16,9 +16,18 @@ pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: &str =
|
||||
"SELECT currval(pg_get_serial_sequence('revisions', 'id'))";
|
||||
|
||||
// Authentication queries.
|
||||
pub const AUTH_LATEST_CONFIRMATION_CREATED_AT: &str = "SELECT created_at FROM account_confirmation_tokens WHERE user_id = ? ORDER BY created_at DESC LIMIT 1";
|
||||
pub const AUTH_UPDATE_NICKNAME: &str = "UPDATE users SET nickname = ?, nickname_key = ?, updated_at = ? WHERE id = ?";
|
||||
pub const AUTH_ACCOUNT_ACTION_BY_TOKEN: &str = "SELECT user_id, action, payload, expires_at, used_at FROM account_action_tokens WHERE token = ?";
|
||||
pub const AUTH_CONSUME_ACCOUNT_ACTION: &str = "UPDATE account_action_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL";
|
||||
pub const AUTH_UPDATE_EMAIL: &str = "UPDATE users SET email = ?, email_key = ?, updated_at = ? WHERE id = ?";
|
||||
pub const AUTH_DELETE_ACCOUNT_ACTIONS: &str = "DELETE FROM account_action_tokens WHERE user_id = ? AND action = ?";
|
||||
pub const AUTH_INSERT_ACCOUNT_ACTION: &str = "INSERT INTO account_action_tokens (token,user_id,action,payload,expires_at,created_at) VALUES (?, ?, ?, ?, ?, ?)";
|
||||
pub const AUTH_DELETE_EXPIRED_UNCONFIRMED_USERS: &str = "DELETE FROM users WHERE confirmed_at IS NULL AND created_at < ?";
|
||||
pub const AUTH_INSERT_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at) VALUES (?, ?, ?, ?, ?, ?)";
|
||||
pub const AUTH_INSERT_DIRECTORY_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at, auth_provider, external_id, external_dn) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
pub const AUTH_UPDATE_DIRECTORY_USER: &str = "UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, auth_provider = ?, external_id = ?, external_dn = ?, updated_at = ? WHERE id = ?";
|
||||
pub const AUTH_INSERT_DIRECTORY_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at, auth_provider, external_id, external_dn, directory_display_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
pub const AUTH_UPDATE_DIRECTORY_USER: &str = "UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, auth_provider = ?, external_id = ?, external_dn = ?, directory_display_name = ?, updated_at = ? WHERE id = ?";
|
||||
pub const AUTH_DIRECTORY_PROFILE_BY_USER: &str = "SELECT auth_provider, directory_display_name FROM users WHERE id = ?";
|
||||
pub const AUTH_USER_BY_EXTERNAL_ID: &str = "SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE auth_provider = ? AND external_id = ?";
|
||||
pub const AUTH_DELETE_USER: &str = "DELETE FROM users WHERE id = ?";
|
||||
pub const AUTH_SESSION_EXPIRES_AT: &str = "SELECT expires_at FROM user_sessions WHERE token = ?";
|
||||
|
||||
@@ -63,6 +63,7 @@ pub struct AppState {
|
||||
pub frontend_log_level: String,
|
||||
pub anonymous_access_token_ttl_days: i64,
|
||||
pub user_session_ttl_days: i64,
|
||||
pub unconfirmed_account_ttl_days: i64,
|
||||
pub ldap: Option<crate::auth::ldap::LdapConfig>,
|
||||
channels: RwLock<HashMap<String, broadcast::Sender<RoomEvent>>>,
|
||||
presence: RwLock<HashMap<String, HashMap<u64, PresenceConnection>>>,
|
||||
@@ -83,6 +84,7 @@ impl AppState {
|
||||
frontend_log_level: String,
|
||||
anonymous_access_token_ttl_days: i64,
|
||||
user_session_ttl_days: i64,
|
||||
unconfirmed_account_ttl_days: i64,
|
||||
ldap: Option<crate::auth::ldap::LdapConfig>,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -98,6 +100,7 @@ impl AppState {
|
||||
frontend_log_level,
|
||||
anonymous_access_token_ttl_days,
|
||||
user_session_ttl_days,
|
||||
unconfirmed_account_ttl_days,
|
||||
ldap,
|
||||
channels: RwLock::new(HashMap::new()),
|
||||
presence: RwLock::new(HashMap::new()),
|
||||
|
||||
@@ -48,6 +48,7 @@ enum ServerMessage {
|
||||
note_title: String,
|
||||
content: String,
|
||||
owner_map: String,
|
||||
access_level: String,
|
||||
},
|
||||
Document {
|
||||
content: String,
|
||||
@@ -188,6 +189,7 @@ async fn handle_socket(
|
||||
note_title: note.title.clone(),
|
||||
content: note.content.clone(),
|
||||
owner_map: note.owner_map.clone(),
|
||||
access_level: if write_allowed { "full".into() } else { "read_only".into() },
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -314,6 +316,7 @@ enum PadServerMessage {
|
||||
title: String,
|
||||
content: String,
|
||||
owner_map: String,
|
||||
access_level: String,
|
||||
},
|
||||
Document {
|
||||
content: String,
|
||||
@@ -453,6 +456,7 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
|
||||
title: pad.title.clone(),
|
||||
content: pad.content.clone(),
|
||||
owner_map: pad.owner_map.clone(),
|
||||
access_level: if write_allowed { "full".into() } else { "read_only".into() },
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
Reference in New Issue
Block a user