diff --git a/Cargo.lock b/Cargo.lock index adb5059..b808555 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.21" +version = "0.2.24" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 9ee49e2..e05125e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.23" +version = "0.2.24" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/migrations/mysql/0012_share_link_tokens.sql b/migrations/mysql/0012_share_link_tokens.sql index 06e5bb3..6987712 100644 --- a/migrations/mysql/0012_share_link_tokens.sql +++ b/migrations/mysql/0012_share_link_tokens.sql @@ -1 +1,2 @@ -ALTER TABLE resource_share_links ADD COLUMN token TEXT NULL; +-- Legacy migration retained for numbering only. Plaintext share tokens are not stored. +SELECT 1; diff --git a/migrations/mysql/0026_share_link_sessions.sql b/migrations/mysql/0026_share_link_sessions.sql index 440e1f6..dd17ee8 100644 --- a/migrations/mysql/0026_share_link_sessions.sql +++ b/migrations/mysql/0026_share_link_sessions.sql @@ -1,4 +1,3 @@ -UPDATE resource_share_links SET token = NULL; CREATE TABLE resource_share_sessions ( session_token_hash VARCHAR(64) PRIMARY KEY, share_token_hash VARCHAR(64) NOT NULL, diff --git a/migrations/mysql/0027_share_link_labels.sql b/migrations/mysql/0027_share_link_labels.sql new file mode 100644 index 0000000..ac53d66 --- /dev/null +++ b/migrations/mysql/0027_share_link_labels.sql @@ -0,0 +1 @@ +ALTER TABLE resource_share_links ADD COLUMN label VARCHAR(120) NULL; diff --git a/migrations/postgres/0012_share_link_tokens.sql b/migrations/postgres/0012_share_link_tokens.sql index f1330f0..6987712 100644 --- a/migrations/postgres/0012_share_link_tokens.sql +++ b/migrations/postgres/0012_share_link_tokens.sql @@ -1 +1,2 @@ -ALTER TABLE resource_share_links ADD COLUMN token TEXT; +-- Legacy migration retained for numbering only. Plaintext share tokens are not stored. +SELECT 1; diff --git a/migrations/postgres/0026_share_link_sessions.sql b/migrations/postgres/0026_share_link_sessions.sql index f652cdf..370d6ef 100644 --- a/migrations/postgres/0026_share_link_sessions.sql +++ b/migrations/postgres/0026_share_link_sessions.sql @@ -1,4 +1,3 @@ -UPDATE resource_share_links SET token = NULL; CREATE TABLE resource_share_sessions ( session_token_hash TEXT PRIMARY KEY, share_token_hash TEXT NOT NULL REFERENCES resource_share_links(token_hash) ON DELETE CASCADE, diff --git a/migrations/postgres/0027_share_link_labels.sql b/migrations/postgres/0027_share_link_labels.sql new file mode 100644 index 0000000..7b21c04 --- /dev/null +++ b/migrations/postgres/0027_share_link_labels.sql @@ -0,0 +1 @@ +ALTER TABLE resource_share_links ADD COLUMN label TEXT; diff --git a/migrations/sqlite/0012_share_link_tokens.sql b/migrations/sqlite/0012_share_link_tokens.sql index f1330f0..6987712 100644 --- a/migrations/sqlite/0012_share_link_tokens.sql +++ b/migrations/sqlite/0012_share_link_tokens.sql @@ -1 +1,2 @@ -ALTER TABLE resource_share_links ADD COLUMN token TEXT; +-- Legacy migration retained for numbering only. Plaintext share tokens are not stored. +SELECT 1; diff --git a/migrations/sqlite/0026_share_link_sessions.sql b/migrations/sqlite/0026_share_link_sessions.sql index 2b28654..afe3aee 100644 --- a/migrations/sqlite/0026_share_link_sessions.sql +++ b/migrations/sqlite/0026_share_link_sessions.sql @@ -1,4 +1,3 @@ -UPDATE resource_share_links SET token = NULL; CREATE TABLE resource_share_sessions ( session_token_hash TEXT PRIMARY KEY, share_token_hash TEXT NOT NULL REFERENCES resource_share_links(token_hash) ON DELETE CASCADE, diff --git a/migrations/sqlite/0027_share_link_labels.sql b/migrations/sqlite/0027_share_link_labels.sql new file mode 100644 index 0000000..7b21c04 --- /dev/null +++ b/migrations/sqlite/0027_share_link_labels.sql @@ -0,0 +1 @@ +ALTER TABLE resource_share_links ADD COLUMN label TEXT; diff --git a/src/api/access_tokens.rs b/src/api/access_tokens.rs index f724c4a..13d01c0 100644 --- a/src/api/access_tokens.rs +++ b/src/api/access_tokens.rs @@ -96,25 +96,6 @@ pub async fn create_resource_access_token( Ok(response) } -pub async fn verify_resource_access_token( - state: &SharedState, - kind: &str, - slug: &str, - token: Option<&str>, -) -> Result { - let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(false); - }; - if crate::auth::share_access_permission(state, kind, slug, Some(token)) - .await - .map_err(|error| ApiError::forbidden(&error.message))? - .is_some() - { - return Ok(true); - } - verify_password_access_token(state, kind, slug, Some(token)).await -} - pub(crate) async fn verify_password_access_token( state: &SharedState, kind: &str, @@ -134,7 +115,22 @@ pub(crate) async fn verify_password_access_token( .bind(Utc::now().to_rfc3339()) .fetch_one(state.db.pool()) .await?; - Ok(count > 0) + if count == 0 { + return Ok(false); + } + + // Password-derived access must stop working when the resource no longer + // has a password. This also invalidates tokens created by older versions + // for private resources that never had a password configured. + match kind { + "workspace" => Ok(db::find_workspace(&state.db, slug) + .await? + .is_some_and(|workspace| workspace.password_hash.is_some())), + "pad" => Ok(db::find_pad(&state.db, slug) + .await? + .is_some_and(|pad| pad.password_hash.is_some())), + _ => Ok(false), + } } pub(super) fn hash_access_token(token: &str) -> String { diff --git a/src/app/pages.rs b/src/app/pages.rs index e7adbef..849daf9 100644 --- a/src/app/pages.rs +++ b/src/app/pages.rs @@ -86,7 +86,6 @@ async fn share_session_redirect( kind, slug, share, - crate::security::share_session_token(headers, kind, slug), &client_key, ) .await diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 67e1bec..8cf9059 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -40,6 +40,7 @@ use crate::{ const MIN_PASSWORD: usize = 8; const MAX_PASSWORD: usize = 128; const MAX_NICKNAME: usize = 40; +const MAX_SHARE_LINK_LABEL: usize = 120; #[derive(Debug, Clone)] pub struct User { @@ -149,6 +150,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for SharingUserRow { #[derive(Debug)] struct SharingLinkRow { token_hash: String, + label: Option, permission: String, expires_at: Option, created_at: String, @@ -158,9 +160,10 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for SharingLinkRow { fn from_row(row: &'r AnyRow) -> Result { Ok(Self { token_hash: crate::row_decode::text(row, 0)?, - permission: crate::row_decode::text(row, 1)?, - expires_at: crate::row_decode::optional_text(row, 2)?, - created_at: crate::row_decode::text(row, 3)?, + label: crate::row_decode::optional_text(row, 1)?, + permission: crate::row_decode::text(row, 2)?, + expires_at: crate::row_decode::optional_text(row, 3)?, + created_at: crate::row_decode::text(row, 4)?, }) } } @@ -293,6 +296,8 @@ pub struct RemoveShareRequest { pub struct CreateShareLinkRequest { kind: String, slug: String, + #[serde(default)] + label: Option, permission: String, expires_at: Option, } @@ -300,7 +305,9 @@ pub struct CreateShareLinkRequest { pub struct UpdateShareLinkRequest { kind: String, slug: String, - token: String, + token_hash: String, + #[serde(default)] + label: Option, permission: String, expires_at: Option, } @@ -308,7 +315,7 @@ pub struct UpdateShareLinkRequest { pub struct RevokeShareLinkRequest { kind: String, slug: String, - token: String, + token_hash: String, } #[derive(Serialize)] @@ -361,8 +368,6 @@ pub struct IdentityResponse { } #[derive(Serialize)] pub struct RegisterResponse { - #[serde(skip_serializing)] - token: Option, nickname: String, email: String, expires_at: Option, @@ -514,7 +519,6 @@ pub async fn register( return Ok(( StatusCode::CREATED, Json(RegisterResponse { - token: None, nickname: user.nickname, email: user.email, expires_at: None, @@ -534,7 +538,6 @@ pub async fn register( let mut response = ( StatusCode::CREATED, Json(RegisterResponse { - token: Some(session.token), nickname: session.nickname, email: session.email, expires_at: Some(session.expires_at), @@ -1441,6 +1444,40 @@ async fn ensure_owner( Ok(()) } +pub(crate) async fn resource_is_public_unprotected( + state: &SharedState, + kind: &str, + slug: &str, +) -> Result { + let slug = slug.trim(); + match kind { + "workspace" => Ok(crate::db::find_workspace(&state.db, slug) + .await + .map_err(AuthError::database)? + .is_some_and(|workspace| { + workspace.is_private == 0 && workspace.password_hash.is_none() + })), + "pad" => Ok(crate::db::find_pad(&state.db, slug) + .await + .map_err(AuthError::database)? + .is_some_and(|pad| pad.is_private == 0 && pad.password_hash.is_none())), + _ => Ok(false), + } +} + +async fn ensure_share_links_enabled( + state: &SharedState, + kind: &str, + slug: &str, +) -> Result<(), AuthError> { + if resource_is_public_unprotected(state, kind, slug).await? { + return Err(AuthError::conflict( + "Direct share links are disabled for public resources without a password.", + )); + } + Ok(()) +} + pub async fn set_resource_privacy( State(state): State, headers: HeaderMap, @@ -1706,6 +1743,7 @@ pub async fn resource_sharing( .get("slug") .ok_or_else(|| AuthError::bad_request("Missing slug."))?; ensure_owner(&state, owner.id, kind, slug).await?; + let share_links_enabled = !resource_is_public_unprotected(&state, kind, slug).await?; let users: Vec = sqlx::query_as(queries::get( state.db.kind(), queries::RESOURCE_SHARING_USERS, @@ -1715,15 +1753,19 @@ pub async fn resource_sharing( .fetch_all(state.db.pool()) .await .map_err(AuthError::database)?; - let links: Vec = sqlx::query_as(queries::get( - state.db.kind(), - queries::RESOURCE_SHARING_LINKS, - )) - .bind(kind) - .bind(slug) - .fetch_all(state.db.pool()) - .await - .map_err(AuthError::database)?; + let links: Vec = if share_links_enabled { + sqlx::query_as(queries::get( + state.db.kind(), + queries::RESOURCE_SHARING_LINKS, + )) + .bind(kind) + .bind(slug) + .fetch_all(state.db.pool()) + .await + .map_err(AuthError::database)? + } else { + Vec::new() + }; let pending: Vec = sqlx::query_as(queries::get( state.db.kind(), queries::RESOURCE_SHARING_PENDING, @@ -1734,7 +1776,7 @@ pub async fn resource_sharing( .await .map_err(AuthError::database)?; Ok(Json( - serde_json::json!({"users":users.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission})).collect::>(), "pending":pending.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission,"expires_at":row.expires_at})).collect::>(), "links":links.into_iter().map(|row|serde_json::json!({"token_hash":row.token_hash,"permission":row.permission,"expires_at":row.expires_at,"created_at":row.created_at})).collect::>() }), + serde_json::json!({"users":users.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission})).collect::>(), "pending":pending.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission,"expires_at":row.expires_at})).collect::>(), "links":links.into_iter().map(|row|serde_json::json!({"token_hash":row.token_hash,"label":row.label,"permission":row.permission,"expires_at":row.expires_at,"created_at":row.created_at})).collect::>(), "share_links_enabled":share_links_enabled }), )) } @@ -1745,12 +1787,15 @@ pub async fn create_share_link( ) -> Result { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; + ensure_share_links_enabled(&state, &req.kind, &req.slug).await?; let permission = validate_permission(&req.permission)?; + let label = normalize_share_link_label(req.label.as_deref())?; let expires_at = normalize_share_expiration(req.expires_at.as_deref())?; let token = random_token(); let token_hash = hash_token(&token); sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT)) - .bind(token_hash) + .bind(&token_hash) + .bind(&label) .bind(&req.kind) .bind(req.slug.trim()) .bind(permission) @@ -1765,7 +1810,7 @@ pub async fn create_share_link( format!("/p/{}", req.slug.trim()) }; let mut response = Json( - serde_json::json!({"token":token,"url":format!("{base}?share={token}"),"permission":permission,"expires_at":expires_at}), + serde_json::json!({"token_hash":token_hash,"url":format!("{base}?share={token}"),"label":label,"permission":permission,"expires_at":expires_at}), ) .into_response(); response.headers_mut().insert( @@ -1786,12 +1831,15 @@ pub async fn update_share_link( ) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; + ensure_share_links_enabled(&state, &req.kind, &req.slug).await?; let permission = validate_permission(&req.permission)?; + let label = normalize_share_link_label(req.label.as_deref())?; let expires_at = normalize_share_expiration(req.expires_at.as_deref())?; let result = sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_UPDATE)) + .bind(&label) .bind(permission) .bind(&expires_at) - .bind(req.token.trim()) + .bind(req.token_hash.trim()) .bind(&req.kind) .bind(req.slug.trim()) .execute(state.db.pool()) @@ -1803,7 +1851,7 @@ pub async fn update_share_link( )); } Ok(Json( - serde_json::json!({"ok":true,"permission":permission,"expires_at":expires_at}), + serde_json::json!({"ok":true,"label":label,"permission":permission,"expires_at":expires_at}), )) } @@ -1814,9 +1862,10 @@ pub async fn revoke_share_link( ) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; + ensure_share_links_enabled(&state, &req.kind, &req.slug).await?; sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_REVOKE)) .bind(Utc::now().to_rfc3339()) - .bind(req.token.trim()) + .bind(req.token_hash.trim()) .bind(&req.kind) .bind(req.slug.trim()) .execute(state.db.pool()) @@ -1826,7 +1875,7 @@ pub async fn revoke_share_link( state.db.kind(), queries::SHARE_SESSIONS_DELETE_BY_LINK, )) - .bind(req.token.trim()) + .bind(req.token_hash.trim()) .bind(&req.kind) .bind(req.slug.trim()) .execute(state.db.pool()) @@ -1842,6 +1891,24 @@ fn validate_permission(value: &str) -> Result<&str, AuthError> { } } +fn normalize_share_link_label(value: Option<&str>) -> Result, AuthError> { + let Some(value) = value else { + return Ok(None); + }; + let value = value.trim(); + if value.is_empty() { + return Ok(None); + } + if value.chars().count() > MAX_SHARE_LINK_LABEL + || value.chars().any(|character| character.is_control()) + { + return Err(AuthError::bad_request( + "Link label must contain at most 120 printable characters.", + )); + } + Ok(Some(value.to_owned())) +} + fn normalize_share_expiration(value: Option<&str>) -> Result, AuthError> { let Some(value) = value else { return Ok(None); @@ -1903,9 +1970,11 @@ pub async fn create_share_session( kind: &str, slug: &str, share_token: &str, - existing_session_token: Option<&str>, client_key: &str, ) -> Result, AuthError> { + if resource_is_public_unprotected(state, kind, slug).await? { + return Ok(None); + } let share_token = share_token.trim(); if !valid_share_token(share_token) || !matches!(kind, "workspace" | "pad") { return Ok(None); @@ -1939,15 +2008,6 @@ pub async fn create_share_session( warn!(kind, slug, "invalid share link permission in database"); return Ok(None); } - if source.permission == "ro" - && share_session_permission(state, kind, slug, existing_session_token) - .await? - .as_deref() - == Some("rw") - { - return Ok(None); - } - let session_limit = now + Duration::days(state.anonymous_access_token_ttl_days); let expires_at = match source.expires_at.as_deref() { Some(value) => match chrono::DateTime::parse_from_rfc3339(value) { @@ -2079,6 +2139,9 @@ pub async fn share_access_permission( slug: &str, token: Option<&str>, ) -> Result, AuthError> { + if resource_is_public_unprotected(state, kind, slug).await? { + return Ok(None); + } let permission = share_session_permission(state, kind, slug, token).await?; if permission.is_some() { return Ok(permission); diff --git a/src/db/mod.rs b/src/db/mod.rs index 546cbeb..89703e6 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -157,7 +157,7 @@ pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) &workspace.password_hash, password.filter(|value| !value.is_empty()), ) { - (None, _) => true, + (None, _) => false, (Some(hash), Some(password)) => PasswordHash::new(hash) .ok() .and_then(|parsed| { @@ -433,7 +433,7 @@ pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool { &pad.password_hash, password.filter(|value| !value.is_empty()), ) { - (None, _) => true, + (None, _) => false, (Some(hash), Some(password)) => PasswordHash::new(hash) .ok() .and_then(|parsed| { @@ -544,3 +544,57 @@ 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, + } + } + + 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(), + owner_map: "[]".into(), + 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/main.rs b/src/main.rs index 97e0ea2..3fc50a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -185,7 +185,7 @@ fn print_startup_credential() { fn startup_credential() -> String { format!( - "RustPad {}\nCopyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl", + "RustPad {}\nCopyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl\nLicense: https://git.linuxiarz.pl/gru/rustpad/src/branch/master/LICENSE.md", env!("CARGO_PKG_VERSION") ) } @@ -304,6 +304,9 @@ mod startup_tests { 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/queries/mysql.rs b/src/queries/mysql.rs index d37a110..1af5d08 100644 --- a/src/queries/mysql.rs +++ b/src/queries/mysql.rs @@ -233,16 +233,16 @@ pub fn get(query: Query) -> &'static str { r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"# } Query::RESOURCE_SHARING_LINKS => { - r#"SELECT token_hash, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at, CAST(created_at AS CHAR CHARACTER SET utf8mb4) AS created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"# + r#"SELECT token_hash, CAST(label AS CHAR CHARACTER SET utf8mb4) AS label, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at, CAST(created_at AS CHAR CHARACTER SET utf8mb4) AS created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"# } Query::RESOURCE_SHARING_PENDING => { r#"SELECT u.email, u.nickname, i.permission, CAST(i.expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"# } Query::SHARE_LINK_INSERT => { - r#"INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)"# + r#"INSERT INTO resource_share_links (token_hash, label, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"# } Query::SHARE_LINK_UPDATE => { - r#"UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"# + r#"UPDATE resource_share_links SET label = ?, permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"# } Query::SHARE_LINK_REVOKE => { r#"UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?"# diff --git a/src/queries/postgres.rs b/src/queries/postgres.rs index b49e2cc..b71f0ad 100644 --- a/src/queries/postgres.rs +++ b/src/queries/postgres.rs @@ -235,16 +235,16 @@ pub fn get(query: Query) -> &'static str { r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = $1 AND rp.resource_slug = $2 ORDER BY u.email"# } Query::RESOURCE_SHARING_LINKS => { - r#"SELECT token_hash, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = $1 AND resource_slug = $2 AND revoked_at IS NULL ORDER BY created_at DESC"# + r#"SELECT token_hash, label, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = $1 AND resource_slug = $2 AND revoked_at IS NULL ORDER BY created_at DESC"# } Query::RESOURCE_SHARING_PENDING => { r#"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = $1 AND i.resource_slug = $2 AND i.accepted_at IS NULL ORDER BY u.email"# } Query::SHARE_LINK_INSERT => { - r#"INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES ($1, $2, $3, $4, $5, $6)"# + r#"INSERT INTO resource_share_links (token_hash, label, resource_kind, resource_slug, permission, expires_at, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7)"# } Query::SHARE_LINK_UPDATE => { - r#"UPDATE resource_share_links SET permission = $1, expires_at = $2 WHERE token_hash = $3 AND resource_kind = $4 AND resource_slug = $5 AND revoked_at IS NULL"# + r#"UPDATE resource_share_links SET label = $1, permission = $2, expires_at = $3 WHERE token_hash = $4 AND resource_kind = $5 AND resource_slug = $6 AND revoked_at IS NULL"# } Query::SHARE_LINK_REVOKE => { r#"UPDATE resource_share_links SET revoked_at = $1 WHERE token_hash = $2 AND resource_kind = $3 AND resource_slug = $4"# diff --git a/src/queries/sqlite.rs b/src/queries/sqlite.rs index 354232a..8ab14b7 100644 --- a/src/queries/sqlite.rs +++ b/src/queries/sqlite.rs @@ -233,16 +233,16 @@ pub fn get(query: Query) -> &'static str { r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"# } Query::RESOURCE_SHARING_LINKS => { - r#"SELECT token_hash, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"# + r#"SELECT token_hash, label, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"# } Query::RESOURCE_SHARING_PENDING => { r#"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"# } Query::SHARE_LINK_INSERT => { - r#"INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)"# + r#"INSERT INTO resource_share_links (token_hash, label, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"# } Query::SHARE_LINK_UPDATE => { - r#"UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"# + r#"UPDATE resource_share_links SET label = ?, permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"# } Query::SHARE_LINK_REVOKE => { r#"UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?"# diff --git a/src/security.rs b/src/security.rs index e4534ad..825c37d 100644 --- a/src/security.rs +++ b/src/security.rs @@ -126,10 +126,6 @@ pub fn share_session_cookie( ) } -pub fn clear_share_session_cookie(kind: &str, slug: &str) -> HeaderValue { - clear_cookie(&share_session_cookie_name(kind, slug)) -} - pub fn client_key(headers: &HeaderMap) -> String { let forwarded_ip = header_ip(headers, "cf-connecting-ip") .or_else(|| header_ip(headers, "x-real-ip")) @@ -360,11 +356,6 @@ mod tests { assert!(value.contains("HttpOnly")); assert!(value.contains("Secure")); assert!(value.contains("SameSite=Lax")); - - let cleared = clear_share_session_cookie("workspace", "private-space") - .to_str() - .unwrap(); - assert!(cleared.contains("Max-Age=0")); } #[test] diff --git a/src/websocket/mod.rs b/src/websocket/mod.rs index 0aa9162..09dd750 100644 --- a/src/websocket/mod.rs +++ b/src/websocket/mod.rs @@ -249,15 +249,18 @@ async fn current_resource_access( access_tokens: &[Option<&str>], session_token: Option<&str>, password_ok: bool, - public_unprotected: bool, ) -> (bool, bool) { + if auth::resource_is_public_unprotected(state, kind, slug) + .await + .unwrap_or(false) + { + return (true, true); + } let permission = resource_permission_from_tokens(state, kind, slug, access_tokens, session_token).await; let password_token_ok = password_access_from_tokens(state, kind, slug, access_tokens).await; - let write_allowed = public_unprotected - || password_ok - || password_token_ok - || permission.as_deref() == Some("rw"); + let write_allowed = + password_ok || password_token_ok || permission.as_deref() == Some("rw"); let read_allowed = write_allowed || permission.as_deref() == Some("ro"); (read_allowed, write_allowed) } @@ -451,7 +454,6 @@ async fn handle_socket( let _ = send_error(&mut socket, "Invalid password").await; return; } - let public_unprotected = workspace.is_private == 0 && workspace.password_hash.is_none(); let (_, write_allowed) = current_resource_access( &state, "workspace", @@ -459,7 +461,6 @@ async fn handle_socket( &external_tokens, session_token.as_deref(), password_ok, - public_unprotected, ) .await; info!(workspace_id = workspace.id, note_id = note.id, nickname = ?nickname, "note websocket authenticated"); @@ -520,7 +521,6 @@ async fn handle_socket( &external_tokens, session_token.as_deref(), password_ok, - public_unprotected, ).await; if !read_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await; break; } if !current_write_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Read-only access".into()}).await; continue; } @@ -549,7 +549,6 @@ async fn handle_socket( &external_tokens, session_token.as_deref(), password_ok, - public_unprotected, ).await; if !read_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await; @@ -564,7 +563,6 @@ async fn handle_socket( &external_tokens, session_token.as_deref(), password_ok, - public_unprotected, ).await; if !read_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await; diff --git a/src/websocket/pad.rs b/src/websocket/pad.rs index 5e7094f..4c67f8a 100644 --- a/src/websocket/pad.rs +++ b/src/websocket/pad.rs @@ -218,7 +218,6 @@ async fn handle_pad_socket( .await; return; } - let public_unprotected = pad.is_private == 0 && pad.password_hash.is_none(); let (_, write_allowed) = current_resource_access( &state, "pad", @@ -226,7 +225,6 @@ async fn handle_pad_socket( &external_tokens, session_token.as_deref(), password_ok, - public_unprotected, ) .await; info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated"); @@ -286,7 +284,6 @@ async fn handle_pad_socket( &external_tokens, session_token.as_deref(), password_ok, - public_unprotected, ).await; if !read_allowed { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;break; } if !current_write_allowed{let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Read-only access".into()}).await;continue;} @@ -317,7 +314,6 @@ async fn handle_pad_socket( &external_tokens, session_token.as_deref(), password_ok, - public_unprotected, ).await; if !read_allowed { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await; @@ -332,7 +328,6 @@ async fn handle_pad_socket( &external_tokens, session_token.as_deref(), password_ok, - public_unprotected, ).await; if !read_allowed { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await; diff --git a/static/css/styles.css b/static/css/styles.css index c849385..44697df 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -3873,7 +3873,7 @@ dialog::backdrop { } .share-link-form { - grid-template-columns: minmax(140px, auto) minmax(110px, 140px) auto auto; + grid-template-columns: minmax(180px, 1fr) minmax(140px, auto) minmax(110px, 140px) auto auto; } .share-forever { @@ -5931,6 +5931,21 @@ dialog::backdrop { width: 100%; } +.share-link-label-edit { + display: grid; + gap: 5px; + margin-bottom: 6px; +} + +.share-link-label-edit>span { + color: var(--muted); + font-size: .78rem; +} + +.share-link-label-edit input { + width: min(100%, 520px); +} + @media (max-width: 800px) { .share-link-row { grid-template-columns: 1fr; @@ -6485,3 +6500,19 @@ dialog::backdrop { text-align: center; } } + +/* Share links: keep one-time URLs readable without storing plaintext tokens. */ +.share-link-id { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: .78rem; + font-weight: 600; +} + +.share-link-once { + color: var(--muted-2) !important; +} + +.form-message.warning { + color: var(--warning); +} diff --git a/static/js/home.js b/static/js/home.js index bd5add8..e55b616 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -121,6 +121,8 @@ function authHeaders() { return {}; } function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; } function shareExpiry(hours, forever) { if (forever) return null; const value = Number(hours); if (!Number.isFinite(value) || value <= 0 || value > 87600) throw new Error("Enter a validity between 1 and 87600 hours."); return new Date(Date.now() + value * 3600000).toISOString(); } function formatShareExpiry(value) { if (!value) return "Never expires"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Expires ${date.toLocaleString()}`; } +function formatShareCreated(value) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Created ${date.toLocaleString()}`; } +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` : ""; } @@ -166,6 +168,7 @@ async function loadResources() { } }); row.querySelector("[data-share]")?.addEventListener("click", async () => { + const initialShareLinksEnabled = Boolean(item.private || item.protected); const dialog = document.createElement("dialog"); dialog.className = "app-dialog share-dialog"; dialog.innerHTML = ` `; @@ -200,24 +204,65 @@ async function loadResources() { const linkForm = dialog.querySelector("[data-link-form]"); const userList = dialog.querySelector("[data-user-list]"); const linkList = dialog.querySelector("[data-link-list]"); + const directLinksEnabled = dialog.querySelector("[data-direct-links-enabled]"); + const directLinksDisabled = dialog.querySelector("[data-direct-links-disabled]"); + const createdLinkValues = new Map(); const syncForever = () => { linkForm.hours.disabled = linkForm.forever.checked; }; linkForm.forever.addEventListener("change", syncForever); syncForever(); const refresh = async () => { const d = await api(`/api/auth/resources/sharing?kind=${encodeURIComponent(item.kind)}&slug=${encodeURIComponent(item.slug)}`, { headers: authHeaders() }); + const shareLinksEnabled = d.share_links_enabled !== false; + directLinksEnabled.hidden = !shareLinksEnabled; + directLinksDisabled.hidden = shareLinksEnabled; userList.innerHTML = d.users.length ? d.users.map(u => ``).join("") : ''; - linkList.innerHTML = d.links.length ? d.links.map(link => { - const linkPreview = ''; - return ``; + linkList.innerHTML = shareLinksEnabled && d.links.length ? d.links.map(link => { + const identifier = shareLinkId(link.token_hash); + const visibleLink = createdLinkValues.get(link.token_hash); + const linkPreview = visibleLink + ? `` + : ``; + return ``; }).join("") : ''; userList.querySelectorAll("[data-remove-user]").forEach(button => button.addEventListener("click", async () => { try { button.disabled = true; await api("/api/auth/resources/sharing", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, email: button.dataset.removeUser }) }); setDialogMessage("Access removed.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); button.disabled = false; } })); - linkList.querySelectorAll("[data-link-token]").forEach(linkRow => { + linkList.querySelectorAll("[data-link-token-hash]").forEach(linkRow => { const forever = linkRow.elements.forever, hours = linkRow.elements.hours; const sync = () => { hours.disabled = forever.checked; }; forever.addEventListener("change", sync); sync(); - linkRow.addEventListener("submit", async event => { event.preventDefault(); try { const expires_at = shareExpiry(hours.value, forever.checked); await api("/api/auth/resources/share-links", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token: linkRow.dataset.linkToken, permission: linkRow.elements.permission.value, expires_at }) }); setDialogMessage("Link updated.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } }); - linkRow.querySelector("[data-revoke-link]").addEventListener("click", async () => { try { await api("/api/auth/resources/share-links", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token: linkRow.dataset.linkToken }) }); setDialogMessage("Link revoked.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } }); + linkRow.querySelector("[data-copy-link]")?.addEventListener("click", async event => { + const button = event.currentTarget; + const input = linkRow.querySelector(".share-link-inline input"); + try { + button.disabled = true; + await copyText(input.value); + setDialogMessage("Link copied.", "success"); + } catch (err) { + setDialogMessage(err.message, "error"); + } finally { + button.disabled = false; + } + }); + linkRow.addEventListener("submit", async event => { event.preventDefault(); try { const expires_at = shareExpiry(hours.value, forever.checked); await api("/api/auth/resources/share-links", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token_hash: linkRow.dataset.linkTokenHash, label: linkRow.elements.label.value, permission: linkRow.elements.permission.value, expires_at }) }); setDialogMessage("Link updated.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } }); + linkRow.querySelector("[data-revoke-link]").addEventListener("click", async () => { try { await api("/api/auth/resources/share-links", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token_hash: linkRow.dataset.linkTokenHash }) }); createdLinkValues.delete(linkRow.dataset.linkTokenHash); setDialogMessage("Link revoked.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } }); }); }; userForm.addEventListener("submit", async event => { event.preventDefault(); try { const result = await api("/api/auth/resources/sharing", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, recipients: userForm.recipients.value, permission: userForm.permission.value }) }); userForm.recipients.value = ""; setDialogMessage(result.confirmation_required ? "Invitation sent. Access will appear after the recipient accepts it." : "Access granted.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } }); - linkForm.addEventListener("submit", async event => { event.preventDefault(); try { const expires_at = shareExpiry(linkForm.hours.value, linkForm.forever.checked); const result = await api("/api/auth/resources/share-links", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, permission: linkForm.permission.value, expires_at }) }); const absolute = new URL(result.url, location.origin).href; await copyText(absolute); setDialogMessage("Link created and copied. For security, it is shown only once.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } }); + linkForm.addEventListener("submit", async event => { + event.preventDefault(); + try { + const expires_at = shareExpiry(linkForm.hours.value, linkForm.forever.checked); + const result = await api("/api/auth/resources/share-links", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, label: linkForm.label.value, permission: linkForm.permission.value, expires_at }) }); + const absolute = new URL(result.url, location.origin).href; + createdLinkValues.set(result.token_hash, absolute); + linkForm.label.value = ""; + await refresh(); + try { + await copyText(absolute); + setDialogMessage("Link created, displayed below and copied.", "success"); + } catch { + setDialogMessage("Link created and displayed below. Automatic copying failed; use the Copy button.", "warning"); + } + } catch (err) { + setDialogMessage(err.message, "error"); + } + }); dialog.showModal(); try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } }); diff --git a/tests/share_session_sql_test.py b/tests/share_session_sql_test.py index 2ac6991..d8f8411 100755 --- a/tests/share_session_sql_test.py +++ b/tests/share_session_sql_test.py @@ -60,6 +60,7 @@ def main() -> None: migrations = sorted(MIGRATIONS.glob("*.sql")) before_sessions = [path for path in migrations if path.name < "0026_share_link_sessions.sql"] session_migration = MIGRATIONS / "0026_share_link_sessions.sql" + label_migration = MIGRATIONS / "0027_share_link_labels.sql" db = sqlite3.connect(":memory:") db.execute("PRAGMA foreign_keys = ON") @@ -74,14 +75,15 @@ def main() -> None: share_hash = hashlib.sha256(raw_link.encode()).hexdigest() db.execute( "INSERT INTO resource_share_links " - "(token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) " - "VALUES (?, ?, 'workspace', 'private-space', 'ro', NULL, 1)", - (share_hash, raw_link), + "(token_hash, resource_kind, resource_slug, permission, expires_at, created_by) " + "VALUES (?, 'workspace', 'private-space', 'ro', NULL, 1)", + (share_hash,), ) db.executescript(session_migration.read_text()) - assert db.execute( - "SELECT token FROM resource_share_links WHERE token_hash = ?", (share_hash,) - ).fetchone() == (None,), "migration must erase persisted plaintext share tokens" + db.executescript(label_migration.read_text()) + columns = {row[1] for row in db.execute("PRAGMA table_info(resource_share_links)")} + assert "token" not in columns, "fresh schema must not contain a plaintext token column" + assert "label" in columns, "share links must support labels" source_sql = query("SHARE_LINK_SESSION_SOURCE") insert_sql = query("SHARE_SESSION_INSERT") @@ -162,23 +164,24 @@ def main() -> None: ).rowcount assert deleted == 1, "revoking a link must remove all derived sessions" - # Newly created links persist only the hash; the plaintext column stays NULL. + # Newly created links persist only the hash and an optional identifying label. second_raw = "one-time-returned-token" second_hash = hashlib.sha256(second_raw.encode()).hexdigest() db.execute( link_insert_sql, - (second_hash, "workspace", "second-space", "rw", None, 1), + (second_hash, "QA link", "workspace", "second-space", "rw", None, 1), ) assert db.execute( - "SELECT token, permission FROM resource_share_links WHERE token_hash = ?", (second_hash,) - ).fetchone() == (None, "rw") + "SELECT label, permission FROM resource_share_links WHERE token_hash = ?", (second_hash,) + ).fetchone() == ("QA link", "rw") listed_link = db.execute(sharing_list_sql, ("workspace", "second-space")).fetchone() assert listed_link == ( second_hash, + "QA link", "rw", None, - listed_link[3], - ), "management listing must never return the plaintext share token" + listed_link[4], + ), "management listing must return the label but never a plaintext share token" db.execute( insert_sql, diff --git a/tests/share_session_static_test.py b/tests/share_session_static_test.py index 96a5252..62afdc6 100755 --- a/tests/share_session_static_test.py +++ b/tests/share_session_static_test.py @@ -25,7 +25,8 @@ def query_sql(backend: str, name: str) -> str: def main() -> None: names = { - "SHARE_LINK_INSERT": 6, + "SHARE_LINK_INSERT": 7, + "SHARE_LINK_UPDATE": 6, "SHARE_LINK_PERMISSION": 3, "SHARE_LINK_SESSION_SOURCE": 3, "SHARE_SESSION_INSERT": 5, @@ -43,6 +44,8 @@ def main() -> None: assert sorted(set(parameters)) == list(range(1, expected + 1)), (name, sql) auth = read("src/auth/mod.rs") + register_response = auth[auth.index("pub struct RegisterResponse"):auth.index("pub async fn identity")] + assert "token:" not in register_response assert ".bind(&token)" not in auth[auth.index("pub async fn create_share_link"):auth.index("pub async fn update_share_link")] assert '"token":row.token' not in auth assert "token: Option" not in auth[auth.index("struct SharingLinkRow"):auth.index("struct ShareLinkSessionSource")] @@ -55,10 +58,29 @@ def main() -> None: assert 'format!("share-session-client:{client_key}")' in auth create_link = auth[auth.index("pub async fn create_share_link"):auth.index("pub async fn update_share_link")] assert "no-store, max-age=0" in create_link + assert '"token_hash":token_hash' in create_link + assert '"token":token' not in create_link + assert '"label":label' in create_link create_session = auth[auth.index("pub async fn create_share_session"):auth.index("async fn share_session_permission")] - assert "existing_session_token" in create_session - assert 'source.permission == "ro"' in create_session - assert '== Some("rw")' in create_session + assert "existing_session_token" not in create_session + assert 'source.permission == "ro"' not in create_session + assert "resource_is_public_unprotected(state, kind, slug).await?" in create_session + assert create_session.index("resource_is_public_unprotected") < create_session.index("valid_share_token") + create_link = auth[auth.index("pub async fn create_share_link"):auth.index("pub async fn update_share_link")] + update_link = auth[auth.index("pub async fn update_share_link"):auth.index("pub async fn revoke_share_link")] + revoke_link = auth[auth.index("pub async fn revoke_share_link"):auth.index("fn validate_permission")] + for management_handler in (create_link, update_link, revoke_link): + assert "ensure_share_links_enabled" in management_handler + assert "Direct share links are disabled for public resources without a password." in auth + share_access = auth[auth.index("pub async fn share_access_permission"):auth.index("pub async fn share_link_permission")] + assert "resource_is_public_unprotected(state, kind, slug).await?" in share_access + sharing = auth[auth.index("pub async fn resource_sharing"):auth.index("pub async fn create_share_link")] + assert '"share_links_enabled":share_links_enabled' in sharing + assert "if share_links_enabled" in sharing + assert "Vec::new()" in sharing + privacy = auth[auth.index("pub async fn set_resource_privacy"):auth.index("pub async fn share_resource_users")] + assert "SHARE_LINK" not in privacy + assert "SHARE_SESSION" not in privacy api = read("src/api/mod.rs") assert "combined_token_access_level" not in api @@ -68,7 +90,16 @@ def main() -> None: access_tokens = read("src/api/access_tokens.rs") assert "verify_password_access_token" in access_tokens - assert "share_access_permission" in access_tokens + assert "verify_resource_access_token" not in access_tokens + assert "workspace.password_hash.is_some()" in access_tokens + assert "pad.password_hash.is_some()" in access_tokens + + database = read("src/db/mod.rs") + workspace_password = database[database.index("pub fn verify_workspace_password"):database.index("pub async fn list_notes")] + pad_password = database[database.index("pub fn verify_pad_password"):database.index("pub async fn save_pad_revision")] + assert "(None, _) => false" in workspace_password + assert "(None, _) => false" in pad_password + assert "missing_password_does_not_grant_password_access" in database websocket = read("src/websocket/mod.rs") + read("src/websocket/pad.rs") assert "cookie_share_session_token" in websocket @@ -79,6 +110,9 @@ def main() -> None: assert "access_refresh" in websocket assert websocket.count("update=updates.recv()=>{") == 2 assert websocket.count('message:"Access expired or revoked"') >= 6 + current_access = read("src/websocket/mod.rs")[read("src/websocket/mod.rs").index("async fn current_resource_access"):read("src/websocket/mod.rs").index("// Merged from note.rs")] + assert "resource_is_public_unprotected(state, kind, slug)" in current_access + assert "public_unprotected: bool" not in current_access pages = read("src/app/pages.rs") assert "canonical_resource_url" in pages @@ -95,20 +129,40 @@ def main() -> None: security = read("src/security.rs") assert "__Host-rustpad_share_" in security assert "HttpOnly; Secure; SameSite=Lax" in security + assert "clear_share_session_cookie" not in security for backend in ("sqlite", "postgres", "mysql"): sharing_list = query_sql(backend, "RESOURCE_SHARING_LINKS") assert not re.search(r"(?:^|,)\s*(?:CAST\()?token\b", sharing_list) + legacy_migration = read(f"migrations/{backend}/0012_share_link_tokens.sql") + assert "ADD COLUMN token" not in legacy_migration migration = read(f"migrations/{backend}/0026_share_link_sessions.sql") - assert "UPDATE resource_share_links SET token = NULL" in migration + assert "resource_share_links SET token" not in migration assert "session_token_hash" in migration assert "ON DELETE CASCADE" in migration + label_migration = read(f"migrations/{backend}/0027_share_link_labels.sql") + assert "ADD COLUMN label" in label_migration + + home_js = read("static/js/home.js") + assert "createdLinkValues" in home_js + assert "result.token_hash" in home_js + assert "data-copy-link" in home_js + assert "The full address is not stored" in home_js + assert 'name="label"' in home_js + assert "token_hash: linkRow.dataset.linkTokenHash" in home_js + assert "Link created, displayed below and copied." in home_js + assert "data-direct-links-disabled" in home_js + assert "d.share_links_enabled !== false" in home_js + assert "Existing links are preserved and become active again" in home_js workspace_js = read("static/js/workspace.js") note_js = read("static/js/note-editor.js") assert 'info.access_level === "none"' in workspace_js assert note_js.count('info.access_level === "none"') >= 2 + main_rs = read("src/main.rs") + assert "https://git.linuxiarz.pl/gru/rustpad/src/branch/master/LICENSE.md" in main_rs + app = read("src/app/mod.rs") assert "PathOnlyMakeSpan" in app assert "request.uri().path()" in app diff --git a/tests/share_url_test.mjs b/tests/share_url_test.mjs index 8da27d7..9da4bab 100644 --- a/tests/share_url_test.mjs +++ b/tests/share_url_test.mjs @@ -2,66 +2,89 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -globalThis.location = { origin: "https://pad.example" }; +const origin = "https://pad.example"; +let currentUrl; +let historyCall; +let dispatchedEvent; + +globalThis.CustomEvent = class CustomEvent { + constructor(type, init = {}) { + this.type = type; + this.detail = init.detail; + } +}; + +globalThis.window = { + location: null, + history: { + pushState(state, _title, url) { + historyCall = { method: "pushState", state }; + setLocation(url); + }, + replaceState(state, _title, url) { + historyCall = { method: "replaceState", state }; + setLocation(url); + }, + }, + dispatchEvent(event) { + dispatchedEvent = event; + }, +}; + +function setLocation(value) { + currentUrl = new URL(value, origin); + window.location = { + get href() { return currentUrl.href; }, + get search() { return currentUrl.search; }, + }; +} + +function resetWindow(value) { + setLocation(value); + historyCall = undefined; + dispatchedEvent = undefined; +} const source = await readFile(new URL("../static/js/url-state.js", import.meta.url), "utf8"); const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`; -const { editorResourceUrl, withShareToken } = await import(moduleUrl); +const { currentShareUrl, readEditorState, writeEditorState } = await import(moduleUrl); -function parsed(path) { - return new URL(path, "https://pad.example"); -} - -test("workspace share token is preserved when opening a note", () => { - const url = parsed(editorResourceUrl("/w/private/n/first", { - shareToken: "share-token-123", - view: "split", - mode: "markdown", - })); - - assert.equal(url.pathname, "/w/private/n/first"); - assert.equal(url.searchParams.get("share"), "share-token-123"); - assert.equal(url.searchParams.get("view"), "split"); - assert.equal(url.searchParams.get("mode"), "markdown"); +test("editor state reads supported values", () => { + resetWindow("/w/demo/n/note?view=preview&mode=text"); + assert.deepEqual(readEditorState(), { view: "preview", mode: "text" }); }); -test("share token is preserved when returning to the workspace", () => { - const url = parsed(withShareToken("/w/private", "share-token-123")); - - assert.equal(url.pathname, "/w/private"); - assert.equal(url.searchParams.get("share"), "share-token-123"); +test("editor state rejects unsupported values", () => { + resetWindow("/w/demo/n/note?view=invalid&mode=html"); + assert.deepEqual(readEditorState(), { view: "split", mode: "markdown" }); }); -test("existing query and hash survive share-aware navigation", () => { - const url = parsed(editorResourceUrl("/w/private/n/first?mode=text#section", { - shareToken: "new-token", - view: "preview", - mode: "markdown", - })); +test("writing editor state preserves unrelated query and hash", () => { + resetWindow("/w/demo/n/note?filter=recent#section"); + const result = new URL(writeEditorState({ view: "edit", mode: "text" })); - assert.equal(url.searchParams.get("share"), "new-token"); - assert.equal(url.searchParams.get("view"), "preview"); - assert.equal(url.searchParams.get("mode"), "markdown"); - assert.equal(url.hash, "#section"); + assert.equal(result.searchParams.get("filter"), "recent"); + assert.equal(result.searchParams.get("view"), "edit"); + assert.equal(result.searchParams.get("mode"), "text"); + assert.equal(result.hash, "#section"); + assert.equal(historyCall.method, "pushState"); + assert.deepEqual(historyCall.state, { view: "edit", mode: "text" }); + assert.equal(dispatchedEvent.type, "rustpad:urlchange"); }); -test("ordinary workspace navigation does not gain or retain a share token", () => { - const url = parsed(editorResourceUrl("/w/public/n/first?share=stale-token", { - view: "split", - mode: "markdown", - })); - - assert.equal(url.searchParams.has("share"), false); +test("replace mode uses replaceState", () => { + resetWindow("/p/demo"); + writeEditorState({ view: "split", mode: "markdown" }, { replace: true }); + assert.equal(historyCall.method, "replaceState"); }); +test("current share URL adds view state without inventing access tokens", () => { + resetWindow("/w/demo/n/note?filter=recent#section"); + const result = new URL(currentShareUrl({ view: "preview", mode: "markdown" })); -test("share-aware helpers reject external application URLs", () => { - assert.equal(withShareToken("https://example.com/steal", "secret"), "/"); -}); - - -test("share-aware helpers accept same-origin absolute URLs", () => { - const url = parsed(withShareToken("https://pad.example/w/private", "share-token-123")); - assert.equal(url.pathname, "/w/private"); - assert.equal(url.searchParams.get("share"), "share-token-123"); + assert.equal(result.searchParams.get("filter"), "recent"); + assert.equal(result.searchParams.get("view"), "preview"); + assert.equal(result.searchParams.get("mode"), "markdown"); + assert.equal(result.searchParams.has("share"), false); + assert.equal(result.hash, "#section"); });