diff --git a/.env.example b/.env.example index 780e492..bb9a4ea 100644 --- a/.env.example +++ b/.env.example @@ -34,4 +34,13 @@ POSTGRES_PASSWORD=rustpad MYSQL_DATABASE=rustpad MYSQL_USER=rustpad MYSQL_PASSWORD=rustpad -MYSQL_ROOT_PASSWORD=rustpad_root \ No newline at end of file +MYSQL_ROOT_PASSWORD=rustpad_root + +# Optional account password reset via SMTP +REGISTRATION_ENABLED=false +PUBLIC_URL=https://pad.example.com +# SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM="RustPad " diff --git a/Cargo.lock b/Cargo.lock index 458cb2e..e1265f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,17 @@ dependencies = [ "password-hash", ] +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atoi" version = "2.0.0" @@ -315,7 +326,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -333,6 +344,22 @@ dependencies = [ "serde", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -380,6 +407,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -464,7 +497,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -841,6 +874,33 @@ dependencies = [ "spin", ] +[[package]] +name = "lettre" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" +dependencies = [ + "async-trait", + "base64", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots 1.0.8", +] + [[package]] name = "libc" version = "0.2.186" @@ -972,6 +1032,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1162,6 +1231,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -1302,6 +1377,7 @@ version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -1339,13 +1415,16 @@ dependencies = [ "chrono", "dotenvy", "futures-util", + "lettre", "mime_guess", "rand_core 0.6.4", "serde", "serde_json", + "sha2", "slug", "sqlx", "tokio", + "tower", "tower-http", "tracing", "tracing-subscriber", @@ -1396,7 +1475,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1606,7 +1685,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.119", ] [[package]] @@ -1629,7 +1708,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.119", "tokio", "url", ] @@ -1774,6 +1853,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -1788,7 +1878,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1808,7 +1898,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1869,7 +1959,17 @@ checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", ] [[package]] @@ -1982,7 +2082,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2174,7 +2274,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -2236,7 +2336,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2247,7 +2347,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2462,7 +2562,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -2483,7 +2583,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2503,7 +2603,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -2543,7 +2643,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1699950..9c55874 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,12 +13,15 @@ chrono = { version = "0.4", features = ["serde"] } dotenvy = "0.15" futures-util = "0.3" mime_guess = "2" +lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1-rustls-tls"] } +sha2 = "0.10" rand_core = { version = "0.6", features = ["getrandom"] } serde = { version = "1", features = ["derive"] } serde_json = "1" slug = "0.1" sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "any", "sqlite", "postgres", "mysql", "chrono", "migrate"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "signal"] } -tower-http = { version = "0.6", features = ["fs", "trace"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["fs", "trace", "set-header"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/README.md b/README.md index a01fe3f..46ff677 100644 --- a/README.md +++ b/README.md @@ -60,3 +60,13 @@ DATABASE_URL=mysql://rustpad:rustpad@mysql:3306/rustpad docker compose up -d rus ``` Migracje są rozdzielone w `migrations/sqlite`, `migrations/postgres` i `migrations/mysql`. Zapytania aplikacji znajdują się centralnie w `src/queries.rs`, a `src/database.rs` odpowiada za wybór sterownika i konfigurację połączenia. + + +## Optional user accounts and password reset + +Nicknames can still be used anonymously while they remain unregistered. Registering a nickname reserves it and requires a valid login session before it can be used in editor WebSocket connections. Configure `PUBLIC_URL`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, and `SMTP_FROM` to enable password-reset e-mails. Reset links expire after 30 minutes and are single-use. + +## Database query layout + +All runtime SQL statements are centralized in `src/queries.rs`. Backend modules reference named constants, which keeps database-specific debugging and query review in one place. + diff --git a/docker-compose.yml b/docker-compose.yml index 6f697e2..f822abf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,14 @@ services: FILES_DIR: ${FILES_DIR:-/data/files} UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB:-20} ASSET_VERSION: ${ASSET_VERSION:-dev} + REGISTRATION_ENABLED: ${REGISTRATION_ENABLED:-false} RUST_LOG: ${RUST_LOG:-rustpad=info,tower_http=info} + PUBLIC_URL: ${PUBLIC_URL:-http://localhost:3000} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USERNAME: ${SMTP_USERNAME:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_FROM: ${SMTP_FROM:-} ports: - "${RUSTPAD_PORT:-3000}:${APP_PORT:-3000}" volumes: diff --git a/migrations/mysql/0006_users_auth.sql b/migrations/mysql/0006_users_auth.sql new file mode 100644 index 0000000..debe991 --- /dev/null +++ b/migrations/mysql/0006_users_auth.sql @@ -0,0 +1,27 @@ +CREATE TABLE users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + nickname VARCHAR(80) NOT NULL UNIQUE, + nickname_key VARCHAR(80) NOT NULL UNIQUE, + email VARCHAR(320) NOT NULL, + email_key VARCHAR(320) NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + updated_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP) +) ENGINE=InnoDB; +CREATE TABLE user_sessions ( + token VARCHAR(128) PRIMARY KEY, + user_id BIGINT NOT NULL, + expires_at VARCHAR(64) NOT NULL, + created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT fk_user_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user_sessions_user (user_id) +) ENGINE=InnoDB; +CREATE TABLE password_reset_tokens ( + token VARCHAR(128) PRIMARY KEY, + user_id BIGINT NOT NULL, + expires_at VARCHAR(64) NOT NULL, + used_at VARCHAR(64), + created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT fk_password_reset_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_password_reset_user (user_id) +) ENGINE=InnoDB; diff --git a/migrations/postgres/0006_users_auth.sql b/migrations/postgres/0006_users_auth.sql new file mode 100644 index 0000000..1da8548 --- /dev/null +++ b/migrations/postgres/0006_users_auth.sql @@ -0,0 +1,25 @@ +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + nickname TEXT NOT NULL UNIQUE, + nickname_key TEXT NOT NULL UNIQUE, + email TEXT NOT NULL, + email_key TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) +); +CREATE TABLE user_sessions ( + token TEXT PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) +); +CREATE INDEX idx_user_sessions_user ON user_sessions(user_id); +CREATE TABLE password_reset_tokens ( + token TEXT PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) +); +CREATE INDEX idx_password_reset_user ON password_reset_tokens(user_id); diff --git a/migrations/sqlite/0006_users_auth.sql b/migrations/sqlite/0006_users_auth.sql new file mode 100644 index 0000000..70c55b1 --- /dev/null +++ b/migrations/sqlite/0006_users_auth.sql @@ -0,0 +1,25 @@ +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nickname TEXT NOT NULL UNIQUE, + nickname_key TEXT NOT NULL UNIQUE, + email TEXT NOT NULL, + email_key TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE user_sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_user_sessions_user ON user_sessions(user_id); +CREATE TABLE password_reset_tokens ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_password_reset_user ON password_reset_tokens(user_id); diff --git a/src/app.rs b/src/app.rs index 75d7c8f..76b2941 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,9 +5,10 @@ use axum::{ routing::{get, post}, Router, }; -use tower_http::{services::ServeDir, trace::TraceLayer}; +use tower::ServiceBuilder; +use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer}; -use crate::{api, db, state::SharedState, websocket}; +use crate::{api, auth, db, state::SharedState, websocket}; pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize) -> Router { Router::new() @@ -19,6 +20,13 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize .route("/health", get(health)) .route("/f/{token}/{filename}", get(api::download_file)) .route("/files/{directory}/{filename}", get(api::download_legacy_file)) + .route("/api/auth/identity", post(auth::identity)) + .route("/api/auth/register", post(auth::register)) + .route("/api/auth/login", post(auth::login)) + .route("/api/auth/me", get(auth::me)) + .route("/api/auth/logout", post(auth::logout)) + .route("/api/auth/password-reset", post(auth::request_reset)) + .route("/api/auth/password-reset/confirm", post(auth::confirm_reset)) .route("/api/public/{token}", get(api::public_page)) .route("/api/public/{token}/tasks", post(api::update_public_task)) .route("/api/pads", post(api::create_pad)) @@ -62,8 +70,17 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize ) .route("/static", get(static_not_found)) .route("/static/{*path}", get(static_not_found)) - .nest_service("/assets", ServeDir::new(static_dir)) + .nest_service( + "/assets", + ServiceBuilder::new() + .layer(SetResponseHeaderLayer::overriding( + header::CACHE_CONTROL, + HeaderValue::from_static("private, must-revalidate"), + )) + .service(ServeDir::new(static_dir)), + ) .fallback(not_found) + .method_not_allowed_fallback(method_not_allowed) .layer(DefaultBodyLimit::max(upload_max_size_bytes.saturating_add(1024 * 1024))) .layer(TraceLayer::new_for_http()) .with_state(state) @@ -74,7 +91,7 @@ async fn health() -> &'static str { } async fn home(State(state): State) -> Response { - versioned_html(include_str!("../static/home.html"), &state.asset_version) + versioned_html(include_str!("../static/home.html"), &state.asset_version, state.registration_enabled) } async fn pad( @@ -85,7 +102,7 @@ async fn pad( Ok(Some(pad)) => { let html = include_str!("../static/pad.html") .replace("__PAD_TITLE__", &escape_html(&pad.title)); - versioned_html(&html, &state.asset_version) + versioned_html(&html, &state.asset_version, state.registration_enabled) }, Ok(None) => error_response( StatusCode::NOT_FOUND, @@ -108,7 +125,7 @@ async fn public_page( Path(token): Path, ) -> Response { match db::find_published_page(&state.db, &token).await { - Ok(Some(_)) => versioned_html(include_str!("../static/public.html"), &state.asset_version), + Ok(Some(_)) => versioned_html(include_str!("../static/public.html"), &state.asset_version, state.registration_enabled), Ok(None) => error_response( StatusCode::NOT_FOUND, "404", @@ -133,7 +150,7 @@ async fn workspace( Ok(Some(workspace)) => { let html = include_str!("../static/workspace.html") .replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title)); - versioned_html(&html, &state.asset_version) + versioned_html(&html, &state.asset_version, state.registration_enabled) }, Ok(None) => error_response( StatusCode::NOT_FOUND, @@ -180,7 +197,7 @@ async fn note( .replace("__NOTE_TITLE__", &escape_html(¬e.title)) .replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title)) .replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug)); - versioned_html(&html, &state.asset_version) + versioned_html(&html, &state.asset_version, state.registration_enabled) }, Ok(None) => error_response( StatusCode::NOT_FOUND, @@ -198,13 +215,28 @@ async fn note( } } -async fn static_not_found() -> Response { - let mut response = (StatusCode::NOT_FOUND, "404").into_response(); - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - response +async fn static_not_found(State(state): State) -> Response { + error_response( + StatusCode::NOT_FOUND, + "404", + "File not found", + "The requested static file does not exist.", + "/", + "Home page", + &state.asset_version, + ) +} + +async fn method_not_allowed(State(state): State) -> Response { + error_response( + StatusCode::METHOD_NOT_ALLOWED, + "405", + "Method not allowed", + "This address does not support the requested operation.", + "/", + "Home page", + &state.asset_version, + ) } async fn not_found(State(state): State) -> Response { @@ -253,8 +285,10 @@ fn error_response( response } -fn versioned_html(template: &str, asset_version: &str) -> Response { - let html = template.replace("__ASSET_VERSION__", asset_version); +fn versioned_html(template: &str, asset_version: &str, registration_enabled: bool) -> Response { + let html = template + .replace("__ASSET_VERSION__", asset_version) + .replace("__REGISTRATION_ENABLED__", if registration_enabled { "true" } else { "false" }); let mut response = Html(html).into_response(); no_store(&mut response); response diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..4fb6c91 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,188 @@ +use argon2::{password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2}; +use axum::{extract::State, http::{HeaderMap, StatusCode}, Json}; +use chrono::{Duration, Utc}; +use lettre::{message::Mailbox, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, transport::smtp::authentication::Credentials}; +use rand_core::{OsRng, RngCore}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::FromRow; + +use crate::{queries, state::{SharedState, SmtpConfig}}; + +const MIN_PASSWORD: usize = 8; +const MAX_PASSWORD: usize = 128; +const MAX_NICKNAME: usize = 40; + +#[derive(Debug, Clone, FromRow)] +pub struct User { pub id: i64, pub nickname: String, pub email: String, pub password_hash: String } + +#[derive(Deserialize)] pub struct IdentityRequest { nickname: String, #[serde(default)] session_token: Option } +#[derive(Deserialize)] pub struct RegisterRequest { nickname: String, email: String, password: String } +#[derive(Deserialize)] pub struct LoginRequest { email: String, password: String } +#[derive(Deserialize)] pub struct ResetRequest { email: String } +#[derive(Deserialize)] pub struct ResetConfirmRequest { token: String, password: String } +#[derive(Serialize)] pub struct SessionResponse { token: String, nickname: String, email: String, expires_at: String } +#[derive(Serialize)] pub struct IdentityResponse { nickname: String, registered: bool } + +pub async fn identity(State(state): State, Json(req): Json) -> Result, AuthError> { + let nickname = validate_nickname(&req.nickname)?; + match find_user_by_nickname(&state, &nickname).await? { + None => Ok(Json(IdentityResponse { nickname, registered: false })), + Some(user) => { + let token = req.session_token.as_deref().ok_or_else(|| AuthError::unauthorized("This nickname is registered. Log in to use it."))?; + let current = user_from_token(&state, token).await?.ok_or_else(|| AuthError::unauthorized("Your session has expired. Log in again."))?; + if current.id != user.id { return Err(AuthError::unauthorized("This nickname belongs to another account.")); } + Ok(Json(IdentityResponse { nickname: user.nickname, registered: true })) + } + } +} + +pub async fn register(State(state): State, Json(req): Json) -> Result<(StatusCode, Json), AuthError> { + if !state.registration_enabled { return Err(AuthError::forbidden("Registration is disabled.")); } + let nickname = validate_nickname(&req.nickname)?; + let email = validate_email(&req.email)?; + validate_password(&req.password)?; + let nickname_key = normalize(&nickname); + let email_key = normalize(&email); + if find_user_by_nickname(&state, &nickname).await?.is_some() { return Err(AuthError::conflict("This nickname is already registered.")); } + if find_user_by_email(&state, &email).await?.is_some() { return Err(AuthError::conflict("This e-mail address is already registered.")); } + let hash = hash_password(&req.password)?; + sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_USER)) + .bind(&nickname).bind(nickname_key).bind(&email).bind(email_key).bind(hash).execute(state.db.pool()).await + .map_err(AuthError::database)?; + let user = find_user_by_nickname(&state, &nickname).await?.ok_or_else(|| AuthError::internal("Failed to create the account."))?; + Ok((StatusCode::CREATED, Json(create_session(&state, &user).await?))) +} + +pub async fn login(State(state): State, Json(req): Json) -> Result, AuthError> { + let email = validate_email(&req.email)?; + let user = find_user_by_email(&state, &email).await?.ok_or_else(|| AuthError::unauthorized("Invalid e-mail address or password."))?; + if !verify_password(&user.password_hash, &req.password) { return Err(AuthError::unauthorized("Invalid e-mail address or password.")); } + Ok(Json(create_session(&state, &user).await?)) +} + +pub async fn me(State(state): State, headers: HeaderMap) -> Result, AuthError> { + let token = bearer(&headers).ok_or_else(|| AuthError::unauthorized("Not logged in."))?; + let user = user_from_token(&state, token).await?.ok_or_else(|| AuthError::unauthorized("Your session has expired."))?; + let expires_at: String = sqlx::query_scalar(queries::get(state.db.kind(), queries::AUTH_SESSION_EXPIRES_AT)) + .bind(token).fetch_one(state.db.pool()).await.map_err(AuthError::database)?; + Ok(Json(SessionResponse { token: token.into(), nickname: user.nickname, email: user.email, expires_at })) +} + +pub async fn logout(State(state): State, headers: HeaderMap) -> Result, AuthError> { + if let Some(token) = bearer(&headers) { sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_SESSION_BY_TOKEN)).bind(token).execute(state.db.pool()).await.map_err(AuthError::database)?; } + Ok(Json(serde_json::json!({"ok": true}))) +} + +pub async fn request_reset(State(state): State, Json(req): Json) -> Result, AuthError> { + let email = validate_email(&req.email)?; + let smtp = state.smtp.as_ref().ok_or_else(|| AuthError::service_unavailable("Password reset is not configured on this server."))?; + if let Some(user) = find_user_by_email(&state, &email).await? { + let token = random_token(); + let expires = (Utc::now() + Duration::minutes(30)).to_rfc3339(); + sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_RESET_TOKENS_BY_USER)).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; + sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_RESET_TOKEN)) + .bind(hash_token(&token)).bind(user.id).bind(expires).execute(state.db.pool()).await.map_err(AuthError::database)?; + send_reset(smtp, &user, &token).await?; + } + Ok(Json(serde_json::json!({"ok": true, "message": "If the account exists, a reset link has been sent."}))) +} + +pub async fn confirm_reset(State(state): State, Json(req): Json) -> Result, AuthError> { + validate_password(&req.password)?; + let now_time = Utc::now(); + let now = now_time.to_rfc3339(); + let token_hash = hash_token(req.token.trim()); + let token_row: Option<(i64, String, Option)> = sqlx::query_as(queries::get(state.db.kind(), queries::AUTH_FIND_RESET_TOKEN)) + .bind(&token_hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?; + let (user_id, expires_at, used_at) = token_row.ok_or_else(|| AuthError::bad_request("The reset link is invalid or has expired."))?; + let expires_at = chrono::DateTime::parse_from_rfc3339(&expires_at) + .map_err(|_| AuthError::bad_request("The reset link is invalid or has expired."))? + .with_timezone(&Utc); + if used_at.is_some() || expires_at <= now_time { + return Err(AuthError::bad_request("The reset link is invalid or has expired.")); + } + let password_hash = hash_password(&req.password)?; + let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; + let updated = sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_PASSWORD)) + .bind(password_hash).bind(&now).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?; + if updated.rows_affected() != 1 { return Err(AuthError::internal("The account could not be updated.")); } + sqlx::query(queries::get(state.db.kind(), queries::AUTH_MARK_RESET_TOKEN_USED)) + .bind(&now).bind(&token_hash).execute(&mut *tx).await.map_err(AuthError::database)?; + sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_SESSIONS_BY_USER)) + .bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?; + tx.commit().await.map_err(AuthError::database)?; + Ok(Json(serde_json::json!({"ok": true}))) +} + +pub async fn user_from_token(state: &SharedState, token: &str) -> Result, AuthError> { + let now = Utc::now().to_rfc3339(); + sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_SESSION)) + .bind(token).bind(now).fetch_optional(state.db.pool()).await.map_err(AuthError::database) +} + +pub async fn authorize_nickname(state: &SharedState, nickname: Option, token: Option) -> Result, String> { + let Some(nickname) = nickname else { return Ok(None); }; + let nickname = validate_nickname(&nickname).map_err(|e| e.message)?; + let registered = find_user_by_nickname(state, &nickname).await.map_err(|_| "Database error".to_string())?; + match registered { + None => Ok(Some(nickname)), + Some(owner) => { + let Some(token) = token else { return Err("This nickname is registered. Log in to use it.".into()); }; + let current = user_from_token(state, &token).await.map_err(|_| "Database error".to_string())?; + match current { Some(user) if user.id == owner.id => Ok(Some(owner.nickname)), _ => Err("This nickname belongs to another account or the session expired.".into()) } + } + } +} + +async fn create_session(state: &SharedState, user: &User) -> Result { + let token = random_token(); + let expires_at = (Utc::now() + Duration::days(30)).to_rfc3339(); + sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_SESSION)) + .bind(&token).bind(user.id).bind(&expires_at).execute(state.db.pool()).await.map_err(AuthError::database)?; + Ok(SessionResponse { token, nickname: user.nickname.clone(), email: user.email.clone(), expires_at }) +} +async fn find_user_by_nickname(state: &SharedState, nickname: &str) -> Result, AuthError> { + sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_NICKNAME)) + .bind(normalize(nickname)).fetch_optional(state.db.pool()).await.map_err(AuthError::database) +} +async fn find_user_by_email(state: &SharedState, email: &str) -> Result, AuthError> { + sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_EMAIL)) + .bind(normalize(email)).fetch_optional(state.db.pool()).await.map_err(AuthError::database) +} +fn validate_nickname(v: &str) -> Result { let v=v.trim(); if v.is_empty() || v.chars().count()>MAX_NICKNAME { return Err(AuthError::bad_request("Nickname must contain 1 to 40 characters.")); } if v.chars().any(|c| c.is_control()) { return Err(AuthError::bad_request("Nickname contains invalid characters.")); } Ok(v.into()) } +fn validate_email(v: &str) -> Result { let v=v.trim(); if v.len()>320 || !v.contains('@') || v.starts_with('@') || v.ends_with('@') { return Err(AuthError::bad_request("Enter a valid e-mail address.")); } Ok(v.into()) } +fn validate_password(v: &str) -> Result<(), AuthError> { if v.len()MAX_PASSWORD { Err(AuthError::bad_request("Password must contain 8 to 128 characters.")) } else { Ok(()) } } +fn normalize(v: &str)->String { v.trim().to_lowercase() } +fn hash_password(v:&str)->Result{let salt=SaltString::generate(&mut OsRng);Argon2::default().hash_password(v.as_bytes(),&salt).map(|h|h.to_string()).map_err(|_|AuthError::internal("Failed to secure the password."))} +fn verify_password(hash:&str,v:&str)->bool{PasswordHash::new(hash).ok().and_then(|h|Argon2::default().verify_password(v.as_bytes(),&h).ok()).is_some()} +fn random_token()->String{let mut bytes=[0u8;32];let mut rng=OsRng;rng.fill_bytes(&mut bytes);bytes.iter().map(|b|format!("{b:02x}")).collect()} +fn hash_token(v:&str)->String{format!("{:x}",Sha256::digest(v.as_bytes()))} +fn bearer(headers:&HeaderMap)->Option<&str>{headers.get("authorization")?.to_str().ok()?.strip_prefix("Bearer ")} +async fn send_reset(smtp:&SmtpConfig,user:&User,token:&str)->Result<(),AuthError>{ + let url=format!("{}/?reset_token={}",smtp.public_url.trim_end_matches('/'),token); + let message=Message::builder().from(smtp.from.parse::().map_err(|_|AuthError::internal("Invalid SMTP_FROM."))?).to(user.email.parse::().map_err(|_|AuthError::internal("Invalid recipient address."))?).subject("RustPad password reset").body(format!("Hello {},\n\nUse this link within 30 minutes to set a new password:\n{}\n\nIf you did not request this, ignore this message.",user.nickname,url)).map_err(|_|AuthError::internal("Failed to build reset e-mail."))?; + // Port 465 uses implicit TLS. Standard submission ports (usually 587) + // require STARTTLS; using implicit TLS there causes an immediate SMTP failure. + let mut builder = if smtp.port == 465 { + AsyncSmtpTransport::::relay(&smtp.host) + } else { + AsyncSmtpTransport::::starttls_relay(&smtp.host) + } + .map_err(|error| { + tracing::error!(error=%error, host=%smtp.host, port=smtp.port, "invalid SMTP configuration"); + AuthError::internal("Invalid SMTP configuration.") + })? + .port(smtp.port); + if !smtp.username.is_empty() { builder=builder.credentials(Credentials::new(smtp.username.clone(),smtp.password.clone())); } + let mailer=builder.build(); + mailer.send(message).await.map_err(|error| { + tracing::error!(error=%error, host=%smtp.host, port=smtp.port, "password reset e-mail failed"); + AuthError::service_unavailable("The reset e-mail could not be sent. Check the SMTP configuration.") + })?; + Ok(()) +} + +pub struct AuthError { status: StatusCode, pub message: String } +impl AuthError { fn bad_request(m:&str)->Self{Self{status:StatusCode::BAD_REQUEST,message:m.into()}} fn unauthorized(m:&str)->Self{Self{status:StatusCode::UNAUTHORIZED,message:m.into()}} fn forbidden(m:&str)->Self{Self{status:StatusCode::FORBIDDEN,message:m.into()}} fn conflict(m:&str)->Self{Self{status:StatusCode::CONFLICT,message:m.into()}} fn internal(m:&str)->Self{Self{status:StatusCode::INTERNAL_SERVER_ERROR,message:m.into()}} fn service_unavailable(m:&str)->Self{Self{status:StatusCode::SERVICE_UNAVAILABLE,message:m.into()}} fn database(e:sqlx::Error)->Self{tracing::error!(error=%e,"authentication database error");Self::internal("Database error.")} } +impl axum::response::IntoResponse for AuthError { fn into_response(self)->axum::response::Response{(self.status,Json(serde_json::json!({"error":self.message}))).into_response()} } diff --git a/src/config.rs b/src/config.rs index 0d1c580..3b9a222 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,8 @@ pub struct Config { pub files_dir: String, pub upload_max_size_bytes: usize, pub asset_version: String, + pub smtp: Option, + pub registration_enabled: bool, } impl Config { @@ -26,6 +28,18 @@ impl Config { return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into()); } + let smtp_host = std::env::var("SMTP_HOST").ok().filter(|v| !v.trim().is_empty()); + let smtp = if let Some(host) = 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")?, + }) + } else { None }; + Ok(Self { host, port, @@ -39,7 +53,9 @@ impl Config { 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_version: env_var("ASSET_VERSION", env!("CARGO_PKG_VERSION")), + smtp, + registration_enabled: env_bool("REGISTRATION_ENABLED", false)?, }) } } @@ -47,3 +63,14 @@ impl Config { fn env_var(name: &str, default: &str) -> String { env::var(name).unwrap_or_else(|_| default.to_owned()) } + +fn env_bool(name: &str, default: bool) -> Result> { + match env::var(name) { + Ok(value) => match value.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), + } +} diff --git a/src/database.rs b/src/database.rs index a7b82b3..3f13825 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,3 +1,4 @@ +use crate::queries; use sqlx::{any::AnyPoolOptions, AnyPool}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -22,9 +23,9 @@ impl Database { .connect(url) .await?; if kind == DatabaseKind::Sqlite { - sqlx::query("PRAGMA foreign_keys = ON").execute(&pool).await?; - sqlx::query("PRAGMA journal_mode = WAL").execute(&pool).await?; - sqlx::query("PRAGMA busy_timeout = 5000").execute(&pool).await?; + sqlx::query(queries::SQLITE_FOREIGN_KEYS_ON).execute(&pool).await?; + sqlx::query(queries::SQLITE_JOURNAL_WAL).execute(&pool).await?; + sqlx::query(queries::SQLITE_BUSY_TIMEOUT).execute(&pool).await?; } Ok(Self { pool, kind }) } diff --git a/src/db.rs b/src/db.rs index 6b3b2ed..1b06aa4 100644 --- a/src/db.rs +++ b/src/db.rs @@ -11,11 +11,11 @@ use sqlx::{Any, Transaction}; async fn inserted_id(kind: DatabaseKind, tx: &mut Transaction<'_, Any>, table: &str) -> Result { let query = match kind { - DatabaseKind::Sqlite => "SELECT last_insert_rowid()", - DatabaseKind::MySql => "SELECT LAST_INSERT_ID()", + DatabaseKind::Sqlite => queries::SQLITE_LAST_INSERT_ID, + DatabaseKind::MySql => queries::MYSQL_LAST_INSERT_ID, DatabaseKind::Postgres => match table { - "note_revisions" => "SELECT currval(pg_get_serial_sequence('note_revisions', 'id'))", - "revisions" => "SELECT currval(pg_get_serial_sequence('revisions', 'id'))", + "note_revisions" => queries::POSTGRES_NOTE_REVISION_LAST_INSERT_ID, + "revisions" => queries::POSTGRES_PAD_REVISION_LAST_INSERT_ID, _ => unreachable!("unsupported identity table"), }, }; @@ -36,7 +36,8 @@ pub struct Workspace { pub struct Note { pub id: i64, #[serde(skip_serializing)] - pub workspace_id: i64, + #[sqlx(rename = "workspace_id")] + pub _workspace_id: i64, pub slug: String, pub title: String, #[serde(skip_serializing)] @@ -66,7 +67,7 @@ impl From for Note { fn from(value: SqliteNote) -> Self { Self { id: value.id, - workspace_id: value.workspace_id, + _workspace_id: value.workspace_id, slug: value.slug, title: value.title, content: value.content, diff --git a/src/main.rs b/src/main.rs index 8775bb0..2fa836d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod api; +mod auth; mod app; mod config; mod database; @@ -34,6 +35,8 @@ async fn main() -> Result<(), Box> { config.asset_version.clone(), config.files_dir.clone(), config.upload_max_size_bytes, + config.smtp.clone(), + config.registration_enabled, )); let app = app::router( state, diff --git a/src/queries.rs b/src/queries.rs index 5dd191f..174fc66 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -1,6 +1,31 @@ use std::{collections::HashMap, sync::{Mutex, OnceLock}}; use crate::database::DatabaseKind; + +// Database bootstrap and identity helpers. +pub const SQLITE_FOREIGN_KEYS_ON: &str = "PRAGMA foreign_keys = ON"; +pub const SQLITE_JOURNAL_WAL: &str = "PRAGMA journal_mode = WAL"; +pub const SQLITE_BUSY_TIMEOUT: &str = "PRAGMA busy_timeout = 5000"; +pub const SQLITE_LAST_INSERT_ID: &str = "SELECT last_insert_rowid()"; +pub const MYSQL_LAST_INSERT_ID: &str = "SELECT LAST_INSERT_ID()"; +pub const POSTGRES_NOTE_REVISION_LAST_INSERT_ID: &str = "SELECT currval(pg_get_serial_sequence('note_revisions', 'id'))"; +pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: &str = "SELECT currval(pg_get_serial_sequence('revisions', 'id'))"; + +// Authentication queries. +pub const AUTH_INSERT_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) VALUES (?, ?, ?, ?, ?)"; +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_DELETE_RESET_TOKENS_BY_USER: &str = "DELETE FROM password_reset_tokens WHERE user_id = ?"; +pub const AUTH_INSERT_RESET_TOKEN: &str = "INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?)"; +pub const AUTH_FIND_RESET_TOKEN: &str = "SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?"; +pub const AUTH_UPDATE_PASSWORD: &str = "UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?"; +pub const AUTH_MARK_RESET_TOKEN_USED: &str = "UPDATE password_reset_tokens SET used_at = ? WHERE token = ?"; +pub const AUTH_DELETE_SESSIONS_BY_USER: &str = "DELETE FROM user_sessions WHERE user_id = ?"; +pub const AUTH_USER_BY_SESSION: &str = "SELECT u.id, u.nickname, u.email, u.password_hash FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ?"; +pub const AUTH_INSERT_SESSION: &str = "INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"; +pub const AUTH_USER_BY_NICKNAME: &str = "SELECT id, nickname, email, password_hash FROM users WHERE nickname_key = ?"; +pub const AUTH_USER_BY_EMAIL: &str = "SELECT id, nickname, email, password_hash FROM users WHERE email_key = ?"; + pub const Q001: &str = "SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE slug = ?"; pub const Q002: &str = "INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"; pub const Q003: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"; diff --git a/src/state.rs b/src/state.rs index b7572cf..fde8609 100644 --- a/src/state.rs +++ b/src/state.rs @@ -4,6 +4,9 @@ use tokio::sync::{broadcast, RwLock}; const CHANNEL_CAPACITY: usize = 256; +#[derive(Debug, Clone)] +pub struct SmtpConfig { pub host: String, pub port: u16, pub username: String, pub password: String, pub from: String, pub public_url: String } + #[derive(Debug, Clone)] pub struct NoteUpdate { pub content: String, @@ -19,12 +22,14 @@ pub struct AppState { pub asset_version: String, pub files_dir: String, pub upload_max_size_bytes: usize, + pub smtp: Option, + pub registration_enabled: bool, channels: RwLock>>, } impl AppState { - pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize) -> Self { - Self { db, asset_version, files_dir, upload_max_size_bytes, channels: RwLock::new(HashMap::new()) } + pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize, smtp: Option, registration_enabled: bool) -> Self { + Self { db, asset_version, files_dir, upload_max_size_bytes, smtp, registration_enabled, channels: RwLock::new(HashMap::new()) } } async fn channel_for_key(&self, key: String) -> broadcast::Sender { if let Some(sender) = self.channels.read().await.get(&key) { return sender.clone(); } diff --git a/src/websocket.rs b/src/websocket.rs index 9fb2bac..a410742 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -2,12 +2,12 @@ use axum::{extract::{ws::{Message, WebSocket}, Path, State, WebSocketUpgrade}, r use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use tracing::{debug, warn}; -use crate::{db, state::{NoteUpdate, SharedState}}; +use crate::{auth, db, state::{NoteUpdate, SharedState}}; #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum ClientMessage { - Authenticate { password: Option, nickname: Option }, + Authenticate { password: Option, nickname: Option, session_token: Option }, Update { content: String, owner_map: Option }, } @@ -26,12 +26,13 @@ pub async fn upgrade(ws: WebSocketUpgrade, Path((workspace_slug, note_slug)): Pa async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug: String, note_slug: String) { let Some(workspace) = db::find_workspace(&state.db, &workspace_slug).await.ok().flatten() else { let _=send_error(&mut socket,"Workspace not found").await; return; }; let Some(note) = db::find_note(&state.db, workspace.id, ¬e_slug).await.ok().flatten() else { let _=send_error(&mut socket,"Note not found").await; return; }; - let (password, nickname) = match socket.recv().await { + let (password, nickname, session_token) = match socket.recv().await { Some(Ok(Message::Text(text))) => match serde_json::from_str::(&text) { - Ok(ClientMessage::Authenticate { password, nickname }) => (password, clean_nickname(nickname)), + Ok(ClientMessage::Authenticate { password, nickname, session_token }) => (password, clean_nickname(nickname), session_token), _ => { let _=send_error(&mut socket,"Wymagane uwierzytelnienie").await; return; } }, _ => return }; + let nickname = match auth::authorize_nickname(&state, nickname, session_token).await { Ok(value) => value, Err(message) => { let _=send_error(&mut socket,&message).await; return; } }; if !db::verify_workspace_password(&workspace, password.as_deref()) { let _=send_error(&mut socket,"Invalid password").await; return; } if send(&mut socket,&ServerMessage::Authenticated { workspace_title:workspace.title.clone(), note_title:note.title.clone(), content:note.content.clone(), owner_map:note.owner_map.clone() }).await.is_err(){return;} let channel=state.note_channel(&workspace_slug,¬e_slug).await; @@ -76,12 +77,13 @@ pub async fn upgrade_pad(ws:WebSocketUpgrade,Path(slug):Path,State(state } async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){ let Some(pad)=db::find_pad(&state.db,&slug).await.ok().flatten() else {let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Note not found".into()}).await;return;}; - let (password,nickname)=match socket.recv().await{ + let (password,nickname,session_token)=match socket.recv().await{ Some(Ok(Message::Text(text)))=>match serde_json::from_str::(&text){ - Ok(ClientMessage::Authenticate{password,nickname})=>(password,clean_nickname(nickname)), + Ok(ClientMessage::Authenticate{password,nickname,session_token})=>(password,clean_nickname(nickname),session_token), _=>{let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Wymagane uwierzytelnienie".into()}).await;return;} },_=>return }; + let nickname=match auth::authorize_nickname(&state,nickname,session_token).await{Ok(value)=>value,Err(message)=>{let _=send_pad(&mut socket,&PadServerMessage::Error{message}).await;return;}}; if !db::verify_pad_password(&pad,password.as_deref()){let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Invalid password".into()}).await;return;} if send_pad(&mut socket,&PadServerMessage::Authenticated{title:pad.title.clone(),content:pad.content.clone(),owner_map:pad.owner_map.clone()}).await.is_err(){return;} let channel=state.pad_channel(&slug).await; diff --git a/static/css/styles.css b/static/css/styles.css index 68d9d84..7a101f8 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -51,7 +51,10 @@ input { width: 100%; min-height: 46px; padding: 0 13px; border-radius: 10px; } input:focus, select:focus, textarea:focus { border-color: #7567db; outline: 1px solid #7567db; outline-offset: 1px; } .password-input { position: relative; } .password-input input { padding-right: 72px; } -.text-button { position: absolute; top: 50%; right: 8px; transform: translateY(-50%); border: 0; background: transparent; color: #a99ef8; padding: 7px; font-size: .78rem; } +.text-button { position: static; transform: none; border: 0; background: transparent; color: #a99ef8; padding: 4px 2px; font-size: .78rem; } +.text-button:hover { color: #c3bbff; } +.password-toggle { position: absolute; top: 50%; right: 8px; transform: translateY(-50%); padding: 7px; } +[hidden] { display: none !important; } .form-message { min-height: 1.2em; margin: -5px 0 0; font-size: .8rem; } .error { color: var(--danger); } .primary-button, .secondary-button, .inline-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; border-radius: 8px; font-weight: 650; text-decoration: none; } @@ -247,6 +250,9 @@ dialog::backdrop { background: rgba(4,6,9,.82); } .home-page { display: flex; min-height: 100vh; flex-direction: column; } .home-page .home-layout { flex: 1 0 auto; } .home-footer { flex: 0 0 auto; width: min(1040px, calc(100% - 32px)); margin: auto auto 28px; padding-top: 24px; color: var(--muted-2); font-size: .76rem; text-align: center; } +.home-footer__inner { display: flex; align-items: center; justify-content: center; gap: 10px; flex-wrap: wrap; } +.home-footer__separator { color: var(--border-strong); } +.home-footer .text-button { font-size: inherit; text-decoration: underline; text-underline-offset: 3px; } .home-footer a { color: var(--muted); text-decoration: none; } .home-footer a:hover { color: white; } .home-header { border-bottom-color: rgba(195, 91, 54, .28); background: linear-gradient(135deg, rgba(126, 48, 27, .28), rgba(51, 25, 18, .08) 58%, transparent); } @@ -466,3 +472,55 @@ dialog::backdrop { background: rgba(4,6,9,.82); } .public-page .task-checkbox:not(:disabled) { cursor:pointer; } .public-page .task-checkbox:disabled { cursor:not-allowed; opacity:.55; } +.identity-actions, .identity-links { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.identity-actions .primary-button { flex: 1 0 100%; } +.identity-panel { position: relative; } +.identity-panel__close { position: absolute; top: 14px; right: 14px; } +.identity-links { justify-content: space-between; } +.identity-links .text-button { padding: 3px 0; } +.auth-panel { display: grid; gap: 12px; padding-top: 14px; border-top: 1px solid var(--border); } +.auth-panel h3 { margin: 0; } +.auth-panel label { display: grid; gap: 6px; } + +/* Account footer and authentication dialog. */ +.home-footer { width: min(1040px, calc(100% - 32px)); margin: auto auto 24px; padding-top: 24px; color: var(--muted); font-size: .78rem; } +.home-footer__inner { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-top: 18px; border-top: 1px solid var(--border); } +.home-footer__account { display: inline-flex; align-items: center; gap: 8px; } +.home-footer__user { color: var(--text); font-weight: 600; } +.home-footer__author { margin-left: auto; } +.footer-action { min-height: 34px; padding: 0 12px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: var(--muted); font: inherit; cursor: pointer; } +.footer-action:hover { border-color: var(--border-strong); background: var(--surface-2); color: var(--text); } +.footer-action--primary { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); color: #d9d2ff; } +.app-dialog { width: min(440px, calc(100% - 28px)); padding: 0; border: 0; background: transparent; } +.app-dialog::backdrop { background: rgba(4, 6, 10, .72); backdrop-filter: blur(3px); } +.identity-panel { position: relative; display: grid; gap: 18px; width: 100%; padding: 28px; } +.identity-panel__header { padding-right: 38px; } +.identity-panel__header h2 { margin: 0 0 7px; } +.identity-panel__header p { margin: 0; } +.identity-fields { display: grid; gap: 14px; } +.identity-fields label { display: grid; gap: 7px; color: var(--muted); font-size: .78rem; } +.modal-close { position: absolute; top: 12px; right: 12px; display: grid; width: 34px; height: 34px; padding: 0; place-items: center; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--muted); font: 400 1.45rem/1 system-ui; cursor: pointer; } +.modal-close:hover { border-color: var(--border); background: var(--surface-2); color: white; } +.identity-links { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.identity-links .text-button { padding: 2px 0; } +.form-message.success { color: #8ed9a4; } +@media (max-width: 600px) { + .home-footer__inner { align-items: stretch; flex-direction: column; text-align: center; } + .home-footer__account { justify-content: center; } + .home-footer__author { margin-left: 0; } + .identity-panel { padding: 24px 20px 20px; } + .identity-links { align-items: flex-start; flex-direction: column; } +} +.home-footer__account[hidden], .footer-action[hidden], .identity-links [hidden], .identity-fields [hidden], .auth-panel[hidden] { display: none !important; } + +.system-dialog-panel { position: relative; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; } +.secondary-button, .danger-button { min-height: 42px; padding: 0 16px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface-2); color: var(--text); font: inherit; cursor: pointer; } +.secondary-button:hover { border-color: var(--muted); } +.danger-button { border-color: #7f3340; background: #6e2935; color: white; } +.danger-button:hover { background: #7c3040; } + +/* Native hidden must win over component display declarations. */ +[hidden] { display: none !important; } +.identity-panel #auth-panel:not([hidden]) { margin-top: 4px; } +.identity-panel #nickname:disabled { opacity: .55; cursor: not-allowed; } diff --git a/static/home.html b/static/home.html index e0d733f..ec2e945 100644 --- a/static/home.html +++ b/static/home.html @@ -8,7 +8,7 @@ - +
@@ -58,6 +58,41 @@
- + +
+ +
+ + +
+ +
+

Log in

+

+
+
+ + + +
+ + +

+
+
diff --git a/static/js/api.js b/static/js/api.js index 55533c4..ff28dda 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -5,8 +5,12 @@ export async function api(path, options = {}) { const headers = new Headers(options.headers || {}); if (!(options.body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json"); const response = await fetch(path, { ...options, headers, signal: controller.signal }); - const data = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(data.error || `Error ${response.status}`); + const contentType = response.headers.get("content-type") || ""; + const data = contentType.includes("application/json") ? await response.json().catch(() => ({})) : {}; + if (!response.ok) { + const defaults = { 400: "Invalid request.", 401: "Authentication required.", 403: "Access denied.", 404: "The requested resource was not found.", 405: "This operation is not allowed.", 409: "The requested change conflicts with existing data.", 413: "The uploaded data is too large.", 429: "Too many requests. Try again later.", 500: "Server error. Try again later.", 503: "Service temporarily unavailable." }; + throw new Error(data.error || defaults[response.status] || `Request failed (${response.status}).`); + } return data; } catch (error) { if (error.name === "AbortError") throw new Error("Timed out"); diff --git a/static/js/auth-ui.js b/static/js/auth-ui.js new file mode 100644 index 0000000..9ce0929 --- /dev/null +++ b/static/js/auth-ui.js @@ -0,0 +1,276 @@ +import { api } from "@rustpad/api"; +import * as sessionStore from "@rustpad/session"; +import { askInput, showMessage } from "./modal.js"; + +const { getAuthToken, setAuthSession, setNickname } = sessionStore; +const clearAuthSession = sessionStore.clearAuthSession || (() => { + localStorage.removeItem("rustpad:auth-token"); + sessionStorage.removeItem("rustpad:auth-token"); + sessionStorage.removeItem("rustpad:nickname"); +}); + +export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }) { + if (dialog.querySelector("#auth-panel")) return bindLegacyIdentityDialog({ dialog, onIdentity }); + const form = dialog.querySelector("#identity-form"); + const nickname = dialog.querySelector("#nickname"); + const message = dialog.querySelector("#identity-error"); + const title = dialog.querySelector("#identity-title"); + const copy = dialog.querySelector("#identity-copy"); + const emailField = dialog.querySelector("#auth-email-field"); + const email = dialog.querySelector("#auth-email"); + const password = dialog.querySelector("#auth-password"); + const submit = dialog.querySelector("#auth-submit"); + const switchMode = dialog.querySelector("#auth-switch-mode"); + const resetButton = dialog.querySelector("#show-reset"); + const backButton = dialog.querySelector("#reset-back"); + const registrationEnabled = document.body.dataset.registrationEnabled === "true"; + let mode = initialMode; + + const setMode = (nextMode) => { + mode = nextMode === "register" && !registrationEnabled ? "login" : nextMode; + const resetting = mode === "reset"; + const registering = mode === "register"; + + title.textContent = resetting ? "Reset password" : registering ? "Register nickname" : "Log in"; + copy.textContent = resetting + ? "Enter the e-mail address assigned to your account." + : registering + ? "Reserve your nickname with an e-mail address and password." + : "Use the e-mail address and password assigned to your account."; + + nickname.closest("label").hidden = !registering; + nickname.disabled = !registering; + emailField.hidden = false; + email.disabled = false; + password.closest("label").hidden = resetting; + password.disabled = resetting; + submit.textContent = resetting ? "Send reset link" : registering ? "Create account" : "Log in"; + switchMode.hidden = resetting || !registrationEnabled; + switchMode.textContent = registering ? "Already registered? Log in" : "Create an account"; + resetButton.hidden = resetting || registering; + backButton.hidden = !resetting; + password.autocomplete = registering ? "new-password" : "current-password"; + message.textContent = ""; + + queueMicrotask(() => { + email.focus(); + }); + }; + + switchMode?.addEventListener("click", () => setMode(mode === "register" ? "login" : "register")); + resetButton?.addEventListener("click", () => setMode("reset")); + backButton?.addEventListener("click", () => setMode("login")); + dialog.querySelector("#close-identity")?.addEventListener("click", () => dialog.close()); + dialog.addEventListener("click", (event) => { + if (event.target === dialog) dialog.close(); + }); + dialog.addEventListener("close", () => { + form.reset(); + message.textContent = ""; + setMode(initialMode); + }); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + message.textContent = ""; + submit.disabled = true; + + try { + if (mode === "reset") { + const result = await api("/api/auth/password-reset", { + method: "POST", + body: JSON.stringify({ email: email.value.trim() }), + }); + message.classList.remove("error"); + message.classList.add("success"); + message.textContent = result.message; + return; + } + + const endpoint = mode === "register" ? "/api/auth/register" : "/api/auth/login"; + const payload = { email: email.value.trim(), password: password.value }; + if (mode === "register") payload.nickname = nickname.value.trim(); + const session = await api(endpoint, { method: "POST", body: JSON.stringify(payload) }); + setAuthSession(session); + await onIdentity(session.nickname, session); + dialog.close(); + } catch (error) { + message.classList.remove("success"); + message.classList.add("error"); + message.textContent = error.message; + } finally { + submit.disabled = false; + } + }); + + setMode(initialMode); + return { setMode }; +} + + +function bindLegacyIdentityDialog({ dialog, onIdentity }) { + const form = dialog.querySelector("#identity-form"); + const nickname = dialog.querySelector("#nickname"); + const authPanel = dialog.querySelector("#auth-panel"); + const message = dialog.querySelector("#identity-error"); + const modeTitle = dialog.querySelector("#auth-mode-title"); + const emailField = dialog.querySelector("#auth-email-field"); + const email = dialog.querySelector("#auth-email"); + const password = dialog.querySelector("#auth-password"); + const authSubmit = dialog.querySelector("#auth-submit"); + const guestSubmit = dialog.querySelector("#guest-continue"); + const registerButton = dialog.querySelector("#show-register"); + const loginButton = dialog.querySelector("#show-login"); + const backButton = dialog.querySelector("#auth-back"); + const logoutButton = dialog.querySelector("#logout-account"); + const registrationEnabled = document.body.dataset.registrationEnabled === "true"; + let mode = "login"; + + const updateActions = () => { + if (logoutButton) logoutButton.hidden = !getAuthToken(); + if (registerButton) registerButton.hidden = !registrationEnabled; + }; + + const collapse = () => { + authPanel.hidden = true; + nickname.disabled = false; + nickname.required = true; + password.value = ""; + message.textContent = ""; + guestSubmit?.focus(); + }; + + const showAuth = (nextMode) => { + mode = nextMode === "register" && !registrationEnabled ? "login" : nextMode; + authPanel.hidden = false; + // Keep the guest nickname form visible so the user can still continue anonymously. + nickname.disabled = mode === "login"; + nickname.required = mode === "register"; + emailField.hidden = false; + email.disabled = false; + modeTitle.textContent = mode === "register" ? "Register nickname" : "Log in"; + authSubmit.textContent = mode === "register" ? "Register and continue" : "Log in and continue"; + password.autocomplete = mode === "register" ? "new-password" : "current-password"; + message.textContent = ""; + queueMicrotask(() => (mode === "register" ? nickname : email).focus()); + }; + + registerButton?.addEventListener("click", () => showAuth("register")); + loginButton?.addEventListener("click", () => showAuth("login")); + backButton?.addEventListener("click", collapse); + dialog.querySelector("#close-identity")?.addEventListener("click", () => dialog.close()); + dialog.addEventListener("click", (event) => { if (event.target === dialog) dialog.close(); }); + dialog.addEventListener("close", collapse); + dialog.addEventListener("cancel", collapse); + + dialog.querySelector("#show-reset")?.addEventListener("click", async () => { + const value = email.value.trim() || await askInput({ + title: "Reset password", + message: "Enter the e-mail address assigned to your account.", + label: "E-mail", + type: "email", + autocomplete: "off", + confirmText: "Send reset link", + bitwardenIgnore: true, + }) || ""; + if (!value) return; + try { + const result = await api("/api/auth/password-reset", { method: "POST", body: JSON.stringify({ email: value }) }); + message.classList.remove("error"); + message.classList.add("success"); + message.textContent = result.message; + } catch (error) { + message.classList.remove("success"); + message.classList.add("error"); + message.textContent = error.message; + } + }); + + logoutButton?.addEventListener("click", async () => { + await logoutCurrentSession(); + updateActions(); + collapse(); + nickname.value = ""; + message.textContent = "Logged out."; + }); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + message.textContent = ""; + const name = nickname.value.trim(); + const authRequested = event.submitter === authSubmit; + try { + if (authRequested) { + const endpoint = mode === "register" ? "/api/auth/register" : "/api/auth/login"; + const payload = { email: email.value.trim(), password: password.value }; + if (mode === "register") payload.nickname = name; + const session = await api(endpoint, { method: "POST", body: JSON.stringify(payload) }); + setAuthSession(session); + await onIdentity(session.nickname, session); + 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); + } catch (error) { + message.classList.remove("success"); + message.classList.add("error"); + message.textContent = error.message; + if (/registered|session|account/i.test(error.message)) showAuth("login"); + } + }); + + updateActions(); + collapse(); + return { setMode: showAuth }; +} + +export async function validateCurrentSession() { + const token = getAuthToken(); + if (!token) return null; + try { + return await api("/api/auth/me", { headers: { Authorization: `Bearer ${token}` } }); + } catch { + clearAuthSession(); + return null; + } +} + +export async function logoutCurrentSession() { + const token = getAuthToken(); + if (token) { + try { + await api("/api/auth/logout", { method: "POST", headers: { Authorization: `Bearer ${token}` } }); + } catch {} + } + clearAuthSession(); +} + +export async function handleResetToken() { + const url = new URL(location.href); + const token = url.searchParams.get("reset_token"); + if (!token) return; + + // Remove the token immediately. Refreshing or navigating back must not reopen the reset dialog. + url.searchParams.delete("reset_token"); + history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`); + + const password = await askInput({ + title: "Set a new password", + message: "The password must contain at least 8 characters.", + label: "New password", + type: "password", + autocomplete: "off", + minLength: 8, + confirmText: "Change password", + bitwardenIgnore: true, + }); + if (!password) return; + try { + await api("/api/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }); + await showMessage("Password changed. The reset link has been used and cannot be opened again.", { title: "Password changed" }); + } catch (error) { + await showMessage(error.message, { title: "Password reset failed" }); + } +} diff --git a/static/js/home.js b/static/js/home.js index e3f53f3..2c54c98 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -1,3 +1,4 @@ +import { bindIdentityDialog, handleResetToken, logoutCurrentSession, validateCurrentSession } from "./auth-ui.js"; import { api } from "@rustpad/api"; function slugify(value, fallback) { @@ -74,3 +75,42 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even setBusy(button, false, "Create workspace", "Creating…"); } }); + +handleResetToken(); + +const identityDialog = document.querySelector("#identity-dialog"); +const guestAccount = document.querySelector("#footer-account-guest"); +const userAccount = document.querySelector("#footer-account-user"); +const userLabel = document.querySelector("#footer-user-label"); +const registerLink = document.querySelector("#footer-register"); +const registrationEnabled = document.body.dataset.registrationEnabled === "true"; + +function renderAccount(session) { + guestAccount.hidden = Boolean(session); + userAccount.hidden = !session; + if (session) userLabel.textContent = `Signed in as ${session.nickname}`; + registerLink.hidden = !registrationEnabled; +} + +if (identityDialog) { + const authDialog = bindIdentityDialog({ + dialog: identityDialog, + onIdentity: async (_nickname, session) => renderAccount(session), + }); + + document.querySelector("#footer-login")?.addEventListener("click", () => { + authDialog.setMode("login"); + identityDialog.showModal(); + }); + registerLink?.addEventListener("click", () => { + authDialog.setMode("register"); + identityDialog.showModal(); + }); + document.querySelector("#footer-logout")?.addEventListener("click", async () => { + await logoutCurrentSession(); + renderAccount(null); + }); + + renderAccount(null); + validateCurrentSession().then(renderAccount); +} diff --git a/static/js/modal.js b/static/js/modal.js new file mode 100644 index 0000000..8881da8 --- /dev/null +++ b/static/js/modal.js @@ -0,0 +1,62 @@ +function ensureDialog() { + let dialog = document.querySelector("#system-dialog"); + if (dialog) return dialog; + dialog = document.createElement("dialog"); + dialog.id = "system-dialog"; + dialog.className = "app-dialog"; + dialog.innerHTML = `
+ +

+ +
+
`; + document.body.append(dialog); + dialog.addEventListener("click", event => { if (event.target === dialog) dialog.close("cancel"); }); + return dialog; +} + +export function showMessage(message, { title = "Information", button = "OK" } = {}) { + const dialog = ensureDialog(); + dialog.querySelector("[data-title]").textContent = title; + dialog.querySelector("[data-message]").textContent = message; + dialog.querySelector("[data-input-wrap]").hidden = true; + dialog.querySelector("[data-cancel]").hidden = true; + dialog.querySelector("[data-confirm]").textContent = button; + dialog.showModal(); + return new Promise(resolve => dialog.addEventListener("close", () => resolve(), { once: true })); +} + +export function askConfirm(message, { title = "Confirm", confirmText = "Confirm", danger = false } = {}) { + const dialog = ensureDialog(); + dialog.querySelector("[data-title]").textContent = title; + dialog.querySelector("[data-message]").textContent = message; + dialog.querySelector("[data-input-wrap]").hidden = true; + dialog.querySelector("[data-cancel]").hidden = false; + const confirm = dialog.querySelector("[data-confirm]"); + confirm.textContent = confirmText; + confirm.classList.toggle("danger-button", danger); + dialog.showModal(); + return new Promise(resolve => dialog.addEventListener("close", () => { + confirm.classList.remove("danger-button"); + resolve(dialog.returnValue === "confirm"); + }, { once: true })); +} + +export function askInput({ title, message = "", label, type = "text", autocomplete = "off", minLength, placeholder = "", confirmText = "Continue", bitwardenIgnore = false }) { + const dialog = ensureDialog(); + dialog.querySelector("[data-title]").textContent = title; + dialog.querySelector("[data-message]").textContent = message; + const wrap = dialog.querySelector("[data-input-wrap]"); + const input = dialog.querySelector("[data-input]"); + wrap.hidden = false; + dialog.querySelector("[data-input-label]").textContent = label; + input.type = type; input.value = ""; input.autocomplete = autocomplete; input.placeholder = placeholder; + input.name = type === "password" ? "new-password" : type === "email" ? "email" : "modal-input"; + if (bitwardenIgnore) input.setAttribute("data-bwignore", "true"); else input.removeAttribute("data-bwignore"); + input.minLength = minLength || 0; + dialog.querySelector("[data-cancel]").hidden = false; + dialog.querySelector("[data-confirm]").textContent = confirmText; + dialog.showModal(); + queueMicrotask(() => input.focus()); + return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm" ? input.value : null), { once: true })); +} diff --git a/static/js/note.js b/static/js/note.js index fe594cf..cd298cd 100644 --- a/static/js/note.js +++ b/static/js/note.js @@ -3,8 +3,10 @@ import { copyText } from "@rustpad/clipboard"; import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format"; import { renderMarkdown } from "@rustpad/markdown"; import { prepareImageFile } from "./image-upload.js"; -import { getNickname, getPassword, setNickname, setPassword } from "@rustpad/session"; +import { getNickname, getPassword, getAuthToken, setPassword } from "@rustpad/session"; +import { bindIdentityDialog } from "./auth-ui.js"; import { NoteSocket } from "@rustpad/socket"; +import { askConfirm } from "./modal.js"; import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state"; const parts=location.pathname.split("/").filter(Boolean), workspaceSlug=parts[1], noteSlug=parts[3]; @@ -69,7 +71,7 @@ function replaceTableCell(line,index,value){ function render(){if(uiState.mode==="markdown"){preview.classList.remove("preview--raw");preview.innerHTML=renderMarkdown(editor.value);document.querySelector("#preview-label").textContent="Markdown + Mermaid preview · text and headings are editable";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";}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 applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null)return;const start=editor.selectionStart,end=editor.selectionEnd;applyingRemote=true;editor.value=content;try{owners=JSON.parse(ownerMap||"[]");}catch{owners=[];}editor.setSelectionRange(Math.min(start,content.length),Math.min(end,content.length));applyingRemote=false;render();} -function connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,nickname,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"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();} +function connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,nickname,sessionToken:getAuthToken(),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"})}`;},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 formatBytes(bytes){const value=Number(bytes)||0;if(value<1024)return `${value} B`;if(value<1024*1024)return `${(value/1024).toFixed(1)} KB`;return `${(value/1024/1024).toFixed(1)} MB`;} async function loadFiles({open=false}={}){ @@ -81,8 +83,10 @@ async function loadFiles({open=false}={}){ if(open&&!document.querySelector("#files-dialog").open)document.querySelector("#files-dialog").showModal(); }catch(error){toast(error.message);} } +bindIdentityDialog({dialog:identityDialog,onIdentity:async value=>{nickname=value;identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}}); +identityDialog.addEventListener("close",()=>{if(!nickname)queueMicrotask(()=>{if(!identityDialog.open)identityDialog.showModal();});}); async function initialize(){try{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`);document.querySelector("#back-workspace").href=`/w/${encodeURIComponent(workspaceSlug)}`;document.title=`${info.title} · ${info.workspace_title}`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`

Note not found

${escapeHtml(e.message)}

`;}} -document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}); + 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();});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);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();}});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 api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates: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 api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates: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);}}); @@ -107,11 +111,11 @@ document.querySelector("#files-list").addEventListener("click",async event=>{ if(copyButton){try{await copyText(copyButton.closest(".file-code").querySelector("textarea").value);toast("Copied");}catch(error){toast(error.message);}return;} const deleteButton=event.target.closest("[data-delete-file]"); if(deleteButton){ - if(!confirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`))return; + if(!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`,{title:"Delete file",confirmText:"Delete",danger:true}))return; try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`,{method:"DELETE",body:JSON.stringify({password:password||null})});toast("File deleted");await loadFiles();}catch(error){toast(error.message);}return; } }); -document.querySelector("#delete-note").addEventListener("click",async()=>{if(!confirm(`Delete note “${info.title}”? This cannot be undone.`))return;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`,{method:"DELETE",body:JSON.stringify({password:password||null})});location.assign(`/w/${encodeURIComponent(workspaceSlug)}`);}catch(error){toast(error.message);}}); +document.querySelector("#delete-note").addEventListener("click",async()=>{if(!await askConfirm(`Delete note “${info.title}”? This cannot be undone.`,{title:"Delete note",confirmText:"Delete",danger:true}))return;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`,{method:"DELETE",body:JSON.stringify({password:password||null})});location.assign(`/w/${encodeURIComponent(workspaceSlug)}`);}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);}); initialize(); diff --git a/static/js/pad.js b/static/js/pad.js index 89e3d16..74dbece 100644 --- a/static/js/pad.js +++ b/static/js/pad.js @@ -3,7 +3,8 @@ import { copyText } from "@rustpad/clipboard"; import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format"; import { renderMarkdown } from "@rustpad/markdown"; import { prepareImageFile } from "./image-upload.js"; -import { getNickname, setNickname } from "@rustpad/session"; +import { getNickname, getAuthToken } from "@rustpad/session"; +import { bindIdentityDialog } from "./auth-ui.js"; import { PadSocket } from "@rustpad/socket"; import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state"; @@ -78,9 +79,11 @@ async function loadFiles({open=false}={}){ if(open)document.querySelector("#files-dialog").showModal(); }catch(error){if(open)toast(error.message);} } -function connect(){socket?.stop();socket=new PadSocket({slug,password,nickname,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"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();} +function connect(){socket?.stop();socket=new PadSocket({slug,password,nickname,sessionToken:getAuthToken(),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"})}`;},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();} +bindIdentityDialog({dialog:identityDialog,onIdentity:async value=>{nickname=value;identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}}); +identityDialog.addEventListener("close",()=>{if(!nickname)queueMicrotask(()=>{if(!identityDialog.open)identityDialog.showModal();});}); async function initialize(){try{info=await api(`/api/pads/${encodeURIComponent(slug)}`);document.title=`${info.title} · RustPad`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`

Note not found

${escapeHtml(e.message)}

`;}} -document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}); + 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();});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);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();}});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 api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates: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 api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates: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);}}); diff --git a/static/js/session.js b/static/js/session.js index 97ec230..9a4606b 100644 --- a/static/js/session.js +++ b/static/js/session.js @@ -1,5 +1,30 @@ export function passwordKey(workspaceSlug) { return `rustpad:workspace:${workspaceSlug}:password`; } export function getPassword(workspaceSlug) { return sessionStorage.getItem(passwordKey(workspaceSlug)) || ""; } export function setPassword(workspaceSlug, password) { if (password) sessionStorage.setItem(passwordKey(workspaceSlug), password); else sessionStorage.removeItem(passwordKey(workspaceSlug)); } -export function getNickname() { return localStorage.getItem("rustpad:nickname") || ""; } -export function setNickname(value) { localStorage.setItem("rustpad:nickname", value.trim()); } +const NICKNAME_KEY = "rustpad:nickname"; +// Nicknames belong to the current browser session. Remove the old persistent +// value so deleting/logging out of a session cannot leave an identity behind. +localStorage.removeItem(NICKNAME_KEY); +export function getNickname() { return sessionStorage.getItem(NICKNAME_KEY) || ""; } +export function setNickname(value) { + const nickname = value.trim(); + if (nickname) sessionStorage.setItem(NICKNAME_KEY, nickname); + else sessionStorage.removeItem(NICKNAME_KEY); +} + +const AUTH_TOKEN_KEY = "rustpad:auth-token"; +const legacyAuthToken = localStorage.getItem(AUTH_TOKEN_KEY); +if (legacyAuthToken && !sessionStorage.getItem(AUTH_TOKEN_KEY)) sessionStorage.setItem(AUTH_TOKEN_KEY, legacyAuthToken); +localStorage.removeItem(AUTH_TOKEN_KEY); +export function getAuthToken() { return sessionStorage.getItem(AUTH_TOKEN_KEY) || ""; } +export function setAuthSession(session) { sessionStorage.setItem(AUTH_TOKEN_KEY, session.token); setNickname(session.nickname); } +export function clearAuthSession() { + localStorage.removeItem(AUTH_TOKEN_KEY); + sessionStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(NICKNAME_KEY); + sessionStorage.removeItem(NICKNAME_KEY); +} +export async function resolveIdentity(api, nickname) { + const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname, session_token: getAuthToken() || null }) }); + setNickname(result.nickname); return result; +} diff --git a/static/js/socket.js b/static/js/socket.js index e021fae..0bf589e 100644 --- a/static/js/socket.js +++ b/static/js/socket.js @@ -1,11 +1,11 @@ export class NoteSocket { - constructor({ workspaceSlug, noteSlug, password, nickname, onStatus, onAuthenticated, onDocument, onError }) { Object.assign(this, { workspaceSlug, noteSlug, password, nickname, onStatus, onAuthenticated, onDocument, onError }); this.socket=null; this.timer=null; this.closed=false; } - connect() { clearTimeout(this.timer); this.closed=false; this.onStatus?.("connecting"); const protocol=location.protocol==="https:"?"wss:":"ws:"; this.socket=new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`); this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null}))); this.socket.addEventListener("message",event=>{const m=JSON.parse(event.data); if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();} if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);} if(m.type==="document")this.onDocument?.(m);}); this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}}); this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();}); } + constructor({ workspaceSlug, noteSlug, password, nickname, sessionToken, onStatus, onAuthenticated, onDocument, onError }) { Object.assign(this, { workspaceSlug, noteSlug, password, nickname, sessionToken, onStatus, onAuthenticated, onDocument, onError }); this.socket=null; this.timer=null; this.closed=false; } + connect() { clearTimeout(this.timer); this.closed=false; this.onStatus?.("connecting"); const protocol=location.protocol==="https:"?"wss:":"ws:"; this.socket=new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`); this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null,session_token:this.sessionToken||null}))); this.socket.addEventListener("message",event=>{const m=JSON.parse(event.data); if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();} if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);} if(m.type==="document")this.onDocument?.(m);}); this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}}); this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();}); } update(content, ownerMap="[]") { if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap})); } stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();} } export class PadSocket { - constructor({slug,password,nickname,onStatus,onAuthenticated,onDocument,onError}){Object.assign(this,{slug,password,nickname,onStatus,onAuthenticated,onDocument,onError});this.socket=null;this.timer=null;this.closed=false;} - connect(){clearTimeout(this.timer);this.closed=false;this.onStatus?.("connecting");const protocol=location.protocol==="https:"?"wss:":"ws:";this.socket=new WebSocket(`${protocol}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`);this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null})));this.socket.addEventListener("message",e=>{const m=JSON.parse(e.data);if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();}if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);}if(m.type==="document")this.onDocument?.(m);});this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}});this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();});} + constructor({slug,password,nickname,sessionToken,onStatus,onAuthenticated,onDocument,onError}){Object.assign(this,{slug,password,nickname,sessionToken,onStatus,onAuthenticated,onDocument,onError});this.socket=null;this.timer=null;this.closed=false;} + connect(){clearTimeout(this.timer);this.closed=false;this.onStatus?.("connecting");const protocol=location.protocol==="https:"?"wss:":"ws:";this.socket=new WebSocket(`${protocol}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`);this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null,session_token:this.sessionToken||null})));this.socket.addEventListener("message",e=>{const m=JSON.parse(e.data);if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();}if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);}if(m.type==="document")this.onDocument?.(m);});this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}});this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();});} update(content,ownerMap="[]"){if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap}));} stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();} } diff --git a/static/js/workspace.js b/static/js/workspace.js index 52707ec..ad9c6a3 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -1,6 +1,7 @@ import { api } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; import { getNickname, getPassword, setPassword } from "@rustpad/session"; +import { askConfirm } from "./modal.js"; const parts = location.pathname.split("/").filter(Boolean); const slug = parts[1]; @@ -116,7 +117,7 @@ notesList.addEventListener("click", async event => { const button = event.target.closest("[data-delete-note]"); if (!button) return; const title = button.dataset.noteTitle; - if (!confirm(`Delete note “${title}”? This cannot be undone.`)) return; + if (!await askConfirm(`Delete note “${title}”? This cannot be undone.`, { title: "Delete note", confirmText: "Delete", danger: true })) return; button.disabled = true; try { await api(`/api/workspaces/${encodeURIComponent(slug)}/notes/${encodeURIComponent(button.dataset.deleteNote)}`, { diff --git a/static/note.html b/static/note.html index 4f19465..19a78ba 100644 --- a/static/note.html +++ b/static/note.html @@ -1,5 +1,5 @@ __NOTE_TITLE__ · RustPad -
__WORKSPACE_TITLE__

__NOTE_TITLE__

+
__WORKSPACE_TITLE__

__NOTE_TITLE__

More
Editor
Markdown preview
· · Changes are saved automatically
-

Keyboard shortcuts

Use Ctrl on Windows/Linux or Cmd on macOS.

Ctrl/Cmd+ZUndoCtrl/Cmd+BBoldCtrl/Cmd+IItalicCtrl/Cmd+Shift+XStrikethroughCtrl/Cmd+KLinkCtrl/Cmd+Shift+7Numbered listCtrl/Cmd+Shift+8Bullet listCtrl/Cmd+Shift+9Task listAlt+1…4Headings H1–H4

Note files

Copy a direct link or ready Markdown/HTML code.

What should we call you?

Your name will be shown next to changes and remembered on this device.

+

Keyboard shortcuts

Use Ctrl on Windows/Linux or Cmd on macOS.

Ctrl/Cmd+ZUndoCtrl/Cmd+BBoldCtrl/Cmd+IItalicCtrl/Cmd+Shift+XStrikethroughCtrl/Cmd+KLinkCtrl/Cmd+Shift+7Numbered listCtrl/Cmd+Shift+8Bullet listCtrl/Cmd+Shift+9Task listAlt+1…4Headings H1–H4

Note files

Copy a direct link or ready Markdown/HTML code.

What should we call you?

Use a free nickname without an account, or register it to reserve it.

Protected workspace

Back
diff --git a/static/pad.html b/static/pad.html index 05ab18e..3d03bda 100644 --- a/static/pad.html +++ b/static/pad.html @@ -1,5 +1,5 @@ __PAD_TITLE__ · RustPad -
RustPad

__PAD_TITLE__

+
RustPad

__PAD_TITLE__

More
Editor
Markdown preview
· · Changes are saved automatically
-

Keyboard shortcuts

Use Ctrl on Windows/Linux or Cmd on macOS.

Ctrl/Cmd+ZUndoCtrl/Cmd+BBoldCtrl/Cmd+IItalicCtrl/Cmd+Shift+XStrikethroughCtrl/Cmd+KLinkCtrl/Cmd+Shift+7Numbered listCtrl/Cmd+Shift+8Bullet listCtrl/Cmd+Shift+9Task listAlt+1…4Headings H1–H4

Note files

Copy a direct link or ready Markdown/HTML code.

What should we call you?

Your name will be shown next to changes and remembered on this device.

+

Keyboard shortcuts

Use Ctrl on Windows/Linux or Cmd on macOS.

Ctrl/Cmd+ZUndoCtrl/Cmd+BBoldCtrl/Cmd+IItalicCtrl/Cmd+Shift+XStrikethroughCtrl/Cmd+KLinkCtrl/Cmd+Shift+7Numbered listCtrl/Cmd+Shift+8Bullet listCtrl/Cmd+Shift+9Task listAlt+1…4Headings H1–H4

Note files

Copy a direct link or ready Markdown/HTML code.

What should we call you?

Use a free nickname without an account, or register it to reserve it.

Protected note

Back