From e3ee6319b98d3d7545577a4123c7a5f663d8be04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 3 Aug 2026 01:35:05 +0200 Subject: [PATCH] fix2 tokens --- migrations/mysql/0026_share_link_sessions.sql | 12 + .../postgres/0026_share_link_sessions.sql | 11 + .../sqlite/0026_share_link_sessions.sql | 11 + src/api/access_tokens.rs | 14 +- src/api/files.rs | 9 +- src/api/mod.rs | 154 ++++++-- src/api/pads_public.rs | 22 +- src/app/mod.rs | 23 +- src/app/pages.rs | 256 ++++++++++++-- src/auth/mod.rs | 330 ++++++++++++++++-- src/main.rs | 18 + src/queries/mod.rs | 15 + src/queries/mysql.rs | 21 +- src/queries/postgres.rs | 21 +- src/queries/sqlite.rs | 21 +- src/security.rs | 49 +++ src/websocket/mod.rs | 173 ++++++--- src/websocket/pad.rs | 91 ++++- static/js/home.js | 6 +- static/js/note-api.js | 4 +- static/js/note-editor.js | 8 +- static/js/url-state.js | 34 -- static/js/workspace.js | 12 +- tests/share_session_sql_test.py | 207 +++++++++++ tests/share_session_static_test.py | 121 +++++++ 25 files changed, 1420 insertions(+), 223 deletions(-) create mode 100644 migrations/mysql/0026_share_link_sessions.sql create mode 100644 migrations/postgres/0026_share_link_sessions.sql create mode 100644 migrations/sqlite/0026_share_link_sessions.sql create mode 100755 tests/share_session_sql_test.py create mode 100755 tests/share_session_static_test.py diff --git a/migrations/mysql/0026_share_link_sessions.sql b/migrations/mysql/0026_share_link_sessions.sql new file mode 100644 index 0000000..440e1f6 --- /dev/null +++ b/migrations/mysql/0026_share_link_sessions.sql @@ -0,0 +1,12 @@ +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, + resource_kind VARCHAR(16) NOT NULL, + resource_slug VARCHAR(255) NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT fk_resource_share_sessions_link FOREIGN KEY(share_token_hash) REFERENCES resource_share_links(token_hash) ON DELETE CASCADE +); +CREATE INDEX idx_resource_share_sessions_link ON resource_share_sessions(share_token_hash); +CREATE INDEX idx_resource_share_sessions_expiry ON resource_share_sessions(expires_at(32)); diff --git a/migrations/postgres/0026_share_link_sessions.sql b/migrations/postgres/0026_share_link_sessions.sql new file mode 100644 index 0000000..f652cdf --- /dev/null +++ b/migrations/postgres/0026_share_link_sessions.sql @@ -0,0 +1,11 @@ +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, + resource_kind TEXT NOT NULL, + resource_slug TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) +); +CREATE INDEX idx_resource_share_sessions_link ON resource_share_sessions(share_token_hash); +CREATE INDEX idx_resource_share_sessions_expiry ON resource_share_sessions(expires_at); diff --git a/migrations/sqlite/0026_share_link_sessions.sql b/migrations/sqlite/0026_share_link_sessions.sql new file mode 100644 index 0000000..2b28654 --- /dev/null +++ b/migrations/sqlite/0026_share_link_sessions.sql @@ -0,0 +1,11 @@ +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, + resource_kind TEXT NOT NULL, + resource_slug TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_resource_share_sessions_link ON resource_share_sessions(share_token_hash); +CREATE INDEX idx_resource_share_sessions_expiry ON resource_share_sessions(expires_at); diff --git a/src/api/access_tokens.rs b/src/api/access_tokens.rs index 1e9c364..f724c4a 100644 --- a/src/api/access_tokens.rs +++ b/src/api/access_tokens.rs @@ -105,13 +105,25 @@ pub async fn verify_resource_access_token( let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(false); }; - if crate::auth::share_link_permission(state, kind, slug, Some(token)) + 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, + 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, diff --git a/src/api/files.rs b/src/api/files.rs index f4a9d12..ccfc8c0 100644 --- a/src/api/files.rs +++ b/src/api/files.rs @@ -66,8 +66,9 @@ pub async fn upload_pad_file( { AccessLevel::Write } else { - combined_token_access_level( + request_access_level( &state, + &headers, "pad", &slug, resource_request_token(&headers, "pad", &slug, access_token.as_deref()), @@ -306,8 +307,9 @@ pub async fn upload_note_file( { AccessLevel::Write } else { - combined_token_access_level( + request_access_level( &state, + &headers, "workspace", &workspace_slug, resource_request_token(&headers, "workspace", &workspace_slug, access_token.as_deref()), @@ -387,8 +389,9 @@ pub async fn delete_note( { AccessLevel::Write } else { - combined_token_access_level( + request_access_level( &state, + &headers, "workspace", &workspace_slug, resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()), diff --git a/src/api/mod.rs b/src/api/mod.rs index da24495..3956abe 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -119,23 +119,19 @@ async fn has_write_permission( kind: &str, slug: &str, ) -> Result { - let resource = crate::security::resource_token(headers, kind, slug); - if external_token_access_level(state, kind, slug, resource).await? >= AccessLevel::Write { - return Ok(true); - } - let authorization = authorization_token(headers); - if authorization != resource - && external_token_access_level(state, kind, slug, authorization).await? - >= AccessLevel::Write + if request_access_level( + state, + headers, + kind, + slug, + None, + crate::security::session_cookie_token(headers), + ) + .await? + >= AccessLevel::Write { return Ok(true); } - let session = crate::security::session_cookie_token(headers); - if session != resource && session != authorization { - if account_token_access_level(state, kind, slug, session).await? >= AccessLevel::Write { - return Ok(true); - } - } match kind { "workspace" => Ok(db::find_workspace(&state.db, slug) .await? @@ -275,6 +271,7 @@ pub struct WorkspaceInfo { slug: String, title: String, protected: bool, + access_level: String, created_at: String, updated_at: String, } @@ -332,6 +329,7 @@ pub struct NoteInfo { slug: String, title: String, protected: bool, + access_level: String, note_protected: bool, allow_public_task_updates: bool, public_page_unprotected: bool, @@ -570,7 +568,16 @@ pub async fn workspace_info( workspace.is_private, ) .await?; - Ok(Json(workspace_info_from(&workspace))) + let access_level = effective_header_access_level( + &state, + &headers, + "workspace", + &workspace.slug, + workspace.is_private, + workspace.password_hash.is_some(), + ) + .await?; + Ok(Json(workspace_info_from(&workspace, access_level))) } pub async fn open_workspace( @@ -636,8 +643,20 @@ pub async fn open_workspace( let start = (page - 1) * per_page; let notes = notes.into_iter().skip(start).take(per_page).collect(); + let mut access_level = effective_header_access_level( + &state, + &headers, + "workspace", + &workspace.slug, + workspace.is_private, + workspace.password_hash.is_some(), + ) + .await?; + if db::verify_workspace_password(&workspace, payload.password.as_deref()) { + access_level = AccessLevel::Write; + } Ok(Json(WorkspaceOpenResponse { - workspace: workspace_info_from(&workspace), + workspace: workspace_info_from(&workspace, access_level), notes, pagination: ListPaginationMeta { page, per_page, total, total_pages }, })) @@ -668,8 +687,9 @@ pub async fn create_note( { AccessLevel::Write } else { - combined_token_access_level( + request_access_level( &state, + &headers, "workspace", &workspace_slug, resource_request_token( @@ -845,6 +865,15 @@ pub async fn note_info( workspace.is_private, ) .await?; + let access_level = effective_header_access_level( + &state, + &headers, + "workspace", + &workspace.slug, + workspace.is_private, + workspace.password_hash.is_some(), + ) + .await?; let note = db::find_note(&state.db, workspace.id, ¬e_slug) .await? .ok_or_else(ApiError::not_found_note)?; @@ -888,6 +917,7 @@ pub async fn note_info( slug: note.slug, title: note.title, protected: workspace.password_hash.is_some(), + access_level: access_level_name(access_level).into(), note_protected: note.protected, allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?, public_page_unprotected: db::note_public_page_unprotected(&state.db, note.id).await?, @@ -1001,8 +1031,9 @@ pub async fn restore( { AccessLevel::Write } else { - combined_token_access_level( + request_access_level( &state, + &headers, "workspace", &workspace_slug, resource_request_token( @@ -1105,7 +1136,7 @@ async fn external_token_access_level( slug: &str, token: Option<&str>, ) -> Result { - let permission = crate::auth::share_link_permission(state, kind, slug, token) + let permission = crate::auth::share_access_permission(state, kind, slug, token) .await .map_err(|error| ApiError::forbidden(&error.message))?; let level = permission_level(permission.as_deref()); @@ -1132,17 +1163,73 @@ async fn account_token_access_level( Ok(permission_level(permission.as_deref())) } -async fn combined_token_access_level( +async fn request_access_level( state: &SharedState, + headers: &HeaderMap, kind: &str, slug: &str, - access_token: Option<&str>, + supplied_access_token: Option<&str>, account_token: Option<&str>, ) -> Result { - Ok(std::cmp::max( - external_token_access_level(state, kind, slug, access_token).await?, - account_token_access_level(state, kind, slug, account_token).await?, - )) + let mut level = account_token_access_level(state, kind, slug, account_token).await?; + if level == AccessLevel::Write { + return Ok(level); + } + + let mut checked_tokens = Vec::with_capacity(4); + for token in [ + supplied_access_token, + crate::security::share_session_token(headers, kind, slug), + crate::security::resource_token(headers, kind, slug), + authorization_token(headers), + ] { + let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else { + continue; + }; + if checked_tokens.contains(&token) { + continue; + } + checked_tokens.push(token); + level = std::cmp::max( + level, + external_token_access_level(state, kind, slug, Some(token)).await?, + ); + if level == AccessLevel::Write { + break; + } + } + Ok(level) +} + +fn access_level_name(level: AccessLevel) -> &'static str { + match level { + AccessLevel::None => "none", + AccessLevel::Read => "read", + AccessLevel::Write => "write", + } +} + +async fn effective_header_access_level( + state: &SharedState, + headers: &HeaderMap, + kind: &str, + slug: &str, + is_private: i64, + password_protected: bool, +) -> Result { + let mut level = request_access_level( + state, + headers, + kind, + slug, + None, + bearer_token(headers), + ) + .await?; + if is_private == 0 && !password_protected { + level = std::cmp::max(level, AccessLevel::Write); + } + Ok(level) } fn require_write(level: AccessLevel) -> Result<(), ApiError> { @@ -1159,18 +1246,12 @@ async fn has_header_resource_access( kind: &str, slug: &str, ) -> Result { - for token in [ - crate::security::resource_token(headers, kind, slug), - authorization_token(headers), - ] { - if external_token_access_level(state, kind, slug, token).await? != AccessLevel::None { - return Ok(true); - } - } - Ok(account_token_access_level( + Ok(request_access_level( state, + headers, kind, slug, + None, crate::security::session_cookie_token(headers), ) .await? @@ -1241,7 +1322,7 @@ pub async fn authorized_workspace( .await? .ok_or_else(ApiError::not_found_workspace)?; let token_level = - combined_token_access_level(state, "workspace", slug, access_token, bearer).await?; + request_access_level(state, headers, "workspace", slug, access_token, bearer).await?; if workspace.is_private != 0 && token_level == AccessLevel::None { return Err(ApiError::not_found_workspace()); } @@ -1280,11 +1361,12 @@ async fn authorized_note( Ok((workspace, note)) } -fn workspace_info_from(workspace: &db::Workspace) -> WorkspaceInfo { +fn workspace_info_from(workspace: &db::Workspace, access_level: AccessLevel) -> WorkspaceInfo { WorkspaceInfo { slug: workspace.slug.clone(), title: workspace.title.clone(), protected: workspace.password_hash.is_some(), + access_level: access_level_name(access_level).into(), created_at: db::normalize_timestamp(&workspace.created_at), updated_at: db::normalize_timestamp(&workspace.updated_at), } diff --git a/src/api/pads_public.rs b/src/api/pads_public.rs index ac3851c..5b994e5 100644 --- a/src/api/pads_public.rs +++ b/src/api/pads_public.rs @@ -29,6 +29,7 @@ pub struct PadInfo { slug: String, title: String, protected: bool, + access_level: String, allow_public_task_updates: bool, public_page_unprotected: bool, public_page_enabled: bool, @@ -106,6 +107,15 @@ pub async fn pad_info( .await? .ok_or_else(ApiError::not_found_note)?; ensure_private_resource_access(&state, &headers, "pad", &pad.slug, pad.is_private).await?; + let access_level = effective_header_access_level( + &state, + &headers, + "pad", + &pad.slug, + pad.is_private, + pad.password_hash.is_some(), + ) + .await?; let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?; let (editor_preferences, personal_editor_settings) = user_editor_preferences(&state, &headers, db::EditorPreferenceResource::Pad(pad.id)) @@ -133,6 +143,7 @@ pub async fn pad_info( slug: pad.slug, title: pad.title, protected: pad.password_hash.is_some(), + access_level: access_level_name(access_level).into(), allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?, public_page_unprotected: db::pad_public_page_unprotected(&state.db, pad.id).await?, public_page_enabled: db::pad_public_page_enabled(&state.db, pad.id).await?, @@ -256,8 +267,9 @@ pub async fn publish_pad_page( { AccessLevel::Write } else { - combined_token_access_level( + request_access_level( &state, + &headers, "pad", &slug, resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()), @@ -311,8 +323,9 @@ pub async fn publish_note_page( { AccessLevel::Write } else { - combined_token_access_level( + request_access_level( &state, + &headers, "workspace", &workspace_slug, resource_request_token( @@ -527,8 +540,9 @@ pub async fn pad_restore( { AccessLevel::Write } else { - combined_token_access_level( + request_access_level( &state, + &headers, "pad", &slug, resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()), @@ -577,7 +591,7 @@ pub(super) async fn authorized_pad( let pad = db::find_pad(&state.db, slug) .await? .ok_or_else(ApiError::not_found_note)?; - let token_level = combined_token_access_level(state, "pad", slug, access_token, bearer).await?; + let token_level = request_access_level(state, headers, "pad", slug, access_token, bearer).await?; if pad.is_private != 0 && token_level == AccessLevel::None { return Err(ApiError::not_found_note()); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 78d79eb..92570ba 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -19,11 +19,30 @@ use axum::{ }; use pages::*; use tower::{ServiceBuilder, service_fn}; -use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer}; +use tower_http::{ + services::ServeDir, + set_header::SetResponseHeaderLayer, + trace::{MakeSpan, TraceLayer}, +}; +use tracing::Span; use crate::{api, auth, state::SharedState, websocket}; use std::convert::Infallible; +#[derive(Clone, Copy)] +struct PathOnlyMakeSpan; + +impl MakeSpan for PathOnlyMakeSpan { + fn make_span(&mut self, request: &axum::http::Request) -> Span { + tracing::info_span!( + "http_request", + method = %request.method(), + path = %request.uri().path(), + version = ?request.version(), + ) + } +} + pub fn router( state: SharedState, static_dir: &str, @@ -208,7 +227,7 @@ pub fn router( .layer(DefaultBodyLimit::max( upload_max_size_bytes.saturating_add(1024 * 1024), )) - .layer(TraceLayer::new_for_http()) + .layer(TraceLayer::new_for_http().make_span_with(PathOnlyMakeSpan)) .layer(middleware::from_fn(require_csrf_token)) .layer(middleware::from_fn(apply_response_header_policy)) .with_state(state) diff --git a/src/app/pages.rs b/src/app/pages.rs index 2a1aaae..e7adbef 100644 --- a/src/app/pages.rs +++ b/src/app/pages.rs @@ -8,12 +8,123 @@ */ use axum::{ - extract::{Path, State}, - http::{HeaderValue, StatusCode, header}, + extract::{OriginalUri, Path, RawQuery, State}, + http::{HeaderMap, HeaderValue, StatusCode, Uri, header}, response::{Html, IntoResponse, Response}, }; -use crate::{assets, db, state::SharedState}; +use crate::{assets, auth, db, state::SharedState}; + +fn decode_query_component(value: &str) -> Option { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' if index + 2 < bytes.len() => { + let high = (bytes[index + 1] as char).to_digit(16)? as u8; + let low = (bytes[index + 2] as char).to_digit(16)? as u8; + decoded.push((high << 4) | low); + index += 3; + } + b'%' => return None, + b'+' => { + decoded.push(b' '); + index += 1; + } + byte => { + decoded.push(byte); + index += 1; + } + } + } + String::from_utf8(decoded).ok() +} + +fn share_token_from_query(query: Option<&str>) -> (bool, Option) { + let Some(query) = query else { + return (false, None); + }; + for field in query.split('&') { + let (name, value) = field.split_once('=').unwrap_or((field, "")); + if decode_query_component(name).as_deref() == Some("share") { + return (true, decode_query_component(value)); + } + } + (false, None) +} + +fn canonical_resource_url(uri: &Uri) -> String { + let remaining_query = uri.query().map(|query| { + query + .split('&') + .filter(|field| { + let name = field.split_once('=').map_or(*field, |(name, _)| name); + decode_query_component(name).as_deref() != Some("share") + }) + .collect::>() + .join("&") + }); + match remaining_query.as_deref().filter(|query| !query.is_empty()) { + Some(query) => format!("{}?{query}", uri.path()), + None => uri.path().to_owned(), + } +} + +async fn share_session_redirect( + state: &SharedState, + headers: &HeaderMap, + uri: &Uri, + kind: &str, + slug: &str, + share: Option<&str>, +) -> Response { + let client_key = crate::security::client_key(headers); + let cookie = match share.map(str::trim).filter(|value| !value.is_empty()) { + Some(share) => match auth::create_share_session( + state, + kind, + slug, + share, + crate::security::share_session_token(headers, kind, slug), + &client_key, + ) + .await + { + Ok(Some(session)) => Some(crate::security::share_session_cookie( + kind, + slug, + &session.token, + session.max_age_seconds, + )), + Ok(None) => None, + Err(error) => { + tracing::warn!(error = %error.message, kind, slug, "failed to exchange share link for guest session"); + None + } + }, + None => None, + }; + + let mut response = StatusCode::SEE_OTHER.into_response(); + response.headers_mut().insert( + header::LOCATION, + HeaderValue::from_str(&canonical_resource_url(uri)) + .expect("request URI is a valid redirect location"), + ); + if let Some(cookie) = cookie { + response.headers_mut().insert(header::SET_COOKIE, cookie); + } + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-store, max-age=0"), + ); + response.headers_mut().insert( + "referrer-policy", + HeaderValue::from_static("no-referrer"), + ); + response +} fn render_editor_page( state: &SharedState, @@ -153,19 +264,39 @@ pub(super) async fn home(State(state): State) -> Response { ) } -pub(super) async fn pad(State(state): State, Path(slug): Path) -> Response { +pub(super) async fn pad( + State(state): State, + headers: HeaderMap, + Path(slug): Path, + RawQuery(query): RawQuery, + OriginalUri(uri): OriginalUri, +) -> Response { match db::find_pad(&state.db, &slug).await { - Ok(Some(pad)) => render_editor_page( - &state, - "pad", - "pad", - &pad.title, - "RustPad", - "/", - "home-brand", - "note", - "Alt+EnterNew line while editing PreviewEscEdit raw Markdown of current Preview line", - ), + Ok(Some(pad)) => { + let (has_share, share) = share_token_from_query(query.as_deref()); + if has_share { + return share_session_redirect( + &state, + &headers, + &uri, + "pad", + &slug, + share.as_deref(), + ) + .await; + } + render_editor_page( + &state, + "pad", + "pad", + &pad.title, + "RustPad", + "/", + "home-brand", + "note", + "Alt+EnterNew line while editing PreviewEscEdit raw Markdown of current Preview line", + ) + } Ok(None) => error_response( StatusCode::NOT_FOUND, "404", @@ -214,10 +345,25 @@ pub(super) async fn public_page( pub(super) async fn workspace( State(state): State, + headers: HeaderMap, Path(workspace_slug): Path, + RawQuery(query): RawQuery, + OriginalUri(uri): OriginalUri, ) -> Response { match db::find_workspace(&state.db, &workspace_slug).await { Ok(Some(workspace)) => { + let (has_share, share) = share_token_from_query(query.as_deref()); + if has_share { + return share_session_redirect( + &state, + &headers, + &uri, + "workspace", + &workspace_slug, + share.as_deref(), + ) + .await; + } let html = include_str!("../../static/workspace.html").replace( "__WORKSPACE_TITLE__", &escape_html(if workspace.is_private != 0 { @@ -254,7 +400,10 @@ pub(super) async fn workspace( pub(super) async fn note( State(state): State, + headers: HeaderMap, Path((workspace_slug, note_slug)): Path<(String, String)>, + RawQuery(query): RawQuery, + OriginalUri(uri): OriginalUri, ) -> Response { let workspace = match db::find_workspace(&state.db, &workspace_slug).await { Ok(Some(workspace)) => workspace, @@ -276,25 +425,39 @@ pub(super) async fn note( }; match db::find_note(&state.db, workspace.id, ¬e_slug).await { - Ok(Some(note)) => render_editor_page( - &state, - "note", - "note", - if workspace.is_private != 0 { - "Note" - } else { - ¬e.title - }, - if workspace.is_private != 0 { - "Workspace" - } else { - &workspace.title - }, - &format!("/w/{workspace_slug}"), - "", - "workspace", - "", - ), + Ok(Some(note)) => { + let (has_share, share) = share_token_from_query(query.as_deref()); + if has_share { + return share_session_redirect( + &state, + &headers, + &uri, + "workspace", + &workspace_slug, + share.as_deref(), + ) + .await; + } + render_editor_page( + &state, + "note", + "note", + if workspace.is_private != 0 { + "Note" + } else { + ¬e.title + }, + if workspace.is_private != 0 { + "Workspace" + } else { + &workspace.title + }, + &format!("/w/{workspace_slug}"), + "", + "workspace", + "", + ) + } Ok(None) => error_response( StatusCode::NOT_FOUND, "404", @@ -400,3 +563,28 @@ fn escape_html(value: &str) -> String { .replace('"', """) .replace('\'', "'") } + +#[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"); + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 5c49873..67e1bec 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -149,7 +149,6 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for SharingUserRow { #[derive(Debug)] struct SharingLinkRow { token_hash: String, - token: Option, permission: String, expires_at: Option, created_at: String, @@ -159,14 +158,68 @@ 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)?, - token: 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)?, + permission: crate::row_decode::text(row, 1)?, + expires_at: crate::row_decode::optional_text(row, 2)?, + created_at: crate::row_decode::text(row, 3)?, }) } } +#[derive(Debug)] +struct ShareLinkSessionSource { + token_hash: String, + permission: String, + expires_at: Option, +} + +impl<'r> sqlx::FromRow<'r, AnyRow> for ShareLinkSessionSource { + 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)?, + }) + } +} + +#[derive(Debug)] +struct ShareLinkPermissionRow { + permission: String, + expires_at: Option, +} + +impl<'r> sqlx::FromRow<'r, AnyRow> for ShareLinkPermissionRow { + fn from_row(row: &'r AnyRow) -> Result { + Ok(Self { + permission: crate::row_decode::text(row, 0)?, + expires_at: crate::row_decode::optional_text(row, 1)?, + }) + } +} + +#[derive(Debug)] +struct ShareSessionPermissionRow { + permission: String, + session_expires_at: String, + link_expires_at: Option, +} + +impl<'r> sqlx::FromRow<'r, AnyRow> for ShareSessionPermissionRow { + fn from_row(row: &'r AnyRow) -> Result { + Ok(Self { + permission: crate::row_decode::text(row, 0)?, + session_expires_at: crate::row_decode::text(row, 1)?, + link_expires_at: crate::row_decode::optional_text(row, 2)?, + }) + } +} + +#[derive(Debug)] +pub struct ShareSession { + pub token: String, + pub max_age_seconds: i64, +} + #[derive(Debug)] struct PendingShareRow { email: String, @@ -1681,7 +1734,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,"token":row.token,"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,"permission":row.permission,"expires_at":row.expires_at,"created_at":row.created_at})).collect::>() }), )) } @@ -1689,20 +1742,19 @@ pub async fn create_share_link( State(state): State, headers: HeaderMap, Json(req): Json, -) -> Result, AuthError> { +) -> Result { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; let permission = validate_permission(&req.permission)?; - validate_share_expiration(req.expires_at.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) .bind(&req.kind) .bind(req.slug.trim()) .bind(permission) - .bind(&req.expires_at) + .bind(&expires_at) .bind(owner.id) .execute(state.db.pool()) .await @@ -1712,9 +1764,19 @@ pub async fn create_share_link( } else { format!("/p/{}", req.slug.trim()) }; - Ok(Json( - serde_json::json!({"token":token,"url":format!("{base}?share={token}"),"permission":permission,"expires_at":req.expires_at}), - )) + let mut response = Json( + serde_json::json!({"token":token,"url":format!("{base}?share={token}"),"permission":permission,"expires_at":expires_at}), + ) + .into_response(); + response.headers_mut().insert( + header::CACHE_CONTROL, + "no-store, max-age=0".parse().expect("valid cache-control"), + ); + response.headers_mut().insert( + header::PRAGMA, + "no-cache".parse().expect("valid pragma"), + ); + Ok(response) } pub async fn update_share_link( @@ -1725,10 +1787,10 @@ pub async fn update_share_link( let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; let permission = validate_permission(&req.permission)?; - validate_share_expiration(req.expires_at.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(permission) - .bind(&req.expires_at) + .bind(&expires_at) .bind(req.token.trim()) .bind(&req.kind) .bind(req.slug.trim()) @@ -1741,7 +1803,7 @@ pub async fn update_share_link( )); } Ok(Json( - serde_json::json!({"ok":true,"permission":permission,"expires_at":req.expires_at}), + serde_json::json!({"ok":true,"permission":permission,"expires_at":expires_at}), )) } @@ -1760,6 +1822,16 @@ pub async fn revoke_share_link( .execute(state.db.pool()) .await .map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::SHARE_SESSIONS_DELETE_BY_LINK, + )) + .bind(req.token.trim()) + .bind(&req.kind) + .bind(req.slug.trim()) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; Ok(Json(serde_json::json!({"ok":true}))) } @@ -1770,9 +1842,9 @@ fn validate_permission(value: &str) -> Result<&str, AuthError> { } } -fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> { +fn normalize_share_expiration(value: Option<&str>) -> Result, AuthError> { let Some(value) = value else { - return Ok(()); + return Ok(None); }; let expires = chrono::DateTime::parse_from_rfc3339(value) .map_err(|_| AuthError::bad_request("Invalid expiration date."))? @@ -1780,7 +1852,7 @@ fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> { if expires <= Utc::now() { return Err(AuthError::bad_request("Expiration must be in the future.")); } - Ok(()) + Ok(Some(expires.to_rfc3339())) } pub async fn is_resource_owner( @@ -1826,6 +1898,194 @@ pub async fn account_resource_permission( Ok(permission) } +pub async fn create_share_session( + state: &SharedState, + kind: &str, + slug: &str, + share_token: &str, + existing_session_token: Option<&str>, + client_key: &str, +) -> Result, AuthError> { + let share_token = share_token.trim(); + if !valid_share_token(share_token) || !matches!(kind, "workspace" | "pad") { + return Ok(None); + } + + let now = Utc::now(); + let now_text = now.to_rfc3339(); + let window = std::time::Duration::from_secs(15 * 60); + state + .check_rate_limit(format!("share-session-client:{client_key}"), 120, window) + .await + .map_err(|seconds| { + AuthError::rate_limited(&format!( + "Too many share-link attempts. Try again in {seconds} seconds." + )) + })?; + let source = sqlx::query_as::<_, ShareLinkSessionSource>(queries::get( + state.db.kind(), + queries::SHARE_LINK_SESSION_SOURCE, + )) + .bind(hash_token(share_token)) + .bind(kind) + .bind(slug) + .fetch_optional(state.db.pool()) + .await + .map_err(AuthError::database)?; + let Some(source) = source else { + return Ok(None); + }; + if !matches!(source.permission.as_str(), "ro" | "rw") { + 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) { + Ok(value) => std::cmp::min(value.with_timezone(&Utc), session_limit), + Err(error) => { + warn!(%error, kind, slug, "invalid share link expiration in database"); + return Ok(None); + } + }, + None => session_limit, + }; + let max_age_seconds = (expires_at - now).num_seconds(); + if max_age_seconds <= 0 { + return Ok(None); + } + + state + .check_rate_limit( + format!("share-session-client:{client_key}:{}", source.token_hash), + 60, + window, + ) + .await + .map_err(|seconds| { + AuthError::rate_limited(&format!( + "Too many share-link sessions. Try again in {seconds} seconds." + )) + })?; + state + .check_rate_limit( + format!("share-session-link:{}", source.token_hash), + 2_000, + std::time::Duration::from_secs(60 * 60), + ) + .await + .map_err(|seconds| { + AuthError::rate_limited(&format!( + "Too many share-link sessions. Try again in {seconds} seconds." + )) + })?; + + sqlx::query(queries::get( + state.db.kind(), + queries::SHARE_SESSIONS_DELETE_EXPIRED, + )) + .bind(&now_text) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; + + let token = random_token(); + sqlx::query(queries::get( + state.db.kind(), + queries::SHARE_SESSION_INSERT, + )) + .bind(hash_token(&token)) + .bind(source.token_hash) + .bind(kind) + .bind(slug) + .bind(expires_at.to_rfc3339()) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; + + Ok(Some(ShareSession { + token, + max_age_seconds, + })) +} + +async fn share_session_permission( + state: &SharedState, + kind: &str, + slug: &str, + token: Option<&str>, +) -> Result, AuthError> { + let Some(token) = token.filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if !valid_share_token(token) { + return Ok(None); + } + let row = sqlx::query_as::<_, ShareSessionPermissionRow>(queries::get( + state.db.kind(), + queries::SHARE_SESSION_PERMISSION, + )) + .bind(hash_token(token)) + .bind(kind) + .bind(slug) + .fetch_optional(state.db.pool()) + .await + .map_err(AuthError::database)?; + let Some(row) = row else { + return Ok(None); + }; + if !matches!(row.permission.as_str(), "ro" | "rw") { + warn!(kind, slug, "invalid share session permission in database"); + return Ok(None); + } + let now = Utc::now(); + let session_expires = match chrono::DateTime::parse_from_rfc3339(&row.session_expires_at) { + Ok(value) => value.with_timezone(&Utc), + Err(error) => { + warn!(%error, kind, slug, "invalid share session expiration in database"); + return Ok(None); + } + }; + if session_expires <= now { + return Ok(None); + } + if let Some(value) = row.link_expires_at.as_deref() { + let link_expires = match chrono::DateTime::parse_from_rfc3339(value) { + Ok(value) => value.with_timezone(&Utc), + Err(error) => { + warn!(%error, kind, slug, "invalid share link expiration in database"); + return Ok(None); + } + }; + if link_expires <= now { + return Ok(None); + } + } + Ok(Some(row.permission)) +} + +pub async fn share_access_permission( + state: &SharedState, + kind: &str, + slug: &str, + token: Option<&str>, +) -> Result, AuthError> { + let permission = share_session_permission(state, kind, slug, token).await?; + if permission.is_some() { + return Ok(permission); + } + share_link_permission(state, kind, slug, token).await +} + pub async fn share_link_permission( state: &SharedState, kind: &str, @@ -1835,19 +2095,39 @@ pub async fn share_link_permission( let Some(token) = token.filter(|value| !value.is_empty()) else { return Ok(None); }; - let now = Utc::now().to_rfc3339(); - let permission: Option = sqlx::query_scalar(queries::get( + if !valid_share_token(token) { + return Ok(None); + } + let row = sqlx::query_as::<_, ShareLinkPermissionRow>(queries::get( state.db.kind(), queries::SHARE_LINK_PERMISSION, )) .bind(hash_token(token)) .bind(kind) .bind(slug) - .bind(now) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; - Ok(permission) + let Some(row) = row else { + return Ok(None); + }; + if !matches!(row.permission.as_str(), "ro" | "rw") { + warn!(kind, slug, "invalid share link permission in database"); + return Ok(None); + } + if let Some(value) = row.expires_at.as_deref() { + let expires = match chrono::DateTime::parse_from_rfc3339(value) { + Ok(value) => value.with_timezone(&Utc), + Err(error) => { + warn!(%error, kind, slug, "invalid share link expiration in database"); + return Ok(None); + } + }; + if expires <= Utc::now() { + return Ok(None); + } + } + Ok(Some(row.permission)) } pub async fn logout( @@ -2286,6 +2566,10 @@ fn random_token() -> String { random_hex_token::<32>() } +fn valid_share_token(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + fn random_confirmation_token() -> String { random_hex_token::<32>() } diff --git a/src/main.rs b/src/main.rs index 5044d9b..97e0ea2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -142,6 +142,24 @@ async fn main() -> Result<(), Box> { tracing::error!(%error, "failed to remove expired unconfirmed accounts") } } + let now = chrono::Utc::now().to_rfc3339(); + match sqlx::query(crate::queries::get( + cleanup_state.db.kind(), + crate::queries::SHARE_SESSIONS_DELETE_EXPIRED, + )) + .bind(now) + .execute(cleanup_state.db.pool()) + .await + { + Ok(result) if result.rows_affected() > 0 => info!( + deleted = result.rows_affected(), + "removed expired share-link sessions" + ), + Ok(_) => {} + Err(error) => { + tracing::error!(%error, "failed to remove expired share-link sessions") + } + } } }); let app = app::router( diff --git a/src/queries/mod.rs b/src/queries/mod.rs index 4a0cba5..3628a95 100644 --- a/src/queries/mod.rs +++ b/src/queries/mod.rs @@ -105,6 +105,11 @@ pub enum Query { SHARE_LINK_REVOKE, RESOURCE_PERMISSION_BY_USER, SHARE_LINK_PERMISSION, + SHARE_LINK_SESSION_SOURCE, + SHARE_SESSION_INSERT, + SHARE_SESSION_PERMISSION, + SHARE_SESSIONS_DELETE_BY_LINK, + SHARE_SESSIONS_DELETE_EXPIRED, Q001, Q002, Q003, @@ -259,6 +264,11 @@ pub const SHARE_LINK_UPDATE: Query = Query::SHARE_LINK_UPDATE; pub const SHARE_LINK_REVOKE: Query = Query::SHARE_LINK_REVOKE; pub const RESOURCE_PERMISSION_BY_USER: Query = Query::RESOURCE_PERMISSION_BY_USER; pub const SHARE_LINK_PERMISSION: Query = Query::SHARE_LINK_PERMISSION; +pub const SHARE_LINK_SESSION_SOURCE: Query = Query::SHARE_LINK_SESSION_SOURCE; +pub const SHARE_SESSION_INSERT: Query = Query::SHARE_SESSION_INSERT; +pub const SHARE_SESSION_PERMISSION: Query = Query::SHARE_SESSION_PERMISSION; +pub const SHARE_SESSIONS_DELETE_BY_LINK: Query = Query::SHARE_SESSIONS_DELETE_BY_LINK; +pub const SHARE_SESSIONS_DELETE_EXPIRED: Query = Query::SHARE_SESSIONS_DELETE_EXPIRED; pub const Q001: Query = Query::Q001; pub const Q002: Query = Query::Q002; pub const Q003: Query = Query::Q003; @@ -325,6 +335,11 @@ mod tests { 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, ] { assert!(!get(DatabaseKind::Sqlite, query).is_empty()); assert!(!get(DatabaseKind::Postgres, query).is_empty()); diff --git a/src/queries/mysql.rs b/src/queries/mysql.rs index 0619daf..d37a110 100644 --- a/src/queries/mysql.rs +++ b/src/queries/mysql.rs @@ -233,13 +233,13 @@ 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, CAST(token AS CHAR CHARACTER SET utf8mb4) AS token, 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, 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, token, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"# + r#"INSERT INTO resource_share_links (token_hash, 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"# @@ -251,7 +251,22 @@ pub fn get(query: Query) -> &'static str { r#"SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"# } Query::SHARE_LINK_PERMISSION => { - r#"SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)"# + r#"SELECT permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"# + } + Query::SHARE_LINK_SESSION_SOURCE => { + r#"SELECT token_hash, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"# + } + Query::SHARE_SESSION_INSERT => { + r#"INSERT INTO resource_share_sessions (session_token_hash, share_token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?, ?)"# + } + Query::SHARE_SESSION_PERMISSION => { + r#"SELECT l.permission, CAST(s.expires_at AS CHAR CHARACTER SET utf8mb4) AS session_expires_at, CAST(l.expires_at AS CHAR CHARACTER SET utf8mb4) AS link_expires_at FROM resource_share_sessions s JOIN resource_share_links l ON l.token_hash = s.share_token_hash AND l.resource_kind = s.resource_kind AND l.resource_slug = s.resource_slug WHERE s.session_token_hash = ? AND s.resource_kind = ? AND s.resource_slug = ? AND l.revoked_at IS NULL"# + } + Query::SHARE_SESSIONS_DELETE_BY_LINK => { + r#"DELETE FROM resource_share_sessions WHERE share_token_hash = ? AND resource_kind = ? AND resource_slug = ?"# + } + Query::SHARE_SESSIONS_DELETE_EXPIRED => { + r#"DELETE FROM resource_share_sessions WHERE expires_at <= ?"# } Query::Q001 => { r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private FROM workspaces WHERE slug = ?"# diff --git a/src/queries/postgres.rs b/src/queries/postgres.rs index 2c26475..b49e2cc 100644 --- a/src/queries/postgres.rs +++ b/src/queries/postgres.rs @@ -235,13 +235,13 @@ 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, token, 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, 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, token, resource_kind, resource_slug, permission, expires_at, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7)"# + r#"INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES ($1, $2, $3, $4, $5, $6)"# } 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"# @@ -253,7 +253,22 @@ pub fn get(query: Query) -> &'static str { r#"SELECT permission FROM resource_permissions WHERE resource_kind = $1 AND resource_slug = $2 AND user_id = $3"# } Query::SHARE_LINK_PERMISSION => { - r#"SELECT permission FROM resource_share_links WHERE token_hash = $1 AND resource_kind = $2 AND resource_slug = $3 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > $4)"# + r#"SELECT permission, expires_at FROM resource_share_links WHERE token_hash = $1 AND resource_kind = $2 AND resource_slug = $3 AND revoked_at IS NULL"# + } + Query::SHARE_LINK_SESSION_SOURCE => { + r#"SELECT token_hash, permission, expires_at FROM resource_share_links WHERE token_hash = $1 AND resource_kind = $2 AND resource_slug = $3 AND revoked_at IS NULL"# + } + Query::SHARE_SESSION_INSERT => { + r#"INSERT INTO resource_share_sessions (session_token_hash, share_token_hash, resource_kind, resource_slug, expires_at) VALUES ($1, $2, $3, $4, $5)"# + } + Query::SHARE_SESSION_PERMISSION => { + r#"SELECT l.permission, s.expires_at, l.expires_at FROM resource_share_sessions s JOIN resource_share_links l ON l.token_hash = s.share_token_hash AND l.resource_kind = s.resource_kind AND l.resource_slug = s.resource_slug WHERE s.session_token_hash = $1 AND s.resource_kind = $2 AND s.resource_slug = $3 AND l.revoked_at IS NULL"# + } + Query::SHARE_SESSIONS_DELETE_BY_LINK => { + r#"DELETE FROM resource_share_sessions WHERE share_token_hash = $1 AND resource_kind = $2 AND resource_slug = $3"# + } + Query::SHARE_SESSIONS_DELETE_EXPIRED => { + r#"DELETE FROM resource_share_sessions WHERE expires_at <= $1"# } Query::Q001 => { r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM workspaces WHERE slug = $1"# diff --git a/src/queries/sqlite.rs b/src/queries/sqlite.rs index 06800b0..354232a 100644 --- a/src/queries/sqlite.rs +++ b/src/queries/sqlite.rs @@ -233,13 +233,13 @@ 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, token, 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, 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, token, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"# + r#"INSERT INTO resource_share_links (token_hash, 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"# @@ -251,7 +251,22 @@ pub fn get(query: Query) -> &'static str { r#"SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"# } Query::SHARE_LINK_PERMISSION => { - r#"SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)"# + r#"SELECT permission, expires_at FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"# + } + Query::SHARE_LINK_SESSION_SOURCE => { + r#"SELECT token_hash, permission, expires_at FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"# + } + Query::SHARE_SESSION_INSERT => { + r#"INSERT INTO resource_share_sessions (session_token_hash, share_token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?, ?)"# + } + Query::SHARE_SESSION_PERMISSION => { + r#"SELECT l.permission, s.expires_at, l.expires_at FROM resource_share_sessions s JOIN resource_share_links l ON l.token_hash = s.share_token_hash AND l.resource_kind = s.resource_kind AND l.resource_slug = s.resource_slug WHERE s.session_token_hash = ? AND s.resource_kind = ? AND s.resource_slug = ? AND l.revoked_at IS NULL"# + } + Query::SHARE_SESSIONS_DELETE_BY_LINK => { + r#"DELETE FROM resource_share_sessions WHERE share_token_hash = ? AND resource_kind = ? AND resource_slug = ?"# + } + Query::SHARE_SESSIONS_DELETE_EXPIRED => { + r#"DELETE FROM resource_share_sessions WHERE expires_at <= ?"# } Query::Q001 => { r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private FROM workspaces WHERE slug = ?"# diff --git a/src/security.rs b/src/security.rs index fae6971..e4534ad 100644 --- a/src/security.rs +++ b/src/security.rs @@ -50,6 +50,15 @@ pub fn resource_token<'a>(headers: &'a HeaderMap, kind: &str, slug: &str) -> Opt cookie_value(headers, &name) } +pub fn share_session_token<'a>( + headers: &'a HeaderMap, + kind: &str, + slug: &str, +) -> Option<&'a str> { + let name = share_session_cookie_name(kind, slug); + cookie_value(headers, &name) +} + pub fn session_cookie(token: &str, ttl_days: i64) -> HeaderValue { secure_cookie(SESSION_COOKIE, token, ttl_days.saturating_mul(86_400)) } @@ -104,6 +113,23 @@ pub fn resource_cookie(kind: &str, slug: &str, token: &str, ttl_days: i64) -> He ) } +pub fn share_session_cookie( + kind: &str, + slug: &str, + token: &str, + max_age_seconds: i64, +) -> HeaderValue { + secure_cookie( + &share_session_cookie_name(kind, slug), + token, + max_age_seconds, + ) +} + +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")) @@ -161,6 +187,11 @@ fn resource_cookie_name(kind: &str, slug: &str) -> String { format!("__Host-rustpad_access_{}", hex::encode(&digest[..12])) } +fn share_session_cookie_name(kind: &str, slug: &str) -> String { + let digest = Sha256::digest(format!("{kind}:{slug}").as_bytes()); + format!("__Host-rustpad_share_{}", hex::encode(&digest[..12])) +} + pub fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { headers .get(header::COOKIE) @@ -318,6 +349,24 @@ mod tests { 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")); + + let cleared = clear_share_session_cookie("workspace", "private-space") + .to_str() + .unwrap(); + assert!(cleared.contains("Max-Age=0")); + } + #[test] fn csrf_requires_matching_cookie_and_header() { let token = "a".repeat(CSRF_TOKEN_BYTES * 2); diff --git a/src/websocket/mod.rs b/src/websocket/mod.rs index 221857b..0aa9162 100644 --- a/src/websocket/mod.rs +++ b/src/websocket/mod.rs @@ -196,37 +196,70 @@ async fn resource_permission_from_tokens( state: &SharedState, kind: &str, slug: &str, - access_token: Option<&str>, + access_tokens: &[Option<&str>], session_token: Option<&str>, ) -> Option { - let access_permission = auth::share_link_permission(state, kind, slug, access_token) + let mut read_allowed = false; + for token in access_tokens { + match auth::share_access_permission(state, kind, slug, *token) + .await + .ok() + .flatten() + .as_deref() + { + Some("rw") => return Some("rw".into()), + Some("ro") => read_allowed = true, + _ => {} + } + } + match auth::account_resource_permission(state, kind, slug, session_token) .await .ok() - .flatten(); - let session_permission = auth::account_resource_permission(state, kind, slug, session_token) - .await - .ok() - .flatten(); - if access_permission.as_deref() == Some("rw") || session_permission.as_deref() == Some("rw") { - Some("rw".into()) - } else if access_permission.as_deref() == Some("ro") - || session_permission.as_deref() == Some("ro") + .flatten() + .as_deref() { - Some("ro".into()) - } else { - None + Some("rw") => Some("rw".into()), + Some("ro") if !read_allowed => Some("ro".into()), + _ if read_allowed => Some("ro".into()), + _ => None, } } -async fn anonymous_access_from_tokens( +async fn password_access_from_tokens( state: &SharedState, kind: &str, slug: &str, - access_token: Option<&str>, + access_tokens: &[Option<&str>], ) -> bool { - crate::api::verify_resource_access_token(state, kind, slug, access_token) - .await - .unwrap_or(false) + for token in access_tokens { + if crate::api::verify_password_access_token(state, kind, slug, *token) + .await + .unwrap_or(false) + { + return true; + } + } + false +} + +async fn current_resource_access( + state: &SharedState, + kind: &str, + slug: &str, + access_tokens: &[Option<&str>], + session_token: Option<&str>, + password_ok: bool, + public_unprotected: bool, +) -> (bool, bool) { + 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 read_allowed = write_allowed || permission.as_deref() == Some("ro"); + (read_allowed, write_allowed) } // Merged from note.rs @@ -241,6 +274,9 @@ pub async fn upgrade( 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); let client_key = crate::security::client_key(&headers); @@ -252,6 +288,7 @@ pub async fn upgrade( workspace_slug, note_slug, account_token, + share_session_token, resource_token, client_key, client_context, @@ -265,7 +302,8 @@ async fn handle_socket( workspace_slug: String, note_slug: String, cookie_session_token: Option, - cookie_access_token: Option, + cookie_share_session_token: Option, + cookie_password_token: Option, client_key: String, client_context: RequestClientContext, ) { @@ -319,7 +357,11 @@ async fn handle_socket( .map(str::trim) .filter(|value| !value.is_empty() && *value != "cookie") .map(str::to_owned); - let access_token = explicit_access_token.or(cookie_access_token); + let external_tokens = [ + explicit_access_token.as_deref(), + cookie_share_session_token.as_deref(), + cookie_password_token.as_deref(), + ]; let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await { Ok(value) => value, Err(message) => { @@ -343,18 +385,17 @@ async fn handle_socket( &state, "workspace", &workspace_slug, - access_token.as_deref(), + &external_tokens, session_token.as_deref(), ) .await; - let anonymous_token_ok = permission.is_none() - && anonymous_access_from_tokens( - &state, - "workspace", - &workspace_slug, - access_token.as_deref(), - ) - .await; + let anonymous_token_ok = password_access_from_tokens( + &state, + "workspace", + &workspace_slug, + &external_tokens, + ) + .await; let password_limit_key = format!("resource-password:{client_key}:workspace:{workspace_slug}"); let password_attempted = password .as_deref() @@ -410,10 +451,17 @@ async fn handle_socket( let _ = send_error(&mut socket, "Invalid password").await; return; } - let write_allowed = permission.as_deref() == Some("rw") - || anonymous_token_ok - || password_ok - || (workspace.is_private == 0 && workspace.password_hash.is_none() && permission.is_none()); + let public_unprotected = workspace.is_private == 0 && workspace.password_hash.is_none(); + let (_, write_allowed) = current_resource_access( + &state, + "workspace", + &workspace_slug, + &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"); if send( &mut socket, @@ -443,6 +491,9 @@ async fn handle_socket( .await; let _ = channel.send(RoomEvent::Presence(users)); let mut last_chat = Instant::now() - Duration::from_secs(1); + let mut access_refresh = tokio::time::interval(Duration::from_secs(10)); + access_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + access_refresh.tick().await; let (mut sender, mut receiver) = socket.split(); if send_split( &mut sender, @@ -462,7 +513,17 @@ async fn handle_socket( incoming=receiver.next()=>match incoming { Some(Ok(Message::Text(text)))=>match serde_json::from_str::(&text) { Ok(ClientMessage::Update{content,owner_map})=>{ - if !write_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Read-only access".into()}).await; continue; } + let (read_allowed, current_write_allowed) = current_resource_access( + &state, + "workspace", + &workspace_slug, + &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; } if content.len()>2_000_000 { let _=send_split(&mut sender,&ServerMessage::Error{message:"The document is too large".into()}).await; continue; } let owner_map=owner_map.unwrap_or_else(||"[]".into()); match db::save_revision(&state.db,note.id,workspace.id,&content,nickname.as_deref(),&owner_map).await { @@ -480,12 +541,42 @@ async fn handle_socket( }, Some(Ok(Message::Close(_)))|None=>break, Some(Ok(_))=>{}, Some(Err(error))=>{debug!(%error,"websocket receive error");break;} }, - update=updates.recv()=>match update { - Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{content:update.content,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,owner_map:update.owner_map}).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;}, - Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_note(&state.db,workspace.id,¬e_slug).await { if send_split(&mut sender,&ServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} }, - Err(tokio::sync::broadcast::error::RecvError::Closed)=>break, + _=access_refresh.tick()=>{ + let (read_allowed, _) = current_resource_access( + &state, + "workspace", + &workspace_slug, + &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; + } + }, + update=updates.recv()=>{ + let (read_allowed, _) = current_resource_access( + &state, + "workspace", + &workspace_slug, + &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; + } + match update { + Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{content:update.content,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,owner_map:update.owner_map}).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;}, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_note(&state.db,workspace.id,¬e_slug).await { if send_split(&mut sender,&ServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} }, + Err(tokio::sync::broadcast::error::RecvError::Closed)=>break, + } } } } diff --git a/src/websocket/pad.rs b/src/websocket/pad.rs index 9eca642..5e7094f 100644 --- a/src/websocket/pad.rs +++ b/src/websocket/pad.rs @@ -44,6 +44,8 @@ pub async fn upgrade_pad( 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, "pad", &slug).map(str::to_owned); let resource_token = crate::security::resource_token(&headers, "pad", &slug).map(str::to_owned); let client_key = crate::security::client_key(&headers); let client_context = RequestClientContext::from_headers(&headers, &client_key); @@ -53,6 +55,7 @@ pub async fn upgrade_pad( state, slug, account_token, + share_session_token, resource_token, client_key, client_context, @@ -64,7 +67,8 @@ async fn handle_pad_socket( state: SharedState, slug: String, cookie_session_token: Option, - cookie_access_token: Option, + cookie_share_session_token: Option, + cookie_password_token: Option, client_key: String, client_context: RequestClientContext, ) { @@ -117,7 +121,11 @@ async fn handle_pad_socket( .map(str::trim) .filter(|value| !value.is_empty() && *value != "cookie") .map(str::to_owned); - let access_token = explicit_access_token.or(cookie_access_token); + let external_tokens = [ + explicit_access_token.as_deref(), + cookie_share_session_token.as_deref(), + cookie_password_token.as_deref(), + ]; let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await { Ok(value) => value, Err(message) => { @@ -141,12 +149,12 @@ async fn handle_pad_socket( &state, "pad", &slug, - access_token.as_deref(), + &external_tokens, session_token.as_deref(), ) .await; - let anonymous_token_ok = permission.is_none() - && anonymous_access_from_tokens(&state, "pad", &slug, access_token.as_deref()).await; + let anonymous_token_ok = + password_access_from_tokens(&state, "pad", &slug, &external_tokens).await; let password_limit_key = format!("resource-password:{client_key}:pad:{slug}"); let password_attempted = password .as_deref() @@ -210,10 +218,17 @@ async fn handle_pad_socket( .await; return; } - let write_allowed = permission.as_deref() == Some("rw") - || anonymous_token_ok - || password_ok - || (pad.is_private == 0 && pad.password_hash.is_none() && permission.is_none()); + let public_unprotected = pad.is_private == 0 && pad.password_hash.is_none(); + let (_, write_allowed) = current_resource_access( + &state, + "pad", + &slug, + &external_tokens, + session_token.as_deref(), + password_ok, + public_unprotected, + ) + .await; info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated"); if send_pad( &mut socket, @@ -242,6 +257,9 @@ async fn handle_pad_socket( .await; let _ = channel.send(RoomEvent::Presence(users)); let mut last_chat = Instant::now() - Duration::from_secs(1); + let mut access_refresh = tokio::time::interval(Duration::from_secs(10)); + access_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + access_refresh.tick().await; let (mut sender, mut receiver) = socket.split(); if send_pad_split( &mut sender, @@ -260,7 +278,18 @@ async fn handle_pad_socket( tokio::select! { incoming=receiver.next()=>match incoming{ Some(Ok(Message::Text(text)))=>match serde_json::from_str::(&text){ - Ok(ClientMessage::Update{content,owner_map})=>{if !write_allowed{let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Read-only access".into()}).await;continue;} + Ok(ClientMessage::Update{content,owner_map})=>{ + let (read_allowed, current_write_allowed) = current_resource_access( + &state, + "pad", + &slug, + &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;} if content.len()>2_000_000 { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"The document is too large".into()}).await; continue; } let owner_map=owner_map.unwrap_or_else(||"[]".into()); if let Ok((revision_id,updated_at))=db::save_pad_revision(&state.db,pad.id,&content,nickname.as_deref(),&owner_map).await{ @@ -280,12 +309,42 @@ async fn handle_pad_socket( Some(Ok(_))=>{}, Some(Err(error))=>{debug!(%error,"pad websocket receive error");break;} }, - update=updates.recv()=>match update{ - Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{content:u.content,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,owner_map:u.owner_map}).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;}, - Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_pad(&state.db,&slug).await { if send_pad_split(&mut sender,&PadServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} }, - Err(tokio::sync::broadcast::error::RecvError::Closed)=>break, + _=access_refresh.tick()=>{ + let (read_allowed, _) = current_resource_access( + &state, + "pad", + &slug, + &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; + } + }, + update=updates.recv()=>{ + let (read_allowed, _) = current_resource_access( + &state, + "pad", + &slug, + &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; + } + match update { + Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{content:u.content,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,owner_map:u.owner_map}).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;}, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_pad(&state.db,&slug).await { if send_pad_split(&mut sender,&PadServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} }, + Err(tokio::sync::broadcast::error::RecvError::Closed)=>break, + } } } } diff --git a/static/js/home.js b/static/js/home.js index 64c7919..bd5add8 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -206,20 +206,18 @@ async function loadResources() { const d = await api(`/api/auth/resources/sharing?kind=${encodeURIComponent(item.kind)}&slug=${encodeURIComponent(item.slug)}`, { headers: authHeaders() }); userList.innerHTML = d.users.length ? d.users.map(u => ``).join("") : ''; linkList.innerHTML = d.links.length ? d.links.map(link => { - const directUrl = link.token ? new URL(`${item.url}?share=${encodeURIComponent(link.token)}`, location.origin).href : ""; - const linkPreview = link.token ? `` : ''; + const linkPreview = ''; 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 => { const forever = linkRow.elements.forever, hours = linkRow.elements.hours; const sync = () => { hours.disabled = forever.checked; }; forever.addEventListener("change", sync); sync(); - linkRow.querySelector("[data-copy-link]")?.addEventListener("click", async () => { try { await copyText(linkRow.querySelector(".share-link-inline input").value); setDialogMessage("Link copied.", "success"); } catch (err) { setDialogMessage(err.message, "error"); } }); 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"); } }); }); }; 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. It remains visible below.", "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"); } }); dialog.showModal(); try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } }); diff --git a/static/js/note-api.js b/static/js/note-api.js index cc34a5d..61a622b 100644 --- a/static/js/note-api.js +++ b/static/js/note-api.js @@ -10,7 +10,6 @@ import { api } from "@rustpad/api"; import { askConfirm } from "@rustpad/modal"; import { NoteSocket, PadSocket } from "@rustpad/socket"; -import { withShareToken } from "@rustpad/url-state"; function encode(value) { return encodeURIComponent(value); @@ -60,7 +59,6 @@ export function createWorkspaceNoteAdapter() { const workspaceSlug = parts[1]; const noteSlug = parts[3]; const base = `/api/workspaces/${encode(workspaceSlug)}/notes/${encode(noteSlug)}`; - const shareToken = new URLSearchParams(location.search).get("share"); return { access: { kind: "workspace", key: workspaceSlug }, @@ -106,7 +104,7 @@ export function createWorkspaceNoteAdapter() { method: "DELETE", body: JSON.stringify({ access_token: accessToken || null }), }); - location.assign(withShareToken(`/w/${encode(workspaceSlug)}`, shareToken)); + location.assign(`/w/${encode(workspaceSlug)}`); }, }; } diff --git a/static/js/note-editor.js b/static/js/note-editor.js index b3b88e8..5a3e490 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -19,7 +19,7 @@ import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, unresolvedMa import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session"; import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui"; import { bindNoteFiles } from "@rustpad/note-files"; -import { currentShareUrl, readEditorState, withShareToken, writeEditorState } from "@rustpad/url-state"; +import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state"; import { toast } from "@rustpad/toast"; import { getTheme } from "@rustpad/theme"; @@ -31,8 +31,6 @@ export function startNoteEditor(adapter) { const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color"); const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle"); const shareToken = new URLSearchParams(location.search).get("share"); - const parentLink = document.querySelector("#resource-parent-link"); - if (parentLink && shareToken) parentLink.href = withShareToken(parentLink.getAttribute("href") || "/", shareToken); const notePreferenceKey = name => `rustpad:${name}:${location.pathname}`; let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = ""; let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false; @@ -843,7 +841,7 @@ export function startNoteEditor(adapter) { }); socket.connect(); } - bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken && !getAuthToken()) passwordDialog.showModal(); else { loadFiles(); connect(); } } }); + bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && info.access_level === "none") passwordDialog.showModal(); else { loadFiles(); connect(); } } }); identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); }); async function showSystemNotFound() { try { @@ -876,7 +874,7 @@ export function startNoteEditor(adapter) { adapter.configureView?.(info); applyUi({ write: true, replace: true }); updateCurrentUser(); - if (info.protected && !accessToken && !getAuthToken()) passwordDialog.showModal(); + if (info.protected && info.access_level === "none") passwordDialog.showModal(); else { loadFiles(); connect(); } } catch (e) { if (e.status === 403 || e.status === 404) { diff --git a/static/js/url-state.js b/static/js/url-state.js index 864cf90..6704f65 100644 --- a/static/js/url-state.js +++ b/static/js/url-state.js @@ -9,40 +9,6 @@ const VIEWS = new Set(["edit", "split", "preview"]); const MODES = new Set(["markdown", "text"]); -const APP_URL_BASE = globalThis.location?.origin || "https://rustpad.invalid"; - -function appUrl(path) { - try { - const url = new URL(path, APP_URL_BASE); - return url.origin === APP_URL_BASE ? url : null; - } catch { - return null; - } -} - -function relativeUrl(url) { - return `${url.pathname}${url.search}${url.hash}`; -} - -export function withShareToken(path, shareToken) { - const url = appUrl(path); - if (!url) return "/"; - const token = typeof shareToken === "string" ? shareToken.trim() : ""; - if (token) url.searchParams.set("share", token); - else url.searchParams.delete("share"); - return relativeUrl(url); -} - -export function editorResourceUrl(path, { shareToken = "", view = "split", mode = "markdown" } = {}) { - const url = appUrl(path); - if (!url) return "/"; - if (VIEWS.has(view)) url.searchParams.set("view", view); - if (MODES.has(mode)) url.searchParams.set("mode", mode); - const token = typeof shareToken === "string" ? shareToken.trim() : ""; - if (token) url.searchParams.set("share", token); - else url.searchParams.delete("share"); - return relativeUrl(url); -} export function readEditorState() { const params = new URLSearchParams(window.location.search); diff --git a/static/js/workspace.js b/static/js/workspace.js index 2ec3a16..1765d60 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -17,7 +17,6 @@ import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui"; import { askConfirm } from "@rustpad/modal"; import { safeAppUrl } from "@rustpad/security"; import { toast } from "@rustpad/toast"; -import { editorResourceUrl } from "@rustpad/url-state"; const parts = location.pathname.split("/").filter(Boolean); const slug = parts[1]; @@ -71,9 +70,6 @@ function setNotesView(view) { button.setAttribute("aria-pressed", String(active)); }); } -function noteEditorUrl(path) { - return safeAppUrl(editorResourceUrl(safeAppUrl(path), { shareToken })); -} function deleteButton(note, inline = false) { const disabled = note.protected; const classes = `note-delete-button${inline ? " note-delete-button--inline" : ""}`; @@ -90,7 +86,7 @@ function renderNotes(notes = notesCache) { if (notesView === "table") { notesList.innerHTML = `
${notes.map(note => ` - + @@ -103,7 +99,7 @@ function renderNotes(notes = notesCache) { } notesList.innerHTML = notes.map(note => `
- +

${escapeHtml(note.title)}

${note.protected ? 'Protected' : ''}
Created by: ${escapeHtml(note.created_by || "Unknown")}${noteStats(note)}Updated: ${formatDate(note.updated_at)}
@@ -155,7 +151,7 @@ async function init() { info = await api(`/api/workspaces/${encodeURIComponent(slug)}`, { headers }); document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-url").textContent = location.pathname; - if (info.protected && !accessToken && !getAuthToken()) dialog.showModal(); else openWorkspace(); + if (info.protected && info.access_level === "none") dialog.showModal(); else openWorkspace(); } catch (e) { if (e.status === 403 || e.status === 404) await showSystemNotFound(); else document.querySelector("#workspace-error").textContent = e.message; @@ -183,7 +179,7 @@ document.querySelector("#note-form").addEventListener("submit", async e => { method: "POST", body: JSON.stringify({ name: document.querySelector("#note-name").value, access_token: accessToken || null, protect: document.querySelector("#note-protect").checked, created_by: nickname || null }) }); - location.assign(noteEditorUrl(note.url)); + location.assign(safeAppUrl(`${note.url}?view=split&mode=markdown`)); } catch (err) { error.textContent = err.message; } }); notesList.addEventListener("click", async event => { diff --git a/tests/share_session_sql_test.py b/tests/share_session_sql_test.py new file mode 100755 index 0000000..2ac6991 --- /dev/null +++ b/tests/share_session_sql_test.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import hashlib +import re +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MIGRATIONS = ROOT / "migrations" / "sqlite" +SQLITE_QUERIES = ROOT / "src" / "queries" / "sqlite.rs" + + +def query(name: str) -> str: + source = SQLITE_QUERIES.read_text() + pattern = rf'Query::{re.escape(name)}\s*=>\s*\{{\s*r#"(.*?)"#\s*\}}' + match = re.search(pattern, source, re.S) + if not match: + raise AssertionError(f"Query::{name} not found") + return match.group(1) + + +def parse_rfc3339(value: str) -> datetime: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError("timestamp must include timezone") + return parsed.astimezone(timezone.utc) + + +def active_permission(row: tuple[str, str, str | None] | None, now: datetime) -> str | None: + if row is None: + return None + permission, session_expires_at, link_expires_at = row + try: + if parse_rfc3339(session_expires_at) <= now: + return None + if link_expires_at is not None and parse_rfc3339(link_expires_at) <= now: + return None + except ValueError: + return None + return permission if permission in {"ro", "rw"} else None + + +def link_active(expires_at: str | None, now: datetime) -> bool: + if expires_at is None: + return True + try: + return parse_rfc3339(expires_at) > now + except ValueError: + return False + + +def apply(connection: sqlite3.Connection, paths: list[Path]) -> None: + for path in paths: + connection.executescript(path.read_text()) + + +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" + + db = sqlite3.connect(":memory:") + db.execute("PRAGMA foreign_keys = ON") + apply(db, before_sessions) + db.execute( + "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) " + "VALUES (?, ?, ?, ?, ?)", + ("Owner", "owner", "owner@example.test", "owner@example.test", "hash"), + ) + + raw_link = "legacy-share-token" + 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), + ) + 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" + + source_sql = query("SHARE_LINK_SESSION_SOURCE") + insert_sql = query("SHARE_SESSION_INSERT") + permission_sql = query("SHARE_SESSION_PERMISSION") + revoke_sessions_sql = query("SHARE_SESSIONS_DELETE_BY_LINK") + delete_expired_sql = query("SHARE_SESSIONS_DELETE_EXPIRED") + link_insert_sql = query("SHARE_LINK_INSERT") + sharing_list_sql = query("RESOURCE_SHARING_LINKS") + + now = datetime(2026, 8, 2, 23, 0, tzinfo=timezone.utc) + source = db.execute(source_sql, (share_hash, "workspace", "private-space")).fetchone() + assert source == (share_hash, "ro", None) + + session_raw = "opaque-browser-session" + session_hash = hashlib.sha256(session_raw.encode()).hexdigest() + db.execute( + insert_sql, + ( + session_hash, + share_hash, + "workspace", + "private-space", + "2026-08-03T00:00:00+00:00", + ), + ) + + def permission(slug: str = "private-space") -> str | None: + row = db.execute(permission_sql, (session_hash, "workspace", slug)).fetchone() + return active_permission(row, now) + + assert permission() == "ro", "ro share session must remain read-only" + assert permission("another-space") is None, "session must be scoped to one resource" + + db.execute( + "UPDATE resource_share_links SET permission = 'rw' WHERE token_hash = ?", (share_hash,) + ) + assert permission() == "rw", "ro -> rw change must affect active sessions immediately" + db.execute( + "UPDATE resource_share_links SET permission = 'ro' WHERE token_hash = ?", (share_hash,) + ) + assert permission() == "ro", "rw -> ro change must remove write access immediately" + + db.execute( + "UPDATE resource_share_sessions SET expires_at = ? WHERE session_token_hash = ?", + ("2026-08-02T22:59:59+00:00", session_hash), + ) + assert permission() is None, "expired browser session must be denied" + db.execute( + "UPDATE resource_share_sessions SET expires_at = ? WHERE session_token_hash = ?", + ("2026-08-03T00:00:00+00:00", session_hash), + ) + + # Offset timestamps are parsed chronologically rather than compared as text. + db.execute( + "UPDATE resource_share_links SET expires_at = ? WHERE token_hash = ?", + ("2026-08-03T01:30:00+02:00", share_hash), + ) + row = db.execute(permission_sql, (session_hash, "workspace", "private-space")).fetchone() + assert active_permission(row, now) == "ro" + later = datetime(2026, 8, 2, 23, 31, tzinfo=timezone.utc) + assert active_permission(row, later) is None, "expired link must invalidate its sessions" + + db.execute( + "UPDATE resource_share_links SET expires_at = 'not-a-date' WHERE token_hash = ?", + (share_hash,), + ) + row = db.execute(permission_sql, (session_hash, "workspace", "private-space")).fetchone() + assert active_permission(row, now) is None, "malformed expiration must fail closed" + assert not link_active("not-a-date", now) + + db.execute( + "UPDATE resource_share_links SET expires_at = NULL, revoked_at = ? WHERE token_hash = ?", + ("2026-08-02T23:05:00+00:00", share_hash), + ) + assert db.execute(permission_sql, (session_hash, "workspace", "private-space")).fetchone() is None + deleted = db.execute( + revoke_sessions_sql, (share_hash, "workspace", "private-space") + ).rowcount + assert deleted == 1, "revoking a link must remove all derived sessions" + + # Newly created links persist only the hash; the plaintext column stays NULL. + 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), + ) + assert db.execute( + "SELECT token, permission FROM resource_share_links WHERE token_hash = ?", (second_hash,) + ).fetchone() == (None, "rw") + listed_link = db.execute(sharing_list_sql, ("workspace", "second-space")).fetchone() + assert listed_link == ( + second_hash, + "rw", + None, + listed_link[3], + ), "management listing must never return the plaintext share token" + + db.execute( + insert_sql, + ( + hashlib.sha256(b"expired-row").hexdigest(), + second_hash, + "workspace", + "second-space", + "2026-08-02T22:00:00+00:00", + ), + ) + db.execute(delete_expired_sql, ("2026-08-02T23:00:00+00:00",)) + assert db.execute( + "SELECT COUNT(*) FROM resource_share_sessions WHERE share_token_hash = ?", (second_hash,) + ).fetchone() == (0,), "expired session cleanup must remove stale rows" + + db.execute("DELETE FROM resource_share_links WHERE token_hash = ?", (second_hash,)) + assert db.execute( + "SELECT COUNT(*) FROM resource_share_sessions WHERE share_token_hash = ?", (second_hash,) + ).fetchone() == (0,), "link deletion must cascade to sessions" + + print("share session SQL regression tests: 17 passed") + + +if __name__ == "__main__": + main() diff --git a/tests/share_session_static_test.py b/tests/share_session_static_test.py new file mode 100755 index 0000000..96a5252 --- /dev/null +++ b/tests/share_session_static_test.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def read(path: str) -> str: + return (ROOT / path).read_text() + + +def query_sql(backend: str, name: str) -> str: + source = read(f"src/queries/{backend}.rs") + match = re.search( + rf'Query::{re.escape(name)}\s*=>\s*\{{\s*r#"(.*?)"#\s*\}}', + source, + re.S, + ) + if not match: + raise AssertionError(f"{backend}: Query::{name} missing") + return match.group(1) + + +def main() -> None: + names = { + "SHARE_LINK_INSERT": 6, + "SHARE_LINK_PERMISSION": 3, + "SHARE_LINK_SESSION_SOURCE": 3, + "SHARE_SESSION_INSERT": 5, + "SHARE_SESSION_PERMISSION": 3, + "SHARE_SESSIONS_DELETE_BY_LINK": 3, + "SHARE_SESSIONS_DELETE_EXPIRED": 1, + } + for backend in ("sqlite", "mysql"): + for name, expected in names.items(): + sql = query_sql(backend, name) + assert sql.count("?") == expected, (backend, name, sql) + for name, expected in names.items(): + sql = query_sql("postgres", name) + parameters = [int(value) for value in re.findall(r"\$(\d+)", sql)] + assert sorted(set(parameters)) == list(range(1, expected + 1)), (name, sql) + + auth = read("src/auth/mod.rs") + 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")] + assert "normalize_share_expiration" in auth + assert "SHARE_SESSIONS_DELETE_BY_LINK" in auth + assert "share_session_permission" in auth + assert "invalid share session expiration in database" in auth + assert "invalid share session permission in database" in auth + assert "valid_share_token" in auth + 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 + 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 + + api = read("src/api/mod.rs") + assert "combined_token_access_level" not in api + request_access = api[api.index("async fn request_access_level"):api.index("fn access_level_name")] + for required in ("share_session_token", "resource_token", "authorization_token", "account_token_access_level"): + assert required in request_access + + access_tokens = read("src/api/access_tokens.rs") + assert "verify_password_access_token" in access_tokens + assert "share_access_permission" in access_tokens + + websocket = read("src/websocket/mod.rs") + read("src/websocket/pad.rs") + assert "cookie_share_session_token" in websocket + assert "cookie_password_token" in websocket + assert "explicit_access_token.or(" not in websocket + assert "verify_password_access_token" in websocket + assert "Access expired or revoked" in websocket + assert "access_refresh" in websocket + assert websocket.count("update=updates.recv()=>{") == 2 + assert websocket.count('message:"Access expired or revoked"') >= 6 + + pages = read("src/app/pages.rs") + assert "canonical_resource_url" in pages + assert "RawQuery" in pages + assert "share_token_from_query" in pages + assert "decode_query_component" in pages + assert "share_session_cookie" in pages + assert "Ok(None) => None" in pages + assert "clear_share_session_cookie" not in pages + assert "no-store, max-age=0" in pages + assert "no-referrer" in pages + assert 'decode_query_component(name).as_deref() != Some("share")' in pages + + security = read("src/security.rs") + assert "__Host-rustpad_share_" in security + assert "HttpOnly; Secure; SameSite=Lax" 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) + migration = read(f"migrations/{backend}/0026_share_link_sessions.sql") + assert "UPDATE resource_share_links SET token = NULL" in migration + assert "session_token_hash" in migration + assert "ON DELETE CASCADE" in migration + + 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 + + app = read("src/app/mod.rs") + assert "PathOnlyMakeSpan" in app + assert "request.uri().path()" in app + assert "TraceLayer::new_for_http().make_span_with(PathOnlyMakeSpan)" in app + + print("share session static regression tests: passed") + + +if __name__ == "__main__": + main()
NameCreated byParticipantsFilesRevisionsStatusUpdatedActions
${escapeHtml(note.title)}${escapeHtml(note.title)} ${escapeHtml(note.created_by || "Unknown")} ${Number(note.participant_count) || 0} ${Number(note.file_count) || 0} (${formatBytes(note.file_size_bytes)})