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..44187e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.0.42" +version = "0.1.1" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 7be527f..ef20dd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.0.42" +version = "0.1.2" 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/mysql/0016_editor_colors.sql b/migrations/mysql/0016_editor_colors.sql new file mode 100644 index 0000000..84ba26c --- /dev/null +++ b/migrations/mysql/0016_editor_colors.sql @@ -0,0 +1,11 @@ +ALTER TABLE users ADD COLUMN editor_color VARCHAR(7) NULL; + +CREATE TABLE user_resource_colors ( + user_id BIGINT NOT NULL, + resource_kind VARCHAR(32) NOT NULL, + resource_slug VARCHAR(255) NOT NULL, + color VARCHAR(7) NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, resource_kind, resource_slug), + CONSTRAINT fk_user_resource_colors_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); 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/postgres/0016_editor_colors.sql b/migrations/postgres/0016_editor_colors.sql new file mode 100644 index 0000000..8cd35b2 --- /dev/null +++ b/migrations/postgres/0016_editor_colors.sql @@ -0,0 +1,10 @@ +ALTER TABLE users ADD COLUMN editor_color TEXT; + +CREATE TABLE user_resource_colors ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + resource_kind TEXT NOT NULL, + resource_slug TEXT NOT NULL, + color TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, resource_kind, resource_slug) +); 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/migrations/sqlite/0016_editor_colors.sql b/migrations/sqlite/0016_editor_colors.sql new file mode 100644 index 0000000..5e8f7f9 --- /dev/null +++ b/migrations/sqlite/0016_editor_colors.sql @@ -0,0 +1,11 @@ +ALTER TABLE users ADD COLUMN editor_color TEXT; + +CREATE TABLE user_resource_colors ( + user_id INTEGER NOT NULL, + resource_kind TEXT NOT NULL, + resource_slug TEXT NOT NULL, + color TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, resource_kind, resource_slug), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); diff --git a/src/api.rs b/src/api.rs index ff5b8c3..d413222 100644 --- a/src/api.rs +++ b/src/api.rs @@ -130,6 +130,13 @@ pub struct NoteInfo { created_at: String, updated_at: String, can_delete_files: bool, + global_color: Option, + note_color: Option, +} + +#[derive(Debug, Deserialize)] +pub struct EditorColorRequest { + color: Option, } pub async fn create_workspace( @@ -234,8 +241,17 @@ pub async fn create_note( .await?; let level = if db::verify_workspace_password(&workspace, payload.password.as_deref()) || (workspace.is_private == 0 && workspace.password_hash.is_none()) - { AccessLevel::Write } else { - combined_token_access_level(&state, "workspace", &workspace_slug, payload.access_token.as_deref(), bearer_token(&headers)).await? + { + AccessLevel::Write + } else { + combined_token_access_level( + &state, + "workspace", + &workspace_slug, + payload.access_token.as_deref(), + bearer_token(&headers), + ) + .await? }; require_write(level)?; let title = validate_name(&payload.name, "Note name")?; @@ -276,6 +292,90 @@ pub async fn create_note( )) } +fn clean_editor_color(value: Option<&str>) -> Result, ApiError> { + let Some(value) = value else { + return Ok(None); + }; + let value = value.trim(); + if value.len() == 7 + && value.starts_with('#') + && value[1..].chars().all(|c| c.is_ascii_hexdigit()) + { + Ok(Some(value.to_ascii_lowercase())) + } else { + Err(ApiError::bad_request("Invalid editor color")) + } +} + +async fn editor_colors( + state: &SharedState, + headers: &HeaderMap, + kind: &str, + slug: &str, +) -> Result<(Option, Option), ApiError> { + let Some(user) = crate::auth::optional_user(state, headers) + .await + .map_err(|e| ApiError::forbidden(&e.message))? + else { + return Ok((None, None)); + }; + let global: Option = sqlx::query_scalar(queries::get( + state.db.kind(), + queries::AUTH_EDITOR_COLOR_BY_USER, + )) + .bind(user.id) + .fetch_one(state.db.pool()) + .await?; + let note: Option = sqlx::query_scalar(queries::get( + state.db.kind(), + queries::RESOURCE_COLOR_BY_USER, + )) + .bind(user.id) + .bind(kind) + .bind(slug) + .fetch_optional(state.db.pool()) + .await?; + Ok((global, note)) +} + +async fn save_editor_color( + state: &SharedState, + headers: &HeaderMap, + kind: &str, + slug: &str, + color: Option<&str>, +) -> Result, ApiError> { + let user = crate::auth::optional_user(state, headers) + .await + .map_err(|e| ApiError::forbidden(&e.message))? + .ok_or_else(|| ApiError::forbidden("Log in to save note colors"))?; + let color = clean_editor_color(color)?; + let mut tx = state.db.pool().begin().await?; + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_COLOR_DELETE, + )) + .bind(user.id) + .bind(kind) + .bind(slug) + .execute(&mut *tx) + .await?; + if let Some(value) = color.as_deref() { + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_COLOR_INSERT, + )) + .bind(user.id) + .bind(kind) + .bind(slug) + .bind(value) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(Json(serde_json::json!({"color": color}))) +} + pub async fn note_info( State(state): State, headers: HeaderMap, @@ -295,6 +395,8 @@ pub async fn note_info( let note = db::find_note(&state.db, workspace.id, ¬e_slug) .await? .ok_or_else(ApiError::not_found_note)?; + let color_slug = format!("{}/{}", workspace_slug, note_slug); + let (global_color, note_color) = editor_colors(&state, &headers, "note", &color_slug).await?; Ok(Json(NoteInfo { workspace_slug: workspace.slug, @@ -327,6 +429,8 @@ pub async fn note_info( .unwrap_or(false); workspace_owner || note_owner }, + global_color, + note_color, })) } @@ -374,8 +478,17 @@ pub async fn restore( .await?; let level = if db::verify_workspace_password(&workspace, payload.password.as_deref()) || (workspace.is_private == 0 && workspace.password_hash.is_none()) - { AccessLevel::Write } else { - combined_token_access_level(&state, "workspace", &workspace_slug, payload.access_token.as_deref(), bearer_token(&headers)).await? + { + AccessLevel::Write + } else { + combined_token_access_level( + &state, + "workspace", + &workspace_slug, + payload.access_token.as_deref(), + bearer_token(&headers), + ) + .await? }; require_write(level)?; let content: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q028)) @@ -407,7 +520,6 @@ pub async fn restore( Ok(Json(serde_json::json!({"ok": true}))) } - #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum AccessLevel { None, @@ -513,9 +625,8 @@ pub async fn authorized_workspace( let workspace = db::find_workspace(&state.db, slug) .await? .ok_or_else(ApiError::not_found_workspace)?; - let token_level = combined_token_access_level( - state, "workspace", slug, access_token, bearer, - ).await?; + let token_level = + combined_token_access_level(state, "workspace", slug, access_token, bearer).await?; if workspace.is_private != 0 && token_level == AccessLevel::None { return Err(ApiError::forbidden("This workspace is private.")); } @@ -536,7 +647,8 @@ async fn authorized_note( access_token: Option<&str>, bearer: Option<&str>, ) -> Result<(db::Workspace, db::Note), ApiError> { - let workspace = authorized_workspace(state, workspace_slug, password, access_token, bearer).await?; + let workspace = + authorized_workspace(state, workspace_slug, password, access_token, bearer).await?; let note = db::find_note(&state.db, workspace.id, note_slug) .await? .ok_or_else(ApiError::not_found_note)?; @@ -644,6 +756,8 @@ pub struct PadInfo { created_at: String, updated_at: String, can_delete_files: bool, + global_color: Option, + note_color: Option, } pub async fn create_pad( @@ -696,6 +810,7 @@ pub async fn pad_info( bearer_token(&headers), ) .await?; + let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?; Ok(Json(PadInfo { slug: pad.slug, title: pad.title, @@ -711,9 +826,64 @@ pub async fn pad_info( ) .await .unwrap_or(false), + global_color, + note_color, })) } +pub async fn pad_editor_color( + State(state): State, + headers: HeaderMap, + Path(slug): Path, +) -> Result, ApiError> { + let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?; + Ok(Json( + serde_json::json!({"global_color": global_color, "note_color": note_color}), + )) +} + +pub async fn note_editor_color( + State(state): State, + headers: HeaderMap, + Path((workspace_slug, note_slug)): Path<(String, String)>, +) -> Result, ApiError> { + let (global_color, note_color) = editor_colors( + &state, + &headers, + "note", + &format!("{}/{}", workspace_slug, note_slug), + ) + .await?; + Ok(Json( + serde_json::json!({"global_color": global_color, "note_color": note_color}), + )) +} + +pub async fn set_pad_editor_color( + State(state): State, + headers: HeaderMap, + Path(slug): Path, + Json(payload): Json, +) -> Result, ApiError> { + save_editor_color(&state, &headers, "pad", &slug, payload.color.as_deref()).await +} + +pub async fn set_note_editor_color( + State(state): State, + headers: HeaderMap, + Path((workspace_slug, note_slug)): Path<(String, String)>, + Json(payload): Json, +) -> Result, ApiError> { + save_editor_color( + &state, + &headers, + "note", + &format!("{}/{}", workspace_slug, note_slug), + payload.color.as_deref(), + ) + .await +} + pub async fn publish_pad_page( State(state): State, headers: HeaderMap, @@ -730,8 +900,17 @@ pub async fn publish_pad_page( .await?; let level = if db::verify_pad_password(&pad, payload.password.as_deref()) || (pad.is_private == 0 && pad.password_hash.is_none()) - { AccessLevel::Write } else { - combined_token_access_level(&state, "pad", &slug, payload.access_token.as_deref(), bearer_token(&headers)).await? + { + AccessLevel::Write + } else { + combined_token_access_level( + &state, + "pad", + &slug, + payload.access_token.as_deref(), + bearer_token(&headers), + ) + .await? }; require_write(level)?; let token = db::publish_pad(&state.db, pad.id).await?; @@ -758,8 +937,17 @@ pub async fn publish_note_page( .await?; let level = if db::verify_workspace_password(&workspace, payload.password.as_deref()) || (workspace.is_private == 0 && workspace.password_hash.is_none()) - { AccessLevel::Write } else { - combined_token_access_level(&state, "workspace", &workspace_slug, payload.access_token.as_deref(), bearer_token(&headers)).await? + { + AccessLevel::Write + } else { + combined_token_access_level( + &state, + "workspace", + &workspace_slug, + payload.access_token.as_deref(), + bearer_token(&headers), + ) + .await? }; require_write(level)?; let token = db::publish_note(&state.db, note.id).await?; @@ -849,8 +1037,17 @@ pub async fn pad_restore( .await?; let level = if db::verify_pad_password(&pad, payload.password.as_deref()) || (pad.is_private == 0 && pad.password_hash.is_none()) - { AccessLevel::Write } else { - combined_token_access_level(&state, "pad", &slug, payload.access_token.as_deref(), bearer_token(&headers)).await? + { + AccessLevel::Write + } else { + combined_token_access_level( + &state, + "pad", + &slug, + payload.access_token.as_deref(), + bearer_token(&headers), + ) + .await? }; require_write(level)?; let content: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q029)) @@ -892,9 +1089,7 @@ async fn authorized_pad( let pad = db::find_pad(&state.db, slug) .await? .ok_or_else(ApiError::not_found_note)?; - let token_level = combined_token_access_level( - state, "pad", slug, access_token, bearer, - ).await?; + let token_level = combined_token_access_level(state, "pad", slug, access_token, bearer).await?; if pad.is_private != 0 && token_level == AccessLevel::None { return Err(ApiError::forbidden("This note is private.")); } @@ -961,11 +1156,27 @@ pub async fn upload_pad_file( file = Some((filename, bytes.to_vec())); } } - let pad = authorized_pad(&state, &slug, password.as_deref(), access_token.as_deref(), bearer_token(&headers)).await?; + let pad = authorized_pad( + &state, + &slug, + password.as_deref(), + access_token.as_deref(), + bearer_token(&headers), + ) + .await?; let level = if db::verify_pad_password(&pad, password.as_deref()) || (pad.is_private == 0 && pad.password_hash.is_none()) - { AccessLevel::Write } else { - combined_token_access_level(&state, "pad", &slug, access_token.as_deref(), bearer_token(&headers)).await? + { + AccessLevel::Write + } else { + combined_token_access_level( + &state, + "pad", + &slug, + access_token.as_deref(), + bearer_token(&headers), + ) + .await? }; require_write(level)?; let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?; @@ -1119,8 +1330,17 @@ pub async fn upload_note_file( let level = if db::verify_workspace_password(&workspace, password.as_deref()) || (workspace.is_private == 0 && workspace.password_hash.is_none()) - { AccessLevel::Write } else { - combined_token_access_level(&state, "workspace", &workspace_slug, access_token.as_deref(), bearer_token(&headers)).await? + { + AccessLevel::Write + } else { + combined_token_access_level( + &state, + "workspace", + &workspace_slug, + access_token.as_deref(), + bearer_token(&headers), + ) + .await? }; require_write(level)?; let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?; @@ -1178,8 +1398,17 @@ pub async fn delete_note( let level = if db::verify_workspace_password(&workspace, payload.password.as_deref()) || (workspace.is_private == 0 && workspace.password_hash.is_none()) - { AccessLevel::Write } else { - combined_token_access_level(&state, "workspace", &workspace_slug, payload.access_token.as_deref(), bearer_token(&headers)).await? + { + AccessLevel::Write + } else { + combined_token_access_level( + &state, + "workspace", + &workspace_slug, + payload.access_token.as_deref(), + bearer_token(&headers), + ) + .await? }; require_write(level)?; if note.protected { diff --git a/src/app.rs b/src/app.rs index 6d13ea0..5b80b1b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,7 +1,8 @@ use axum::{ Router, - extract::{DefaultBodyLimit, Path, State}, - http::{HeaderValue, StatusCode, header}, + extract::{DefaultBodyLimit, Path, Request, State}, + http::{HeaderName, HeaderValue, StatusCode, header}, + middleware::{self, Next}, response::{Html, IntoResponse, Response}, routing::{get, post}, }; @@ -46,6 +47,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 +58,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", @@ -94,6 +109,10 @@ pub fn router( .route("/api/pads", post(api::create_pad)) .route("/api/pads/{slug}", get(api::pad_info)) .route("/api/pads/{slug}/history", post(api::pad_history)) + .route( + "/api/pads/{slug}/editor-color", + get(api::pad_editor_color).post(api::set_pad_editor_color), + ) .route("/api/pads/{slug}/publish", post(api::publish_pad_page)) .route("/api/pads/{slug}/restore", post(api::pad_restore)) .route( @@ -118,6 +137,10 @@ pub fn router( "/api/workspaces/{workspace_slug}/notes/{note_slug}", get(api::note_info).delete(api::delete_note), ) + .route( + "/api/workspaces/{workspace_slug}/notes/{note_slug}/editor-color", + get(api::note_editor_color).post(api::set_note_editor_color), + ) .route( "/api/workspaces/{workspace_slug}/notes/{note_slug}/publish", post(api::publish_note_page), @@ -156,10 +179,45 @@ pub fn router( .layer(DefaultBodyLimit::max( upload_max_size_bytes.saturating_add(1024 * 1024), )) + .layer(SetResponseHeaderLayer::if_not_present( + HeaderName::from_static("x-frame-options"), + HeaderValue::from_static("DENY"), + )) + .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(middleware::from_fn(add_non_asset_security_headers)) .with_state(state) } +async fn add_non_asset_security_headers(request: Request, next: Next) -> Response { + let is_asset = request.uri().path().starts_with("/assets/"); + let mut response = next.run(request).await; + + if !is_asset { + let headers = response.headers_mut(); + headers + .entry("x-content-type-options") + .or_insert(HeaderValue::from_static("nosniff")); + headers + .entry("referrer-policy") + .or_insert(HeaderValue::from_static("strict-origin-when-cross-origin")); + headers + .entry("permissions-policy") + .or_insert(HeaderValue::from_static( + "camera=(), microphone=(), geolocation=(), payment=(), usb=()", + )); + } + + response +} + async fn private_workspace_error(State(state): State) -> Response { error_response( StatusCode::FORBIDDEN, @@ -176,6 +234,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..d0b6204 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,27 @@ 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, + #[serde(default)] + editor_color: Option, +} +#[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 +169,11 @@ pub struct SessionResponse { nickname: String, email: String, expires_at: String, + directory_managed: bool, + directory_display_name: Option, + directory_organization: Option, + suggested_nickname: Option, + editor_color: Option, } #[derive(Serialize)] pub struct IdentityResponse { @@ -348,6 +378,73 @@ 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 +512,56 @@ 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 +579,329 @@ 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?; + let editor_color: Option = sqlx::query_scalar(queries::get( + state.db.kind(), + queries::AUTH_EDITOR_COLOR_BY_USER, + )) + .bind(user.id) + .fetch_one(state.db.pool()) + .await + .map_err(AuthError::database)?; Ok(Json(SessionResponse { token: token.into(), nickname: user.nickname, email: user.email, expires_at, + directory_managed, + directory_display_name, + directory_organization, + suggested_nickname, + editor_color, })) } +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; + } + } + } + + if let Some(value) = req.editor_color.as_deref() { + let color = validate_editor_color(value)?; + sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_UPDATE_EDITOR_COLOR, + )) + .bind(color) + .bind(Utc::now().to_rfc3339()) + .bind(user.id) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; + } + + Ok(Json(serde_json::json!({ + "ok": true, + "nickname": nickname, + "editor_color": req.editor_color.as_deref(), + "email_pending": email_pending, + "message": if email_pending { + "Profile updated. Confirm the new e-mail address using the link sent to it." + } else { + "Profile updated." + } + }))) +} + +pub async fn request_account_deletion( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result, AuthError> { + if state.ldap.is_some() { + return Err(AuthError::forbidden( + "LDAP accounts cannot be deleted here.", + )); + } + let user = require_user(&state, &headers).await?; + if !verify_password(&user.password_hash, &req.password) { + return Err(AuthError::unauthorized( + "The current password is incorrect.", + )); + } + let smtp = state + .smtp + .as_ref() + .ok_or_else(|| AuthError::service_unavailable("SMTP is not configured."))?; + create_account_action(&state, &user, "delete", None, smtp).await?; + Ok(Json( + serde_json::json!({"ok":true,"message":"A confirmation link has been sent to your e-mail address."}), + )) +} + +pub async fn confirm_account_action( + State(state): State, + Json(req): Json, +) -> Result, AuthError> { + let now = Utc::now(); + let hash = hash_token(req.token.trim()); + let row: Option<(i64, String, Option, String, Option)> = sqlx::query_as( + queries::get(state.db.kind(), queries::AUTH_ACCOUNT_ACTION_BY_TOKEN), + ) + .bind(&hash) + .fetch_optional(state.db.pool()) + .await + .map_err(AuthError::database)?; + let (user_id, action, payload, expires_at, used_at) = row.ok_or_else(|| { + AuthError::bad_request("The confirmation link is invalid or has expired.") + })?; + let expires = chrono::DateTime::parse_from_rfc3339(&expires_at) + .map_err(|_| AuthError::bad_request("The confirmation link is invalid or has expired."))? + .with_timezone(&Utc); + if used_at.is_some() || expires <= now { + return Err(AuthError::bad_request( + "The confirmation link is invalid or has expired.", + )); + } + let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_CONSUME_ACCOUNT_ACTION, + )) + .bind(now.to_rfc3339()) + .bind(&hash) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; + let message = if action == "email" { + let email = payload.ok_or_else(|| AuthError::internal("Missing e-mail change payload."))?; + if find_user_by_email(&state, &email).await?.is_some() { + return Err(AuthError::conflict( + "This e-mail address is already registered.", + )); + } + sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_EMAIL)) + .bind(&email) + .bind(normalize(&email)) + .bind(now.to_rfc3339()) + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; + "E-mail address changed." + } else if action == "delete" { + sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_USER)) + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; + "Account deleted." + } else { + return Err(AuthError::bad_request("Unknown account action.")); + }; + tx.commit().await.map_err(AuthError::database)?; + Ok(Json(serde_json::json!({"ok":true,"message":message}))) +} + +async fn create_account_action( + state: &SharedState, + user: &User, + action: &str, + payload: Option<&str>, + smtp: &SmtpConfig, +) -> Result<(), AuthError> { + sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_DELETE_ACCOUNT_ACTIONS, + )) + .bind(user.id) + .bind(action) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; + let token = random_token(); + let expires = (Utc::now() + Duration::hours(1)).to_rfc3339(); + sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_INSERT_ACCOUNT_ACTION, + )) + .bind(hash_token(&token)) + .bind(user.id) + .bind(action) + .bind(payload) + .bind(expires) + .bind(Utc::now().to_rfc3339()) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; + send_account_action(smtp, user, action, payload, &token).await +} + +async fn send_account_action( + smtp: &SmtpConfig, + user: &User, + action: &str, + payload: Option<&str>, + token: &str, +) -> Result<(), AuthError> { + let site = smtp.public_url.trim_end_matches('/'); + let url = format!("{site}/?account_action_token={token}"); + let sender = smtp + .from + .parse::() + .map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?; + let target = if action == "email" { + payload.unwrap_or(&user.email) + } else { + &user.email + }; + let recipient = target + .parse::() + .map_err(|_| AuthError::internal("Invalid recipient address."))?; + let (subject, title, copy) = if action == "email" { + ( + "Confirm your new RustPad e-mail", + "Confirm e-mail change", + "Confirm the new e-mail address within one hour.", + ) + } else { + ( + "Confirm RustPad account deletion", + "Confirm account deletion", + "Confirm permanent account deletion within one hour.", + ) + }; + let text = format!( + "Hello {},\n\n{}\n{}\n\nIf you did not request this, ignore this message.", + user.nickname, copy, url + ); + let html = format!( + r#"

{}

Hello {},

{}

Confirm action

{}

If you did not request this, ignore this message.

"#, + title, user.nickname, copy, url, url, url + ); + let message = Message::builder() + .from(sender) + .to(recipient) + .subject(subject) + .multipart( + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(text), + ) + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_HTML) + .body(html), + ), + ) + .map_err(|_| AuthError::internal("Failed to build account confirmation e-mail."))?; + send_message(smtp, message, "account action e-mail").await +} + pub async fn resources( State(state): State, headers: HeaderMap, @@ -668,6 +1130,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; } @@ -1201,12 +1667,13 @@ pub async fn confirm_reset( pub async fn user_from_token(state: &SharedState, token: &str) -> Result, AuthError> { let now = Utc::now(); let now_rfc3339 = now.to_rfc3339(); - let user = sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_SESSION)) - .bind(token) - .bind(&now_rfc3339) - .fetch_optional(state.db.pool()) - .await - .map_err(AuthError::database)?; + let user = + sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_SESSION)) + .bind(token) + .bind(&now_rfc3339) + .fetch_optional(state.db.pool()) + .await + .map_err(AuthError::database)?; if let Some(user) = user { let expires_at = (now + Duration::days(state.user_session_ttl_days)).to_rfc3339(); @@ -1276,11 +1743,26 @@ async fn create_session(state: &SharedState, user: &User) -> Result = sqlx::query_scalar(queries::get( + state.db.kind(), + queries::AUTH_EDITOR_COLOR_BY_USER, + )) + .bind(user.id) + .fetch_one(state.db.pool()) + .await + .map_err(AuthError::database)?; Ok(SessionResponse { token, nickname: user.nickname.clone(), email: user.email.clone(), expires_at, + directory_managed, + directory_display_name, + directory_organization, + suggested_nickname, + editor_color, }) } async fn find_user_by_nickname( @@ -1319,6 +1801,18 @@ async fn find_user_by_email(state: &SharedState, email: &str) -> Result Result { + let value = value.trim(); + if value.len() == 7 + && value.starts_with('#') + && value[1..].chars().all(|c| c.is_ascii_hexdigit()) + { + Ok(value.to_ascii_lowercase()) + } else { + Err(AuthError::bad_request("Invalid editor color.")) + } +} + fn validate_nickname(v: &str) -> Result { let v = v.trim(); if v.is_empty() || v.chars().count() > MAX_NICKNAME { diff --git a/src/config.rs b/src/config.rs index e450891..584d030 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use std::{env, net::IpAddr}; +use std::{collections::HashMap, env, net::IpAddr, path::Path}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuthorizationType { @@ -8,12 +8,8 @@ pub enum AuthorizationType { } impl AuthorizationType { - pub fn from_env() -> Result> { - 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..ad64f84 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -16,14 +16,36 @@ 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_UPDATE_EDITOR_COLOR: &str = + "UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"; +pub const AUTH_EDITOR_COLOR_BY_USER: &str = "SELECT editor_color FROM users WHERE id = ?"; +pub const RESOURCE_COLOR_BY_USER: &str = "SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"; +pub const RESOURCE_COLOR_DELETE: &str = "DELETE FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"; +pub const RESOURCE_COLOR_INSERT: &str = "INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)"; +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 = ?"; pub const AUTH_DELETE_SESSION_BY_TOKEN: &str = "DELETE FROM user_sessions WHERE token = ?"; -pub const AUTH_REFRESH_SESSION: &str = "UPDATE user_sessions SET expires_at = ? WHERE token = ? AND expires_at > ?"; +pub const AUTH_REFRESH_SESSION: &str = + "UPDATE user_sessions SET expires_at = ? WHERE token = ? AND expires_at > ?"; pub const AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER: &str = "DELETE FROM account_confirmation_tokens WHERE user_id = ?"; pub const AUTH_INSERT_CONFIRMATION_TOKEN: &str = 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..96a4499 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -263,7 +263,7 @@ textarea:focus { .inline-button { width: auto; margin-top: 18px; - padding: 0 16px; + padding: 0 18px; } .app-header { @@ -445,6 +445,11 @@ textarea:focus { display: none; } +.workspace > .editor-column, +.workspace > .preview-column { + transition: opacity .18s ease, transform .18s ease; +} + .editor-column, .preview-column { display: grid; @@ -823,43 +828,6 @@ dialog::backdrop { display: none; } - .editor-toolbar { - overflow-x: auto; - flex-wrap: nowrap; - } - - .toolbar-fill { - display: none; - } - - .workspace.view-split { - grid-template-columns: 1fr; - } - - .workspace.view-split .preview-column { - display: none; - } - - .view-switch button[data-view="split"] { - display: none; - } - - .preview-column { - border-left: 0; - } - - textarea, - .preview { - padding: 18px; - } - - .editor-footer { - align-items: flex-start; - flex-direction: column; - justify-content: center; - gap: 2px; - padding: 7px 12px; - } } @media (max-width: 480px) { @@ -961,10 +929,6 @@ dialog::backdrop { gap: 8px; } -.inline-button { - width: auto; - padding-inline: 18px; -} .error-card { width: min(560px, 100%); @@ -1117,6 +1081,7 @@ dialog::backdrop { position: relative; display: inline-flex; align-items: center; + gap: 0.35rem; } .user-chip { @@ -3623,17 +3588,6 @@ dialog::backdrop { padding: 7px 11px; } -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} @media (max-width: 800px) { .share-dialog { @@ -4078,7 +4032,9 @@ dialog::backdrop { white-space: nowrap; } -.document-owner-badge[hidden] { display: none; } +.document-owner-badge[hidden] { + display: none; +} /* Per-character authorship overlay. The textarea remains the editable surface. */ .authorship-layer { @@ -4109,11 +4065,15 @@ dialog::backdrop { background: transparent; } -.hide-editor-line-numbers .authorship-layer { left: 0; } +.hide-editor-line-numbers .authorship-layer { + left: 0; +} @media (max-width: 720px) { .authorship-layer { left: 42px; } - .hide-editor-line-numbers .authorship-layer { left: 0; } + .hide-editor-line-numbers .authorship-layer { + left: 0; +} } /* Multiple authors can contribute to one line. */ @@ -4209,3 +4169,303 @@ dialog::backdrop { .file-code button { height: auto; } + +.footer-access { + color: var(--muted, #9ca3af); + font-weight: 600; +} + +.profile-suggestion { + margin: 0; + color: var(--muted); + font-size: .82rem; +} + +#profile-dialog input[readonly] { + cursor: default; + opacity: .78; +} + +.header-navigation { + display: flex; + align-items: center; + margin-left: auto; +} + +.header-menu-toggle { + display: none; +} + +.header-user-control { + flex: 0 0 auto; + margin-left: auto; +} + +.profile-color-field input[type="color"] { + width: 100%; + min-height: 42px; + padding: 4px; + cursor: pointer; +} + +/* Responsive note editor -------------------------------------------------- */ +@media (max-width: 1499px) { + .pad-page { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 100dvh; + overflow: hidden; + } + + .pad-page .app-header { + align-items: stretch; + gap: 10px; + padding: 10px 12px; + } + + .pad-page .app-header__main { + min-width: 0; + flex: 1 1 auto; + } + + .pad-page .document-heading { + flex: 1; + } + + .pad-page .header-user-control { + margin-left: auto; + } + + .pad-page .header-navigation { + position: relative; + flex: 0 0 auto; + margin-left: 0; + } + + .pad-page .header-menu-toggle { + display: inline-flex; + width: 42px; + min-width: 42px; + height: 42px; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 5px; + padding: 0; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--surface-2); + } + + .pad-page .header-menu-toggle span { + width: 18px; + height: 2px; + border-radius: 2px; + background: currentColor; + transition: transform .18s ease, opacity .18s ease; + } + + .pad-page .header-menu-toggle[aria-expanded="true"] span:nth-child(1) { + transform: translateY(7px) rotate(45deg); + } + + .pad-page .header-menu-toggle[aria-expanded="true"] span:nth-child(2) { + opacity: 0; + } + + .pad-page .header-menu-toggle[aria-expanded="true"] span:nth-child(3) { + transform: translateY(-7px) rotate(-45deg); + } + + .pad-page .header-actions { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 20; + display: none; + width: min(320px, calc(100vw - 20px)); + max-height: min(70dvh, 520px); + align-items: stretch; + flex-direction: column; + gap: 8px; + overflow-y: auto; + padding: 10px; + border: 1px solid var(--border-strong); + border-radius: 10px; + background: #0d1015; + box-shadow: 0 16px 40px rgb(0 0 0 / 45%); + } + + .pad-page .header-actions.is-open { + display: flex; + } + + .pad-page .header-actions > * { + width: 100%; + flex: 0 0 auto; + } + + .pad-page .header-actions .secondary-button, + .pad-page .header-actions .user-chip { + justify-content: flex-start; + } + + .pad-page .header-actions .public-task-toggle { + min-height: 40px; + padding: 8px 10px; + } + + .pad-page .editor-layout { + flex: 1 1 auto; + width: 100%; + height: auto; + min-height: 320px; + } + + .pad-page .editor-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + min-height: 0; + padding: 8px; + overflow: visible; + } + + .pad-page .toolbar-group { + min-width: 0; + overflow-x: auto; + padding-right: 0; + border-right: 0; + scrollbar-width: thin; + } + + .pad-page .editor-controls, + .pad-page .toolbar-action, + .pad-page .line-toggle, + .pad-page .toolbar-fill, + .pad-page #mode-toggle { + display: none; + } + + .pad-page .view-switch { + position: sticky; + right: 0; + z-index: 2; + align-self: center; + background: #0d1015; + } + + .pad-page .view-switch button[data-view="split"] { + display: none; + } + + .pad-page .workspace, + .pad-page .workspace.view-split { + grid-template-columns: minmax(0, 1fr); + } + + .pad-page .workspace.view-edit .editor-column, + .pad-page .workspace.view-preview .preview-column { + display: grid; + opacity: 1; + transform: translateX(0); + } + + .pad-page .workspace.view-edit .preview-column, + .pad-page .workspace.view-preview .editor-column { + display: none; + opacity: 0; + } + + .pad-page .preview-column { + border-left: 0; + } + + .pad-page textarea, + .pad-page .preview, + .pad-page .authorship-layer { + padding: 16px; + } + + .pad-page .editor-footer { + align-items: center; + gap: 8px; + min-height: 42px; + padding: 6px 10px; + overflow-x: auto; + white-space: nowrap; + scrollbar-width: thin; + } + + .pad-page .footer-left, + .pad-page .footer-right { + flex: 0 0 auto; + } +} + +@media (max-width: 520px) { + .pad-page .app-header { + padding-inline: 10px; + } + + .pad-page .brand { + max-width: 40vw; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .pad-page .document-heading h1 { + font-size: .9rem; + } + + .pad-page .user-chip__name { + max-width: 88px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .pad-page .editor-toolbar { + padding: 6px; + } + + .pad-page .editor-toolbar button, + .pad-page .editor-toolbar select { + min-height: 32px; + padding-inline: 8px; + } + + .pad-page textarea, + .pad-page .preview, + .pad-page .authorship-layer { + padding: 12px; + } +} + +@media (min-width: 1500px) { + .pad-page .workspace.view-split { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } +} + + +/* Per-note author color override */ +.use-global-color { + display: inline-grid; + width: 1.75rem; + height: 1.75rem; + padding: 0; + place-items: center; + border: 1px solid var(--border); + border-radius: 50%; + background: var(--surface-2); + color: var(--muted); + cursor: pointer; +} + +.use-global-color:hover { + border-color: var(--accent); + color: var(--text); +} diff --git a/static/home.html b/static/home.html index 8b37681..d69ae8a 100644 --- a/static/home.html +++ b/static/home.html @@ -80,6 +80,7 @@ Author:

+ + +
+ +

Profile

Manage your local RustPad account.

+
+ + +

+ + + + + + +
+ + +

+
+
+
+ \ No newline at end of file diff --git a/static/js/auth-ui.js b/static/js/auth-ui.js index bde6a14..7a3f43b 100644 --- a/static/js/auth-ui.js +++ b/static/js/auth-ui.js @@ -121,6 +121,25 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" } message.classList.remove("success"); message.classList.add("error"); message.textContent = error.message; + if (/confirm the account/i.test(error.message) && email.value.trim()) { + const resend = document.createElement("button"); + resend.type = "button"; + resend.className = "text-button resend-confirmation"; + resend.textContent = "Resend confirmation e-mail"; + resend.addEventListener("click", async () => { + resend.disabled = true; + try { + const result = await api("/api/auth/resend-confirmation", { method: "POST", body: JSON.stringify({ email: email.value.trim() }) }); + message.classList.remove("error"); + message.classList.add("success"); + message.textContent = result.message; + } catch (resendError) { + message.textContent = resendError.message; + resend.disabled = false; + } + }); + message.append(document.createElement("br"), resend); + } } finally { submit.disabled = false; } @@ -255,12 +274,14 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) { } setAuthSession(session); await onIdentity(session.nickname, session); + dialog.close(); return; } nickname.disabled = false; const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname: name, session_token: getAuthToken() || null }) }); setNickname(result.nickname); await onIdentity(result.nickname, null); + dialog.close(); } catch (error) { message.classList.remove("success"); message.classList.add("error"); diff --git a/static/js/home.js b/static/js/home.js index 596064d..cbe33e2 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -84,6 +84,16 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even handleAccountConfirmationToken(); handleResetToken(); +function toast(text) { + const element = document.querySelector("#toast"); + if (!element) return; + element.textContent = text; + element.classList.add("visible"); + clearTimeout(toast.timer); + toast.timer = setTimeout(() => element.classList.remove("visible"), 3000); +} + +{ 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=>toast(r.message)).catch(e=>toast(e.message)).finally(()=>{params.delete("account_action_token");history.replaceState({},"",`${location.pathname}${params.size?`?${params}`:""}${location.hash}`);}); } } const identityDialog = document.querySelector("#identity-dialog"); const guestAccount = document.querySelector("#footer-account-guest"); @@ -94,6 +104,10 @@ const registrationEnabled = document.body.dataset.registrationEnabled === "true" const resourcesDialog = document.querySelector("#resources-dialog"); const resourcesList = document.querySelector("#resources-list"); const resourcesError = document.querySelector("#resources-error"); +const profileDialog = document.querySelector("#profile-dialog"); + +const profileForm = document.querySelector("#profile-form"); +let currentSession = null; function authHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; } @@ -179,7 +193,7 @@ async function loadResources() { linkRow.querySelector("[data-revoke-link]").addEventListener("click", async () => { try { await api("/api/auth/resources/share-links", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token: linkRow.dataset.linkToken }) }); setDialogMessage("Link revoked.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } }); }); }; - userForm.addEventListener("submit", async event => { event.preventDefault(); try { await api("/api/auth/resources/sharing", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, emails: userForm.emails.value, permission: userForm.permission.value }) }); userForm.emails.value = ""; setDialogMessage("Access granted.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } }); + userForm.addEventListener("submit", async event => { event.preventDefault(); try { const result = await api("/api/auth/resources/sharing", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, emails: userForm.emails.value, permission: userForm.permission.value }) }); userForm.emails.value = ""; setDialogMessage(result.confirmation_required ? "Invitation sent. Access will appear after the recipient accepts it." : "Access granted.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } }); linkForm.addEventListener("submit", async event => { event.preventDefault(); try { const expires_at = shareExpiry(linkForm.hours.value, linkForm.forever.checked); const result = await api("/api/auth/resources/share-links", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, permission: linkForm.permission.value, expires_at }) }); const absolute = new URL(result.url, location.origin).href; await copyText(absolute); setDialogMessage("Link created and copied. It remains visible below.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } }); dialog.showModal(); try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } @@ -232,6 +246,7 @@ async function loadResources() { function renderAccount(session) { + currentSession = session; guestAccount.hidden = Boolean(session); userAccount.hidden = !session; if (session) userLabel.textContent = `Signed in as ${session.nickname}`; @@ -252,6 +267,46 @@ if (identityDialog) { authDialog.setMode("register"); identityDialog.showModal(); }); + + document.querySelector("#footer-profile")?.addEventListener("click", () => { + document.querySelector("#profile-nickname").value = currentSession?.nickname || ""; + const profileColor = document.querySelector("#profile-color"); + profileColor.value = currentSession?.editor_color || "#7c6cff"; + document.querySelector("#profile-current-email").value = currentSession?.email || ""; + 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 selectedColor=document.querySelector("#profile-color").value; const result=await api("/api/auth/profile",{method:"POST",headers:authHeaders(),body:JSON.stringify({nickname:document.querySelector("#profile-nickname").value.trim(),editor_color:selectedColor,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; currentSession.editor_color=result.editor_color; renderAccount(currentSession); } catch(e){ message.textContent=e.message; message.classList.add("error"); } + }); + document.querySelector("#profile-delete")?.addEventListener("click", async () => { + const message=document.querySelector("#profile-message"); const password=document.querySelector("#profile-password").value; + message.classList.remove("success", "error"); + if (!password) { message.textContent="Enter the current password first."; message.classList.add("error"); return; } + if (!confirm("Send an e-mail link to permanently delete this account?")) return; + try { const result=await api("/api/auth/account/delete",{method:"POST",headers:authHeaders(),body:JSON.stringify({password})}); message.textContent=result.message; message.classList.add("success"); } catch(e){ message.textContent=e.message; message.classList.add("error"); } + }); + document.querySelector("#footer-resources")?.addEventListener("click", async () => { resourcesDialog.showModal(); await loadResources(); }); document.querySelector("#close-resources")?.addEventListener("click", () => resourcesDialog.close()); resourcesDialog?.addEventListener("click", (event) => { if (event.target === resourcesDialog) resourcesDialog.close(); }); diff --git a/static/js/note-api.js b/static/js/note-api.js index 90b537e..5ae0d70 100644 --- a/static/js/note-api.js +++ b/static/js/note-api.js @@ -15,6 +15,8 @@ export function createPadAdapter() { addressSelector: "#pad-url", title: info => `${info.title} · RustPad`, loadInfo: headers => api(base, { headers }), + loadColor: headers => api(`${base}/editor-color`, { headers }), + saveColor: (headers, color) => api(`${base}/editor-color`, { method: "POST", headers, body: JSON.stringify({ color }) }), fileEndpoints: { list: `${base}/files`, upload: `${base}/files`, @@ -53,6 +55,8 @@ export function createWorkspaceNoteAdapter() { addressSelector: "#note-url", title: info => `${info.title} · ${info.workspace_title}`, loadInfo: headers => api(base, { headers }), + loadColor: headers => api(`${base}/editor-color`, { headers }), + saveColor: (headers, color) => api(`${base}/editor-color`, { method: "POST", headers, body: JSON.stringify({ color }) }), fileEndpoints: { list: `${base}/files`, upload: `${base}/files`, diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 65f8e66..6816908 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -14,27 +14,44 @@ import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url export function startNoteEditor(adapter) { const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer"); const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog"); - const roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"); + const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"); let unreadChat = 0; - const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"); + const 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"), useGlobalColorButton = document.querySelector("#use-global-color"); const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken); - let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = ""; + let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = ""; + const compactLayoutQuery = window.matchMedia("(max-width: 1499px)"); + let compactView = uiState.view === "preview" ? "preview" : "edit"; const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off"; previewLineToggle.checked = localStorage.getItem("rustpad:preview-line-numbers") === "on"; compactToggle.checked = localStorage.getItem("rustpad:compact") !== "off"; fontFamily.value = localStorage.getItem("rustpad:font-family") || "mono"; fontSize.value = localStorage.getItem("rustpad:font-size") || "14"; function defaultColorFor(name) { let h = 0; for (const c of name || "?") h = (h * 31 + c.charCodeAt(0)) % 360; return `hsl(${h} 70% 62%)`; } - function storedColorKey(name) { return `rustpad:user-color:${encodeURIComponent(name || "")}`; } function ownerParts(owner) { const raw = String(owner || ""); const split = raw.lastIndexOf("\u001f"); return split < 0 ? { name: raw, color: "" } : { name: raw.slice(0, split), color: raw.slice(split + 1) }; } function ownerName(owner) { return ownerParts(owner).name; } function colorFor(owner) { const parts = ownerParts(owner); const ownColor = parts.name === nickname ? currentUserColor() : ""; return /^#[0-9a-f]{6}$/i.test(ownColor) ? ownColor : /^#[0-9a-f]{6}$/i.test(parts.color) ? parts.color : defaultColorFor(parts.name); } - function currentUserColor() { return localStorage.getItem(storedColorKey(nickname)) || ""; } + const guestColorKey = `rustpad:guest-color:${adapter.access.kind}:${adapter.access.key}`; + function readGuestColor() { return sessionStorage.getItem(guestColorKey) || ""; } + function writeGuestColor(color) { if (color) sessionStorage.setItem(guestColorKey, color); else sessionStorage.removeItem(guestColorKey); } + function globalUserColor() { return globalColor || ""; } + function noteUserColor() { return noteColor || ""; } + function currentUserColor() { return noteUserColor() || globalUserColor(); } function currentOwner() { const color = currentUserColor(); return color ? `${nickname}\u001f${color}` : nickname; } - function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); userColorPicker.value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; } + function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); const overridden = Boolean(noteUserColor()); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); currentUser.title = overridden ? "Note color override" : "Global profile color"; userColorPicker.value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; useGlobalColorButton.hidden = !overridden; } function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1800); } function sessionHeaders() { const token = accessToken || getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } - async function loadNoteInfo() { info = await adapter.loadInfo(sessionHeaders()); return info; } + function accountHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } + async function loadNoteInfo() { + info = await adapter.loadInfo(sessionHeaders()); + globalColor = info.global_color || ""; noteColor = info.note_color || ""; + if (getAuthToken()) { + const colors = await adapter.loadColor(accountHeaders()); + globalColor = colors.global_color || ""; noteColor = colors.note_color || ""; + } else { + noteColor = readGuestColor(); + } + updateCurrentUser(); return info; + } function updatePresence(users) { const entries = Array.isArray(users) ? users : []; roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...entries.map(entry => { const user = typeof entry === "string" ? { name: entry, color: "" } : entry || {}; const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } } function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; } function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); } @@ -169,14 +186,35 @@ export function startNoteEditor(adapter) { return `${leading ? "| " : ""}${cells.join(" | ")}${trailing ? " |" : ""}`; } function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `
${escapeHtml(line) || "
"}
`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); } - function applyUi({ write = false, replace = false } = {}) { editorWorkspace.className = `workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`; editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`); document.body.classList.toggle("compact-editor", compactToggle.checked); document.querySelectorAll("[data-view]").forEach(b => { const a = b.dataset.view === uiState.view; b.classList.toggle("active", a); b.setAttribute("aria-pressed", String(a)); }); const markdown = uiState.mode === "markdown"; modeToggle.classList.toggle("active", markdown); modeToggle.textContent = markdown ? "Markdown" : "Text"; render(); if (write) writeEditorState(uiState, { replace }); updateAddressLabel(); } + function activeView() { + return compactLayoutQuery.matches ? compactView : uiState.view; + } + + function applyUi({ write = false, replace = false } = {}) { + const view = activeView(); + editorWorkspace.className = `workspace view-${view} editor-workspace-font-${fontFamily.value}`; + editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`); + document.body.classList.toggle("compact-editor", compactToggle.checked); + document.body.classList.toggle("compact-note-layout", compactLayoutQuery.matches); + document.querySelectorAll("[data-view]").forEach(button => { + const active = button.dataset.view === view; + button.classList.toggle("active", active); + button.setAttribute("aria-pressed", String(active)); + }); + const markdown = uiState.mode === "markdown"; + modeToggle.classList.toggle("active", markdown); + modeToggle.textContent = markdown ? "Markdown" : "Text"; + render(); + if (write) writeEditorState(uiState, { replace }); + updateAddressLabel(); + } function applyRemote(content, ownerMap) { if (content === editor.value && ownerMap == null) return; const previous = editor.value, start = editor.selectionStart, end = editor.selectionEnd, direction = editor.selectionDirection, scrollTop = editor.scrollTop, scrollLeft = editor.scrollLeft; const mapped = mapSelectionThroughEdit(previous, content, start, end); applyingRemote = true; editor.value = content; authorship = parseAuthorship(content, ownerMap); previousContent = content; editor.setSelectionRange(mapped.start, mapped.end, direction); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; applyingRemote = false; render(); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; authorshipLayer.scrollTop = scrollTop; authorshipLayer.scrollLeft = scrollLeft; } const { loadFiles } = bindNoteFiles({ editor, toast, getAccessToken: () => accessToken, canDelete: () => Boolean(info?.can_delete_files), endpoints: adapter.fileEndpoints, }); - function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { if (passwordDialog.open) passwordDialog.close(); applyRemote(m.content, m.owner_map); editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { document.querySelector("#password-error").textContent = m; if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); } + function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { if (passwordDialog.open) passwordDialog.close(); const readOnly = m.access_level === "read_only"; editor.readOnly = readOnly; accessLevel.textContent = readOnly ? "Access: read only" : "Access: full"; applyRemote(m.content, m.owner_map); if (!readOnly) editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { const friendly = /read-only access/i.test(m) ? "This note is read only. Enter the password or ask the owner to grant write access." : m; document.querySelector("#password-error").textContent = friendly; if (/read-only access/i.test(m)) { toast(friendly); accessLevel.textContent = "Access: read only"; editor.readOnly = true; return; } if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); } bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } }); identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); }); async function initialize() { @@ -204,7 +242,40 @@ export function startNoteEditor(adapter) { } } - document.querySelectorAll("[data-view]").forEach(b => b.addEventListener("click", () => { uiState = { ...uiState, view: b.dataset.view }; applyUi({ write: true }); })); modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); }); lineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-numbers", lineToggle.checked ? "on" : "off"); renderGutter(); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:preview-line-numbers", previewLineToggle.checked ? "on" : "off"); renderGutter(); alignPreviewLineNumbers(preview); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); }); + document.querySelectorAll("[data-view]").forEach(button => button.addEventListener("click", () => { + if (compactLayoutQuery.matches) { + compactView = button.dataset.view === "preview" ? "preview" : "edit"; + applyUi(); + return; + } + uiState = { ...uiState, view: button.dataset.view }; + applyUi({ write: true }); + })); + compactLayoutQuery.addEventListener("change", () => applyUi()); + const headerMenuToggle = document.querySelector("#header-menu-toggle"); + const headerActions = document.querySelector("#header-actions"); + const setHeaderMenuOpen = open => { + headerActions.classList.toggle("is-open", open); + headerMenuToggle.setAttribute("aria-expanded", String(open)); + headerMenuToggle.setAttribute("aria-label", open ? "Close navigation menu" : "Open navigation menu"); + }; + headerMenuToggle.addEventListener("click", event => { + event.stopPropagation(); + setHeaderMenuOpen(!headerActions.classList.contains("is-open")); + }); + headerActions.addEventListener("click", event => { + if (compactLayoutQuery.matches && event.target.closest("button")) setHeaderMenuOpen(false); + }); + document.addEventListener("click", event => { + if (!event.target.closest(".header-navigation")) setHeaderMenuOpen(false); + }); + document.addEventListener("keydown", event => { + if (event.key === "Escape") setHeaderMenuOpen(false); + }); + compactLayoutQuery.addEventListener("change", event => { + if (!event.matches) setHeaderMenuOpen(false); + }); + modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); }); lineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-numbers", lineToggle.checked ? "on" : "off"); renderGutter(); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:preview-line-numbers", previewLineToggle.checked ? "on" : "off"); renderGutter(); alignPreviewLineNumbers(preview); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); }); window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") }); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`); editor.value = lines.join("\n"); editor.dispatchEvent(new Event("input", { bubbles: true })); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Enter") { event.preventDefault(); target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; const lineIndex = Number(target.dataset.sourceLine) - 1; if (lineIndex < 0) return; const lines = editor.value.split("\n"); const value = markdownFromPreview(target); let next; if (target.dataset.tableCell !== undefined) next = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value); else { const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || ""; next = prefix + value + suffix; } if (lines[lineIndex] === next) return; lines[lineIndex] = next; editor.value = lines.join("\n"); editor.setSelectionRange(editor.value.length, editor.value.length); editor.dispatchEvent(new Event("input", { bubbles: true })); }, { capture: true }); publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await adapter.publish(accessToken, publicTaskUpdates.checked); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { publicTaskUpdates.disabled = false; } }); document.querySelector("#publish-page").addEventListener("click", async () => { try { const result = await adapter.publish(accessToken, publicTaskUpdates.checked); const url = new URL(result.url, location.origin).href; await copyText(url); toast("Page link copied"); window.open(url, "_blank", "noopener"); } catch (error) { toast(error.message); } }); roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } }); @@ -212,26 +283,37 @@ export function startNoteEditor(adapter) { chatForm.addEventListener("submit", event => { event.preventDefault(); const text = chatInput.value.trim(); if (!text || !socket) return; socket.chat(text); chatInput.value = ""; chatInput.focus(); }); if (!chatMessages.children.length) { const empty = document.createElement("p"); empty.className = "chat-empty"; empty.textContent = "No messages yet"; chatMessages.append(empty); } currentUser.addEventListener("click", () => userColorPicker.click()); - userColorPicker.addEventListener("input", () => { - localStorage.setItem(storedColorKey(nickname), userColorPicker.value); + userColorPicker.addEventListener("change", async () => { + noteColor = userColorPicker.value; + if (getAuthToken()) { + try { await adapter.saveColor(accountHeaders(), noteColor); } catch (error) { toast(error.message); await loadNoteInfo(); return; } + } else { + writeGuestColor(noteColor); + toast("Color saved for this tab"); + } const replacement = currentOwner(); authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length); updateCurrentUser(); render(); socket?.setColor(userColorPicker.value); if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length)); }); - editor.addEventListener("keydown", continueIndentation); editor.addEventListener("scroll", () => { gutter.scrollTop = editor.scrollTop; authorshipLayer.scrollTop = editor.scrollTop; authorshipLayer.scrollLeft = editor.scrollLeft; renderGutter(); }); editor.addEventListener("input", () => { const nextContent = editor.value; authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length); authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner()); previousContent = nextContent; render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Saving…"; saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250); }); - document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); accessToken = result.access_token; setAccessToken(adapter.access.kind, adapter.access.key, accessToken); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } }); - const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '

Loading…

'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `
${escapeHtml(author)}

${snippet}

`; }).join("") : '

No history yet.

'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast("Version restored"); }); } } catch (e) { list.innerHTML = `

${escapeHtml(e.message)}

`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); }); - - window.addEventListener("storage", event => { - if (event.key !== storedColorKey(nickname)) return; + useGlobalColorButton.addEventListener("click", async () => { + if (getAuthToken()) { + try { await adapter.saveColor(accountHeaders(), null); } catch (error) { toast(error.message); return; } + } + noteColor = ""; + if (!getAuthToken()) writeGuestColor(""); const replacement = currentOwner(); authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length); updateCurrentUser(); render(); socket?.setColor(currentUserColor() || null); if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length)); + toast("Global profile color restored"); }); + editor.addEventListener("keydown", continueIndentation); editor.addEventListener("scroll", () => { gutter.scrollTop = editor.scrollTop; authorshipLayer.scrollTop = editor.scrollTop; authorshipLayer.scrollLeft = editor.scrollLeft; renderGutter(); }); editor.addEventListener("input", () => { const nextContent = editor.value; authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length); authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner()); previousContent = nextContent; render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Saving…"; saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250); }); + document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); accessToken = result.access_token; setAccessToken(adapter.access.kind, adapter.access.key, accessToken); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } }); + const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '

Loading…

'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `
${escapeHtml(author)}

${snippet}

`; }).join("") : '

No history yet.

'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast("Version restored"); }); } } catch (e) { list.innerHTML = `

${escapeHtml(e.message)}

`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); }); + const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast(error.message); } }); window.addEventListener("error", event => { setStatus("offline", "Application error"); console.error(event.error || event.message); }); window.addEventListener("unhandledrejection", event => { setStatus("offline", "Application error"); console.error(event.reason); }); diff --git a/static/note.html b/static/note.html index c8c9d20..ebc6098 100644 --- a/static/note.html +++ b/static/note.html @@ -20,16 +20,23 @@

-
+ +
+ +
+
@@ -131,7 +138,7 @@ · · Access: checking… · · Changes are saved automatically diff --git a/static/pad.html b/static/pad.html index 4d6117b..3319d8f 100644 --- a/static/pad.html +++ b/static/pad.html @@ -13,22 +13,29 @@
-
RustPad +
RustPad

__PAD_TITLE__

-
+ +
+ +
+
@@ -129,7 +136,7 @@ · · Access: checking… · · Changes are saved automatically diff --git a/static/public.html b/static/public.html index 9184562..57e817b 100644 --- a/static/public.html +++ b/static/public.html @@ -13,7 +13,7 @@
- RustPad + RustPad
diff --git a/static/workspace.html b/static/workspace.html index a2862cb..e2db503 100644 --- a/static/workspace.html +++ b/static/workspace.html @@ -13,7 +13,7 @@
-
RustPad +
RustPad

__WORKSPACE_TITLE__

diff --git a/systemd/README.md b/systemd/README.md new file mode 100644 index 0000000..78ae5d3 --- /dev/null +++ b/systemd/README.md @@ -0,0 +1,10 @@ +# systemd installation + +1. Install the application files under `/opt/rustpad` and the binary as `/usr/local/bin/rustpad`. +2. Create the service account: `useradd --system --home /var/lib/rustpad --shell /usr/sbin/nologin rustpad`. +3. Copy `rustpad.yaml` to `/etc/rustpad/rustpad.yaml` and restrict secrets: `chmod 640 /etc/rustpad/rustpad.yaml`. +4. Copy `rustpad.service` to `/etc/systemd/system/rustpad.service`. +5. Validate before starting: `/usr/local/bin/rustpad --config /etc/rustpad/rustpad.yaml check-config`. +6. Run `systemctl daemon-reload && systemctl enable --now rustpad`. + +Environment variables and an optional `.env` file override the YAML values. This allows secrets to be supplied by the service manager without editing the main configuration file. diff --git a/systemd/rustpad.service b/systemd/rustpad.service new file mode 100644 index 0000000..be324da --- /dev/null +++ b/systemd/rustpad.service @@ -0,0 +1,39 @@ +[Unit] +Description=RustPad collaborative notepad +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=rustpad +Group=rustpad +WorkingDirectory=/opt/rustpad +ExecStartPre=/usr/local/bin/rustpad --config /etc/rustpad/rustpad.yaml check-config +ExecStart=/usr/local/bin/rustpad --config /etc/rustpad/rustpad.yaml +Restart=on-failure +RestartSec=5s +TimeoutStopSec=30s +KillSignal=SIGTERM + +StateDirectory=rustpad +StateDirectoryMode=0750 +UMask=0027 +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +RestrictSUIDSGID=true +RestrictRealtime=true +LockPersonality=true +MemoryDenyWriteExecute=true +CapabilityBoundingSet= +AmbientCapabilities= +#ReadWritePaths=/var/lib/rustpad + +[Install] +WantedBy=multi-user.target diff --git a/systemd/rustpad.yaml b/systemd/rustpad.yaml new file mode 100644 index 0000000..57ed796 --- /dev/null +++ b/systemd/rustpad.yaml @@ -0,0 +1,63 @@ +# Environment variables override values from this file. +app: + host: 127.0.0.1 + port: 3000 + +database: + url: "sqlite:///var/lib/rustpad/db/rustpad.db?mode=rwc" + max_connections: 8 + +static_dir: /opt/rustpad/static +files_dir: /var/lib/rustpad/files +storage_driver: local +upload_max_size_mb: 20 +asset_cache_max_age_seconds: 600 +file_cache_max_age_seconds: 600 + +registration_enabled: false +account_confirmation_required: false +share_confirmation_required: false +frontend_log_level: warn +anonymous_access_token_ttl_days: 7 +user_session_ttl_days: 3 +unconfirmed_account_ttl_days: 3 + +authorization: + type: local + +# Uncomment for LDAP/AD and set authorization.type to ldap or ad. +# ldap: +# url: "ldaps://ldap.example.org:636" +# starttls: false +# bind_dn: "cn=rustpad,ou=services,dc=example,dc=org" +# bind_password: "change-me" +# base_dn: "ou=people,dc=example,dc=org" +# user_filter: "(uid={username})" +# username_attribute: uid +# email_attribute: mail +# display_name_attribute: displayName +# external_id_attribute: entryUUID +# organization: example +# email_required: true +# link_existing_by_email: false +# tls_verify: true +# connect_timeout_seconds: 5 +# operation_timeout_seconds: 10 + +# Uncomment to enable SMTP. +# smtp: +# host: smtp.example.org +# port: 587 +# username: rustpad +# password: "change-me" +# from: "RustPad " +# public_url: "https://pad.example.org" + +# For S3, set storage_driver: s3 and configure: +# s3: +# endpoint: "https://s3.example.org" +# region: eu-central-1 +# bucket: rustpad +# access_key: "change-me" +# secret_key: "change-me" +# force_path_style: true