tokens and more

This commit is contained in:
Mateusz Gruszczyński
2026-08-01 00:15:37 +02:00
parent 6c5232ccc5
commit 1401054c71
18 changed files with 1966 additions and 285 deletions
+1
View File
@@ -12,3 +12,4 @@ Dockerfile*
docker-compose*.yml docker-compose*.yml
migrate/ migrate/
scripts/*.txt scripts/*.txt
tests/
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.2.14" version = "0.2.15"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.2.14" version = "0.2.15"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+18
View File
@@ -278,3 +278,21 @@ SQL is selected explicitly by database engine. Application code uses logical que
- `src/queries/mysql.rs` - `src/queries/mysql.rs`
PostgreSQL statements use native `$1`, `$2`, ... placeholders. Query text is not rewritten at runtime, and result-shape casts are defined independently for each engine. PostgreSQL statements use native `$1`, `$2`, ... placeholders. Query text is not rewritten at runtime, and result-shape casts are defined independently for each engine.
## Random API test data
`tests/random_data.py` creates data only through RustPad's HTTP API. It logs in, obtains a CSRF token, creates workspaces and notes, and can seed the initial Markdown content from generated text or cached snapshots of random Wikipedia pages with Wikimedia images.
```bash
export RUSTPAD_TEST_PASSWORD='test1234'
python3 tests/random_data.py \
--ip localhost \
--port 3000 \
--source wikipedia \
--notes 10000 \
--workspaces 10 \
--notes-in-workspaces 1000 \
--user test
```
`--notes-in-workspaces` is applied to every workspace. The example creates 10,000 standalone notes and another 10,000 notes inside 10 workspaces. Wikipedia mode never falls back to generated content. Use `--wikipedia-images`, `--wikipedia-attempts`, `--workers`, `--source-pool-size`, `--scheme https`, `--base-url`, or `--dry-run` as needed. The login value may be a local account e-mail or an LDAP/AD username.
+84 -42
View File
@@ -38,6 +38,7 @@ const MAX_NAME_LENGTH: usize = 80;
const MIN_PASSWORD_LENGTH: usize = 8; const MIN_PASSWORD_LENGTH: usize = 8;
const MAX_PASSWORD_LENGTH: usize = 128; const MAX_PASSWORD_LENGTH: usize = 128;
const MIN_WORKSPACE_SLUG_LENGTH: usize = 6; const MIN_WORKSPACE_SLUG_LENGTH: usize = 6;
const MAX_DOCUMENT_SIZE_BYTES: usize = 2_000_000;
fn bearer_token(headers: &HeaderMap) -> Option<&str> { fn bearer_token(headers: &HeaderMap) -> Option<&str> {
crate::security::session_token(headers) crate::security::session_token(headers)
@@ -81,9 +82,9 @@ fn requester_guest_id(headers: &HeaderMap) -> Option<&str> {
.map(str::trim) .map(str::trim)
.filter(|value| { .filter(|value| {
(16..=64).contains(&value.len()) (16..=64).contains(&value.len())
&& value && value.chars().all(|character| {
.chars() character.is_ascii_alphanumeric() || matches!(character, '-' | '_')
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) })
}) })
} }
@@ -109,9 +110,7 @@ fn pad_creator_is_requester(headers: &HeaderMap, pad: &db::Pad) -> bool {
pad.created_by_guest_id pad.created_by_guest_id
.as_deref() .as_deref()
.zip(requester_guest_id(headers)) .zip(requester_guest_id(headers))
.is_some_and(|(owner_guest_id, requester_guest_id)| { .is_some_and(|(owner_guest_id, requester_guest_id)| owner_guest_id == requester_guest_id)
owner_guest_id == requester_guest_id
})
} }
async fn has_write_permission( async fn has_write_permission(
@@ -126,7 +125,8 @@ async fn has_write_permission(
} }
let authorization = authorization_token(headers); let authorization = authorization_token(headers);
if authorization != resource if authorization != resource
&& external_token_access_level(state, kind, slug, authorization).await? >= AccessLevel::Write && external_token_access_level(state, kind, slug, authorization).await?
>= AccessLevel::Write
{ {
return Ok(true); return Ok(true);
} }
@@ -191,10 +191,7 @@ pub(crate) async fn markdown_file_references(
}) })
.map(|file| MarkdownFileReference { .map(|file| MarkdownFileReference {
filename: file.filename, filename: file.filename,
url: crate::file_urls::public_file_url( url: crate::file_urls::public_file_url(state.files_public_url.as_deref(), &file.url),
state.files_public_url.as_deref(),
&file.url,
),
mime_type: file.mime_type, mime_type: file.mime_type,
}) })
.collect()) .collect())
@@ -253,6 +250,8 @@ pub struct PublicTaskUpdateRequest {
pub struct CreateNoteRequest { pub struct CreateNoteRequest {
name: String, name: String,
#[serde(default)] #[serde(default)]
content: Option<String>,
#[serde(default)]
password: Option<String>, password: Option<String>,
#[serde(default)] #[serde(default)]
access_token: Option<String>, access_token: Option<String>,
@@ -385,14 +384,7 @@ async fn save_editor_settings(
creator_can_manage_authorship: bool, creator_can_manage_authorship: bool,
payload: EditorSettingsRequest, payload: EditorSettingsRequest,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
if !has_write_permission( if !has_write_permission(state, headers, permission_kind, permission_slug).await? {
state,
headers,
permission_kind,
permission_slug,
)
.await?
{
return Err(ApiError::forbidden( return Err(ApiError::forbidden(
"Read and write access is required to save editor preferences", "Read and write access is required to save editor preferences",
)); ));
@@ -416,13 +408,13 @@ async fn save_editor_settings(
let can_manage_authorship = if wants_global_update { let can_manage_authorship = if wants_global_update {
creator_can_manage_authorship creator_can_manage_authorship
|| crate::auth::is_resource_owner( || crate::auth::is_resource_owner(
state, state,
permission_kind, permission_kind,
permission_slug, permission_slug,
user_session_token(headers), user_session_token(headers),
) )
.await .await
.unwrap_or(false) .unwrap_or(false)
} else { } else {
false false
}; };
@@ -433,7 +425,10 @@ async fn save_editor_settings(
} }
let preferences = if wants_personal_update { let preferences = if wants_personal_update {
let user_id = user.as_ref().expect("personal preferences require a user").id; let user_id = user
.as_ref()
.expect("personal preferences require a user")
.id;
let mut preferences = db::load_editor_preferences(&state.db, user_id, resource) let mut preferences = db::load_editor_preferences(&state.db, user_id, resource)
.await? .await?
.unwrap_or_default(); .unwrap_or_default();
@@ -467,12 +462,8 @@ async fn save_editor_settings(
}; };
let resource_settings = if wants_global_update { let resource_settings = if wants_global_update {
let mut settings = db::load_resource_editor_settings( let mut settings =
&state.db, db::load_resource_editor_settings(&state.db, settings_kind, settings_slug).await?;
settings_kind,
settings_slug,
)
.await?;
if let Some(mode) = payload.authorship_mode { if let Some(mode) = payload.authorship_mode {
settings.authorship_mode = match mode.as_str() { settings.authorship_mode = match mode.as_str() {
"simple" => "simple".into(), "simple" => "simple".into(),
@@ -567,7 +558,12 @@ pub async fn open_workspace(
&state, &state,
&workspace_slug, &workspace_slug,
payload.password.as_deref(), payload.password.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()), resource_request_token(
&headers,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
),
bearer_token(&headers), bearer_token(&headers),
&headers, &headers,
) )
@@ -614,7 +610,12 @@ pub async fn create_note(
&state, &state,
&workspace_slug, &workspace_slug,
payload.password.as_deref(), payload.password.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()), resource_request_token(
&headers,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
),
bearer_token(&headers), bearer_token(&headers),
&headers, &headers,
) )
@@ -628,13 +629,19 @@ pub async fn create_note(
&state, &state,
"workspace", "workspace",
&workspace_slug, &workspace_slug,
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()), resource_request_token(
&headers,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
),
bearer_token(&headers), bearer_token(&headers),
) )
.await? .await?
}; };
require_write(level)?; require_write(level)?;
let title = validate_name(&payload.name, "Note name")?; let title = validate_name(&payload.name, "Note name")?;
let initial_content = validate_initial_content(payload.content.as_deref())?;
let base = slugify(title); let base = slugify(title);
if base.is_empty() { if base.is_empty() {
return Err(ApiError::bad_request( return Err(ApiError::bad_request(
@@ -670,6 +677,17 @@ pub async fn create_note(
created_by_guest_id.as_deref(), created_by_guest_id.as_deref(),
) )
.await?; .await?;
if let Some(content) = initial_content {
db::save_revision(
&state.db,
note.id,
workspace.id,
content,
created_by.as_deref(),
"[]",
)
.await?;
}
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(NoteListItem { Json(NoteListItem {
@@ -865,9 +883,8 @@ pub async fn set_note_editor_settings(
let note = db::find_note(&state.db, workspace.id, &note_slug) let note = db::find_note(&state.db, workspace.id, &note_slug)
.await? .await?
.ok_or_else(ApiError::not_found_note)?; .ok_or_else(ApiError::not_found_note)?;
let creator_can_manage_authorship = let creator_can_manage_authorship = note_creator_is_requester(&state, &headers, &note).await?
note_creator_is_requester(&state, &headers, &note).await? || has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
|| has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
save_editor_settings( save_editor_settings(
&state, &state,
&headers, &headers,
@@ -893,7 +910,12 @@ pub async fn history(
&workspace_slug, &workspace_slug,
&note_slug, &note_slug,
payload.password.as_deref(), payload.password.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()), resource_request_token(
&headers,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
),
bearer_token(&headers), bearer_token(&headers),
&headers, &headers,
) )
@@ -921,7 +943,12 @@ pub async fn restore(
&workspace_slug, &workspace_slug,
&note_slug, &note_slug,
payload.password.as_deref(), payload.password.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()), resource_request_token(
&headers,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
),
bearer_token(&headers), bearer_token(&headers),
&headers, &headers,
) )
@@ -935,7 +962,12 @@ pub async fn restore(
&state, &state,
"workspace", "workspace",
&workspace_slug, &workspace_slug,
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()), resource_request_token(
&headers,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
),
bearer_token(&headers), bearer_token(&headers),
) )
.await? .await?
@@ -1238,6 +1270,16 @@ fn validate_password(password: Option<&str>) -> Result<Option<&str>, ApiError> {
Ok(Some(password)) Ok(Some(password))
} }
fn validate_initial_content(content: Option<&str>) -> Result<Option<&str>, ApiError> {
let Some(content) = content.filter(|value| !value.is_empty()) else {
return Ok(None);
};
if content.len() > MAX_DOCUMENT_SIZE_BYTES {
return Err(ApiError::bad_request("The document is too large"));
}
Ok(Some(content))
}
async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result<String, ApiError> { async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result<String, ApiError> {
let base = slugify(title); let base = slugify(title);
if base.is_empty() { if base.is_empty() {
+24 -56
View File
@@ -14,6 +14,8 @@ pub struct CreatePadRequest {
name: String, name: String,
#[serde(default)] #[serde(default)]
password: Option<String>, password: Option<String>,
#[serde(default)]
content: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -58,6 +60,7 @@ pub async fn create_pad(
) -> Result<(StatusCode, Json<CreatePadResponse>), ApiError> { ) -> Result<(StatusCode, Json<CreatePadResponse>), ApiError> {
let title = validate_name(&payload.name, "Note name")?; let title = validate_name(&payload.name, "Note name")?;
let password = validate_password(payload.password.as_deref())?; let password = validate_password(payload.password.as_deref())?;
let initial_content = validate_initial_content(payload.content.as_deref())?;
let base = slugify(title); let base = slugify(title);
if base.is_empty() { if base.is_empty() {
return Err(ApiError::bad_request( return Err(ApiError::bad_request(
@@ -68,26 +71,23 @@ pub async fn create_pad(
let account_user = crate::auth::optional_user(&state, &headers) let account_user = crate::auth::optional_user(&state, &headers)
.await .await
.map_err(|e| ApiError::forbidden(&e.message))?; .map_err(|e| ApiError::forbidden(&e.message))?;
let author = account_user.as_ref().map(|user| user.nickname.clone());
let created_by_guest_id = if account_user.is_none() { let created_by_guest_id = if account_user.is_none() {
requester_guest_id(&headers) requester_guest_id(&headers)
} else { } else {
None None
}; };
let pad = db::create_pad( let pad = db::create_pad(&state.db, &slug, title, password, created_by_guest_id).await?;
&state.db, if let Some(user) = account_user.as_ref() {
&slug,
title,
password,
created_by_guest_id,
)
.await?;
if let Some(user) = account_user {
sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD)) sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD))
.bind(user.id) .bind(user.id)
.bind(&pad.slug) .bind(&pad.slug)
.execute(state.db.pool()) .execute(state.db.pool())
.await?; .await?;
} }
if let Some(content) = initial_content {
db::save_pad_revision(&state.db, pad.id, content, author.as_deref(), "[]").await?;
}
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(CreatePadResponse { Json(CreatePadResponse {
@@ -105,31 +105,17 @@ pub async fn pad_info(
let pad = db::find_pad(&state.db, &slug) let pad = db::find_pad(&state.db, &slug)
.await? .await?
.ok_or_else(ApiError::not_found_note)?; .ok_or_else(ApiError::not_found_note)?;
ensure_private_resource_access( ensure_private_resource_access(&state, &headers, "pad", &pad.slug, pad.is_private).await?;
&state,
&headers,
"pad",
&pad.slug,
pad.is_private,
)
.await?;
let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?; let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?;
let (editor_preferences, personal_editor_settings) = user_editor_preferences( let (editor_preferences, personal_editor_settings) =
&state, user_editor_preferences(&state, &headers, db::EditorPreferenceResource::Pad(pad.id))
&headers, .await?;
db::EditorPreferenceResource::Pad(pad.id),
)
.await?;
let resource_editor_settings = let resource_editor_settings =
db::load_resource_editor_settings(&state.db, "pad", &slug).await?; db::load_resource_editor_settings(&state.db, "pad", &slug).await?;
let account_owner = crate::auth::is_resource_owner( let account_owner =
&state, crate::auth::is_resource_owner(&state, "pad", &slug, user_session_token(&headers))
"pad", .await
&slug, .unwrap_or(false);
user_session_token(&headers),
)
.await
.unwrap_or(false);
let guest_owner = pad_creator_is_requester(&headers, &pad); let guest_owner = pad_creator_is_requester(&headers, &pad);
let password_write_access = has_password_write_access(&state, &headers, "pad", &slug).await?; let password_write_access = has_password_write_access(&state, &headers, "pad", &slug).await?;
let can_manage_authorship = account_owner || guest_owner || password_write_access; let can_manage_authorship = account_owner || guest_owner || password_write_access;
@@ -432,15 +418,8 @@ async fn ensure_public_page_access(
return Ok(()); return Ok(());
} }
let password_ok = db::verify_workspace_password(&workspace, password); let password_ok = db::verify_workspace_password(&workspace, password);
check_resource_password_attempt( check_resource_password_attempt(state, headers, "workspace", &slug, password, password_ok)
state, .await?;
headers,
"workspace",
&slug,
password,
password_ok,
)
.await?;
if password_ok { if password_ok {
return Ok(()); return Ok(());
} }
@@ -462,13 +441,8 @@ pub async fn public_page(
.await? .await?
.ok_or_else(ApiError::not_found_note)?; .ok_or_else(ApiError::not_found_note)?;
ensure_public_page_access(&state, &headers, &page).await?; ensure_public_page_access(&state, &headers, &page).await?;
let files = markdown_file_references( let files =
&state, markdown_file_references(&state, page.pad_id, page.note_id, Some(&page.content)).await?;
page.pad_id,
page.note_id,
Some(&page.content),
)
.await?;
Ok(Json(PublicPageResponse { Ok(Json(PublicPageResponse {
title: page.title, title: page.title,
content: page.content, content: page.content,
@@ -496,13 +470,8 @@ pub async fn update_public_task(
let page = db::update_public_task(&state.db, &token, payload.source_line, payload.checked) let page = db::update_public_task(&state.db, &token, payload.source_line, payload.checked)
.await? .await?
.ok_or_else(ApiError::not_found_note)?; .ok_or_else(ApiError::not_found_note)?;
let files = markdown_file_references( let files =
&state, markdown_file_references(&state, page.pad_id, page.note_id, Some(&page.content)).await?;
page.pad_id,
page.note_id,
Some(&page.content),
)
.await?;
Ok(Json(PublicPageResponse { Ok(Json(PublicPageResponse {
title: page.title, title: page.title,
content: page.content, content: page.content,
@@ -614,8 +583,7 @@ pub(super) async fn authorized_pad(
} }
if pad.password_hash.is_some() && token_level < AccessLevel::Write { if pad.password_hash.is_some() && token_level < AccessLevel::Write {
let password_ok = db::verify_pad_password(&pad, password); let password_ok = db::verify_pad_password(&pad, password);
check_resource_password_attempt(state, headers, "pad", slug, password, password_ok) check_resource_password_attempt(state, headers, "pad", slug, password, password_ok).await?;
.await?;
if token_level == AccessLevel::None && !password_ok { if token_level == AccessLevel::None && !password_ok {
return Err(ApiError::forbidden("Password required or incorrect.")); return Err(ApiError::forbidden("Password required or incorrect."));
} }
+23 -2
View File
@@ -10,11 +10,12 @@
mod pages; mod pages;
use axum::{ use axum::{
Json,
Router, Router,
extract::{DefaultBodyLimit, Request}, extract::{DefaultBodyLimit, Request},
http::{HeaderName, HeaderValue, StatusCode, header}, http::{HeaderName, HeaderValue, Method, StatusCode, header},
middleware::{self, Next}, middleware::{self, Next},
response::Response, response::{IntoResponse, Response},
routing::{get, post}, routing::{get, post},
}; };
use pages::*; use pages::*;
@@ -75,6 +76,7 @@ pub fn router(
get(api::download_legacy_file), get(api::download_legacy_file),
) )
.route("/api/auth/identity", post(auth::identity)) .route("/api/auth/identity", post(auth::identity))
.route("/api/security/csrf", get(crate::security::csrf_token_endpoint))
.route("/api/access-token", post(api::create_resource_access_token)) .route("/api/access-token", post(api::create_resource_access_token))
.route("/api/auth/register", post(auth::register)) .route("/api/auth/register", post(auth::register))
.route("/api/auth/login", post(auth::login)) .route("/api/auth/login", post(auth::login))
@@ -221,10 +223,29 @@ pub fn router(
HeaderValue::from_static("same-origin"), HeaderValue::from_static("same-origin"),
)) ))
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer(middleware::from_fn(require_csrf_token))
.layer(middleware::from_fn(add_non_asset_security_headers)) .layer(middleware::from_fn(add_non_asset_security_headers))
.with_state(state) .with_state(state)
} }
async fn require_csrf_token(request: Request, next: Next) -> Response {
let method = request.method();
let unsafe_method = method == Method::POST
|| method == Method::PUT
|| method == Method::PATCH
|| method == Method::DELETE;
if unsafe_method && !crate::security::csrf_request_is_valid(request.headers()) {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "Security token is missing or expired. Refresh the page and try again."
})),
)
.into_response();
}
next.run(request).await
}
async fn add_non_asset_security_headers(request: Request, next: Next) -> Response { async fn add_non_asset_security_headers(request: Request, next: Next) -> Response {
let path = request.uri().path(); let path = request.uri().path();
let is_asset = path.starts_with("/assets/"); let is_asset = path.starts_with("/assets/");
+34 -20
View File
@@ -444,7 +444,8 @@ pub async fn register(
"Account created. Check your e-mail and confirm the account before logging in." "Account created. Check your e-mail and confirm the account before logging in."
.into(), .into(),
}), }),
).into_response()); )
.into_response());
} }
let session = create_session(&state, &user).await?; let session = create_session(&state, &user).await?;
@@ -461,7 +462,8 @@ pub async fn register(
theme: session.theme, theme: session.theme,
message: "Account created.".into(), message: "Account created.".into(),
}), }),
).into_response(); )
.into_response();
response.headers_mut().insert(header::SET_COOKIE, cookie); response.headers_mut().insert(header::SET_COOKIE, cookie);
Ok(response) Ok(response)
} }
@@ -479,15 +481,19 @@ pub async fn login(
state state
.check_rate_limit(client_limit_key.clone(), 30, window) .check_rate_limit(client_limit_key.clone(), 30, window)
.await .await
.map_err(|seconds| AuthError::rate_limited(&format!( .map_err(|seconds| {
"Too many login attempts. Try again in {seconds} seconds." AuthError::rate_limited(&format!(
)))?; "Too many login attempts. Try again in {seconds} seconds."
))
})?;
state state
.check_rate_limit(limit_key.clone(), 5, window) .check_rate_limit(limit_key.clone(), 5, window)
.await .await
.map_err(|seconds| AuthError::rate_limited(&format!( .map_err(|seconds| {
"Too many login attempts. Try again in {seconds} seconds." AuthError::rate_limited(&format!(
)))?; "Too many login attempts. Try again in {seconds} seconds."
))
})?;
let session = if state.ldap.is_some() { let session = if state.ldap.is_some() {
ldap::login(&state, &req.email, &req.password).await? ldap::login(&state, &req.email, &req.password).await?
} else { } else {
@@ -1816,9 +1822,11 @@ pub async fn request_reset(
state state
.check_rate_limit(format!("password-reset-client:{client_key}"), 10, window) .check_rate_limit(format!("password-reset-client:{client_key}"), 10, window)
.await .await
.map_err(|seconds| AuthError::rate_limited(&format!( .map_err(|seconds| {
"Too many password reset requests. Try again in {seconds} seconds." AuthError::rate_limited(&format!(
)))?; "Too many password reset requests. Try again in {seconds} seconds."
))
})?;
state state
.check_rate_limit( .check_rate_limit(
format!("password-reset:{client_key}:{}", normalize(&email)), format!("password-reset:{client_key}:{}", normalize(&email)),
@@ -1826,9 +1834,11 @@ pub async fn request_reset(
window, window,
) )
.await .await
.map_err(|seconds| AuthError::rate_limited(&format!( .map_err(|seconds| {
"Too many password reset requests. Try again in {seconds} seconds." AuthError::rate_limited(&format!(
)))?; "Too many password reset requests. Try again in {seconds} seconds."
))
})?;
info!(email_domain = %email_domain(&email), "password reset requested"); info!(email_domain = %email_domain(&email), "password reset requested");
let smtp = state.smtp.as_ref().ok_or_else(|| { let smtp = state.smtp.as_ref().ok_or_else(|| {
AuthError::service_unavailable("Password reset is not configured on this server.") AuthError::service_unavailable("Password reset is not configured on this server.")
@@ -1883,15 +1893,19 @@ pub async fn confirm_reset(
state state
.check_rate_limit(client_limit_key.clone(), 20, window) .check_rate_limit(client_limit_key.clone(), 20, window)
.await .await
.map_err(|seconds| AuthError::rate_limited(&format!( .map_err(|seconds| {
"Too many reset attempts. Try again in {seconds} seconds." AuthError::rate_limited(&format!(
)))?; "Too many reset attempts. Try again in {seconds} seconds."
))
})?;
state state
.check_rate_limit(limit_key.clone(), 10, window) .check_rate_limit(limit_key.clone(), 10, window)
.await .await
.map_err(|seconds| AuthError::rate_limited(&format!( .map_err(|seconds| {
"Too many reset attempts. Try again in {seconds} seconds." AuthError::rate_limited(&format!(
)))?; "Too many reset attempts. Try again in {seconds} seconds."
))
})?;
info!("password reset confirmation requested"); info!("password reset confirmation requested");
let now_time = Utc::now(); let now_time = Utc::now();
let now = now_time.to_rfc3339(); let now = now_time.to_rfc3339();
+118 -1
View File
@@ -7,10 +7,26 @@
* See LICENSE file in repository root for details. * See LICENSE file in repository root for details.
*/ */
use axum::http::{HeaderMap, HeaderValue, Uri, header}; use axum::{
Json,
http::{HeaderMap, HeaderValue, Uri, header},
response::{IntoResponse, Response},
};
use rand_core::{OsRng, RngCore};
use serde::Serialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
pub const SESSION_COOKIE: &str = "__Host-rustpad_session"; pub const SESSION_COOKIE: &str = "__Host-rustpad_session";
pub const CSRF_COOKIE: &str = "__Host-rustpad_csrf";
pub const CSRF_HEADER: &str = "x-rustpad-csrf";
const CSRF_TOKEN_BYTES: usize = 32;
const CSRF_TTL_SECONDS: i64 = 24 * 60 * 60;
#[derive(Serialize)]
pub struct CsrfResponse {
token: String,
}
pub fn session_token(headers: &HeaderMap) -> Option<&str> { pub fn session_token(headers: &HeaderMap) -> Option<&str> {
session_cookie_token(headers) session_cookie_token(headers)
@@ -42,6 +58,44 @@ pub fn clear_session_cookie() -> HeaderValue {
clear_cookie(SESSION_COOKIE) clear_cookie(SESSION_COOKIE)
} }
pub async fn csrf_token_endpoint(headers: HeaderMap) -> Response {
let token = csrf_cookie_token(&headers)
.filter(|value| valid_csrf_token(value))
.map(str::to_owned)
.unwrap_or_else(random_csrf_token);
let mut response = Json(CsrfResponse {
token: token.clone(),
})
.into_response();
response
.headers_mut()
.insert(header::SET_COOKIE, csrf_cookie(&token));
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("no-store, max-age=0"),
);
response
}
pub fn csrf_request_is_valid(headers: &HeaderMap) -> bool {
let Some(cookie) = csrf_cookie_token(headers).filter(|value| valid_csrf_token(value)) else {
return false;
};
let Some(provided) = headers
.get(CSRF_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| valid_csrf_token(value))
else {
return false;
};
constant_time_eq(cookie.as_bytes(), provided.as_bytes())
}
pub fn csrf_cookie_token(headers: &HeaderMap) -> Option<&str> {
cookie_value(headers, CSRF_COOKIE)
}
pub fn resource_cookie(kind: &str, slug: &str, token: &str, ttl_days: i64) -> HeaderValue { pub fn resource_cookie(kind: &str, slug: &str, token: &str, ttl_days: i64) -> HeaderValue {
secure_cookie( secure_cookie(
&resource_cookie_name(kind, slug), &resource_cookie_name(kind, slug),
@@ -127,6 +181,37 @@ fn secure_cookie(name: &str, value: &str, max_age: i64) -> HeaderValue {
.expect("valid secure cookie") .expect("valid secure cookie")
} }
fn csrf_cookie(token: &str) -> HeaderValue {
HeaderValue::from_str(&format!(
"{CSRF_COOKIE}={token}; Path=/; Max-Age={}; Secure; SameSite=Strict",
CSRF_TTL_SECONDS
))
.expect("valid csrf cookie")
}
fn random_csrf_token() -> String {
let mut bytes = [0_u8; CSRF_TOKEN_BYTES];
let mut rng = OsRng;
rng.fill_bytes(&mut bytes);
hex::encode(bytes)
}
fn valid_csrf_token(value: &str) -> bool {
value.len() == CSRF_TOKEN_BYTES * 2 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() {
return false;
}
left.iter()
.zip(right)
.fold(0_u8, |difference, (left, right)| {
difference | (*left ^ *right)
})
== 0
}
fn clear_cookie(name: &str) -> HeaderValue { fn clear_cookie(name: &str) -> HeaderValue {
HeaderValue::from_str(&format!( HeaderValue::from_str(&format!(
"{name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax" "{name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax"
@@ -232,4 +317,36 @@ mod tests {
assert!(value.contains("SameSite=Lax")); assert!(value.contains("SameSite=Lax"));
assert!(value.starts_with("__Host-rustpad_session=abc123;")); assert!(value.starts_with("__Host-rustpad_session=abc123;"));
} }
#[test]
fn csrf_requires_matching_cookie_and_header() {
let token = "a".repeat(CSRF_TOKEN_BYTES * 2);
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
HeaderValue::from_str(&format!("{CSRF_COOKIE}={token}")).unwrap(),
);
headers.insert(
axum::http::HeaderName::from_static(CSRF_HEADER),
HeaderValue::from_str(&token).unwrap(),
);
assert!(csrf_request_is_valid(&headers));
headers.insert(
axum::http::HeaderName::from_static(CSRF_HEADER),
HeaderValue::from_static(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
),
);
assert!(!csrf_request_is_valid(&headers));
}
#[test]
fn csrf_cookie_is_strict_and_script_readable() {
let token = "a".repeat(CSRF_TOKEN_BYTES * 2);
let value = csrf_cookie(&token).to_str().unwrap();
assert!(value.contains("Secure"));
assert!(value.contains("SameSite=Strict"));
assert!(!value.contains("HttpOnly"));
}
} }
+184 -5
View File
@@ -7,11 +7,12 @@ use axum::{
Path, State, WebSocketUpgrade, Path, State, WebSocketUpgrade,
ws::{Message, WebSocket}, ws::{Message, WebSocket},
}, },
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
@@ -19,6 +20,92 @@ mod pad;
pub use pad::upgrade_pad; pub use pad::upgrade_pad;
const HEARTBEAT_INTERVAL_MS: u64 = 10_000;
const HEARTBEAT_TIMEOUT_MS: u64 = 30_000;
const MAX_RECONNECT_DELAY_MS: u64 = 12_000;
const LATENCY_SAMPLE_WINDOW: usize = 20;
#[derive(Debug, Clone, Default, Deserialize)]
struct ClientDiagnostics {
#[serde(default)]
language: Option<String>,
#[serde(default)]
timezone: Option<String>,
#[serde(default)]
platform: Option<String>,
#[serde(default)]
effective_type: Option<String>,
#[serde(default)]
downlink_mbps: Option<f64>,
#[serde(default)]
network_rtt_ms: Option<u64>,
#[serde(default)]
save_data: Option<bool>,
}
#[derive(Debug, Clone)]
struct RequestClientContext {
client_id: String,
user_agent: Option<String>,
accept_language: Option<String>,
request_scheme: Option<String>,
}
impl RequestClientContext {
fn from_headers(headers: &HeaderMap, client_key: &str) -> Self {
let digest = Sha256::digest(client_key.as_bytes());
Self {
client_id: hex::encode(&digest[..8]),
user_agent: diagnostic_header(headers, header::USER_AGENT.as_str(), 180),
accept_language: diagnostic_header(headers, header::ACCEPT_LANGUAGE.as_str(), 100),
request_scheme: diagnostic_header(headers, "x-forwarded-proto", 12).or_else(|| {
headers
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split_once("://").map(|(scheme, _)| scheme))
.and_then(|value| clean_diagnostic_text(Some(value.to_owned()), 12))
}),
}
}
}
#[derive(Debug, Serialize)]
struct QualityThresholds {
excellent_max_ms: u64,
good_max_ms: u64,
degraded_max_ms: u64,
}
#[derive(Debug, Serialize)]
struct DiagnosticClient {
id: String,
user_agent: Option<String>,
accept_language: Option<String>,
request_scheme: Option<String>,
language: Option<String>,
timezone: Option<String>,
platform: Option<String>,
effective_type: Option<String>,
downlink_mbps: Option<f64>,
network_rtt_ms: Option<u64>,
save_data: Option<bool>,
}
#[derive(Debug, Serialize)]
struct ConnectionDiagnostics {
connection_id: u64,
connected_at: String,
server_time: String,
server_version: &'static str,
transport: &'static str,
heartbeat_interval_ms: u64,
heartbeat_timeout_ms: u64,
max_reconnect_delay_ms: u64,
latency_sample_window: usize,
quality_thresholds: QualityThresholds,
client: DiagnosticClient,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
enum ClientMessage { enum ClientMessage {
@@ -28,6 +115,8 @@ enum ClientMessage {
nickname: Option<String>, nickname: Option<String>,
guest_id: Option<String>, guest_id: Option<String>,
color: Option<String>, color: Option<String>,
#[serde(default)]
diagnostics: Option<ClientDiagnostics>,
}, },
Update { Update {
content: String, content: String,
@@ -71,11 +160,79 @@ enum ServerMessage {
Pong { Pong {
nonce: u64, nonce: u64,
}, },
Diagnostics {
diagnostics: ConnectionDiagnostics,
},
Error { Error {
message: String, message: String,
}, },
} }
fn connection_diagnostics(
connection_id: u64,
request: &RequestClientContext,
client: Option<ClientDiagnostics>,
) -> ConnectionDiagnostics {
let client = client.unwrap_or_default();
let now = chrono::Utc::now().to_rfc3339();
ConnectionDiagnostics {
connection_id,
connected_at: now.clone(),
server_time: now,
server_version: env!("CARGO_PKG_VERSION"),
transport: "websocket",
heartbeat_interval_ms: HEARTBEAT_INTERVAL_MS,
heartbeat_timeout_ms: HEARTBEAT_TIMEOUT_MS,
max_reconnect_delay_ms: MAX_RECONNECT_DELAY_MS,
latency_sample_window: LATENCY_SAMPLE_WINDOW,
quality_thresholds: QualityThresholds {
excellent_max_ms: 100,
good_max_ms: 250,
degraded_max_ms: 600,
},
client: DiagnosticClient {
id: request.client_id.clone(),
user_agent: request.user_agent.clone(),
accept_language: request.accept_language.clone(),
request_scheme: request.request_scheme.clone(),
language: clean_diagnostic_text(client.language, 40),
timezone: clean_diagnostic_text(client.timezone, 80),
platform: clean_diagnostic_text(client.platform, 80),
effective_type: clean_diagnostic_text(client.effective_type, 20),
downlink_mbps: client
.downlink_mbps
.filter(|value| value.is_finite())
.map(|value| value.clamp(0.0, 10_000.0)),
network_rtt_ms: client.network_rtt_ms.map(|value| value.min(120_000)),
save_data: client.save_data,
},
}
}
fn diagnostic_header(headers: &HeaderMap, name: &str, max_chars: usize) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(',').next())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned)
.and_then(|value| clean_diagnostic_text(Some(value), max_chars))
}
fn clean_diagnostic_text(value: Option<String>, max_chars: usize) -> Option<String> {
value
.map(|value| {
value
.trim()
.chars()
.filter(|character| !character.is_control())
.take(max_chars)
.collect::<String>()
})
.filter(|value| !value.is_empty())
}
async fn resource_permission_from_tokens( async fn resource_permission_from_tokens(
state: &SharedState, state: &SharedState,
kind: &str, kind: &str,
@@ -125,9 +282,10 @@ pub async fn upgrade(
return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response(); return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response();
} }
let account_token = crate::security::session_token(&headers).map(str::to_owned); let account_token = crate::security::session_token(&headers).map(str::to_owned);
let resource_token = crate::security::resource_token(&headers, "workspace", &workspace_slug) let resource_token =
.map(str::to_owned); crate::security::resource_token(&headers, "workspace", &workspace_slug).map(str::to_owned);
let client_key = crate::security::client_key(&headers); let client_key = crate::security::client_key(&headers);
let client_context = RequestClientContext::from_headers(&headers, &client_key);
ws.on_upgrade(move |socket| { ws.on_upgrade(move |socket| {
handle_socket( handle_socket(
socket, socket,
@@ -137,6 +295,7 @@ pub async fn upgrade(
account_token, account_token,
resource_token, resource_token,
client_key, client_key,
client_context,
) )
}) })
} }
@@ -149,6 +308,7 @@ async fn handle_socket(
cookie_session_token: Option<String>, cookie_session_token: Option<String>,
cookie_access_token: Option<String>, cookie_access_token: Option<String>,
client_key: String, client_key: String,
client_context: RequestClientContext,
) { ) {
info!(%workspace_slug, %note_slug, "note websocket connected"); info!(%workspace_slug, %note_slug, "note websocket connected");
let Some(workspace) = db::find_workspace(&state.db, &workspace_slug) let Some(workspace) = db::find_workspace(&state.db, &workspace_slug)
@@ -169,7 +329,7 @@ async fn handle_socket(
let _ = send_error(&mut socket, "Note not found").await; let _ = send_error(&mut socket, "Note not found").await;
return; return;
}; };
let (password, access_token, nickname, guest_id, color) = let (password, access_token, nickname, guest_id, color, client_diagnostics) =
match socket.recv().await { match socket.recv().await {
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) { Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Authenticate { Ok(ClientMessage::Authenticate {
@@ -178,12 +338,14 @@ async fn handle_socket(
nickname, nickname,
guest_id, guest_id,
color, color,
diagnostics,
}) => ( }) => (
password, password,
access_token, access_token,
clean_nickname(nickname), clean_nickname(nickname),
clean_guest_id(guest_id), clean_guest_id(guest_id),
clean_color(color), clean_color(color),
diagnostics,
), ),
_ => { _ => {
let _ = send_error(&mut socket, "Wymagane uwierzytelnienie").await; let _ = send_error(&mut socket, "Wymagane uwierzytelnienie").await;
@@ -249,7 +411,11 @@ async fn handle_socket(
.check_rate_limit(format!("resource-password-client:{client_key}"), 50, window) .check_rate_limit(format!("resource-password-client:{client_key}"), 50, window)
.await .await
{ {
let _ = send_error(&mut socket, &format!("Too many password attempts. Try again in {seconds} seconds.")).await; let _ = send_error(
&mut socket,
&format!("Too many password attempts. Try again in {seconds} seconds."),
)
.await;
return; return;
} }
if let Err(seconds) = state if let Err(seconds) = state
@@ -319,6 +485,19 @@ async fn handle_socket(
let _ = channel.send(RoomEvent::Presence(users)); let _ = channel.send(RoomEvent::Presence(users));
let mut last_chat = Instant::now() - Duration::from_secs(1); let mut last_chat = Instant::now() - Duration::from_secs(1);
let (mut sender, mut receiver) = socket.split(); let (mut sender, mut receiver) = socket.split();
if send_split(
&mut sender,
&ServerMessage::Diagnostics {
diagnostics: connection_diagnostics(connection_id, &client_context, client_diagnostics),
},
)
.await
.is_err()
{
let users = state.leave_room(&room_key, connection_id).await;
let _ = channel.send(RoomEvent::Presence(users));
return;
}
loop { loop {
tokio::select! { tokio::select! {
incoming=receiver.next()=>match incoming { incoming=receiver.next()=>match incoming {
+34 -17
View File
@@ -26,6 +26,9 @@ enum PadServerMessage {
Pong { Pong {
nonce: u64, nonce: u64,
}, },
Diagnostics {
diagnostics: ConnectionDiagnostics,
},
Error { Error {
message: String, message: String,
}, },
@@ -41,11 +44,19 @@ pub async fn upgrade_pad(
return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response(); return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response();
} }
let account_token = crate::security::session_token(&headers).map(str::to_owned); let account_token = crate::security::session_token(&headers).map(str::to_owned);
let resource_token = crate::security::resource_token(&headers, "pad", &slug) let resource_token = crate::security::resource_token(&headers, "pad", &slug).map(str::to_owned);
.map(str::to_owned);
let client_key = crate::security::client_key(&headers); let client_key = crate::security::client_key(&headers);
let client_context = RequestClientContext::from_headers(&headers, &client_key);
ws.on_upgrade(move |socket| { ws.on_upgrade(move |socket| {
handle_pad_socket(socket, state, slug, account_token, resource_token, client_key) handle_pad_socket(
socket,
state,
slug,
account_token,
resource_token,
client_key,
client_context,
)
}) })
} }
async fn handle_pad_socket( async fn handle_pad_socket(
@@ -55,6 +66,7 @@ async fn handle_pad_socket(
cookie_session_token: Option<String>, cookie_session_token: Option<String>,
cookie_access_token: Option<String>, cookie_access_token: Option<String>,
client_key: String, client_key: String,
client_context: RequestClientContext,
) { ) {
info!(%slug, "pad websocket connected"); info!(%slug, "pad websocket connected");
let Some(pad) = db::find_pad(&state.db, &slug).await.ok().flatten() else { let Some(pad) = db::find_pad(&state.db, &slug).await.ok().flatten() else {
@@ -68,7 +80,7 @@ async fn handle_pad_socket(
.await; .await;
return; return;
}; };
let (password, access_token, nickname, guest_id, color) = let (password, access_token, nickname, guest_id, color, client_diagnostics) =
match socket.recv().await { match socket.recv().await {
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) { Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Authenticate { Ok(ClientMessage::Authenticate {
@@ -77,12 +89,14 @@ async fn handle_pad_socket(
nickname, nickname,
guest_id, guest_id,
color, color,
diagnostics,
}) => ( }) => (
password, password,
access_token, access_token,
clean_nickname(nickname), clean_nickname(nickname),
clean_guest_id(guest_id), clean_guest_id(guest_id),
clean_color(color), clean_color(color),
diagnostics,
), ),
_ => { _ => {
let _ = send_pad( let _ = send_pad(
@@ -132,13 +146,7 @@ async fn handle_pad_socket(
) )
.await; .await;
let anonymous_token_ok = permission.is_none() let anonymous_token_ok = permission.is_none()
&& anonymous_access_from_tokens( && anonymous_access_from_tokens(&state, "pad", &slug, access_token.as_deref()).await;
&state,
"pad",
&slug,
access_token.as_deref(),
)
.await;
let password_limit_key = format!("resource-password:{client_key}:pad:{slug}"); let password_limit_key = format!("resource-password:{client_key}:pad:{slug}");
let password_attempted = password let password_attempted = password
.as_deref() .as_deref()
@@ -157,9 +165,7 @@ async fn handle_pad_socket(
let _ = send_pad( let _ = send_pad(
&mut socket, &mut socket,
&PadServerMessage::Error { &PadServerMessage::Error {
message: format!( message: format!("Too many password attempts. Try again in {seconds} seconds."),
"Too many password attempts. Try again in {seconds} seconds."
),
}, },
) )
.await; .await;
@@ -172,9 +178,7 @@ async fn handle_pad_socket(
let _ = send_pad( let _ = send_pad(
&mut socket, &mut socket,
&PadServerMessage::Error { &PadServerMessage::Error {
message: format!( message: format!("Too many password attempts. Try again in {seconds} seconds."),
"Too many password attempts. Try again in {seconds} seconds."
),
}, },
) )
.await; .await;
@@ -239,6 +243,19 @@ async fn handle_pad_socket(
let _ = channel.send(RoomEvent::Presence(users)); let _ = channel.send(RoomEvent::Presence(users));
let mut last_chat = Instant::now() - Duration::from_secs(1); let mut last_chat = Instant::now() - Duration::from_secs(1);
let (mut sender, mut receiver) = socket.split(); let (mut sender, mut receiver) = socket.split();
if send_pad_split(
&mut sender,
&PadServerMessage::Diagnostics {
diagnostics: connection_diagnostics(connection_id, &client_context, client_diagnostics),
},
)
.await
.is_err()
{
let users = state.leave_room(&room_key, connection_id).await;
let _ = channel.send(RoomEvent::Presence(users));
return;
}
loop { loop {
tokio::select! { tokio::select! {
incoming=receiver.next()=>match incoming{ incoming=receiver.next()=>match incoming{
+134 -1
View File
@@ -2772,6 +2772,110 @@ dialog::backdrop {
font-size: inherit; font-size: inherit;
} }
.footer-connection-block {
display: inline-flex;
align-items: center;
gap: 4px;
}
:is(.connection-details, .mobile-connection-details) {
position: relative;
}
:is(.connection-details, .mobile-connection-details)>summary {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--muted-2);
cursor: pointer;
list-style: none;
}
:is(.connection-details, .mobile-connection-details)>summary::-webkit-details-marker {
display: none;
}
.connection-details__chevron {
font-size: .68rem;
transition: transform .16s ease;
}
.connection-details[open] .connection-details__chevron {
transform: rotate(180deg);
}
.connection-diagnostics-popover {
position: absolute;
z-index: 55;
left: 0;
bottom: calc(100% + 9px);
width: min(430px, calc(100vw - 24px));
padding: 12px;
border: 1px solid var(--border-strong);
border-radius: 10px;
background: var(--surface-card);
box-shadow: 0 16px 40px var(--shadow-45);
color: var(--text);
font-family: inherit;
white-space: normal;
}
.connection-diagnostics-popover>strong {
display: block;
margin-bottom: 9px;
font-size: .78rem;
}
.connection-diagnostics-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
margin: 0;
}
.connection-diagnostics-grid>div {
min-width: 0;
padding: 7px 8px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--surface-inset);
}
.connection-diagnostics-grid__wide {
grid-column: 1 / -1;
}
.connection-diagnostics-grid dt {
margin: 0 0 3px;
color: var(--muted);
font-size: .62rem;
font-weight: 700;
letter-spacing: .05em;
text-transform: uppercase;
}
.connection-diagnostics-grid dd {
overflow-wrap: anywhere;
margin: 0;
color: var(--text);
font-family: inherit;
font-size: .7rem;
font-weight: 600;
line-height: 1.35;
}
:is(.connection-details, .mobile-connection-details):is(.is-quality-excellent, .is-quality-good) [data-connection-diagnostic="quality"] {
color: var(--success);
}
:is(.connection-details, .mobile-connection-details).is-quality-degraded [data-connection-diagnostic="quality"] {
color: var(--warning);
}
:is(.connection-details, .mobile-connection-details).is-quality-poor [data-connection-diagnostic="quality"] {
color: var(--danger);
}
.markdown-more { .markdown-more {
position: relative; position: relative;
} }
@@ -5160,18 +5264,46 @@ dialog::backdrop {
align-items: center; align-items: center;
gap: 5px; gap: 5px;
min-width: 0; min-width: 0;
min-height: 34px;
padding: 0 8px 0 4px; padding: 0 8px 0 4px;
border-radius: 999px;
background: var(--wash-hover);
color: var(--muted); color: var(--muted);
font-size: .72rem; font-size: .72rem;
white-space: nowrap; white-space: nowrap;
} }
.mobile-connection-details {
flex: 0 0 auto;
}
.mobile-connection-details[open]>.mobile-connection-status {
background: color-mix(in srgb, var(--accent) 24%, var(--wash-hover));
}
.mobile-connection-status #mobile-status-text { .mobile-connection-status #mobile-status-text {
max-width: 92px; max-width: 92px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
#mobile-socket-latency {
color: var(--muted-2);
font-size: .66rem;
}
.mobile-connection-diagnostics-popover {
position: fixed;
right: 12px;
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
left: auto;
width: min(430px, calc(100vw - 24px));
max-height: calc(100dvh - 88px);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.pad-page .toast { .pad-page .toast {
bottom: calc(62px + env(safe-area-inset-bottom, 0px)); bottom: calc(62px + env(safe-area-inset-bottom, 0px));
} }
@@ -5303,7 +5435,7 @@ dialog::backdrop {
@media (max-width: 720px) { @media (max-width: 720px) {
.editor-column-label { .editor-column-label {
align-items: flex-start; align-items: center;
} }
.authorship-mode-control button { .authorship-mode-control button {
@@ -6154,6 +6286,7 @@ dialog::backdrop {
.pad-page .participant-badges:empty { .pad-page .participant-badges:empty {
display: none; display: none;
} }
/* Unified RustPad resource identity used by notes and workspaces. */ /* Unified RustPad resource identity used by notes and workspaces. */
.resource-brand { .resource-brand {
display: grid; display: grid;
+112 -7
View File
@@ -163,10 +163,64 @@
</div> </div>
<footer class="editor-footer"> <footer class="editor-footer">
<div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 <div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0
words</span><span class="footer-connection-block"> · <span class="footer-status status"><span words</span>
id="status-dot" class="status__dot"></span><span <div class="footer-connection-block"> · <details id="connection-details" class="connection-details">
id="status-text">Connecting…</span></span> · <span id="socket-latency" <summary title="WebSocket connection diagnostics"><span class="footer-status status"><span
class="footer-socket-latency" title="WebSocket round-trip time">— ms</span></span> · id="status-dot" class="status__dot"></span><span
id="status-text">Connecting…</span></span><span id="socket-latency"
class="footer-socket-latency">— ms</span><span class="connection-details__chevron"
aria-hidden="true">▾</span></summary>
<div class="connection-diagnostics-popover">
<strong>Connection diagnostics</strong>
<dl class="connection-diagnostics-grid">
<div>
<dt>Quality</dt>
<dd data-connection-diagnostic="quality">Waiting</dd>
</div>
<div>
<dt>Latency</dt>
<dd data-connection-diagnostic="latency"></dd>
</div>
<div>
<dt>Jitter</dt>
<dd data-connection-diagnostic="jitter"></dd>
</div>
<div>
<dt>Uptime</dt>
<dd data-connection-diagnostic="uptime"></dd>
</div>
<div>
<dt>Reconnects</dt>
<dd data-connection-diagnostic="reconnects">0</dd>
</div>
<div>
<dt>Transport</dt>
<dd data-connection-diagnostic="transport">WebSocket</dd>
</div>
<div>
<dt>Heartbeat</dt>
<dd data-connection-diagnostic="heartbeat"></dd>
</div>
<div>
<dt>Network</dt>
<dd data-connection-diagnostic="network"></dd>
</div>
<div>
<dt>Client</dt>
<dd data-connection-diagnostic="client"></dd>
</div>
<div>
<dt>Server</dt>
<dd data-connection-diagnostic="server"></dd>
</div>
<div class="connection-diagnostics-grid__wide">
<dt>Last event</dt>
<dd data-connection-diagnostic="last-event"></dd>
</div>
</dl>
</div>
</details>
</div> ·
<details id="room-details" class="room-details"> <details id="room-details" class="room-details">
<summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread" <summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread"
hidden></span></summary> hidden></span></summary>
@@ -296,9 +350,60 @@
id="mobile-color-picker" type="color" aria-label="Change editor color"></label> id="mobile-color-picker" type="color" aria-label="Change editor color"></label>
<button id="mobile-chat-button" class="mobile-chat-button" type="button" title="Chat" <button id="mobile-chat-button" class="mobile-chat-button" type="button" title="Chat"
aria-label="Open chat">💬<span id="mobile-chat-unread" class="mobile-chat-unread" hidden></span></button> aria-label="Open chat">💬<span id="mobile-chat-unread" class="mobile-chat-unread" hidden></span></button>
<span id="mobile-connection-status" class="mobile-connection-status" title="WebSocket status"><span <details id="mobile-connection-details" class="mobile-connection-details">
id="mobile-status-dot" class="status__dot"></span><span <summary id="mobile-connection-status" class="mobile-connection-status"
id="mobile-status-text">Connecting…</span></span> title="WebSocket connection diagnostics"><span id="mobile-status-dot" class="status__dot"></span><span
id="mobile-status-text">Connecting…</span><span id="mobile-socket-latency"></span></summary>
<div class="connection-diagnostics-popover mobile-connection-diagnostics-popover">
<strong>Connection diagnostics</strong>
<dl class="connection-diagnostics-grid">
<div>
<dt>Quality</dt>
<dd data-connection-diagnostic="quality">Waiting</dd>
</div>
<div>
<dt>Latency</dt>
<dd data-connection-diagnostic="latency"></dd>
</div>
<div>
<dt>Jitter</dt>
<dd data-connection-diagnostic="jitter"></dd>
</div>
<div>
<dt>Uptime</dt>
<dd data-connection-diagnostic="uptime"></dd>
</div>
<div>
<dt>Reconnects</dt>
<dd data-connection-diagnostic="reconnects">0</dd>
</div>
<div>
<dt>Transport</dt>
<dd data-connection-diagnostic="transport">WebSocket</dd>
</div>
<div>
<dt>Heartbeat</dt>
<dd data-connection-diagnostic="heartbeat"></dd>
</div>
<div>
<dt>Network</dt>
<dd data-connection-diagnostic="network"></dd>
</div>
<div>
<dt>Client</dt>
<dd data-connection-diagnostic="client"></dd>
</div>
<div>
<dt>Server</dt>
<dd data-connection-diagnostic="server"></dd>
</div>
<div class="connection-diagnostics-grid__wide">
<dt>Last event</dt>
<dd data-connection-diagnostic="last-event"></dd>
</div>
</dl>
</div>
</details>
</div> </div>
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>
</body> </body>
+2 -3
View File
@@ -74,9 +74,8 @@
<footer class="home-footer"> <footer class="home-footer">
<div class="home-footer__inner"> <div class="home-footer__inner">
<div id="footer-account-guest" class="home-footer__account"> <div id="footer-account-guest" class="home-footer__account">
<button id="footer-login" class="footer-action" type="button">Log in</button> <button id="footer-login" class="footer-action footer-action--primary" type="button">Log in</button>
<button id="footer-register" class="footer-action footer-action--primary" type="button">Register <button id="footer-register" class="footer-action footer-action--primary" type="button">Register</button>
nickname</button>
</div> </div>
<div id="footer-account-user" class="home-footer__account" hidden> <div id="footer-account-user" class="home-footer__account" hidden>
<span id="footer-user-label" class="home-footer__user"> <span id="footer-user-label" class="home-footer__user">
+161 -116
View File
@@ -26,6 +26,37 @@ const DEFAULT_ERRORS = {
504: "The server took too long to respond. Try again.", 504: "The server took too long to respond. Try again.",
}; };
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
const CSRF_REFRESH_MS = 20 * 60 * 1000;
let csrfTokenPromise = null;
let csrfTokenFetchedAt = 0;
async function csrfToken({ refresh = false } = {}) {
if (refresh || Date.now() - csrfTokenFetchedAt >= CSRF_REFRESH_MS) {
csrfTokenPromise = null;
csrfTokenFetchedAt = 0;
}
if (!csrfTokenPromise) {
csrfTokenPromise = fetch("/api/security/csrf", {
credentials: "same-origin",
cache: "no-store",
signal: AbortSignal.timeout(5000),
}).then(async response => {
const data = await response.json().catch(() => ({}));
if (!response.ok || typeof data.token !== "string" || !data.token) {
throw requestError(response.status, data);
}
csrfTokenFetchedAt = Date.now();
return data.token;
}).catch(error => {
csrfTokenPromise = null;
csrfTokenFetchedAt = 0;
throw error;
});
}
return csrfTokenPromise;
}
function formatBytes(bytes) { function formatBytes(bytes) {
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes % (1024 * 1024) ? 1 : 0)} MB`; if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes % (1024 * 1024) ? 1 : 0)} MB`;
if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} KB`; if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} KB`;
@@ -67,10 +98,14 @@ function validateUploadSize(body) {
} }
} }
function requestHeaders(options, body) { async function requestHeaders(options, body) {
const headers = new Headers(options.headers || {}); const headers = new Headers(options.headers || {});
headers.delete("x-rustpad-user-token"); headers.delete("x-rustpad-user-token");
if (!(body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json"); if (!(body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json");
const method = String(options.method || "GET").toUpperCase();
if (!SAFE_METHODS.has(method) && !headers.has("x-rustpad-csrf")) {
headers.set("x-rustpad-csrf", await csrfToken());
}
return headers; return headers;
} }
@@ -92,10 +127,18 @@ export async function api(path, options = {}) {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 12000); const timeout = setTimeout(() => controller.abort(), 12000);
try { try {
const headers = requestHeaders(options, options.body); let headers = await requestHeaders(options, options.body);
const started = performance.now(); const started = performance.now();
logDebug("api.request", { method: options.method || "GET", path }); logDebug("api.request", { method: options.method || "GET", path });
const response = await fetch(path, { ...options, headers, signal: controller.signal }); let response = await fetch(path, { ...options, headers, credentials: "same-origin", signal: controller.signal });
if (response.status === 403 && !SAFE_METHODS.has(String(options.method || "GET").toUpperCase())) {
const preview = await response.clone().json().catch(() => ({}));
if (/security token/i.test(preview.error || "")) {
headers = new Headers(headers);
headers.set("x-rustpad-csrf", await csrfToken({ refresh: true }));
response = await fetch(path, { ...options, headers, credentials: "same-origin", signal: controller.signal });
}
}
const durationMs = Math.round(performance.now() - started); const durationMs = Math.round(performance.now() - started);
logDebug("api.response", { method: options.method || "GET", path, status: response.status, durationMs }); logDebug("api.response", { method: options.method || "GET", path, status: response.status, durationMs });
const contentType = response.headers.get("content-type") || ""; const contentType = response.headers.get("content-type") || "";
@@ -129,120 +172,122 @@ export function uploadWithProgress(path, options = {}) {
const stallTimeoutMs = Number(options.stallTimeoutMs) > 0 ? Number(options.stallTimeoutMs) : 90000; const stallTimeoutMs = Number(options.stallTimeoutMs) > 0 ? Number(options.stallTimeoutMs) : 90000;
const responseTimeoutMs = Number(options.responseTimeoutMs) > 0 ? Number(options.responseTimeoutMs) : 120000; const responseTimeoutMs = Number(options.responseTimeoutMs) > 0 ? Number(options.responseTimeoutMs) : 120000;
return new Promise((resolve, reject) => { return (async () => {
const xhr = new XMLHttpRequest(); const headers = await requestHeaders({ ...options, method }, options.body);
const headers = requestHeaders(options, options.body); return new Promise((resolve, reject) => {
const started = performance.now(); const xhr = new XMLHttpRequest();
let lastAt = started; const started = performance.now();
let lastLoaded = 0; let lastAt = started;
let speed = 0; let lastLoaded = 0;
let stallTimer = null; let speed = 0;
let responseTimer = null; let stallTimer = null;
let stalled = false; let responseTimer = null;
let responseTimedOut = false; let stalled = false;
let externallyAborted = false; let responseTimedOut = false;
let externallyAborted = false;
const clearStallTimer = () => { const clearStallTimer = () => {
clearTimeout(stallTimer); clearTimeout(stallTimer);
stallTimer = null; stallTimer = null;
}; };
const armStallTimer = () => { const armStallTimer = () => {
clearStallTimer(); clearStallTimer();
stallTimer = setTimeout(() => { stallTimer = setTimeout(() => {
stalled = true; stalled = true;
xhr.abort(); xhr.abort();
}, stallTimeoutMs); }, stallTimeoutMs);
}; };
const cleanup = () => { const cleanup = () => {
clearStallTimer(); clearStallTimer();
clearTimeout(responseTimer); clearTimeout(responseTimer);
responseTimer = null; responseTimer = null;
options.signal?.removeEventListener("abort", abortFromSignal); options.signal?.removeEventListener("abort", abortFromSignal);
}; };
const abortFromSignal = () => { const abortFromSignal = () => {
externallyAborted = true;
xhr.abort();
};
const fail = error => {
cleanup();
reject(error);
};
xhr.open(method, path, true);
xhr.responseType = "text";
for (const [name, value] of headers.entries()) xhr.setRequestHeader(name, value);
xhr.upload.addEventListener("loadstart", () => {
armStallTimer();
options.onProgress?.({ loaded: 0, total: fallbackTotal, speed: 0, percent: 0 });
});
xhr.upload.addEventListener("progress", event => {
const now = performance.now();
const elapsedSeconds = Math.max((now - lastAt) / 1000, 0.001);
const deltaBytes = Math.max(0, event.loaded - lastLoaded);
const instantaneousSpeed = deltaBytes / elapsedSeconds;
speed = speed > 0 ? speed * 0.72 + instantaneousSpeed * 0.28 : instantaneousSpeed;
lastAt = now;
lastLoaded = event.loaded;
const total = event.lengthComputable ? event.total : fallbackTotal;
const percent = total > 0 ? Math.min(100, (event.loaded / total) * 100) : null;
options.onProgress?.({ loaded: event.loaded, total, speed, percent });
if (total > 0 && event.loaded >= total) clearStallTimer();
else armStallTimer();
});
xhr.upload.addEventListener("load", event => {
clearStallTimer();
clearTimeout(responseTimer);
responseTimer = setTimeout(() => {
responseTimedOut = true;
xhr.abort();
}, responseTimeoutMs);
const total = event.lengthComputable ? event.total : fallbackTotal;
options.onProgress?.({ loaded: total || lastLoaded, total, speed, percent: total > 0 ? 100 : null, phase: "processing" });
});
xhr.addEventListener("load", () => {
cleanup();
const durationMs = Math.round(performance.now() - started);
logDebug("api.response", { method, path, status: xhr.status, durationMs });
let data = {};
try { data = xhr.responseText ? JSON.parse(xhr.responseText) : {}; } catch { }
if (xhr.status >= 200 && xhr.status < 300) {
resolve(data);
return;
}
if (xhr.status === 401) void clearSessionIfInvalid();
const error = requestError(xhr.status, data);
logWarn("api.failed", { method, path, status: xhr.status, message: error.message });
reject(error);
});
xhr.addEventListener("error", () => {
const error = new Error("Upload failed before the server returned a response. Check the connection and try again.");
logError("api.network_error", error, { method, path });
fail(error);
});
xhr.addEventListener("abort", () => {
const error = new Error(stalled
? "Upload stopped making progress. Check the connection and try again."
: responseTimedOut ? "The file was sent, but the server did not finish processing it. Try again."
: externallyAborted ? "Upload cancelled." : "Upload interrupted. Try again.");
error.name = externallyAborted ? "AbortError" : "UploadError";
logWarn(stalled ? "api.upload_stalled" : responseTimedOut ? "api.upload_response_timeout" : "api.upload_aborted", { method, path });
fail(error);
});
if (options.signal) {
if (options.signal.aborted) {
externallyAborted = true; externallyAborted = true;
const error = new Error("Upload cancelled."); xhr.abort();
error.name = "AbortError"; };
fail(error); const fail = error => {
return; cleanup();
} reject(error);
options.signal.addEventListener("abort", abortFromSignal, { once: true }); };
}
logDebug("api.request", { method, path }); xhr.open(method, path, true);
xhr.send(options.body ?? null); xhr.responseType = "text";
}); for (const [name, value] of headers.entries()) xhr.setRequestHeader(name, value);
xhr.upload.addEventListener("loadstart", () => {
armStallTimer();
options.onProgress?.({ loaded: 0, total: fallbackTotal, speed: 0, percent: 0 });
});
xhr.upload.addEventListener("progress", event => {
const now = performance.now();
const elapsedSeconds = Math.max((now - lastAt) / 1000, 0.001);
const deltaBytes = Math.max(0, event.loaded - lastLoaded);
const instantaneousSpeed = deltaBytes / elapsedSeconds;
speed = speed > 0 ? speed * 0.72 + instantaneousSpeed * 0.28 : instantaneousSpeed;
lastAt = now;
lastLoaded = event.loaded;
const total = event.lengthComputable ? event.total : fallbackTotal;
const percent = total > 0 ? Math.min(100, (event.loaded / total) * 100) : null;
options.onProgress?.({ loaded: event.loaded, total, speed, percent });
if (total > 0 && event.loaded >= total) clearStallTimer();
else armStallTimer();
});
xhr.upload.addEventListener("load", event => {
clearStallTimer();
clearTimeout(responseTimer);
responseTimer = setTimeout(() => {
responseTimedOut = true;
xhr.abort();
}, responseTimeoutMs);
const total = event.lengthComputable ? event.total : fallbackTotal;
options.onProgress?.({ loaded: total || lastLoaded, total, speed, percent: total > 0 ? 100 : null, phase: "processing" });
});
xhr.addEventListener("load", () => {
cleanup();
const durationMs = Math.round(performance.now() - started);
logDebug("api.response", { method, path, status: xhr.status, durationMs });
let data = {};
try { data = xhr.responseText ? JSON.parse(xhr.responseText) : {}; } catch { }
if (xhr.status >= 200 && xhr.status < 300) {
resolve(data);
return;
}
if (xhr.status === 401) void clearSessionIfInvalid();
const error = requestError(xhr.status, data);
logWarn("api.failed", { method, path, status: xhr.status, message: error.message });
reject(error);
});
xhr.addEventListener("error", () => {
const error = new Error("Upload failed before the server returned a response. Check the connection and try again.");
logError("api.network_error", error, { method, path });
fail(error);
});
xhr.addEventListener("abort", () => {
const error = new Error(stalled
? "Upload stopped making progress. Check the connection and try again."
: responseTimedOut ? "The file was sent, but the server did not finish processing it. Try again."
: externallyAborted ? "Upload cancelled." : "Upload interrupted. Try again.");
error.name = externallyAborted ? "AbortError" : "UploadError";
logWarn(stalled ? "api.upload_stalled" : responseTimedOut ? "api.upload_response_timeout" : "api.upload_aborted", { method, path });
fail(error);
});
if (options.signal) {
if (options.signal.aborted) {
externallyAborted = true;
const error = new Error("Upload cancelled.");
error.name = "AbortError";
fail(error);
return;
}
options.signal.addEventListener("abort", abortFromSignal, { once: true });
}
logDebug("api.request", { method, path });
xhr.send(options.body ?? null);
});
})();
} }
+65 -3
View File
@@ -26,7 +26,7 @@ import { getTheme } from "@rustpad/theme";
export function startNoteEditor(adapter) { export function startNoteEditor(adapter) {
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer"); const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog"); const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"), connectionNotice = document.querySelector("#connection-notice"), connectionNoticeTitle = document.querySelector("#connection-notice-title"), connectionNoticeMessage = document.querySelector("#connection-notice-message"); const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), mobileConnectionDetails = document.querySelector("#mobile-connection-details"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"), connectionNotice = document.querySelector("#connection-notice"), connectionNoticeTitle = document.querySelector("#connection-notice-title"), connectionNoticeMessage = document.querySelector("#connection-notice-message");
let unreadChat = 0; let unreadChat = 0;
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 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 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");
@@ -216,7 +216,65 @@ export function startNoteEditor(adapter) {
updateCurrentUser(); return info; updateCurrentUser(); return info;
} }
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...presenceUsers.map(user => { const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } renderGutter(); } function updatePresence(users) { const entries = Array.isArray(users) ? users : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...presenceUsers.map(user => { const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } renderGutter(); }
function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; } function updateLatency(ms) {
const text = Number.isFinite(ms) ? `${ms} ms` : "— ms";
socketLatency.textContent = text;
const mobileLatency = document.querySelector("#mobile-socket-latency");
if (mobileLatency) mobileLatency.textContent = text;
}
function setDiagnosticField(name, value) {
document.querySelectorAll(`[data-connection-diagnostic="${name}"]`).forEach(node => { node.textContent = value; });
}
function formatDiagnosticDuration(milliseconds) {
const seconds = Math.max(0, Math.floor(Number(milliseconds || 0) / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
}
function renderConnectionDiagnostics(snapshot = {}) {
const server = snapshot.server || {};
const runtime = snapshot.runtime || {};
const latency = runtime.latency || {};
const client = server.client || {};
const quality = latency.quality || (runtime.state === "open" ? "measuring" : runtime.state || "waiting");
const qualityLabel = quality.charAt(0).toUpperCase() + quality.slice(1);
setDiagnosticField("quality", qualityLabel);
setDiagnosticField("latency", Number.isFinite(latency.current)
? `${latency.current} ms · avg ${latency.average} ms · ${latency.minimum}${latency.maximum} ms`
: "Waiting for heartbeat");
setDiagnosticField("jitter", Number.isFinite(latency.jitter) ? `${latency.jitter} ms` : "—");
setDiagnosticField("uptime", runtime.authenticated_at
? formatDiagnosticDuration(runtime.uptime_ms)
: runtime.last_connection_uptime_ms ? `last ${formatDiagnosticDuration(runtime.last_connection_uptime_ms)}` : "—");
setDiagnosticField("reconnects", `${runtime.total_reconnects || 0}${runtime.reconnect_attempt ? ` · attempt ${runtime.reconnect_attempt}` : ""}`);
const scheme = client.request_scheme ? `${client.request_scheme.toUpperCase()} / ` : "";
setDiagnosticField("transport", `${scheme}${server.transport || "WebSocket"}`);
setDiagnosticField("heartbeat", server.heartbeat_interval_ms
? `${Math.round(server.heartbeat_interval_ms / 1000)}s ping · ${Math.round(server.heartbeat_timeout_ms / 1000)}s timeout`
: "Waiting for server policy");
const network = runtime.network || {};
const networkParts = [network.effective_type || client.effective_type];
if (Number.isFinite(network.downlink_mbps ?? client.downlink_mbps)) networkParts.push(`${network.downlink_mbps ?? client.downlink_mbps} Mb/s`);
if (Number.isFinite(network.rtt_ms ?? client.network_rtt_ms)) networkParts.push(`system RTT ${Math.round(network.rtt_ms ?? client.network_rtt_ms)} ms`);
if ((network.save_data ?? client.save_data) === true) networkParts.push("data saver");
setDiagnosticField("network", networkParts.filter(Boolean).join(" · ") || (runtime.online === false ? "Offline" : "Not exposed by browser"));
const clientParts = [client.platform, client.timezone, client.language || client.accept_language, client.id ? `id ${client.id}` : null, client.user_agent];
setDiagnosticField("client", clientParts.filter(Boolean).join(" · ") || "Waiting for server data");
setDiagnosticField("server", server.server_version ? `RustPad ${server.server_version} · connection ${server.connection_id}` : "Waiting for server data");
const lastEvent = runtime.last_close
? `Closed ${runtime.last_close.code}${runtime.last_close.reason ? `: ${runtime.last_close.reason}` : ""}`
: runtime.last_message_at ? `Message ${new Date(runtime.last_message_at).toLocaleTimeString()}` : "No messages yet";
const traffic = `${formatBytes(runtime.bytes_received)} received · ${formatBytes(runtime.bytes_sent)} sent`;
const buffered = runtime.buffered_amount ? ` · ${formatBytes(runtime.buffered_amount)} buffered` : "";
setDiagnosticField("last-event", `${lastEvent} · ${runtime.visibility || document.visibilityState} · ${traffic}${buffered}`);
for (const details of [document.querySelector("#connection-details"), document.querySelector("#mobile-connection-details")]) {
if (!details) continue;
details.classList.remove("is-quality-excellent", "is-quality-good", "is-quality-degraded", "is-quality-poor");
if (["excellent", "good", "degraded", "poor"].includes(quality)) details.classList.add(`is-quality-${quality}`);
}
}
function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); } function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); }
function appendChatMessage(message) { const empty = chatMessages.querySelector(".chat-empty"); empty?.remove(); const row = document.createElement("p"); row.className = "chat-message"; const author = document.createElement("strong"); author.textContent = message.sender; const text = document.createElement("span"); appendLinkifiedText(text, message.text); row.append(author, text); chatMessages.append(row); while (chatMessages.children.length > 100) chatMessages.firstElementChild.remove(); chatMessages.scrollTop = chatMessages.scrollHeight; if (message.sender !== nickname && !roomDetails.open) { unreadChat++; chatUnread.hidden = false; chatUnread.textContent = unreadChat > 99 ? "99+" : String(unreadChat); if (mobileChatUnread) { mobileChatUnread.hidden = false; mobileChatUnread.textContent = chatUnread.textContent; } const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(`${message.sender} wrote in RustPad`, { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } } function appendChatMessage(message) { const empty = chatMessages.querySelector(".chat-empty"); empty?.remove(); const row = document.createElement("p"); row.className = "chat-message"; const author = document.createElement("strong"); author.textContent = message.sender; const text = document.createElement("span"); appendLinkifiedText(text, message.text); row.append(author, text); chatMessages.append(row); while (chatMessages.children.length > 100) chatMessages.firstElementChild.remove(); chatMessages.scrollTop = chatMessages.scrollHeight; if (message.sender !== nickname && !roomDetails.open) { unreadChat++; chatUnread.hidden = false; chatUnread.textContent = unreadChat > 99 ? "99+" : String(unreadChat); if (mobileChatUnread) { mobileChatUnread.hidden = false; mobileChatUnread.textContent = chatUnread.textContent; } const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(`${message.sender} wrote in RustPad`, { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } }
function clearUnread() { unreadChat = 0; chatUnread.hidden = true; chatUnread.textContent = ""; if (mobileChatUnread) { mobileChatUnread.hidden = true; mobileChatUnread.textContent = ""; } document.title = document.title.replace(/^● /, ""); } function clearUnread() { unreadChat = 0; chatUnread.hidden = true; chatUnread.textContent = ""; if (mobileChatUnread) { mobileChatUnread.hidden = true; mobileChatUnread.textContent = ""; } document.title = document.title.replace(/^● /, ""); }
@@ -774,6 +832,7 @@ export function startNoteEditor(adapter) {
}, },
onPresence: updatePresence, onPresence: updatePresence,
onLatency: updateLatency, onLatency: updateLatency,
onDiagnostics: renderConnectionDiagnostics,
onChat: appendChatMessage, onChat: appendChatMessage,
onError: message => { onError: message => {
hideConnectionNotice(); hideConnectionNotice();
@@ -1281,10 +1340,13 @@ export function startNoteEditor(adapter) {
pageSettings?.querySelector("summary")?.setAttribute("title", enabled ? "Published page enabled" : "Published page disabled"); pageSettings?.querySelector("summary")?.setAttribute("title", enabled ? "Published page enabled" : "Published page disabled");
} }
document.addEventListener("pointerdown", event => { document.addEventListener("pointerdown", event => {
if (pageSettings?.open && !event.target.closest(".page-settings")) pageSettings.open = false; const target = event.target instanceof Element ? event.target : null;
if (pageSettings?.open && !target?.closest(".page-settings")) pageSettings.open = false;
if (mobileConnectionDetails?.open && (!target || !mobileConnectionDetails.contains(target))) mobileConnectionDetails.open = false;
}, { passive: true }); }, { passive: true });
document.addEventListener("keydown", event => { document.addEventListener("keydown", event => {
if (event.key === "Escape" && pageSettings?.open) pageSettings.open = false; if (event.key === "Escape" && pageSettings?.open) pageSettings.open = false;
if (event.key === "Escape" && mobileConnectionDetails?.open) mobileConnectionDetails.open = false;
}); });
const savePublicOptions = async () => adapter.publish(accessToken, publicTaskUpdates.checked, unprotectPublicPage.checked, publicPageEnabled.checked); const savePublicOptions = async () => adapter.publish(accessToken, publicTaskUpdates.checked, unprotectPublicPage.checked, publicPageEnabled.checked);
publicPageEnabled.addEventListener("change", async () => { publicPageEnabled.addEventListener("change", async () => {
+139 -8
View File
@@ -9,9 +9,10 @@
import { logError, logInfo, logWarn } from "@rustpad/logger"; import { logError, logInfo, logWarn } from "@rustpad/logger";
const HEARTBEAT_INTERVAL_MS = 10000; const DEFAULT_HEARTBEAT_INTERVAL_MS = 10000;
const HEARTBEAT_TIMEOUT_MS = 30000; const DEFAULT_HEARTBEAT_TIMEOUT_MS = 30000;
const MAX_RECONNECT_DELAY_MS = 12000; const DEFAULT_MAX_RECONNECT_DELAY_MS = 12000;
const DEFAULT_LATENCY_SAMPLE_WINDOW = 20;
class RoomSocket { class RoomSocket {
constructor(options) { constructor(options) {
@@ -23,6 +24,21 @@ class RoomSocket {
this.intentionalClose = false; this.intentionalClose = false;
this.reconnectAttempt = 0; this.reconnectAttempt = 0;
this.pendingPings = new Map(); this.pendingPings = new Map();
this.heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS;
this.heartbeatTimeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS;
this.maxReconnectDelayMs = DEFAULT_MAX_RECONNECT_DELAY_MS;
this.latencySampleWindow = DEFAULT_LATENCY_SAMPLE_WINDOW;
this.latencySamples = [];
this.totalReconnects = 0;
this.connectedAt = null;
this.authenticatedAt = null;
this.lastConnectionUptimeMs = 0;
this.lastMessageAt = null;
this.lastClose = null;
this.bytesSent = 0;
this.bytesReceived = 0;
this.serverDiagnostics = null;
this.diagnosticsTimer = null;
this.handleOnline = () => this.reconnectNow("Network connection restored."); this.handleOnline = () => this.reconnectNow("Network connection restored.");
this.handleOffline = () => this.handleNetworkOffline(); this.handleOffline = () => this.handleNetworkOffline();
this.handleVisibility = () => this.checkHeartbeat(); this.handleVisibility = () => this.checkHeartbeat();
@@ -34,9 +50,23 @@ class RoomSocket {
get url() { throw new Error("Socket URL not implemented"); } get url() { throw new Error("Socket URL not implemented"); }
get kind() { return "room"; } get kind() { return "room"; }
clientDiagnostics() {
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
return {
language: navigator.language || null,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || null,
platform: navigator.userAgentData?.platform || navigator.platform || null,
effective_type: connection?.effectiveType || null,
downlink_mbps: Number.isFinite(connection?.downlink) ? connection.downlink : null,
network_rtt_ms: Number.isFinite(connection?.rtt) ? Math.max(0, Math.round(connection.rtt)) : null,
save_data: typeof connection?.saveData === "boolean" ? connection.saveData : null,
};
}
connect() { connect() {
clearTimeout(this.reconnectTimer); clearTimeout(this.reconnectTimer);
clearInterval(this.pingTimer); clearInterval(this.pingTimer);
clearInterval(this.diagnosticsTimer);
if (this.stopped) return; if (this.stopped) return;
if (!navigator.onLine) { if (!navigator.onLine) {
this.scheduleReconnect("Your device is offline."); this.scheduleReconnect("Your device is offline.");
@@ -48,6 +78,7 @@ class RoomSocket {
attempt: this.reconnectAttempt, attempt: this.reconnectAttempt,
message: this.reconnectAttempt ? "Re-establishing the live connection." : "Opening the live connection.", message: this.reconnectAttempt ? "Re-establishing the live connection." : "Opening the live connection.",
}); });
this.emitDiagnostics();
let socket; let socket;
try { try {
@@ -62,6 +93,9 @@ class RoomSocket {
socket.addEventListener("open", () => { socket.addEventListener("open", () => {
if (socket !== this.socket || this.stopped) return; if (socket !== this.socket || this.stopped) return;
logInfo("websocket.open", { kind: this.kind }); logInfo("websocket.open", { kind: this.kind });
this.connectedAt = Date.now();
this.serverDiagnostics = null;
this.lastClose = null;
this.send({ this.send({
type: "authenticate", type: "authenticate",
password: this.password || null, password: this.password || null,
@@ -69,13 +103,17 @@ class RoomSocket {
nickname: this.nickname || null, nickname: this.nickname || null,
guest_id: this.guestId || null, guest_id: this.guestId || null,
color: this.color || null, color: this.color || null,
diagnostics: this.clientDiagnostics(),
}); });
this.emitDiagnostics();
}); });
socket.addEventListener("message", event => { socket.addEventListener("message", event => {
if (socket !== this.socket || this.stopped) return; if (socket !== this.socket || this.stopped) return;
let message; let message;
try { message = JSON.parse(event.data); } catch { return; } try { message = JSON.parse(event.data); } catch { return; }
this.lastMessageAt = Date.now();
this.bytesReceived += typeof event.data === "string" ? new Blob([event.data]).size : Number(event.data?.byteLength || 0);
if (message.type === "error") { if (message.type === "error") {
this.intentionalClose = true; this.intentionalClose = true;
this.onError?.(message.message); this.onError?.(message.message);
@@ -85,9 +123,17 @@ class RoomSocket {
if (message.type === "authenticated") { if (message.type === "authenticated") {
const restored = this.reconnectAttempt > 0; const restored = this.reconnectAttempt > 0;
this.reconnectAttempt = 0; this.reconnectAttempt = 0;
this.authenticatedAt = Date.now();
this.latencySamples = [];
this.onStatus?.("online", { restored }); this.onStatus?.("online", { restored });
this.onAuthenticated?.(message); this.onAuthenticated?.(message);
this.startPing(); this.startPing();
this.startDiagnostics();
this.emitDiagnostics();
return;
}
if (message.type === "diagnostics") {
this.applyServerDiagnostics(message.diagnostics || {});
return; return;
} }
if (message.type === "document") this.onDocument?.(message); if (message.type === "document") this.onDocument?.(message);
@@ -97,7 +143,11 @@ class RoomSocket {
const started = this.pendingPings.get(message.nonce); const started = this.pendingPings.get(message.nonce);
if (started !== undefined) { if (started !== undefined) {
this.pendingPings.delete(message.nonce); this.pendingPings.delete(message.nonce);
this.onLatency?.(Math.max(0, Math.round(performance.now() - started))); const latency = Math.max(0, Math.round(performance.now() - started));
this.latencySamples.push(latency);
while (this.latencySamples.length > this.latencySampleWindow) this.latencySamples.shift();
this.onLatency?.(latency);
this.emitDiagnostics();
} }
} }
}); });
@@ -105,9 +155,16 @@ class RoomSocket {
socket.addEventListener("close", event => { socket.addEventListener("close", event => {
if (socket !== this.socket) return; if (socket !== this.socket) return;
clearInterval(this.pingTimer); clearInterval(this.pingTimer);
clearInterval(this.diagnosticsTimer);
this.pendingPings.clear(); this.pendingPings.clear();
this.onPresence?.([]); this.onPresence?.([]);
this.onLatency?.(null); this.onLatency?.(null);
const closedAt = Date.now();
this.lastConnectionUptimeMs = this.authenticatedAt ? Math.max(0, closedAt - this.authenticatedAt) : this.lastConnectionUptimeMs;
this.connectedAt = null;
this.authenticatedAt = null;
this.lastClose = { code: event.code, reason: event.reason || "", at: closedAt };
this.emitDiagnostics();
logWarn("websocket.close", { logWarn("websocket.close", {
kind: this.kind, kind: this.kind,
code: event.code, code: event.code,
@@ -138,9 +195,11 @@ class RoomSocket {
if (this.stopped) return; if (this.stopped) return;
clearTimeout(this.reconnectTimer); clearTimeout(this.reconnectTimer);
this.reconnectAttempt += 1; this.reconnectAttempt += 1;
const baseDelay = Math.min(MAX_RECONNECT_DELAY_MS, 750 * (2 ** Math.min(this.reconnectAttempt - 1, 4))); const baseDelay = Math.min(this.maxReconnectDelayMs, 750 * (2 ** Math.min(this.reconnectAttempt - 1, 4)));
const retryInMs = navigator.onLine ? baseDelay : 3000; const retryInMs = navigator.onLine ? baseDelay : 3000;
this.totalReconnects += 1;
this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt, message, retryInMs }); this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt, message, retryInMs });
this.emitDiagnostics();
this.reconnectTimer = window.setTimeout(() => this.connect(), retryInMs); this.reconnectTimer = window.setTimeout(() => this.connect(), retryInMs);
} }
@@ -164,7 +223,7 @@ class RoomSocket {
checkHeartbeat() { checkHeartbeat() {
if (this.stopped || this.socket?.readyState !== WebSocket.OPEN) return; if (this.stopped || this.socket?.readyState !== WebSocket.OPEN) return;
const now = performance.now(); const now = performance.now();
const expired = [...this.pendingPings.values()].some(started => now - started >= HEARTBEAT_TIMEOUT_MS); const expired = [...this.pendingPings.values()].some(started => now - started >= this.heartbeatTimeoutMs);
if (expired) { if (expired) {
logWarn("websocket.heartbeat_timeout", { kind: this.kind }); logWarn("websocket.heartbeat_timeout", { kind: this.kind });
this.socket.close(4000, "heartbeat timeout"); this.socket.close(4000, "heartbeat timeout");
@@ -181,12 +240,83 @@ class RoomSocket {
this.send({ type: "ping", nonce }); this.send({ type: "ping", nonce });
}; };
ping(); ping();
this.pingTimer = window.setInterval(ping, HEARTBEAT_INTERVAL_MS); this.pingTimer = window.setInterval(ping, this.heartbeatIntervalMs);
}
startDiagnostics() {
clearInterval(this.diagnosticsTimer);
this.diagnosticsTimer = window.setInterval(() => this.emitDiagnostics(), 1000);
}
applyServerDiagnostics(diagnostics) {
this.serverDiagnostics = diagnostics;
const positiveNumber = (value, fallback, min, max) => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= min && parsed <= max ? parsed : fallback;
};
this.heartbeatIntervalMs = positiveNumber(diagnostics.heartbeat_interval_ms, this.heartbeatIntervalMs, 1000, 120000);
this.heartbeatTimeoutMs = positiveNumber(diagnostics.heartbeat_timeout_ms, this.heartbeatTimeoutMs, this.heartbeatIntervalMs * 2, 300000);
this.maxReconnectDelayMs = positiveNumber(diagnostics.max_reconnect_delay_ms, this.maxReconnectDelayMs, 1000, 120000);
this.latencySampleWindow = Math.round(positiveNumber(diagnostics.latency_sample_window, this.latencySampleWindow, 3, 100));
while (this.latencySamples.length > this.latencySampleWindow) this.latencySamples.shift();
if (this.socket?.readyState === WebSocket.OPEN && this.authenticatedAt) this.startPing();
this.onServerDiagnostics?.(diagnostics);
this.emitDiagnostics();
}
latencyStats() {
if (!this.latencySamples.length) return { current: null, average: null, minimum: null, maximum: null, jitter: null, quality: "unknown" };
const samples = this.latencySamples;
const current = samples.at(-1);
const average = Math.round(samples.reduce((sum, value) => sum + value, 0) / samples.length);
const minimum = Math.min(...samples);
const maximum = Math.max(...samples);
const differences = samples.slice(1).map((value, index) => Math.abs(value - samples[index]));
const jitter = differences.length ? Math.round(differences.reduce((sum, value) => sum + value, 0) / differences.length) : 0;
const thresholds = this.serverDiagnostics?.quality_thresholds || {};
const excellent = Number(thresholds.excellent_max_ms ?? 100);
const good = Number(thresholds.good_max_ms ?? 250);
const degraded = Number(thresholds.degraded_max_ms ?? 600);
const quality = current <= excellent ? "excellent" : current <= good ? "good" : current <= degraded ? "degraded" : "poor";
return { current, average, minimum, maximum, jitter, quality };
}
emitDiagnostics() {
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
const readyStates = ["connecting", "open", "closing", "closed"];
this.onDiagnostics?.({
server: this.serverDiagnostics,
runtime: {
state: readyStates[this.socket?.readyState ?? WebSocket.CLOSED] || "closed",
online: navigator.onLine,
visibility: document.visibilityState,
uptime_ms: this.authenticatedAt ? Math.max(0, Date.now() - this.authenticatedAt) : 0,
last_connection_uptime_ms: this.lastConnectionUptimeMs,
reconnect_attempt: this.reconnectAttempt,
total_reconnects: this.totalReconnects,
connected_at: this.connectedAt,
authenticated_at: this.authenticatedAt,
last_message_at: this.lastMessageAt,
last_close: this.lastClose,
buffered_amount: this.socket?.bufferedAmount || 0,
bytes_sent: this.bytesSent,
bytes_received: this.bytesReceived,
network: {
effective_type: connection?.effectiveType || null,
downlink_mbps: Number.isFinite(connection?.downlink) ? connection.downlink : null,
rtt_ms: Number.isFinite(connection?.rtt) ? connection.rtt : null,
save_data: typeof connection?.saveData === "boolean" ? connection.saveData : null,
},
latency: this.latencyStats(),
},
});
} }
send(message) { send(message) {
if (this.socket?.readyState !== WebSocket.OPEN) return false; if (this.socket?.readyState !== WebSocket.OPEN) return false;
this.socket.send(JSON.stringify(message)); const payload = JSON.stringify(message);
this.bytesSent += new Blob([payload]).size;
this.socket.send(payload);
return true; return true;
} }
@@ -199,6 +329,7 @@ class RoomSocket {
this.intentionalClose = true; this.intentionalClose = true;
clearTimeout(this.reconnectTimer); clearTimeout(this.reconnectTimer);
clearInterval(this.pingTimer); clearInterval(this.pingTimer);
clearInterval(this.diagnosticsTimer);
window.removeEventListener("online", this.handleOnline); window.removeEventListener("online", this.handleOnline);
window.removeEventListener("offline", this.handleOffline); window.removeEventListener("offline", this.handleOffline);
document.removeEventListener("visibilitychange", this.handleVisibility); document.removeEventListener("visibilitychange", this.handleVisibility);
+829
View File
@@ -0,0 +1,829 @@
#!/usr/bin/env python3
"""Populate RustPad through its HTTP API with generated test data."""
from __future__ import annotations
import argparse
import concurrent.futures
import getpass
from html.parser import HTMLParser
import json
import mimetypes
import os
import random
import ssl
import string
import sys
import threading
import time
from dataclasses import dataclass
from http.cookies import SimpleCookie
from typing import Any, Iterable
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urljoin, urlparse
from urllib.request import HTTPSHandler, Request, build_opener
UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
WIKIPEDIA_RANDOM_URL = "https://en.wikipedia.org/wiki/Special:Random"
MAX_SOURCE_BYTES = 1_500_000
MAX_DOCUMENT_BYTES = 1_800_000
MAX_WIKIPEDIA_ATTACHMENT_BYTES = 5_000_000
WIKIMEDIA_IMAGE_HOST_SUFFIX = ".wikimedia.org"
WIKIPEDIA_IMAGE_EXTENSIONS = (".avif", ".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp")
WIKIPEDIA_IMAGE_MIME_TYPES = {
"image/avif",
"image/bmp",
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
}
class ApiFailure(RuntimeError):
def __init__(self, status: int, message: str, path: str) -> None:
super().__init__(f"{status} {path}: {message}")
self.status = status
self.message = message
self.path = path
class RustPadClient:
def __init__(
self,
base_url: str,
*,
timeout: float,
retries: int,
insecure: bool,
) -> None:
self.base_url = base_url.rstrip("/") + "/"
self.timeout = timeout
self.retries = retries
self.cookies: dict[str, str] = {}
self.csrf_token: str | None = None
self.lock = threading.RLock()
self.context = ssl._create_unverified_context() if insecure else ssl.create_default_context()
self.local = threading.local()
def _opener(self) -> Any:
opener = getattr(self.local, "opener", None)
if opener is None:
opener = build_opener(HTTPSHandler(context=self.context))
self.local.opener = opener
return opener
def _cookie_header(self) -> str:
with self.lock:
return "; ".join(f"{name}={value}" for name, value in self.cookies.items())
def _store_cookies(self, headers: Any) -> None:
values = headers.get_all("Set-Cookie") or []
if not values:
return
with self.lock:
for raw in values:
parsed = SimpleCookie()
parsed.load(raw)
for name, morsel in parsed.items():
if morsel["max-age"] == "0" or not morsel.value:
self.cookies.pop(name, None)
else:
self.cookies[name] = morsel.value
def refresh_csrf(self) -> str:
with self.lock:
data = self.request("GET", "/api/security/csrf", retry_csrf=False)
token = str(data.get("token") or "")
if not token:
raise RuntimeError("The server did not return a CSRF token.")
self.csrf_token = token
return token
def _request_bytes(
self,
method: str,
path: str,
body: bytes | None,
content_type: str | None,
*,
retry_csrf: bool = True,
) -> dict[str, Any]:
method = method.upper()
url = urljoin(self.base_url, path.lstrip("/"))
for attempt in range(self.retries + 1):
headers = {
"Accept": "application/json",
"User-Agent": "RustPad-random-data/1.0",
}
if content_type:
headers["Content-Type"] = content_type
cookie = self._cookie_header()
if cookie:
headers["Cookie"] = cookie
if method in UNSAFE_METHODS:
if not self.csrf_token:
self.refresh_csrf()
headers["X-Rustpad-CSRF"] = self.csrf_token or ""
request = Request(url, data=body, headers=headers, method=method)
try:
with self._opener().open(request, timeout=self.timeout) as response:
self._store_cookies(response.headers)
raw = response.read()
if not raw:
return {}
return json.loads(raw.decode("utf-8"))
except HTTPError as error:
self._store_cookies(error.headers)
raw = error.read()
try:
data = json.loads(raw.decode("utf-8")) if raw else {}
except (UnicodeDecodeError, json.JSONDecodeError):
data = {}
message = str(data.get("error") or error.reason or "Request failed")
if (
error.code == 403
and retry_csrf
and "security token" in message.lower()
):
with self.lock:
self.csrf_token = None
self.refresh_csrf()
return self._request_bytes(
method,
path,
body,
content_type,
retry_csrf=False,
)
if error.code in {429, 500, 502, 503, 504} and attempt < self.retries:
retry_after = error.headers.get("Retry-After")
try:
delay = float(retry_after) if retry_after else min(10.0, 0.5 * (2**attempt))
except ValueError:
delay = min(10.0, 0.5 * (2**attempt))
time.sleep(delay + random.random() * 0.25)
continue
raise ApiFailure(error.code, message, path) from error
except (URLError, TimeoutError, json.JSONDecodeError) as error:
if attempt < self.retries:
time.sleep(min(10.0, 0.5 * (2**attempt)) + random.random() * 0.25)
continue
raise RuntimeError(f"Request to {path} failed: {error}") from error
raise RuntimeError(f"Request to {path} failed after retries.")
def request(
self,
method: str,
path: str,
payload: dict[str, Any] | None = None,
*,
retry_csrf: bool = True,
) -> dict[str, Any]:
body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
content_type = "application/json" if body is not None else None
return self._request_bytes(
method,
path,
body,
content_type,
retry_csrf=retry_csrf,
)
def upload_multipart(
self,
path: str,
*,
filename: str,
data: bytes,
mime_type: str,
) -> dict[str, Any]:
boundary = f"rustpad-{''.join(random.choices(string.ascii_letters + string.digits, k=32))}"
chunks = [
f"--{boundary}\r\n".encode("ascii"),
b'Content-Disposition: form-data; name="access_token"\r\n\r\n',
b"\r\n",
f"--{boundary}\r\n".encode("ascii"),
(
"Content-Disposition: form-data; name=\"file\"; "
f"filename=\"{filename.replace(chr(34), '_')}\"\r\n"
).encode("utf-8"),
f"Content-Type: {mime_type}\r\n\r\n".encode("ascii"),
data,
b"\r\n",
f"--{boundary}--\r\n".encode("ascii"),
]
return self._request_bytes(
"POST",
path,
b"".join(chunks),
f"multipart/form-data; boundary={boundary}",
)
def login(self, user: str, password: str) -> dict[str, Any]:
self.refresh_csrf()
session = self.request(
"POST",
"/api/auth/login",
{"email": user, "password": password},
)
verified = self.request("GET", "/api/auth/me")
return verified or session
def create_pad(self, name: str, content: str) -> dict[str, Any]:
return self.request("POST", "/api/pads", {"name": name, "content": content})
def create_workspace(self, name: str) -> dict[str, Any]:
return self.request("POST", "/api/workspaces", {"name": name})
def create_workspace_note(self, workspace_slug: str, name: str, content: str) -> dict[str, Any]:
return self.request(
"POST",
f"/api/workspaces/{quote(workspace_slug, safe='')}/notes",
{"name": name, "content": content},
)
def upload_pad_attachment(self, pad_slug: str, attachment: SourceAttachment) -> dict[str, Any]:
return self.upload_multipart(
f"/api/pads/{quote(pad_slug, safe='')}/files",
filename=attachment.filename,
data=attachment.data,
mime_type=attachment.mime_type,
)
def upload_workspace_note_attachment(
self,
workspace_slug: str,
note_slug: str,
attachment: SourceAttachment,
) -> dict[str, Any]:
return self.upload_multipart(
(
f"/api/workspaces/{quote(workspace_slug, safe='')}/notes/"
f"{quote(note_slug, safe='')}/files"
),
filename=attachment.filename,
data=attachment.data,
mime_type=attachment.mime_type,
)
class ReadableHtmlParser(HTMLParser):
BLOCK_TAGS = {"h1", "h2", "h3", "h4", "p", "li", "blockquote", "pre", "br"}
SKIP_TAGS = {"script", "style", "svg", "noscript", "nav", "footer"}
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.parts: list[str] = []
self.skip_depth = 0
self.title = "Wikipedia snapshot"
self.in_title = False
self.images: list[tuple[str, str, int | None, int | None]] = []
@staticmethod
def _dimension(value: str | None) -> int | None:
if not value:
return None
try:
return int(float(value))
except ValueError:
return None
@staticmethod
def _srcset_url(value: str | None) -> str:
candidates = []
for item in str(value or "").split(","):
parts = item.strip().split()
if not parts:
continue
descriptor = parts[1] if len(parts) > 1 else "1x"
try:
weight = float(descriptor.removesuffix("w").removesuffix("x"))
except ValueError:
weight = 1.0
candidates.append((weight, parts[0]))
if not candidates:
return ""
candidates.sort()
for weight, url in candidates:
if weight >= 640:
return url
return candidates[-1][1]
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
attributes = {name.lower(): value for name, value in attrs}
if tag in self.SKIP_TAGS:
self.skip_depth += 1
if tag == "title":
self.in_title = True
if tag == "img" and not self.skip_depth:
src = str(attributes.get("data-src") or attributes.get("src") or "")
src = src or self._srcset_url(attributes.get("srcset"))
if src:
alt = " ".join(str(attributes.get("alt") or "Wikipedia image").split())
self.images.append((src, alt, self._dimension(attributes.get("width")), self._dimension(attributes.get("height"))))
if not self.skip_depth and tag in self.BLOCK_TAGS:
self.parts.append("\n")
if tag == "li":
self.parts.append("- ")
def handle_endtag(self, tag: str) -> None:
if tag == "title":
self.in_title = False
if tag in self.SKIP_TAGS and self.skip_depth:
self.skip_depth -= 1
if not self.skip_depth and tag in self.BLOCK_TAGS:
self.parts.append("\n")
def handle_data(self, data: str) -> None:
if self.skip_depth:
return
text = " ".join(data.split())
if not text:
return
if self.in_title:
self.title = text.removesuffix(" - Wikipedia")
return
self.parts.append(text + " ")
def wikipedia_images(self, source_url: str, limit: int) -> list[tuple[str, str]]:
result: list[tuple[str, str]] = []
seen: set[str] = set()
for raw_url, alt, width, height in self.images:
url = urljoin(source_url, raw_url)
parsed = urlparse(url)
host = parsed.hostname or ""
path = parsed.path.lower()
if parsed.scheme != "https" or not host.endswith(WIKIMEDIA_IMAGE_HOST_SUFFIX):
continue
if not path.endswith(WIKIPEDIA_IMAGE_EXTENSIONS):
continue
if (width is not None and width < 160) or (height is not None and height < 120):
continue
if any(fragment in path for fragment in ("/icons/", "wikimedia-button", "poweredby_mediawiki", "commons-logo")):
continue
if url in seen:
continue
seen.add(url)
safe_alt = alt.replace("[", "(").replace("]", ")").replace("\n", " ").strip()
result.append((url, safe_alt or "Wikipedia image"))
if len(result) >= limit:
break
return result
def markdown(self, source_url: str, attachments: tuple[SourceAttachment, ...]) -> str:
lines = [" ".join(line.split()) for line in "".join(self.parts).splitlines()]
lines = [line for line in lines if line]
content = "\n\n".join(lines[:350])
image_markdown = "\n\n".join(
f"[image={attachment.filename},{attachment.label}]"
for attachment in attachments
)
return (
f"# {self.title}\n\n"
f"> Test-data snapshot from Wikipedia. Source: {source_url}\n\n"
f"{image_markdown}\n\n"
f"{content}\n"
)
@dataclass(frozen=True)
class SourceAttachment:
filename: str
data: bytes
mime_type: str
label: str
@dataclass(frozen=True)
class SourceDocument:
title: str
content: str
attachments: tuple[SourceAttachment, ...] = ()
class SourcePool:
def __init__(self, documents: list[SourceDocument], seed: int | None) -> None:
self.documents = documents
self.random = random.Random(seed)
self.lock = threading.Lock()
def for_item(self, index: int, label: str) -> SourceDocument:
with self.lock:
source = self.random.choice(self.documents)
suffix = "".join(self.random.choices(string.ascii_lowercase + string.digits, k=8))
title = f"{label} {index:06d} {suffix}"
content = (
f"{source.content.rstrip()}\n\n"
f"---\n\nLoad-test item: `{label}-{index:06d}-{suffix}`\n"
)
encoded = content.encode("utf-8")
if len(encoded) > MAX_DOCUMENT_BYTES:
content = encoded[:MAX_DOCUMENT_BYTES].decode("utf-8", errors="ignore")
return SourceDocument(
title=title[:80],
content=content,
attachments=source.attachments,
)
def generated_document(index: int) -> SourceDocument:
rng = random.Random(index * 7919 + 17)
words = [
"architecture", "latency", "workspace", "revision", "markdown", "session",
"security", "collaboration", "storage", "deployment", "monitoring", "testing",
]
paragraphs = []
for paragraph_index in range(8):
sentence_words = [rng.choice(words) for _ in range(rng.randint(35, 70))]
paragraphs.append(" ".join(sentence_words).capitalize() + ".")
content = (
f"# Generated document {index}\n\n"
f"- [ ] Validate record {index}\n"
f"- [x] Generate deterministic content\n"
f"- [ ] Review WebSocket diagnostics\n\n"
+ "\n\n".join(paragraphs)
+ f"\n\n```json\n{{\"index\": {index}, \"seed\": {rng.randint(1, 999999)}}}\n```\n"
)
return SourceDocument(title=f"Generated source {index}", content=content)
def image_extension(url: str, mime_type: str) -> str:
mapping = {
"image/avif": ".avif",
"image/bmp": ".bmp",
"image/gif": ".gif",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}
if mime_type in mapping:
return mapping[mime_type]
path_extension = os.path.splitext(urlparse(url).path)[1].lower()
if path_extension in WIKIPEDIA_IMAGE_EXTENSIONS:
return ".jpg" if path_extension == ".jpeg" else path_extension
return mimetypes.guess_extension(mime_type) or ".bin"
def download_wikipedia_attachment(
opener: Any,
url: str,
label: str,
index: int,
timeout: float,
) -> SourceAttachment:
request = Request(
url,
headers={
"Accept": "image/avif,image/webp,image/png,image/jpeg,image/gif,image/bmp;q=0.9,*/*;q=0.1",
"User-Agent": "RustPad-random-data/1.2 (test data generator)",
},
)
with opener.open(request, timeout=timeout) as response:
content_length = response.headers.get("Content-Length")
if content_length:
try:
if int(content_length) > MAX_WIKIPEDIA_ATTACHMENT_BYTES:
raise RuntimeError(f"Wikipedia image exceeds {MAX_WIKIPEDIA_ATTACHMENT_BYTES} bytes")
except ValueError:
pass
mime_type = str(response.headers.get_content_type() or "application/octet-stream").lower()
if mime_type not in WIKIPEDIA_IMAGE_MIME_TYPES:
raise RuntimeError(f"Unsupported Wikipedia image type: {mime_type}")
data = response.read(MAX_WIKIPEDIA_ATTACHMENT_BYTES + 1)
if len(data) > MAX_WIKIPEDIA_ATTACHMENT_BYTES:
raise RuntimeError(f"Wikipedia image exceeds {MAX_WIKIPEDIA_ATTACHMENT_BYTES} bytes")
if not data:
raise RuntimeError("Wikipedia image is empty")
extension = image_extension(url, mime_type)
filename = f"wikipedia-{index:02d}{extension}"
safe_label = label.replace("]", ")").replace("\r", " ").replace("\n", " ").strip()
return SourceAttachment(
filename=filename,
data=data,
mime_type=mime_type,
label=safe_label or f"Wikipedia image {index}",
)
def fetch_wikipedia_snapshot(
index: int,
timeout: float,
insecure: bool,
image_limit: int,
attempts: int,
) -> SourceDocument:
context = ssl._create_unverified_context() if insecure else ssl.create_default_context()
opener = build_opener(HTTPSHandler(context=context))
last_error: Exception | None = None
for attempt in range(1, attempts + 1):
request = Request(
WIKIPEDIA_RANDOM_URL,
headers={"User-Agent": "RustPad-random-data/1.1 (test data generator)"},
)
try:
with opener.open(request, timeout=timeout) as response:
raw = response.read(MAX_SOURCE_BYTES)
source_url = response.geturl()
charset = response.headers.get_content_charset() or "utf-8"
parser = ReadableHtmlParser()
parser.feed(raw.decode(charset, errors="replace"))
image_candidates = parser.wikipedia_images(source_url, image_limit * 3)
if not image_candidates:
last_error = RuntimeError(f"Wikipedia page had no usable images (attempt {attempt}/{attempts})")
continue
attachments: list[SourceAttachment] = []
for image_url, label in image_candidates:
try:
attachments.append(
download_wikipedia_attachment(
opener,
image_url,
label,
len(attachments) + 1,
timeout,
)
)
except Exception as error: # noqa: BLE001 - another candidate may still work.
last_error = error
continue
if len(attachments) >= image_limit:
break
if not attachments:
last_error = RuntimeError(
f"Wikipedia page images could not be downloaded (attempt {attempt}/{attempts}): {last_error}"
)
continue
attachment_tuple = tuple(attachments)
content = parser.markdown(source_url, attachment_tuple)
return SourceDocument(
title=parser.title or f"Wikipedia {index}",
content=content,
attachments=attachment_tuple,
)
except Exception as error: # noqa: BLE001 - retries cover transient Wikipedia failures.
last_error = error
raise RuntimeError(f"Could not fetch a Wikipedia article with images: {last_error}")
def build_source_pool(args: argparse.Namespace, total_items: int) -> SourcePool:
pool_size = max(1, min(args.source_pool_size, max(1, total_items)))
if args.source == "generated":
return SourcePool([generated_document(index) for index in range(pool_size)], args.seed)
documents: list[SourceDocument] = []
failures = 0
with concurrent.futures.ThreadPoolExecutor(max_workers=min(args.source_workers, pool_size)) as executor:
futures = [
executor.submit(
fetch_wikipedia_snapshot,
index,
args.timeout,
args.insecure,
args.wikipedia_images,
args.wikipedia_attempts,
)
for index in range(pool_size)
]
for index, future in enumerate(concurrent.futures.as_completed(futures), start=1):
try:
documents.append(future.result())
print(f"Source snapshots: {index}/{pool_size}", end="\r", flush=True)
except Exception as error: # noqa: BLE001 - all source failures are reported below.
failures += 1
print(f"\nWikipedia source failed: {error}", file=sys.stderr)
print()
if not documents:
raise RuntimeError("No Wikipedia snapshot with a usable image could be downloaded.")
if failures:
print(
f"Using {len(documents)} Wikipedia snapshots; {failures} source downloads failed. "
"No generated fallback was added.",
file=sys.stderr,
)
return SourcePool(documents, args.seed)
class Progress:
def __init__(self, total: int) -> None:
self.total = total
self.completed = 0
self.failed = 0
self.started = time.monotonic()
self.lock = threading.Lock()
def record(self, success: bool) -> None:
with self.lock:
self.completed += 1
if not success:
self.failed += 1
if self.completed == self.total or self.completed % 100 == 0:
elapsed = max(0.001, time.monotonic() - self.started)
rate = self.completed / elapsed
print(
f"Created: {self.completed}/{self.total} | failures: {self.failed} | {rate:.1f}/s",
flush=True,
)
def execute_tasks(
tasks: Iterable[tuple[str, str, str | None, int]],
*,
client: RustPadClient,
sources: SourcePool,
workers: int,
total: int,
) -> tuple[list[str], int]:
progress = Progress(total)
failures: list[str] = []
failures_lock = threading.Lock()
def run(task: tuple[str, str, str | None, int]) -> None:
kind, label, workspace_slug, index = task
document = sources.for_item(index, label)
try:
if kind == "pad":
created = client.create_pad(document.title, document.content)
pad_slug = str(created.get("slug") or "")
if not pad_slug:
raise RuntimeError("Created pad response did not contain a slug.")
for attachment in document.attachments:
client.upload_pad_attachment(pad_slug, attachment)
else:
if not workspace_slug:
raise RuntimeError("Workspace slug is missing.")
created = client.create_workspace_note(workspace_slug, document.title, document.content)
note_slug = str(created.get("slug") or "")
if not note_slug:
raise RuntimeError("Created workspace note response did not contain a slug.")
for attachment in document.attachments:
client.upload_workspace_note_attachment(
workspace_slug,
note_slug,
attachment,
)
progress.record(True)
except Exception as error: # noqa: BLE001 - all failures are reported after the run.
with failures_lock:
if len(failures) < 50:
failures.append(str(error))
progress.record(False)
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
list(executor.map(run, tasks, chunksize=1))
return failures, progress.failed
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Create standalone notes, workspaces and workspace notes through the RustPad API.",
)
parser.add_argument("--ip", default="localhost", help="RustPad host name or IP address")
parser.add_argument("--port", type=int, default=3000, help="RustPad port")
parser.add_argument("--scheme", choices=("http", "https"), default="http")
parser.add_argument("--base-url", help="Complete base URL; overrides --ip, --port and --scheme")
parser.add_argument("--source", choices=("generated", "wikipedia"), default="generated")
parser.add_argument("--notes", type=int, default=0, help="Total number of notes to create")
parser.add_argument("--workspaces", type=int, default=0, help="Number of workspaces")
parser.add_argument(
"--notes-in-workspaces",
type=int,
default=0,
help="Number of notes from --notes distributed across all workspaces",
)
parser.add_argument("--user", required=True, help="Login e-mail or LDAP/AD username")
parser.add_argument("--password", help="Account password; otherwise RUSTPAD_TEST_PASSWORD or a prompt is used")
parser.add_argument("--workers", type=int, default=12, help="Concurrent API requests")
parser.add_argument("--source-workers", type=int, default=6, help="Concurrent website downloads")
parser.add_argument("--source-pool-size", type=int, default=40, help="Website/generated source documents reused by test items")
parser.add_argument(
"--wikipedia-images",
type=int,
default=3,
help="Maximum Wikipedia images downloaded and uploaded as attachments to each note",
)
parser.add_argument("--wikipedia-attempts", type=int, default=8, help="Random articles tried when a Wikipedia page has no usable image")
parser.add_argument("--timeout", type=float, default=30.0, help="HTTP timeout in seconds")
parser.add_argument("--retries", type=int, default=5, help="Retries for transient API errors")
parser.add_argument("--seed", type=int, help="Deterministic random seed")
parser.add_argument("--prefix", default="Load test", help="Workspace name prefix")
parser.add_argument("--insecure", action="store_true", help="Disable TLS certificate verification")
parser.add_argument("--dry-run", action="store_true", help="Print counts without writing data")
args = parser.parse_args()
for name in ("notes", "workspaces", "notes_in_workspaces"):
if getattr(args, name) < 0:
parser.error(f"--{name.replace('_', '-')} cannot be negative")
if args.workers < 1 or args.source_workers < 1 or args.source_pool_size < 1:
parser.error("worker and source-pool values must be positive")
if not 1 <= args.wikipedia_images <= 10:
parser.error("--wikipedia-images must be between 1 and 10")
if args.wikipedia_attempts < 1:
parser.error("--wikipedia-attempts must be positive")
if args.notes_in_workspaces > args.notes:
parser.error("--notes-in-workspaces cannot exceed --notes")
if args.notes_in_workspaces and not args.workspaces:
parser.error("--workspaces must be positive when --notes-in-workspaces is used")
return args
def base_url(args: argparse.Namespace) -> str:
if args.base_url:
return args.base_url.rstrip("/")
host = f"[{args.ip}]" if ":" in args.ip and not args.ip.startswith("[") else args.ip
return f"{args.scheme}://{host}:{args.port}"
def resolve_password(args: argparse.Namespace) -> str:
password = args.password or os.environ.get("RUSTPAD_TEST_PASSWORD")
if password:
return password
if not sys.stdin.isatty():
raise RuntimeError("Set --password or RUSTPAD_TEST_PASSWORD when stdin is not interactive.")
return getpass.getpass("RustPad password: ")
def main() -> int:
args = parse_args()
random.seed(args.seed)
workspace_note_total = args.notes_in_workspaces
standalone_note_total = args.notes - workspace_note_total
item_total = args.notes
per_workspace = []
if args.workspaces:
base_count, remainder = divmod(workspace_note_total, args.workspaces)
per_workspace = [
base_count + (1 if index < remainder else 0)
for index in range(args.workspaces)
]
print(
f"Target: {args.notes} notes total: {standalone_note_total} standalone and "
f"{workspace_note_total} across {args.workspaces} workspaces."
)
if per_workspace:
minimum = min(per_workspace)
maximum = max(per_workspace)
distribution = str(minimum) if minimum == maximum else f"{minimum}-{maximum}"
print(f"Workspace distribution: {distribution} notes per workspace.")
if args.dry_run:
return 0
if item_total == 0 and args.workspaces == 0:
print("Nothing to create.")
return 0
client = RustPadClient(
base_url(args),
timeout=args.timeout,
retries=args.retries,
insecure=args.insecure,
)
session = client.login(args.user, resolve_password(args))
print(f"Logged in as {session.get('nickname') or args.user}.")
sources = build_source_pool(args, max(1, item_total))
workspace_slugs: list[str] = []
for index in range(1, args.workspaces + 1):
suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
name = f"{args.prefix} workspace {index:04d} {suffix}"[:80]
workspace = client.create_workspace(name)
workspace_slugs.append(str(workspace["slug"]))
print(f"Workspaces: {index}/{args.workspaces}", end="\r", flush=True)
if args.workspaces:
print()
tasks: list[tuple[str, str, str | None, int]] = []
for index in range(1, standalone_note_total + 1):
tasks.append(("pad", f"Standalone note", None, index))
absolute_index = standalone_note_total
for workspace_index, (slug, note_count) in enumerate(
zip(workspace_slugs, per_workspace, strict=True),
start=1,
):
for _note_index in range(1, note_count + 1):
absolute_index += 1
tasks.append(("workspace-note", f"Workspace {workspace_index:04d} note", slug, absolute_index))
failures, failed_count = execute_tasks(
tasks,
client=client,
sources=sources,
workers=args.workers,
total=len(tasks),
) if tasks else ([], 0)
if failed_count:
print(f"Completed with {failed_count} failures ({len(failures)} shown):", file=sys.stderr)
for failure in failures:
print(f"- {failure}", file=sys.stderr)
return 1
print("Data generation completed successfully.")
return 0
if __name__ == "__main__":
raise SystemExit(main())