V0.1.0 #3

Merged
gru merged 4 commits from v0.1.0 into master 2026-07-27 12:50:13 +02:00
28 changed files with 961 additions and 152 deletions
Showing only changes of commit 2857006949 - Show all commits
+3 -3
View File
@@ -20,10 +20,10 @@ DATABASE_MAX_CONNECTIONS=8
# Session lifetime in days # Session lifetime in days
# Anonymous pad/workspace access tokens # Anonymous pad/workspace access tokens
ANONYMOUS_ACCESS_TOKEN_TTL_DAYS=7 ANONYMOUS_ACCESS_TOKEN_TTL_DAYS=3
# Logged-in user sessions # Logged-in user sessions
USER_SESSION_TTL_DAYS=30 USER_SESSION_TTL_DAYS=3
UNCONFIRMED_ACCOUNT_TTL_DAYS=3
# Logging # Logging
# available: warn, debug, info # available: warn, debug, info
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.0.42" version = "0.0.43"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.0.42" version = "0.0.43"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+18 -3
View File
@@ -242,9 +242,24 @@ docker compose up -d
Test users: Test users:
- `mateusz` / `test1234` - `admin` / `test1234`
- `anna` / `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` 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/`.
+11
View File
@@ -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);
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN directory_display_name VARCHAR(255);
@@ -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);
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN directory_display_name TEXT;
@@ -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);
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN directory_display_name TEXT;
+45 -1
View File
@@ -1,7 +1,7 @@
use axum::{ use axum::{
Router, Router,
extract::{DefaultBodyLimit, Path, State}, extract::{DefaultBodyLimit, Path, State},
http::{HeaderValue, StatusCode, header}, http::{HeaderName, HeaderValue, StatusCode, header},
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
routing::{get, post}, routing::{get, post},
}; };
@@ -46,6 +46,7 @@ pub fn router(
.route("/errors/private-workspace", get(private_workspace_error)) .route("/errors/private-workspace", get(private_workspace_error))
.route("/health", get(health)) .route("/health", get(health))
.route("/robots.txt", get(robots_txt)) .route("/robots.txt", get(robots_txt))
.route("/favicon.ico", get(favicon))
.route("/f/{token}/{filename}", get(api::download_file)) .route("/f/{token}/{filename}", get(api::download_file))
.route( .route(
"/files/{directory}/{filename}", "/files/{directory}/{filename}",
@@ -56,7 +57,20 @@ pub fn router(
.route("/api/auth/register", post(auth::register)) .route("/api/auth/register", post(auth::register))
.route("/api/auth/login", post(auth::login)) .route("/api/auth/login", post(auth::login))
.route("/api/auth/confirm-account", post(auth::confirm_account)) .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/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/logout", post(auth::logout))
.route( .route(
"/api/auth/resources", "/api/auth/resources",
@@ -156,6 +170,32 @@ pub fn router(
.layer(DefaultBodyLimit::max( .layer(DefaultBodyLimit::max(
upload_max_size_bytes.saturating_add(1024 * 1024), 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()) .layer(TraceLayer::new_for_http())
.with_state(state) .with_state(state)
} }
@@ -176,6 +216,10 @@ async fn health() -> &'static str {
"ok" "ok"
} }
async fn favicon() -> StatusCode {
StatusCode::NO_CONTENT
}
async fn robots_txt() -> Response { async fn robots_txt() -> Response {
let mut response = "User-agent: *\nDisallow: /f/\nDisallow: /files/\n".into_response(); let mut response = "User-agent: *\nDisallow: /f/\nDisallow: /files/\n".into_response();
response.headers_mut().insert( response.headers_mut().insert(
+16 -6
View File
@@ -40,6 +40,7 @@ pub struct LdapIdentity {
pub username: String, pub username: String,
pub email: String, pub email: String,
pub nickname: String, pub nickname: String,
pub display_name: String,
pub provider: String, pub provider: String,
pub external_id: String, pub external_id: String,
pub external_dn: String, pub external_dn: String,
@@ -157,23 +158,30 @@ pub async fn authenticate(
} }
let _ = ldap.unbind().await; let _ = ldap.unbind().await;
let organization = config.organization.trim(); let nickname = directory_nickname(&display_name, &email, &username);
let nickname = if organization.is_empty() {
display_name
} else {
format!("{organization}/{display_name}")
};
Ok(Some(LdapIdentity { Ok(Some(LdapIdentity {
username, username,
email, email,
nickname, nickname,
display_name,
provider: config.provider.clone(), provider: config.provider.clone(),
external_id, external_id,
external_dn: user_dn, external_dn: user_dn,
})) }))
} }
fn directory_nickname(display_name: &str, email: &str, username: &str) -> String {
let words: Vec<&str> = display_name.split_whitespace().filter(|v| !v.is_empty()).collect();
let candidate = if words.len() >= 2 {
let first = words[0].chars().next().unwrap_or('u');
format!("{}.{}", first, words[words.len() - 1])
} else {
email.split('@').next().filter(|v| !v.is_empty()).unwrap_or(username).to_owned()
};
candidate.to_lowercase()
}
fn first_attr(entry: &SearchEntry, name: &str) -> Option<String> { fn first_attr(entry: &SearchEntry, name: &str) -> Option<String> {
entry entry
.attrs .attrs
@@ -279,6 +287,7 @@ async fn provision_ldap_user(
.bind(&identity.provider) .bind(&identity.provider)
.bind(&identity.external_id) .bind(&identity.external_id)
.bind(&identity.external_dn) .bind(&identity.external_dn)
.bind(&identity.display_name)
.execute(state.db.pool()) .execute(state.db.pool())
.await .await
.map_err(AuthError::database)?; .map_err(AuthError::database)?;
@@ -306,6 +315,7 @@ async fn sync_directory_user(
.bind(&identity.provider) .bind(&identity.provider)
.bind(&identity.external_id) .bind(&identity.external_id)
.bind(&identity.external_dn) .bind(&identity.external_dn)
.bind(&identity.display_name)
.bind(Utc::now().to_rfc3339()) .bind(Utc::now().to_rfc3339())
.bind(user.id) .bind(user.id)
.execute(state.db.pool()) .execute(state.db.pool())
+281
View File
@@ -63,6 +63,10 @@ pub struct ConfirmAccountRequest {
token: String, token: String,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ResendConfirmationRequest {
email: String,
}
#[derive(Deserialize)]
pub struct ResetRequest { pub struct ResetRequest {
email: String, email: String,
} }
@@ -72,6 +76,17 @@ pub struct ResetConfirmRequest {
password: String, password: String,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ProfileUpdateRequest {
#[serde(default)] nickname: Option<String>,
#[serde(default)] new_email: Option<String>,
#[serde(default)] new_password: Option<String>,
#[serde(default)] password: String,
}
#[derive(Deserialize)]
pub struct DeleteAccountRequest { password: String }
#[derive(Deserialize)]
pub struct AccountActionConfirmRequest { token: String }
#[derive(Deserialize)]
pub struct ResourceActionRequest { pub struct ResourceActionRequest {
kind: String, kind: String,
slug: String, slug: String,
@@ -144,6 +159,10 @@ pub struct SessionResponse {
nickname: String, nickname: String,
email: String, email: String,
expires_at: String, expires_at: String,
directory_managed: bool,
directory_display_name: Option<String>,
directory_organization: Option<String>,
suggested_nickname: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct IdentityResponse { pub struct IdentityResponse {
@@ -348,6 +367,46 @@ pub async fn login(
} }
} }
pub async fn resend_confirmation(
State(state): State<SharedState>,
Json(req): Json<ResendConfirmationRequest>,
) -> Result<Json<serde_json::Value>, AuthError> {
if !state.account_confirmation_required {
return Err(AuthError::bad_request("Account confirmation is not enabled."));
}
let smtp = state.smtp.as_ref().ok_or_else(|| AuthError::service_unavailable("SMTP is not configured."))?;
let email = validate_email(&req.email)?;
let user = find_user_by_email(&state, &email).await?
.ok_or_else(|| AuthError::bad_request("No unconfirmed account exists for this e-mail address."))?;
if user.confirmed_at.is_some() {
return Err(AuthError::bad_request("This account is already confirmed."));
}
let last_created: Option<String> = sqlx::query_scalar(queries::get(
state.db.kind(),
queries::AUTH_LATEST_CONFIRMATION_CREATED_AT,
))
.bind(user.id).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
if let Some(value) = last_created {
if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&value) {
let available = created.with_timezone(&Utc) + Duration::minutes(10);
if available > Utc::now() {
let seconds = (available - Utc::now()).num_seconds().max(1);
return Err(AuthError::bad_request(&format!("A new confirmation e-mail can be sent in {} minute(s).", (seconds + 59) / 60)));
}
}
}
sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER))
.bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
let token = random_confirmation_token();
let token_hash = hash_token(&token);
let expires_at = (Utc::now() + Duration::hours(24)).to_rfc3339();
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_CONFIRMATION_TOKEN))
.bind(token_hash).bind(user.id).bind(expires_at).execute(state.db.pool()).await.map_err(AuthError::database)?;
send_registration_email(smtp, &user, Some(&token)).await?;
Ok(Json(serde_json::json!({"ok":true,"message":"A new confirmation e-mail has been sent."})))
}
pub async fn confirm_account( pub async fn confirm_account(
State(state): State<SharedState>, State(state): State<SharedState>,
Json(req): Json<ConfirmAccountRequest>, Json(req): Json<ConfirmAccountRequest>,
@@ -415,6 +474,53 @@ pub async fn confirm_account(
)) ))
} }
async fn directory_profile_metadata(
state: &SharedState,
user: &User,
) -> Result<(bool, Option<String>, Option<String>, Option<String>), AuthError> {
let row: Option<(String, Option<String>)> = sqlx::query_as(queries::get(
state.db.kind(),
queries::AUTH_DIRECTORY_PROFILE_BY_USER,
))
.bind(user.id)
.fetch_optional(state.db.pool())
.await
.map_err(AuthError::database)?;
let Some((provider, display_name)) = row else {
return Ok((false, None, None, None));
};
if provider == "local" {
return Ok((false, None, None, None));
}
let display_name = display_name.filter(|value| !value.trim().is_empty());
let organization = state
.ldap
.as_ref()
.map(|config| config.organization.trim().to_owned())
.filter(|value| !value.is_empty());
let suggested = suggested_directory_nickname(display_name.as_deref(), &user.email);
Ok((true, display_name, organization, suggested))
}
fn suggested_directory_nickname(display_name: Option<&str>, email: &str) -> Option<String> {
if let Some(display_name) = display_name {
let words: Vec<&str> = display_name.split_whitespace().filter(|word| !word.is_empty()).collect();
if words.len() >= 2 {
let first = words.first().copied().unwrap_or_default();
let last = words.last().copied().unwrap_or_default();
let candidate = format!("{}.{}", first, last).to_lowercase();
if let Ok(value) = validate_nickname(&candidate) {
return Some(value);
}
}
}
email
.split('@')
.next()
.filter(|value| !value.trim().is_empty())
.and_then(|value| validate_nickname(&value.to_lowercase()).ok())
}
pub async fn me( pub async fn me(
State(state): State<SharedState>, State(state): State<SharedState>,
headers: HeaderMap, headers: HeaderMap,
@@ -432,14 +538,179 @@ pub async fn me(
.fetch_one(state.db.pool()) .fetch_one(state.db.pool())
.await .await
.map_err(AuthError::database)?; .map_err(AuthError::database)?;
let (directory_managed, directory_display_name, directory_organization, suggested_nickname) =
directory_profile_metadata(&state, &user).await?;
Ok(Json(SessionResponse { Ok(Json(SessionResponse {
token: token.into(), token: token.into(),
nickname: user.nickname, nickname: user.nickname,
email: user.email, email: user.email,
expires_at, expires_at,
directory_managed,
directory_display_name,
directory_organization,
suggested_nickname,
})) }))
} }
pub async fn update_profile(
State(state): State<SharedState>,
headers: HeaderMap,
Json(req): Json<ProfileUpdateRequest>,
) -> Result<Json<serde_json::Value>, AuthError> {
let user = require_user(&state, &headers).await?;
let (directory_managed, _, _, _) = directory_profile_metadata(&state, &user).await?;
if directory_managed {
if req
.new_email
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
|| req
.new_password
.as_deref()
.is_some_and(|value| !value.is_empty())
{
return Err(AuthError::forbidden(
"E-mail and password are managed by LDAP/AD.",
));
}
} else if !verify_password(&user.password_hash, &req.password) {
return Err(AuthError::unauthorized(
"The current password is incorrect.",
));
}
let mut nickname = user.nickname.clone();
if let Some(value) = req.nickname.as_deref() {
nickname = validate_nickname(value)?;
if normalize(&nickname) != normalize(&user.nickname)
&& find_user_by_nickname(&state, &nickname).await?.is_some()
{
return Err(AuthError::conflict(
"This nickname is already registered.",
));
}
sqlx::query(queries::get(
state.db.kind(),
queries::AUTH_UPDATE_NICKNAME,
))
.bind(&nickname)
.bind(normalize(&nickname))
.bind(Utc::now().to_rfc3339())
.bind(user.id)
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
}
let mut email_pending = false;
if !directory_managed {
if let Some(value) = req.new_password.as_deref().filter(|value| !value.is_empty()) {
validate_password(value)?;
let hash = hash_password(value)?;
sqlx::query(queries::get(
state.db.kind(),
queries::AUTH_UPDATE_PASSWORD,
))
.bind(hash)
.bind(Utc::now().to_rfc3339())
.bind(user.id)
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
}
if let Some(value) = req
.new_email
.as_deref()
.filter(|value| !value.trim().is_empty())
{
let email = validate_email(value)?;
if normalize(&email) != normalize(&user.email) {
if find_user_by_email(&state, &email).await?.is_some() {
return Err(AuthError::conflict(
"This e-mail address is already registered.",
));
}
let smtp = state.smtp.as_ref().ok_or_else(|| {
AuthError::service_unavailable("SMTP is not configured.")
})?;
create_account_action(&state, &user, "email", Some(&email), smtp).await?;
email_pending = true;
}
}
}
Ok(Json(serde_json::json!({
"ok": true,
"nickname": nickname,
"email_pending": email_pending,
"message": if email_pending {
"Profile updated. Confirm the new e-mail address using the link sent to it."
} else {
"Profile updated."
}
})))
}
pub async fn request_account_deletion(
State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<DeleteAccountRequest>,
) -> Result<Json<serde_json::Value>, AuthError> {
if state.ldap.is_some() { return Err(AuthError::forbidden("LDAP accounts cannot be deleted here.")); }
let user = require_user(&state, &headers).await?;
if !verify_password(&user.password_hash, &req.password) { return Err(AuthError::unauthorized("The current password is incorrect.")); }
let smtp = state.smtp.as_ref().ok_or_else(|| AuthError::service_unavailable("SMTP is not configured."))?;
create_account_action(&state, &user, "delete", None, smtp).await?;
Ok(Json(serde_json::json!({"ok":true,"message":"A confirmation link has been sent to your e-mail address."})))
}
pub async fn confirm_account_action(
State(state): State<SharedState>, Json(req): Json<AccountActionConfirmRequest>,
) -> Result<Json<serde_json::Value>, AuthError> {
let now = Utc::now(); let hash = hash_token(req.token.trim());
let row: Option<(i64,String,Option<String>,String,Option<String>)> = sqlx::query_as(queries::get(state.db.kind(), queries::AUTH_ACCOUNT_ACTION_BY_TOKEN))
.bind(&hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
let (user_id, action, payload, expires_at, used_at) = row.ok_or_else(|| AuthError::bad_request("The confirmation link is invalid or has expired."))?;
let expires = chrono::DateTime::parse_from_rfc3339(&expires_at).map_err(|_| AuthError::bad_request("The confirmation link is invalid or has expired."))?.with_timezone(&Utc);
if used_at.is_some() || expires <= now { return Err(AuthError::bad_request("The confirmation link is invalid or has expired.")); }
let mut tx=state.db.pool().begin().await.map_err(AuthError::database)?;
sqlx::query(queries::get(state.db.kind(), queries::AUTH_CONSUME_ACCOUNT_ACTION))
.bind(now.to_rfc3339()).bind(&hash).execute(&mut *tx).await.map_err(AuthError::database)?;
let message = if action == "email" {
let email=payload.ok_or_else(|| AuthError::internal("Missing e-mail change payload."))?;
if find_user_by_email(&state,&email).await?.is_some() { return Err(AuthError::conflict("This e-mail address is already registered.")); }
sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_EMAIL))
.bind(&email).bind(normalize(&email)).bind(now.to_rfc3339()).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?;
"E-mail address changed."
} else if action == "delete" {
sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_USER)).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?;
"Account deleted."
} else { return Err(AuthError::bad_request("Unknown account action.")); };
tx.commit().await.map_err(AuthError::database)?;
Ok(Json(serde_json::json!({"ok":true,"message":message})))
}
async fn create_account_action(state:&SharedState,user:&User,action:&str,payload:Option<&str>,smtp:&SmtpConfig)->Result<(),AuthError>{
sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_ACCOUNT_ACTIONS))
.bind(user.id).bind(action).execute(state.db.pool()).await.map_err(AuthError::database)?;
let token=random_token(); let expires=(Utc::now()+Duration::hours(1)).to_rfc3339();
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_ACCOUNT_ACTION))
.bind(hash_token(&token)).bind(user.id).bind(action).bind(payload).bind(expires).bind(Utc::now().to_rfc3339()).execute(state.db.pool()).await.map_err(AuthError::database)?;
send_account_action(smtp,user,action,payload,&token).await
}
async fn send_account_action(smtp:&SmtpConfig,user:&User,action:&str,payload:Option<&str>,token:&str)->Result<(),AuthError>{
let site=smtp.public_url.trim_end_matches('/'); let url=format!("{site}/?account_action_token={token}");
let sender=smtp.from.parse::<Mailbox>().map_err(|_|AuthError::internal("Invalid SMTP_FROM."))?;
let target=if action=="email" { payload.unwrap_or(&user.email) } else { &user.email };
let recipient=target.parse::<Mailbox>().map_err(|_|AuthError::internal("Invalid recipient address."))?;
let (subject,title,copy)=if action=="email" {("Confirm your new RustPad e-mail","Confirm e-mail change","Confirm the new e-mail address within one hour.")} else {("Confirm RustPad account deletion","Confirm account deletion","Confirm permanent account deletion within one hour.")};
let text=format!("Hello {},\n\n{}\n{}\n\nIf you did not request this, ignore this message.",user.nickname,copy,url);
let html=format!(r#"<!doctype html><html lang="en"><body style="margin:0;padding:24px;background:#f4f4f5;font-family:Arial,sans-serif;color:#18181b"><div style="max-width:560px;margin:0 auto;padding:24px;background:#fff;border-radius:10px"><h1 style="margin-top:0;font-size:22px">{}</h1><p>Hello {},</p><p>{}</p><p><a href="{}" style="display:inline-block;padding:11px 18px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px">Confirm action</a></p><p style="font-size:13px;overflow-wrap:anywhere"><a href="{}">{}</a></p><p>If you did not request this, ignore this message.</p></div></body></html>"#,title,user.nickname,copy,url,url,url);
let message=Message::builder().from(sender).to(recipient).subject(subject).multipart(MultiPart::alternative().singlepart(SinglePart::builder().header(ContentType::TEXT_PLAIN).body(text)).singlepart(SinglePart::builder().header(ContentType::TEXT_HTML).body(html))).map_err(|_|AuthError::internal("Failed to build account confirmation e-mail."))?;
send_message(smtp,message,"account action e-mail").await
}
pub async fn resources( pub async fn resources(
State(state): State<SharedState>, State(state): State<SharedState>,
headers: HeaderMap, headers: HeaderMap,
@@ -668,6 +939,10 @@ pub async fn share_resource_users(
missing.push(email); missing.push(email);
continue; continue;
}; };
if user.confirmed_at.is_none() {
missing.push(format!("{} (account not activated)", email));
continue;
}
if user.id == owner.id { if user.id == owner.id {
continue; continue;
} }
@@ -1276,11 +1551,17 @@ async fn create_session(state: &SharedState, user: &User) -> Result<SessionRespo
.await .await
.map_err(AuthError::database)?; .map_err(AuthError::database)?;
debug!(user_id = user.id, expires_at = %expires_at, "authentication session created"); debug!(user_id = user.id, expires_at = %expires_at, "authentication session created");
let (directory_managed, directory_display_name, directory_organization, suggested_nickname) =
directory_profile_metadata(state, user).await?;
Ok(SessionResponse { Ok(SessionResponse {
token, token,
nickname: user.nickname.clone(), nickname: user.nickname.clone(),
email: user.email.clone(), email: user.email.clone(),
expires_at, expires_at,
directory_managed,
directory_display_name,
directory_organization,
suggested_nickname,
}) })
} }
async fn find_user_by_nickname( async fn find_user_by_nickname(
+223 -116
View File
@@ -1,4 +1,4 @@
use std::{env, net::IpAddr}; use std::{collections::HashMap, env, net::IpAddr, path::Path};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthorizationType { pub enum AuthorizationType {
@@ -8,12 +8,8 @@ pub enum AuthorizationType {
} }
impl AuthorizationType { impl AuthorizationType {
pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> { fn from_values(values: &ConfigValues) -> Result<Self, Box<dyn std::error::Error>> {
match env_var("AUTHORIZATION_TYPE", "local") match values.get("AUTHORIZATION_TYPE", "local").trim().to_ascii_lowercase().as_str() {
.trim()
.to_ascii_lowercase()
.as_str()
{
"local" => Ok(Self::Local), "local" => Ok(Self::Local),
"ldap" => Ok(Self::Ldap), "ldap" => Ok(Self::Ldap),
"ad" => Ok(Self::Ad), "ad" => Ok(Self::Ad),
@@ -50,36 +46,32 @@ pub struct Config {
pub frontend_log_level: String, pub frontend_log_level: String,
pub anonymous_access_token_ttl_days: i64, pub anonymous_access_token_ttl_days: i64,
pub user_session_ttl_days: i64, pub user_session_ttl_days: i64,
pub unconfirmed_account_ttl_days: i64,
pub authorization_type: AuthorizationType, pub authorization_type: AuthorizationType,
pub ldap: Option<crate::auth::ldap::LdapConfig>, pub ldap: Option<crate::auth::ldap::LdapConfig>,
} }
impl Config { impl Config {
pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> { pub fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
let host = env_var("APP_HOST", "127.0.0.1").parse()?; let values = ConfigValues::load(path)?;
let port = env_var("APP_PORT", "3000").parse()?; let host = values.get("APP_HOST", "127.0.0.1").parse()?;
let database_max_connections = env_var("DATABASE_MAX_CONNECTIONS", "8").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 storage = match values.get("STORAGE_DRIVER", "local").trim().to_ascii_lowercase().as_str() {
let anonymous_access_token_ttl_days = "local" => crate::storage::StorageConfig::Local { root: files_dir.clone().into() },
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(),
},
"s3" => crate::storage::StorageConfig::S3 { "s3" => crate::storage::StorageConfig::S3 {
endpoint: env::var("S3_ENDPOINT").ok(), endpoint: values.optional("S3_ENDPOINT"),
region: env_var("S3_REGION", "us-east-1"), region: values.get("S3_REGION", "us-east-1"),
bucket: required_env("S3_BUCKET")?, bucket: values.required("S3_BUCKET", "STORAGE_DRIVER=s3")?,
access_key: required_env("S3_ACCESS_KEY")?, access_key: values.required("S3_ACCESS_KEY", "STORAGE_DRIVER=s3")?,
secret_key: required_env("S3_SECRET_KEY")?, secret_key: values.required("S3_SECRET_KEY", "STORAGE_DRIVER=s3")?,
force_path_style: env_bool("S3_FORCE_PATH_STYLE", false)?, force_path_style: values.bool("S3_FORCE_PATH_STYLE", false)?,
}, },
_ => return Err("STORAGE_DRIVER must be local or s3".into()), _ => 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()); 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 { let ldap = match authorization_type {
AuthorizationType::Local => None, AuthorizationType::Local => None,
AuthorizationType::Ldap | AuthorizationType::Ad => { AuthorizationType::Ldap | AuthorizationType::Ad => {
let context = format!("AUTHORIZATION_TYPE={}", authorization_type.as_str()); let context = format!("AUTHORIZATION_TYPE={}", authorization_type.as_str());
let (default_filter, default_username_attribute) = match authorization_type { let (default_filter, default_username_attribute) = match authorization_type {
AuthorizationType::Ldap => ("(uid={username})", "uid"), AuthorizationType::Ldap => ("(uid={username})", "uid"),
AuthorizationType::Ad => ( AuthorizationType::Ad => ("(|(sAMAccountName={username})(userPrincipalName={username}))", "sAMAccountName"),
"(|(sAMAccountName={username})(userPrincipalName={username}))",
"sAMAccountName",
),
AuthorizationType::Local => unreachable!(), AuthorizationType::Local => unreachable!(),
}; };
Some(crate::auth::ldap::LdapConfig { Some(crate::auth::ldap::LdapConfig {
url: required_nonempty_env("LDAP_URL", &context)?, url: values.required("LDAP_URL", &context)?,
starttls: env_bool("LDAP_STARTTLS", false)?, starttls: values.bool("LDAP_STARTTLS", false)?,
bind_dn: env::var("LDAP_BIND_DN").unwrap_or_default(), bind_dn: values.get("LDAP_BIND_DN", ""),
bind_password: env::var("LDAP_BIND_PASSWORD").unwrap_or_default(), bind_password: values.get("LDAP_BIND_PASSWORD", ""),
base_dn: required_nonempty_env("LDAP_BASE_DN", &context)?, base_dn: values.required("LDAP_BASE_DN", &context)?,
user_filter: env_var("LDAP_USER_FILTER", default_filter), user_filter: values.get("LDAP_USER_FILTER", default_filter),
username_attribute: env_var( username_attribute: values.get("LDAP_USERNAME_ATTRIBUTE", default_username_attribute),
"LDAP_USERNAME_ATTRIBUTE", email_attribute: values.get("LDAP_EMAIL_ATTRIBUTE", "mail"),
default_username_attribute, display_name_attribute: values.get("LDAP_DISPLAY_NAME_ATTRIBUTE", "displayName"),
), external_id_attribute: values.get("LDAP_EXTERNAL_ID_ATTRIBUTE", match authorization_type {
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::Ldap => "entryUUID",
AuthorizationType::Ad => "objectGUID", AuthorizationType::Ad => "objectGUID",
AuthorizationType::Local => unreachable!(), AuthorizationType::Local => unreachable!(),
}, }),
), organization: values.get("LDAP_ORGANIZATION", "organization"),
organization: env_var("LDAP_ORGANIZATION", "organization"),
provider: authorization_type.as_str().to_owned(), provider: authorization_type.as_str().to_owned(),
email_required: env_bool("LDAP_EMAIL_REQUIRED", true)?, email_required: values.bool("LDAP_EMAIL_REQUIRED", true)?,
link_existing_by_email: env_bool("LDAP_LINK_EXISTING_BY_EMAIL", false)?, link_existing_by_email: values.bool("LDAP_LINK_EXISTING_BY_EMAIL", false)?,
tls_verify: env_bool("LDAP_TLS_VERIFY", true)?, tls_verify: values.bool("LDAP_TLS_VERIFY", true)?,
connect_timeout_seconds: env_positive_u64("LDAP_CONNECT_TIMEOUT_SECONDS", 5)?, connect_timeout_seconds: values.positive_u64("LDAP_CONNECT_TIMEOUT_SECONDS", 5)?,
operation_timeout_seconds: env_positive_u64( operation_timeout_seconds: values.positive_u64("LDAP_OPERATION_TIMEOUT_SECONDS", 10)?,
"LDAP_OPERATION_TIMEOUT_SECONDS",
10,
)?,
}) })
} }
}; };
let smtp_host = std::env::var("SMTP_HOST") let smtp = if let Some(host) = values.optional("SMTP_HOST") {
.ok()
.filter(|v| !v.trim().is_empty());
let smtp = if let Some(host) = smtp_host {
Some(crate::state::SmtpConfig { Some(crate::state::SmtpConfig {
host, host,
port: env_var("SMTP_PORT", "587").parse()?, port: values.get("SMTP_PORT", "587").parse()?,
username: std::env::var("SMTP_USERNAME").unwrap_or_default(), username: values.get("SMTP_USERNAME", ""),
password: std::env::var("SMTP_PASSWORD").unwrap_or_default(), password: values.get("SMTP_PASSWORD", ""),
from: std::env::var("SMTP_FROM") from: values.required("SMTP_FROM", "SMTP_HOST is set")?,
.map_err(|_| "SMTP_FROM is required when SMTP_HOST is set")?, public_url: values.required("PUBLIC_URL", "SMTP_HOST is set")?,
public_url: std::env::var("PUBLIC_URL")
.map_err(|_| "PUBLIC_URL is required when SMTP_HOST is set")?,
}) })
} else { } else {
None None
}; };
Ok(Self { let config = Self {
host, host,
port, 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, database_max_connections,
static_dir: env_var("STATIC_DIR", "static"), static_dir: values.get("STATIC_DIR", "static"),
files_dir, files_dir,
storage, storage,
upload_max_size_bytes: upload_max_size_mb upload_max_size_bytes: upload_max_size_mb.checked_mul(1024 * 1024).ok_or("UPLOAD_MAX_SIZE_MB is too large")?,
.checked_mul(1024 * 1024)
.ok_or("UPLOAD_MAX_SIZE_MB is too large")?,
asset_version: env!("CARGO_PKG_VERSION").to_owned(), asset_version: env!("CARGO_PKG_VERSION").to_owned(),
asset_cache_max_age_seconds: env_nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?, asset_cache_max_age_seconds: values.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
file_cache_max_age_seconds: env_nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?, file_cache_max_age_seconds: values.nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?,
smtp, smtp,
registration_enabled: env_bool("REGISTRATION_ENABLED", false)?, registration_enabled: values.bool("REGISTRATION_ENABLED", false)?,
account_confirmation_required: env_bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?, account_confirmation_required: values.bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?,
share_confirmation_required: env_bool("SHARE_CONFIRMATION_REQUIRED", false)?, share_confirmation_required: values.bool("SHARE_CONFIRMATION_REQUIRED", false)?,
frontend_log_level: env_log_level("FRONTEND_LOG_LEVEL", "warn")?, frontend_log_level: values.log_level("FRONTEND_LOG_LEVEL", "warn")?,
anonymous_access_token_ttl_days, anonymous_access_token_ttl_days,
user_session_ttl_days, user_session_ttl_days,
unconfirmed_account_ttl_days,
authorization_type, authorization_type,
ldap, ldap,
}) };
config.validate()?;
Ok(config)
}
fn validate(&self) -> Result<(), Box<dyn std::error::Error>> {
if !(self.database_url.starts_with("sqlite:")
|| self.database_url.starts_with("postgres:")
|| self.database_url.starts_with("postgresql:")
|| self.database_url.starts_with("mysql:"))
{
return Err("DATABASE_URL must use sqlite, postgres/postgresql, or mysql".into());
}
if self.database_max_connections == 0 {
return Err("DATABASE_MAX_CONNECTIONS must be greater than 0".into());
}
if self.static_dir.trim().is_empty() || self.files_dir.trim().is_empty() {
return Err("STATIC_DIR and FILES_DIR cannot be empty".into());
}
if let Some(smtp) = &self.smtp {
if !(smtp.public_url.starts_with("http://") || smtp.public_url.starts_with("https://")) {
return Err("PUBLIC_URL must start with http:// or https://".into());
}
}
Ok(())
} }
} }
fn env_var(name: &str, default: &str) -> String {
env::var(name).unwrap_or_else(|_| default.to_owned()) const KNOWN_CONFIG_KEYS: &[&str] = &[
"APP_HOST", "APP_PORT", "DATABASE_URL", "DATABASE_MAX_CONNECTIONS",
"STATIC_DIR", "FILES_DIR", "STORAGE_DRIVER", "UPLOAD_MAX_SIZE_MB",
"ASSET_CACHE_MAX_AGE_SECONDS", "FILE_CACHE_MAX_AGE_SECONDS",
"REGISTRATION_ENABLED", "ACCOUNT_CONFIRMATION_REQUIRED", "SHARE_CONFIRMATION_REQUIRED",
"FRONTEND_LOG_LEVEL", "ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", "USER_SESSION_TTL_DAYS",
"UNCONFIRMED_ACCOUNT_TTL_DAYS", "AUTHORIZATION_TYPE",
"S3_ENDPOINT", "S3_REGION", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY",
"S3_FORCE_PATH_STYLE", "SMTP_HOST", "SMTP_PORT", "SMTP_USERNAME", "SMTP_PASSWORD",
"SMTP_FROM", "PUBLIC_URL", "LDAP_URL", "LDAP_STARTTLS", "LDAP_BIND_DN",
"LDAP_BIND_PASSWORD", "LDAP_BASE_DN", "LDAP_USER_FILTER", "LDAP_USERNAME_ATTRIBUTE",
"LDAP_EMAIL_ATTRIBUTE", "LDAP_DISPLAY_NAME_ATTRIBUTE", "LDAP_EXTERNAL_ID_ATTRIBUTE",
"LDAP_ORGANIZATION", "LDAP_EMAIL_REQUIRED", "LDAP_LINK_EXISTING_BY_EMAIL",
"LDAP_TLS_VERIFY", "LDAP_CONNECT_TIMEOUT_SECONDS", "LDAP_OPERATION_TIMEOUT_SECONDS",
];
#[derive(Default)]
struct ConfigValues {
file: HashMap<String, String>,
} }
fn env_bool(name: &str, default: bool) -> Result<bool, Box<dyn std::error::Error>> { impl ConfigValues {
match env::var(name) { fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
Ok(value) => match value.trim().to_ascii_lowercase().as_str() { let Some(path) = path else { return Ok(Self::default()); };
let content = std::fs::read_to_string(path)
.map_err(|error| format!("cannot read config file {}: {error}", path.display()))?;
let file = parse_yaml_config(&content)
.map_err(|error| format!("invalid YAML in {}: {error}", path.display()))?;
for key in file.keys() {
if !KNOWN_CONFIG_KEYS.contains(&key.as_str()) {
return Err(format!("unknown configuration key: {key}").into());
}
}
Ok(Self { file })
}
fn get(&self, name: &str, default: &str) -> String {
env::var(name).ok().or_else(|| self.file.get(name).cloned()).unwrap_or_else(|| default.to_owned())
}
fn optional(&self, name: &str) -> Option<String> {
env::var(name).ok().or_else(|| self.file.get(name).cloned()).filter(|value| !value.trim().is_empty())
}
fn required(&self, name: &str, context: &str) -> Result<String, Box<dyn std::error::Error>> {
self.optional(name).ok_or_else(|| format!("{name} is required when {context}").into())
}
fn bool(&self, name: &str, default: bool) -> Result<bool, Box<dyn std::error::Error>> {
match self.get(name, if default { "true" } else { "false" }).trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true), "1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false), "0" | "false" | "no" | "off" => Ok(false),
_ => Err(format!("{name} must be true or false").into()), _ => Err(format!("{name} must be true or false").into()),
},
Err(_) => Ok(default),
} }
} }
fn env_log_level(name: &str, default: &str) -> Result<String, Box<dyn std::error::Error>> { fn log_level(&self, name: &str, default: &str) -> Result<String, Box<dyn std::error::Error>> {
let value = env_var(name, default).trim().to_ascii_lowercase(); let value = self.get(name, default).trim().to_ascii_lowercase();
match value.as_str() { match value.as_str() {
"off" | "error" | "warn" | "info" | "debug" => Ok(value), "off" | "error" | "warn" | "info" | "debug" => Ok(value),
_ => Err(format!("{name} must be one of: off, error, warn, info, debug").into()), _ => Err(format!("{name} must be one of: off, error, warn, info, debug").into()),
} }
} }
fn env_positive_i64(name: &str, default: i64) -> Result<i64, Box<dyn std::error::Error>> { fn positive_i64(&self, name: &str, default: i64) -> Result<i64, Box<dyn std::error::Error>> {
let value: i64 = env_var(name, &default.to_string()).parse()?; let value: i64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be an integer"))?;
if value <= 0 { if value <= 0 { return Err(format!("{name} must be greater than 0").into()); }
return Err(format!("{name} must be greater than 0").into());
}
Ok(value) Ok(value)
} }
fn env_positive_u64(name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> { fn positive_u64(&self, name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
let value: u64 = env_var(name, &default.to_string()).parse()?; let value: u64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer"))?;
if value == 0 { if value == 0 { return Err(format!("{name} must be greater than 0").into()); }
return Err(format!("{name} must be greater than 0").into());
}
Ok(value) Ok(value)
} }
fn env_nonnegative_u64(name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> { fn nonnegative_u64(&self, name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
Ok(env_var(name, &default.to_string()).parse()?) self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer").into())
}
} }
fn required_env(name: &str) -> Result<String, Box<dyn std::error::Error>> { fn parse_yaml_config(content: &str) -> Result<HashMap<String, String>, String> {
let value = env::var(name).map_err(|_| format!("{name} is required when STORAGE_DRIVER=s3"))?; let mut output = HashMap::new();
if value.trim().is_empty() { let mut sections: Vec<(usize, String)> = Vec::new();
return Err(format!("{name} cannot be empty when STORAGE_DRIVER=s3").into());
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"));
} }
Ok(value) 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 required_nonempty_env(name: &str, context: &str) -> Result<String, Box<dyn std::error::Error>> { fn strip_yaml_comment(line: &str) -> &str {
let value = env::var(name).map_err(|_| format!("{name} is required when {context}"))?; let mut single = false;
if value.trim().is_empty() { let mut double = false;
return Err(format!("{name} cannot be empty when {context}").into()); let mut escaped = false;
for (index, character) in line.char_indices() {
if escaped {
escaped = false;
continue;
} }
Ok(value) match character {
'\\' if double => escaped = true,
'\'' if !double => single = !single,
'"' if !single => double = !double,
'#' if !single && !double => return &line[..index],
_ => {}
}
}
line
}
fn parse_yaml_scalar(value: &str) -> Result<String, String> {
if value.starts_with('"') {
if !value.ends_with('"') || value.len() < 2 {
return Err("unterminated double-quoted value".to_owned());
}
return serde_json::from_str::<String>(value)
.map_err(|error| format!("invalid double-quoted value: {error}"));
}
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("''", "'"));
}
if value.eq_ignore_ascii_case("null") || value == "~" {
return Ok(String::new());
}
Ok(value.to_owned())
} }
+95 -2
View File
@@ -10,7 +10,7 @@ mod state;
mod storage; mod storage;
mod websocket; mod websocket;
use std::{net::SocketAddr, sync::Arc}; use std::{net::SocketAddr, path::PathBuf, sync::Arc};
use config::Config; use config::Config;
use database::{Database, DatabaseKind}; use database::{Database, DatabaseKind};
@@ -19,12 +19,18 @@ use tokio::net::TcpListener;
use tracing::{info, warn}; use tracing::{info, warn};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
let cli = parse_command()?;
init_tracing(); 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!( info!(
host = %config.host, host = %config.host,
port = config.port, port = config.port,
@@ -61,6 +67,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
info!(database_kind = ?db.kind(), "database connection established"); info!(database_kind = ?db.kind(), "database connection established");
run_migrations(&db).await?; run_migrations(&db).await?;
info!(database_kind = ?db.kind(), "database migrations completed"); 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?; let storage = storage::Storage::from_config(config.storage.clone()).await?;
info!( info!(
@@ -80,8 +90,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
config.frontend_log_level.clone(), config.frontend_log_level.clone(),
config.anonymous_access_token_ttl_days, config.anonymous_access_token_ttl_days,
config.user_session_ttl_days, config.user_session_ttl_days,
config.unconfirmed_account_ttl_days,
config.ldap.clone(), 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( let app = app::router(
state, state,
&config.static_dir, &config.static_dir,
@@ -99,6 +124,74 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(()) Ok(())
} }
#[derive(Clone, Copy)]
enum Command { Run, CheckConfig, Migrate }
struct Cli {
command: Command,
config: Option<PathBuf>,
}
fn parse_command() -> Result<Cli, Box<dyn std::error::Error>> {
let mut command = Command::Run;
let mut config = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"-v" | "--version" => {
println!("rustpad {}", env!("CARGO_PKG_VERSION"));
std::process::exit(0);
}
"-h" | "--help" => {
print_help();
std::process::exit(0);
}
"-c" | "--config" => {
let path = args.next().ok_or("--config requires a file path")?;
if config.replace(PathBuf::from(path)).is_some() {
return Err("--config can only be specified once".into());
}
}
"check-config" => {
if !matches!(command, Command::Run) {
return Err("only one command may be specified".into());
}
command = Command::CheckConfig;
}
"migrate" => {
if !matches!(command, Command::Run) {
return Err("only one command may be specified".into());
}
command = Command::Migrate;
}
_ if arg.starts_with('-') => return Err(format!("unknown option: {arg}; use --help").into()),
_ => return Err(format!("unknown command: {arg}; use --help").into()),
}
}
Ok(Cli { command, config })
}
fn print_help() {
println!(
"rustpad {version}
USAGE:
rustpad [OPTIONS] [COMMAND]
OPTIONS:
-c, --config <FILE> Load YAML configuration file; environment variables override it
-h, --help Show help
-v, --version Show version
COMMANDS:
check-config Parse and validate configuration, then exit
migrate Validate configuration, apply database migrations, then exit",
version = env!("CARGO_PKG_VERSION")
);
}
fn init_tracing() { fn init_tracing() {
tracing_subscriber::registry() tracing_subscriber::registry()
.with( .with(
+11 -2
View File
@@ -16,9 +16,18 @@ pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: &str =
"SELECT currval(pg_get_serial_sequence('revisions', 'id'))"; "SELECT currval(pg_get_serial_sequence('revisions', 'id'))";
// Authentication queries. // 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_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_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 = ?, updated_at = ? WHERE id = ?"; 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_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_DELETE_USER: &str = "DELETE FROM users WHERE id = ?";
pub const AUTH_SESSION_EXPIRES_AT: &str = "SELECT expires_at FROM user_sessions WHERE token = ?"; pub const AUTH_SESSION_EXPIRES_AT: &str = "SELECT expires_at FROM user_sessions WHERE token = ?";
+3
View File
@@ -63,6 +63,7 @@ pub struct AppState {
pub frontend_log_level: String, pub frontend_log_level: String,
pub anonymous_access_token_ttl_days: i64, pub anonymous_access_token_ttl_days: i64,
pub user_session_ttl_days: i64, pub user_session_ttl_days: i64,
pub unconfirmed_account_ttl_days: i64,
pub ldap: Option<crate::auth::ldap::LdapConfig>, pub ldap: Option<crate::auth::ldap::LdapConfig>,
channels: RwLock<HashMap<String, broadcast::Sender<RoomEvent>>>, channels: RwLock<HashMap<String, broadcast::Sender<RoomEvent>>>,
presence: RwLock<HashMap<String, HashMap<u64, PresenceConnection>>>, presence: RwLock<HashMap<String, HashMap<u64, PresenceConnection>>>,
@@ -83,6 +84,7 @@ impl AppState {
frontend_log_level: String, frontend_log_level: String,
anonymous_access_token_ttl_days: i64, anonymous_access_token_ttl_days: i64,
user_session_ttl_days: i64, user_session_ttl_days: i64,
unconfirmed_account_ttl_days: i64,
ldap: Option<crate::auth::ldap::LdapConfig>, ldap: Option<crate::auth::ldap::LdapConfig>,
) -> Self { ) -> Self {
Self { Self {
@@ -98,6 +100,7 @@ impl AppState {
frontend_log_level, frontend_log_level,
anonymous_access_token_ttl_days, anonymous_access_token_ttl_days,
user_session_ttl_days, user_session_ttl_days,
unconfirmed_account_ttl_days,
ldap, ldap,
channels: RwLock::new(HashMap::new()), channels: RwLock::new(HashMap::new()),
presence: RwLock::new(HashMap::new()), presence: RwLock::new(HashMap::new()),
+4
View File
@@ -48,6 +48,7 @@ enum ServerMessage {
note_title: String, note_title: String,
content: String, content: String,
owner_map: String, owner_map: String,
access_level: String,
}, },
Document { Document {
content: String, content: String,
@@ -188,6 +189,7 @@ async fn handle_socket(
note_title: note.title.clone(), note_title: note.title.clone(),
content: note.content.clone(), content: note.content.clone(),
owner_map: note.owner_map.clone(), owner_map: note.owner_map.clone(),
access_level: if write_allowed { "full".into() } else { "read_only".into() },
}, },
) )
.await .await
@@ -314,6 +316,7 @@ enum PadServerMessage {
title: String, title: String,
content: String, content: String,
owner_map: String, owner_map: String,
access_level: String,
}, },
Document { Document {
content: String, content: String,
@@ -453,6 +456,7 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
title: pad.title.clone(), title: pad.title.clone(),
content: pad.content.clone(), content: pad.content.clone(),
owner_map: pad.owner_map.clone(), owner_map: pad.owner_map.clone(),
access_level: if write_allowed { "full".into() } else { "read_only".into() },
}, },
) )
.await .await
+13
View File
@@ -4209,3 +4209,16 @@ dialog::backdrop {
.file-code button { .file-code button {
height: auto; 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;
}
+21
View File
@@ -80,6 +80,7 @@
<div id="footer-account-user" class="home-footer__account" hidden> <div id="footer-account-user" class="home-footer__account" hidden>
<span id="footer-user-label" class="home-footer__user"></span> <span id="footer-user-label" class="home-footer__user"></span>
<button id="footer-resources" class="footer-action footer-action--primary" type="button">My notes</button> <button id="footer-resources" class="footer-action footer-action--primary" type="button">My notes</button>
<button id="footer-profile" class="footer-action" type="button">Profile</button>
<button id="footer-logout" class="footer-action" type="button">Log out</button> <button id="footer-logout" class="footer-action" type="button">Log out</button>
</div> </div>
<span class="home-footer__author">Author: <a href="https://www.linuxiarz.pl" <span class="home-footer__author">Author: <a href="https://www.linuxiarz.pl"
@@ -122,6 +123,26 @@
<p id="resources-error" class="form-message error" role="alert"></p> <p id="resources-error" class="form-message error" role="alert"></p>
</div> </div>
</dialog> </dialog>
<dialog id="profile-dialog" class="app-dialog">
<form id="profile-form" class="dialog-panel identity-panel" autocomplete="off">
<button id="close-profile" class="modal-close" type="button" aria-label="Close dialog">×</button>
<header class="identity-panel__header"><h2>Profile</h2><p id="profile-copy" class="dialog-copy">Manage your local RustPad account.</p></header>
<div class="identity-fields">
<label data-directory-profile-field>Full name<input id="profile-directory-name" readonly></label>
<label data-directory-profile-field>Organization<input id="profile-directory-organization" readonly></label>
<p id="profile-nickname-suggestion" data-directory-profile-field class="profile-suggestion"></p>
<label>Nickname<input id="profile-nickname" maxlength="40" required></label>
<label data-local-profile-field>New e-mail<input id="profile-email" type="email" maxlength="320" placeholder="Leave empty to keep current"></label>
<label data-local-profile-field>New password<input id="profile-new-password" type="password" minlength="8" maxlength="128" placeholder="Leave empty to keep current"></label>
<label data-local-profile-field>Current password<input id="profile-password" type="password" minlength="8" maxlength="128" required></label>
</div>
<button class="primary-button" type="submit">Save profile</button>
<button id="profile-delete" data-local-profile-field class="danger-button" type="button">Delete account</button>
<p id="profile-message" class="form-message" role="status"></p>
</form>
</dialog>
</body> </body>
</html> </html>
+21
View File
@@ -121,6 +121,25 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
message.classList.remove("success"); message.classList.remove("success");
message.classList.add("error"); message.classList.add("error");
message.textContent = error.message; 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 { } finally {
submit.disabled = false; submit.disabled = false;
} }
@@ -255,12 +274,14 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
} }
setAuthSession(session); setAuthSession(session);
await onIdentity(session.nickname, session); await onIdentity(session.nickname, session);
dialog.close();
return; return;
} }
nickname.disabled = false; nickname.disabled = false;
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname: name, session_token: getAuthToken() || null }) }); const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname: name, session_token: getAuthToken() || null }) });
setNickname(result.nickname); setNickname(result.nickname);
await onIdentity(result.nickname, null); await onIdentity(result.nickname, null);
dialog.close();
} catch (error) { } catch (error) {
message.classList.remove("success"); message.classList.remove("success");
message.classList.add("error"); message.classList.add("error");
+43 -1
View File
@@ -84,6 +84,7 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even
handleAccountConfirmationToken(); handleAccountConfirmationToken();
handleResetToken(); 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 identityDialog = document.querySelector("#identity-dialog");
const guestAccount = document.querySelector("#footer-account-guest"); 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 resourcesDialog = document.querySelector("#resources-dialog");
const resourcesList = document.querySelector("#resources-list"); const resourcesList = document.querySelector("#resources-list");
const resourcesError = document.querySelector("#resources-error"); 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 authHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; } 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"); } }); 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"); } }); 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(); dialog.showModal();
try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); }
@@ -232,6 +236,7 @@ async function loadResources() {
function renderAccount(session) { function renderAccount(session) {
currentSession = session;
guestAccount.hidden = Boolean(session); guestAccount.hidden = Boolean(session);
userAccount.hidden = !session; userAccount.hidden = !session;
if (session) userLabel.textContent = `Signed in as ${session.nickname}`; if (session) userLabel.textContent = `Signed in as ${session.nickname}`;
@@ -252,6 +257,43 @@ if (identityDialog) {
authDialog.setMode("register"); authDialog.setMode("register");
identityDialog.showModal(); 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("#footer-resources")?.addEventListener("click", async () => { resourcesDialog.showModal(); await loadResources(); });
document.querySelector("#close-resources")?.addEventListener("click", () => resourcesDialog.close()); document.querySelector("#close-resources")?.addEventListener("click", () => resourcesDialog.close());
resourcesDialog?.addEventListener("click", (event) => { if (event.target === resourcesDialog) resourcesDialog.close(); }); resourcesDialog?.addEventListener("click", (event) => { if (event.target === resourcesDialog) resourcesDialog.close(); });
+2 -2
View File
@@ -14,7 +14,7 @@ import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url
export function startNoteEditor(adapter) { 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 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 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; 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 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); 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), editor, toast, getAccessToken: () => accessToken, canDelete: () => Boolean(info?.can_delete_files),
endpoints: adapter.fileEndpoints, 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(); } } }); 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(); }); }); identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
async function initialize() { async function initialize() {
+1 -1
View File
@@ -131,7 +131,7 @@
</div> </div>
</details> </details>
</div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" </div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button"
aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" aria-haspopup="dialog">Shortcuts</button> · <span id="access-level" class="footer-access">Access: checking…</span> · <button id="footer-files" class="footer-link"
type="button">0 files</button> · <span id="save-state">Changes are saved type="button">0 files</button> · <span id="save-state">Changes are saved
automatically</span></span> automatically</span></span>
</footer> </footer>
+1 -1
View File
@@ -129,7 +129,7 @@
</div> </div>
</details> </details>
</div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" </div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button"
aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" aria-haspopup="dialog">Shortcuts</button> · <span id="access-level" class="footer-access">Access: checking…</span> · <button id="footer-files" class="footer-link"
type="button">0 files</button> · <span id="save-state">Changes are saved type="button">0 files</button> · <span id="save-state">Changes are saved
automatically</span></span> automatically</span></span>
</footer> </footer>
+10
View File
@@ -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.
+39
View File
@@ -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
+63
View File
@@ -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 <rustpad@example.org>"
# 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