From a9911da9a3596bf69952cad337b6c39907123758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 5 Aug 2026 09:53:28 +0200 Subject: [PATCH] improvements --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/api/files.rs | 45 +----- src/api/mod.rs | 99 +++++++----- src/api/pads_public.rs | 25 ++- src/app/mod.rs | 120 +------------- src/app/pages.rs | 25 +-- src/assets.rs | 2 +- src/auth/mod.rs | 28 +++- src/collab.rs | 87 +--------- src/config/mod.rs | 31 +--- src/config/smtp.rs | 33 +--- src/db/mod.rs | 57 +------ src/file_urls.rs | 61 +------ src/main.rs | 16 +- src/queries/mod.rs | 29 +--- src/queries/mysql.rs | 44 +---- src/security.rs | 134 +-------------- src/state.rs | 54 +++++++ src/tests/api.rs | 53 ++++++ src/tests/api_files.rs | 50 ++++++ src/tests/app.rs | 121 ++++++++++++++ src/tests/app_pages.rs | 33 ++++ src/tests/collab.rs | 92 +++++++++++ src/tests/config.rs | 36 +++++ src/tests/config_smtp.rs | 38 +++++ src/tests/db.rs | 62 +++++++ src/tests/file_urls.rs | 66 ++++++++ src/tests/main.rs | 20 +++ src/tests/queries.rs | 34 ++++ src/tests/queries_mysql.rs | 49 ++++++ src/tests/security.rs | 137 ++++++++++++++++ src/tests/state.rs | 37 +++++ src/tests/websocket.rs | 55 +++++++ src/websocket/mod.rs | 322 ++++++++++++++++++++++++++++++++++++- src/websocket/pad.rs | 25 +++ static/css/styles.css | 79 ++++++++- static/js/home.js | 44 ++++- static/js/note-api.js | 9 +- static/js/note-editor.js | 20 ++- static/js/socket.js | 11 ++ static/js/workspace.js | 108 ++++++++++++- 42 files changed, 1667 insertions(+), 728 deletions(-) create mode 100644 src/tests/api.rs create mode 100644 src/tests/api_files.rs create mode 100644 src/tests/app.rs create mode 100644 src/tests/app_pages.rs create mode 100644 src/tests/collab.rs create mode 100644 src/tests/config.rs create mode 100644 src/tests/config_smtp.rs create mode 100644 src/tests/db.rs create mode 100644 src/tests/file_urls.rs create mode 100644 src/tests/main.rs create mode 100644 src/tests/queries.rs create mode 100644 src/tests/queries_mysql.rs create mode 100644 src/tests/security.rs create mode 100644 src/tests/state.rs create mode 100644 src/tests/websocket.rs diff --git a/Cargo.lock b/Cargo.lock index bbae90c..9ba8f60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.36" +version = "0.2.38" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 9c574b3..14a127b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.36" +version = "0.2.38" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/src/api/files.rs b/src/api/files.rs index 47e41ce..78c0568 100644 --- a/src/api/files.rs +++ b/src/api/files.rs @@ -620,46 +620,5 @@ fn sanitize_filename(value: &str) -> String { } #[cfg(test)] -mod tests { - use super::{content_references_file, content_references_stored_file, is_safe_inline_image_mime}; - - #[test] - fn only_raster_images_are_inline() { - assert!(is_safe_inline_image_mime("image/png")); - assert!(is_safe_inline_image_mime("image/jpeg")); - assert!(!is_safe_inline_image_mime("image/svg+xml")); - assert!(!is_safe_inline_image_mime("text/html")); - assert!(!is_safe_inline_image_mime("application/xml")); - } - - #[test] - fn extended_image_alias_is_still_attached() { - assert!(content_references_file( - "[image=photo.jpg,Photo,a=left,size=640x400]", - "photo.jpg", - "/f/token/photo.jpg", - )); - assert!(content_references_file( - "[file=report.pdf,Quarterly report]", - "report.pdf", - "/f/token/report.pdf", - )); - } - - #[test] - fn attachment_references_survive_origin_changes() { - let stored = "/f/token/image.png"; - assert!(content_references_stored_file( - "![diagram](https://old-files.example.com/f/token/image.png)", - "image.png", - stored, - None, - )); - assert!(content_references_stored_file( - "![diagram](/f/token/image.png)", - "image.png", - "https://old-files.example.com/f/token/image.png", - Some("https://new-files.example.com"), - )); - } -} +#[path = "../tests/api_files.rs"] +mod tests; diff --git a/src/api/mod.rs b/src/api/mod.rs index 22f120c..984dd73 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -89,14 +89,35 @@ fn requester_guest_id(headers: &HeaderMap) -> Option<&str> { }) } +fn guest_owner_is_requester(headers: &HeaderMap, owner_guest_id: Option<&str>) -> bool { + owner_guest_id + .zip(requester_guest_id(headers)) + .is_some_and(|(owner_guest_id, requester_guest_id)| owner_guest_id == requester_guest_id) +} + +fn can_set_resource_password( + password_protected: bool, + account_owner: bool, + guest_owner: bool, +) -> bool { + !password_protected && (account_owner || guest_owner) +} + +fn can_manage_resource_settings( + account_owner: bool, + guest_owner: bool, + password_write_access: bool, +) -> bool { + account_owner || guest_owner || password_write_access +} + async fn note_creator_is_requester( state: &SharedState, headers: &HeaderMap, note: &db::Note, ) -> Result { if let Some(owner_guest_id) = note.created_by_guest_id.as_deref() { - return Ok(requester_guest_id(headers) - .is_some_and(|requester_guest_id| requester_guest_id == owner_guest_id)); + return Ok(guest_owner_is_requester(headers, Some(owner_guest_id))); } let Some(user) = session_user(state, headers).await? else { return Ok(false); @@ -108,18 +129,11 @@ async fn note_creator_is_requester( } fn pad_creator_is_requester(headers: &HeaderMap, pad: &db::Pad) -> bool { - pad.created_by_guest_id - .as_deref() - .zip(requester_guest_id(headers)) - .is_some_and(|(owner_guest_id, requester_guest_id)| owner_guest_id == requester_guest_id) + guest_owner_is_requester(headers, pad.created_by_guest_id.as_deref()) } fn workspace_creator_is_requester(headers: &HeaderMap, workspace: &db::Workspace) -> bool { - workspace - .created_by_guest_id - .as_deref() - .zip(requester_guest_id(headers)) - .is_some_and(|(owner_guest_id, requester_guest_id)| owner_guest_id == requester_guest_id) + guest_owner_is_requester(headers, workspace.created_by_guest_id.as_deref()) } async fn can_set_workspace_password( @@ -127,9 +141,6 @@ async fn can_set_workspace_password( headers: &HeaderMap, workspace: &db::Workspace, ) -> bool { - if workspace.password_hash.is_some() { - return false; - } let account_owner = crate::auth::is_resource_owner( state, "workspace", @@ -138,7 +149,11 @@ async fn can_set_workspace_password( ) .await .unwrap_or(false); - account_owner || workspace_creator_is_requester(headers, workspace) + can_set_resource_password( + workspace.password_hash.is_some(), + account_owner, + workspace_creator_is_requester(headers, workspace), + ) } async fn has_write_permission( @@ -314,6 +329,8 @@ pub struct CreateNoteRequest { #[derive(Debug, Deserialize)] pub struct SetWorkspacePasswordRequest { password: String, + #[serde(default)] + client_id: Option, } #[derive(Debug, Deserialize)] @@ -685,9 +702,14 @@ pub async fn set_workspace_password( "Only the workspace owner can set its password.", )); } + let except_client_id = + crate::websocket::clean_collaboration_client_id(payload.client_id); let password = validate_password(Some(payload.password.as_str()))? .ok_or_else(|| ApiError::bad_request("Password is required."))?; db::set_workspace_password(&state.db, &workspace_slug, password).await?; + state + .notify_workspace_password_required(&workspace_slug, except_client_id) + .await; Ok(Json(serde_json::json!({"ok": true, "protected": true}))) } @@ -1021,7 +1043,8 @@ pub async fn note_info( let note_owner = note_creator_is_requester(&state, &headers, ¬e).await?; let password_write_access = has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?; - let can_manage_authorship = workspace_owner || note_owner || password_write_access; + let can_manage_authorship = + can_manage_resource_settings(workspace_owner, note_owner, password_write_access); let can_delete_files = can_manage_authorship; let upload_max_size_bytes = resource_upload_limit(&state, &headers, "workspace", &workspace_slug).await?; @@ -1066,8 +1089,11 @@ pub async fn note_info( personal_editor_settings, can_save_editor_settings, can_manage_authorship, - can_set_password: workspace.password_hash.is_none() - && (workspace_owner || workspace_guest_owner), + can_set_password: can_set_resource_password( + workspace.password_hash.is_some(), + workspace_owner, + workspace_guest_owner, + ), files: markdown_file_references(&state, None, Some(note.id), None).await?, })) } @@ -1084,8 +1110,11 @@ pub async fn set_note_editor_settings( let note = db::find_note(&state.db, workspace.id, ¬e_slug) .await? .ok_or_else(ApiError::not_found_note)?; - let creator_can_manage_authorship = note_creator_is_requester(&state, &headers, ¬e).await? - || has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?; + let creator_can_manage_authorship = can_manage_resource_settings( + false, + note_creator_is_requester(&state, &headers, ¬e).await?, + has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?, + ); save_editor_settings( &state, &headers, @@ -1255,28 +1284,6 @@ fn permission_level(permission: Option<&str>) -> AccessLevel { } } -async fn anonymous_access_token_valid( - 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(), - queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT, - )) - .bind(access_tokens::hash_access_token(token)) - .bind(kind) - .bind(slug) - .bind(Utc::now().to_rfc3339()) - .fetch_one(state.db.pool()) - .await?; - Ok(count > 0) -} - async fn has_password_write_access( state: &SharedState, headers: &HeaderMap, @@ -1287,7 +1294,7 @@ async fn has_password_write_access( crate::security::resource_token(headers, kind, slug), authorization_token(headers), ] { - if anonymous_access_token_valid(state, kind, slug, token).await? { + if verify_password_access_token(state, kind, slug, token).await? { return Ok(true); } } @@ -1307,7 +1314,7 @@ async fn external_token_access_level( if level != AccessLevel::None { return Ok(level); } - if anonymous_access_token_valid(state, kind, slug, token).await? { + if verify_password_access_token(state, kind, slug, token).await? { // A server-issued token created after a correct resource password // retains the historical read/write semantics of password access. return Ok(AccessLevel::Write); @@ -1612,3 +1619,7 @@ async fn unique_note_slug( } Err(ApiError::internal("Failed to create a unique address")) } + +#[cfg(test)] +#[path = "../tests/api.rs"] +mod guest_resource_access_tests; diff --git a/src/api/pads_public.rs b/src/api/pads_public.rs index ce84a04..dbeafcc 100644 --- a/src/api/pads_public.rs +++ b/src/api/pads_public.rs @@ -21,6 +21,8 @@ pub struct CreatePadRequest { #[derive(Debug, Deserialize)] pub struct SetPadPasswordRequest { password: String, + #[serde(default)] + client_id: Option, } #[derive(Debug, Serialize)] @@ -135,7 +137,8 @@ pub async fn pad_info( .unwrap_or(false); let guest_owner = pad_creator_is_requester(&headers, &pad); let password_write_access = has_password_write_access(&state, &headers, "pad", &slug).await?; - let can_manage_authorship = account_owner || guest_owner || password_write_access; + let can_manage_authorship = + can_manage_resource_settings(account_owner, guest_owner, password_write_access); let upload_max_size_bytes = resource_upload_limit(&state, &headers, "pad", &slug).await?; let can_upload_files = upload_max_size_bytes.is_some(); let can_save_editor_settings = (personal_editor_settings || can_manage_authorship) @@ -174,7 +177,11 @@ pub async fn pad_info( personal_editor_settings, can_save_editor_settings, can_manage_authorship, - can_set_password: pad.password_hash.is_none() && (account_owner || guest_owner), + can_set_password: can_set_resource_password( + pad.password_hash.is_some(), + account_owner, + guest_owner, + ), files: markdown_file_references(&state, Some(pad.id), None, None).await?, })) } @@ -195,12 +202,17 @@ pub async fn set_pad_password( &state, "pad", &slug, user_session_token(&headers), ).await.unwrap_or(false); let guest_owner = pad_creator_is_requester(&headers, &pad); - if !account_owner && !guest_owner { + if !can_set_resource_password(pad.password_hash.is_some(), account_owner, guest_owner) { return Err(ApiError::forbidden("Only the note owner can set its password.")); } + let except_client_id = + crate::websocket::clean_collaboration_client_id(payload.client_id); let password = validate_password(Some(payload.password.as_str()))? .ok_or_else(|| ApiError::bad_request("Password is required."))?; db::set_pad_password(&state.db, &slug, password).await?; + state + .notify_pad_password_required(&slug, except_client_id) + .await; Ok(Json(serde_json::json!({"ok": true, "protected": true}))) } @@ -213,8 +225,11 @@ pub async fn set_pad_editor_settings( let pad = db::find_pad(&state.db, &slug) .await? .ok_or_else(ApiError::not_found_note)?; - let creator_can_manage_authorship = pad_creator_is_requester(&headers, &pad) - || has_password_write_access(&state, &headers, "pad", &slug).await?; + let creator_can_manage_authorship = can_manage_resource_settings( + false, + pad_creator_is_requester(&headers, &pad), + has_password_write_access(&state, &headers, "pad", &slug).await?, + ); save_editor_settings( &state, &headers, diff --git a/src/app/mod.rs b/src/app/mod.rs index 983c451..00f773d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -215,6 +215,10 @@ pub fn router( axum::routing::delete(api::delete_note_file), ) .route("/ws/p/{slug}", get(websocket::upgrade_pad)) + .route( + "/ws/watch/workspace/{workspace_slug}", + get(websocket::upgrade_workspace_watch), + ) .route("/ws/{workspace_slug}/{note_slug}", get(websocket::upgrade)) .route("/static", get(static_not_found)) .route("/static/{*path}", get(static_not_found)) @@ -333,117 +337,5 @@ fn is_icon_path(path: &str) -> bool { } #[cfg(test)] -mod tests { - use super::{ResponseHeaderPolicy, apply_response_headers, response_header_policy}; - use axum::http::{HeaderMap, HeaderValue, header}; - - #[test] - fn classifies_assets_and_icons_as_static_assets() { - for path in [ - "/assets/app.js", - "/assets", - "/favicon.ico", - "/icons/favicon.svg", - "/icons/missing.svg", - ] { - assert_eq!( - response_header_policy(path), - ResponseHeaderPolicy::StaticAsset - ); - } - } - - #[test] - fn classifies_file_routes_as_files() { - for path in ["/f", "/f/token/image.png"] { - assert_eq!(response_header_policy(path), ResponseHeaderPolicy::File); - } - } - - #[test] - fn classifies_other_routes_as_application() { - for path in [ - "/", - "/api/auth/me", - "/static/missing.css", - "/files/legacy/image.png", - "/unknown", - ] { - assert_eq!( - response_header_policy(path), - ResponseHeaderPolicy::Application - ); - } - } - - #[test] - fn static_asset_policy_only_adds_nosniff() { - let mut headers = HeaderMap::new(); - headers.insert( - header::CACHE_CONTROL, - HeaderValue::from_static("public, max-age=3600"), - ); - - apply_response_headers(ResponseHeaderPolicy::StaticAsset, &mut headers); - - assert_eq!(headers.len(), 2); - assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff"); - assert!(!headers.contains_key("x-frame-options")); - assert!(!headers.contains_key("cross-origin-opener-policy")); - assert!(!headers.contains_key("cross-origin-resource-policy")); - assert!(!headers.contains_key("referrer-policy")); - assert!(!headers.contains_key("permissions-policy")); - } - - #[test] - fn file_policy_keeps_file_headers_without_document_policies() { - let mut headers = HeaderMap::new(); - headers.insert( - "content-security-policy", - HeaderValue::from_static("default-src 'none'; sandbox"), - ); - headers.insert( - header::CONTENT_DISPOSITION, - HeaderValue::from_static("attachment; filename=\"manual.pdf\""), - ); - - apply_response_headers(ResponseHeaderPolicy::File, &mut headers); - - assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff"); - assert_eq!( - headers["content-security-policy"], - "default-src 'none'; sandbox" - ); - assert!(headers.contains_key(header::CONTENT_DISPOSITION)); - assert!(!headers.contains_key("x-frame-options")); - assert!(!headers.contains_key("cross-origin-opener-policy")); - assert!(!headers.contains_key("cross-origin-resource-policy")); - assert!(!headers.contains_key("referrer-policy")); - assert!(!headers.contains_key("permissions-policy")); - } - - #[test] - fn application_policy_preserves_handler_headers() { - let mut headers = HeaderMap::new(); - headers.insert( - "content-security-policy", - HeaderValue::from_static("default-src 'none'; sandbox"), - ); - - apply_response_headers(ResponseHeaderPolicy::Application, &mut headers); - - assert_eq!( - headers["content-security-policy"], - "default-src 'none'; sandbox" - ); - assert_eq!(headers["x-frame-options"], "DENY"); - assert_eq!(headers["cross-origin-opener-policy"], "same-origin"); - assert_eq!(headers["cross-origin-resource-policy"], "same-origin"); - assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff"); - assert_eq!( - headers["referrer-policy"], - "strict-origin-when-cross-origin" - ); - assert!(headers.contains_key("permissions-policy")); - } -} +#[path = "../tests/app.rs"] +mod tests; diff --git a/src/app/pages.rs b/src/app/pages.rs index 849daf9..f7d3a39 100644 --- a/src/app/pages.rs +++ b/src/app/pages.rs @@ -564,26 +564,5 @@ fn escape_html(value: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn share_exchange_redirects_to_canonical_resource_path() { - let uri: Uri = "/w/private?view=all&share=secret&page=2".parse().unwrap(); - assert_eq!(canonical_resource_url(&uri), "/w/private?view=all&page=2"); - } - - #[test] - fn encoded_or_repeated_parameters_are_parsed_without_rejection() { - let uri: Uri = "/w/private?%73hare=one&share=two".parse().unwrap(); - assert_eq!(share_token_from_query(uri.query()), (true, Some("one".into()))); - assert_eq!(canonical_resource_url(&uri), "/w/private"); - } - - #[test] - fn malformed_share_parameter_is_still_removed_from_the_url() { - let uri: Uri = "/w/private?share=%ZZ&keep=no".parse().unwrap(); - assert_eq!(share_token_from_query(uri.query()), (true, None)); - assert_eq!(canonical_resource_url(&uri), "/w/private?keep=no"); - } -} +#[path = "../tests/app_pages.rs"] +mod tests; diff --git a/src/assets.rs b/src/assets.rs index d90ca75..9f6e952 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -75,7 +75,7 @@ pub fn render_html( let mut response = Html(html).into_response(); response.headers_mut().insert( header::CACHE_CONTROL, - HeaderValue::from_static("private, no-store"), + HeaderValue::from_static("private, no-cache, no-store"), ); response } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 5806632..f0b3882 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1299,6 +1299,8 @@ pub async fn update_resource( Json(req): Json, ) -> Result, AuthError> { let user = require_user(&state, &headers).await?; + let kind = req.kind.trim(); + let slug = req.slug.trim(); let hash = match req .password .as_deref() @@ -1311,15 +1313,16 @@ pub async fn update_resource( } None => None, }; - ensure_owner(&state, user.id, &req.kind, &req.slug).await?; - let query = match req.kind.as_str() { + let password_enabled = hash.is_some(); + ensure_owner(&state, user.id, kind, slug).await?; + let query = match kind { "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()) + .bind(slug) .execute(state.db.pool()) .await .map_err(AuthError::database)?; @@ -1327,12 +1330,21 @@ pub async fn update_resource( state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE, )) - .bind(req.kind.as_str()) - .bind(req.slug.trim()) + .bind(kind) + .bind(slug) .execute(state.db.pool()) .await .map_err(AuthError::database)?; - Ok(Json(serde_json::json!({"ok":true}))) + if password_enabled { + match kind { + "workspace" => state.notify_workspace_password_required(slug, None).await, + "pad" => state.notify_pad_password_required(slug, None).await, + _ => {} + } + } + Ok(Json( + serde_json::json!({"ok":true,"protected":password_enabled}), + )) } pub async fn delete_resource( @@ -1826,7 +1838,9 @@ pub async fn create_share_link( .into_response(); response.headers_mut().insert( header::CACHE_CONTROL, - "no-store, max-age=0".parse().expect("valid cache-control"), + "no-cache, no-store, max-age=0" + .parse() + .expect("valid cache-control"), ); response .headers_mut() diff --git a/src/collab.rs b/src/collab.rs index 8149d1c..3b15448 100644 --- a/src/collab.rs +++ b/src/collab.rs @@ -822,88 +822,5 @@ pub fn owner_spans_from_map(content: &str, owner_map: &str) -> Vec { } #[cfg(test)] -mod tests { - use super::*; - - fn operation(components: Vec) -> TextOperation { - TextOperation { components } - } - - #[test] - fn concurrent_insertions_have_stable_order() { - let left = operation(vec![ - OperationComponent::Retain { count: 1 }, - OperationComponent::Insert { - text: "X".into(), - owners: Vec::new(), - }, - OperationComponent::Retain { count: 1 }, - ]); - let right = operation(vec![ - OperationComponent::Retain { count: 1 }, - OperationComponent::Insert { - text: "Y".into(), - owners: Vec::new(), - }, - OperationComponent::Retain { count: 1 }, - ]); - let left_prime = transform_operation(&left, &right, true).unwrap(); - let right_prime = transform_operation(&right, &left, false).unwrap(); - let after_right = apply_operation_to_document("aYb", "[]", &left_prime, &[]) - .unwrap() - .0; - let after_left = apply_operation_to_document("aXb", "[]", &right_prime, &[]) - .unwrap() - .0; - assert_eq!(after_right, "aXYb"); - assert_eq!(after_left, "aXYb"); - } - - #[test] - fn utf16_offsets_support_emoji() { - let operation = operation(vec![ - OperationComponent::Retain { count: 3 }, - OperationComponent::Insert { - text: "x".into(), - owners: Vec::new(), - }, - OperationComponent::Retain { count: 1 }, - ]); - let result = apply_operation_to_document("A😀B", "[]", &operation, &[]) - .unwrap() - .0; - assert_eq!(result, "A😀xB"); - } - - #[test] - fn operation_from_edit_preserves_utf16_boundaries() { - let operation = operation_from_edit("A😀B", "A😀xB", "[]"); - let result = apply_operation_to_document("A😀B", "[]", &operation, &[]) - .unwrap() - .0; - assert_eq!(result, "A😀xB"); - } - - #[test] - fn acknowledgements_survive_history_compaction() { - let mut document = CollaborativeDocument::new(String::new(), "[]".into(), 0); - for update_id in 1..=MAX_OPERATION_HISTORY as u64 + 8 { - let base_revision_id = document.revision_id; - let revision_id = base_revision_id + 1; - document.revision_id = revision_id; - document.record(AppliedOperation { - base_revision_id, - revision_id, - client_id: "client-123".into(), - update_id, - operation: TextOperation::default(), - owner_replacements: Vec::new(), - }); - } - assert!(document.has_applied_update("client-123", 1)); - assert_eq!( - document.acknowledged_updates("client-123"), - vec![MAX_OPERATION_HISTORY as u64 + 8] - ); - } -} +#[path = "tests/collab.rs"] +mod tests; diff --git a/src/config/mod.rs b/src/config/mod.rs index 55eb808..daf4cae 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -250,32 +250,5 @@ fn megabytes_to_bytes( } #[cfg(test)] -mod tests { - use super::{megabytes_to_bytes, multipart_body_limit_bytes}; - - #[test] - fn converts_upload_megabytes_to_bytes() { - assert_eq!(megabytes_to_bytes("LIMIT", 5).unwrap(), 5 * 1024 * 1024); - } - - #[test] - fn rejects_overflowing_upload_limit() { - assert!(megabytes_to_bytes("LIMIT", u64::MAX).is_err()); - } - - #[test] - fn multipart_limit_uses_user_limit_when_guest_uploads_are_disabled() { - assert_eq!( - multipart_body_limit_bytes(20 * 1024 * 1024, false, 50 * 1024 * 1024), - 21 * 1024 * 1024 - ); - } - - #[test] - fn multipart_limit_uses_larger_enabled_guest_limit() { - assert_eq!( - multipart_body_limit_bytes(20 * 1024 * 1024, true, 50 * 1024 * 1024), - 51 * 1024 * 1024 - ); - } -} +#[path = "../tests/config.rs"] +mod tests; diff --git a/src/config/smtp.rs b/src/config/smtp.rs index 8c0d17a..b9a4d76 100644 --- a/src/config/smtp.rs +++ b/src/config/smtp.rs @@ -79,34 +79,5 @@ fn normalize_smtp_from(value: String) -> Result\" ".to_owned()).unwrap(), - "RustPad " - ); - assert!(normalize_smtp_from("RustPad".to_owned()).is_err()); - } -} +#[path = "../tests/config_smtp.rs"] +mod tests; diff --git a/src/db/mod.rs b/src/db/mod.rs index 5e20015..25dbc38 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -715,58 +715,5 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for Pad { } #[cfg(test)] -mod password_verification_tests { - use super::*; - - fn workspace(password_hash: Option) -> Workspace { - Workspace { - id: 1, - slug: "private-workspace".into(), - title: "Private workspace".into(), - password_hash, - created_at: String::new(), - updated_at: String::new(), - is_private: 1, - created_by_guest_id: None, - } - } - - fn pad(password_hash: Option) -> Pad { - Pad { - id: 1, - slug: "private-pad".into(), - title: "Private pad".into(), - content: String::new(), - password_hash, - created_at: String::new(), - updated_at: String::new(), - is_private: 1, - created_by_guest_id: None, - } - } - - #[test] - fn missing_password_does_not_grant_password_access() { - assert!(!verify_workspace_password(&workspace(None), None)); - assert!(!verify_workspace_password( - &workspace(None), - Some("anything") - )); - assert!(!verify_pad_password(&pad(None), None)); - assert!(!verify_pad_password(&pad(None), Some("anything"))); - } - - #[test] - fn configured_password_is_verified() { - let workspace = workspace(Some(hash_password("workspace-secret"))); - assert!(verify_workspace_password( - &workspace, - Some("workspace-secret") - )); - assert!(!verify_workspace_password(&workspace, Some("wrong"))); - - let pad = pad(Some(hash_password("pad-secret"))); - assert!(verify_pad_password(&pad, Some("pad-secret"))); - assert!(!verify_pad_password(&pad, Some("wrong"))); - } -} +#[path = "../tests/db.rs"] +mod password_verification_tests; diff --git a/src/file_urls.rs b/src/file_urls.rs index fd65d2f..822fad3 100644 --- a/src/file_urls.rs +++ b/src/file_urls.rs @@ -87,62 +87,5 @@ pub fn public_file_url(public_base: Option<&str>, stored_url: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalizes_bare_domain_and_http_origins() { - assert_eq!( - normalize_public_base(Some("files.note.example.com".into())).unwrap(), - Some("https://files.note.example.com".into()) - ); - assert_eq!( - normalize_public_base(Some("http://localhost:3001/".into())).unwrap(), - Some("http://localhost:3001".into()) - ); - assert_eq!(normalize_public_base(Some(" ".into())).unwrap(), None); - } - - #[test] - fn rejects_non_origin_public_urls() { - assert!(normalize_public_base(Some("ftp://files.example.com".into())).is_err()); - assert!(normalize_public_base(Some("https://files.example.com/path".into())).is_err()); - assert!(normalize_public_base(Some("https://user@files.example.com".into())).is_err()); - assert!(normalize_public_base(Some("files.example.com\\path".into())).is_err()); - } - - #[test] - fn extracts_canonical_path_from_relative_and_absolute_urls() { - assert_eq!( - canonical_file_path("/f/token/image.png"), - Some("/f/token/image.png".into()) - ); - assert_eq!( - canonical_file_path("https://files.example.com/f/token/image.png"), - Some("/f/token/image.png".into()) - ); - assert_eq!( - canonical_file_path("https://files.example.com/f/token/image.png?download=1"), - Some("/f/token/image.png".into()) - ); - assert_eq!(canonical_file_path("/files/token/image.png"), None); - } - - #[test] - fn switches_between_custom_origin_and_application_path() { - let stored = "/f/token/manual.pdf"; - assert_eq!(public_file_url(None, stored), stored); - assert_eq!( - public_file_url(Some("https://files.example.com"), stored), - "https://files.example.com/f/token/manual.pdf" - ); - assert_eq!( - public_file_url(None, "https://old.example.com/f/token/manual.pdf"), - stored - ); - assert_eq!( - public_file_url(Some("https://files.example.com"), "/invalid/path"), - "/invalid/path" - ); - } -} +#[path = "tests/file_urls.rs"] +mod tests; diff --git a/src/main.rs b/src/main.rs index 6d9520e..6ba66f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -301,20 +301,8 @@ async fn shutdown_signal() { } #[cfg(test)] -mod startup_tests { - use super::startup_credential; - - #[test] - fn startup_credential_contains_product_identity() { - let credential = startup_credential(); - assert!(credential.contains(&format!("RustPad {}", env!("CARGO_PKG_VERSION")))); - assert!(credential.contains("Mateusz GruszczyƄski @linuxiarz.pl")); - assert!( - credential - .contains("https://git.linuxiarz.pl/gru/rustpad/src/branch/master/LICENSE.md") - ); - } -} +#[path = "tests/main.rs"] +mod startup_tests; async fn run_migrations(db: &Database) -> Result<(), sqlx::migrate::MigrateError> { let path = match db.kind() { diff --git a/src/queries/mod.rs b/src/queries/mod.rs index ad1c997..c7346ca 100644 --- a/src/queries/mod.rs +++ b/src/queries/mod.rs @@ -334,30 +334,5 @@ pub const Q058: Query = Query::Q058; pub const Q059: Query = Query::Q059; #[cfg(test)] -mod tests { - use super::*; - #[test] - fn every_backend_has_explicit_queries() { - for query in [ - Q001, - Q003, - Q004, - Q011, - Q021, - Q033, - USER_LIST_WORKSPACES, - USER_LIST_PADS, - SHARE_LINK_SESSION_SOURCE, - SHARE_SESSION_INSERT, - SHARE_SESSION_PERMISSION, - SHARE_SESSIONS_DELETE_BY_LINK, - SHARE_SESSIONS_DELETE_EXPIRED, - PAD_PUBLIC_PAGE_DISABLED, - NOTE_PUBLIC_PAGE_DISABLED, - ] { - assert!(!get(DatabaseKind::Sqlite, query).is_empty()); - assert!(!get(DatabaseKind::Postgres, query).is_empty()); - assert!(!get(DatabaseKind::MySql, query).is_empty()); - } - } -} +#[path = "../tests/queries.rs"] +mod tests; diff --git a/src/queries/mysql.rs b/src/queries/mysql.rs index 7593153..beadda8 100644 --- a/src/queries/mysql.rs +++ b/src/queries/mysql.rs @@ -397,45 +397,5 @@ pub fn get(query: Query) -> &'static str { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn boolean_projections_are_normalized_for_sqlx_any() { - // MySQL BOOLEAN is TINYINT(1), which sqlx::Any 0.8 cannot map directly. - for (query, expected_casts) in [ - (Query::RESOURCE_EDITOR_SETTINGS_SELECT, 1), - (Query::EDITOR_PREFERENCES_SELECT_PAD, 4), - (Query::EDITOR_PREFERENCES_SELECT_NOTE, 4), - (Query::AUTH_USER_BY_EXTERNAL_ID, 1), - (Query::AUTH_USER_BY_SESSION, 1), - (Query::AUTH_USER_BY_NICKNAME, 1), - (Query::AUTH_USER_BY_EMAIL, 1), - (Query::AUTH_USER_BY_SHARE_IDENTIFIER, 1), - (Query::USER_LIST_WORKSPACES, 6), - (Query::USER_LIST_PADS, 6), - (Query::PAD_PUBLIC_PAGE_DISABLED, 1), - (Query::NOTE_PUBLIC_PAGE_DISABLED, 1), - (Query::Q001, 1), - (Query::Q003, 1), - (Query::Q004, 1), - (Query::Q011, 1), - (Query::Q021, 1), - (Query::Q033, 1), - (Query::Q036, 1), - (Query::Q038, 1), - (Query::Q046, 1), - (Query::Q044, 1), - (Query::Q045, 1), - (Query::Q048, 1), - (Query::Q049, 1), - ] { - let sql = get(query); - assert_eq!( - sql.matches("AS SIGNED").count(), - expected_casts, - "MySQL boolean projection is not normalized in {query:?}: {sql}" - ); - } - } -} +#[path = "../tests/queries_mysql.rs"] +mod tests; diff --git a/src/security.rs b/src/security.rs index 4e4dab5..c7d28cc 100644 --- a/src/security.rs +++ b/src/security.rs @@ -77,7 +77,7 @@ pub async fn csrf_token_endpoint(headers: HeaderMap) -> Response { .insert(header::SET_COOKIE, csrf_cookie(&token)); response.headers_mut().insert( header::CACHE_CONTROL, - HeaderValue::from_static("no-store, max-age=0"), + HeaderValue::from_static("no-cache, no-store, max-age=0"), ); response } @@ -256,133 +256,5 @@ fn first_header_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> } #[cfg(test)] -mod tests { - use super::*; - - fn websocket_headers(origin: &'static str, host: &'static str) -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert(header::ORIGIN, HeaderValue::from_static(origin)); - headers.insert(header::HOST, HeaderValue::from_static(host)); - headers - } - - #[test] - fn account_sessions_are_cookie_only() { - let mut headers = HeaderMap::new(); - headers.insert( - header::AUTHORIZATION, - HeaderValue::from_static("Bearer legacy-account-token"), - ); - assert_eq!(session_token(&headers), None); - assert_eq!(bearer_token(&headers), Some("legacy-account-token")); - - headers.insert( - header::COOKIE, - HeaderValue::from_static("__Host-rustpad_session=cookie-token"), - ); - assert_eq!(session_token(&headers), Some("cookie-token")); - } - - #[test] - fn prefers_proxy_controlled_real_ip() { - let mut headers = HeaderMap::new(); - headers.insert( - axum::http::HeaderName::from_static("x-forwarded-for"), - HeaderValue::from_static("203.0.113.10"), - ); - headers.insert( - axum::http::HeaderName::from_static("x-real-ip"), - HeaderValue::from_static("198.51.100.20"), - ); - assert_eq!(client_key(&headers), "ip:198.51.100.20"); - } - - #[test] - fn accepts_same_origin_websocket() { - let headers = websocket_headers("https://pad.example.com", "pad.example.com"); - assert!(websocket_origin_allowed(&headers)); - } - - #[test] - fn rejects_cross_origin_websocket() { - let headers = websocket_headers("https://evil.example", "pad.example.com"); - assert!(!websocket_origin_allowed(&headers)); - } - - #[test] - fn does_not_trust_forwarded_host_for_websocket_origin() { - let mut headers = websocket_headers("https://evil.example", "pad.example.com"); - headers.insert( - axum::http::HeaderName::from_static("x-forwarded-host"), - HeaderValue::from_static("evil.example"), - ); - assert!(!websocket_origin_allowed(&headers)); - } - - #[test] - fn rejects_origin_with_path() { - let headers = websocket_headers("https://pad.example.com/other", "pad.example.com"); - assert!(!websocket_origin_allowed(&headers)); - } - - #[test] - fn rejects_missing_websocket_origin() { - let mut headers = HeaderMap::new(); - headers.insert(header::HOST, HeaderValue::from_static("pad.example.com")); - assert!(!websocket_origin_allowed(&headers)); - } - - #[test] - fn secure_cookies_are_not_script_readable() { - let value = session_cookie("abc123", 7).to_str().unwrap(); - assert!(value.contains("HttpOnly")); - assert!(value.contains("Secure")); - assert!(value.contains("SameSite=Lax")); - assert!(value.starts_with("__Host-rustpad_session=abc123;")); - } - - #[test] - fn share_sessions_use_separate_scoped_opaque_cookies() { - let value = share_session_cookie("workspace", "private-space", "opaque", 600) - .to_str() - .unwrap(); - assert!(value.starts_with("__Host-rustpad_share_")); - assert!(value.contains("=opaque;")); - assert!(value.contains("Max-Age=600")); - assert!(value.contains("HttpOnly")); - assert!(value.contains("Secure")); - assert!(value.contains("SameSite=Lax")); - } - - #[test] - fn csrf_requires_matching_cookie_and_header() { - let token = "a".repeat(CSRF_TOKEN_BYTES * 2); - let mut headers = HeaderMap::new(); - headers.insert( - header::COOKIE, - HeaderValue::from_str(&format!("{CSRF_COOKIE}={token}")).unwrap(), - ); - headers.insert( - axum::http::HeaderName::from_static(CSRF_HEADER), - HeaderValue::from_str(&token).unwrap(), - ); - assert!(csrf_request_is_valid(&headers)); - - headers.insert( - axum::http::HeaderName::from_static(CSRF_HEADER), - HeaderValue::from_static( - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - ), - ); - assert!(!csrf_request_is_valid(&headers)); - } - - #[test] - fn csrf_cookie_is_strict_and_script_readable() { - let token = "a".repeat(CSRF_TOKEN_BYTES * 2); - let value = csrf_cookie(&token).to_str().unwrap(); - assert!(value.contains("Secure")); - assert!(value.contains("SameSite=Strict")); - assert!(!value.contains("HttpOnly")); - } -} +#[path = "tests/security.rs"] +mod tests; diff --git a/src/state.rs b/src/state.rs index 1200bf2..77611d0 100644 --- a/src/state.rs +++ b/src/state.rs @@ -80,6 +80,14 @@ fn compact_presence_name(name: &str) -> String { format!("{initial}.{rest}") } +fn workspace_password_event_channel( + channel_key: &str, + workspace_key: &str, + note_prefix: &str, +) -> bool { + channel_key == workspace_key || channel_key.starts_with(note_prefix) +} + #[derive(Debug, Clone)] struct PresenceConnection { identity: String, @@ -91,6 +99,7 @@ pub enum RoomEvent { Document(NoteUpdate), Presence(Vec), Chat { sender: String, text: String }, + PasswordRequired { except_client_id: Option }, } #[derive(Debug)] @@ -253,9 +262,16 @@ impl AppState { pub fn note_room_key(workspace_slug: &str, note_slug: &str) -> String { format!("workspace:{workspace_slug}/{note_slug}") } + pub fn workspace_room_key(workspace_slug: &str) -> String { + format!("workspace:{workspace_slug}") + } pub fn pad_room_key(slug: &str) -> String { format!("pad:{slug}") } + pub async fn workspace_channel(&self, workspace_slug: &str) -> broadcast::Sender { + self.channel_for_key(Self::workspace_room_key(workspace_slug)) + .await + } pub async fn note_channel( &self, workspace_slug: &str, @@ -267,6 +283,40 @@ impl AppState { pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender { self.channel_for_key(Self::pad_room_key(slug)).await } + pub async fn notify_pad_password_required( + &self, + slug: &str, + except_client_id: Option, + ) { + let key = Self::pad_room_key(slug); + let sender = self.channels.read().await.get(&key).cloned(); + if let Some(sender) = sender { + let _ = sender.send(RoomEvent::PasswordRequired { except_client_id }); + } + } + pub async fn notify_workspace_password_required( + &self, + workspace_slug: &str, + except_client_id: Option, + ) { + let workspace_key = Self::workspace_room_key(workspace_slug); + let prefix = format!("workspace:{workspace_slug}/"); + let senders = self + .channels + .read() + .await + .iter() + .filter(|(key, _)| { + workspace_password_event_channel(key.as_str(), &workspace_key, &prefix) + }) + .map(|(_, sender)| sender.clone()) + .collect::>(); + for sender in senders { + let _ = sender.send(RoomEvent::PasswordRequired { + except_client_id: except_client_id.clone(), + }); + } + } pub async fn join_room( &self, key: &str, @@ -327,6 +377,10 @@ impl AppState { } } +#[cfg(test)] +#[path = "tests/state.rs"] +mod tests; + fn sorted_users(room: &HashMap) -> Vec { let mut by_identity: HashMap<&str, PresenceUser> = HashMap::new(); for connection in room.values() { diff --git a/src/tests/api.rs b/src/tests/api.rs new file mode 100644 index 0000000..dd5a489 --- /dev/null +++ b/src/tests/api.rs @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +use super::*; + +fn headers_with_guest_id(guest_id: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + HeaderValue::from_str(&format!("rustpad_guest_id={guest_id}")) + .expect("valid cookie header"), + ); + headers +} + +#[test] +fn guest_owner_requires_the_original_browser_identifier() { + let owner_id = "0123456789abcdef0123456789abcdef"; + let owner_headers = headers_with_guest_id(owner_id); + let visitor_headers = headers_with_guest_id("fedcba9876543210fedcba9876543210"); + + assert!(guest_owner_is_requester(&owner_headers, Some(owner_id))); + assert!(!guest_owner_is_requester(&visitor_headers, Some(owner_id))); + assert!(!guest_owner_is_requester(&HeaderMap::new(), Some(owner_id))); +} + +#[test] +fn invalid_guest_identifier_does_not_grant_ownership() { + let headers = headers_with_guest_id("too-short"); + assert!(!guest_owner_is_requester(&headers, Some("too-short"))); +} + +#[test] +fn only_an_owner_can_set_the_first_password() { + assert!(can_set_resource_password(false, true, false)); + assert!(can_set_resource_password(false, false, true)); + assert!(!can_set_resource_password(false, false, false)); + assert!(!can_set_resource_password(true, true, true)); +} + +#[test] +fn settings_allow_owner_or_verified_password_holder() { + assert!(can_manage_resource_settings(true, false, false)); + assert!(can_manage_resource_settings(false, true, false)); + assert!(can_manage_resource_settings(false, false, true)); + assert!(!can_manage_resource_settings(false, false, false)); +} diff --git a/src/tests/api_files.rs b/src/tests/api_files.rs new file mode 100644 index 0000000..3a494aa --- /dev/null +++ b/src/tests/api_files.rs @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +use super::{content_references_file, content_references_stored_file, is_safe_inline_image_mime}; + +#[test] +fn only_raster_images_are_inline() { + assert!(is_safe_inline_image_mime("image/png")); + assert!(is_safe_inline_image_mime("image/jpeg")); + assert!(!is_safe_inline_image_mime("image/svg+xml")); + assert!(!is_safe_inline_image_mime("text/html")); + assert!(!is_safe_inline_image_mime("application/xml")); +} + +#[test] +fn extended_image_alias_is_still_attached() { + assert!(content_references_file( + "[image=photo.jpg,Photo,a=left,size=640x400]", + "photo.jpg", + "/f/token/photo.jpg", + )); + assert!(content_references_file( + "[file=report.pdf,Quarterly report]", + "report.pdf", + "/f/token/report.pdf", + )); +} + +#[test] +fn attachment_references_survive_origin_changes() { + let stored = "/f/token/image.png"; + assert!(content_references_stored_file( + "![diagram](https://old-files.example.com/f/token/image.png)", + "image.png", + stored, + None, + )); + assert!(content_references_stored_file( + "![diagram](/f/token/image.png)", + "image.png", + "https://old-files.example.com/f/token/image.png", + Some("https://new-files.example.com"), + )); +} diff --git a/src/tests/app.rs b/src/tests/app.rs new file mode 100644 index 0000000..46a7a8d --- /dev/null +++ b/src/tests/app.rs @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +use super::{ResponseHeaderPolicy, apply_response_headers, response_header_policy}; +use axum::http::{HeaderMap, HeaderValue, header}; + +#[test] +fn classifies_assets_and_icons_as_static_assets() { + for path in [ + "/assets/app.js", + "/assets", + "/favicon.ico", + "/icons/favicon.svg", + "/icons/missing.svg", + ] { + assert_eq!( + response_header_policy(path), + ResponseHeaderPolicy::StaticAsset + ); + } +} + +#[test] +fn classifies_file_routes_as_files() { + for path in ["/f", "/f/token/image.png"] { + assert_eq!(response_header_policy(path), ResponseHeaderPolicy::File); + } +} + +#[test] +fn classifies_other_routes_as_application() { + for path in [ + "/", + "/api/auth/me", + "/static/missing.css", + "/files/legacy/image.png", + "/unknown", + ] { + assert_eq!( + response_header_policy(path), + ResponseHeaderPolicy::Application + ); + } +} + +#[test] +fn static_asset_policy_only_adds_nosniff() { + let mut headers = HeaderMap::new(); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=3600"), + ); + + apply_response_headers(ResponseHeaderPolicy::StaticAsset, &mut headers); + + assert_eq!(headers.len(), 2); + assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff"); + assert!(!headers.contains_key("x-frame-options")); + assert!(!headers.contains_key("cross-origin-opener-policy")); + assert!(!headers.contains_key("cross-origin-resource-policy")); + assert!(!headers.contains_key("referrer-policy")); + assert!(!headers.contains_key("permissions-policy")); +} + +#[test] +fn file_policy_keeps_file_headers_without_document_policies() { + let mut headers = HeaderMap::new(); + headers.insert( + "content-security-policy", + HeaderValue::from_static("default-src 'none'; sandbox"), + ); + headers.insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_static("attachment; filename=\"manual.pdf\""), + ); + + apply_response_headers(ResponseHeaderPolicy::File, &mut headers); + + assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff"); + assert_eq!( + headers["content-security-policy"], + "default-src 'none'; sandbox" + ); + assert!(headers.contains_key(header::CONTENT_DISPOSITION)); + assert!(!headers.contains_key("x-frame-options")); + assert!(!headers.contains_key("cross-origin-opener-policy")); + assert!(!headers.contains_key("cross-origin-resource-policy")); + assert!(!headers.contains_key("referrer-policy")); + assert!(!headers.contains_key("permissions-policy")); +} + +#[test] +fn application_policy_preserves_handler_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + "content-security-policy", + HeaderValue::from_static("default-src 'none'; sandbox"), + ); + + apply_response_headers(ResponseHeaderPolicy::Application, &mut headers); + + assert_eq!( + headers["content-security-policy"], + "default-src 'none'; sandbox" + ); + assert_eq!(headers["x-frame-options"], "DENY"); + assert_eq!(headers["cross-origin-opener-policy"], "same-origin"); + assert_eq!(headers["cross-origin-resource-policy"], "same-origin"); + assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff"); + assert_eq!( + headers["referrer-policy"], + "strict-origin-when-cross-origin" + ); + assert!(headers.contains_key("permissions-policy")); +} diff --git a/src/tests/app_pages.rs b/src/tests/app_pages.rs new file mode 100644 index 0000000..9e47802 --- /dev/null +++ b/src/tests/app_pages.rs @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +use super::*; + +#[test] +fn share_exchange_redirects_to_canonical_resource_path() { + let uri: Uri = "/w/private?view=all&share=secret&page=2".parse().unwrap(); + assert_eq!(canonical_resource_url(&uri), "/w/private?view=all&page=2"); +} + +#[test] +fn encoded_or_repeated_parameters_are_parsed_without_rejection() { + let uri: Uri = "/w/private?%73hare=one&share=two".parse().unwrap(); + assert_eq!( + share_token_from_query(uri.query()), + (true, Some("one".into())) + ); + assert_eq!(canonical_resource_url(&uri), "/w/private"); +} + +#[test] +fn malformed_share_parameter_is_still_removed_from_the_url() { + let uri: Uri = "/w/private?share=%ZZ&keep=no".parse().unwrap(); + assert_eq!(share_token_from_query(uri.query()), (true, None)); + assert_eq!(canonical_resource_url(&uri), "/w/private?keep=no"); +} diff --git a/src/tests/collab.rs b/src/tests/collab.rs new file mode 100644 index 0000000..60d9b30 --- /dev/null +++ b/src/tests/collab.rs @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +use super::*; + +fn operation(components: Vec) -> TextOperation { + TextOperation { components } +} + +#[test] +fn concurrent_insertions_have_stable_order() { + let left = operation(vec![ + OperationComponent::Retain { count: 1 }, + OperationComponent::Insert { + text: "X".into(), + owners: Vec::new(), + }, + OperationComponent::Retain { count: 1 }, + ]); + let right = operation(vec![ + OperationComponent::Retain { count: 1 }, + OperationComponent::Insert { + text: "Y".into(), + owners: Vec::new(), + }, + OperationComponent::Retain { count: 1 }, + ]); + let left_prime = transform_operation(&left, &right, true).unwrap(); + let right_prime = transform_operation(&right, &left, false).unwrap(); + let after_right = apply_operation_to_document("aYb", "[]", &left_prime, &[]) + .unwrap() + .0; + let after_left = apply_operation_to_document("aXb", "[]", &right_prime, &[]) + .unwrap() + .0; + assert_eq!(after_right, "aXYb"); + assert_eq!(after_left, "aXYb"); +} + +#[test] +fn utf16_offsets_support_emoji() { + let operation = operation(vec![ + OperationComponent::Retain { count: 3 }, + OperationComponent::Insert { + text: "x".into(), + owners: Vec::new(), + }, + OperationComponent::Retain { count: 1 }, + ]); + let result = apply_operation_to_document("A😀B", "[]", &operation, &[]) + .unwrap() + .0; + assert_eq!(result, "A😀xB"); +} + +#[test] +fn operation_from_edit_preserves_utf16_boundaries() { + let operation = operation_from_edit("A😀B", "A😀xB", "[]"); + let result = apply_operation_to_document("A😀B", "[]", &operation, &[]) + .unwrap() + .0; + assert_eq!(result, "A😀xB"); +} + +#[test] +fn acknowledgements_survive_history_compaction() { + let mut document = CollaborativeDocument::new(String::new(), "[]".into(), 0); + for update_id in 1..=MAX_OPERATION_HISTORY as u64 + 8 { + let base_revision_id = document.revision_id; + let revision_id = base_revision_id + 1; + document.revision_id = revision_id; + document.record(AppliedOperation { + base_revision_id, + revision_id, + client_id: "client-123".into(), + update_id, + operation: TextOperation::default(), + owner_replacements: Vec::new(), + }); + } + assert!(document.has_applied_update("client-123", 1)); + assert_eq!( + document.acknowledged_updates("client-123"), + vec![MAX_OPERATION_HISTORY as u64 + 8] + ); +} diff --git a/src/tests/config.rs b/src/tests/config.rs new file mode 100644 index 0000000..867d27c --- /dev/null +++ b/src/tests/config.rs @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +use super::{megabytes_to_bytes, multipart_body_limit_bytes}; + +#[test] +fn converts_upload_megabytes_to_bytes() { + assert_eq!(megabytes_to_bytes("LIMIT", 5).unwrap(), 5 * 1024 * 1024); +} + +#[test] +fn rejects_overflowing_upload_limit() { + assert!(megabytes_to_bytes("LIMIT", u64::MAX).is_err()); +} + +#[test] +fn multipart_limit_uses_user_limit_when_guest_uploads_are_disabled() { + assert_eq!( + multipart_body_limit_bytes(20 * 1024 * 1024, false, 50 * 1024 * 1024), + 21 * 1024 * 1024 + ); +} + +#[test] +fn multipart_limit_uses_larger_enabled_guest_limit() { + assert_eq!( + multipart_body_limit_bytes(20 * 1024 * 1024, true, 50 * 1024 * 1024), + 51 * 1024 * 1024 + ); +} diff --git a/src/tests/config_smtp.rs b/src/tests/config_smtp.rs new file mode 100644 index 0000000..75a06ff --- /dev/null +++ b/src/tests/config_smtp.rs @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +use super::*; + +#[test] +fn detects_security_from_standard_ports() { + assert_eq!(smtp_security_for_port(25), SmtpSecurity::None); + assert_eq!(smtp_security_for_port(465), SmtpSecurity::Tls); + assert_eq!(smtp_security_for_port(587), SmtpSecurity::StartTls); + assert_eq!(smtp_security_for_port(2525), SmtpSecurity::None); +} + +#[test] +fn accepts_supported_security_modes() { + assert_eq!(parse_smtp_security("none").unwrap(), SmtpSecurity::None); + assert_eq!( + parse_smtp_security("starttls").unwrap(), + SmtpSecurity::StartTls + ); + assert_eq!(parse_smtp_security("tls").unwrap(), SmtpSecurity::Tls); + assert!(parse_smtp_security("auto").is_err()); +} + +#[test] +fn normalizes_smtp_from() { + assert_eq!( + normalize_smtp_from(" \"RustPad \" ".to_owned()).unwrap(), + "RustPad " + ); + assert!(normalize_smtp_from("RustPad".to_owned()).is_err()); +} diff --git a/src/tests/db.rs b/src/tests/db.rs new file mode 100644 index 0000000..3466aa2 --- /dev/null +++ b/src/tests/db.rs @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +use super::*; + +fn workspace(password_hash: Option) -> Workspace { + Workspace { + id: 1, + slug: "private-workspace".into(), + title: "Private workspace".into(), + password_hash, + created_at: String::new(), + updated_at: String::new(), + is_private: 1, + created_by_guest_id: None, + } +} + +fn pad(password_hash: Option) -> Pad { + Pad { + id: 1, + slug: "private-pad".into(), + title: "Private pad".into(), + content: String::new(), + password_hash, + created_at: String::new(), + updated_at: String::new(), + is_private: 1, + created_by_guest_id: None, + } +} + +#[test] +fn missing_password_does_not_grant_password_access() { + assert!(!verify_workspace_password(&workspace(None), None)); + assert!(!verify_workspace_password( + &workspace(None), + Some("anything") + )); + assert!(!verify_pad_password(&pad(None), None)); + assert!(!verify_pad_password(&pad(None), Some("anything"))); +} + +#[test] +fn configured_password_is_verified() { + let workspace = workspace(Some(hash_password("workspace-secret"))); + assert!(verify_workspace_password( + &workspace, + Some("workspace-secret") + )); + assert!(!verify_workspace_password(&workspace, Some("wrong"))); + + let pad = pad(Some(hash_password("pad-secret"))); + assert!(verify_pad_password(&pad, Some("pad-secret"))); + assert!(!verify_pad_password(&pad, Some("wrong"))); +} diff --git a/src/tests/file_urls.rs b/src/tests/file_urls.rs new file mode 100644 index 0000000..fe33126 --- /dev/null +++ b/src/tests/file_urls.rs @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +use super::*; + +#[test] +fn normalizes_bare_domain_and_http_origins() { + assert_eq!( + normalize_public_base(Some("files.note.example.com".into())).unwrap(), + Some("https://files.note.example.com".into()) + ); + assert_eq!( + normalize_public_base(Some("http://localhost:3001/".into())).unwrap(), + Some("http://localhost:3001".into()) + ); + assert_eq!(normalize_public_base(Some(" ".into())).unwrap(), None); +} + +#[test] +fn rejects_non_origin_public_urls() { + assert!(normalize_public_base(Some("ftp://files.example.com".into())).is_err()); + assert!(normalize_public_base(Some("https://files.example.com/path".into())).is_err()); + assert!(normalize_public_base(Some("https://user@files.example.com".into())).is_err()); + assert!(normalize_public_base(Some("files.example.com\\path".into())).is_err()); +} + +#[test] +fn extracts_canonical_path_from_relative_and_absolute_urls() { + assert_eq!( + canonical_file_path("/f/token/image.png"), + Some("/f/token/image.png".into()) + ); + assert_eq!( + canonical_file_path("https://files.example.com/f/token/image.png"), + Some("/f/token/image.png".into()) + ); + assert_eq!( + canonical_file_path("https://files.example.com/f/token/image.png?download=1"), + Some("/f/token/image.png".into()) + ); + assert_eq!(canonical_file_path("/files/token/image.png"), None); +} + +#[test] +fn switches_between_custom_origin_and_application_path() { + let stored = "/f/token/manual.pdf"; + assert_eq!(public_file_url(None, stored), stored); + assert_eq!( + public_file_url(Some("https://files.example.com"), stored), + "https://files.example.com/f/token/manual.pdf" + ); + assert_eq!( + public_file_url(None, "https://old.example.com/f/token/manual.pdf"), + stored + ); + assert_eq!( + public_file_url(Some("https://files.example.com"), "/invalid/path"), + "/invalid/path" + ); +} diff --git a/src/tests/main.rs b/src/tests/main.rs new file mode 100644 index 0000000..bfa45f4 --- /dev/null +++ b/src/tests/main.rs @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +use super::startup_credential; + +#[test] +fn startup_credential_contains_product_identity() { + let credential = startup_credential(); + assert!(credential.contains(&format!("RustPad {}", env!("CARGO_PKG_VERSION")))); + assert!(credential.contains("Mateusz GruszczyƄski @linuxiarz.pl")); + assert!( + credential.contains("https://git.linuxiarz.pl/gru/rustpad/src/branch/master/LICENSE.md") + ); +} diff --git a/src/tests/queries.rs b/src/tests/queries.rs new file mode 100644 index 0000000..be08bca --- /dev/null +++ b/src/tests/queries.rs @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +use super::*; +#[test] +fn every_backend_has_explicit_queries() { + for query in [ + Q001, + Q003, + Q004, + Q011, + Q021, + Q033, + USER_LIST_WORKSPACES, + USER_LIST_PADS, + SHARE_LINK_SESSION_SOURCE, + SHARE_SESSION_INSERT, + SHARE_SESSION_PERMISSION, + SHARE_SESSIONS_DELETE_BY_LINK, + SHARE_SESSIONS_DELETE_EXPIRED, + PAD_PUBLIC_PAGE_DISABLED, + NOTE_PUBLIC_PAGE_DISABLED, + ] { + assert!(!get(DatabaseKind::Sqlite, query).is_empty()); + assert!(!get(DatabaseKind::Postgres, query).is_empty()); + assert!(!get(DatabaseKind::MySql, query).is_empty()); + } +} diff --git a/src/tests/queries_mysql.rs b/src/tests/queries_mysql.rs new file mode 100644 index 0000000..736d094 --- /dev/null +++ b/src/tests/queries_mysql.rs @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +use super::*; + +#[test] +fn boolean_projections_are_normalized_for_sqlx_any() { + // MySQL BOOLEAN is TINYINT(1), which sqlx::Any 0.8 cannot map directly. + for (query, expected_casts) in [ + (Query::RESOURCE_EDITOR_SETTINGS_SELECT, 1), + (Query::EDITOR_PREFERENCES_SELECT_PAD, 4), + (Query::EDITOR_PREFERENCES_SELECT_NOTE, 4), + (Query::AUTH_USER_BY_EXTERNAL_ID, 1), + (Query::AUTH_USER_BY_SESSION, 1), + (Query::AUTH_USER_BY_NICKNAME, 1), + (Query::AUTH_USER_BY_EMAIL, 1), + (Query::AUTH_USER_BY_SHARE_IDENTIFIER, 1), + (Query::USER_LIST_WORKSPACES, 6), + (Query::USER_LIST_PADS, 6), + (Query::PAD_PUBLIC_PAGE_DISABLED, 1), + (Query::NOTE_PUBLIC_PAGE_DISABLED, 1), + (Query::Q001, 1), + (Query::Q003, 1), + (Query::Q004, 1), + (Query::Q011, 1), + (Query::Q021, 1), + (Query::Q033, 1), + (Query::Q036, 1), + (Query::Q038, 1), + (Query::Q046, 1), + (Query::Q044, 1), + (Query::Q045, 1), + (Query::Q048, 1), + (Query::Q049, 1), + ] { + let sql = get(query); + assert_eq!( + sql.matches("AS SIGNED").count(), + expected_casts, + "MySQL boolean projection is not normalized in {query:?}: {sql}" + ); + } +} diff --git a/src/tests/security.rs b/src/tests/security.rs new file mode 100644 index 0000000..bed705a --- /dev/null +++ b/src/tests/security.rs @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +use super::*; + +fn websocket_headers(origin: &'static str, host: &'static str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(header::ORIGIN, HeaderValue::from_static(origin)); + headers.insert(header::HOST, HeaderValue::from_static(host)); + headers +} + +#[test] +fn account_sessions_are_cookie_only() { + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_static("Bearer legacy-account-token"), + ); + assert_eq!(session_token(&headers), None); + assert_eq!(bearer_token(&headers), Some("legacy-account-token")); + + headers.insert( + header::COOKIE, + HeaderValue::from_static("__Host-rustpad_session=cookie-token"), + ); + assert_eq!(session_token(&headers), Some("cookie-token")); +} + +#[test] +fn prefers_proxy_controlled_real_ip() { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::HeaderName::from_static("x-forwarded-for"), + HeaderValue::from_static("203.0.113.10"), + ); + headers.insert( + axum::http::HeaderName::from_static("x-real-ip"), + HeaderValue::from_static("198.51.100.20"), + ); + assert_eq!(client_key(&headers), "ip:198.51.100.20"); +} + +#[test] +fn accepts_same_origin_websocket() { + let headers = websocket_headers("https://pad.example.com", "pad.example.com"); + assert!(websocket_origin_allowed(&headers)); +} + +#[test] +fn rejects_cross_origin_websocket() { + let headers = websocket_headers("https://evil.example", "pad.example.com"); + assert!(!websocket_origin_allowed(&headers)); +} + +#[test] +fn does_not_trust_forwarded_host_for_websocket_origin() { + let mut headers = websocket_headers("https://evil.example", "pad.example.com"); + headers.insert( + axum::http::HeaderName::from_static("x-forwarded-host"), + HeaderValue::from_static("evil.example"), + ); + assert!(!websocket_origin_allowed(&headers)); +} + +#[test] +fn rejects_origin_with_path() { + let headers = websocket_headers("https://pad.example.com/other", "pad.example.com"); + assert!(!websocket_origin_allowed(&headers)); +} + +#[test] +fn rejects_missing_websocket_origin() { + let mut headers = HeaderMap::new(); + headers.insert(header::HOST, HeaderValue::from_static("pad.example.com")); + assert!(!websocket_origin_allowed(&headers)); +} + +#[test] +fn secure_cookies_are_not_script_readable() { + let value = session_cookie("abc123", 7).to_str().unwrap(); + assert!(value.contains("HttpOnly")); + assert!(value.contains("Secure")); + assert!(value.contains("SameSite=Lax")); + assert!(value.starts_with("__Host-rustpad_session=abc123;")); +} + +#[test] +fn share_sessions_use_separate_scoped_opaque_cookies() { + let value = share_session_cookie("workspace", "private-space", "opaque", 600) + .to_str() + .unwrap(); + assert!(value.starts_with("__Host-rustpad_share_")); + assert!(value.contains("=opaque;")); + assert!(value.contains("Max-Age=600")); + assert!(value.contains("HttpOnly")); + assert!(value.contains("Secure")); + assert!(value.contains("SameSite=Lax")); +} + +#[test] +fn csrf_requires_matching_cookie_and_header() { + let token = "a".repeat(CSRF_TOKEN_BYTES * 2); + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + HeaderValue::from_str(&format!("{CSRF_COOKIE}={token}")).unwrap(), + ); + headers.insert( + axum::http::HeaderName::from_static(CSRF_HEADER), + HeaderValue::from_str(&token).unwrap(), + ); + assert!(csrf_request_is_valid(&headers)); + + headers.insert( + axum::http::HeaderName::from_static(CSRF_HEADER), + HeaderValue::from_static( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ), + ); + assert!(!csrf_request_is_valid(&headers)); +} + +#[test] +fn csrf_cookie_is_strict_and_script_readable() { + let token = "a".repeat(CSRF_TOKEN_BYTES * 2); + let value = csrf_cookie(&token).to_str().unwrap(); + assert!(value.contains("Secure")); + assert!(value.contains("SameSite=Strict")); + assert!(!value.contains("HttpOnly")); +} diff --git a/src/tests/state.rs b/src/tests/state.rs new file mode 100644 index 0000000..9653ea1 --- /dev/null +++ b/src/tests/state.rs @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +use super::*; + +#[test] +fn workspace_password_event_reaches_the_workspace_page_and_its_notes() { + let workspace_key = AppState::workspace_room_key("team"); + let note_prefix = "workspace:team/"; + + assert!(workspace_password_event_channel( + "workspace:team", + &workspace_key, + note_prefix, + )); + assert!(workspace_password_event_channel( + "workspace:team/roadmap", + &workspace_key, + note_prefix, + )); + assert!(!workspace_password_event_channel( + "workspace:team-two/roadmap", + &workspace_key, + note_prefix, + )); + assert!(!workspace_password_event_channel( + "workspace:teams/roadmap", + &workspace_key, + note_prefix, + )); +} diff --git a/src/tests/websocket.rs b/src/tests/websocket.rs new file mode 100644 index 0000000..dc26bd8 --- /dev/null +++ b/src/tests/websocket.rs @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +use super::*; + +#[test] +fn password_event_only_excludes_the_matching_connection() { + assert!(password_event_excludes_connection( + Some("client_owner"), + "client_owner", + )); + assert!(!password_event_excludes_connection( + Some("client_owner"), + "client_visitor", + )); + assert!(!password_event_excludes_connection(None, "client_owner")); +} + +#[test] +fn password_event_client_id_is_validated() { + assert_eq!( + clean_collaboration_client_id(Some("client_owner_123".into())), + Some("client_owner_123".into()), + ); + assert_eq!(clean_collaboration_client_id(Some("short".into())), None); + assert_eq!( + clean_collaboration_client_id(Some("invalid client id!".into())), + None, + ); +} + +#[test] +fn workspace_watch_authentication_accepts_a_minimal_client_message() { + let message = serde_json::from_str::( + r#"{"type":"authenticate","access_token":null,"client_id":"workspace_watch_123"}"#, + ) + .expect("workspace watch authentication should parse"); + + match message { + WorkspaceWatchClientMessage::Authenticate { + access_token, + client_id, + } => { + assert!(access_token.is_none()); + assert_eq!(client_id.as_deref(), Some("workspace_watch_123")); + } + WorkspaceWatchClientMessage::Ping { .. } => panic!("unexpected ping message"), + } +} diff --git a/src/websocket/mod.rs b/src/websocket/mod.rs index 9d1706c..9edf700 100644 --- a/src/websocket/mod.rs +++ b/src/websocket/mod.rs @@ -162,11 +162,37 @@ enum ServerMessage { Diagnostics { diagnostics: ConnectionDiagnostics, }, + PasswordRequired, + PasswordChanged, Error { message: String, }, } +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum WorkspaceWatchClientMessage { + Authenticate { + #[serde(default)] + access_token: Option, + #[serde(default)] + client_id: Option, + }, + Ping { + nonce: u64, + }, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum WorkspaceWatchServerMessage { + Watching, + Pong { nonce: u64 }, + PasswordRequired, + PasswordChanged, + Error { message: String }, +} + fn connection_diagnostics( request: &RequestClientContext, client: Option, @@ -267,6 +293,13 @@ async fn password_access_from_tokens( false } +fn password_event_excludes_connection( + except_client_id: Option<&str>, + connection_client_id: &str, +) -> bool { + except_client_id.is_some_and(|except_client_id| except_client_id == connection_client_id) +} + async fn current_resource_access( state: &SharedState, kind: &str, @@ -289,6 +322,244 @@ async fn current_resource_access( (read_allowed, write_allowed) } +pub async fn upgrade_workspace_watch( + ws: WebSocketUpgrade, + headers: HeaderMap, + Path(workspace_slug): Path, + State(state): State, +) -> Response { + if !crate::security::websocket_origin_allowed(&headers) { + warn!(%workspace_slug, "workspace watch websocket rejected: invalid origin"); + return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response(); + } + let account_token = crate::security::session_token(&headers).map(str::to_owned); + let share_session_token = + crate::security::share_session_token(&headers, "workspace", &workspace_slug) + .map(str::to_owned); + let resource_token = + crate::security::resource_token(&headers, "workspace", &workspace_slug).map(str::to_owned); + ws.on_upgrade(move |socket| { + handle_workspace_watch( + socket, + state, + workspace_slug, + account_token, + share_session_token, + resource_token, + ) + }) +} + +async fn workspace_watch_access_lost_message( + state: &SharedState, + workspace_slug: &str, +) -> WorkspaceWatchServerMessage { + if db::find_workspace(&state.db, workspace_slug) + .await + .ok() + .flatten() + .is_some_and(|workspace| workspace.password_hash.is_some()) + { + WorkspaceWatchServerMessage::PasswordRequired + } else { + WorkspaceWatchServerMessage::Error { + message: "Access expired or revoked".into(), + } + } +} + +async fn handle_workspace_watch( + mut socket: WebSocket, + state: SharedState, + workspace_slug: String, + cookie_session_token: Option, + cookie_share_session_token: Option, + cookie_password_token: Option, +) { + let (explicit_access_token, collaboration_client_id) = match socket.recv().await { + Some(Ok(Message::Text(text))) => { + match serde_json::from_str::(&text) { + Ok(WorkspaceWatchClientMessage::Authenticate { + access_token, + client_id, + }) => ( + access_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty() && *value != "cookie") + .map(str::to_owned), + clean_collaboration_client_id(client_id) + .unwrap_or_else(|| format!("watch_{}", db::random_suffix(24))), + ), + _ => { + let _ = send_workspace_watch( + &mut socket, + &WorkspaceWatchServerMessage::Error { + message: "Authentication required".into(), + }, + ) + .await; + return; + } + } + } + _ => return, + }; + + let Some(workspace) = db::find_workspace(&state.db, &workspace_slug) + .await + .ok() + .flatten() + else { + let _ = send_workspace_watch( + &mut socket, + &WorkspaceWatchServerMessage::Error { + message: "Workspace not found".into(), + }, + ) + .await; + return; + }; + + let session_token = cookie_session_token; + let external_tokens = [ + explicit_access_token.as_deref(), + cookie_share_session_token.as_deref(), + cookie_password_token.as_deref(), + ]; + let channel = state.workspace_channel(&workspace_slug).await; + let mut updates = channel.subscribe(); + let (read_allowed, _) = current_resource_access( + &state, + "workspace", + &workspace_slug, + &external_tokens, + session_token.as_deref(), + false, + ) + .await; + if !read_allowed { + let message = if workspace.password_hash.is_some() { + WorkspaceWatchServerMessage::PasswordRequired + } else { + WorkspaceWatchServerMessage::Error { + message: "Workspace not found".into(), + } + }; + let _ = send_workspace_watch(&mut socket, &message).await; + return; + } + if send_workspace_watch(&mut socket, &WorkspaceWatchServerMessage::Watching) + .await + .is_err() + { + return; + } + + let (mut sender, mut receiver) = socket.split(); + let mut access_refresh = tokio::time::interval(Duration::from_secs(10)); + access_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + incoming = receiver.next() => { + match incoming { + Some(Ok(Message::Text(text))) => { + match serde_json::from_str::(&text) { + Ok(WorkspaceWatchClientMessage::Ping { nonce }) => { + if send_workspace_watch_split( + &mut sender, + &WorkspaceWatchServerMessage::Pong { nonce }, + ).await.is_err() { + break; + } + } + Ok(WorkspaceWatchClientMessage::Authenticate { .. }) => {} + Err(error) => warn!(%error, %workspace_slug, "invalid workspace watch message"), + } + } + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(_)) => {} + Some(Err(error)) => { + debug!(%error, %workspace_slug, "workspace watch receive error"); + break; + } + } + } + _ = access_refresh.tick() => { + let (read_allowed, _) = current_resource_access( + &state, + "workspace", + &workspace_slug, + &external_tokens, + session_token.as_deref(), + false, + ).await; + if !read_allowed { + let message = workspace_watch_access_lost_message( + &state, + &workspace_slug, + ).await; + let _ = send_workspace_watch_split( + &mut sender, + &message, + ).await; + break; + } + } + update = updates.recv() => { + match update { + Ok(RoomEvent::PasswordRequired { except_client_id }) => { + if password_event_excludes_connection( + except_client_id.as_deref(), + &collaboration_client_id, + ) { + let _ = send_workspace_watch_split( + &mut sender, + &WorkspaceWatchServerMessage::PasswordChanged, + ).await; + break; + } + let (read_allowed, _) = current_resource_access( + &state, + "workspace", + &workspace_slug, + &external_tokens, + session_token.as_deref(), + false, + ).await; + if !read_allowed { + let _ = send_workspace_watch_split( + &mut sender, + &WorkspaceWatchServerMessage::PasswordRequired, + ).await; + break; + } + } + Ok(_) => {} + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + let (read_allowed, _) = current_resource_access( + &state, + "workspace", + &workspace_slug, + &external_tokens, + session_token.as_deref(), + false, + ).await; + if !read_allowed { + let _ = send_workspace_watch_split( + &mut sender, + &WorkspaceWatchServerMessage::PasswordRequired, + ).await; + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + } + } +} + // Merged from note.rs pub async fn upgrade( ws: WebSocketUpgrade, @@ -760,6 +1031,28 @@ async fn handle_socket( } }, update=updates.recv()=>{ + if let Ok(RoomEvent::PasswordRequired { except_client_id }) = &update { + if password_event_excludes_connection( + except_client_id.as_deref(), + &collaboration_client_id, + ) { + let _=send_split(&mut sender,&ServerMessage::PasswordChanged).await; + break; + } + let (read_allowed, _) = current_resource_access( + &state, + "workspace", + &workspace_slug, + &external_tokens, + session_token.as_deref(), + false, + ).await; + if !read_allowed { + let _=send_split(&mut sender,&ServerMessage::PasswordRequired).await; + break; + } + continue; + } let (read_allowed, _) = current_resource_access( &state, "workspace", @@ -776,6 +1069,7 @@ async fn handle_socket( Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{base_revision_id:update.base_revision_id,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,client_id:update.client_id,update_id:update.update_id,operation:update.operation,owner_replacements:update.owner_replacements}).await.is_err(){break;}, Ok(RoomEvent::Presence(users))=>if send_split(&mut sender,&ServerMessage::Presence{users}).await.is_err(){break;}, Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_split(&mut sender,&ServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;}, + Ok(RoomEvent::PasswordRequired { .. })=>{}, Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>{ let document=collaborative_document.lock().await; let snapshot=(document.content.clone(),document.revision_id,document.owner_map.clone(),document.acknowledged_updates(&collaboration_client_id)); @@ -795,7 +1089,7 @@ async fn handle_socket( "note websocket disconnected" ); } -fn clean_collaboration_client_id(value: Option) -> Option { +pub(crate) fn clean_collaboration_client_id(value: Option) -> Option { value .map(|value| value.trim().chars().take(64).collect::()) .filter(|value| { @@ -887,4 +1181,30 @@ async fn send_split( .await } +async fn send_workspace_watch( + socket: &mut WebSocket, + message: &WorkspaceWatchServerMessage, +) -> Result<(), axum::Error> { + socket + .send(Message::Text( + serde_json::to_string(message).unwrap().into(), + )) + .await +} + +async fn send_workspace_watch_split( + sender: &mut futures_util::stream::SplitSink, + message: &WorkspaceWatchServerMessage, +) -> Result<(), axum::Error> { + sender + .send(Message::Text( + serde_json::to_string(message).unwrap().into(), + )) + .await +} + // Merged from pad.rs + +#[cfg(test)] +#[path = "../tests/websocket.rs"] +mod tests; diff --git a/src/websocket/pad.rs b/src/websocket/pad.rs index f9666c5..130613a 100644 --- a/src/websocket/pad.rs +++ b/src/websocket/pad.rs @@ -42,6 +42,8 @@ enum PadServerMessage { Diagnostics { diagnostics: ConnectionDiagnostics, }, + PasswordRequired, + PasswordChanged, Error { message: String, }, @@ -528,6 +530,28 @@ async fn handle_pad_socket( } }, update=updates.recv()=>{ + if let Ok(RoomEvent::PasswordRequired { except_client_id }) = &update { + if password_event_excludes_connection( + except_client_id.as_deref(), + &collaboration_client_id, + ) { + let _=send_pad_split(&mut sender,&PadServerMessage::PasswordChanged).await; + break; + } + let (read_allowed, _) = current_resource_access( + &state, + "pad", + &slug, + &external_tokens, + session_token.as_deref(), + false, + ).await; + if !read_allowed { + let _=send_pad_split(&mut sender,&PadServerMessage::PasswordRequired).await; + break; + } + continue; + } let (read_allowed, _) = current_resource_access( &state, "pad", @@ -544,6 +568,7 @@ async fn handle_pad_socket( Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{base_revision_id:u.base_revision_id,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,client_id:u.client_id,update_id:u.update_id,operation:u.operation,owner_replacements:u.owner_replacements}).await.is_err(){break;}, Ok(RoomEvent::Presence(users))=>if send_pad_split(&mut sender,&PadServerMessage::Presence{users}).await.is_err(){break;}, Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_pad_split(&mut sender,&PadServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;}, + Ok(RoomEvent::PasswordRequired { .. })=>{}, Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>{ let document=collaborative_document.lock().await; let snapshot=(document.content.clone(),document.revision_id,document.owner_map.clone(),document.acknowledged_updates(&collaboration_client_id)); diff --git a/static/css/styles.css b/static/css/styles.css index be25fb9..e823d68 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -3556,15 +3556,86 @@ dialog::backdrop { .resource-actions { display: flex; - flex: 0 0 auto; + flex: 0 1 auto; gap: 8px; flex-wrap: wrap; + max-width: 100%; + margin-left: auto; } .resource-actions button { padding: 7px 10px; } +.resource-password-menu { + position: relative; + flex: 0 0 auto; +} + +.resource-password-menu>summary { + list-style: none; + user-select: none; +} + +.resource-password-menu>summary::-webkit-details-marker { + display: none; +} + +.resource-password-menu__chevron { + font-size: .72em; + line-height: 1; + transition: transform .16s ease; +} + +.resource-password-menu[open] .resource-password-menu__chevron { + transform: rotate(180deg); +} + +.resource-password-menu__panel { + position: absolute; + z-index: 50; + top: calc(100% + 6px); + right: 0; + display: grid; + gap: 3px; + min-width: 180px; + padding: 6px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--panel); + box-shadow: 0 12px 30px var(--shadow-28); + white-space: nowrap; +} + +.resource-password-menu__item { + width: 100%; + min-height: 36px; + padding: 8px 10px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--text); + font: inherit; + font-weight: 600; + line-height: 1.2; + text-align: left; + cursor: pointer; +} + +.resource-password-menu__item:hover, +.resource-password-menu__item:focus-visible { + background: var(--surface-3); +} + +.resource-password-menu__item--danger { + color: var(--danger); +} + +.resource-password-menu__item--danger:hover, +.resource-password-menu__item--danger:focus-visible { + background: var(--danger-subtle-bg); +} + @media (max-width:640px) { .resource-row { align-items: flex-start; @@ -3580,8 +3651,10 @@ dialog::backdrop { display: flex; align-items: center; justify-content: space-between; - gap: 16px; + flex-wrap: wrap; + gap: 12px 16px; width: 100%; + min-width: 0; } .resource-inline { @@ -3630,6 +3703,7 @@ dialog::backdrop { .resource-actions { width: 100%; + margin-left: 0; } } @@ -4348,6 +4422,7 @@ dialog::backdrop { } .resource-copy { + flex: 1 1 220px; min-width: 0; } diff --git a/static/js/home.js b/static/js/home.js index 06701e0..48d2b13 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -126,6 +126,20 @@ function shareLinkId(tokenHash) { return String(tokenHash || "").slice(0, 12); } function renderResourcesPagination(meta) { resourcesPagination.innerHTML = meta.total ? `Page ${meta.page} of ${meta.total_pages} · ${meta.total} items` : ""; } +function closeResourcePasswordMenus(except = null) { + document.querySelectorAll(".resource-password-menu[open]").forEach(menu => { + if (menu !== except) menu.removeAttribute("open"); + }); +} + +document.addEventListener("click", event => { + const menu = event.target.closest?.(".resource-password-menu"); + closeResourcePasswordMenus(menu); +}); + +document.addEventListener("keydown", event => { + if (event.key === "Escape") closeResourcePasswordMenus(); +}); async function loadResources() { resourcesError.textContent = ""; resourcesList.innerHTML = "

Loading


"; try { @@ -140,7 +154,10 @@ async function loadResources() { const sharedLabel = !item.owned ? `Shared by ${escapeHtml(item.shared_by || "another user")}` : ""; const permissionLabel = item.permission === "rw" ? "Read and write" : "Read only"; row.classList.toggle("resource-row--shared", !Boolean(item.owned)); - row.innerHTML = `
${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}
${item.owned ? `` : ""}
`; + const passwordActions = item.protected + ? `` + : ``; + row.innerHTML = `
${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}
${item.owned ? `
Password

${passwordActions}
` : ""}
`; const inline = row.querySelector("[data-inline]"); const closeInline = () => { inline.hidden = true; inline.innerHTML = ""; }; @@ -267,16 +284,17 @@ async function loadResources() { try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } }); - row.querySelector("[data-password]")?.addEventListener("click", () => { + row.querySelector("[data-password]")?.addEventListener("click", event => { + event.currentTarget.closest(".resource-password-menu")?.removeAttribute("open"); inline.hidden = false; - inline.innerHTML = `

Leave empty to remove password protection.

`; + inline.innerHTML = `

`; const form = inline.querySelector("form"); const input = form.querySelector("input"); form.querySelector("[data-cancel]").addEventListener("click", closeInline); form.addEventListener("submit", async event => { event.preventDefault(); const password = input.value; - if (password && password.length < 8) { setInlineMessage("Password must contain at least 8 characters.", "error"); return; } + if (password.length < 8) { setInlineMessage("Password must contain at least 8 characters.", "error"); return; } const submit = form.querySelector('[type="submit"]'); submit.disabled = true; setInlineMessage(""); @@ -291,6 +309,24 @@ async function loadResources() { input.focus(); }); + row.querySelector("[data-remove-password]")?.addEventListener("click", event => { + event.currentTarget.closest(".resource-password-menu")?.removeAttribute("open"); + inline.hidden = false; + inline.innerHTML = `

Remove password protection from “${escapeHtml(item.title)}”?

Anyone with the public link will be able to open it without a password.

`; + inline.querySelector("[data-cancel]").addEventListener("click", closeInline); + inline.querySelector("[data-confirm-remove-password]").addEventListener("click", async event => { + event.currentTarget.disabled = true; + setInlineMessage(""); + try { + await api("/api/auth/resources", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, password: "" }) }); + await loadResources(); + } catch (e) { + setInlineMessage(e.message, "error"); + event.currentTarget.disabled = false; + } + }); + }); + row.querySelector("[data-delete]")?.addEventListener("click", () => { inline.hidden = false; inline.innerHTML = `

Delete “${escapeHtml(item.title)}” permanently?

`; diff --git a/static/js/note-api.js b/static/js/note-api.js index 53f8adc..7c905ad 100644 --- a/static/js/note-api.js +++ b/static/js/note-api.js @@ -38,7 +38,10 @@ export function createPadAdapter() { method: "POST", body: JSON.stringify({ kind: "pad", slug, password }), }), - setPassword: password => api(`${base}/password`, { method: "POST", body: JSON.stringify({ password }) }), + setPassword: (password, clientId) => api(`${base}/password`, { + method: "POST", + body: JSON.stringify({ password, client_id: clientId || null }), + }), publish: (accessToken, allowTaskUpdates, unprotectPage, enabled = true) => api(`${base}/publish`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage, enabled }), @@ -81,9 +84,9 @@ export function createWorkspaceNoteAdapter() { method: "POST", body: JSON.stringify({ kind: "workspace", slug: workspaceSlug, password }), }), - setPassword: password => api(`/api/workspaces/${encode(workspaceSlug)}/password`, { + setPassword: (password, clientId) => api(`/api/workspaces/${encode(workspaceSlug)}/password`, { method: "POST", - body: JSON.stringify({ password }), + body: JSON.stringify({ password, client_id: clientId || null }), }), publish: (accessToken, allowTaskUpdates, unprotectPage, enabled = true) => api(`${base}/publish`, { method: "POST", diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 7ddb7e3..e225b0e 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -1332,6 +1332,24 @@ export function startNoteEditor(adapter) { onLatency: updateLatency, onDiagnostics: renderConnectionDiagnostics, onChat: appendChatMessage, + onPasswordRequired: async () => { + resourceUnlocked = false; + info = { ...info, protected: true, access_level: "none", can_set_password: false }; + updatePageControls(); + clearTimeout(saveTimer); + saveState.textContent = collaboration.hasPending() + ? "Password required — pending changes kept" + : "Password required"; + setDocumentReadOnly(true, "Password required"); + document.querySelector("#password-error").textContent = "A password was set for this note. Enter it to continue."; + if (!passwordDialog.open) passwordDialog.showModal(); + document.querySelector("#open-password")?.focus(); + try { + await loadNoteInfo(); + adapter.configureView?.(info); + updatePageControls(); + } catch { } + }, onError: message => { hideConnectionNotice(); const friendly = /read-only access/i.test(message) ? "This note is read only. Enter the password or ask the owner to grant write access." : message; @@ -1932,7 +1950,7 @@ export function startNoteEditor(adapter) { const submit = setPagePasswordForm.querySelector('button[type="submit"]'); submit.disabled = true; try { - await adapter.setPassword(passwordValue); + await adapter.setPassword(passwordValue, collaborationClientId); const access = await adapter.requestAccess(passwordValue); setAccessToken(adapter.access.kind, adapter.access.key, access.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key) || shareToken; diff --git a/static/js/socket.js b/static/js/socket.js index 3d2b4c1..b6936ac 100644 --- a/static/js/socket.js +++ b/static/js/socket.js @@ -109,6 +109,17 @@ class RoomSocket { try { message = JSON.parse(event.data); } catch { return; } this.lastMessageAt = Date.now(); this.bytesReceived += typeof event.data === "string" ? new Blob([event.data]).size : Number(event.data?.byteLength || 0); + if (message.type === "password_required") { + this.intentionalClose = true; + this.onPasswordRequired?.(); + socket.close(); + return; + } + if (message.type === "password_changed") { + this.intentionalClose = true; + socket.close(); + return; + } if (message.type === "error") { this.intentionalClose = true; this.onError?.(message.message); diff --git a/static/js/workspace.js b/static/js/workspace.js index 9413c68..2e977cb 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -25,6 +25,12 @@ const shareToken = new URLSearchParams(location.search).get("share"); let accessToken = shareToken || getAccessToken("workspace", slug); let nickname = getNickname(); getGuestId(); +const workspaceWatchClientId = `workspace_watch_${crypto.randomUUID()}`; +let workspaceWatchSocket; +let workspaceWatchPingTimer; +let workspaceWatchReconnectTimer; +let workspaceWatchIntentionalClose = false; +let workspaceLockedForPassword = false; const dialog = document.querySelector("#password-dialog"); const identityDialog = document.querySelector("#identity-dialog"); const workspaceContent = document.querySelector("#workspace-content"); @@ -45,6 +51,86 @@ function updateWorkspacePasswordControl() { workspacePasswordForm.hidden = Boolean(info?.protected || !info?.can_set_password); } +function stopWorkspaceWatch() { + clearInterval(workspaceWatchPingTimer); + clearTimeout(workspaceWatchReconnectTimer); + workspaceWatchPingTimer = undefined; + workspaceWatchReconnectTimer = undefined; + if (workspaceWatchSocket) { + workspaceWatchIntentionalClose = true; + workspaceWatchSocket.close(); + workspaceWatchSocket = undefined; + } +} + +function lockWorkspaceForPassword(message = "A password was set for this workspace. Enter it to continue.") { + workspaceLockedForPassword = true; + info = { ...info, protected: true, access_level: "none", can_set_password: false }; + stopWorkspaceWatch(); + workspaceContent.hidden = true; + document.querySelector("#password-error").textContent = message; + if (!dialog.open) dialog.showModal(); + document.querySelector("#open-password")?.focus(); +} + +function scheduleWorkspaceWatchReconnect() { + clearTimeout(workspaceWatchReconnectTimer); + if (workspaceLockedForPassword || !nickname) return; + workspaceWatchReconnectTimer = window.setTimeout(connectWorkspaceWatch, 1500); +} + +function connectWorkspaceWatch() { + if (workspaceLockedForPassword || !nickname) return; + if (workspaceWatchSocket?.readyState === WebSocket.OPEN || workspaceWatchSocket?.readyState === WebSocket.CONNECTING) return; + clearTimeout(workspaceWatchReconnectTimer); + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + const socket = new WebSocket(`${protocol}//${location.host}/ws/watch/workspace/${encodeURIComponent(slug)}`); + workspaceWatchSocket = socket; + workspaceWatchIntentionalClose = false; + socket.addEventListener("open", () => { + if (socket !== workspaceWatchSocket) return; + socket.send(JSON.stringify({ + type: "authenticate", + access_token: accessToken || null, + client_id: workspaceWatchClientId, + })); + clearInterval(workspaceWatchPingTimer); + workspaceWatchPingTimer = window.setInterval(() => { + if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "ping", nonce: Date.now() })); + }, 10000); + }); + socket.addEventListener("message", event => { + if (socket !== workspaceWatchSocket) return; + let message; + try { message = JSON.parse(event.data); } catch { return; } + if (message.type === "password_required") { + workspaceWatchIntentionalClose = true; + lockWorkspaceForPassword(); + return; + } + if (message.type === "password_changed") { + workspaceWatchIntentionalClose = true; + socket.close(); + return; + } + if (message.type === "error") { + workspaceWatchIntentionalClose = true; + socket.close(); + if (/password/i.test(message.message || "")) lockWorkspaceForPassword(message.message); + } + }); + socket.addEventListener("close", () => { + if (socket !== workspaceWatchSocket) return; + clearInterval(workspaceWatchPingTimer); + workspaceWatchPingTimer = undefined; + workspaceWatchSocket = undefined; + if (!workspaceWatchIntentionalClose) scheduleWorkspaceWatchReconnect(); + }); + socket.addEventListener("error", () => { + if (socket.readyState !== WebSocket.CLOSING && socket.readyState !== WebSocket.CLOSED) socket.close(); + }); +} + function escapeHtml(v) { const e = document.createElement("div"); e.textContent = v; return e.innerHTML; } function formatBytes(value) { const bytes = Math.max(0, Number(value) || 0); @@ -143,11 +229,13 @@ async function openWorkspace() { renderNotes(); renderNotesPagination(data.pagination); updateWorkspacePasswordControl(); + workspaceLockedForPassword = false; + workspaceContent.hidden = false; if (dialog.open) dialog.close(); + connectWorkspaceWatch(); } catch (e) { if (info?.protected || e.message.toLowerCase().includes("password")) { - document.querySelector("#password-error").textContent = e.message; - if (!dialog.open) dialog.showModal(); + lockWorkspaceForPassword(e.message); } else if (e.status === 403 || e.status === 404) { await showSystemNotFound(); } else document.querySelector("#workspace-error").textContent = e.message; @@ -160,7 +248,8 @@ async function init() { document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-url").textContent = location.pathname; updateWorkspacePasswordControl(); - if (info.protected && info.access_level === "none") dialog.showModal(); else openWorkspace(); + if (info.protected && info.access_level === "none") lockWorkspaceForPassword("Enter the workspace password to continue."); + else await openWorkspace(); } catch (e) { if (e.status === 403 || e.status === 404) await showSystemNotFound(); else document.querySelector("#workspace-error").textContent = e.message; @@ -175,7 +264,7 @@ document.querySelector("#password-form").addEventListener("submit", async e => { accessToken = getAccessToken("workspace", slug); document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; - openWorkspace(); + await openWorkspace(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } }); workspacePasswordForm.addEventListener("submit", async event => { @@ -191,7 +280,7 @@ workspacePasswordForm.addEventListener("submit", async event => { try { await api(`/api/workspaces/${encodeURIComponent(slug)}/password`, { method: "POST", - body: JSON.stringify({ password }), + body: JSON.stringify({ password, client_id: workspaceWatchClientId }), }); const result = await api("/api/access-token", { method: "POST", @@ -278,5 +367,14 @@ identityDialog.addEventListener("close", () => { }); }); +dialog.addEventListener("cancel", event => { + if (workspaceLockedForPassword) { + event.preventDefault(); + document.querySelector("#open-password")?.focus(); + } +}); + +window.addEventListener("beforeunload", stopWorkspaceWatch); + setNotesView(notesView); startAuthorizedWorkspace();