From 3ed74b1eacd23315b1d5caa5d8fc2fb3e046e4df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 22 Jul 2026 23:49:16 +0200 Subject: [PATCH] session lifetime --- .env.example | 7 + Cargo.lock | 1 + Cargo.toml | 3 +- docker-compose.yml | 2 + .../mysql/0009_resource_access_tokens.sql | 9 ++ .../postgres/0009_resource_access_tokens.sql | 9 ++ .../sqlite/0009_resource_access_tokens.sql | 9 ++ src/api.rs | 130 +++++++++++++++--- src/app.rs | 1 + src/auth.rs | 6 +- src/config.rs | 14 ++ src/main.rs | 4 + src/state.rs | 6 +- src/websocket.rs | 14 +- static/js/home.js | 6 +- static/js/note.js | 26 ++-- static/js/pad.js | 20 +-- static/js/session.js | 36 +++-- static/js/socket.js | 8 +- static/js/workspace.js | 25 ++-- 20 files changed, 262 insertions(+), 74 deletions(-) create mode 100644 migrations/mysql/0009_resource_access_tokens.sql create mode 100644 migrations/postgres/0009_resource_access_tokens.sql create mode 100644 migrations/sqlite/0009_resource_access_tokens.sql diff --git a/.env.example b/.env.example index 9e2d4dc..5949e1a 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,12 @@ DATABASE_URL=sqlite:///data/db/rustpad.db?mode=rwc DATABASE_MAX_CONNECTIONS=8 +# Session lifetime in days +# Anonymous pad/workspace access tokens +ANONYMOUS_ACCESS_TOKEN_TTL_DAYS=7 +# Logged-in user sessions +USER_SESSION_TTL_DAYS=30 + # Logging # available: warn, debug, info @@ -48,3 +54,4 @@ SMTP_PORT=587 SMTP_USERNAME= SMTP_PASSWORD= SMTP_FROM="RustPad " + diff --git a/Cargo.lock b/Cargo.lock index 78923cc..3b45b22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1415,6 +1415,7 @@ dependencies = [ "chrono", "dotenvy", "futures-util", + "hex", "lettre", "mime_guess", "rand_core 0.6.4", diff --git a/Cargo.toml b/Cargo.toml index eba2890..08199f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.0.7" +version = "0.0.8" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" @@ -25,3 +25,4 @@ tower = "0.5.3" tower-http = { version = "0.7", features = ["fs", "trace", "set-header"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +hex = "0.4" diff --git a/docker-compose.yml b/docker-compose.yml index 91fd294..7f0f4ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB:-20} REGISTRATION_ENABLED: ${REGISTRATION_ENABLED:-false} ACCOUNT_CONFIRMATION_REQUIRED: ${ACCOUNT_CONFIRMATION_REQUIRED:-false} + ANONYMOUS_ACCESS_TOKEN_TTL_DAYS: ${ANONYMOUS_ACCESS_TOKEN_TTL_DAYS:-7} + USER_SESSION_TTL_DAYS: ${USER_SESSION_TTL_DAYS:-30} RUST_LOG: ${RUST_LOG:-rustpad=info,tower_http=warn} FRONTEND_LOG_LEVEL: ${FRONTEND_LOG_LEVEL:-warn} PUBLIC_URL: ${PUBLIC_URL:-http://localhost:3000} diff --git a/migrations/mysql/0009_resource_access_tokens.sql b/migrations/mysql/0009_resource_access_tokens.sql new file mode 100644 index 0000000..a74f882 --- /dev/null +++ b/migrations/mysql/0009_resource_access_tokens.sql @@ -0,0 +1,9 @@ +CREATE TABLE resource_access_tokens ( + token_hash VARCHAR(64) PRIMARY KEY, + resource_kind VARCHAR(16) NOT NULL, + resource_slug VARCHAR(255) NOT NULL, + expires_at VARCHAR(40) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_resource_access_tokens_resource (resource_kind, resource_slug), + INDEX idx_resource_access_tokens_expires (expires_at) +); diff --git a/migrations/postgres/0009_resource_access_tokens.sql b/migrations/postgres/0009_resource_access_tokens.sql new file mode 100644 index 0000000..c40d2e0 --- /dev/null +++ b/migrations/postgres/0009_resource_access_tokens.sql @@ -0,0 +1,9 @@ +CREATE TABLE resource_access_tokens ( + token_hash TEXT PRIMARY KEY, + resource_kind TEXT NOT NULL, + resource_slug TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) +); +CREATE INDEX idx_resource_access_tokens_resource ON resource_access_tokens(resource_kind, resource_slug); +CREATE INDEX idx_resource_access_tokens_expires ON resource_access_tokens(expires_at); diff --git a/migrations/sqlite/0009_resource_access_tokens.sql b/migrations/sqlite/0009_resource_access_tokens.sql new file mode 100644 index 0000000..e776514 --- /dev/null +++ b/migrations/sqlite/0009_resource_access_tokens.sql @@ -0,0 +1,9 @@ +CREATE TABLE resource_access_tokens ( + token_hash TEXT PRIMARY KEY, + resource_kind TEXT NOT NULL, + resource_slug TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_resource_access_tokens_resource ON resource_access_tokens(resource_kind, resource_slug); +CREATE INDEX idx_resource_access_tokens_expires ON resource_access_tokens(expires_at); diff --git a/src/api.rs b/src/api.rs index 8a9974e..84e2a73 100644 --- a/src/api.rs +++ b/src/api.rs @@ -5,6 +5,9 @@ use axum::{ Json, }; use serde::{Deserialize, Serialize}; +use chrono::{Duration, Utc}; +use rand_core::{OsRng, RngCore}; +use sha2::{Digest, Sha256}; use slug::slugify; use crate::{ @@ -47,6 +50,8 @@ pub struct CreateWorkspaceResponse { pub struct PasswordRequest { #[serde(default)] password: Option, + #[serde(default)] + access_token: Option, } #[derive(Debug, Deserialize)] @@ -54,6 +59,8 @@ pub struct PublishRequest { #[serde(default)] password: Option, #[serde(default)] + access_token: Option, + #[serde(default)] allow_task_updates: bool, } @@ -69,6 +76,8 @@ pub struct CreateNoteRequest { #[serde(default)] password: Option, #[serde(default)] + access_token: Option, + #[serde(default)] protect: bool, #[serde(default)] created_by: Option, @@ -78,6 +87,8 @@ pub struct CreateNoteRequest { pub struct RestoreRequest { #[serde(default)] password: Option, + #[serde(default)] + access_token: Option, revision_id: i64, } @@ -158,7 +169,7 @@ pub async fn open_workspace( Path(workspace_slug): Path, Json(payload): Json, ) -> Result, ApiError> { - let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref()).await?; + let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let notes = db::list_notes(&state.db, workspace.id) .await? .into_iter() @@ -184,7 +195,7 @@ pub async fn create_note( Path(workspace_slug): Path, Json(payload): Json, ) -> Result<(StatusCode, Json), ApiError> { - let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref()).await?; + let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let title = validate_name(&payload.name, "Note name")?; let base = slugify(title); if base.is_empty() { @@ -237,7 +248,7 @@ pub async fn history( Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result>, ApiError> { - let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?; + let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let _ = workspace; let revisions = db::list_revisions(&state.db, note.id) .await? @@ -255,7 +266,7 @@ pub async fn restore( Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result, ApiError> { - let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?; + let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let content: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q028)) .bind(payload.revision_id) .bind(note.id) @@ -279,11 +290,14 @@ pub async fn authorized_workspace( state: &SharedState, slug: &str, password: Option<&str>, + access_token: Option<&str>, ) -> Result { let workspace = db::find_workspace(&state.db, slug) .await? .ok_or_else(ApiError::not_found_workspace)?; - if !db::verify_workspace_password(&workspace, password) { + if workspace.password_hash.is_some() + && !db::verify_workspace_password(&workspace, password) + && !verify_resource_access_token(state, "workspace", slug, access_token).await? { return Err(ApiError::unauthorized()); } Ok(workspace) @@ -294,8 +308,9 @@ async fn authorized_note( workspace_slug: &str, note_slug: &str, password: Option<&str>, + access_token: Option<&str>, ) -> Result<(db::Workspace, db::Note), ApiError> { - let workspace = authorized_workspace(state, workspace_slug, password).await?; + let workspace = authorized_workspace(state, workspace_slug, password, access_token).await?; let note = db::find_note(&state.db, workspace.id, note_slug) .await? .ok_or_else(ApiError::not_found_note)?; @@ -447,7 +462,7 @@ pub async fn publish_pad_page( Path(slug): Path, Json(payload): Json, ) -> Result, ApiError> { - let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?; + let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let token = db::publish_pad(&state.db, pad.id).await?; db::set_pad_public_task_updates(&state.db, pad.id, payload.allow_task_updates).await?; Ok(Json(PublishResponse { url: format!("/s/{token}") })) @@ -458,7 +473,7 @@ pub async fn publish_note_page( Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result, ApiError> { - let (_, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?; + let (_, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let token = db::publish_note(&state.db, note.id).await?; db::set_note_public_task_updates(&state.db, note.id, payload.allow_task_updates).await?; Ok(Json(PublishResponse { url: format!("/s/{token}") })) @@ -500,7 +515,7 @@ pub async fn pad_history( Path(slug): Path, Json(payload): Json, ) -> Result>, ApiError> { - let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?; + let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let revisions = db::list_pad_revisions(&state.db, pad.id) .await? .into_iter() @@ -517,7 +532,7 @@ pub async fn pad_restore( Path(slug): Path, Json(payload): Json, ) -> Result, ApiError> { - let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?; + let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let content: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q029)) .bind(payload.revision_id) .bind(pad.id) @@ -546,11 +561,14 @@ async fn authorized_pad( state: &SharedState, slug: &str, password: Option<&str>, + access_token: Option<&str>, ) -> Result { let pad = db::find_pad(&state.db, slug) .await? .ok_or_else(ApiError::not_found_note)?; - if !db::verify_pad_password(&pad, password) { + if pad.password_hash.is_some() + && !db::verify_pad_password(&pad, password) + && !verify_resource_access_token(state, "pad", slug, access_token).await? { return Err(ApiError::unauthorized()); } Ok(pad) @@ -577,11 +595,14 @@ pub async fn upload_pad_file( mut multipart: Multipart, ) -> Result, ApiError> { let mut password: Option = None; + let mut access_token: Option = None; let mut file: Option<(String, Vec)> = None; while let Some(field) = multipart.next_field().await.map_err(|_| ApiError::bad_request("Invalid form data"))? { let name = field.name().unwrap_or_default().to_owned(); if name == "password" { password = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid password"))?); + } else if name == "access_token" { + access_token = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid access token"))?); } else if name == "file" { let filename = field.file_name().unwrap_or("plik").to_owned(); let bytes = field.bytes().await.map_err(|_| ApiError::bad_request("Failed to read the file"))?; @@ -591,7 +612,7 @@ pub async fn upload_pad_file( file = Some((filename, bytes.to_vec())); } } - let pad = authorized_pad(&state, &slug, password.as_deref()).await?; + let pad = authorized_pad(&state, &slug, password.as_deref(), access_token.as_deref()).await?; let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?; let safe = sanitize_filename(&original); let file_token = db::pad_file_token(&state.db, pad.id).await?; @@ -618,7 +639,7 @@ pub async fn pad_files( Path(slug): Path, Json(payload): Json, ) -> Result>, ApiError> { - let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?; + let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let mut files = db::list_pad_files(&state.db, pad.id).await?; for file in &mut files { let attached = pad.content.contains(&file.url); @@ -638,11 +659,14 @@ pub async fn upload_note_file( mut multipart: Multipart, ) -> Result, ApiError> { let mut password: Option = None; + let mut access_token: Option = None; let mut file: Option<(String, Vec)> = None; while let Some(field) = multipart.next_field().await.map_err(|_| ApiError::bad_request("Invalid form data"))? { let name = field.name().unwrap_or_default().to_owned(); if name == "password" { password = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid password"))?); + } else if name == "access_token" { + access_token = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid access token"))?); } else if name == "file" { let filename = field.file_name().unwrap_or("plik").to_owned(); let bytes = field.bytes().await.map_err(|_| ApiError::bad_request("Failed to read the file"))?; @@ -652,7 +676,7 @@ pub async fn upload_note_file( file = Some((filename, bytes.to_vec())); } } - let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, password.as_deref()).await?; + let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, password.as_deref(), access_token.as_deref()).await?; let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?; let safe = sanitize_filename(&original); let file_token = db::note_file_token(&state.db, note.id).await?; @@ -680,7 +704,7 @@ pub async fn delete_note( Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result, ApiError> { - let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?; + let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; if note.protected { return Err(ApiError::bad_request("This note is protected and cannot be deleted")); } db::delete_note(&state.db, note.id).await?; Ok(Json(serde_json::json!({"ok": true}))) @@ -691,7 +715,7 @@ pub async fn note_files( Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result>, ApiError> { - let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?; + let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let mut files = db::list_note_files(&state.db, note.id).await?; for file in &mut files { let attached = note.content.contains(&file.url); @@ -710,7 +734,7 @@ pub async fn delete_note_file( Path((workspace_slug, note_slug, file_id)): Path<(String, String, i64)>, Json(payload): Json, ) -> Result, ApiError> { - let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?; + let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; if workspace.password_hash.is_none() || payload.password.as_deref().unwrap_or_default().is_empty() { return Err(ApiError::unauthorized()); } @@ -786,6 +810,78 @@ fn sanitize_filename(value: &str) -> String { if clean.is_empty() || clean == "." || clean == ".." { "plik".into() } else { clean.chars().take(160).collect() } } +#[derive(Debug, Deserialize)] +pub struct AccessTokenRequest { + kind: String, + slug: String, + password: String, +} + +#[derive(Debug, Serialize)] +pub struct AccessTokenResponse { + access_token: String, + expires_at: String, +} + +pub async fn create_resource_access_token( + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let kind = payload.kind.trim(); + let slug = payload.slug.trim(); + match kind { + "workspace" => { + let workspace = db::find_workspace(&state.db, slug).await?.ok_or_else(ApiError::not_found_workspace)?; + if !db::verify_workspace_password(&workspace, Some(payload.password.as_str())) { + return Err(ApiError::unauthorized()); + } + } + "pad" => { + let pad = db::find_pad(&state.db, slug).await?.ok_or_else(ApiError::not_found_note)?; + if !db::verify_pad_password(&pad, Some(payload.password.as_str())) { + return Err(ApiError::unauthorized()); + } + } + _ => return Err(ApiError::bad_request("Invalid resource kind")), + } + + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + let token = hex::encode(bytes); + let expires_at = (Utc::now() + Duration::days(state.anonymous_access_token_ttl_days)).to_rfc3339(); + sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)")) + .bind(hash_access_token(&token)) + .bind(kind) + .bind(slug) + .bind(&expires_at) + .execute(state.db.pool()) + .await?; + Ok(Json(AccessTokenResponse { access_token: token, expires_at })) +} + +pub async fn verify_resource_access_token( + state: &SharedState, + kind: &str, + slug: &str, + token: Option<&str>, +) -> Result { + let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(false); + }; + let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), "SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND expires_at > ?")) + .bind(hash_access_token(token)) + .bind(kind) + .bind(slug) + .bind(Utc::now().to_rfc3339()) + .fetch_one(state.db.pool()) + .await?; + Ok(count > 0) +} + +fn hash_access_token(token: &str) -> String { + hex::encode(Sha256::digest(token.as_bytes())) +} + pub struct ApiError { status: StatusCode, message: String, diff --git a/src/app.rs b/src/app.rs index fbebb97..355c35a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -38,6 +38,7 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize .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/access-token", post(api::create_resource_access_token)) .route("/api/auth/register", post(auth::register)) .route("/api/auth/login", post(auth::login)) .route("/api/auth/confirm-account", post(auth::confirm_account)) diff --git a/src/auth.rs b/src/auth.rs index 0be2aa4..731ab91 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -172,6 +172,8 @@ pub async fn update_resource(State(state): State, headers: HeaderMa ensure_owner(&state, user.id, &req.kind, &req.slug).await?; let query = match req.kind.as_str() { "workspace" => queries::USER_SET_WORKSPACE_PASSWORD, "pad" => queries::USER_SET_PAD_PASSWORD, _ => return Err(AuthError::bad_request("Unknown resource type.")) }; sqlx::query(queries::get(state.db.kind(), query)).bind(hash).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; + sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?")) + .bind(req.kind.as_str()).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; Ok(Json(serde_json::json!({"ok":true}))) } @@ -179,6 +181,8 @@ pub async fn delete_resource(State(state): State, headers: HeaderMa let user = require_user(&state, &headers).await?; ensure_owner(&state, user.id, &req.kind, &req.slug).await?; let query = match req.kind.as_str() { "workspace" => queries::USER_DELETE_WORKSPACE, "pad" => queries::USER_DELETE_PAD, _ => return Err(AuthError::bad_request("Unknown resource type.")) }; + sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?")) + .bind(req.kind.as_str()).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; sqlx::query(queries::get(state.db.kind(), query)).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; Ok(Json(serde_json::json!({"ok":true}))) } @@ -276,7 +280,7 @@ pub async fn authorize_nickname(state: &SharedState, nickname: Option, t async fn create_session(state: &SharedState, user: &User) -> Result { let token = random_token(); - let expires_at = (Utc::now() + Duration::days(30)).to_rfc3339(); + let expires_at = (Utc::now() + Duration::days(state.user_session_ttl_days)).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)?; debug!(user_id = user.id, expires_at = %expires_at, "authentication session created"); diff --git a/src/config.rs b/src/config.rs index 2dba3d8..234d703 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,8 @@ pub struct Config { pub registration_enabled: bool, pub account_confirmation_required: bool, pub frontend_log_level: String, + pub anonymous_access_token_ttl_days: i64, + pub user_session_ttl_days: i64, } impl Config { @@ -25,6 +27,8 @@ impl Config { let upload_max_size_mb: usize = env_var("UPLOAD_MAX_SIZE_MB", "20").parse()?; + let anonymous_access_token_ttl_days = env_positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?; + let user_session_ttl_days = env_positive_i64("USER_SESSION_TTL_DAYS", 30)?; if upload_max_size_mb == 0 { return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into()); @@ -60,6 +64,8 @@ impl Config { registration_enabled: env_bool("REGISTRATION_ENABLED", false)?, account_confirmation_required: env_bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?, frontend_log_level: env_log_level("FRONTEND_LOG_LEVEL", "warn")?, + anonymous_access_token_ttl_days, + user_session_ttl_days, }) } } @@ -86,3 +92,11 @@ fn env_log_level(name: &str, default: &str) -> Result Err(format!("{name} must be one of: off, error, warn, info, debug").into()), } } + +fn env_positive_i64(name: &str, default: i64) -> Result> { + let value: i64 = env_var(name, &default.to_string()).parse()?; + if value <= 0 { + return Err(format!("{name} must be greater than 0").into()); + } + Ok(value) +} diff --git a/src/main.rs b/src/main.rs index 6282d31..5844990 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,8 @@ async fn main() -> Result<(), Box> { registration_enabled = config.registration_enabled, account_confirmation_required = config.account_confirmation_required, frontend_log_level = %config.frontend_log_level, + anonymous_access_token_ttl_days = config.anonymous_access_token_ttl_days, + user_session_ttl_days = config.user_session_ttl_days, smtp_configured = config.smtp.is_some(), asset_version = %config.asset_version, "configuration loaded" @@ -58,6 +60,8 @@ async fn main() -> Result<(), Box> { config.registration_enabled, config.account_confirmation_required, config.frontend_log_level.clone(), + config.anonymous_access_token_ttl_days, + config.user_session_ttl_days, )); let app = app::router( state, diff --git a/src/state.rs b/src/state.rs index 4cb76f1..c17560b 100644 --- a/src/state.rs +++ b/src/state.rs @@ -28,12 +28,14 @@ pub struct AppState { pub registration_enabled: bool, pub account_confirmation_required: bool, pub frontend_log_level: String, + pub anonymous_access_token_ttl_days: i64, + pub user_session_ttl_days: i64, channels: RwLock>>, } impl AppState { - pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize, smtp: Option, registration_enabled: bool, account_confirmation_required: bool, frontend_log_level: String) -> Self { - Self { db, asset_version, files_dir, upload_max_size_bytes, smtp, registration_enabled, account_confirmation_required, frontend_log_level, 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, account_confirmation_required: bool, frontend_log_level: String, anonymous_access_token_ttl_days: i64, user_session_ttl_days: i64) -> Self { + Self { db, asset_version, files_dir, upload_max_size_bytes, smtp, registration_enabled, account_confirmation_required, frontend_log_level, anonymous_access_token_ttl_days, user_session_ttl_days, 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 61f0938..6b6d84b 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -7,7 +7,7 @@ use crate::{auth, db, state::{NoteUpdate, SharedState}}; #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum ClientMessage { - Authenticate { password: Option, nickname: Option, session_token: Option }, + Authenticate { password: Option, access_token: Option, nickname: Option, session_token: Option }, Update { content: String, owner_map: Option }, } @@ -27,14 +27,14 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug info!(%workspace_slug, %note_slug, "note websocket connected"); let Some(workspace) = db::find_workspace(&state.db, &workspace_slug).await.ok().flatten() else { warn!(%workspace_slug, %note_slug, "note websocket rejected: workspace not found"); 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 { warn!(%workspace_slug, %note_slug, "note websocket rejected: note not found"); let _=send_error(&mut socket,"Note not found").await; return; }; - let (password, nickname, session_token) = match socket.recv().await { + let (password, access_token, nickname, session_token) = match socket.recv().await { Some(Ok(Message::Text(text))) => match serde_json::from_str::(&text) { - Ok(ClientMessage::Authenticate { password, nickname, session_token }) => (password, clean_nickname(nickname), session_token), + Ok(ClientMessage::Authenticate { password, access_token, nickname, session_token }) => (password, access_token, 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()) { warn!(workspace_id = workspace.id, note_id = note.id, "note websocket rejected: invalid workspace password"); let _=send_error(&mut socket,"Invalid password").await; return; } + if workspace.password_hash.is_some() && !db::verify_workspace_password(&workspace, password.as_deref()) && !crate::api::verify_resource_access_token(&state, "workspace", &workspace_slug, access_token.as_deref()).await.unwrap_or(false) { warn!(workspace_id = workspace.id, note_id = note.id, "note websocket rejected: invalid workspace password"); let _=send_error(&mut socket,"Invalid password").await; return; } info!(workspace_id = workspace.id, note_id = note.id, nickname = ?nickname, "note websocket authenticated"); 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; @@ -92,14 +92,14 @@ pub async fn upgrade_pad(ws:WebSocketUpgrade,Path(slug):Path,State(state async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){ info!(%slug, "pad websocket connected"); let Some(pad)=db::find_pad(&state.db,&slug).await.ok().flatten() else {warn!(%slug, "pad websocket rejected: pad not found");let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Note not found".into()}).await;return;}; - let (password,nickname,session_token)=match socket.recv().await{ + let (password,access_token,nickname,session_token)=match socket.recv().await{ Some(Ok(Message::Text(text)))=>match serde_json::from_str::(&text){ - Ok(ClientMessage::Authenticate{password,nickname,session_token})=>(password,clean_nickname(nickname),session_token), + Ok(ClientMessage::Authenticate{password,access_token,nickname,session_token})=>(password,access_token,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()){warn!(pad_id = pad.id, "pad websocket rejected: invalid password");let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Invalid password".into()}).await;return;} + if pad.password_hash.is_some() && !db::verify_pad_password(&pad,password.as_deref()) && !crate::api::verify_resource_access_token(&state,"pad",&slug,access_token.as_deref()).await.unwrap_or(false){warn!(pad_id = pad.id, "pad websocket rejected: invalid password");let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Invalid password".into()}).await;return;} info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated"); 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/js/home.js b/static/js/home.js index a9f7dc3..eaabd88 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -2,7 +2,7 @@ import { installGlobalDiagnostics, logInfo } from "./logger.js"; installGlobalDiagnostics(); import { bindIdentityDialog, handleAccountConfirmationToken, handleResetToken, logoutCurrentSession, validateCurrentSession } from "./auth-ui.js"; -import { getAuthToken } from "@rustpad/session"; +import { getAuthToken, setAccessToken } from "@rustpad/session"; import { api } from "@rustpad/api"; function slugify(value, fallback) { @@ -50,7 +50,7 @@ document.querySelector("#pad-form").addEventListener("submit", async (event) => const payload = { name: name.value.trim() }; if (password.value) payload.password = password.value; const result = await api("/api/pads", { method: "POST", headers: authHeaders(), body: JSON.stringify(payload) }); - if (password.value) sessionStorage.setItem(`rustpad:pad:${result.slug}:password`, password.value); + if (password.value) { const grant = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "pad", slug: result.slug, password: password.value }) }); setAccessToken("pad", result.slug, grant.access_token); } window.location.assign(`${result.url}?view=split&mode=markdown`); } catch (requestError) { error.textContent = requestError.message; @@ -71,7 +71,7 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even const payload = { name: name.value.trim() }; if (password.value) payload.password = password.value; const result = await api("/api/workspaces", { method: "POST", headers: authHeaders(), body: JSON.stringify(payload) }); - if (password.value) sessionStorage.setItem(`rustpad:workspace:${result.slug}:password`, password.value); + if (password.value) { const grant = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "workspace", slug: result.slug, password: password.value }) }); setAccessToken("workspace", result.slug, grant.access_token); } window.location.assign(result.url); } catch (requestError) { error.textContent = requestError.message; diff --git a/static/js/note.js b/static/js/note.js index 03f7d2c..2977529 100644 --- a/static/js/note.js +++ b/static/js/note.js @@ -6,7 +6,7 @@ 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, getAuthToken, setPassword } from "@rustpad/session"; +import { getNickname, getAccessToken, getAuthToken, setAccessToken } from "@rustpad/session"; import { bindIdentityDialog } from "./auth-ui.js"; import { NoteSocket } from "@rustpad/socket"; import { askConfirm } from "./modal.js"; @@ -16,7 +16,7 @@ const parts=location.pathname.split("/").filter(Boolean), workspaceSlug=parts[1] const editor=document.querySelector("#editor"), preview=document.querySelector("#preview"), editorWorkspace=document.querySelector("#editor-workspace"), gutter=document.querySelector("#line-gutter"), ownerLabels=document.querySelector("#owner-labels"); const modeToggle=document.querySelector("#mode-toggle"), passwordDialog=document.querySelector("#password-dialog"), identityDialog=document.querySelector("#identity-dialog"); const compactToggle=document.querySelector("#compact-toggle"), publicTaskUpdates=document.querySelector("#public-task-updates"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size"); -let password=getPassword(workspaceSlug), nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[]; +let accessToken=getAccessToken("workspace",workspaceSlug), password="", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[]; const lineToggle=document.querySelector("#line-numbers-toggle"); lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off"; compactToggle.checked=localStorage.getItem("rustpad:compact")!=="off"; fontFamily.value=localStorage.getItem("rustpad:font-family")||"mono"; @@ -74,29 +74,29 @@ 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,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 connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,accessToken,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}={}){ try{ - const files=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"PUT",body:JSON.stringify({password:password||null})}); + const files=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"PUT",body:JSON.stringify({access_token:accessToken||null})}); document.querySelector("#footer-files").textContent=`${files.length} ${files.length===1?"file":"files"}`; const list=document.querySelector("#files-list"); - list.innerHTML=files.length?files.map(file=>`
${escapeHtml(file.filename)}
${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)} · ${file.is_attached?"in note":"removed from content"}
${info?.protected&&password?``:""}
`).join(""):'

No files uploaded.

'; + list.innerHTML=files.length?files.map(file=>`
${escapeHtml(file.filename)}
${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)} · ${file.is_attached?"in note":"removed from content"}
${info?.protected&&accessToken?``:""}
`).join(""):'

No files uploaded.

'; 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();}}}); +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&&!accessToken)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)}

`;}} +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&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`

Note not found

${escapeHtml(e.message)}

`;}} 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);}}); +publicTaskUpdates.addEventListener("change",async()=>{publicTaskUpdates.disabled=true;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({access_token:accessToken||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({access_token:accessToken||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);}}); editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.lengthsocket?.update(editor.value,JSON.stringify(owners)),250);}); -document.querySelector("#password-form").addEventListener("submit",e=>{e.preventDefault();password=document.querySelector("#open-password").value;setPassword(workspaceSlug,password);document.querySelector("#password-error").textContent="";loadFiles();connect();}); -const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='

Loading…

';try{const revisions=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/history`,{method:"POST",body:JSON.stringify({password:password||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `
${escapeHtml(author)}

${snippet}

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

No history yet.

';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`,{method:"POST",body:JSON.stringify({password:password||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`

${escapeHtml(e.message)}

`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");}); -document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("password",password||"");form.append("file",file);try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");loadFiles();}catch(err){toast(err.message);}e.target.value="";}); +document.querySelector("#password-form").addEventListener("submit",async e=>{e.preventDefault();try{password=document.querySelector("#open-password").value;const result=await api("/api/access-token",{method:"POST",body:JSON.stringify({kind:"workspace",slug:workspaceSlug,password})});accessToken=result.access_token;setAccessToken("workspace",workspaceSlug,accessToken);password="";document.querySelector("#open-password").value="";document.querySelector("#password-error").textContent="";loadFiles();connect();}catch(error){document.querySelector("#password-error").textContent=error.message;}}); +const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='

Loading…

';try{const revisions=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/history`,{method:"POST",body:JSON.stringify({access_token:accessToken||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `
${escapeHtml(author)}

${snippet}

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

No history yet.

';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`

${escapeHtml(e.message)}

`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");}); +document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("access_token",accessToken||"");form.append("file",file);try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");loadFiles();}catch(err){toast(err.message);}e.target.value="";}); document.querySelector("#files-button").addEventListener("click",()=>loadFiles({open:true})); document.querySelector("#footer-files").addEventListener("click",()=>loadFiles({open:true})); document.querySelector("#close-files").addEventListener("click",()=>document.querySelector("#files-dialog").close()); @@ -115,10 +115,10 @@ document.querySelector("#files-list").addEventListener("click",async event=>{ const deleteButton=event.target.closest("[data-delete-file]"); if(deleteButton){ 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; + try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`,{method:"DELETE",body:JSON.stringify({access_token:accessToken||null})});toast("File deleted");await loadFiles();}catch(error){toast(error.message);}return; } }); -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);}}); +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({access_token:accessToken||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 8c0daae..0a7a323 100644 --- a/static/js/pad.js +++ b/static/js/pad.js @@ -6,7 +6,7 @@ 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, getAuthToken } from "@rustpad/session"; +import { getNickname, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session"; import { bindIdentityDialog } from "./auth-ui.js"; import { PadSocket } from "@rustpad/socket"; import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state"; @@ -15,7 +15,7 @@ const slug=location.pathname.split("/").filter(Boolean)[1]; const editor=document.querySelector("#editor"), preview=document.querySelector("#preview"), editorWorkspace=document.querySelector("#editor-workspace"), gutter=document.querySelector("#line-gutter"), ownerLabels=document.querySelector("#owner-labels"); const modeToggle=document.querySelector("#mode-toggle"), passwordDialog=document.querySelector("#password-dialog"), identityDialog=document.querySelector("#identity-dialog"); const compactToggle=document.querySelector("#compact-toggle"), publicTaskUpdates=document.querySelector("#public-task-updates"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size"); -let password=sessionStorage.getItem(`rustpad:pad:${slug}:password`)||"", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[]; +let accessToken=getAccessToken("pad",slug), password="", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[]; const lineToggle=document.querySelector("#line-numbers-toggle"); lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off"; compactToggle.checked=localStorage.getItem("rustpad:compact")!=="off"; fontFamily.value=localStorage.getItem("rustpad:font-family")||"mono"; @@ -76,24 +76,24 @@ function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null async function loadFiles({open=false}={}){ try{ - const files=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"PUT",body:JSON.stringify({password:password||null})}); + const files=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"PUT",body:JSON.stringify({access_token:accessToken||null})}); document.querySelector("#footer-files").textContent=`${files.length} ${files.length===1?"file":"files"}`; document.querySelector("#files-list").innerHTML=files.length?files.map(file=>`
${escapeHtml(file.filename)}
${escapeHtml(file.mime_type)} · ${Math.max(1,Math.round(file.size_bytes/1024))} KB · ${formatDate(file.created_at)} · ${file.is_attached?"in note":"removed from content"}
`).join(""):'

No files uploaded.

'; if(open)document.querySelector("#files-dialog").showModal(); }catch(error){if(open)toast(error.message);} } -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();}}}); +function connect(){socket?.stop();socket=new PadSocket({slug,password,accessToken,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&&!accessToken)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)}

`;}} +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&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`

Note not found

${escapeHtml(e.message)}

`;}} 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);}}); +publicTaskUpdates.addEventListener("change",async()=>{publicTaskUpdates.disabled=true;try{await api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({access_token:accessToken||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({access_token:accessToken||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);}}); editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.lengthsocket?.update(editor.value,JSON.stringify(owners)),250);}); -document.querySelector("#password-form").addEventListener("submit",e=>{e.preventDefault();password=document.querySelector("#open-password").value;sessionStorage.setItem(`rustpad:pad:${slug}:password`,password);document.querySelector("#password-error").textContent="";connect();}); -const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='

Loading…

';try{const revisions=await api(`/api/pads/${encodeURIComponent(slug)}/history`,{method:"POST",body:JSON.stringify({password:password||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `
${escapeHtml(author)}

${snippet}

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

No history yet.

';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/pads/${encodeURIComponent(slug)}/restore`,{method:"POST",body:JSON.stringify({password:password||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`

${escapeHtml(e.message)}

`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");}); -document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("password",password||"");form.append("file",file);try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");loadFiles();}catch(err){toast(err.message);}e.target.value="";}); +document.querySelector("#password-form").addEventListener("submit",async e=>{e.preventDefault();try{password=document.querySelector("#open-password").value;const result=await api("/api/access-token",{method:"POST",body:JSON.stringify({kind:"pad",slug,password})});accessToken=result.access_token;setAccessToken("pad",slug,accessToken);password="";document.querySelector("#open-password").value="";document.querySelector("#password-error").textContent="";connect();}catch(error){document.querySelector("#password-error").textContent=error.message;}}); +const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='

Loading…

';try{const revisions=await api(`/api/pads/${encodeURIComponent(slug)}/history`,{method:"POST",body:JSON.stringify({access_token:accessToken||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `
${escapeHtml(author)}

${snippet}

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

No history yet.

';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/pads/${encodeURIComponent(slug)}/restore`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`

${escapeHtml(e.message)}

`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");}); +document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("access_token",accessToken||"");form.append("file",file);try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");loadFiles();}catch(err){toast(err.message);}e.target.value="";}); document.querySelector("#files-button").addEventListener("click",()=>loadFiles({open:true})); document.querySelector("#footer-files").addEventListener("click",()=>loadFiles({open:true})); diff --git a/static/js/session.js b/static/js/session.js index 2760353..e6c70c4 100644 --- a/static/js/session.js +++ b/static/js/session.js @@ -1,6 +1,18 @@ -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 accessTokenKey(resourceKind, resourceSlug) { return `rustpad:access:${resourceKind}:${resourceSlug}`; } +export function getAccessToken(resourceKind, resourceSlug) { + return localStorage.getItem(accessTokenKey(resourceKind, resourceSlug)) || ""; +} +export function setAccessToken(resourceKind, resourceSlug, token) { + const key = accessTokenKey(resourceKind, resourceSlug); + if (token) localStorage.setItem(key, token); + else localStorage.removeItem(key); +} +// Remove plaintext passwords saved by the previous frontend version. +for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i); + if (key?.startsWith("rustpad:workspace:") && key.endsWith(":password")) localStorage.removeItem(key); +} + const NICKNAME_KEY = "rustpad:nickname"; const NICKNAME_COOKIE = "rustpad_nickname"; // A session cookie is shared by tabs, but disappears when the browser session @@ -29,11 +41,19 @@ export function setNickname(value) { } 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); } +// Account sessions must be shared by every tab on this origin. Keep a +// compatibility fallback for sessions created by older frontend versions. +const legacySessionToken = sessionStorage.getItem(AUTH_TOKEN_KEY); +if (legacySessionToken && !localStorage.getItem(AUTH_TOKEN_KEY)) { + localStorage.setItem(AUTH_TOKEN_KEY, legacySessionToken); +} +sessionStorage.removeItem(AUTH_TOKEN_KEY); +export function getAuthToken() { return localStorage.getItem(AUTH_TOKEN_KEY) || ""; } +export function setAuthSession(session) { + localStorage.setItem(AUTH_TOKEN_KEY, session.token); + sessionStorage.removeItem(AUTH_TOKEN_KEY); + setNickname(session.nickname); +} export function clearAuthSession() { localStorage.removeItem(AUTH_TOKEN_KEY); sessionStorage.removeItem(AUTH_TOKEN_KEY); diff --git a/static/js/socket.js b/static/js/socket.js index 8a595dc..4193537 100644 --- a/static/js/socket.js +++ b/static/js/socket.js @@ -1,13 +1,13 @@ import { logDebug, logError, logInfo, logWarn } from "./logger.js"; export class NoteSocket { - 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",()=>{logInfo("websocket.open",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});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"){logInfo("websocket.authenticated",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});this.onStatus?.("online");this.onAuthenticated?.(m);} if(m.type==="document")this.onDocument?.(m);}); this.socket.addEventListener("close",event=>{logWarn("websocket.close",{kind:"note",code:event.code,reason:event.reason||"",intentional:this.closed});if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}}); this.socket.addEventListener("error",event=>{logError("websocket.error",event,{kind:"note"});this.onError?.("Failed to connect to the WebSocket server");this.socket.close();}); } + constructor({ workspaceSlug, noteSlug, password, accessToken, nickname, sessionToken, onStatus, onAuthenticated, onDocument, onError }) { Object.assign(this, { workspaceSlug, noteSlug, password, accessToken, 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",()=>{logInfo("websocket.open",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,access_token:this.accessToken||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"){logInfo("websocket.authenticated",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});this.onStatus?.("online");this.onAuthenticated?.(m);} if(m.type==="document")this.onDocument?.(m);}); this.socket.addEventListener("close",event=>{logWarn("websocket.close",{kind:"note",code:event.code,reason:event.reason||"",intentional:this.closed});if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}}); this.socket.addEventListener("error",event=>{logError("websocket.error",event,{kind:"note"});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,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",()=>{logInfo("websocket.open",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});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"){logInfo("websocket.authenticated",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});this.onStatus?.("online");this.onAuthenticated?.(m);}if(m.type==="document")this.onDocument?.(m);});this.socket.addEventListener("close",event=>{logWarn("websocket.close",{kind:"note",code:event.code,reason:event.reason||"",intentional:this.closed});if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}});this.socket.addEventListener("error",event=>{logError("websocket.error",event,{kind:"note"});this.onError?.("Failed to connect to the WebSocket server");this.socket.close();});} + constructor({slug,password,accessToken,nickname,sessionToken,onStatus,onAuthenticated,onDocument,onError}){Object.assign(this,{slug,password,accessToken,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",()=>{logInfo("websocket.open",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,access_token:this.accessToken||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"){logInfo("websocket.authenticated",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});this.onStatus?.("online");this.onAuthenticated?.(m);}if(m.type==="document")this.onDocument?.(m);});this.socket.addEventListener("close",event=>{logWarn("websocket.close",{kind:"note",code:event.code,reason:event.reason||"",intentional:this.closed});if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}});this.socket.addEventListener("error",event=>{logError("websocket.error",event,{kind:"note"});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 6d25968..dde2e0b 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -3,13 +3,13 @@ installGlobalDiagnostics(); import { api } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; -import { getNickname, getPassword, setPassword } from "@rustpad/session"; +import { getNickname, getAccessToken, setAccessToken } from "@rustpad/session"; import { askConfirm } from "./modal.js"; const parts = location.pathname.split("/").filter(Boolean); const slug = parts[1]; let info; -let password = getPassword(slug); +let accessToken = getAccessToken("workspace", slug); const dialog = document.querySelector("#password-dialog"); const notesList = document.querySelector("#notes-list"); const notesViewKey = `rustpad:workspace:${slug}:notes-view`; @@ -77,7 +77,7 @@ function renderNotes(notes = notesCache) { } async function openWorkspace() { try { - const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open`, { method: "POST", body: JSON.stringify({ password: password || null }) }); + const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null }) }); info = data.workspace; document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-url").textContent = location.pathname; @@ -97,11 +97,20 @@ async function init() { info = await api(`/api/workspaces/${encodeURIComponent(slug)}`); document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-url").textContent = location.pathname; - if (info.protected && !password) dialog.showModal(); else openWorkspace(); + if (info.protected && !accessToken) dialog.showModal(); else openWorkspace(); } catch (e) { document.querySelector("#workspace-error").textContent = e.message; } } -document.querySelector("#password-form").addEventListener("submit", e => { - e.preventDefault(); password = document.querySelector("#open-password").value; setPassword(slug, password); openWorkspace(); +document.querySelector("#password-form").addEventListener("submit", async e => { + e.preventDefault(); + try { + const password = document.querySelector("#open-password").value; + const result = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "workspace", slug, password }) }); + accessToken = result.access_token; + setAccessToken("workspace", slug, accessToken); + document.querySelector("#open-password").value = ""; + document.querySelector("#password-error").textContent = ""; + openWorkspace(); + } catch (error) { document.querySelector("#password-error").textContent = error.message; } }); document.querySelector("#new-note-button").addEventListener("click", () => document.querySelector("#note-dialog").showModal()); document.querySelector("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close()); @@ -111,7 +120,7 @@ document.querySelector("#note-form").addEventListener("submit", async e => { try { const note = await api(`/api/workspaces/${encodeURIComponent(slug)}/notes`, { method: "POST", - body: JSON.stringify({ name: document.querySelector("#note-name").value, password: password || null, protect: document.querySelector("#note-protect").checked, created_by: getNickname() || null }) + body: JSON.stringify({ name: document.querySelector("#note-name").value, access_token: accessToken || null, protect: document.querySelector("#note-protect").checked, created_by: getNickname() || null }) }); location.assign(`${note.url}?view=split&mode=markdown`); } catch (err) { error.textContent = err.message; } @@ -124,7 +133,7 @@ notesList.addEventListener("click", async event => { button.disabled = true; try { await api(`/api/workspaces/${encodeURIComponent(slug)}/notes/${encodeURIComponent(button.dataset.deleteNote)}`, { - method: "DELETE", body: JSON.stringify({ password: password || null }) + method: "DELETE", body: JSON.stringify({ access_token: accessToken || null }) }); toast("Note deleted"); await openWorkspace();