tokens and more
This commit is contained in:
@@ -12,3 +12,4 @@ Dockerfile*
|
|||||||
docker-compose*.yml
|
docker-compose*.yml
|
||||||
migrate/
|
migrate/
|
||||||
scripts/*.txt
|
scripts/*.txt
|
||||||
|
tests/
|
||||||
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.14"
|
version = "0.2.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.14"
|
version = "0.2.15"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.94"
|
rust-version = "1.94"
|
||||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||||
|
|||||||
@@ -278,3 +278,21 @@ SQL is selected explicitly by database engine. Application code uses logical que
|
|||||||
- `src/queries/mysql.rs`
|
- `src/queries/mysql.rs`
|
||||||
|
|
||||||
PostgreSQL statements use native `$1`, `$2`, ... placeholders. Query text is not rewritten at runtime, and result-shape casts are defined independently for each engine.
|
PostgreSQL statements use native `$1`, `$2`, ... placeholders. Query text is not rewritten at runtime, and result-shape casts are defined independently for each engine.
|
||||||
|
|
||||||
|
## Random API test data
|
||||||
|
|
||||||
|
`tests/random_data.py` creates data only through RustPad's HTTP API. It logs in, obtains a CSRF token, creates workspaces and notes, and can seed the initial Markdown content from generated text or cached snapshots of random Wikipedia pages with Wikimedia images.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export RUSTPAD_TEST_PASSWORD='test1234'
|
||||||
|
python3 tests/random_data.py \
|
||||||
|
--ip localhost \
|
||||||
|
--port 3000 \
|
||||||
|
--source wikipedia \
|
||||||
|
--notes 10000 \
|
||||||
|
--workspaces 10 \
|
||||||
|
--notes-in-workspaces 1000 \
|
||||||
|
--user test
|
||||||
|
```
|
||||||
|
|
||||||
|
`--notes-in-workspaces` is applied to every workspace. The example creates 10,000 standalone notes and another 10,000 notes inside 10 workspaces. Wikipedia mode never falls back to generated content. Use `--wikipedia-images`, `--wikipedia-attempts`, `--workers`, `--source-pool-size`, `--scheme https`, `--base-url`, or `--dry-run` as needed. The login value may be a local account e-mail or an LDAP/AD username.
|
||||||
|
|||||||
+76
-34
@@ -38,6 +38,7 @@ const MAX_NAME_LENGTH: usize = 80;
|
|||||||
const MIN_PASSWORD_LENGTH: usize = 8;
|
const MIN_PASSWORD_LENGTH: usize = 8;
|
||||||
const MAX_PASSWORD_LENGTH: usize = 128;
|
const MAX_PASSWORD_LENGTH: usize = 128;
|
||||||
const MIN_WORKSPACE_SLUG_LENGTH: usize = 6;
|
const MIN_WORKSPACE_SLUG_LENGTH: usize = 6;
|
||||||
|
const MAX_DOCUMENT_SIZE_BYTES: usize = 2_000_000;
|
||||||
|
|
||||||
fn bearer_token(headers: &HeaderMap) -> Option<&str> {
|
fn bearer_token(headers: &HeaderMap) -> Option<&str> {
|
||||||
crate::security::session_token(headers)
|
crate::security::session_token(headers)
|
||||||
@@ -81,9 +82,9 @@ fn requester_guest_id(headers: &HeaderMap) -> Option<&str> {
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| {
|
.filter(|value| {
|
||||||
(16..=64).contains(&value.len())
|
(16..=64).contains(&value.len())
|
||||||
&& value
|
&& value.chars().all(|character| {
|
||||||
.chars()
|
character.is_ascii_alphanumeric() || matches!(character, '-' | '_')
|
||||||
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,9 +110,7 @@ fn pad_creator_is_requester(headers: &HeaderMap, pad: &db::Pad) -> bool {
|
|||||||
pad.created_by_guest_id
|
pad.created_by_guest_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.zip(requester_guest_id(headers))
|
.zip(requester_guest_id(headers))
|
||||||
.is_some_and(|(owner_guest_id, requester_guest_id)| {
|
.is_some_and(|(owner_guest_id, requester_guest_id)| owner_guest_id == requester_guest_id)
|
||||||
owner_guest_id == requester_guest_id
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn has_write_permission(
|
async fn has_write_permission(
|
||||||
@@ -126,7 +125,8 @@ async fn has_write_permission(
|
|||||||
}
|
}
|
||||||
let authorization = authorization_token(headers);
|
let authorization = authorization_token(headers);
|
||||||
if authorization != resource
|
if authorization != resource
|
||||||
&& external_token_access_level(state, kind, slug, authorization).await? >= AccessLevel::Write
|
&& external_token_access_level(state, kind, slug, authorization).await?
|
||||||
|
>= AccessLevel::Write
|
||||||
{
|
{
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
@@ -191,10 +191,7 @@ pub(crate) async fn markdown_file_references(
|
|||||||
})
|
})
|
||||||
.map(|file| MarkdownFileReference {
|
.map(|file| MarkdownFileReference {
|
||||||
filename: file.filename,
|
filename: file.filename,
|
||||||
url: crate::file_urls::public_file_url(
|
url: crate::file_urls::public_file_url(state.files_public_url.as_deref(), &file.url),
|
||||||
state.files_public_url.as_deref(),
|
|
||||||
&file.url,
|
|
||||||
),
|
|
||||||
mime_type: file.mime_type,
|
mime_type: file.mime_type,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -253,6 +250,8 @@ pub struct PublicTaskUpdateRequest {
|
|||||||
pub struct CreateNoteRequest {
|
pub struct CreateNoteRequest {
|
||||||
name: String,
|
name: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
content: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
access_token: Option<String>,
|
access_token: Option<String>,
|
||||||
@@ -385,14 +384,7 @@ async fn save_editor_settings(
|
|||||||
creator_can_manage_authorship: bool,
|
creator_can_manage_authorship: bool,
|
||||||
payload: EditorSettingsRequest,
|
payload: EditorSettingsRequest,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
if !has_write_permission(
|
if !has_write_permission(state, headers, permission_kind, permission_slug).await? {
|
||||||
state,
|
|
||||||
headers,
|
|
||||||
permission_kind,
|
|
||||||
permission_slug,
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
return Err(ApiError::forbidden(
|
return Err(ApiError::forbidden(
|
||||||
"Read and write access is required to save editor preferences",
|
"Read and write access is required to save editor preferences",
|
||||||
));
|
));
|
||||||
@@ -433,7 +425,10 @@ async fn save_editor_settings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let preferences = if wants_personal_update {
|
let preferences = if wants_personal_update {
|
||||||
let user_id = user.as_ref().expect("personal preferences require a user").id;
|
let user_id = user
|
||||||
|
.as_ref()
|
||||||
|
.expect("personal preferences require a user")
|
||||||
|
.id;
|
||||||
let mut preferences = db::load_editor_preferences(&state.db, user_id, resource)
|
let mut preferences = db::load_editor_preferences(&state.db, user_id, resource)
|
||||||
.await?
|
.await?
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
@@ -467,12 +462,8 @@ async fn save_editor_settings(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let resource_settings = if wants_global_update {
|
let resource_settings = if wants_global_update {
|
||||||
let mut settings = db::load_resource_editor_settings(
|
let mut settings =
|
||||||
&state.db,
|
db::load_resource_editor_settings(&state.db, settings_kind, settings_slug).await?;
|
||||||
settings_kind,
|
|
||||||
settings_slug,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if let Some(mode) = payload.authorship_mode {
|
if let Some(mode) = payload.authorship_mode {
|
||||||
settings.authorship_mode = match mode.as_str() {
|
settings.authorship_mode = match mode.as_str() {
|
||||||
"simple" => "simple".into(),
|
"simple" => "simple".into(),
|
||||||
@@ -567,7 +558,12 @@ pub async fn open_workspace(
|
|||||||
&state,
|
&state,
|
||||||
&workspace_slug,
|
&workspace_slug,
|
||||||
payload.password.as_deref(),
|
payload.password.as_deref(),
|
||||||
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
resource_request_token(
|
||||||
|
&headers,
|
||||||
|
"workspace",
|
||||||
|
&workspace_slug,
|
||||||
|
payload.access_token.as_deref(),
|
||||||
|
),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
&headers,
|
&headers,
|
||||||
)
|
)
|
||||||
@@ -614,7 +610,12 @@ pub async fn create_note(
|
|||||||
&state,
|
&state,
|
||||||
&workspace_slug,
|
&workspace_slug,
|
||||||
payload.password.as_deref(),
|
payload.password.as_deref(),
|
||||||
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
resource_request_token(
|
||||||
|
&headers,
|
||||||
|
"workspace",
|
||||||
|
&workspace_slug,
|
||||||
|
payload.access_token.as_deref(),
|
||||||
|
),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
&headers,
|
&headers,
|
||||||
)
|
)
|
||||||
@@ -628,13 +629,19 @@ pub async fn create_note(
|
|||||||
&state,
|
&state,
|
||||||
"workspace",
|
"workspace",
|
||||||
&workspace_slug,
|
&workspace_slug,
|
||||||
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
resource_request_token(
|
||||||
|
&headers,
|
||||||
|
"workspace",
|
||||||
|
&workspace_slug,
|
||||||
|
payload.access_token.as_deref(),
|
||||||
|
),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
require_write(level)?;
|
require_write(level)?;
|
||||||
let title = validate_name(&payload.name, "Note name")?;
|
let title = validate_name(&payload.name, "Note name")?;
|
||||||
|
let initial_content = validate_initial_content(payload.content.as_deref())?;
|
||||||
let base = slugify(title);
|
let base = slugify(title);
|
||||||
if base.is_empty() {
|
if base.is_empty() {
|
||||||
return Err(ApiError::bad_request(
|
return Err(ApiError::bad_request(
|
||||||
@@ -670,6 +677,17 @@ pub async fn create_note(
|
|||||||
created_by_guest_id.as_deref(),
|
created_by_guest_id.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
if let Some(content) = initial_content {
|
||||||
|
db::save_revision(
|
||||||
|
&state.db,
|
||||||
|
note.id,
|
||||||
|
workspace.id,
|
||||||
|
content,
|
||||||
|
created_by.as_deref(),
|
||||||
|
"[]",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
Json(NoteListItem {
|
Json(NoteListItem {
|
||||||
@@ -865,8 +883,7 @@ pub async fn set_note_editor_settings(
|
|||||||
let note = db::find_note(&state.db, workspace.id, ¬e_slug)
|
let note = db::find_note(&state.db, workspace.id, ¬e_slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_note)?;
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
let creator_can_manage_authorship =
|
let creator_can_manage_authorship = note_creator_is_requester(&state, &headers, ¬e).await?
|
||||||
note_creator_is_requester(&state, &headers, ¬e).await?
|
|
||||||
|| has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
|
|| has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
|
||||||
save_editor_settings(
|
save_editor_settings(
|
||||||
&state,
|
&state,
|
||||||
@@ -893,7 +910,12 @@ pub async fn history(
|
|||||||
&workspace_slug,
|
&workspace_slug,
|
||||||
¬e_slug,
|
¬e_slug,
|
||||||
payload.password.as_deref(),
|
payload.password.as_deref(),
|
||||||
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
resource_request_token(
|
||||||
|
&headers,
|
||||||
|
"workspace",
|
||||||
|
&workspace_slug,
|
||||||
|
payload.access_token.as_deref(),
|
||||||
|
),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
&headers,
|
&headers,
|
||||||
)
|
)
|
||||||
@@ -921,7 +943,12 @@ pub async fn restore(
|
|||||||
&workspace_slug,
|
&workspace_slug,
|
||||||
¬e_slug,
|
¬e_slug,
|
||||||
payload.password.as_deref(),
|
payload.password.as_deref(),
|
||||||
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
resource_request_token(
|
||||||
|
&headers,
|
||||||
|
"workspace",
|
||||||
|
&workspace_slug,
|
||||||
|
payload.access_token.as_deref(),
|
||||||
|
),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
&headers,
|
&headers,
|
||||||
)
|
)
|
||||||
@@ -935,7 +962,12 @@ pub async fn restore(
|
|||||||
&state,
|
&state,
|
||||||
"workspace",
|
"workspace",
|
||||||
&workspace_slug,
|
&workspace_slug,
|
||||||
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
resource_request_token(
|
||||||
|
&headers,
|
||||||
|
"workspace",
|
||||||
|
&workspace_slug,
|
||||||
|
payload.access_token.as_deref(),
|
||||||
|
),
|
||||||
bearer_token(&headers),
|
bearer_token(&headers),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
@@ -1238,6 +1270,16 @@ fn validate_password(password: Option<&str>) -> Result<Option<&str>, ApiError> {
|
|||||||
Ok(Some(password))
|
Ok(Some(password))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_initial_content(content: Option<&str>) -> Result<Option<&str>, ApiError> {
|
||||||
|
let Some(content) = content.filter(|value| !value.is_empty()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if content.len() > MAX_DOCUMENT_SIZE_BYTES {
|
||||||
|
return Err(ApiError::bad_request("The document is too large"));
|
||||||
|
}
|
||||||
|
Ok(Some(content))
|
||||||
|
}
|
||||||
|
|
||||||
async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result<String, ApiError> {
|
async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result<String, ApiError> {
|
||||||
let base = slugify(title);
|
let base = slugify(title);
|
||||||
if base.is_empty() {
|
if base.is_empty() {
|
||||||
|
|||||||
+20
-52
@@ -14,6 +14,8 @@ pub struct CreatePadRequest {
|
|||||||
name: String,
|
name: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
content: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -58,6 +60,7 @@ pub async fn create_pad(
|
|||||||
) -> Result<(StatusCode, Json<CreatePadResponse>), ApiError> {
|
) -> Result<(StatusCode, Json<CreatePadResponse>), ApiError> {
|
||||||
let title = validate_name(&payload.name, "Note name")?;
|
let title = validate_name(&payload.name, "Note name")?;
|
||||||
let password = validate_password(payload.password.as_deref())?;
|
let password = validate_password(payload.password.as_deref())?;
|
||||||
|
let initial_content = validate_initial_content(payload.content.as_deref())?;
|
||||||
let base = slugify(title);
|
let base = slugify(title);
|
||||||
if base.is_empty() {
|
if base.is_empty() {
|
||||||
return Err(ApiError::bad_request(
|
return Err(ApiError::bad_request(
|
||||||
@@ -68,26 +71,23 @@ pub async fn create_pad(
|
|||||||
let account_user = crate::auth::optional_user(&state, &headers)
|
let account_user = crate::auth::optional_user(&state, &headers)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::forbidden(&e.message))?;
|
.map_err(|e| ApiError::forbidden(&e.message))?;
|
||||||
|
let author = account_user.as_ref().map(|user| user.nickname.clone());
|
||||||
let created_by_guest_id = if account_user.is_none() {
|
let created_by_guest_id = if account_user.is_none() {
|
||||||
requester_guest_id(&headers)
|
requester_guest_id(&headers)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let pad = db::create_pad(
|
let pad = db::create_pad(&state.db, &slug, title, password, created_by_guest_id).await?;
|
||||||
&state.db,
|
if let Some(user) = account_user.as_ref() {
|
||||||
&slug,
|
|
||||||
title,
|
|
||||||
password,
|
|
||||||
created_by_guest_id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if let Some(user) = account_user {
|
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD))
|
sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD))
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.bind(&pad.slug)
|
.bind(&pad.slug)
|
||||||
.execute(state.db.pool())
|
.execute(state.db.pool())
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
if let Some(content) = initial_content {
|
||||||
|
db::save_pad_revision(&state.db, pad.id, content, author.as_deref(), "[]").await?;
|
||||||
|
}
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
Json(CreatePadResponse {
|
Json(CreatePadResponse {
|
||||||
@@ -105,29 +105,15 @@ pub async fn pad_info(
|
|||||||
let pad = db::find_pad(&state.db, &slug)
|
let pad = db::find_pad(&state.db, &slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_note)?;
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
ensure_private_resource_access(
|
ensure_private_resource_access(&state, &headers, "pad", &pad.slug, pad.is_private).await?;
|
||||||
&state,
|
|
||||||
&headers,
|
|
||||||
"pad",
|
|
||||||
&pad.slug,
|
|
||||||
pad.is_private,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?;
|
let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?;
|
||||||
let (editor_preferences, personal_editor_settings) = user_editor_preferences(
|
let (editor_preferences, personal_editor_settings) =
|
||||||
&state,
|
user_editor_preferences(&state, &headers, db::EditorPreferenceResource::Pad(pad.id))
|
||||||
&headers,
|
|
||||||
db::EditorPreferenceResource::Pad(pad.id),
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
let resource_editor_settings =
|
let resource_editor_settings =
|
||||||
db::load_resource_editor_settings(&state.db, "pad", &slug).await?;
|
db::load_resource_editor_settings(&state.db, "pad", &slug).await?;
|
||||||
let account_owner = crate::auth::is_resource_owner(
|
let account_owner =
|
||||||
&state,
|
crate::auth::is_resource_owner(&state, "pad", &slug, user_session_token(&headers))
|
||||||
"pad",
|
|
||||||
&slug,
|
|
||||||
user_session_token(&headers),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let guest_owner = pad_creator_is_requester(&headers, &pad);
|
let guest_owner = pad_creator_is_requester(&headers, &pad);
|
||||||
@@ -432,14 +418,7 @@ async fn ensure_public_page_access(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let password_ok = db::verify_workspace_password(&workspace, password);
|
let password_ok = db::verify_workspace_password(&workspace, password);
|
||||||
check_resource_password_attempt(
|
check_resource_password_attempt(state, headers, "workspace", &slug, password, password_ok)
|
||||||
state,
|
|
||||||
headers,
|
|
||||||
"workspace",
|
|
||||||
&slug,
|
|
||||||
password,
|
|
||||||
password_ok,
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
if password_ok {
|
if password_ok {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -462,13 +441,8 @@ pub async fn public_page(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_note)?;
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
ensure_public_page_access(&state, &headers, &page).await?;
|
ensure_public_page_access(&state, &headers, &page).await?;
|
||||||
let files = markdown_file_references(
|
let files =
|
||||||
&state,
|
markdown_file_references(&state, page.pad_id, page.note_id, Some(&page.content)).await?;
|
||||||
page.pad_id,
|
|
||||||
page.note_id,
|
|
||||||
Some(&page.content),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(PublicPageResponse {
|
Ok(Json(PublicPageResponse {
|
||||||
title: page.title,
|
title: page.title,
|
||||||
content: page.content,
|
content: page.content,
|
||||||
@@ -496,13 +470,8 @@ pub async fn update_public_task(
|
|||||||
let page = db::update_public_task(&state.db, &token, payload.source_line, payload.checked)
|
let page = db::update_public_task(&state.db, &token, payload.source_line, payload.checked)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_note)?;
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
let files = markdown_file_references(
|
let files =
|
||||||
&state,
|
markdown_file_references(&state, page.pad_id, page.note_id, Some(&page.content)).await?;
|
||||||
page.pad_id,
|
|
||||||
page.note_id,
|
|
||||||
Some(&page.content),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(PublicPageResponse {
|
Ok(Json(PublicPageResponse {
|
||||||
title: page.title,
|
title: page.title,
|
||||||
content: page.content,
|
content: page.content,
|
||||||
@@ -614,8 +583,7 @@ pub(super) async fn authorized_pad(
|
|||||||
}
|
}
|
||||||
if pad.password_hash.is_some() && token_level < AccessLevel::Write {
|
if pad.password_hash.is_some() && token_level < AccessLevel::Write {
|
||||||
let password_ok = db::verify_pad_password(&pad, password);
|
let password_ok = db::verify_pad_password(&pad, password);
|
||||||
check_resource_password_attempt(state, headers, "pad", slug, password, password_ok)
|
check_resource_password_attempt(state, headers, "pad", slug, password, password_ok).await?;
|
||||||
.await?;
|
|
||||||
if token_level == AccessLevel::None && !password_ok {
|
if token_level == AccessLevel::None && !password_ok {
|
||||||
return Err(ApiError::forbidden("Password required or incorrect."));
|
return Err(ApiError::forbidden("Password required or incorrect."));
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-2
@@ -10,11 +10,12 @@
|
|||||||
mod pages;
|
mod pages;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Json,
|
||||||
Router,
|
Router,
|
||||||
extract::{DefaultBodyLimit, Request},
|
extract::{DefaultBodyLimit, Request},
|
||||||
http::{HeaderName, HeaderValue, StatusCode, header},
|
http::{HeaderName, HeaderValue, Method, StatusCode, header},
|
||||||
middleware::{self, Next},
|
middleware::{self, Next},
|
||||||
response::Response,
|
response::{IntoResponse, Response},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
use pages::*;
|
use pages::*;
|
||||||
@@ -75,6 +76,7 @@ pub fn router(
|
|||||||
get(api::download_legacy_file),
|
get(api::download_legacy_file),
|
||||||
)
|
)
|
||||||
.route("/api/auth/identity", post(auth::identity))
|
.route("/api/auth/identity", post(auth::identity))
|
||||||
|
.route("/api/security/csrf", get(crate::security::csrf_token_endpoint))
|
||||||
.route("/api/access-token", post(api::create_resource_access_token))
|
.route("/api/access-token", post(api::create_resource_access_token))
|
||||||
.route("/api/auth/register", post(auth::register))
|
.route("/api/auth/register", post(auth::register))
|
||||||
.route("/api/auth/login", post(auth::login))
|
.route("/api/auth/login", post(auth::login))
|
||||||
@@ -221,10 +223,29 @@ pub fn router(
|
|||||||
HeaderValue::from_static("same-origin"),
|
HeaderValue::from_static("same-origin"),
|
||||||
))
|
))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.layer(middleware::from_fn(require_csrf_token))
|
||||||
.layer(middleware::from_fn(add_non_asset_security_headers))
|
.layer(middleware::from_fn(add_non_asset_security_headers))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn require_csrf_token(request: Request, next: Next) -> Response {
|
||||||
|
let method = request.method();
|
||||||
|
let unsafe_method = method == Method::POST
|
||||||
|
|| method == Method::PUT
|
||||||
|
|| method == Method::PATCH
|
||||||
|
|| method == Method::DELETE;
|
||||||
|
if unsafe_method && !crate::security::csrf_request_is_valid(request.headers()) {
|
||||||
|
return (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Security token is missing or expired. Refresh the page and try again."
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
next.run(request).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn add_non_asset_security_headers(request: Request, next: Next) -> Response {
|
async fn add_non_asset_security_headers(request: Request, next: Next) -> Response {
|
||||||
let path = request.uri().path();
|
let path = request.uri().path();
|
||||||
let is_asset = path.starts_with("/assets/");
|
let is_asset = path.starts_with("/assets/");
|
||||||
|
|||||||
+28
-14
@@ -444,7 +444,8 @@ pub async fn register(
|
|||||||
"Account created. Check your e-mail and confirm the account before logging in."
|
"Account created. Check your e-mail and confirm the account before logging in."
|
||||||
.into(),
|
.into(),
|
||||||
}),
|
}),
|
||||||
).into_response());
|
)
|
||||||
|
.into_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
let session = create_session(&state, &user).await?;
|
let session = create_session(&state, &user).await?;
|
||||||
@@ -461,7 +462,8 @@ pub async fn register(
|
|||||||
theme: session.theme,
|
theme: session.theme,
|
||||||
message: "Account created.".into(),
|
message: "Account created.".into(),
|
||||||
}),
|
}),
|
||||||
).into_response();
|
)
|
||||||
|
.into_response();
|
||||||
response.headers_mut().insert(header::SET_COOKIE, cookie);
|
response.headers_mut().insert(header::SET_COOKIE, cookie);
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
@@ -479,15 +481,19 @@ pub async fn login(
|
|||||||
state
|
state
|
||||||
.check_rate_limit(client_limit_key.clone(), 30, window)
|
.check_rate_limit(client_limit_key.clone(), 30, window)
|
||||||
.await
|
.await
|
||||||
.map_err(|seconds| AuthError::rate_limited(&format!(
|
.map_err(|seconds| {
|
||||||
|
AuthError::rate_limited(&format!(
|
||||||
"Too many login attempts. Try again in {seconds} seconds."
|
"Too many login attempts. Try again in {seconds} seconds."
|
||||||
)))?;
|
))
|
||||||
|
})?;
|
||||||
state
|
state
|
||||||
.check_rate_limit(limit_key.clone(), 5, window)
|
.check_rate_limit(limit_key.clone(), 5, window)
|
||||||
.await
|
.await
|
||||||
.map_err(|seconds| AuthError::rate_limited(&format!(
|
.map_err(|seconds| {
|
||||||
|
AuthError::rate_limited(&format!(
|
||||||
"Too many login attempts. Try again in {seconds} seconds."
|
"Too many login attempts. Try again in {seconds} seconds."
|
||||||
)))?;
|
))
|
||||||
|
})?;
|
||||||
let session = if state.ldap.is_some() {
|
let session = if state.ldap.is_some() {
|
||||||
ldap::login(&state, &req.email, &req.password).await?
|
ldap::login(&state, &req.email, &req.password).await?
|
||||||
} else {
|
} else {
|
||||||
@@ -1816,9 +1822,11 @@ pub async fn request_reset(
|
|||||||
state
|
state
|
||||||
.check_rate_limit(format!("password-reset-client:{client_key}"), 10, window)
|
.check_rate_limit(format!("password-reset-client:{client_key}"), 10, window)
|
||||||
.await
|
.await
|
||||||
.map_err(|seconds| AuthError::rate_limited(&format!(
|
.map_err(|seconds| {
|
||||||
|
AuthError::rate_limited(&format!(
|
||||||
"Too many password reset requests. Try again in {seconds} seconds."
|
"Too many password reset requests. Try again in {seconds} seconds."
|
||||||
)))?;
|
))
|
||||||
|
})?;
|
||||||
state
|
state
|
||||||
.check_rate_limit(
|
.check_rate_limit(
|
||||||
format!("password-reset:{client_key}:{}", normalize(&email)),
|
format!("password-reset:{client_key}:{}", normalize(&email)),
|
||||||
@@ -1826,9 +1834,11 @@ pub async fn request_reset(
|
|||||||
window,
|
window,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|seconds| AuthError::rate_limited(&format!(
|
.map_err(|seconds| {
|
||||||
|
AuthError::rate_limited(&format!(
|
||||||
"Too many password reset requests. Try again in {seconds} seconds."
|
"Too many password reset requests. Try again in {seconds} seconds."
|
||||||
)))?;
|
))
|
||||||
|
})?;
|
||||||
info!(email_domain = %email_domain(&email), "password reset requested");
|
info!(email_domain = %email_domain(&email), "password reset requested");
|
||||||
let smtp = state.smtp.as_ref().ok_or_else(|| {
|
let smtp = state.smtp.as_ref().ok_or_else(|| {
|
||||||
AuthError::service_unavailable("Password reset is not configured on this server.")
|
AuthError::service_unavailable("Password reset is not configured on this server.")
|
||||||
@@ -1883,15 +1893,19 @@ pub async fn confirm_reset(
|
|||||||
state
|
state
|
||||||
.check_rate_limit(client_limit_key.clone(), 20, window)
|
.check_rate_limit(client_limit_key.clone(), 20, window)
|
||||||
.await
|
.await
|
||||||
.map_err(|seconds| AuthError::rate_limited(&format!(
|
.map_err(|seconds| {
|
||||||
|
AuthError::rate_limited(&format!(
|
||||||
"Too many reset attempts. Try again in {seconds} seconds."
|
"Too many reset attempts. Try again in {seconds} seconds."
|
||||||
)))?;
|
))
|
||||||
|
})?;
|
||||||
state
|
state
|
||||||
.check_rate_limit(limit_key.clone(), 10, window)
|
.check_rate_limit(limit_key.clone(), 10, window)
|
||||||
.await
|
.await
|
||||||
.map_err(|seconds| AuthError::rate_limited(&format!(
|
.map_err(|seconds| {
|
||||||
|
AuthError::rate_limited(&format!(
|
||||||
"Too many reset attempts. Try again in {seconds} seconds."
|
"Too many reset attempts. Try again in {seconds} seconds."
|
||||||
)))?;
|
))
|
||||||
|
})?;
|
||||||
info!("password reset confirmation requested");
|
info!("password reset confirmation requested");
|
||||||
let now_time = Utc::now();
|
let now_time = Utc::now();
|
||||||
let now = now_time.to_rfc3339();
|
let now = now_time.to_rfc3339();
|
||||||
|
|||||||
+118
-1
@@ -7,10 +7,26 @@
|
|||||||
* See LICENSE file in repository root for details.
|
* See LICENSE file in repository root for details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use axum::http::{HeaderMap, HeaderValue, Uri, header};
|
use axum::{
|
||||||
|
Json,
|
||||||
|
http::{HeaderMap, HeaderValue, Uri, header},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use rand_core::{OsRng, RngCore};
|
||||||
|
use serde::Serialize;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
pub const SESSION_COOKIE: &str = "__Host-rustpad_session";
|
pub const SESSION_COOKIE: &str = "__Host-rustpad_session";
|
||||||
|
pub const CSRF_COOKIE: &str = "__Host-rustpad_csrf";
|
||||||
|
pub const CSRF_HEADER: &str = "x-rustpad-csrf";
|
||||||
|
|
||||||
|
const CSRF_TOKEN_BYTES: usize = 32;
|
||||||
|
const CSRF_TTL_SECONDS: i64 = 24 * 60 * 60;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct CsrfResponse {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn session_token(headers: &HeaderMap) -> Option<&str> {
|
pub fn session_token(headers: &HeaderMap) -> Option<&str> {
|
||||||
session_cookie_token(headers)
|
session_cookie_token(headers)
|
||||||
@@ -42,6 +58,44 @@ pub fn clear_session_cookie() -> HeaderValue {
|
|||||||
clear_cookie(SESSION_COOKIE)
|
clear_cookie(SESSION_COOKIE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn csrf_token_endpoint(headers: HeaderMap) -> Response {
|
||||||
|
let token = csrf_cookie_token(&headers)
|
||||||
|
.filter(|value| valid_csrf_token(value))
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_else(random_csrf_token);
|
||||||
|
let mut response = Json(CsrfResponse {
|
||||||
|
token: token.clone(),
|
||||||
|
})
|
||||||
|
.into_response();
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(header::SET_COOKIE, csrf_cookie(&token));
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("no-store, max-age=0"),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn csrf_request_is_valid(headers: &HeaderMap) -> bool {
|
||||||
|
let Some(cookie) = csrf_cookie_token(headers).filter(|value| valid_csrf_token(value)) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(provided) = headers
|
||||||
|
.get(CSRF_HEADER)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| valid_csrf_token(value))
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
constant_time_eq(cookie.as_bytes(), provided.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn csrf_cookie_token(headers: &HeaderMap) -> Option<&str> {
|
||||||
|
cookie_value(headers, CSRF_COOKIE)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resource_cookie(kind: &str, slug: &str, token: &str, ttl_days: i64) -> HeaderValue {
|
pub fn resource_cookie(kind: &str, slug: &str, token: &str, ttl_days: i64) -> HeaderValue {
|
||||||
secure_cookie(
|
secure_cookie(
|
||||||
&resource_cookie_name(kind, slug),
|
&resource_cookie_name(kind, slug),
|
||||||
@@ -127,6 +181,37 @@ fn secure_cookie(name: &str, value: &str, max_age: i64) -> HeaderValue {
|
|||||||
.expect("valid secure cookie")
|
.expect("valid secure cookie")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn csrf_cookie(token: &str) -> HeaderValue {
|
||||||
|
HeaderValue::from_str(&format!(
|
||||||
|
"{CSRF_COOKIE}={token}; Path=/; Max-Age={}; Secure; SameSite=Strict",
|
||||||
|
CSRF_TTL_SECONDS
|
||||||
|
))
|
||||||
|
.expect("valid csrf cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_csrf_token() -> String {
|
||||||
|
let mut bytes = [0_u8; CSRF_TOKEN_BYTES];
|
||||||
|
let mut rng = OsRng;
|
||||||
|
rng.fill_bytes(&mut bytes);
|
||||||
|
hex::encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_csrf_token(value: &str) -> bool {
|
||||||
|
value.len() == CSRF_TOKEN_BYTES * 2 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
|
||||||
|
if left.len() != right.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
left.iter()
|
||||||
|
.zip(right)
|
||||||
|
.fold(0_u8, |difference, (left, right)| {
|
||||||
|
difference | (*left ^ *right)
|
||||||
|
})
|
||||||
|
== 0
|
||||||
|
}
|
||||||
|
|
||||||
fn clear_cookie(name: &str) -> HeaderValue {
|
fn clear_cookie(name: &str) -> HeaderValue {
|
||||||
HeaderValue::from_str(&format!(
|
HeaderValue::from_str(&format!(
|
||||||
"{name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax"
|
"{name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax"
|
||||||
@@ -232,4 +317,36 @@ mod tests {
|
|||||||
assert!(value.contains("SameSite=Lax"));
|
assert!(value.contains("SameSite=Lax"));
|
||||||
assert!(value.starts_with("__Host-rustpad_session=abc123;"));
|
assert!(value.starts_with("__Host-rustpad_session=abc123;"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn csrf_requires_matching_cookie_and_header() {
|
||||||
|
let token = "a".repeat(CSRF_TOKEN_BYTES * 2);
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::COOKIE,
|
||||||
|
HeaderValue::from_str(&format!("{CSRF_COOKIE}={token}")).unwrap(),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
axum::http::HeaderName::from_static(CSRF_HEADER),
|
||||||
|
HeaderValue::from_str(&token).unwrap(),
|
||||||
|
);
|
||||||
|
assert!(csrf_request_is_valid(&headers));
|
||||||
|
|
||||||
|
headers.insert(
|
||||||
|
axum::http::HeaderName::from_static(CSRF_HEADER),
|
||||||
|
HeaderValue::from_static(
|
||||||
|
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert!(!csrf_request_is_valid(&headers));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn csrf_cookie_is_strict_and_script_readable() {
|
||||||
|
let token = "a".repeat(CSRF_TOKEN_BYTES * 2);
|
||||||
|
let value = csrf_cookie(&token).to_str().unwrap();
|
||||||
|
assert!(value.contains("Secure"));
|
||||||
|
assert!(value.contains("SameSite=Strict"));
|
||||||
|
assert!(!value.contains("HttpOnly"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+184
-5
@@ -7,11 +7,12 @@ use axum::{
|
|||||||
Path, State, WebSocketUpgrade,
|
Path, State, WebSocketUpgrade,
|
||||||
ws::{Message, WebSocket},
|
ws::{Message, WebSocket},
|
||||||
},
|
},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode, header},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
@@ -19,6 +20,92 @@ mod pad;
|
|||||||
|
|
||||||
pub use pad::upgrade_pad;
|
pub use pad::upgrade_pad;
|
||||||
|
|
||||||
|
const HEARTBEAT_INTERVAL_MS: u64 = 10_000;
|
||||||
|
const HEARTBEAT_TIMEOUT_MS: u64 = 30_000;
|
||||||
|
const MAX_RECONNECT_DELAY_MS: u64 = 12_000;
|
||||||
|
const LATENCY_SAMPLE_WINDOW: usize = 20;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
struct ClientDiagnostics {
|
||||||
|
#[serde(default)]
|
||||||
|
language: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
timezone: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
platform: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
effective_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
downlink_mbps: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
network_rtt_ms: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
save_data: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct RequestClientContext {
|
||||||
|
client_id: String,
|
||||||
|
user_agent: Option<String>,
|
||||||
|
accept_language: Option<String>,
|
||||||
|
request_scheme: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestClientContext {
|
||||||
|
fn from_headers(headers: &HeaderMap, client_key: &str) -> Self {
|
||||||
|
let digest = Sha256::digest(client_key.as_bytes());
|
||||||
|
Self {
|
||||||
|
client_id: hex::encode(&digest[..8]),
|
||||||
|
user_agent: diagnostic_header(headers, header::USER_AGENT.as_str(), 180),
|
||||||
|
accept_language: diagnostic_header(headers, header::ACCEPT_LANGUAGE.as_str(), 100),
|
||||||
|
request_scheme: diagnostic_header(headers, "x-forwarded-proto", 12).or_else(|| {
|
||||||
|
headers
|
||||||
|
.get(header::ORIGIN)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.split_once("://").map(|(scheme, _)| scheme))
|
||||||
|
.and_then(|value| clean_diagnostic_text(Some(value.to_owned()), 12))
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct QualityThresholds {
|
||||||
|
excellent_max_ms: u64,
|
||||||
|
good_max_ms: u64,
|
||||||
|
degraded_max_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct DiagnosticClient {
|
||||||
|
id: String,
|
||||||
|
user_agent: Option<String>,
|
||||||
|
accept_language: Option<String>,
|
||||||
|
request_scheme: Option<String>,
|
||||||
|
language: Option<String>,
|
||||||
|
timezone: Option<String>,
|
||||||
|
platform: Option<String>,
|
||||||
|
effective_type: Option<String>,
|
||||||
|
downlink_mbps: Option<f64>,
|
||||||
|
network_rtt_ms: Option<u64>,
|
||||||
|
save_data: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ConnectionDiagnostics {
|
||||||
|
connection_id: u64,
|
||||||
|
connected_at: String,
|
||||||
|
server_time: String,
|
||||||
|
server_version: &'static str,
|
||||||
|
transport: &'static str,
|
||||||
|
heartbeat_interval_ms: u64,
|
||||||
|
heartbeat_timeout_ms: u64,
|
||||||
|
max_reconnect_delay_ms: u64,
|
||||||
|
latency_sample_window: usize,
|
||||||
|
quality_thresholds: QualityThresholds,
|
||||||
|
client: DiagnosticClient,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
enum ClientMessage {
|
enum ClientMessage {
|
||||||
@@ -28,6 +115,8 @@ enum ClientMessage {
|
|||||||
nickname: Option<String>,
|
nickname: Option<String>,
|
||||||
guest_id: Option<String>,
|
guest_id: Option<String>,
|
||||||
color: Option<String>,
|
color: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
diagnostics: Option<ClientDiagnostics>,
|
||||||
},
|
},
|
||||||
Update {
|
Update {
|
||||||
content: String,
|
content: String,
|
||||||
@@ -71,11 +160,79 @@ enum ServerMessage {
|
|||||||
Pong {
|
Pong {
|
||||||
nonce: u64,
|
nonce: u64,
|
||||||
},
|
},
|
||||||
|
Diagnostics {
|
||||||
|
diagnostics: ConnectionDiagnostics,
|
||||||
|
},
|
||||||
Error {
|
Error {
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn connection_diagnostics(
|
||||||
|
connection_id: u64,
|
||||||
|
request: &RequestClientContext,
|
||||||
|
client: Option<ClientDiagnostics>,
|
||||||
|
) -> ConnectionDiagnostics {
|
||||||
|
let client = client.unwrap_or_default();
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
ConnectionDiagnostics {
|
||||||
|
connection_id,
|
||||||
|
connected_at: now.clone(),
|
||||||
|
server_time: now,
|
||||||
|
server_version: env!("CARGO_PKG_VERSION"),
|
||||||
|
transport: "websocket",
|
||||||
|
heartbeat_interval_ms: HEARTBEAT_INTERVAL_MS,
|
||||||
|
heartbeat_timeout_ms: HEARTBEAT_TIMEOUT_MS,
|
||||||
|
max_reconnect_delay_ms: MAX_RECONNECT_DELAY_MS,
|
||||||
|
latency_sample_window: LATENCY_SAMPLE_WINDOW,
|
||||||
|
quality_thresholds: QualityThresholds {
|
||||||
|
excellent_max_ms: 100,
|
||||||
|
good_max_ms: 250,
|
||||||
|
degraded_max_ms: 600,
|
||||||
|
},
|
||||||
|
client: DiagnosticClient {
|
||||||
|
id: request.client_id.clone(),
|
||||||
|
user_agent: request.user_agent.clone(),
|
||||||
|
accept_language: request.accept_language.clone(),
|
||||||
|
request_scheme: request.request_scheme.clone(),
|
||||||
|
language: clean_diagnostic_text(client.language, 40),
|
||||||
|
timezone: clean_diagnostic_text(client.timezone, 80),
|
||||||
|
platform: clean_diagnostic_text(client.platform, 80),
|
||||||
|
effective_type: clean_diagnostic_text(client.effective_type, 20),
|
||||||
|
downlink_mbps: client
|
||||||
|
.downlink_mbps
|
||||||
|
.filter(|value| value.is_finite())
|
||||||
|
.map(|value| value.clamp(0.0, 10_000.0)),
|
||||||
|
network_rtt_ms: client.network_rtt_ms.map(|value| value.min(120_000)),
|
||||||
|
save_data: client.save_data,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagnostic_header(headers: &HeaderMap, name: &str, max_chars: usize) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get(name)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.split(',').next())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.and_then(|value| clean_diagnostic_text(Some(value), max_chars))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_diagnostic_text(value: Option<String>, max_chars: usize) -> Option<String> {
|
||||||
|
value
|
||||||
|
.map(|value| {
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.chars()
|
||||||
|
.filter(|character| !character.is_control())
|
||||||
|
.take(max_chars)
|
||||||
|
.collect::<String>()
|
||||||
|
})
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
async fn resource_permission_from_tokens(
|
async fn resource_permission_from_tokens(
|
||||||
state: &SharedState,
|
state: &SharedState,
|
||||||
kind: &str,
|
kind: &str,
|
||||||
@@ -125,9 +282,10 @@ pub async fn upgrade(
|
|||||||
return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response();
|
return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response();
|
||||||
}
|
}
|
||||||
let account_token = crate::security::session_token(&headers).map(str::to_owned);
|
let account_token = crate::security::session_token(&headers).map(str::to_owned);
|
||||||
let resource_token = crate::security::resource_token(&headers, "workspace", &workspace_slug)
|
let resource_token =
|
||||||
.map(str::to_owned);
|
crate::security::resource_token(&headers, "workspace", &workspace_slug).map(str::to_owned);
|
||||||
let client_key = crate::security::client_key(&headers);
|
let client_key = crate::security::client_key(&headers);
|
||||||
|
let client_context = RequestClientContext::from_headers(&headers, &client_key);
|
||||||
ws.on_upgrade(move |socket| {
|
ws.on_upgrade(move |socket| {
|
||||||
handle_socket(
|
handle_socket(
|
||||||
socket,
|
socket,
|
||||||
@@ -137,6 +295,7 @@ pub async fn upgrade(
|
|||||||
account_token,
|
account_token,
|
||||||
resource_token,
|
resource_token,
|
||||||
client_key,
|
client_key,
|
||||||
|
client_context,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -149,6 +308,7 @@ async fn handle_socket(
|
|||||||
cookie_session_token: Option<String>,
|
cookie_session_token: Option<String>,
|
||||||
cookie_access_token: Option<String>,
|
cookie_access_token: Option<String>,
|
||||||
client_key: String,
|
client_key: String,
|
||||||
|
client_context: RequestClientContext,
|
||||||
) {
|
) {
|
||||||
info!(%workspace_slug, %note_slug, "note websocket connected");
|
info!(%workspace_slug, %note_slug, "note websocket connected");
|
||||||
let Some(workspace) = db::find_workspace(&state.db, &workspace_slug)
|
let Some(workspace) = db::find_workspace(&state.db, &workspace_slug)
|
||||||
@@ -169,7 +329,7 @@ async fn handle_socket(
|
|||||||
let _ = send_error(&mut socket, "Note not found").await;
|
let _ = send_error(&mut socket, "Note not found").await;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let (password, access_token, nickname, guest_id, color) =
|
let (password, access_token, nickname, guest_id, color, client_diagnostics) =
|
||||||
match socket.recv().await {
|
match socket.recv().await {
|
||||||
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
|
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
|
||||||
Ok(ClientMessage::Authenticate {
|
Ok(ClientMessage::Authenticate {
|
||||||
@@ -178,12 +338,14 @@ async fn handle_socket(
|
|||||||
nickname,
|
nickname,
|
||||||
guest_id,
|
guest_id,
|
||||||
color,
|
color,
|
||||||
|
diagnostics,
|
||||||
}) => (
|
}) => (
|
||||||
password,
|
password,
|
||||||
access_token,
|
access_token,
|
||||||
clean_nickname(nickname),
|
clean_nickname(nickname),
|
||||||
clean_guest_id(guest_id),
|
clean_guest_id(guest_id),
|
||||||
clean_color(color),
|
clean_color(color),
|
||||||
|
diagnostics,
|
||||||
),
|
),
|
||||||
_ => {
|
_ => {
|
||||||
let _ = send_error(&mut socket, "Wymagane uwierzytelnienie").await;
|
let _ = send_error(&mut socket, "Wymagane uwierzytelnienie").await;
|
||||||
@@ -249,7 +411,11 @@ async fn handle_socket(
|
|||||||
.check_rate_limit(format!("resource-password-client:{client_key}"), 50, window)
|
.check_rate_limit(format!("resource-password-client:{client_key}"), 50, window)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
let _ = send_error(&mut socket, &format!("Too many password attempts. Try again in {seconds} seconds.")).await;
|
let _ = send_error(
|
||||||
|
&mut socket,
|
||||||
|
&format!("Too many password attempts. Try again in {seconds} seconds."),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Err(seconds) = state
|
if let Err(seconds) = state
|
||||||
@@ -319,6 +485,19 @@ async fn handle_socket(
|
|||||||
let _ = channel.send(RoomEvent::Presence(users));
|
let _ = channel.send(RoomEvent::Presence(users));
|
||||||
let mut last_chat = Instant::now() - Duration::from_secs(1);
|
let mut last_chat = Instant::now() - Duration::from_secs(1);
|
||||||
let (mut sender, mut receiver) = socket.split();
|
let (mut sender, mut receiver) = socket.split();
|
||||||
|
if send_split(
|
||||||
|
&mut sender,
|
||||||
|
&ServerMessage::Diagnostics {
|
||||||
|
diagnostics: connection_diagnostics(connection_id, &client_context, client_diagnostics),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
let users = state.leave_room(&room_key, connection_id).await;
|
||||||
|
let _ = channel.send(RoomEvent::Presence(users));
|
||||||
|
return;
|
||||||
|
}
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
incoming=receiver.next()=>match incoming {
|
incoming=receiver.next()=>match incoming {
|
||||||
|
|||||||
+34
-17
@@ -26,6 +26,9 @@ enum PadServerMessage {
|
|||||||
Pong {
|
Pong {
|
||||||
nonce: u64,
|
nonce: u64,
|
||||||
},
|
},
|
||||||
|
Diagnostics {
|
||||||
|
diagnostics: ConnectionDiagnostics,
|
||||||
|
},
|
||||||
Error {
|
Error {
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
@@ -41,11 +44,19 @@ pub async fn upgrade_pad(
|
|||||||
return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response();
|
return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response();
|
||||||
}
|
}
|
||||||
let account_token = crate::security::session_token(&headers).map(str::to_owned);
|
let account_token = crate::security::session_token(&headers).map(str::to_owned);
|
||||||
let resource_token = crate::security::resource_token(&headers, "pad", &slug)
|
let resource_token = crate::security::resource_token(&headers, "pad", &slug).map(str::to_owned);
|
||||||
.map(str::to_owned);
|
|
||||||
let client_key = crate::security::client_key(&headers);
|
let client_key = crate::security::client_key(&headers);
|
||||||
|
let client_context = RequestClientContext::from_headers(&headers, &client_key);
|
||||||
ws.on_upgrade(move |socket| {
|
ws.on_upgrade(move |socket| {
|
||||||
handle_pad_socket(socket, state, slug, account_token, resource_token, client_key)
|
handle_pad_socket(
|
||||||
|
socket,
|
||||||
|
state,
|
||||||
|
slug,
|
||||||
|
account_token,
|
||||||
|
resource_token,
|
||||||
|
client_key,
|
||||||
|
client_context,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
async fn handle_pad_socket(
|
async fn handle_pad_socket(
|
||||||
@@ -55,6 +66,7 @@ async fn handle_pad_socket(
|
|||||||
cookie_session_token: Option<String>,
|
cookie_session_token: Option<String>,
|
||||||
cookie_access_token: Option<String>,
|
cookie_access_token: Option<String>,
|
||||||
client_key: String,
|
client_key: String,
|
||||||
|
client_context: RequestClientContext,
|
||||||
) {
|
) {
|
||||||
info!(%slug, "pad websocket connected");
|
info!(%slug, "pad websocket connected");
|
||||||
let Some(pad) = db::find_pad(&state.db, &slug).await.ok().flatten() else {
|
let Some(pad) = db::find_pad(&state.db, &slug).await.ok().flatten() else {
|
||||||
@@ -68,7 +80,7 @@ async fn handle_pad_socket(
|
|||||||
.await;
|
.await;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let (password, access_token, nickname, guest_id, color) =
|
let (password, access_token, nickname, guest_id, color, client_diagnostics) =
|
||||||
match socket.recv().await {
|
match socket.recv().await {
|
||||||
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
|
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
|
||||||
Ok(ClientMessage::Authenticate {
|
Ok(ClientMessage::Authenticate {
|
||||||
@@ -77,12 +89,14 @@ async fn handle_pad_socket(
|
|||||||
nickname,
|
nickname,
|
||||||
guest_id,
|
guest_id,
|
||||||
color,
|
color,
|
||||||
|
diagnostics,
|
||||||
}) => (
|
}) => (
|
||||||
password,
|
password,
|
||||||
access_token,
|
access_token,
|
||||||
clean_nickname(nickname),
|
clean_nickname(nickname),
|
||||||
clean_guest_id(guest_id),
|
clean_guest_id(guest_id),
|
||||||
clean_color(color),
|
clean_color(color),
|
||||||
|
diagnostics,
|
||||||
),
|
),
|
||||||
_ => {
|
_ => {
|
||||||
let _ = send_pad(
|
let _ = send_pad(
|
||||||
@@ -132,13 +146,7 @@ async fn handle_pad_socket(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let anonymous_token_ok = permission.is_none()
|
let anonymous_token_ok = permission.is_none()
|
||||||
&& anonymous_access_from_tokens(
|
&& anonymous_access_from_tokens(&state, "pad", &slug, access_token.as_deref()).await;
|
||||||
&state,
|
|
||||||
"pad",
|
|
||||||
&slug,
|
|
||||||
access_token.as_deref(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let password_limit_key = format!("resource-password:{client_key}:pad:{slug}");
|
let password_limit_key = format!("resource-password:{client_key}:pad:{slug}");
|
||||||
let password_attempted = password
|
let password_attempted = password
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -157,9 +165,7 @@ async fn handle_pad_socket(
|
|||||||
let _ = send_pad(
|
let _ = send_pad(
|
||||||
&mut socket,
|
&mut socket,
|
||||||
&PadServerMessage::Error {
|
&PadServerMessage::Error {
|
||||||
message: format!(
|
message: format!("Too many password attempts. Try again in {seconds} seconds."),
|
||||||
"Too many password attempts. Try again in {seconds} seconds."
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -172,9 +178,7 @@ async fn handle_pad_socket(
|
|||||||
let _ = send_pad(
|
let _ = send_pad(
|
||||||
&mut socket,
|
&mut socket,
|
||||||
&PadServerMessage::Error {
|
&PadServerMessage::Error {
|
||||||
message: format!(
|
message: format!("Too many password attempts. Try again in {seconds} seconds."),
|
||||||
"Too many password attempts. Try again in {seconds} seconds."
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -239,6 +243,19 @@ async fn handle_pad_socket(
|
|||||||
let _ = channel.send(RoomEvent::Presence(users));
|
let _ = channel.send(RoomEvent::Presence(users));
|
||||||
let mut last_chat = Instant::now() - Duration::from_secs(1);
|
let mut last_chat = Instant::now() - Duration::from_secs(1);
|
||||||
let (mut sender, mut receiver) = socket.split();
|
let (mut sender, mut receiver) = socket.split();
|
||||||
|
if send_pad_split(
|
||||||
|
&mut sender,
|
||||||
|
&PadServerMessage::Diagnostics {
|
||||||
|
diagnostics: connection_diagnostics(connection_id, &client_context, client_diagnostics),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
let users = state.leave_room(&room_key, connection_id).await;
|
||||||
|
let _ = channel.send(RoomEvent::Presence(users));
|
||||||
|
return;
|
||||||
|
}
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
incoming=receiver.next()=>match incoming{
|
incoming=receiver.next()=>match incoming{
|
||||||
|
|||||||
+134
-1
@@ -2772,6 +2772,110 @@ dialog::backdrop {
|
|||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-connection-block {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.connection-details, .mobile-connection-details) {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.connection-details, .mobile-connection-details)>summary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: var(--muted-2);
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.connection-details, .mobile-connection-details)>summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-details__chevron {
|
||||||
|
font-size: .68rem;
|
||||||
|
transition: transform .16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-details[open] .connection-details__chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-diagnostics-popover {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 55;
|
||||||
|
left: 0;
|
||||||
|
bottom: calc(100% + 9px);
|
||||||
|
width: min(430px, calc(100vw - 24px));
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface-card);
|
||||||
|
box-shadow: 0 16px 40px var(--shadow-45);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-diagnostics-popover>strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 9px;
|
||||||
|
font-size: .78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-diagnostics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-diagnostics-grid>div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-diagnostics-grid__wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-diagnostics-grid dt {
|
||||||
|
margin: 0 0 3px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-diagnostics-grid dd {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: .7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.connection-details, .mobile-connection-details):is(.is-quality-excellent, .is-quality-good) [data-connection-diagnostic="quality"] {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.connection-details, .mobile-connection-details).is-quality-degraded [data-connection-diagnostic="quality"] {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.connection-details, .mobile-connection-details).is-quality-poor [data-connection-diagnostic="quality"] {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-more {
|
.markdown-more {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -5160,18 +5264,46 @@ dialog::backdrop {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 34px;
|
||||||
padding: 0 8px 0 4px;
|
padding: 0 8px 0 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--wash-hover);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: .72rem;
|
font-size: .72rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-connection-details {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-connection-details[open]>.mobile-connection-status {
|
||||||
|
background: color-mix(in srgb, var(--accent) 24%, var(--wash-hover));
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-connection-status #mobile-status-text {
|
.mobile-connection-status #mobile-status-text {
|
||||||
max-width: 92px;
|
max-width: 92px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#mobile-socket-latency {
|
||||||
|
color: var(--muted-2);
|
||||||
|
font-size: .66rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-connection-diagnostics-popover {
|
||||||
|
position: fixed;
|
||||||
|
right: 12px;
|
||||||
|
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
|
||||||
|
left: auto;
|
||||||
|
width: min(430px, calc(100vw - 24px));
|
||||||
|
max-height: calc(100dvh - 88px);
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
.pad-page .toast {
|
.pad-page .toast {
|
||||||
bottom: calc(62px + env(safe-area-inset-bottom, 0px));
|
bottom: calc(62px + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
@@ -5303,7 +5435,7 @@ dialog::backdrop {
|
|||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.editor-column-label {
|
.editor-column-label {
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authorship-mode-control button {
|
.authorship-mode-control button {
|
||||||
@@ -6154,6 +6286,7 @@ dialog::backdrop {
|
|||||||
.pad-page .participant-badges:empty {
|
.pad-page .participant-badges:empty {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Unified RustPad resource identity used by notes and workspaces. */
|
/* Unified RustPad resource identity used by notes and workspaces. */
|
||||||
.resource-brand {
|
.resource-brand {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
+111
-6
@@ -163,10 +163,64 @@
|
|||||||
</div>
|
</div>
|
||||||
<footer class="editor-footer">
|
<footer class="editor-footer">
|
||||||
<div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0
|
<div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0
|
||||||
words</span><span class="footer-connection-block"> · <span class="footer-status status"><span
|
words</span>
|
||||||
|
<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-dot" class="status__dot"></span><span
|
||||||
id="status-text">Connecting…</span></span> · <span id="socket-latency"
|
id="status-text">Connecting…</span></span><span id="socket-latency"
|
||||||
class="footer-socket-latency" title="WebSocket round-trip time">— ms</span></span> ·
|
class="footer-socket-latency">— ms</span><span class="connection-details__chevron"
|
||||||
|
aria-hidden="true">▾</span></summary>
|
||||||
|
<div class="connection-diagnostics-popover">
|
||||||
|
<strong>Connection diagnostics</strong>
|
||||||
|
<dl class="connection-diagnostics-grid">
|
||||||
|
<div>
|
||||||
|
<dt>Quality</dt>
|
||||||
|
<dd data-connection-diagnostic="quality">Waiting</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Latency</dt>
|
||||||
|
<dd data-connection-diagnostic="latency">—</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Jitter</dt>
|
||||||
|
<dd data-connection-diagnostic="jitter">—</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Uptime</dt>
|
||||||
|
<dd data-connection-diagnostic="uptime">—</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Reconnects</dt>
|
||||||
|
<dd data-connection-diagnostic="reconnects">0</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Transport</dt>
|
||||||
|
<dd data-connection-diagnostic="transport">WebSocket</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Heartbeat</dt>
|
||||||
|
<dd data-connection-diagnostic="heartbeat">—</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Network</dt>
|
||||||
|
<dd data-connection-diagnostic="network">—</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Client</dt>
|
||||||
|
<dd data-connection-diagnostic="client">—</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Server</dt>
|
||||||
|
<dd data-connection-diagnostic="server">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="connection-diagnostics-grid__wide">
|
||||||
|
<dt>Last event</dt>
|
||||||
|
<dd data-connection-diagnostic="last-event">—</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div> ·
|
||||||
<details id="room-details" class="room-details">
|
<details id="room-details" class="room-details">
|
||||||
<summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread"
|
<summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread"
|
||||||
hidden></span></summary>
|
hidden></span></summary>
|
||||||
@@ -296,9 +350,60 @@
|
|||||||
id="mobile-color-picker" type="color" aria-label="Change editor color"></label>
|
id="mobile-color-picker" type="color" aria-label="Change editor color"></label>
|
||||||
<button id="mobile-chat-button" class="mobile-chat-button" type="button" title="Chat"
|
<button id="mobile-chat-button" class="mobile-chat-button" type="button" title="Chat"
|
||||||
aria-label="Open chat">💬<span id="mobile-chat-unread" class="mobile-chat-unread" hidden></span></button>
|
aria-label="Open chat">💬<span id="mobile-chat-unread" class="mobile-chat-unread" hidden></span></button>
|
||||||
<span id="mobile-connection-status" class="mobile-connection-status" title="WebSocket status"><span
|
<details id="mobile-connection-details" class="mobile-connection-details">
|
||||||
id="mobile-status-dot" class="status__dot"></span><span
|
<summary id="mobile-connection-status" class="mobile-connection-status"
|
||||||
id="mobile-status-text">Connecting…</span></span>
|
title="WebSocket connection diagnostics"><span id="mobile-status-dot" class="status__dot"></span><span
|
||||||
|
id="mobile-status-text">Connecting…</span><span id="mobile-socket-latency">—</span></summary>
|
||||||
|
<div class="connection-diagnostics-popover mobile-connection-diagnostics-popover">
|
||||||
|
<strong>Connection diagnostics</strong>
|
||||||
|
<dl class="connection-diagnostics-grid">
|
||||||
|
<div>
|
||||||
|
<dt>Quality</dt>
|
||||||
|
<dd data-connection-diagnostic="quality">Waiting</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Latency</dt>
|
||||||
|
<dd data-connection-diagnostic="latency">—</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Jitter</dt>
|
||||||
|
<dd data-connection-diagnostic="jitter">—</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Uptime</dt>
|
||||||
|
<dd data-connection-diagnostic="uptime">—</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Reconnects</dt>
|
||||||
|
<dd data-connection-diagnostic="reconnects">0</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Transport</dt>
|
||||||
|
<dd data-connection-diagnostic="transport">WebSocket</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Heartbeat</dt>
|
||||||
|
<dd data-connection-diagnostic="heartbeat">—</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Network</dt>
|
||||||
|
<dd data-connection-diagnostic="network">—</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Client</dt>
|
||||||
|
<dd data-connection-diagnostic="client">—</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Server</dt>
|
||||||
|
<dd data-connection-diagnostic="server">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="connection-diagnostics-grid__wide">
|
||||||
|
<dt>Last event</dt>
|
||||||
|
<dd data-connection-diagnostic="last-event">—</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div id="toast" class="toast"></div>
|
<div id="toast" class="toast"></div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+2
-3
@@ -74,9 +74,8 @@
|
|||||||
<footer class="home-footer">
|
<footer class="home-footer">
|
||||||
<div class="home-footer__inner">
|
<div class="home-footer__inner">
|
||||||
<div id="footer-account-guest" class="home-footer__account">
|
<div id="footer-account-guest" class="home-footer__account">
|
||||||
<button id="footer-login" class="footer-action" type="button">Log in</button>
|
<button id="footer-login" class="footer-action footer-action--primary" type="button">Log in</button>
|
||||||
<button id="footer-register" class="footer-action footer-action--primary" type="button">Register
|
<button id="footer-register" class="footer-action footer-action--primary" type="button">Register</button>
|
||||||
nickname</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="footer-account-user" class="home-footer__account" hidden>
|
<div id="footer-account-user" class="home-footer__account" hidden>
|
||||||
<span id="footer-user-label" class="home-footer__user">
|
<span id="footer-user-label" class="home-footer__user">
|
||||||
|
|||||||
+49
-4
@@ -26,6 +26,37 @@ const DEFAULT_ERRORS = {
|
|||||||
504: "The server took too long to respond. Try again.",
|
504: "The server took too long to respond. Try again.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||||
|
const CSRF_REFRESH_MS = 20 * 60 * 1000;
|
||||||
|
let csrfTokenPromise = null;
|
||||||
|
let csrfTokenFetchedAt = 0;
|
||||||
|
|
||||||
|
async function csrfToken({ refresh = false } = {}) {
|
||||||
|
if (refresh || Date.now() - csrfTokenFetchedAt >= CSRF_REFRESH_MS) {
|
||||||
|
csrfTokenPromise = null;
|
||||||
|
csrfTokenFetchedAt = 0;
|
||||||
|
}
|
||||||
|
if (!csrfTokenPromise) {
|
||||||
|
csrfTokenPromise = fetch("/api/security/csrf", {
|
||||||
|
credentials: "same-origin",
|
||||||
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
}).then(async response => {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || typeof data.token !== "string" || !data.token) {
|
||||||
|
throw requestError(response.status, data);
|
||||||
|
}
|
||||||
|
csrfTokenFetchedAt = Date.now();
|
||||||
|
return data.token;
|
||||||
|
}).catch(error => {
|
||||||
|
csrfTokenPromise = null;
|
||||||
|
csrfTokenFetchedAt = 0;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return csrfTokenPromise;
|
||||||
|
}
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes) {
|
||||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes % (1024 * 1024) ? 1 : 0)} MB`;
|
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes % (1024 * 1024) ? 1 : 0)} MB`;
|
||||||
if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} KB`;
|
if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} KB`;
|
||||||
@@ -67,10 +98,14 @@ function validateUploadSize(body) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestHeaders(options, body) {
|
async function requestHeaders(options, body) {
|
||||||
const headers = new Headers(options.headers || {});
|
const headers = new Headers(options.headers || {});
|
||||||
headers.delete("x-rustpad-user-token");
|
headers.delete("x-rustpad-user-token");
|
||||||
if (!(body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json");
|
if (!(body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json");
|
||||||
|
const method = String(options.method || "GET").toUpperCase();
|
||||||
|
if (!SAFE_METHODS.has(method) && !headers.has("x-rustpad-csrf")) {
|
||||||
|
headers.set("x-rustpad-csrf", await csrfToken());
|
||||||
|
}
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,10 +127,18 @@ export async function api(path, options = {}) {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 12000);
|
const timeout = setTimeout(() => controller.abort(), 12000);
|
||||||
try {
|
try {
|
||||||
const headers = requestHeaders(options, options.body);
|
let headers = await requestHeaders(options, options.body);
|
||||||
const started = performance.now();
|
const started = performance.now();
|
||||||
logDebug("api.request", { method: options.method || "GET", path });
|
logDebug("api.request", { method: options.method || "GET", path });
|
||||||
const response = await fetch(path, { ...options, headers, signal: controller.signal });
|
let response = await fetch(path, { ...options, headers, credentials: "same-origin", signal: controller.signal });
|
||||||
|
if (response.status === 403 && !SAFE_METHODS.has(String(options.method || "GET").toUpperCase())) {
|
||||||
|
const preview = await response.clone().json().catch(() => ({}));
|
||||||
|
if (/security token/i.test(preview.error || "")) {
|
||||||
|
headers = new Headers(headers);
|
||||||
|
headers.set("x-rustpad-csrf", await csrfToken({ refresh: true }));
|
||||||
|
response = await fetch(path, { ...options, headers, credentials: "same-origin", signal: controller.signal });
|
||||||
|
}
|
||||||
|
}
|
||||||
const durationMs = Math.round(performance.now() - started);
|
const durationMs = Math.round(performance.now() - started);
|
||||||
logDebug("api.response", { method: options.method || "GET", path, status: response.status, durationMs });
|
logDebug("api.response", { method: options.method || "GET", path, status: response.status, durationMs });
|
||||||
const contentType = response.headers.get("content-type") || "";
|
const contentType = response.headers.get("content-type") || "";
|
||||||
@@ -129,9 +172,10 @@ export function uploadWithProgress(path, options = {}) {
|
|||||||
const stallTimeoutMs = Number(options.stallTimeoutMs) > 0 ? Number(options.stallTimeoutMs) : 90000;
|
const stallTimeoutMs = Number(options.stallTimeoutMs) > 0 ? Number(options.stallTimeoutMs) : 90000;
|
||||||
const responseTimeoutMs = Number(options.responseTimeoutMs) > 0 ? Number(options.responseTimeoutMs) : 120000;
|
const responseTimeoutMs = Number(options.responseTimeoutMs) > 0 ? Number(options.responseTimeoutMs) : 120000;
|
||||||
|
|
||||||
|
return (async () => {
|
||||||
|
const headers = await requestHeaders({ ...options, method }, options.body);
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
const headers = requestHeaders(options, options.body);
|
|
||||||
const started = performance.now();
|
const started = performance.now();
|
||||||
let lastAt = started;
|
let lastAt = started;
|
||||||
let lastLoaded = 0;
|
let lastLoaded = 0;
|
||||||
@@ -245,4 +289,5 @@ export function uploadWithProgress(path, options = {}) {
|
|||||||
logDebug("api.request", { method, path });
|
logDebug("api.request", { method, path });
|
||||||
xhr.send(options.body ?? null);
|
xhr.send(options.body ?? null);
|
||||||
});
|
});
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { getTheme } from "@rustpad/theme";
|
|||||||
export function startNoteEditor(adapter) {
|
export function startNoteEditor(adapter) {
|
||||||
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
|
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
|
||||||
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
|
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
|
||||||
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"), connectionNotice = document.querySelector("#connection-notice"), connectionNoticeTitle = document.querySelector("#connection-notice-title"), connectionNoticeMessage = document.querySelector("#connection-notice-message");
|
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), mobileConnectionDetails = document.querySelector("#mobile-connection-details"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"), connectionNotice = document.querySelector("#connection-notice"), connectionNoticeTitle = document.querySelector("#connection-notice-title"), connectionNoticeMessage = document.querySelector("#connection-notice-message");
|
||||||
let unreadChat = 0;
|
let unreadChat = 0;
|
||||||
const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
|
const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
|
||||||
const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle");
|
const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle");
|
||||||
@@ -216,7 +216,65 @@ export function startNoteEditor(adapter) {
|
|||||||
updateCurrentUser(); return info;
|
updateCurrentUser(); return info;
|
||||||
}
|
}
|
||||||
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...presenceUsers.map(user => { const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } renderGutter(); }
|
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...presenceUsers.map(user => { const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } renderGutter(); }
|
||||||
function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; }
|
function updateLatency(ms) {
|
||||||
|
const text = Number.isFinite(ms) ? `${ms} ms` : "— ms";
|
||||||
|
socketLatency.textContent = text;
|
||||||
|
const mobileLatency = document.querySelector("#mobile-socket-latency");
|
||||||
|
if (mobileLatency) mobileLatency.textContent = text;
|
||||||
|
}
|
||||||
|
function setDiagnosticField(name, value) {
|
||||||
|
document.querySelectorAll(`[data-connection-diagnostic="${name}"]`).forEach(node => { node.textContent = value; });
|
||||||
|
}
|
||||||
|
function formatDiagnosticDuration(milliseconds) {
|
||||||
|
const seconds = Math.max(0, Math.floor(Number(milliseconds || 0) / 1000));
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
return `${hours}h ${minutes % 60}m`;
|
||||||
|
}
|
||||||
|
function renderConnectionDiagnostics(snapshot = {}) {
|
||||||
|
const server = snapshot.server || {};
|
||||||
|
const runtime = snapshot.runtime || {};
|
||||||
|
const latency = runtime.latency || {};
|
||||||
|
const client = server.client || {};
|
||||||
|
const quality = latency.quality || (runtime.state === "open" ? "measuring" : runtime.state || "waiting");
|
||||||
|
const qualityLabel = quality.charAt(0).toUpperCase() + quality.slice(1);
|
||||||
|
setDiagnosticField("quality", qualityLabel);
|
||||||
|
setDiagnosticField("latency", Number.isFinite(latency.current)
|
||||||
|
? `${latency.current} ms · avg ${latency.average} ms · ${latency.minimum}–${latency.maximum} ms`
|
||||||
|
: "Waiting for heartbeat");
|
||||||
|
setDiagnosticField("jitter", Number.isFinite(latency.jitter) ? `${latency.jitter} ms` : "—");
|
||||||
|
setDiagnosticField("uptime", runtime.authenticated_at
|
||||||
|
? formatDiagnosticDuration(runtime.uptime_ms)
|
||||||
|
: runtime.last_connection_uptime_ms ? `last ${formatDiagnosticDuration(runtime.last_connection_uptime_ms)}` : "—");
|
||||||
|
setDiagnosticField("reconnects", `${runtime.total_reconnects || 0}${runtime.reconnect_attempt ? ` · attempt ${runtime.reconnect_attempt}` : ""}`);
|
||||||
|
const scheme = client.request_scheme ? `${client.request_scheme.toUpperCase()} / ` : "";
|
||||||
|
setDiagnosticField("transport", `${scheme}${server.transport || "WebSocket"}`);
|
||||||
|
setDiagnosticField("heartbeat", server.heartbeat_interval_ms
|
||||||
|
? `${Math.round(server.heartbeat_interval_ms / 1000)}s ping · ${Math.round(server.heartbeat_timeout_ms / 1000)}s timeout`
|
||||||
|
: "Waiting for server policy");
|
||||||
|
const network = runtime.network || {};
|
||||||
|
const networkParts = [network.effective_type || client.effective_type];
|
||||||
|
if (Number.isFinite(network.downlink_mbps ?? client.downlink_mbps)) networkParts.push(`${network.downlink_mbps ?? client.downlink_mbps} Mb/s`);
|
||||||
|
if (Number.isFinite(network.rtt_ms ?? client.network_rtt_ms)) networkParts.push(`system RTT ${Math.round(network.rtt_ms ?? client.network_rtt_ms)} ms`);
|
||||||
|
if ((network.save_data ?? client.save_data) === true) networkParts.push("data saver");
|
||||||
|
setDiagnosticField("network", networkParts.filter(Boolean).join(" · ") || (runtime.online === false ? "Offline" : "Not exposed by browser"));
|
||||||
|
const clientParts = [client.platform, client.timezone, client.language || client.accept_language, client.id ? `id ${client.id}` : null, client.user_agent];
|
||||||
|
setDiagnosticField("client", clientParts.filter(Boolean).join(" · ") || "Waiting for server data");
|
||||||
|
setDiagnosticField("server", server.server_version ? `RustPad ${server.server_version} · connection ${server.connection_id}` : "Waiting for server data");
|
||||||
|
const lastEvent = runtime.last_close
|
||||||
|
? `Closed ${runtime.last_close.code}${runtime.last_close.reason ? `: ${runtime.last_close.reason}` : ""}`
|
||||||
|
: runtime.last_message_at ? `Message ${new Date(runtime.last_message_at).toLocaleTimeString()}` : "No messages yet";
|
||||||
|
const traffic = `${formatBytes(runtime.bytes_received)} received · ${formatBytes(runtime.bytes_sent)} sent`;
|
||||||
|
const buffered = runtime.buffered_amount ? ` · ${formatBytes(runtime.buffered_amount)} buffered` : "";
|
||||||
|
setDiagnosticField("last-event", `${lastEvent} · ${runtime.visibility || document.visibilityState} · ${traffic}${buffered}`);
|
||||||
|
for (const details of [document.querySelector("#connection-details"), document.querySelector("#mobile-connection-details")]) {
|
||||||
|
if (!details) continue;
|
||||||
|
details.classList.remove("is-quality-excellent", "is-quality-good", "is-quality-degraded", "is-quality-poor");
|
||||||
|
if (["excellent", "good", "degraded", "poor"].includes(quality)) details.classList.add(`is-quality-${quality}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); }
|
function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); }
|
||||||
function appendChatMessage(message) { const empty = chatMessages.querySelector(".chat-empty"); empty?.remove(); const row = document.createElement("p"); row.className = "chat-message"; const author = document.createElement("strong"); author.textContent = message.sender; const text = document.createElement("span"); appendLinkifiedText(text, message.text); row.append(author, text); chatMessages.append(row); while (chatMessages.children.length > 100) chatMessages.firstElementChild.remove(); chatMessages.scrollTop = chatMessages.scrollHeight; if (message.sender !== nickname && !roomDetails.open) { unreadChat++; chatUnread.hidden = false; chatUnread.textContent = unreadChat > 99 ? "99+" : String(unreadChat); if (mobileChatUnread) { mobileChatUnread.hidden = false; mobileChatUnread.textContent = chatUnread.textContent; } const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `● ${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(`${message.sender} wrote in RustPad`, { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } }
|
function appendChatMessage(message) { const empty = chatMessages.querySelector(".chat-empty"); empty?.remove(); const row = document.createElement("p"); row.className = "chat-message"; const author = document.createElement("strong"); author.textContent = message.sender; const text = document.createElement("span"); appendLinkifiedText(text, message.text); row.append(author, text); chatMessages.append(row); while (chatMessages.children.length > 100) chatMessages.firstElementChild.remove(); chatMessages.scrollTop = chatMessages.scrollHeight; if (message.sender !== nickname && !roomDetails.open) { unreadChat++; chatUnread.hidden = false; chatUnread.textContent = unreadChat > 99 ? "99+" : String(unreadChat); if (mobileChatUnread) { mobileChatUnread.hidden = false; mobileChatUnread.textContent = chatUnread.textContent; } const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `● ${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(`${message.sender} wrote in RustPad`, { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } }
|
||||||
function clearUnread() { unreadChat = 0; chatUnread.hidden = true; chatUnread.textContent = ""; if (mobileChatUnread) { mobileChatUnread.hidden = true; mobileChatUnread.textContent = ""; } document.title = document.title.replace(/^● /, ""); }
|
function clearUnread() { unreadChat = 0; chatUnread.hidden = true; chatUnread.textContent = ""; if (mobileChatUnread) { mobileChatUnread.hidden = true; mobileChatUnread.textContent = ""; } document.title = document.title.replace(/^● /, ""); }
|
||||||
@@ -774,6 +832,7 @@ export function startNoteEditor(adapter) {
|
|||||||
},
|
},
|
||||||
onPresence: updatePresence,
|
onPresence: updatePresence,
|
||||||
onLatency: updateLatency,
|
onLatency: updateLatency,
|
||||||
|
onDiagnostics: renderConnectionDiagnostics,
|
||||||
onChat: appendChatMessage,
|
onChat: appendChatMessage,
|
||||||
onError: message => {
|
onError: message => {
|
||||||
hideConnectionNotice();
|
hideConnectionNotice();
|
||||||
@@ -1281,10 +1340,13 @@ export function startNoteEditor(adapter) {
|
|||||||
pageSettings?.querySelector("summary")?.setAttribute("title", enabled ? "Published page enabled" : "Published page disabled");
|
pageSettings?.querySelector("summary")?.setAttribute("title", enabled ? "Published page enabled" : "Published page disabled");
|
||||||
}
|
}
|
||||||
document.addEventListener("pointerdown", event => {
|
document.addEventListener("pointerdown", event => {
|
||||||
if (pageSettings?.open && !event.target.closest(".page-settings")) pageSettings.open = false;
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
|
if (pageSettings?.open && !target?.closest(".page-settings")) pageSettings.open = false;
|
||||||
|
if (mobileConnectionDetails?.open && (!target || !mobileConnectionDetails.contains(target))) mobileConnectionDetails.open = false;
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
document.addEventListener("keydown", event => {
|
document.addEventListener("keydown", event => {
|
||||||
if (event.key === "Escape" && pageSettings?.open) pageSettings.open = false;
|
if (event.key === "Escape" && pageSettings?.open) pageSettings.open = false;
|
||||||
|
if (event.key === "Escape" && mobileConnectionDetails?.open) mobileConnectionDetails.open = false;
|
||||||
});
|
});
|
||||||
const savePublicOptions = async () => adapter.publish(accessToken, publicTaskUpdates.checked, unprotectPublicPage.checked, publicPageEnabled.checked);
|
const savePublicOptions = async () => adapter.publish(accessToken, publicTaskUpdates.checked, unprotectPublicPage.checked, publicPageEnabled.checked);
|
||||||
publicPageEnabled.addEventListener("change", async () => {
|
publicPageEnabled.addEventListener("change", async () => {
|
||||||
|
|||||||
+139
-8
@@ -9,9 +9,10 @@
|
|||||||
|
|
||||||
import { logError, logInfo, logWarn } from "@rustpad/logger";
|
import { logError, logInfo, logWarn } from "@rustpad/logger";
|
||||||
|
|
||||||
const HEARTBEAT_INTERVAL_MS = 10000;
|
const DEFAULT_HEARTBEAT_INTERVAL_MS = 10000;
|
||||||
const HEARTBEAT_TIMEOUT_MS = 30000;
|
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 30000;
|
||||||
const MAX_RECONNECT_DELAY_MS = 12000;
|
const DEFAULT_MAX_RECONNECT_DELAY_MS = 12000;
|
||||||
|
const DEFAULT_LATENCY_SAMPLE_WINDOW = 20;
|
||||||
|
|
||||||
class RoomSocket {
|
class RoomSocket {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
@@ -23,6 +24,21 @@ class RoomSocket {
|
|||||||
this.intentionalClose = false;
|
this.intentionalClose = false;
|
||||||
this.reconnectAttempt = 0;
|
this.reconnectAttempt = 0;
|
||||||
this.pendingPings = new Map();
|
this.pendingPings = new Map();
|
||||||
|
this.heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS;
|
||||||
|
this.heartbeatTimeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS;
|
||||||
|
this.maxReconnectDelayMs = DEFAULT_MAX_RECONNECT_DELAY_MS;
|
||||||
|
this.latencySampleWindow = DEFAULT_LATENCY_SAMPLE_WINDOW;
|
||||||
|
this.latencySamples = [];
|
||||||
|
this.totalReconnects = 0;
|
||||||
|
this.connectedAt = null;
|
||||||
|
this.authenticatedAt = null;
|
||||||
|
this.lastConnectionUptimeMs = 0;
|
||||||
|
this.lastMessageAt = null;
|
||||||
|
this.lastClose = null;
|
||||||
|
this.bytesSent = 0;
|
||||||
|
this.bytesReceived = 0;
|
||||||
|
this.serverDiagnostics = null;
|
||||||
|
this.diagnosticsTimer = null;
|
||||||
this.handleOnline = () => this.reconnectNow("Network connection restored.");
|
this.handleOnline = () => this.reconnectNow("Network connection restored.");
|
||||||
this.handleOffline = () => this.handleNetworkOffline();
|
this.handleOffline = () => this.handleNetworkOffline();
|
||||||
this.handleVisibility = () => this.checkHeartbeat();
|
this.handleVisibility = () => this.checkHeartbeat();
|
||||||
@@ -34,9 +50,23 @@ class RoomSocket {
|
|||||||
get url() { throw new Error("Socket URL not implemented"); }
|
get url() { throw new Error("Socket URL not implemented"); }
|
||||||
get kind() { return "room"; }
|
get kind() { return "room"; }
|
||||||
|
|
||||||
|
clientDiagnostics() {
|
||||||
|
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
|
||||||
|
return {
|
||||||
|
language: navigator.language || null,
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || null,
|
||||||
|
platform: navigator.userAgentData?.platform || navigator.platform || null,
|
||||||
|
effective_type: connection?.effectiveType || null,
|
||||||
|
downlink_mbps: Number.isFinite(connection?.downlink) ? connection.downlink : null,
|
||||||
|
network_rtt_ms: Number.isFinite(connection?.rtt) ? Math.max(0, Math.round(connection.rtt)) : null,
|
||||||
|
save_data: typeof connection?.saveData === "boolean" ? connection.saveData : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
clearTimeout(this.reconnectTimer);
|
clearTimeout(this.reconnectTimer);
|
||||||
clearInterval(this.pingTimer);
|
clearInterval(this.pingTimer);
|
||||||
|
clearInterval(this.diagnosticsTimer);
|
||||||
if (this.stopped) return;
|
if (this.stopped) return;
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
this.scheduleReconnect("Your device is offline.");
|
this.scheduleReconnect("Your device is offline.");
|
||||||
@@ -48,6 +78,7 @@ class RoomSocket {
|
|||||||
attempt: this.reconnectAttempt,
|
attempt: this.reconnectAttempt,
|
||||||
message: this.reconnectAttempt ? "Re-establishing the live connection." : "Opening the live connection.",
|
message: this.reconnectAttempt ? "Re-establishing the live connection." : "Opening the live connection.",
|
||||||
});
|
});
|
||||||
|
this.emitDiagnostics();
|
||||||
|
|
||||||
let socket;
|
let socket;
|
||||||
try {
|
try {
|
||||||
@@ -62,6 +93,9 @@ class RoomSocket {
|
|||||||
socket.addEventListener("open", () => {
|
socket.addEventListener("open", () => {
|
||||||
if (socket !== this.socket || this.stopped) return;
|
if (socket !== this.socket || this.stopped) return;
|
||||||
logInfo("websocket.open", { kind: this.kind });
|
logInfo("websocket.open", { kind: this.kind });
|
||||||
|
this.connectedAt = Date.now();
|
||||||
|
this.serverDiagnostics = null;
|
||||||
|
this.lastClose = null;
|
||||||
this.send({
|
this.send({
|
||||||
type: "authenticate",
|
type: "authenticate",
|
||||||
password: this.password || null,
|
password: this.password || null,
|
||||||
@@ -69,13 +103,17 @@ class RoomSocket {
|
|||||||
nickname: this.nickname || null,
|
nickname: this.nickname || null,
|
||||||
guest_id: this.guestId || null,
|
guest_id: this.guestId || null,
|
||||||
color: this.color || null,
|
color: this.color || null,
|
||||||
|
diagnostics: this.clientDiagnostics(),
|
||||||
});
|
});
|
||||||
|
this.emitDiagnostics();
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.addEventListener("message", event => {
|
socket.addEventListener("message", event => {
|
||||||
if (socket !== this.socket || this.stopped) return;
|
if (socket !== this.socket || this.stopped) return;
|
||||||
let message;
|
let message;
|
||||||
try { message = JSON.parse(event.data); } catch { return; }
|
try { message = JSON.parse(event.data); } catch { return; }
|
||||||
|
this.lastMessageAt = Date.now();
|
||||||
|
this.bytesReceived += typeof event.data === "string" ? new Blob([event.data]).size : Number(event.data?.byteLength || 0);
|
||||||
if (message.type === "error") {
|
if (message.type === "error") {
|
||||||
this.intentionalClose = true;
|
this.intentionalClose = true;
|
||||||
this.onError?.(message.message);
|
this.onError?.(message.message);
|
||||||
@@ -85,9 +123,17 @@ class RoomSocket {
|
|||||||
if (message.type === "authenticated") {
|
if (message.type === "authenticated") {
|
||||||
const restored = this.reconnectAttempt > 0;
|
const restored = this.reconnectAttempt > 0;
|
||||||
this.reconnectAttempt = 0;
|
this.reconnectAttempt = 0;
|
||||||
|
this.authenticatedAt = Date.now();
|
||||||
|
this.latencySamples = [];
|
||||||
this.onStatus?.("online", { restored });
|
this.onStatus?.("online", { restored });
|
||||||
this.onAuthenticated?.(message);
|
this.onAuthenticated?.(message);
|
||||||
this.startPing();
|
this.startPing();
|
||||||
|
this.startDiagnostics();
|
||||||
|
this.emitDiagnostics();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.type === "diagnostics") {
|
||||||
|
this.applyServerDiagnostics(message.diagnostics || {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.type === "document") this.onDocument?.(message);
|
if (message.type === "document") this.onDocument?.(message);
|
||||||
@@ -97,7 +143,11 @@ class RoomSocket {
|
|||||||
const started = this.pendingPings.get(message.nonce);
|
const started = this.pendingPings.get(message.nonce);
|
||||||
if (started !== undefined) {
|
if (started !== undefined) {
|
||||||
this.pendingPings.delete(message.nonce);
|
this.pendingPings.delete(message.nonce);
|
||||||
this.onLatency?.(Math.max(0, Math.round(performance.now() - started)));
|
const latency = Math.max(0, Math.round(performance.now() - started));
|
||||||
|
this.latencySamples.push(latency);
|
||||||
|
while (this.latencySamples.length > this.latencySampleWindow) this.latencySamples.shift();
|
||||||
|
this.onLatency?.(latency);
|
||||||
|
this.emitDiagnostics();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -105,9 +155,16 @@ class RoomSocket {
|
|||||||
socket.addEventListener("close", event => {
|
socket.addEventListener("close", event => {
|
||||||
if (socket !== this.socket) return;
|
if (socket !== this.socket) return;
|
||||||
clearInterval(this.pingTimer);
|
clearInterval(this.pingTimer);
|
||||||
|
clearInterval(this.diagnosticsTimer);
|
||||||
this.pendingPings.clear();
|
this.pendingPings.clear();
|
||||||
this.onPresence?.([]);
|
this.onPresence?.([]);
|
||||||
this.onLatency?.(null);
|
this.onLatency?.(null);
|
||||||
|
const closedAt = Date.now();
|
||||||
|
this.lastConnectionUptimeMs = this.authenticatedAt ? Math.max(0, closedAt - this.authenticatedAt) : this.lastConnectionUptimeMs;
|
||||||
|
this.connectedAt = null;
|
||||||
|
this.authenticatedAt = null;
|
||||||
|
this.lastClose = { code: event.code, reason: event.reason || "", at: closedAt };
|
||||||
|
this.emitDiagnostics();
|
||||||
logWarn("websocket.close", {
|
logWarn("websocket.close", {
|
||||||
kind: this.kind,
|
kind: this.kind,
|
||||||
code: event.code,
|
code: event.code,
|
||||||
@@ -138,9 +195,11 @@ class RoomSocket {
|
|||||||
if (this.stopped) return;
|
if (this.stopped) return;
|
||||||
clearTimeout(this.reconnectTimer);
|
clearTimeout(this.reconnectTimer);
|
||||||
this.reconnectAttempt += 1;
|
this.reconnectAttempt += 1;
|
||||||
const baseDelay = Math.min(MAX_RECONNECT_DELAY_MS, 750 * (2 ** Math.min(this.reconnectAttempt - 1, 4)));
|
const baseDelay = Math.min(this.maxReconnectDelayMs, 750 * (2 ** Math.min(this.reconnectAttempt - 1, 4)));
|
||||||
const retryInMs = navigator.onLine ? baseDelay : 3000;
|
const retryInMs = navigator.onLine ? baseDelay : 3000;
|
||||||
|
this.totalReconnects += 1;
|
||||||
this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt, message, retryInMs });
|
this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt, message, retryInMs });
|
||||||
|
this.emitDiagnostics();
|
||||||
this.reconnectTimer = window.setTimeout(() => this.connect(), retryInMs);
|
this.reconnectTimer = window.setTimeout(() => this.connect(), retryInMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +223,7 @@ class RoomSocket {
|
|||||||
checkHeartbeat() {
|
checkHeartbeat() {
|
||||||
if (this.stopped || this.socket?.readyState !== WebSocket.OPEN) return;
|
if (this.stopped || this.socket?.readyState !== WebSocket.OPEN) return;
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
const expired = [...this.pendingPings.values()].some(started => now - started >= HEARTBEAT_TIMEOUT_MS);
|
const expired = [...this.pendingPings.values()].some(started => now - started >= this.heartbeatTimeoutMs);
|
||||||
if (expired) {
|
if (expired) {
|
||||||
logWarn("websocket.heartbeat_timeout", { kind: this.kind });
|
logWarn("websocket.heartbeat_timeout", { kind: this.kind });
|
||||||
this.socket.close(4000, "heartbeat timeout");
|
this.socket.close(4000, "heartbeat timeout");
|
||||||
@@ -181,12 +240,83 @@ class RoomSocket {
|
|||||||
this.send({ type: "ping", nonce });
|
this.send({ type: "ping", nonce });
|
||||||
};
|
};
|
||||||
ping();
|
ping();
|
||||||
this.pingTimer = window.setInterval(ping, HEARTBEAT_INTERVAL_MS);
|
this.pingTimer = window.setInterval(ping, this.heartbeatIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
startDiagnostics() {
|
||||||
|
clearInterval(this.diagnosticsTimer);
|
||||||
|
this.diagnosticsTimer = window.setInterval(() => this.emitDiagnostics(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyServerDiagnostics(diagnostics) {
|
||||||
|
this.serverDiagnostics = diagnostics;
|
||||||
|
const positiveNumber = (value, fallback, min, max) => {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) && parsed >= min && parsed <= max ? parsed : fallback;
|
||||||
|
};
|
||||||
|
this.heartbeatIntervalMs = positiveNumber(diagnostics.heartbeat_interval_ms, this.heartbeatIntervalMs, 1000, 120000);
|
||||||
|
this.heartbeatTimeoutMs = positiveNumber(diagnostics.heartbeat_timeout_ms, this.heartbeatTimeoutMs, this.heartbeatIntervalMs * 2, 300000);
|
||||||
|
this.maxReconnectDelayMs = positiveNumber(diagnostics.max_reconnect_delay_ms, this.maxReconnectDelayMs, 1000, 120000);
|
||||||
|
this.latencySampleWindow = Math.round(positiveNumber(diagnostics.latency_sample_window, this.latencySampleWindow, 3, 100));
|
||||||
|
while (this.latencySamples.length > this.latencySampleWindow) this.latencySamples.shift();
|
||||||
|
if (this.socket?.readyState === WebSocket.OPEN && this.authenticatedAt) this.startPing();
|
||||||
|
this.onServerDiagnostics?.(diagnostics);
|
||||||
|
this.emitDiagnostics();
|
||||||
|
}
|
||||||
|
|
||||||
|
latencyStats() {
|
||||||
|
if (!this.latencySamples.length) return { current: null, average: null, minimum: null, maximum: null, jitter: null, quality: "unknown" };
|
||||||
|
const samples = this.latencySamples;
|
||||||
|
const current = samples.at(-1);
|
||||||
|
const average = Math.round(samples.reduce((sum, value) => sum + value, 0) / samples.length);
|
||||||
|
const minimum = Math.min(...samples);
|
||||||
|
const maximum = Math.max(...samples);
|
||||||
|
const differences = samples.slice(1).map((value, index) => Math.abs(value - samples[index]));
|
||||||
|
const jitter = differences.length ? Math.round(differences.reduce((sum, value) => sum + value, 0) / differences.length) : 0;
|
||||||
|
const thresholds = this.serverDiagnostics?.quality_thresholds || {};
|
||||||
|
const excellent = Number(thresholds.excellent_max_ms ?? 100);
|
||||||
|
const good = Number(thresholds.good_max_ms ?? 250);
|
||||||
|
const degraded = Number(thresholds.degraded_max_ms ?? 600);
|
||||||
|
const quality = current <= excellent ? "excellent" : current <= good ? "good" : current <= degraded ? "degraded" : "poor";
|
||||||
|
return { current, average, minimum, maximum, jitter, quality };
|
||||||
|
}
|
||||||
|
|
||||||
|
emitDiagnostics() {
|
||||||
|
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
|
||||||
|
const readyStates = ["connecting", "open", "closing", "closed"];
|
||||||
|
this.onDiagnostics?.({
|
||||||
|
server: this.serverDiagnostics,
|
||||||
|
runtime: {
|
||||||
|
state: readyStates[this.socket?.readyState ?? WebSocket.CLOSED] || "closed",
|
||||||
|
online: navigator.onLine,
|
||||||
|
visibility: document.visibilityState,
|
||||||
|
uptime_ms: this.authenticatedAt ? Math.max(0, Date.now() - this.authenticatedAt) : 0,
|
||||||
|
last_connection_uptime_ms: this.lastConnectionUptimeMs,
|
||||||
|
reconnect_attempt: this.reconnectAttempt,
|
||||||
|
total_reconnects: this.totalReconnects,
|
||||||
|
connected_at: this.connectedAt,
|
||||||
|
authenticated_at: this.authenticatedAt,
|
||||||
|
last_message_at: this.lastMessageAt,
|
||||||
|
last_close: this.lastClose,
|
||||||
|
buffered_amount: this.socket?.bufferedAmount || 0,
|
||||||
|
bytes_sent: this.bytesSent,
|
||||||
|
bytes_received: this.bytesReceived,
|
||||||
|
network: {
|
||||||
|
effective_type: connection?.effectiveType || null,
|
||||||
|
downlink_mbps: Number.isFinite(connection?.downlink) ? connection.downlink : null,
|
||||||
|
rtt_ms: Number.isFinite(connection?.rtt) ? connection.rtt : null,
|
||||||
|
save_data: typeof connection?.saveData === "boolean" ? connection.saveData : null,
|
||||||
|
},
|
||||||
|
latency: this.latencyStats(),
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
send(message) {
|
send(message) {
|
||||||
if (this.socket?.readyState !== WebSocket.OPEN) return false;
|
if (this.socket?.readyState !== WebSocket.OPEN) return false;
|
||||||
this.socket.send(JSON.stringify(message));
|
const payload = JSON.stringify(message);
|
||||||
|
this.bytesSent += new Blob([payload]).size;
|
||||||
|
this.socket.send(payload);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +329,7 @@ class RoomSocket {
|
|||||||
this.intentionalClose = true;
|
this.intentionalClose = true;
|
||||||
clearTimeout(this.reconnectTimer);
|
clearTimeout(this.reconnectTimer);
|
||||||
clearInterval(this.pingTimer);
|
clearInterval(this.pingTimer);
|
||||||
|
clearInterval(this.diagnosticsTimer);
|
||||||
window.removeEventListener("online", this.handleOnline);
|
window.removeEventListener("online", this.handleOnline);
|
||||||
window.removeEventListener("offline", this.handleOffline);
|
window.removeEventListener("offline", this.handleOffline);
|
||||||
document.removeEventListener("visibilitychange", this.handleVisibility);
|
document.removeEventListener("visibilitychange", this.handleVisibility);
|
||||||
|
|||||||
Executable
+829
@@ -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())
|
||||||
Reference in New Issue
Block a user