diff --git a/.env.example b/.env.example index 153521d..3fdffb1 100644 --- a/.env.example +++ b/.env.example @@ -20,10 +20,10 @@ DATABASE_MAX_CONNECTIONS=8 # Session lifetime in days # Anonymous pad/workspace access tokens -ANONYMOUS_ACCESS_TOKEN_TTL_DAYS=7 +ANONYMOUS_ACCESS_TOKEN_TTL_DAYS=3 # Logged-in user sessions -USER_SESSION_TTL_DAYS=30 - +USER_SESSION_TTL_DAYS=3 +UNCONFIRMED_ACCOUNT_TTL_DAYS=3 # Logging # available: warn, debug, info diff --git a/Cargo.lock b/Cargo.lock index 1824907..c200587 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.0.42" +version = "0.0.43" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 7be527f..4e78b9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.0.42" +version = "0.0.43" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/README.md b/README.md index 5313158..4b92502 100644 --- a/README.md +++ b/README.md @@ -242,9 +242,24 @@ docker compose up -d Test users: -- `mateusz` / `test1234` -- `anna` / `test1234` +- `admin` / `test1234` +- `user` / `test1234` -phpLDAPadmin: `http://10.87.2.6:8088` +phpLDAPadmin: `http://10.0.0.1:8088` Administrator: `cn=admin,dc=example,dc=org` / `admin` + + +## CLI and YAML configuration + +RustPad reads `.env` as before and can additionally load a YAML file. Environment variables have higher priority than YAML values. + +```bash +rustpad --version +rustpad --help +rustpad --config /etc/rustpad/rustpad.yaml check-config +rustpad --config /etc/rustpad/rustpad.yaml migrate +rustpad --config /etc/rustpad/rustpad.yaml +``` + +`check-config` validates YAML syntax, supported keys, value types, required LDAP/S3/SMTP fields, database URL scheme, paths and dependent settings. It does not connect to the database or LDAP server. Example deployment files are in `systemd/`. diff --git a/migrations/mysql/0014_account_profile.sql b/migrations/mysql/0014_account_profile.sql new file mode 100644 index 0000000..99064ca --- /dev/null +++ b/migrations/mysql/0014_account_profile.sql @@ -0,0 +1,11 @@ +CREATE TABLE account_action_tokens ( + token VARCHAR(64) PRIMARY KEY, + user_id BIGINT NOT NULL, + action VARCHAR(32) NOT NULL, + payload TEXT, + expires_at VARCHAR(64) NOT NULL, + used_at VARCHAR(64), + created_at VARCHAR(64) NOT NULL, + CONSTRAINT fk_account_action_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX idx_account_action_user ON account_action_tokens(user_id); diff --git a/migrations/mysql/0015_directory_display_name.sql b/migrations/mysql/0015_directory_display_name.sql new file mode 100644 index 0000000..3c0aaba --- /dev/null +++ b/migrations/mysql/0015_directory_display_name.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN directory_display_name VARCHAR(255); diff --git a/migrations/postgres/0014_account_profile.sql b/migrations/postgres/0014_account_profile.sql new file mode 100644 index 0000000..f1fed9b --- /dev/null +++ b/migrations/postgres/0014_account_profile.sql @@ -0,0 +1,10 @@ +CREATE TABLE account_action_tokens ( + token TEXT PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + action TEXT NOT NULL, + payload TEXT, + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_account_action_user ON account_action_tokens(user_id); diff --git a/migrations/postgres/0015_directory_display_name.sql b/migrations/postgres/0015_directory_display_name.sql new file mode 100644 index 0000000..b983e56 --- /dev/null +++ b/migrations/postgres/0015_directory_display_name.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN directory_display_name TEXT; diff --git a/migrations/sqlite/0014_account_profile.sql b/migrations/sqlite/0014_account_profile.sql new file mode 100644 index 0000000..3e3e629 --- /dev/null +++ b/migrations/sqlite/0014_account_profile.sql @@ -0,0 +1,10 @@ +CREATE TABLE account_action_tokens ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + action TEXT NOT NULL, + payload TEXT, + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_account_action_user ON account_action_tokens(user_id); diff --git a/migrations/sqlite/0015_directory_display_name.sql b/migrations/sqlite/0015_directory_display_name.sql new file mode 100644 index 0000000..b983e56 --- /dev/null +++ b/migrations/sqlite/0015_directory_display_name.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN directory_display_name TEXT; diff --git a/src/app.rs b/src/app.rs index 6d13ea0..635d132 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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( diff --git a/src/auth/ldap.rs b/src/auth/ldap.rs index 7783ad6..19cea69 100644 --- a/src/auth/ldap.rs +++ b/src/auth/ldap.rs @@ -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 { 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()) diff --git a/src/auth/mod.rs b/src/auth/mod.rs index dccf923..e4b16c7 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -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, + #[serde(default)] new_email: Option, + #[serde(default)] new_password: Option, + #[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, + directory_organization: Option, + suggested_nickname: Option, } #[derive(Serialize)] pub struct IdentityResponse { @@ -348,6 +367,46 @@ pub async fn login( } } + +pub async fn resend_confirmation( + State(state): State, + Json(req): Json, +) -> Result, AuthError> { + if !state.account_confirmation_required { + return Err(AuthError::bad_request("Account confirmation is not enabled.")); + } + let smtp = state.smtp.as_ref().ok_or_else(|| AuthError::service_unavailable("SMTP is not configured."))?; + let email = validate_email(&req.email)?; + let user = find_user_by_email(&state, &email).await? + .ok_or_else(|| AuthError::bad_request("No unconfirmed account exists for this e-mail address."))?; + if user.confirmed_at.is_some() { + return Err(AuthError::bad_request("This account is already confirmed.")); + } + let last_created: Option = sqlx::query_scalar(queries::get( + state.db.kind(), + queries::AUTH_LATEST_CONFIRMATION_CREATED_AT, + )) + .bind(user.id).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?; + if let Some(value) = last_created { + if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&value) { + let available = created.with_timezone(&Utc) + Duration::minutes(10); + if available > Utc::now() { + let seconds = (available - Utc::now()).num_seconds().max(1); + return Err(AuthError::bad_request(&format!("A new confirmation e-mail can be sent in {} minute(s).", (seconds + 59) / 60))); + } + } + } + sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER)) + .bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; + let token = random_confirmation_token(); + let token_hash = hash_token(&token); + let expires_at = (Utc::now() + Duration::hours(24)).to_rfc3339(); + sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_CONFIRMATION_TOKEN)) + .bind(token_hash).bind(user.id).bind(expires_at).execute(state.db.pool()).await.map_err(AuthError::database)?; + send_registration_email(smtp, &user, Some(&token)).await?; + Ok(Json(serde_json::json!({"ok":true,"message":"A new confirmation e-mail has been sent."}))) +} + pub async fn confirm_account( State(state): State, Json(req): Json, @@ -415,6 +474,53 @@ pub async fn confirm_account( )) } +async fn directory_profile_metadata( + state: &SharedState, + user: &User, +) -> Result<(bool, Option, Option, Option), AuthError> { + let row: Option<(String, Option)> = 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 { + 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, 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, + headers: HeaderMap, + Json(req): Json, +) -> Result, AuthError> { + let user = require_user(&state, &headers).await?; + let (directory_managed, _, _, _) = directory_profile_metadata(&state, &user).await?; + + if directory_managed { + if req + .new_email + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || req + .new_password + .as_deref() + .is_some_and(|value| !value.is_empty()) + { + return Err(AuthError::forbidden( + "E-mail and password are managed by LDAP/AD.", + )); + } + } else 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, headers: HeaderMap, Json(req): Json, +) -> Result, AuthError> { + if state.ldap.is_some() { return Err(AuthError::forbidden("LDAP accounts cannot be deleted here.")); } + let user = require_user(&state, &headers).await?; + if !verify_password(&user.password_hash, &req.password) { return Err(AuthError::unauthorized("The current password is incorrect.")); } + let smtp = state.smtp.as_ref().ok_or_else(|| AuthError::service_unavailable("SMTP is not configured."))?; + create_account_action(&state, &user, "delete", None, smtp).await?; + Ok(Json(serde_json::json!({"ok":true,"message":"A confirmation link has been sent to your e-mail address."}))) +} + +pub async fn confirm_account_action( + State(state): State, Json(req): Json, +) -> Result, AuthError> { + let now = Utc::now(); let hash = hash_token(req.token.trim()); + let row: Option<(i64,String,Option,String,Option)> = 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::().map_err(|_|AuthError::internal("Invalid SMTP_FROM."))?; + let target=if action=="email" { payload.unwrap_or(&user.email) } else { &user.email }; + let recipient=target.parse::().map_err(|_|AuthError::internal("Invalid recipient address."))?; + let (subject,title,copy)=if action=="email" {("Confirm your new RustPad e-mail","Confirm e-mail change","Confirm the new e-mail address within one hour.")} else {("Confirm RustPad account deletion","Confirm account deletion","Confirm permanent account deletion within one hour.")}; + let text=format!("Hello {},\n\n{}\n{}\n\nIf you did not request this, ignore this message.",user.nickname,copy,url); + let html=format!(r#"

{}

Hello {},

{}

Confirm action

{}

If you did not request this, ignore this message.

"#,title,user.nickname,copy,url,url,url); + let message=Message::builder().from(sender).to(recipient).subject(subject).multipart(MultiPart::alternative().singlepart(SinglePart::builder().header(ContentType::TEXT_PLAIN).body(text)).singlepart(SinglePart::builder().header(ContentType::TEXT_HTML).body(html))).map_err(|_|AuthError::internal("Failed to build account confirmation e-mail."))?; + send_message(smtp,message,"account action e-mail").await +} + pub async fn resources( State(state): State, headers: HeaderMap, @@ -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 Result> { - match env_var("AUTHORIZATION_TYPE", "local") - .trim() - .to_ascii_lowercase() - .as_str() - { + fn from_values(values: &ConfigValues) -> Result> { + 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, } impl Config { - pub fn from_env() -> Result> { - 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> { + 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> { + 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, } -fn env_bool(name: &str, default: bool) -> Result> { - match env::var(name) { - Ok(value) => match value.trim().to_ascii_lowercase().as_str() { +impl ConfigValues { + fn load(path: Option<&Path>) -> Result> { + 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 { + 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> { + self.optional(name).ok_or_else(|| format!("{name} is required when {context}").into()) + } + + fn bool(&self, name: &str, default: bool) -> Result> { + 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> { + 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> { + 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> { + 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> { + 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> { - 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, 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> { - 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> { - 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 { + if value.starts_with('"') { + if !value.ends_with('"') || value.len() < 2 { + return Err("unterminated double-quoted value".to_owned()); + } + return serde_json::from_str::(value) + .map_err(|error| format!("invalid double-quoted value: {error}")); } - Ok(value) -} - -fn env_nonnegative_u64(name: &str, default: u64) -> Result> { - Ok(env_var(name, &default.to_string()).parse()?) -} - -fn required_env(name: &str) -> Result> { - 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> { - 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()) } diff --git a/src/main.rs b/src/main.rs index 8dcdf9a..26c9ed7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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> { 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> { 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> { 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> { Ok(()) } +#[derive(Clone, Copy)] +enum Command { Run, CheckConfig, Migrate } + +struct Cli { + command: Command, + config: Option, +} + +fn parse_command() -> Result> { + 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 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( diff --git a/src/queries.rs b/src/queries.rs index 8b99e1d..36e0b86 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -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 = ?"; diff --git a/src/state.rs b/src/state.rs index cd7e810..8bed166 100644 --- a/src/state.rs +++ b/src/state.rs @@ -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, channels: RwLock>>, presence: RwLock>>, @@ -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, ) -> 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()), diff --git a/src/websocket.rs b/src/websocket.rs index 28ba898..aa7bcca 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -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 diff --git a/static/css/styles.css b/static/css/styles.css index 5247341..6a81000 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -4209,3 +4209,16 @@ dialog::backdrop { .file-code button { height: auto; } + +.footer-access{font-weight:600;color:var(--muted,#9ca3af)} + +.profile-suggestion { + margin: 0; + color: var(--muted); + font-size: .82rem; +} + +#profile-dialog input[readonly] { + cursor: default; + opacity: .78; +} diff --git a/static/home.html b/static/home.html index 8b37681..a7ddd4b 100644 --- a/static/home.html +++ b/static/home.html @@ -80,6 +80,7 @@ Author:

+ + +
+ +

Profile

Manage your local RustPad account.

+
+ + +

+ + + + +
+ + +

+
+
+ \ No newline at end of file diff --git a/static/js/auth-ui.js b/static/js/auth-ui.js index bde6a14..7a3f43b 100644 --- a/static/js/auth-ui.js +++ b/static/js/auth-ui.js @@ -121,6 +121,25 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" } message.classList.remove("success"); message.classList.add("error"); message.textContent = error.message; + if (/confirm the account/i.test(error.message) && email.value.trim()) { + const resend = document.createElement("button"); + resend.type = "button"; + resend.className = "text-button resend-confirmation"; + resend.textContent = "Resend confirmation e-mail"; + resend.addEventListener("click", async () => { + resend.disabled = true; + try { + const result = await api("/api/auth/resend-confirmation", { method: "POST", body: JSON.stringify({ email: email.value.trim() }) }); + message.classList.remove("error"); + message.classList.add("success"); + message.textContent = result.message; + } catch (resendError) { + message.textContent = resendError.message; + resend.disabled = false; + } + }); + message.append(document.createElement("br"), resend); + } } finally { submit.disabled = false; } @@ -255,12 +274,14 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) { } setAuthSession(session); await onIdentity(session.nickname, session); + dialog.close(); return; } nickname.disabled = false; const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname: name, session_token: getAuthToken() || null }) }); setNickname(result.nickname); await onIdentity(result.nickname, null); + dialog.close(); } catch (error) { message.classList.remove("success"); message.classList.add("error"); diff --git a/static/js/home.js b/static/js/home.js index 596064d..1cb9732 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -84,6 +84,7 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even handleAccountConfirmationToken(); handleResetToken(); +{ const params=new URLSearchParams(location.search); const token=params.get("account_action_token"); if(token){ api("/api/auth/account-action/confirm",{method:"POST",body:JSON.stringify({token})}).then(r=>alert(r.message)).catch(e=>alert(e.message)).finally(()=>{params.delete("account_action_token");history.replaceState({},"",`${location.pathname}${params.size?`?${params}`:""}${location.hash}`);}); } } const identityDialog = document.querySelector("#identity-dialog"); const guestAccount = document.querySelector("#footer-account-guest"); @@ -94,6 +95,9 @@ const registrationEnabled = document.body.dataset.registrationEnabled === "true" const resourcesDialog = document.querySelector("#resources-dialog"); const resourcesList = document.querySelector("#resources-list"); const resourcesError = document.querySelector("#resources-error"); +const profileDialog = document.querySelector("#profile-dialog"); +const profileForm = document.querySelector("#profile-form"); +let currentSession = null; function authHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; } @@ -179,7 +183,7 @@ async function loadResources() { linkRow.querySelector("[data-revoke-link]").addEventListener("click", async () => { try { await api("/api/auth/resources/share-links", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token: linkRow.dataset.linkToken }) }); setDialogMessage("Link revoked.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } }); }); }; - userForm.addEventListener("submit", async event => { event.preventDefault(); try { await api("/api/auth/resources/sharing", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, emails: userForm.emails.value, permission: userForm.permission.value }) }); userForm.emails.value = ""; setDialogMessage("Access granted.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } }); + userForm.addEventListener("submit", async event => { event.preventDefault(); try { const result = await api("/api/auth/resources/sharing", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, emails: userForm.emails.value, permission: userForm.permission.value }) }); userForm.emails.value = ""; setDialogMessage(result.confirmation_required ? "Invitation sent. Access will appear after the recipient accepts it." : "Access granted.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } }); linkForm.addEventListener("submit", async event => { event.preventDefault(); try { const expires_at = shareExpiry(linkForm.hours.value, linkForm.forever.checked); const result = await api("/api/auth/resources/share-links", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, permission: linkForm.permission.value, expires_at }) }); const absolute = new URL(result.url, location.origin).href; await copyText(absolute); setDialogMessage("Link created and copied. It remains visible below.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } }); dialog.showModal(); try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } @@ -232,6 +236,7 @@ async function loadResources() { function renderAccount(session) { + currentSession = session; guestAccount.hidden = Boolean(session); userAccount.hidden = !session; if (session) userLabel.textContent = `Signed in as ${session.nickname}`; @@ -252,6 +257,43 @@ if (identityDialog) { authDialog.setMode("register"); identityDialog.showModal(); }); + + document.querySelector("#footer-profile")?.addEventListener("click", () => { + document.querySelector("#profile-nickname").value = currentSession?.nickname || ""; + document.querySelector("#profile-email").value = ""; + document.querySelector("#profile-new-password").value = ""; + document.querySelector("#profile-password").value = ""; + const profileMessage = document.querySelector("#profile-message"); + profileMessage.textContent = ""; + profileMessage.classList.remove("success", "error"); + const directoryManaged = Boolean(currentSession?.directory_managed); + document.querySelector("#profile-copy").textContent = directoryManaged + ? "Directory account details are read-only. You can change only the displayed nickname." + : "Manage your local RustPad account."; + document.querySelectorAll("[data-local-profile-field]").forEach(element => { element.hidden = directoryManaged; }); + document.querySelectorAll("[data-directory-profile-field]").forEach(element => { element.hidden = !directoryManaged; }); + document.querySelector("#profile-directory-name").value = currentSession?.directory_display_name || ""; + document.querySelector("#profile-directory-organization").value = currentSession?.directory_organization || ""; + const suggestion = currentSession?.suggested_nickname; + const suggestionElement = document.querySelector("#profile-nickname-suggestion"); + suggestionElement.textContent = suggestion ? `Suggested nickname: ${suggestion}` : "No automatic nickname suggestion is available."; + document.querySelector("#profile-password").required = !directoryManaged; + profileDialog.showModal(); + }); + document.querySelector("#close-profile")?.addEventListener("click", () => profileDialog.close()); + profileDialog?.addEventListener("click", event => { if (event.target === profileDialog) profileDialog.close(); }); + profileForm?.addEventListener("submit", async event => { + event.preventDefault(); const message=document.querySelector("#profile-message"); message.textContent=""; message.classList.remove("success", "error"); + try { const result=await api("/api/auth/profile",{method:"POST",headers:authHeaders(),body:JSON.stringify({nickname:document.querySelector("#profile-nickname").value.trim(),new_email:currentSession?.directory_managed?null:(document.querySelector("#profile-email").value.trim()||null),new_password:currentSession?.directory_managed?null:(document.querySelector("#profile-new-password").value||null),password:currentSession?.directory_managed?"":document.querySelector("#profile-password").value})}); message.textContent=result.message; message.classList.add("success"); currentSession.nickname=result.nickname; renderAccount(currentSession); } catch(e){ message.textContent=e.message; message.classList.add("error"); } + }); + document.querySelector("#profile-delete")?.addEventListener("click", async () => { + const message=document.querySelector("#profile-message"); const password=document.querySelector("#profile-password").value; + message.classList.remove("success", "error"); + if (!password) { message.textContent="Enter the current password first."; message.classList.add("error"); return; } + if (!confirm("Send an e-mail link to permanently delete this account?")) return; + try { const result=await api("/api/auth/account/delete",{method:"POST",headers:authHeaders(),body:JSON.stringify({password})}); message.textContent=result.message; message.classList.add("success"); } catch(e){ message.textContent=e.message; message.classList.add("error"); } + }); + document.querySelector("#footer-resources")?.addEventListener("click", async () => { resourcesDialog.showModal(); await loadResources(); }); document.querySelector("#close-resources")?.addEventListener("click", () => resourcesDialog.close()); resourcesDialog?.addEventListener("click", (event) => { if (event.target === resourcesDialog) resourcesDialog.close(); }); diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 65f8e66..984f7f1 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -14,7 +14,7 @@ import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url export function startNoteEditor(adapter) { const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer"); const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog"); - const roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"); + const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"); let unreadChat = 0; const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"); const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken); @@ -176,7 +176,7 @@ export function startNoteEditor(adapter) { editor, toast, getAccessToken: () => accessToken, canDelete: () => Boolean(info?.can_delete_files), endpoints: adapter.fileEndpoints, }); - function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { if (passwordDialog.open) passwordDialog.close(); applyRemote(m.content, m.owner_map); editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { document.querySelector("#password-error").textContent = m; if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); } + function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { if (passwordDialog.open) passwordDialog.close(); const readOnly = m.access_level === "read_only"; editor.readOnly = readOnly; accessLevel.textContent = readOnly ? "Access: read only" : "Access: full"; applyRemote(m.content, m.owner_map); if (!readOnly) editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { const friendly = /read-only access/i.test(m) ? "This note is read only. Enter the password or ask the owner to grant write access." : m; document.querySelector("#password-error").textContent = friendly; if (/read-only access/i.test(m)) { toast(friendly); accessLevel.textContent = "Access: read only"; editor.readOnly = true; return; } if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); } bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } }); identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); }); async function initialize() { diff --git a/static/note.html b/static/note.html index c8c9d20..ad94fd4 100644 --- a/static/note.html +++ b/static/note.html @@ -131,7 +131,7 @@ · · Access: checking… · · Changes are saved automatically diff --git a/static/pad.html b/static/pad.html index 4d6117b..c447612 100644 --- a/static/pad.html +++ b/static/pad.html @@ -129,7 +129,7 @@ · · Access: checking… · · Changes are saved automatically diff --git a/systemd/README.md b/systemd/README.md new file mode 100644 index 0000000..78ae5d3 --- /dev/null +++ b/systemd/README.md @@ -0,0 +1,10 @@ +# systemd installation + +1. Install the application files under `/opt/rustpad` and the binary as `/usr/local/bin/rustpad`. +2. Create the service account: `useradd --system --home /var/lib/rustpad --shell /usr/sbin/nologin rustpad`. +3. Copy `rustpad.yaml` to `/etc/rustpad/rustpad.yaml` and restrict secrets: `chmod 640 /etc/rustpad/rustpad.yaml`. +4. Copy `rustpad.service` to `/etc/systemd/system/rustpad.service`. +5. Validate before starting: `/usr/local/bin/rustpad --config /etc/rustpad/rustpad.yaml check-config`. +6. Run `systemctl daemon-reload && systemctl enable --now rustpad`. + +Environment variables and an optional `.env` file override the YAML values. This allows secrets to be supplied by the service manager without editing the main configuration file. diff --git a/systemd/rustpad.service b/systemd/rustpad.service new file mode 100644 index 0000000..be324da --- /dev/null +++ b/systemd/rustpad.service @@ -0,0 +1,39 @@ +[Unit] +Description=RustPad collaborative notepad +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=rustpad +Group=rustpad +WorkingDirectory=/opt/rustpad +ExecStartPre=/usr/local/bin/rustpad --config /etc/rustpad/rustpad.yaml check-config +ExecStart=/usr/local/bin/rustpad --config /etc/rustpad/rustpad.yaml +Restart=on-failure +RestartSec=5s +TimeoutStopSec=30s +KillSignal=SIGTERM + +StateDirectory=rustpad +StateDirectoryMode=0750 +UMask=0027 +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +RestrictSUIDSGID=true +RestrictRealtime=true +LockPersonality=true +MemoryDenyWriteExecute=true +CapabilityBoundingSet= +AmbientCapabilities= +#ReadWritePaths=/var/lib/rustpad + +[Install] +WantedBy=multi-user.target diff --git a/systemd/rustpad.yaml b/systemd/rustpad.yaml new file mode 100644 index 0000000..57ed796 --- /dev/null +++ b/systemd/rustpad.yaml @@ -0,0 +1,63 @@ +# Environment variables override values from this file. +app: + host: 127.0.0.1 + port: 3000 + +database: + url: "sqlite:///var/lib/rustpad/db/rustpad.db?mode=rwc" + max_connections: 8 + +static_dir: /opt/rustpad/static +files_dir: /var/lib/rustpad/files +storage_driver: local +upload_max_size_mb: 20 +asset_cache_max_age_seconds: 600 +file_cache_max_age_seconds: 600 + +registration_enabled: false +account_confirmation_required: false +share_confirmation_required: false +frontend_log_level: warn +anonymous_access_token_ttl_days: 7 +user_session_ttl_days: 3 +unconfirmed_account_ttl_days: 3 + +authorization: + type: local + +# Uncomment for LDAP/AD and set authorization.type to ldap or ad. +# ldap: +# url: "ldaps://ldap.example.org:636" +# starttls: false +# bind_dn: "cn=rustpad,ou=services,dc=example,dc=org" +# bind_password: "change-me" +# base_dn: "ou=people,dc=example,dc=org" +# user_filter: "(uid={username})" +# username_attribute: uid +# email_attribute: mail +# display_name_attribute: displayName +# external_id_attribute: entryUUID +# organization: example +# email_required: true +# link_existing_by_email: false +# tls_verify: true +# connect_timeout_seconds: 5 +# operation_timeout_seconds: 10 + +# Uncomment to enable SMTP. +# smtp: +# host: smtp.example.org +# port: 587 +# username: rustpad +# password: "change-me" +# from: "RustPad " +# public_url: "https://pad.example.org" + +# For S3, set storage_driver: s3 and configure: +# s3: +# endpoint: "https://s3.example.org" +# region: eu-central-1 +# bucket: rustpad +# access_key: "change-me" +# secret_key: "change-me" +# force_path_style: true