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
+2 -1
View File
@@ -11,4 +11,5 @@ README.md
Dockerfile*
docker-compose*.yml
migrate/
scripts/*.txt
scripts/*.txt
tests/
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.2.14"
version = "0.2.15"
dependencies = [
"argon2",
"aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
version = "0.2.14"
version = "0.2.15"
edition = "2024"
rust-version = "1.94"
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`
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 MAX_PASSWORD_LENGTH: usize = 128;
const MIN_WORKSPACE_SLUG_LENGTH: usize = 6;
const MAX_DOCUMENT_SIZE_BYTES: usize = 2_000_000;
fn bearer_token(headers: &HeaderMap) -> Option<&str> {
crate::security::session_token(headers)
@@ -81,9 +82,9 @@ fn requester_guest_id(headers: &HeaderMap) -> Option<&str> {
.map(str::trim)
.filter(|value| {
(16..=64).contains(&value.len())
&& value
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
&& value.chars().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
.as_deref()
.zip(requester_guest_id(headers))
.is_some_and(|(owner_guest_id, requester_guest_id)| {
owner_guest_id == requester_guest_id
})
.is_some_and(|(owner_guest_id, requester_guest_id)| owner_guest_id == requester_guest_id)
}
async fn has_write_permission(
@@ -126,7 +125,8 @@ async fn has_write_permission(
}
let authorization = authorization_token(headers);
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);
}
@@ -191,10 +191,7 @@ pub(crate) async fn markdown_file_references(
})
.map(|file| MarkdownFileReference {
filename: file.filename,
url: crate::file_urls::public_file_url(
state.files_public_url.as_deref(),
&file.url,
),
url: crate::file_urls::public_file_url(state.files_public_url.as_deref(), &file.url),
mime_type: file.mime_type,
})
.collect())
@@ -253,6 +250,8 @@ pub struct PublicTaskUpdateRequest {
pub struct CreateNoteRequest {
name: String,
#[serde(default)]
content: Option<String>,
#[serde(default)]
password: Option<String>,
#[serde(default)]
access_token: Option<String>,
@@ -385,14 +384,7 @@ async fn save_editor_settings(
creator_can_manage_authorship: bool,
payload: EditorSettingsRequest,
) -> Result<Json<serde_json::Value>, ApiError> {
if !has_write_permission(
state,
headers,
permission_kind,
permission_slug,
)
.await?
{
if !has_write_permission(state, headers, permission_kind, permission_slug).await? {
return Err(ApiError::forbidden(
"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 {
creator_can_manage_authorship
|| crate::auth::is_resource_owner(
state,
permission_kind,
permission_slug,
user_session_token(headers),
)
.await
.unwrap_or(false)
state,
permission_kind,
permission_slug,
user_session_token(headers),
)
.await
.unwrap_or(false)
} else {
false
};
@@ -433,7 +425,10 @@ async fn save_editor_settings(
}
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)
.await?
.unwrap_or_default();
@@ -467,12 +462,8 @@ async fn save_editor_settings(
};
let resource_settings = if wants_global_update {
let mut settings = db::load_resource_editor_settings(
&state.db,
settings_kind,
settings_slug,
)
.await?;
let mut settings =
db::load_resource_editor_settings(&state.db, settings_kind, settings_slug).await?;
if let Some(mode) = payload.authorship_mode {
settings.authorship_mode = match mode.as_str() {
"simple" => "simple".into(),
@@ -567,7 +558,12 @@ pub async fn open_workspace(
&state,
&workspace_slug,
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),
&headers,
)
@@ -614,7 +610,12 @@ pub async fn create_note(
&state,
&workspace_slug,
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),
&headers,
)
@@ -628,13 +629,19 @@ pub async fn create_note(
&state,
"workspace",
&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),
)
.await?
};
require_write(level)?;
let title = validate_name(&payload.name, "Note name")?;
let initial_content = validate_initial_content(payload.content.as_deref())?;
let base = slugify(title);
if base.is_empty() {
return Err(ApiError::bad_request(
@@ -670,6 +677,17 @@ pub async fn create_note(
created_by_guest_id.as_deref(),
)
.await?;
if let Some(content) = initial_content {
db::save_revision(
&state.db,
note.id,
workspace.id,
content,
created_by.as_deref(),
"[]",
)
.await?;
}
Ok((
StatusCode::CREATED,
Json(NoteListItem {
@@ -865,9 +883,8 @@ pub async fn set_note_editor_settings(
let note = db::find_note(&state.db, workspace.id, &note_slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
let creator_can_manage_authorship =
note_creator_is_requester(&state, &headers, &note).await?
|| has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
let creator_can_manage_authorship = note_creator_is_requester(&state, &headers, &note).await?
|| has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
save_editor_settings(
&state,
&headers,
@@ -893,7 +910,12 @@ pub async fn history(
&workspace_slug,
&note_slug,
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),
&headers,
)
@@ -921,7 +943,12 @@ pub async fn restore(
&workspace_slug,
&note_slug,
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),
&headers,
)
@@ -935,7 +962,12 @@ pub async fn restore(
&state,
"workspace",
&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),
)
.await?
@@ -1238,6 +1270,16 @@ fn validate_password(password: Option<&str>) -> Result<Option<&str>, ApiError> {
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> {
let base = slugify(title);
if base.is_empty() {
+24 -56
View File
@@ -14,6 +14,8 @@ pub struct CreatePadRequest {
name: String,
#[serde(default)]
password: Option<String>,
#[serde(default)]
content: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -58,6 +60,7 @@ pub async fn create_pad(
) -> Result<(StatusCode, Json<CreatePadResponse>), ApiError> {
let title = validate_name(&payload.name, "Note name")?;
let password = validate_password(payload.password.as_deref())?;
let initial_content = validate_initial_content(payload.content.as_deref())?;
let base = slugify(title);
if base.is_empty() {
return Err(ApiError::bad_request(
@@ -68,26 +71,23 @@ pub async fn create_pad(
let account_user = crate::auth::optional_user(&state, &headers)
.await
.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() {
requester_guest_id(&headers)
} else {
None
};
let pad = db::create_pad(
&state.db,
&slug,
title,
password,
created_by_guest_id,
)
.await?;
if let Some(user) = account_user {
let pad = db::create_pad(&state.db, &slug, title, password, created_by_guest_id).await?;
if let Some(user) = account_user.as_ref() {
sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD))
.bind(user.id)
.bind(&pad.slug)
.execute(state.db.pool())
.await?;
}
if let Some(content) = initial_content {
db::save_pad_revision(&state.db, pad.id, content, author.as_deref(), "[]").await?;
}
Ok((
StatusCode::CREATED,
Json(CreatePadResponse {
@@ -105,31 +105,17 @@ pub async fn pad_info(
let pad = db::find_pad(&state.db, &slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
ensure_private_resource_access(
&state,
&headers,
"pad",
&pad.slug,
pad.is_private,
)
.await?;
ensure_private_resource_access(&state, &headers, "pad", &pad.slug, pad.is_private).await?;
let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?;
let (editor_preferences, personal_editor_settings) = user_editor_preferences(
&state,
&headers,
db::EditorPreferenceResource::Pad(pad.id),
)
.await?;
let (editor_preferences, personal_editor_settings) =
user_editor_preferences(&state, &headers, db::EditorPreferenceResource::Pad(pad.id))
.await?;
let resource_editor_settings =
db::load_resource_editor_settings(&state.db, "pad", &slug).await?;
let account_owner = crate::auth::is_resource_owner(
&state,
"pad",
&slug,
user_session_token(&headers),
)
.await
.unwrap_or(false);
let account_owner =
crate::auth::is_resource_owner(&state, "pad", &slug, user_session_token(&headers))
.await
.unwrap_or(false);
let guest_owner = pad_creator_is_requester(&headers, &pad);
let password_write_access = has_password_write_access(&state, &headers, "pad", &slug).await?;
let can_manage_authorship = account_owner || guest_owner || password_write_access;
@@ -432,15 +418,8 @@ async fn ensure_public_page_access(
return Ok(());
}
let password_ok = db::verify_workspace_password(&workspace, password);
check_resource_password_attempt(
state,
headers,
"workspace",
&slug,
password,
password_ok,
)
.await?;
check_resource_password_attempt(state, headers, "workspace", &slug, password, password_ok)
.await?;
if password_ok {
return Ok(());
}
@@ -462,13 +441,8 @@ pub async fn public_page(
.await?
.ok_or_else(ApiError::not_found_note)?;
ensure_public_page_access(&state, &headers, &page).await?;
let files = markdown_file_references(
&state,
page.pad_id,
page.note_id,
Some(&page.content),
)
.await?;
let files =
markdown_file_references(&state, page.pad_id, page.note_id, Some(&page.content)).await?;
Ok(Json(PublicPageResponse {
title: page.title,
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)
.await?
.ok_or_else(ApiError::not_found_note)?;
let files = markdown_file_references(
&state,
page.pad_id,
page.note_id,
Some(&page.content),
)
.await?;
let files =
markdown_file_references(&state, page.pad_id, page.note_id, Some(&page.content)).await?;
Ok(Json(PublicPageResponse {
title: page.title,
content: page.content,
@@ -614,8 +583,7 @@ pub(super) async fn authorized_pad(
}
if pad.password_hash.is_some() && token_level < AccessLevel::Write {
let password_ok = db::verify_pad_password(&pad, password);
check_resource_password_attempt(state, headers, "pad", slug, password, password_ok)
.await?;
check_resource_password_attempt(state, headers, "pad", slug, password, password_ok).await?;
if token_level == AccessLevel::None && !password_ok {
return Err(ApiError::forbidden("Password required or incorrect."));
}
+23 -2
View File
@@ -10,11 +10,12 @@
mod pages;
use axum::{
Json,
Router,
extract::{DefaultBodyLimit, Request},
http::{HeaderName, HeaderValue, StatusCode, header},
http::{HeaderName, HeaderValue, Method, StatusCode, header},
middleware::{self, Next},
response::Response,
response::{IntoResponse, Response},
routing::{get, post},
};
use pages::*;
@@ -75,6 +76,7 @@ pub fn router(
get(api::download_legacy_file),
)
.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/auth/register", post(auth::register))
.route("/api/auth/login", post(auth::login))
@@ -221,10 +223,29 @@ pub fn router(
HeaderValue::from_static("same-origin"),
))
.layer(TraceLayer::new_for_http())
.layer(middleware::from_fn(require_csrf_token))
.layer(middleware::from_fn(add_non_asset_security_headers))
.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 {
let path = request.uri().path();
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."
.into(),
}),
).into_response());
)
.into_response());
}
let session = create_session(&state, &user).await?;
@@ -461,7 +462,8 @@ pub async fn register(
theme: session.theme,
message: "Account created.".into(),
}),
).into_response();
)
.into_response();
response.headers_mut().insert(header::SET_COOKIE, cookie);
Ok(response)
}
@@ -479,15 +481,19 @@ pub async fn login(
state
.check_rate_limit(client_limit_key.clone(), 30, window)
.await
.map_err(|seconds| AuthError::rate_limited(&format!(
"Too many login attempts. Try again in {seconds} seconds."
)))?;
.map_err(|seconds| {
AuthError::rate_limited(&format!(
"Too many login attempts. Try again in {seconds} seconds."
))
})?;
state
.check_rate_limit(limit_key.clone(), 5, window)
.await
.map_err(|seconds| AuthError::rate_limited(&format!(
"Too many login attempts. Try again in {seconds} seconds."
)))?;
.map_err(|seconds| {
AuthError::rate_limited(&format!(
"Too many login attempts. Try again in {seconds} seconds."
))
})?;
let session = if state.ldap.is_some() {
ldap::login(&state, &req.email, &req.password).await?
} else {
@@ -1816,9 +1822,11 @@ pub async fn request_reset(
state
.check_rate_limit(format!("password-reset-client:{client_key}"), 10, window)
.await
.map_err(|seconds| AuthError::rate_limited(&format!(
"Too many password reset requests. Try again in {seconds} seconds."
)))?;
.map_err(|seconds| {
AuthError::rate_limited(&format!(
"Too many password reset requests. Try again in {seconds} seconds."
))
})?;
state
.check_rate_limit(
format!("password-reset:{client_key}:{}", normalize(&email)),
@@ -1826,9 +1834,11 @@ pub async fn request_reset(
window,
)
.await
.map_err(|seconds| AuthError::rate_limited(&format!(
"Too many password reset requests. Try again in {seconds} seconds."
)))?;
.map_err(|seconds| {
AuthError::rate_limited(&format!(
"Too many password reset requests. Try again in {seconds} seconds."
))
})?;
info!(email_domain = %email_domain(&email), "password reset requested");
let smtp = state.smtp.as_ref().ok_or_else(|| {
AuthError::service_unavailable("Password reset is not configured on this server.")
@@ -1883,15 +1893,19 @@ pub async fn confirm_reset(
state
.check_rate_limit(client_limit_key.clone(), 20, window)
.await
.map_err(|seconds| AuthError::rate_limited(&format!(
"Too many reset attempts. Try again in {seconds} seconds."
)))?;
.map_err(|seconds| {
AuthError::rate_limited(&format!(
"Too many reset attempts. Try again in {seconds} seconds."
))
})?;
state
.check_rate_limit(limit_key.clone(), 10, window)
.await
.map_err(|seconds| AuthError::rate_limited(&format!(
"Too many reset attempts. Try again in {seconds} seconds."
)))?;
.map_err(|seconds| {
AuthError::rate_limited(&format!(
"Too many reset attempts. Try again in {seconds} seconds."
))
})?;
info!("password reset confirmation requested");
let now_time = Utc::now();
let now = now_time.to_rfc3339();
+118 -1
View File
@@ -7,10 +7,26 @@
* 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};
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> {
session_cookie_token(headers)
@@ -42,6 +58,44 @@ pub fn clear_session_cookie() -> HeaderValue {
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 {
secure_cookie(
&resource_cookie_name(kind, slug),
@@ -127,6 +181,37 @@ fn secure_cookie(name: &str, value: &str, max_age: i64) -> HeaderValue {
.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 {
HeaderValue::from_str(&format!(
"{name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax"
@@ -232,4 +317,36 @@ mod tests {
assert!(value.contains("SameSite=Lax"));
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,
ws::{Message, WebSocket},
},
http::{HeaderMap, StatusCode},
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Response},
};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::time::{Duration, Instant};
use tracing::{debug, info, warn};
@@ -19,6 +20,92 @@ mod 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)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ClientMessage {
@@ -28,6 +115,8 @@ enum ClientMessage {
nickname: Option<String>,
guest_id: Option<String>,
color: Option<String>,
#[serde(default)]
diagnostics: Option<ClientDiagnostics>,
},
Update {
content: String,
@@ -71,11 +160,79 @@ enum ServerMessage {
Pong {
nonce: u64,
},
Diagnostics {
diagnostics: ConnectionDiagnostics,
},
Error {
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(
state: &SharedState,
kind: &str,
@@ -125,9 +282,10 @@ pub async fn upgrade(
return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response();
}
let account_token = crate::security::session_token(&headers).map(str::to_owned);
let resource_token = crate::security::resource_token(&headers, "workspace", &workspace_slug)
.map(str::to_owned);
let resource_token =
crate::security::resource_token(&headers, "workspace", &workspace_slug).map(str::to_owned);
let client_key = crate::security::client_key(&headers);
let client_context = RequestClientContext::from_headers(&headers, &client_key);
ws.on_upgrade(move |socket| {
handle_socket(
socket,
@@ -137,6 +295,7 @@ pub async fn upgrade(
account_token,
resource_token,
client_key,
client_context,
)
})
}
@@ -149,6 +308,7 @@ async fn handle_socket(
cookie_session_token: Option<String>,
cookie_access_token: Option<String>,
client_key: String,
client_context: RequestClientContext,
) {
info!(%workspace_slug, %note_slug, "note websocket connected");
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;
return;
};
let (password, access_token, nickname, guest_id, color) =
let (password, access_token, nickname, guest_id, color, client_diagnostics) =
match socket.recv().await {
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Authenticate {
@@ -178,12 +338,14 @@ async fn handle_socket(
nickname,
guest_id,
color,
diagnostics,
}) => (
password,
access_token,
clean_nickname(nickname),
clean_guest_id(guest_id),
clean_color(color),
diagnostics,
),
_ => {
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)
.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;
}
if let Err(seconds) = state
@@ -319,6 +485,19 @@ async fn handle_socket(
let _ = channel.send(RoomEvent::Presence(users));
let mut last_chat = Instant::now() - Duration::from_secs(1);
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 {
tokio::select! {
incoming=receiver.next()=>match incoming {
+34 -17
View File
@@ -26,6 +26,9 @@ enum PadServerMessage {
Pong {
nonce: u64,
},
Diagnostics {
diagnostics: ConnectionDiagnostics,
},
Error {
message: String,
},
@@ -41,11 +44,19 @@ pub async fn upgrade_pad(
return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response();
}
let account_token = crate::security::session_token(&headers).map(str::to_owned);
let resource_token = crate::security::resource_token(&headers, "pad", &slug)
.map(str::to_owned);
let resource_token = crate::security::resource_token(&headers, "pad", &slug).map(str::to_owned);
let client_key = crate::security::client_key(&headers);
let client_context = RequestClientContext::from_headers(&headers, &client_key);
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(
@@ -55,6 +66,7 @@ async fn handle_pad_socket(
cookie_session_token: Option<String>,
cookie_access_token: Option<String>,
client_key: String,
client_context: RequestClientContext,
) {
info!(%slug, "pad websocket connected");
let Some(pad) = db::find_pad(&state.db, &slug).await.ok().flatten() else {
@@ -68,7 +80,7 @@ async fn handle_pad_socket(
.await;
return;
};
let (password, access_token, nickname, guest_id, color) =
let (password, access_token, nickname, guest_id, color, client_diagnostics) =
match socket.recv().await {
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Authenticate {
@@ -77,12 +89,14 @@ async fn handle_pad_socket(
nickname,
guest_id,
color,
diagnostics,
}) => (
password,
access_token,
clean_nickname(nickname),
clean_guest_id(guest_id),
clean_color(color),
diagnostics,
),
_ => {
let _ = send_pad(
@@ -132,13 +146,7 @@ async fn handle_pad_socket(
)
.await;
let anonymous_token_ok = permission.is_none()
&& anonymous_access_from_tokens(
&state,
"pad",
&slug,
access_token.as_deref(),
)
.await;
&& anonymous_access_from_tokens(&state, "pad", &slug, access_token.as_deref()).await;
let password_limit_key = format!("resource-password:{client_key}:pad:{slug}");
let password_attempted = password
.as_deref()
@@ -157,9 +165,7 @@ async fn handle_pad_socket(
let _ = send_pad(
&mut socket,
&PadServerMessage::Error {
message: format!(
"Too many password attempts. Try again in {seconds} seconds."
),
message: format!("Too many password attempts. Try again in {seconds} seconds."),
},
)
.await;
@@ -172,9 +178,7 @@ async fn handle_pad_socket(
let _ = send_pad(
&mut socket,
&PadServerMessage::Error {
message: format!(
"Too many password attempts. Try again in {seconds} seconds."
),
message: format!("Too many password attempts. Try again in {seconds} seconds."),
},
)
.await;
@@ -239,6 +243,19 @@ async fn handle_pad_socket(
let _ = channel.send(RoomEvent::Presence(users));
let mut last_chat = Instant::now() - Duration::from_secs(1);
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 {
tokio::select! {
incoming=receiver.next()=>match incoming{
+135 -2
View File
@@ -2772,6 +2772,110 @@ dialog::backdrop {
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 {
position: relative;
}
@@ -5160,18 +5264,46 @@ dialog::backdrop {
align-items: center;
gap: 5px;
min-width: 0;
min-height: 34px;
padding: 0 8px 0 4px;
border-radius: 999px;
background: var(--wash-hover);
color: var(--muted);
font-size: .72rem;
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 {
max-width: 92px;
overflow: hidden;
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 {
bottom: calc(62px + env(safe-area-inset-bottom, 0px));
}
@@ -5303,7 +5435,7 @@ dialog::backdrop {
@media (max-width: 720px) {
.editor-column-label {
align-items: flex-start;
align-items: center;
}
.authorship-mode-control button {
@@ -6154,6 +6286,7 @@ dialog::backdrop {
.pad-page .participant-badges:empty {
display: none;
}
/* Unified RustPad resource identity used by notes and workspaces. */
.resource-brand {
display: grid;
@@ -6222,4 +6355,4 @@ dialog::backdrop {
padding-left: 0;
font-size: .68rem;
}
}
}
+112 -7
View File
@@ -163,10 +163,64 @@
</div>
<footer class="editor-footer">
<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
id="status-dot" class="status__dot"></span><span
id="status-text">Connecting…</span></span> · <span id="socket-latency"
class="footer-socket-latency" title="WebSocket round-trip time">— ms</span></span> ·
words</span>
<div class="footer-connection-block"> · <details id="connection-details" class="connection-details">
<summary title="WebSocket connection diagnostics"><span class="footer-status status"><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">
<summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread"
hidden></span></summary>
@@ -296,9 +350,60 @@
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"
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
id="mobile-status-dot" class="status__dot"></span><span
id="mobile-status-text">Connecting…</span></span>
<details id="mobile-connection-details" class="mobile-connection-details">
<summary id="mobile-connection-status" class="mobile-connection-status"
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 id="toast" class="toast"></div>
</body>
+2 -3
View File
@@ -74,9 +74,8 @@
<footer class="home-footer">
<div class="home-footer__inner">
<div id="footer-account-guest" class="home-footer__account">
<button id="footer-login" class="footer-action" type="button">Log in</button>
<button id="footer-register" class="footer-action footer-action--primary" type="button">Register
nickname</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>
</div>
<div id="footer-account-user" class="home-footer__account" hidden>
<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.",
};
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) {
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes % (1024 * 1024) ? 1 : 0)} MB`;
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 || {});
headers.delete("x-rustpad-user-token");
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;
}
@@ -92,10 +127,18 @@ export async function api(path, options = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 12000);
try {
const headers = requestHeaders(options, options.body);
let headers = await requestHeaders(options, options.body);
const started = performance.now();
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);
logDebug("api.response", { method: options.method || "GET", path, status: response.status, durationMs });
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 responseTimeoutMs = Number(options.responseTimeoutMs) > 0 ? Number(options.responseTimeoutMs) : 120000;
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const headers = requestHeaders(options, options.body);
const started = performance.now();
let lastAt = started;
let lastLoaded = 0;
let speed = 0;
let stallTimer = null;
let responseTimer = null;
let stalled = false;
let responseTimedOut = false;
let externallyAborted = false;
return (async () => {
const headers = await requestHeaders({ ...options, method }, options.body);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const started = performance.now();
let lastAt = started;
let lastLoaded = 0;
let speed = 0;
let stallTimer = null;
let responseTimer = null;
let stalled = false;
let responseTimedOut = false;
let externallyAborted = false;
const clearStallTimer = () => {
clearTimeout(stallTimer);
stallTimer = null;
};
const armStallTimer = () => {
clearStallTimer();
stallTimer = setTimeout(() => {
stalled = true;
xhr.abort();
}, stallTimeoutMs);
};
const cleanup = () => {
clearStallTimer();
clearTimeout(responseTimer);
responseTimer = null;
options.signal?.removeEventListener("abort", 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) {
const clearStallTimer = () => {
clearTimeout(stallTimer);
stallTimer = null;
};
const armStallTimer = () => {
clearStallTimer();
stallTimer = setTimeout(() => {
stalled = true;
xhr.abort();
}, stallTimeoutMs);
};
const cleanup = () => {
clearStallTimer();
clearTimeout(responseTimer);
responseTimer = null;
options.signal?.removeEventListener("abort", abortFromSignal);
};
const abortFromSignal = () => {
externallyAborted = true;
const error = new Error("Upload cancelled.");
error.name = "AbortError";
fail(error);
return;
}
options.signal.addEventListener("abort", abortFromSignal, { once: true });
}
xhr.abort();
};
const fail = error => {
cleanup();
reject(error);
};
logDebug("api.request", { method, path });
xhr.send(options.body ?? null);
});
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;
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) {
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 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;
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");
@@ -216,7 +216,65 @@ export function startNoteEditor(adapter) {
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 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 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(/^● /, ""); }
@@ -774,6 +832,7 @@ export function startNoteEditor(adapter) {
},
onPresence: updatePresence,
onLatency: updateLatency,
onDiagnostics: renderConnectionDiagnostics,
onChat: appendChatMessage,
onError: message => {
hideConnectionNotice();
@@ -1281,10 +1340,13 @@ export function startNoteEditor(adapter) {
pageSettings?.querySelector("summary")?.setAttribute("title", enabled ? "Published page enabled" : "Published page disabled");
}
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 });
document.addEventListener("keydown", event => {
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);
publicPageEnabled.addEventListener("change", async () => {
+139 -8
View File
@@ -9,9 +9,10 @@
import { logError, logInfo, logWarn } from "@rustpad/logger";
const HEARTBEAT_INTERVAL_MS = 10000;
const HEARTBEAT_TIMEOUT_MS = 30000;
const MAX_RECONNECT_DELAY_MS = 12000;
const DEFAULT_HEARTBEAT_INTERVAL_MS = 10000;
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 30000;
const DEFAULT_MAX_RECONNECT_DELAY_MS = 12000;
const DEFAULT_LATENCY_SAMPLE_WINDOW = 20;
class RoomSocket {
constructor(options) {
@@ -23,6 +24,21 @@ class RoomSocket {
this.intentionalClose = false;
this.reconnectAttempt = 0;
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.handleOffline = () => this.handleNetworkOffline();
this.handleVisibility = () => this.checkHeartbeat();
@@ -34,9 +50,23 @@ class RoomSocket {
get url() { throw new Error("Socket URL not implemented"); }
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() {
clearTimeout(this.reconnectTimer);
clearInterval(this.pingTimer);
clearInterval(this.diagnosticsTimer);
if (this.stopped) return;
if (!navigator.onLine) {
this.scheduleReconnect("Your device is offline.");
@@ -48,6 +78,7 @@ class RoomSocket {
attempt: this.reconnectAttempt,
message: this.reconnectAttempt ? "Re-establishing the live connection." : "Opening the live connection.",
});
this.emitDiagnostics();
let socket;
try {
@@ -62,6 +93,9 @@ class RoomSocket {
socket.addEventListener("open", () => {
if (socket !== this.socket || this.stopped) return;
logInfo("websocket.open", { kind: this.kind });
this.connectedAt = Date.now();
this.serverDiagnostics = null;
this.lastClose = null;
this.send({
type: "authenticate",
password: this.password || null,
@@ -69,13 +103,17 @@ class RoomSocket {
nickname: this.nickname || null,
guest_id: this.guestId || null,
color: this.color || null,
diagnostics: this.clientDiagnostics(),
});
this.emitDiagnostics();
});
socket.addEventListener("message", event => {
if (socket !== this.socket || this.stopped) return;
let message;
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") {
this.intentionalClose = true;
this.onError?.(message.message);
@@ -85,9 +123,17 @@ class RoomSocket {
if (message.type === "authenticated") {
const restored = this.reconnectAttempt > 0;
this.reconnectAttempt = 0;
this.authenticatedAt = Date.now();
this.latencySamples = [];
this.onStatus?.("online", { restored });
this.onAuthenticated?.(message);
this.startPing();
this.startDiagnostics();
this.emitDiagnostics();
return;
}
if (message.type === "diagnostics") {
this.applyServerDiagnostics(message.diagnostics || {});
return;
}
if (message.type === "document") this.onDocument?.(message);
@@ -97,7 +143,11 @@ class RoomSocket {
const started = this.pendingPings.get(message.nonce);
if (started !== undefined) {
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 => {
if (socket !== this.socket) return;
clearInterval(this.pingTimer);
clearInterval(this.diagnosticsTimer);
this.pendingPings.clear();
this.onPresence?.([]);
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", {
kind: this.kind,
code: event.code,
@@ -138,9 +195,11 @@ class RoomSocket {
if (this.stopped) return;
clearTimeout(this.reconnectTimer);
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;
this.totalReconnects += 1;
this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt, message, retryInMs });
this.emitDiagnostics();
this.reconnectTimer = window.setTimeout(() => this.connect(), retryInMs);
}
@@ -164,7 +223,7 @@ class RoomSocket {
checkHeartbeat() {
if (this.stopped || this.socket?.readyState !== WebSocket.OPEN) return;
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) {
logWarn("websocket.heartbeat_timeout", { kind: this.kind });
this.socket.close(4000, "heartbeat timeout");
@@ -181,12 +240,83 @@ class RoomSocket {
this.send({ type: "ping", nonce });
};
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) {
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;
}
@@ -199,6 +329,7 @@ class RoomSocket {
this.intentionalClose = true;
clearTimeout(this.reconnectTimer);
clearInterval(this.pingTimer);
clearInterval(this.diagnosticsTimer);
window.removeEventListener("online", this.handleOnline);
window.removeEventListener("offline", this.handleOffline);
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())