From 28570069498ec122fdfa7d6db7ba8eacd264f6dc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?=
Date: Sun, 26 Jul 2026 23:32:44 +0200
Subject: [PATCH 1/4] v0.1.0
---
.env.example | 6 +-
Cargo.lock | 2 +-
Cargo.toml | 2 +-
README.md | 21 +-
migrations/mysql/0014_account_profile.sql | 11 +
.../mysql/0015_directory_display_name.sql | 1 +
migrations/postgres/0014_account_profile.sql | 10 +
.../postgres/0015_directory_display_name.sql | 1 +
migrations/sqlite/0014_account_profile.sql | 10 +
.../sqlite/0015_directory_display_name.sql | 1 +
src/app.rs | 46 ++-
src/auth/ldap.rs | 22 +-
src/auth/mod.rs | 281 ++++++++++++++
src/config.rs | 363 ++++++++++++------
src/main.rs | 97 ++++-
src/queries.rs | 13 +-
src/state.rs | 3 +
src/websocket.rs | 4 +
static/css/styles.css | 13 +
static/home.html | 21 +
static/js/auth-ui.js | 21 +
static/js/home.js | 44 ++-
static/js/note-editor.js | 4 +-
static/note.html | 2 +-
static/pad.html | 2 +-
systemd/README.md | 10 +
systemd/rustpad.service | 39 ++
systemd/rustpad.yaml | 63 +++
28 files changed, 961 insertions(+), 152 deletions(-)
create mode 100644 migrations/mysql/0014_account_profile.sql
create mode 100644 migrations/mysql/0015_directory_display_name.sql
create mode 100644 migrations/postgres/0014_account_profile.sql
create mode 100644 migrations/postgres/0015_directory_display_name.sql
create mode 100644 migrations/sqlite/0014_account_profile.sql
create mode 100644 migrations/sqlite/0015_directory_display_name.sql
create mode 100644 systemd/README.md
create mode 100644 systemd/rustpad.service
create mode 100644 systemd/rustpad.yaml
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#""#,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 @@
+
+
+