From 09669ff330791ffc713af50afee6064b29504246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 28 Jul 2026 23:53:37 +0200 Subject: [PATCH] fixes --- Cargo.lock | 2 +- Cargo.toml | 2 +- .../mysql/0019_public_page_protection.sql | 1 + .../postgres/0019_public_page_protection.sql | 1 + .../sqlite/0019_public_page_protection.sql | 1 + src/api/mod.rs | 8 +- src/api/pads_public.rs | 49 +++++- src/app/mod.rs | 1 - src/app/pages.rs | 34 ++-- src/auth/mod.rs | 5 - src/db/public_pages.rs | 31 ++++ src/queries/mod.rs | 8 + src/queries/mysql.rs | 5 + src/queries/postgres.rs | 5 + src/queries/sqlite.rs | 5 + src/state.rs | 16 ++ src/websocket/mod.rs | 2 +- src/websocket/pad.rs | 2 +- static/css/styles.css | 148 +++++++++++++++++- static/home.html | 1 + static/js/home.js | 18 ++- static/js/note-api.js | 8 +- static/js/note-editor.js | 96 ++++++++---- static/js/public.js | 10 +- static/js/workspace.js | 20 ++- static/note.html | 6 +- static/pad.html | 6 +- static/public.html | 10 ++ 28 files changed, 412 insertions(+), 89 deletions(-) create mode 100644 migrations/mysql/0019_public_page_protection.sql create mode 100644 migrations/postgres/0019_public_page_protection.sql create mode 100644 migrations/sqlite/0019_public_page_protection.sql diff --git a/Cargo.lock b/Cargo.lock index 25b2024..668a115 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.1.20" +version = "0.1.22" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 874c197..41c4bde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.1.20" +version = "0.1.22" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/migrations/mysql/0019_public_page_protection.sql b/migrations/mysql/0019_public_page_protection.sql new file mode 100644 index 0000000..d687465 --- /dev/null +++ b/migrations/mysql/0019_public_page_protection.sql @@ -0,0 +1 @@ +ALTER TABLE published_pages ADD COLUMN unprotected BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/postgres/0019_public_page_protection.sql b/migrations/postgres/0019_public_page_protection.sql new file mode 100644 index 0000000..d687465 --- /dev/null +++ b/migrations/postgres/0019_public_page_protection.sql @@ -0,0 +1 @@ +ALTER TABLE published_pages ADD COLUMN unprotected BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/sqlite/0019_public_page_protection.sql b/migrations/sqlite/0019_public_page_protection.sql new file mode 100644 index 0000000..af1e152 --- /dev/null +++ b/migrations/sqlite/0019_public_page_protection.sql @@ -0,0 +1 @@ +ALTER TABLE published_pages ADD COLUMN unprotected INTEGER NOT NULL DEFAULT 0; diff --git a/src/api/mod.rs b/src/api/mod.rs index 3bc77a1..de8b3bb 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -81,6 +81,8 @@ pub struct PublishRequest { access_token: Option, #[serde(default)] allow_task_updates: bool, + #[serde(default)] + unprotect_page: bool, } #[derive(Debug, Deserialize)] @@ -150,6 +152,7 @@ pub struct NoteInfo { protected: bool, note_protected: bool, allow_public_task_updates: bool, + public_page_unprotected: bool, created_at: String, updated_at: String, can_delete_files: bool, @@ -445,6 +448,7 @@ pub async fn note_info( protected: workspace.password_hash.is_some(), note_protected: note.protected, allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?, + public_page_unprotected: db::note_public_page_unprotected(&state.db, note.id).await?, created_at: db::normalize_timestamp(¬e.created_at), updated_at: db::normalize_timestamp(¬e.updated_at), can_delete_files: { @@ -651,7 +655,7 @@ async fn ensure_private_resource_access( if verify_resource_access_token(state, kind, slug, token).await? { return Ok(()); } - Err(ApiError::forbidden("This resource is private.")) + Err(ApiError::not_found_workspace()) } pub async fn authorized_workspace( @@ -667,7 +671,7 @@ pub async fn authorized_workspace( let token_level = combined_token_access_level(state, "workspace", slug, access_token, bearer).await?; if workspace.is_private != 0 && token_level == AccessLevel::None { - return Err(ApiError::forbidden("This workspace is private.")); + return Err(ApiError::not_found_workspace()); } if workspace.password_hash.is_some() && !db::verify_workspace_password(&workspace, password) diff --git a/src/api/pads_public.rs b/src/api/pads_public.rs index 0edd5f1..334bec3 100644 --- a/src/api/pads_public.rs +++ b/src/api/pads_public.rs @@ -19,6 +19,7 @@ pub struct PadInfo { title: String, protected: bool, allow_public_task_updates: bool, + public_page_unprotected: bool, created_at: String, updated_at: String, can_delete_files: bool, @@ -82,6 +83,7 @@ pub async fn pad_info( title: pad.title, protected: pad.password_hash.is_some(), allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?, + public_page_unprotected: db::pad_public_page_unprotected(&state.db, pad.id).await?, created_at: db::normalize_timestamp(&pad.created_at), updated_at: db::normalize_timestamp(&pad.updated_at), can_delete_files: crate::auth::is_resource_owner( @@ -181,6 +183,7 @@ pub async fn publish_pad_page( require_write(level)?; 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?; + db::set_pad_public_page_unprotected(&state.db, pad.id, payload.unprotect_page).await?; Ok(Json(PublishResponse { url: format!("/s/{token}"), })) @@ -218,18 +221,58 @@ pub async fn publish_note_page( require_write(level)?; 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?; + db::set_note_public_page_unprotected(&state.db, note.id, payload.unprotect_page).await?; Ok(Json(PublishResponse { url: format!("/s/{token}"), })) } + +fn page_password(headers: &HeaderMap) -> Option<&str> { + headers.get("x-rustpad-page-password").and_then(|value| value.to_str().ok()).map(str::trim).filter(|value| !value.is_empty()) +} + +async fn ensure_public_page_access(state: &SharedState, headers: &HeaderMap, page: &db::PublishedPage) -> Result<(), ApiError> { + let password = page_password(headers); + let bearer = bearer_token(headers); + if let Some(pad_id) = page.pad_id { + if db::pad_public_page_unprotected(&state.db, pad_id).await? { return Ok(()); } + let sql = match state.db.kind() { + crate::database::DatabaseKind::Postgres => "SELECT slug FROM pads WHERE id = $1", + _ => "SELECT slug FROM pads WHERE id = ?", + }; + let slug: Option = sqlx::query_scalar(sql).bind(pad_id).fetch_optional(state.db.pool()).await?; + let Some(slug) = slug else { return Err(ApiError::not_found_note()); }; + let pad = db::find_pad(&state.db, &slug).await?.ok_or_else(ApiError::not_found_note)?; + if db::verify_pad_password(&pad, password) { return Ok(()); } + if verify_resource_access_token(state, "pad", &slug, bearer).await? { return Ok(()); } + return if pad.password_hash.is_some() { Err(ApiError::forbidden("Password required or incorrect.")) } else { Err(ApiError::forbidden("This published page is protected.")) }; + } + if let Some(note_id) = page.note_id { + if db::note_public_page_unprotected(&state.db, note_id).await? { return Ok(()); } + let sql = match state.db.kind() { + crate::database::DatabaseKind::Postgres => "SELECT w.slug FROM notes n JOIN workspaces w ON w.id = n.workspace_id WHERE n.id = $1", + _ => "SELECT w.slug FROM notes n JOIN workspaces w ON w.id = n.workspace_id WHERE n.id = ?", + }; + let slug: Option = sqlx::query_scalar(sql).bind(note_id).fetch_optional(state.db.pool()).await?; + let Some(slug) = slug else { return Err(ApiError::not_found_note()); }; + let workspace = db::find_workspace(&state.db, &slug).await?.ok_or_else(ApiError::not_found_workspace)?; + if db::verify_workspace_password(&workspace, password) { return Ok(()); } + if verify_resource_access_token(state, "workspace", &slug, bearer).await? { return Ok(()); } + return if workspace.password_hash.is_some() { Err(ApiError::forbidden("Password required or incorrect.")) } else { Err(ApiError::forbidden("This published page is protected.")) }; + } + Err(ApiError::not_found_note()) +} + pub async fn public_page( State(state): State, + headers: HeaderMap, Path(token): Path, ) -> Result, ApiError> { let page = db::find_published_page(&state.db, &token) .await? .ok_or_else(ApiError::not_found_note)?; + ensure_public_page_access(&state, &headers, &page).await?; Ok(Json(PublicPageResponse { title: page.title, content: page.content, @@ -240,12 +283,14 @@ pub async fn public_page( pub async fn update_public_task( State(state): State, + headers: HeaderMap, Path(token): Path, Json(payload): Json, ) -> Result, ApiError> { let current = db::find_published_page(&state.db, &token) .await? .ok_or_else(ApiError::not_found_note)?; + ensure_public_page_access(&state, &headers, ¤t).await?; if !current.allow_task_updates { return Err(ApiError::forbidden( "Task updates are disabled for this page", @@ -357,13 +402,13 @@ pub(super) async fn authorized_pad( .ok_or_else(ApiError::not_found_note)?; let token_level = combined_token_access_level(state, "pad", slug, access_token, bearer).await?; if pad.is_private != 0 && token_level == AccessLevel::None { - return Err(ApiError::forbidden("This note is private.")); + return Err(ApiError::not_found_note()); } if pad.password_hash.is_some() && !db::verify_pad_password(&pad, password) && token_level == AccessLevel::None { - return Err(ApiError::unauthorized()); + return Err(ApiError::forbidden("Password required or incorrect.")); } Ok(pad) } diff --git a/src/app/mod.rs b/src/app/mod.rs index 51cabbe..e5c36c9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -50,7 +50,6 @@ pub fn router( .route("/s/{token}", get(public_page)) .route("/w/{workspace_slug}", get(workspace)) .route("/w/{workspace_slug}/n/{note_slug}", get(note)) - .route("/errors/private-workspace", get(private_workspace_error)) .route("/health", get(health)) .route("/robots.txt", get(robots_txt)) .route("/favicon.ico", get(favicon)) diff --git a/src/app/pages.rs b/src/app/pages.rs index 17c13eb..f812f3b 100644 --- a/src/app/pages.rs +++ b/src/app/pages.rs @@ -6,18 +6,6 @@ use axum::{ use crate::{assets, db, state::SharedState}; -pub(super) async fn private_workspace_error(State(state): State) -> Response { - error_response( - StatusCode::FORBIDDEN, - "403", - "Private workspace", - "You do not have permission to access this private workspace. Ask the owner to share it with your account or use a valid share link.", - "/", - "Home page", - &state.asset_version, - ) -} - pub(super) async fn health() -> &'static str { "ok" } @@ -112,7 +100,7 @@ pub(super) async fn workspace( match db::find_workspace(&state.db, &workspace_slug).await { Ok(Some(workspace)) => { let html = include_str!("../../static/workspace.html") - .replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title)); + .replace("__WORKSPACE_TITLE__", &escape_html(if workspace.is_private != 0 { "Workspace" } else { &workspace.title })); assets::render_html( &html, &state.asset_version, @@ -126,8 +114,8 @@ pub(super) async fn workspace( Ok(None) => error_response( StatusCode::NOT_FOUND, "404", - "Workspace not found", - "This workspace does not exist or has been deleted.", + "Page not found", + "Check the address or return to the home page.", "/", "Home page", &state.asset_version, @@ -149,8 +137,8 @@ pub(super) async fn note( return error_response( StatusCode::NOT_FOUND, "404", - "Workspace not found", - "The workspace for this note does not exist or has been deleted.", + "Page not found", + "Check the address or return to the home page.", "/", "Home page", &state.asset_version, @@ -165,8 +153,8 @@ pub(super) async fn note( match db::find_note(&state.db, workspace.id, ¬e_slug).await { Ok(Some(note)) => { let html = include_str!("../../static/note.html") - .replace("__NOTE_TITLE__", &escape_html(¬e.title)) - .replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title)) + .replace("__NOTE_TITLE__", &escape_html(if workspace.is_private != 0 { "Note" } else { ¬e.title })) + .replace("__WORKSPACE_TITLE__", &escape_html(if workspace.is_private != 0 { "Workspace" } else { &workspace.title })) .replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug)); assets::render_html( &html, @@ -181,10 +169,10 @@ pub(super) async fn note( Ok(None) => error_response( StatusCode::NOT_FOUND, "404", - "Note not found", - "This note does not exist or has been deleted.", - &format!("/w/{workspace_slug}"), - "Back do workspace", + "Page not found", + "Check the address or return to the home page.", + "/", + "Home page", &state.asset_version, ), Err(error) => { diff --git a/src/auth/mod.rs b/src/auth/mod.rs index f6c0848..c064569 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -279,11 +279,6 @@ pub async fn identity( State(state): State, Json(req): Json, ) -> Result, AuthError> { - if state.ldap.is_some() && req.session_token.is_none() { - return Err(AuthError::unauthorized( - "Log in with your organization account.", - )); - } let nickname = validate_nickname(&req.nickname)?; debug!(nickname = %nickname, has_session = req.session_token.is_some(), "identity check requested"); match find_user_by_nickname(&state, &nickname).await? { diff --git a/src/db/public_pages.rs b/src/db/public_pages.rs index 18da564..4a5904f 100644 --- a/src/db/public_pages.rs +++ b/src/db/public_pages.rs @@ -185,6 +185,37 @@ pub async fn set_note_public_task_updates( Ok(()) } + +pub async fn pad_public_page_unprotected(pool: &Database, pad_id: i64) -> Result { + if pool.kind() == DatabaseKind::Postgres { + return Ok(sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q048)).bind(pad_id).fetch_optional(pool.pool()).await?.unwrap_or(false)); + } + Ok(sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q048)).bind(pad_id).fetch_optional(pool.pool()).await?.unwrap_or(0) != 0) +} + +pub async fn note_public_page_unprotected(pool: &Database, note_id: i64) -> Result { + if pool.kind() == DatabaseKind::Postgres { + return Ok(sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q049)).bind(note_id).fetch_optional(pool.pool()).await?.unwrap_or(false)); + } + Ok(sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q049)).bind(note_id).fetch_optional(pool.pool()).await?.unwrap_or(0) != 0) +} + +pub async fn set_pad_public_page_unprotected(pool: &Database, pad_id: i64, value: bool) -> Result<(), sqlx::Error> { + publish_pad(pool, pad_id).await?; + let mut query = sqlx::query(queries::get(pool.kind(), queries::Q050)); + query = if pool.kind() == DatabaseKind::Postgres { query.bind(value) } else { query.bind(if value { 1i64 } else { 0i64 }) }; + query.bind(pad_id).execute(pool.pool()).await?; + Ok(()) +} + +pub async fn set_note_public_page_unprotected(pool: &Database, note_id: i64, value: bool) -> Result<(), sqlx::Error> { + publish_note(pool, note_id).await?; + let mut query = sqlx::query(queries::get(pool.kind(), queries::Q051)); + query = if pool.kind() == DatabaseKind::Postgres { query.bind(value) } else { query.bind(if value { 1i64 } else { 0i64 }) }; + query.bind(note_id).execute(pool.pool()).await?; + Ok(()) +} + pub async fn update_public_task( pool: &Database, token: &str, diff --git a/src/queries/mod.rs b/src/queries/mod.rs index 7ac2929..646e296 100644 --- a/src/queries/mod.rs +++ b/src/queries/mod.rs @@ -132,6 +132,10 @@ pub enum Query { Q043, Q044, Q045, + Q048, + Q049, + Q050, + Q051, } pub fn get(kind: DatabaseKind, query: Query) -> &'static str { @@ -267,6 +271,10 @@ pub const Q042: Query = Query::Q042; pub const Q043: Query = Query::Q043; pub const Q044: Query = Query::Q044; pub const Q045: Query = Query::Q045; +pub const Q048: Query = Query::Q048; +pub const Q049: Query = Query::Q049; +pub const Q050: Query = Query::Q050; +pub const Q051: Query = Query::Q051; #[cfg(test)] mod tests { diff --git a/src/queries/mysql.rs b/src/queries/mysql.rs index 4f7da31..2d9846b 100644 --- a/src/queries/mysql.rs +++ b/src/queries/mysql.rs @@ -127,5 +127,10 @@ pub fn get(query: Query) -> &'static str { Query::Q043 => r#"UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#, Query::Q044 => r#"SELECT CAST(CASE WHEN allow_task_updates THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE pad_id = ?"#, Query::Q045 => r#"SELECT CAST(CASE WHEN allow_task_updates THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE note_id = ?"#, + + Query::Q048 => r#"SELECT CAST(CASE WHEN unprotected THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE pad_id = ?"#, + Query::Q049 => r#"SELECT CAST(CASE WHEN unprotected THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE note_id = ?"#, + Query::Q050 => r#"UPDATE published_pages SET unprotected = ? WHERE pad_id = ?"#, + Query::Q051 => r#"UPDATE published_pages SET unprotected = ? WHERE note_id = ?"#, } } diff --git a/src/queries/postgres.rs b/src/queries/postgres.rs index 56790ef..f9f7952 100644 --- a/src/queries/postgres.rs +++ b/src/queries/postgres.rs @@ -127,5 +127,10 @@ pub fn get(query: Query) -> &'static str { Query::Q043 => r#"UPDATE notes SET content = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $2"#, Query::Q044 => r#"SELECT allow_task_updates FROM published_pages WHERE pad_id = $1"#, Query::Q045 => r#"SELECT allow_task_updates FROM published_pages WHERE note_id = $1"#, + + Query::Q048 => r#"SELECT unprotected FROM published_pages WHERE pad_id = $1"#, + Query::Q049 => r#"SELECT unprotected FROM published_pages WHERE note_id = $1"#, + Query::Q050 => r#"UPDATE published_pages SET unprotected = $1 WHERE pad_id = $2"#, + Query::Q051 => r#"UPDATE published_pages SET unprotected = $1 WHERE note_id = $2"#, } } diff --git a/src/queries/sqlite.rs b/src/queries/sqlite.rs index b044db6..b195144 100644 --- a/src/queries/sqlite.rs +++ b/src/queries/sqlite.rs @@ -127,5 +127,10 @@ pub fn get(query: Query) -> &'static str { Query::Q043 => r#"UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#, Query::Q044 => r#"SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"#, Query::Q045 => r#"SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"#, + + Query::Q048 => r#"SELECT CASE WHEN unprotected THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"#, + Query::Q049 => r#"SELECT CASE WHEN unprotected THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"#, + Query::Q050 => r#"UPDATE published_pages SET unprotected = ? WHERE pad_id = ?"#, + Query::Q051 => r#"UPDATE published_pages SET unprotected = ? WHERE note_id = ?"#, } } diff --git a/src/state.rs b/src/state.rs index 2aa518c..3233889 100644 --- a/src/state.rs +++ b/src/state.rs @@ -41,9 +41,24 @@ pub struct NoteUpdate { #[derive(Debug, Clone, Serialize)] pub struct PresenceUser { pub name: String, + pub compact_name: String, pub color: Option, } +fn compact_presence_name(name: &str) -> String { + let trimmed = name.trim(); + let Some((first, rest)) = trimmed.split_once('.') else { + return trimmed.to_string(); + }; + if first.is_empty() || rest.is_empty() { + return trimmed.to_string(); + } + let Some(initial) = first.chars().next() else { + return trimmed.to_string(); + }; + format!("{initial}.{rest}") +} + #[derive(Debug, Clone)] struct PresenceConnection { identity: String, @@ -158,6 +173,7 @@ impl AppState { PresenceConnection { identity, user: PresenceUser { + compact_name: compact_presence_name(&nickname), name: nickname, color, }, diff --git a/src/websocket/mod.rs b/src/websocket/mod.rs index 8906b02..adc396f 100644 --- a/src/websocket/mod.rs +++ b/src/websocket/mod.rs @@ -171,7 +171,7 @@ async fn handle_socket( .unwrap_or(false); let password_ok = db::verify_workspace_password(&workspace, password.as_deref()); if workspace.is_private != 0 && permission.is_none() && !anonymous_token_ok { - let _ = send_error(&mut socket, "This workspace is private").await; + let _ = send_error(&mut socket, "Workspace not found").await; return; } if workspace.password_hash.is_some() diff --git a/src/websocket/pad.rs b/src/websocket/pad.rs index 32bbd86..241d6d9 100644 --- a/src/websocket/pad.rs +++ b/src/websocket/pad.rs @@ -114,7 +114,7 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri let _ = send_pad( &mut socket, &PadServerMessage::Error { - message: "This note is private".into(), + message: "Note not found".into(), }, ) .await; diff --git a/static/css/styles.css b/static/css/styles.css index f81afcb..8d50cc5 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -449,11 +449,18 @@ textarea:focus { .editor-column, .preview-column { display: grid; - grid-template-rows: 30px minmax(0, 1fr); min-width: 0; min-height: 0; } +.editor-column { + grid-template-rows: 30px auto minmax(0, 1fr); +} + +.preview-column { + grid-template-rows: 30px minmax(0, 1fr); +} + .preview-column { border-left: 1px solid var(--border); } @@ -3993,7 +4000,6 @@ dialog::backdrop { .authorship-fragment { border-radius: 2px; background: color-mix(in srgb, var(--owner) 18%, transparent); - box-shadow: inset 0 -2px color-mix(in srgb, var(--owner) 72%, transparent); color: transparent; } @@ -4554,3 +4560,141 @@ dialog::backdrop { display: none; } } + +/* Authorship display modes */ +.editor-column-label { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.authorship-mode-control { display: inline-flex; gap: 2px; padding: 2px; border: 1px solid var(--border); border-radius: 8px; background: color-mix(in srgb, var(--panel) 88%, transparent); } +.authorship-mode-control button { min-height: 26px; padding: 0 9px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); font-size: 11px; } +.authorship-mode-control button.active { background: var(--surface-strong, #262b35); color: var(--text); } +.participant-badges { display: flex; flex-wrap: wrap; align-items: center; align-content: center; gap: 6px; min-width: 0; min-height: 0; padding: 7px 12px; border-bottom: 1px solid var(--border); } +.participant-badges[hidden] { display: none; } +.participant-badge { display: inline-flex; flex: 0 0 auto; align-items: center; width: auto; max-width: 100%; min-height: 0; padding: 3px 8px; border: 1px solid color-mix(in srgb, var(--owner) 65%, transparent); border-radius: 999px; background: color-mix(in srgb, var(--owner) 18%, transparent); font: 600 11px/1.3 system-ui, sans-serif; } +@media (max-width: 720px) { + .editor-column-label { align-items: flex-start; } + .authorship-mode-control button { padding-inline: 7px; } + .participant-badges { padding: 6px 8px; } + .public-task-toggle { width: 100%; } +} + +.public-page-options { display: grid; gap: 4px; align-content: center; } +.public-page-options .public-task-toggle { min-height: 24px; } +@media (max-width: 720px) { .public-page-options { width: 100%; } } + +/* Keep the whole editor surface consistent in Simple and Full modes. */ +.editor-shell { + background: #0d1015; +} +.editor-shell textarea { + z-index: 3; + background: transparent; +} +.authorship-layer { + z-index: 2; + background: transparent; +} +.owner-labels { + z-index: 4; +} +.line-gutter { + z-index: 5; +} + + +/* Stable editor canvas in both authorship modes. */ +.pad-page .editor-column, +.pad-page .editor-shell { + background: #0d1015; +} + +.pad-page .editor-shell { + isolation: isolate; +} + +.pad-page .editor-shell::before { + position: absolute; + z-index: 0; + inset: 0; + background: #0d1015; + content: ""; + pointer-events: none; +} + +.pad-page .editor-shell textarea, +.pad-page .owner-labels { + background: transparent !important; +} + +/* The authorship canvas must paint the whole editable area, not only text rows. */ +.pad-page .authorship-layer { + width: auto; + height: auto; + min-width: 0; + min-height: 0; + background: #0d1015 !important; +} + +.pad-page .line-gutter { + background: #0d1015; +} + +.resources-access-rules { + margin: 10px 0 0; + padding: 9px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(255, 255, 255, .02); + color: var(--muted); + font-size: .78rem; + line-height: 1.45; +} + +/* Full-height editor/gutter separator, independent of the number of text lines. */ +.pad-page .editor-shell::after { + position: absolute; + z-index: 6; + top: 0; + bottom: 0; + left: 48px; + width: 1px; + background: var(--border); + content: ""; + pointer-events: none; +} + +.pad-page .line-gutter { + align-self: stretch; + height: 100%; + min-height: 100%; + border-right: 0; +} + +.pad-page.hide-editor-line-numbers .editor-shell::after { + display: none; +} + +@media (max-width: 720px) { + .pad-page .editor-shell::after { + left: 38px; + } +} + +/* Fill the editor column with the actual editor shell. + The grid version could size the shell to its content in Full authorship mode, + so the gutter separator stopped after the last rendered line. */ +.pad-page .editor-column { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.pad-page .editor-column > .column-label, +.pad-page .editor-column > .participant-badges { + flex: 0 0 auto; +} + +.pad-page .editor-column > .editor-shell { + flex: 1 1 auto; + width: 100%; + min-height: 0; +} diff --git a/static/home.html b/static/home.html index 37ce5d3..72c7c21 100644 --- a/static/home.html +++ b/static/home.html @@ -118,6 +118,7 @@

My notes and workspaces

Items created while signed in are assigned to your account.

+

Access rules: Public items open from their link; a password adds link-based protection. Private items are visible only to their owner and explicitly shared accounts or valid share links. Unauthorized visitors receive a not-found response.

diff --git a/static/js/home.js b/static/js/home.js index f21ac30..dd8841c 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -126,9 +126,21 @@ async function loadResources() { message.textContent = text; }; - row.querySelector("[data-privacy]")?.addEventListener("click", async () => { - try { await api("/api/auth/resources/privacy", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, private: !Boolean(item.private) }) }); await loadResources(); } - catch (e) { resourcesError.textContent = e.message; } + row.querySelector("[data-privacy]")?.addEventListener("click", async event => { + const button = event.currentTarget; + const nextPrivate = !Boolean(item.private); + button.disabled = true; + try { + await api("/api/auth/resources/privacy", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, private: nextPrivate }) }); + item.private = nextPrivate; + button.textContent = nextPrivate ? "Make public" : "Make private"; + const meta = row.querySelector(".resource-copy small"); + meta.textContent = `${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}`; + } catch (e) { + resourcesError.textContent = e.message; + } finally { + button.disabled = false; + } }); row.querySelector("[data-share]")?.addEventListener("click", async () => { const dialog = document.createElement("dialog"); diff --git a/static/js/note-api.js b/static/js/note-api.js index 5ae0d70..1933e94 100644 --- a/static/js/note-api.js +++ b/static/js/note-api.js @@ -27,9 +27,9 @@ export function createPadAdapter() { method: "POST", body: JSON.stringify({ kind: "pad", slug, password }), }), - publish: (accessToken, allowTaskUpdates) => api(`${base}/publish`, { + publish: (accessToken, allowTaskUpdates, unprotectPage) => api(`${base}/publish`, { method: "POST", - body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates }), + body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage }), }), loadHistory: accessToken => api(`${base}/history`, { method: "POST", @@ -67,9 +67,9 @@ export function createWorkspaceNoteAdapter() { method: "POST", body: JSON.stringify({ kind: "workspace", slug: workspaceSlug, password }), }), - publish: (accessToken, allowTaskUpdates) => api(`${base}/publish`, { + publish: (accessToken, allowTaskUpdates, unprotectPage) => api(`${base}/publish`, { method: "POST", - body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates }), + body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage }), }), loadHistory: accessToken => api(`${base}/history`, { method: "POST", diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 5899f4f..567426d 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -17,9 +17,9 @@ export function startNoteEditor(adapter) { const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog"); const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"); let unreadChat = 0; - const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color"); + const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color"); const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken); - let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = ""; + let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = ["full", "advanced"].includes(localStorage.getItem("rustpad:authorship-mode")) ? "full" : "simple"; const compactLayoutQuery = window.matchMedia("(max-width: 1499px)"); let compactView = uiState.view === "preview" ? "preview" : "edit"; const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off"; @@ -61,7 +61,7 @@ export function startNoteEditor(adapter) { } updateCurrentUser(); return info; } - function updatePresence(users) { const entries = Array.isArray(users) ? users : []; roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...entries.map(entry => { const user = typeof entry === "string" ? { name: entry, color: "" } : entry || {}; const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } } + function updatePresence(users) { const entries = Array.isArray(users) ? users : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...presenceUsers.map(user => { const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } renderGutter(); } function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; } function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); } function appendChatMessage(message) { const empty = chatMessages.querySelector(".chat-empty"); empty?.remove(); const row = document.createElement("p"); row.className = "chat-message"; const author = document.createElement("strong"); author.textContent = message.sender; const text = document.createElement("span"); appendLinkifiedText(text, message.text); row.append(author, text); chatMessages.append(row); while (chatMessages.children.length > 100) chatMessages.firstElementChild.remove(); chatMessages.scrollTop = chatMessages.scrollHeight; if (message.sender !== nickname && !roomDetails.open) { unreadChat++; chatUnread.hidden = false; chatUnread.textContent = unreadChat > 99 ? "99+" : String(unreadChat); if (mobileChatUnread) { mobileChatUnread.hidden = false; mobileChatUnread.textContent = chatUnread.textContent; } const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `● ${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(`${message.sender} wrote in RustPad`, { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } } @@ -71,48 +71,55 @@ export function startNoteEditor(adapter) { function updateAddressLabel() { document.querySelector(adapter.addressSelector).textContent = `${location.pathname}${location.search}`; } async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '

Failed to load Mermaid.

')); } } async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } } + function renderParticipantBadges(owners) { + if (!participantBadges) return; + const people = new Map(); + for (const owner of owners) people.set(ownerName(owner), { name: ownerName(owner), compactName: "", color: colorFor(owner) }); + for (const user of presenceUsers) { + const name = user.name || "Guest"; + const color = /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(name); + people.set(name, { name, compactName: user.compact_name || name, color }); + } + participantBadges.hidden = authorshipMode !== "simple" || people.size < 2; + const compact = people.size > 4; + participantBadges.replaceChildren(...[...people.values()].map(person => { + const badge = document.createElement("span"); + badge.className = "participant-badge"; + badge.style.setProperty("--owner", person.color); + badge.textContent = compact && person.compactName ? person.compactName : person.name; + badge.title = person.name; + return badge; + })); + } function renderGutter() { const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1); const lines = Array.from({ length: lineCount }); const owners = authorshipOwners(authorship); - const showSingleOwner = owners.length === 1; - const showAuthorship = owners.length > 1; + const showAuthorship = owners.length > 0; const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : []; + const full = authorshipMode === "full"; authorshipLayer.hidden = !showAuthorship; - ownerLabels.hidden = !(showSingleOwner || showAuthorship); + ownerLabels.hidden = !full || !showAuthorship; + renderParticipantBadges(owners); + document.querySelectorAll("[data-authorship-mode]").forEach(button => button.classList.toggle("active", button.dataset.authorshipMode === authorshipMode)); + editorWorkspace.dataset.authorshipMode = authorshipMode; const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24; gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`; gutter.innerHTML = lines.map((_, i) => `
${i + 1}
`).join(""); ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`); - if (showSingleOwner) { - const owner = owners[0]; - const top = paddingTop - editor.scrollTop; - const markerHeight = Math.max(lineHeight, lineCount * lineHeight); - const ownerColor = colorFor(owner); - ownerLabels.innerHTML = `${escapeHtml(ownerName(owner))}`; - } else { + if (full) { let previousAuthorSignature = null; ownerLabels.innerHTML = lines.map((_, i) => { const authors = authorsByLine[i] || []; if (!authors.length) return ""; - const top = paddingTop + i * lineHeight - editor.scrollTop; - const signature = authors - .map(owner => ownerName(owner)) - .sort((a, b) => a.localeCompare(b)) - .join("\u0000"); - const startsOwnershipBlock = signature !== previousAuthorSignature; + const signature = authors.map(owner => ownerName(owner)).sort((a, b) => a.localeCompare(b)).join("\u0000"); + if (signature === previousAuthorSignature) return ""; previousAuthorSignature = signature; - - const lineMarker = ``; - if (!startsOwnershipBlock) return lineMarker; - - const badges = authors - .map(owner => `${escapeHtml(ownerName(owner))}`) - .join(""); - return `${lineMarker}${badges}`; + const badges = authors.map(owner => `${escapeHtml(ownerName(owner))}`).join(""); + return `${badges}`; }).join(""); - } + } else ownerLabels.replaceChildren(); if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor); else authorshipLayer.replaceChildren(); document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked); @@ -212,7 +219,12 @@ export function startNoteEditor(adapter) { editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`); document.body.classList.toggle("compact-editor", compactToggle.checked); document.body.classList.toggle("compact-note-layout", compactLayoutQuery.matches); - document.querySelectorAll("[data-view]").forEach(button => { + document.querySelectorAll("[data-authorship-mode]").forEach(button => button.addEventListener("click", () => { + authorshipMode = button.dataset.authorshipMode === "full" ? "full" : "simple"; + localStorage.setItem("rustpad:authorship-mode", authorshipMode); + renderGutter(); + })); + document.querySelectorAll("[data-view]").forEach(button => { const active = button.dataset.view === view; button.classList.toggle("active", active); button.setAttribute("aria-pressed", String(active)); @@ -233,6 +245,21 @@ export function startNoteEditor(adapter) { function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { resourceUnlocked = true; if (passwordDialog.open) passwordDialog.close(); const readOnly = m.access_level === "read_only"; editor.readOnly = readOnly; accessLevel.textContent = readOnly ? "Access: read only" : "Access: full"; applyRemote(m.content, m.owner_map); if (!readOnly) editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { const friendly = /read-only access/i.test(m) ? "This note is read only. Enter the password or ask the owner to grant write access." : m; document.querySelector("#password-error").textContent = friendly; if (/read-only access/i.test(m)) { toast(friendly); accessLevel.textContent = "Access: read only"; editor.readOnly = true; return; } if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); } bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } }); identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); }); + async function showSystemNotFound() { + try { + const response = await fetch(`${location.pathname.replace(/\/$/, "")}/__not_found__`, { + cache: "no-store", + credentials: "same-origin", + }); + const html = await response.text(); + document.open(); + document.write(html); + document.close(); + } catch { + document.body.textContent = "404 Not Found"; + } + } + async function initialize() { try { if (getAuthToken()) { @@ -247,14 +274,18 @@ export function startNoteEditor(adapter) { accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key); await loadNoteInfo(); document.title = adapter.title(info); - publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); + publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); unprotectPublicPage.checked = Boolean(info.public_page_unprotected); adapter.configureView?.(info); applyUi({ write: true, replace: true }); updateCurrentUser(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } catch (e) { - document.body.innerHTML = `

Note not found

${escapeHtml(e.message)}

`; + if (e.status === 403 || e.status === 404) { + await showSystemNotFound(); + return; + } + document.body.innerHTML = `

Page could not be loaded

${escapeHtml(e.message)}

`; } } @@ -364,7 +395,8 @@ export function startNoteEditor(adapter) { }); window.addEventListener("resize", () => { if (mobileBubble?.style.left) placeMobileBubble(mobileBubble.getBoundingClientRect()); }); window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") }); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`); editor.value = lines.join("\n"); editor.dispatchEvent(new Event("input", { bubbles: true })); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Enter") { event.preventDefault(); target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; const lineIndex = Number(target.dataset.sourceLine) - 1; if (lineIndex < 0) return; const lines = editor.value.split("\n"); const value = markdownFromPreview(target); let next; if (target.dataset.tableCell !== undefined) next = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value); else { const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || ""; next = prefix + value + suffix; } if (lines[lineIndex] === next) return; lines[lineIndex] = next; editor.value = lines.join("\n"); editor.setSelectionRange(editor.value.length, editor.value.length); editor.dispatchEvent(new Event("input", { bubbles: true })); }, { capture: true }); - publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await adapter.publish(accessToken, publicTaskUpdates.checked); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { publicTaskUpdates.disabled = false; } }); document.querySelector("#publish-page").addEventListener("click", async () => { try { const result = await adapter.publish(accessToken, publicTaskUpdates.checked); const url = new URL(result.url, location.origin).href; await copyText(url); toast("Page link copied"); window.open(url, "_blank", "noopener"); } catch (error) { toast(error.message); } }); + const savePublicOptions = async () => adapter.publish(accessToken, publicTaskUpdates.checked, unprotectPublicPage.checked); + publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await savePublicOptions(); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { publicTaskUpdates.disabled = false; } }); unprotectPublicPage.addEventListener("change", async () => { unprotectPublicPage.disabled = true; try { await savePublicOptions(); toast(unprotectPublicPage.checked ? "Published page is now unprotected" : "Published page protection enabled"); } catch (error) { unprotectPublicPage.checked = !unprotectPublicPage.checked; toast(error.message); } finally { unprotectPublicPage.disabled = false; } }); document.querySelector("#publish-page").addEventListener("click", async () => { try { const result = await savePublicOptions(); const url = new URL(result.url, location.origin).href; await copyText(url); toast("Page link copied"); window.open(url, "_blank", "noopener"); } catch (error) { toast(error.message); } }); roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } else { roomDetails.classList.remove("is-mobile-open"); } }); document.addEventListener("visibilitychange", () => { if (!document.hidden && roomDetails.open) clearUnread(); }); chatForm.addEventListener("submit", event => { event.preventDefault(); const text = chatInput.value.trim(); if (!text || !socket) return; socket.chat(text); chatInput.value = ""; chatInput.focus(); }); diff --git a/static/js/public.js b/static/js/public.js index 013e72e..2c95fc4 100644 --- a/static/js/public.js +++ b/static/js/public.js @@ -9,6 +9,10 @@ import { toast } from "@rustpad/toast"; const token = location.pathname.split("/").filter(Boolean)[1]; const content = document.querySelector("#public-content"); const lineNumbersToggle = document.querySelector("#public-line-numbers-toggle"); +const passwordDialog = document.querySelector("#public-password-dialog"), passwordForm = document.querySelector("#public-password-form"), passwordInput = document.querySelector("#public-password"), passwordError = document.querySelector("#public-password-error"); +const passwordKey = `rustpad:public-page-password:${token}`; +let pagePassword = sessionStorage.getItem(passwordKey) || ""; +function pageHeaders() { return pagePassword ? { "X-RustPad-Page-Password": pagePassword } : {}; } async function renderMermaid() { const nodes = content.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '

Failed to load Mermaid.

')); } } async function renderCodeHighlight() { const blocks = content.querySelectorAll('pre code[class^="language-"]'); if (!blocks.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); blocks.forEach(block => { const lines = block.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(block); return; } const language = [...block.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); block.classList.add("hljs"); }); } catch { } } function lockPublicContent(allowTaskUpdates) { @@ -26,7 +30,7 @@ function scrollToPublicAnchor(hash, behavior = "auto") { target.scrollIntoView({ behavior, block: "start" }); return true; } -async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`); document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { content.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = String(error.message); content.append(message); } } +async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() }); if (passwordDialog.open) passwordDialog.close(); passwordError.textContent = ""; document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { if (error.status === 401 || error.status === 403) { passwordError.textContent = error.status === 403 ? "Sign in with an authorized account or enter the resource password." : "Enter the correct password."; if (!passwordDialog.open) passwordDialog.showModal(); passwordInput.focus(); return; } content.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = String(error.message); content.append(message); } } content.addEventListener("click", event => { const link = event.target.closest('.markdown-toc a[href^="#"]'); if (!link) return; @@ -36,7 +40,9 @@ content.addEventListener("click", event => { history.replaceState(null, "", `${location.pathname}${location.search}${hash}`); }); window.addEventListener("hashchange", () => scrollToPublicAnchor(location.hash, "smooth")); -content.addEventListener("change", async event => { const box = event.target.closest(".task-checkbox"); if (!box || box.disabled) return; const previous = !box.checked; box.disabled = true; try { const page = await api(`/api/public/${encodeURIComponent(token)}/tasks`, { method: "POST", body: JSON.stringify({ source_line: Number(box.dataset.sourceLine), checked: box.checked }) }); document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")} · tasks can be updated`; toast("Task saved"); } catch (error) { box.checked = previous; toast(error.message); } finally { box.disabled = false; } }); +content.addEventListener("change", async event => { const box = event.target.closest(".task-checkbox"); if (!box || box.disabled) return; const previous = !box.checked; box.disabled = true; try { const page = await api(`/api/public/${encodeURIComponent(token)}/tasks`, { method: "POST", headers: pageHeaders(), body: JSON.stringify({ source_line: Number(box.dataset.sourceLine), checked: box.checked }) }); document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")} · tasks can be updated`; toast("Task saved"); } catch (error) { box.checked = previous; toast(error.message); } finally { box.disabled = false; } }); lineNumbersToggle.addEventListener("change", () => { document.body.classList.toggle("hide-preview-line-numbers", !lineNumbersToggle.checked); }); +passwordForm.addEventListener("submit", async event => { event.preventDefault(); pagePassword = passwordInput.value; sessionStorage.setItem(passwordKey, pagePassword); await initialize(); }); +passwordDialog.addEventListener("cancel", event => event.preventDefault()); document.querySelector("#copy-public-link").addEventListener("click", async () => { try { await copyText(location.href); toast("Link copied"); } catch (error) { toast(error.message); } }); initialize(); diff --git a/static/js/workspace.js b/static/js/workspace.js index d547e2a..d243167 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -92,6 +92,20 @@ function renderNotes(notes = notesCache) { ${deleteButton(note)} `).join(""); } +async function showSystemNotFound() { + try { + const response = await fetch(`${location.pathname.replace(/\/$/, "")}/__not_found__`, { + cache: "no-store", + credentials: "same-origin", + }); + const html = await response.text(); + document.open(); + document.write(html); + document.close(); + } catch { + document.body.textContent = "404 Not Found"; + } +} async function openWorkspace() { try { const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null }) }); @@ -106,8 +120,8 @@ async function openWorkspace() { if (info?.protected || e.message.toLowerCase().includes("password")) { document.querySelector("#password-error").textContent = e.message; if (!dialog.open) dialog.showModal(); - } else if (e.status === 403) { - location.replace("/errors/private-workspace"); + } else if (e.status === 403 || e.status === 404) { + await showSystemNotFound(); } else document.querySelector("#workspace-error").textContent = e.message; } } @@ -119,7 +133,7 @@ async function init() { document.querySelector("#workspace-url").textContent = location.pathname; if (info.protected && !accessToken) dialog.showModal(); else openWorkspace(); } catch (e) { - if (e.status === 403) location.replace("/errors/private-workspace"); + if (e.status === 403 || e.status === 404) await showSystemNotFound(); else document.querySelector("#workspace-error").textContent = e.message; } } diff --git a/static/note.html b/static/note.html index 318e87b..2d47b66 100644 --- a/static/note.html +++ b/static/note.html @@ -31,9 +31,9 @@
@@ -104,7 +104,7 @@
-
Editor
+
Editor