0.2.88
This commit is contained in:
1 parent
df403c0aa7
commit
9e133628e5
17 files changed
+248
-12
No files matched your search
@@ -10,6 +10,9 @@ RUSTPAD_PORT=8200
|
|||||||
# SQLite — default
|
# SQLite — default
|
||||||
DATABASE_URL=sqlite:///data/db/rustpad.db?mode=rwc
|
DATABASE_URL=sqlite:///data/db/rustpad.db?mode=rwc
|
||||||
|
|
||||||
|
# http compression:
|
||||||
|
HTTP_COMPRESSION=true
|
||||||
|
|
||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
# DATABASE_URL=postgres://rustpad:rustpad@postgres:5432/rustpad
|
# DATABASE_URL=postgres://rustpad:rustpad@postgres:5432/rustpad
|
||||||
|
|
||||||
|
|||||||
Generated
+107
-1
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -11,6 +17,21 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-no-stdlib"
|
||||||
|
version = "3.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-stdlib"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -86,6 +107,18 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-compression"
|
||||||
|
version = "0.4.50"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1"
|
||||||
|
dependencies = [
|
||||||
|
"compression-codecs",
|
||||||
|
"compression-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.91"
|
version = "0.1.91"
|
||||||
@@ -712,6 +745,27 @@ dependencies = [
|
|||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli"
|
||||||
|
version = "9.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
"brotli-decompressor",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli-decompressor"
|
||||||
|
version = "6.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
@@ -787,6 +841,24 @@ version = "0.5.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-codecs"
|
||||||
|
version = "0.4.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
|
||||||
|
dependencies = [
|
||||||
|
"brotli",
|
||||||
|
"compression-core",
|
||||||
|
"flate2",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-core"
|
||||||
|
version = "0.4.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -1149,6 +1221,17 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
"zlib-rs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -2021,6 +2104,16 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -2581,7 +2674,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.87"
|
version = "0.2.88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
@@ -2835,6 +2928,12 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -3350,6 +3449,7 @@ version = "0.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233"
|
checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-compression",
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -4026,6 +4126,12 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zlib-rs"
|
||||||
|
version = "0.6.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.87"
|
version = "0.2.88"
|
||||||
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"
|
||||||
@@ -28,7 +28,7 @@ slug = "0.1.6"
|
|||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "any", "sqlite", "postgres", "mysql", "chrono", "migrate"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "any", "sqlite", "postgres", "mysql", "chrono", "migrate"] }
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "signal"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "signal"] }
|
||||||
tower = "0.5.3"
|
tower = "0.5.3"
|
||||||
tower-http = { version = "0.7", features = ["fs", "trace", "set-header"] }
|
tower-http = { version = "0.7", features = ["fs", "trace", "set-header", "compression-br", "compression-gzip"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Rustpad 0.2.88 — wyniki testów wydajności
|
||||||
|
|
||||||
|
Zmiana: w `src/websocket/pad.rs` i `src/websocket/mod.rs` ograniczono sprawdzanie dostępu przy przychodzących zdarzeniach broadcast do najwyżej jednego sprawdzenia na sekundę na połączenie. Kontrola cykliczna i zdarzenia zmiany hasła pozostają aktywne. W zestawie pozostają optymalizacje renderowania z poprzedniej iteracji.
|
||||||
|
|
||||||
|
## Symulacja deterministyczna (Node.js)
|
||||||
|
|
||||||
|
Założenia: 10 s, 2 edycje/s na użytkownika, każda edycja rozsyłana do wszystkich N odbiorców (w tym nadawcy), bez rzeczywistej sieci, bazy danych, WebSocket czy renderowania. Symulacja porównuje liczby sprawdzeń dostępu, a **nie** rzeczywistą przepustowość ani opóźnienia Rustpada.
|
||||||
|
|
||||||
|
| Użytkownicy | Dostawy zdarzeń | Sprawdzenia przed | Sprawdzenia po | Redukcja |
|
||||||
|
|---:|---:|---:|---:|---:|
|
||||||
|
| 5 | 500 | 500 | 50 | 90,00% |
|
||||||
|
| 100 | 200 000 | 200 000 | 1 000 | 99,50% |
|
||||||
|
| 300 | 1 800 000 | 1 800 000 | 3 000 | 99,83% |
|
||||||
|
| 500 | 5 000 000 | 5 000 000 | 5 000 | 99,90% |
|
||||||
|
|
||||||
|
Uruchomienie: `node tests/broadcast-load-simulation.mjs`.
|
||||||
|
|
||||||
|
## Testy
|
||||||
|
|
||||||
|
- `node tests/feature-smoke-test.mjs` — PASS
|
||||||
|
- `node tests/mention-autocomplete-test.mjs` — PASS
|
||||||
|
- `node tests/mention-navigation-test.mjs` — PASS
|
||||||
|
- `node --check static/js/*.js` (osobno dla plików) — PASS
|
||||||
|
- `cargo test` — NIEURUCHOMIONY (brak Rust/cargo w środowisku)
|
||||||
|
- Test end-to-end z setkami aktywnych przeglądarek i prawdziwym WebSocket/PostgreSQL — NIEURUCHOMIONY.
|
||||||
|
|
||||||
|
## Ograniczenia
|
||||||
|
|
||||||
|
Nie można zagwarantować płynnej pracy kilkuset użytkowników bez testu na docelowym sprzęcie i bazie. Każdy zapis nadal przechodzi przez serializację zmian i trwałe utrwalenie w bazie; duża liczba operacji może nasycić bazę i sieć. Zmiana odświeżania uprawnień może wydłużyć wykrycie odwołania dostępu w trakcie ciągłego broadcastu do około 1 sekundy; istniejący timer kontroli okresowej pozostaje. Wersja 0.2.88 jest wydaniem z nową poprawką na życzenie użytkownika i nie należy jej mylić z wcześniej wydaną paczką 0.2.88.
|
||||||
@@ -371,3 +371,13 @@ python3 tests/random_data.py \
|
|||||||
```
|
```
|
||||||
|
|
||||||
`--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.
|
`--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.
|
||||||
|
|
||||||
|
### HTTP response compression
|
||||||
|
|
||||||
|
Rustpad optionally supports Brotli and gzip for HTTP responses. Add to `.env` (or the YAML config):
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
HTTP_COMPRESSION=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Defaults to `false`. When enabled, the server selects Brotli or gzip supported by the browser via `Accept-Encoding`, and sends ordinary responses otherwise. This does **not** compress live WebSocket messages. When a reverse proxy already applies HTTP compression, avoid double configuration where possible.
|
||||||
@@ -20,6 +20,7 @@ use axum::{
|
|||||||
use pages::*;
|
use pages::*;
|
||||||
use tower::{ServiceBuilder, service_fn};
|
use tower::{ServiceBuilder, service_fn};
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
|
compression::CompressionLayer,
|
||||||
services::ServeDir,
|
services::ServeDir,
|
||||||
set_header::SetResponseHeaderLayer,
|
set_header::SetResponseHeaderLayer,
|
||||||
trace::{MakeSpan, TraceLayer},
|
trace::{MakeSpan, TraceLayer},
|
||||||
@@ -48,6 +49,7 @@ pub fn router(
|
|||||||
static_dir: &str,
|
static_dir: &str,
|
||||||
upload_body_limit_bytes: usize,
|
upload_body_limit_bytes: usize,
|
||||||
asset_cache_max_age_seconds: u64,
|
asset_cache_max_age_seconds: u64,
|
||||||
|
http_compression: bool,
|
||||||
) -> Router {
|
) -> Router {
|
||||||
let asset_version = state.asset_version.clone();
|
let asset_version = state.asset_version.clone();
|
||||||
let asset_not_found = service_fn(move |_request| {
|
let asset_not_found = service_fn(move |_request| {
|
||||||
@@ -259,6 +261,12 @@ pub fn router(
|
|||||||
.layer(TraceLayer::new_for_http().make_span_with(PathOnlyMakeSpan))
|
.layer(TraceLayer::new_for_http().make_span_with(PathOnlyMakeSpan))
|
||||||
.layer(middleware::from_fn(require_csrf_token))
|
.layer(middleware::from_fn(require_csrf_token))
|
||||||
.layer(middleware::from_fn(apply_response_header_policy))
|
.layer(middleware::from_fn(apply_response_header_policy))
|
||||||
|
// Negotiate HTTP compression only when enabled; WebSocket frames are unaffected.
|
||||||
|
.layer(
|
||||||
|
CompressionLayer::new()
|
||||||
|
.br(http_compression)
|
||||||
|
.gzip(http_compression),
|
||||||
|
)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ pub struct Config {
|
|||||||
pub guest_upload_max_size_bytes: usize,
|
pub guest_upload_max_size_bytes: usize,
|
||||||
pub asset_version: String,
|
pub asset_version: String,
|
||||||
pub asset_cache_max_age_seconds: u64,
|
pub asset_cache_max_age_seconds: u64,
|
||||||
|
pub http_compression: bool,
|
||||||
pub file_cache_max_age_seconds: u64,
|
pub file_cache_max_age_seconds: u64,
|
||||||
pub files_public_url: Option<String>,
|
pub files_public_url: Option<String>,
|
||||||
pub smtp: Option<crate::state::SmtpConfig>,
|
pub smtp: Option<crate::state::SmtpConfig>,
|
||||||
@@ -174,6 +175,7 @@ impl Config {
|
|||||||
guest_upload_max_size_mb,
|
guest_upload_max_size_mb,
|
||||||
)?,
|
)?,
|
||||||
asset_version: env!("CARGO_PKG_VERSION").to_owned(),
|
asset_version: env!("CARGO_PKG_VERSION").to_owned(),
|
||||||
|
http_compression: values.bool("HTTP_COMPRESSION", false)?,
|
||||||
asset_cache_max_age_seconds: values
|
asset_cache_max_age_seconds: values
|
||||||
.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
|
.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
|
||||||
file_cache_max_age_seconds: values
|
file_cache_max_age_seconds: values
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const KNOWN_CONFIG_KEYS: &[&str] = &[
|
|||||||
"GUEST_UPLOAD_ENABLED",
|
"GUEST_UPLOAD_ENABLED",
|
||||||
"GUEST_UPLOAD_MAX_SIZE_MB",
|
"GUEST_UPLOAD_MAX_SIZE_MB",
|
||||||
"ASSET_CACHE_MAX_AGE_SECONDS",
|
"ASSET_CACHE_MAX_AGE_SECONDS",
|
||||||
|
"HTTP_COMPRESSION",
|
||||||
"FILE_CACHE_MAX_AGE_SECONDS",
|
"FILE_CACHE_MAX_AGE_SECONDS",
|
||||||
"REGISTRATION_ENABLED",
|
"REGISTRATION_ENABLED",
|
||||||
"ACCOUNT_CONFIRMATION_REQUIRED",
|
"ACCOUNT_CONFIRMATION_REQUIRED",
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
guest_upload_enabled = config.guest_upload_enabled,
|
guest_upload_enabled = config.guest_upload_enabled,
|
||||||
guest_upload_max_size_bytes = config.guest_upload_max_size_bytes,
|
guest_upload_max_size_bytes = config.guest_upload_max_size_bytes,
|
||||||
asset_cache_max_age_seconds = config.asset_cache_max_age_seconds,
|
asset_cache_max_age_seconds = config.asset_cache_max_age_seconds,
|
||||||
|
http_compression = config.http_compression,
|
||||||
file_cache_max_age_seconds = config.file_cache_max_age_seconds,
|
file_cache_max_age_seconds = config.file_cache_max_age_seconds,
|
||||||
files_public_url = config.files_public_url.as_deref().unwrap_or("application origin"),
|
files_public_url = config.files_public_url.as_deref().unwrap_or("application origin"),
|
||||||
registration_enabled = config.registration_enabled,
|
registration_enabled = config.registration_enabled,
|
||||||
@@ -173,6 +174,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
&config.static_dir,
|
&config.static_dir,
|
||||||
config.upload_body_limit_bytes(),
|
config.upload_body_limit_bytes(),
|
||||||
config.asset_cache_max_age_seconds,
|
config.asset_cache_max_age_seconds,
|
||||||
|
config.http_compression,
|
||||||
);
|
);
|
||||||
let address = SocketAddr::new(config.host, config.port);
|
let address = SocketAddr::new(config.host, config.port);
|
||||||
let listener = TcpListener::bind(address).await?;
|
let listener = TcpListener::bind(address).await?;
|
||||||
|
|||||||
@@ -863,6 +863,7 @@ async fn handle_socket(
|
|||||||
access_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
access_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
access_refresh.tick().await;
|
access_refresh.tick().await;
|
||||||
let (mut sender, mut receiver) = socket.split();
|
let (mut sender, mut receiver) = socket.split();
|
||||||
|
let mut last_broadcast_access_check = Instant::now() - Duration::from_secs(2);
|
||||||
if send_split(
|
if send_split(
|
||||||
&mut sender,
|
&mut sender,
|
||||||
&ServerMessage::Diagnostics {
|
&ServerMessage::Diagnostics {
|
||||||
@@ -1053,6 +1054,10 @@ async fn handle_socket(
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Broadcasts can arrive hundreds of times a second per connection.
|
||||||
|
// Keep access checks off the per-message hot path; the separate
|
||||||
|
// access_refresh ticker also enforces revocation while idle.
|
||||||
|
if last_broadcast_access_check.elapsed() >= Duration::from_secs(1) {
|
||||||
let (read_allowed, _) = current_resource_access(
|
let (read_allowed, _) = current_resource_access(
|
||||||
&state,
|
&state,
|
||||||
"workspace",
|
"workspace",
|
||||||
@@ -1065,6 +1070,8 @@ async fn handle_socket(
|
|||||||
let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
last_broadcast_access_check = Instant::now();
|
||||||
|
}
|
||||||
match update {
|
match update {
|
||||||
Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{base_revision_id:update.base_revision_id,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,client_id:update.client_id,update_id:update.update_id,operation:update.operation,owner_replacements:update.owner_replacements}).await.is_err(){break;},
|
Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{base_revision_id:update.base_revision_id,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,client_id:update.client_id,update_id:update.update_id,operation:update.operation,owner_replacements:update.owner_replacements}).await.is_err(){break;},
|
||||||
Ok(RoomEvent::Presence(users))=>if send_split(&mut sender,&ServerMessage::Presence{users}).await.is_err(){break;},
|
Ok(RoomEvent::Presence(users))=>if send_split(&mut sender,&ServerMessage::Presence{users}).await.is_err(){break;},
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ async fn handle_pad_socket(
|
|||||||
.await;
|
.await;
|
||||||
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 last_broadcast_access_check = Instant::now() - Duration::from_secs(2);
|
||||||
let mut access_refresh = tokio::time::interval(Duration::from_secs(10));
|
let mut access_refresh = tokio::time::interval(Duration::from_secs(10));
|
||||||
access_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
access_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
access_refresh.tick().await;
|
access_refresh.tick().await;
|
||||||
@@ -552,6 +553,10 @@ async fn handle_pad_socket(
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Broadcasts can arrive hundreds of times a second per connection.
|
||||||
|
// Keep access checks off the per-message hot path; the separate
|
||||||
|
// access_refresh ticker also enforces revocation while idle.
|
||||||
|
if last_broadcast_access_check.elapsed() >= Duration::from_secs(1) {
|
||||||
let (read_allowed, _) = current_resource_access(
|
let (read_allowed, _) = current_resource_access(
|
||||||
&state,
|
&state,
|
||||||
"pad",
|
"pad",
|
||||||
@@ -564,6 +569,8 @@ async fn handle_pad_socket(
|
|||||||
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
last_broadcast_access_check = Instant::now();
|
||||||
|
}
|
||||||
match update {
|
match update {
|
||||||
Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{base_revision_id:u.base_revision_id,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,client_id:u.client_id,update_id:u.update_id,operation:u.operation,owner_replacements:u.owner_replacements}).await.is_err(){break;},
|
Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{base_revision_id:u.base_revision_id,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,client_id:u.client_id,update_id:u.update_id,operation:u.operation,owner_replacements:u.owner_replacements}).await.is_err(){break;},
|
||||||
Ok(RoomEvent::Presence(users))=>if send_pad_split(&mut sender,&PadServerMessage::Presence{users}).await.is_err(){break;},
|
Ok(RoomEvent::Presence(users))=>if send_pad_split(&mut sender,&PadServerMessage::Presence{users}).await.is_err(){break;},
|
||||||
|
|||||||
@@ -4099,6 +4099,16 @@ dialog::backdrop {
|
|||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-body .task-list-item--cancelled>.list-item-content {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .task-list-item--cancelled>.task-checkbox {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: .6;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-body .task-list-item>ul,
|
.markdown-body .task-list-item>ul,
|
||||||
.markdown-body .task-list-item>ol {
|
.markdown-body .task-list-item>ol {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ function renderStandaloneMedia(line, sourceLine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function listLine(line) {
|
function listLine(line) {
|
||||||
const match = line.match(/^(\s*)([-*+]|(\d+)\.)\s+(?:\[([ xX])\]\s+)?(.+)$/);
|
const match = line.match(/^(\s*)([-*+]|(\d+)\.)\s+(?:\[([ xX~])\]\s+)?(.+)$/);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const indent = match[1].replace(/\t/g, " ").length;
|
const indent = match[1].replace(/\t/g, " ").length;
|
||||||
return {
|
return {
|
||||||
@@ -283,6 +283,7 @@ function listLine(line) {
|
|||||||
type: match[3] ? "ol" : "ul",
|
type: match[3] ? "ol" : "ul",
|
||||||
number: match[3] ? Number(match[3]) : null,
|
number: match[3] ? Number(match[3]) : null,
|
||||||
checked: match[4] == null ? null : match[4].toLowerCase() === "x",
|
checked: match[4] == null ? null : match[4].toLowerCase() === "x",
|
||||||
|
cancelled: match[4] === "~",
|
||||||
text: match[5],
|
text: match[5],
|
||||||
marker: match[3] ? `${match[3]}. ` : `${match[2]} `
|
marker: match[3] ? `${match[3]}. ` : `${match[2]} `
|
||||||
};
|
};
|
||||||
@@ -302,14 +303,14 @@ function renderList(lines, start, lineOffset = 0, baseIndent = null, forcedType
|
|||||||
if (!item || item.indent < indent || item.indent !== indent || item.type !== type) break;
|
if (!item || item.indent < indent || item.indent !== indent || item.type !== type) break;
|
||||||
|
|
||||||
const sourceLine = index + lineOffset + 1;
|
const sourceLine = index + lineOffset + 1;
|
||||||
const prefix = `${item.whitespace}${item.marker}${item.checked == null ? "" : `[${item.checked ? "x" : " "}] `}`;
|
const prefix = `${item.whitespace}${item.marker}${item.checked == null ? "" : `[${item.cancelled ? "~" : item.checked ? "x" : " "}] `}`;
|
||||||
const valueAttr = "";
|
const valueAttr = "";
|
||||||
const taskClass = item.checked == null ? "" : " task-list-item";
|
const taskClass = item.checked == null ? "" : ` task-list-item${item.cancelled ? " task-list-item--cancelled" : ""}`;
|
||||||
hasTask ||= item.checked != null;
|
hasTask ||= item.checked != null;
|
||||||
const gutterOffset = `${(depth + 1) * 1.75}em`;
|
const gutterOffset = `${(depth + 1) * 1.75}em`;
|
||||||
body += `<li class="preview-source-line list-source-line${taskClass}" data-source-line="${sourceLine}"${valueAttr} style="--list-gutter-offset:${gutterOffset}">`;
|
body += `<li class="preview-source-line list-source-line${taskClass}" data-source-line="${sourceLine}"${valueAttr} style="--list-gutter-offset:${gutterOffset}">`;
|
||||||
if (item.checked != null) {
|
if (item.checked != null) {
|
||||||
body += `<input type="checkbox" class="task-checkbox" data-source-line="${sourceLine}"${item.checked ? " checked" : ""}>`;
|
body += `<input type="checkbox" class="task-checkbox" data-source-line="${sourceLine}"${item.checked ? " checked" : ""}${item.cancelled ? " disabled aria-label=\"Cancelled task\"" : ""}>`;
|
||||||
}
|
}
|
||||||
body += `<span class="preview-editable list-item-content" data-source-line="${sourceLine}" data-source-prefix="${escapeHtml(prefix)}">${inline(item.text)}</span>`;
|
body += `<span class="preview-editable list-item-content" data-source-line="${sourceLine}" data-source-prefix="${escapeHtml(prefix)}">${inline(item.text)}</span>`;
|
||||||
index++;
|
index++;
|
||||||
|
|||||||
@@ -1247,6 +1247,21 @@ export function startNoteEditor(adapter) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const render = createRenderQueue(renderNow);
|
const render = createRenderQueue(renderNow);
|
||||||
|
// Collaborative edits can arrive many times per second. Keep typing and
|
||||||
|
// WebSocket processing responsive by batching expensive Markdown/DOM work.
|
||||||
|
let deferredRenderTimer = null;
|
||||||
|
let firstDeferredRenderAt = 0;
|
||||||
|
function scheduleDocumentRender() {
|
||||||
|
const now = performance.now();
|
||||||
|
if (deferredRenderTimer === null) firstDeferredRenderAt = now;
|
||||||
|
else clearTimeout(deferredRenderTimer);
|
||||||
|
const delay = Math.min(180, Math.max(0, 750 - (now - firstDeferredRenderAt)));
|
||||||
|
deferredRenderTimer = setTimeout(() => {
|
||||||
|
deferredRenderTimer = null;
|
||||||
|
firstDeferredRenderAt = 0;
|
||||||
|
render();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
function queueCollaborativeOperation(operation, ownerReplacements = []) {
|
function queueCollaborativeOperation(operation, ownerReplacements = []) {
|
||||||
if (!collaboration.ready || !canEditDocument()) return false;
|
if (!collaboration.ready || !canEditDocument()) return false;
|
||||||
try {
|
try {
|
||||||
@@ -1407,7 +1422,7 @@ export function startNoteEditor(adapter) {
|
|||||||
if (ownerMap != null) authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length);
|
if (ownerMap != null) authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length);
|
||||||
previousContent = content;
|
previousContent = content;
|
||||||
editHistory.syncCurrent();
|
editHistory.syncCurrent();
|
||||||
renderGutter();
|
scheduleDocumentRender();
|
||||||
requestAnimationFrame(revealLinkedLine);
|
requestAnimationFrame(revealLinkedLine);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1427,7 +1442,7 @@ export function startNoteEditor(adapter) {
|
|||||||
applyingRemote = false;
|
applyingRemote = false;
|
||||||
if (resetHistory) editHistory.reset();
|
if (resetHistory) editHistory.reset();
|
||||||
else editHistory.syncCurrent();
|
else editHistory.syncCurrent();
|
||||||
render();
|
scheduleDocumentRender();
|
||||||
editor.scrollTop = initialLoad ? 0 : scrollTop;
|
editor.scrollTop = initialLoad ? 0 : scrollTop;
|
||||||
editor.scrollLeft = scrollLeft;
|
editor.scrollLeft = scrollLeft;
|
||||||
syncEditorLayers();
|
syncEditorLayers();
|
||||||
@@ -2238,7 +2253,7 @@ export function startNoteEditor(adapter) {
|
|||||||
});
|
});
|
||||||
preview.addEventListener("change", event => {
|
preview.addEventListener("change", event => {
|
||||||
const checkbox = event.target.closest(".task-checkbox");
|
const checkbox = event.target.closest(".task-checkbox");
|
||||||
if (!checkbox) return;
|
if (!checkbox || checkbox.disabled) return;
|
||||||
if (!canEditDocument()) {
|
if (!canEditDocument()) {
|
||||||
checkbox.checked = !checkbox.checked;
|
checkbox.checked = !checkbox.checked;
|
||||||
saveState.textContent = "Read only";
|
saveState.textContent = "Read only";
|
||||||
@@ -2488,7 +2503,7 @@ export function startNoteEditor(adapter) {
|
|||||||
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previous.length);
|
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previous.length);
|
||||||
authorship = applyAuthorshipEdit(authorship, previous, nextContent, currentOwner());
|
authorship = applyAuthorshipEdit(authorship, previous, nextContent, currentOwner());
|
||||||
previousContent = nextContent;
|
previousContent = nextContent;
|
||||||
render();
|
scheduleDocumentRender();
|
||||||
if (applyingRemote || applyingHistory) return;
|
if (applyingRemote || applyingHistory) return;
|
||||||
queueCollaborativeOperation(operationFromEdit(previous, nextContent, authorship));
|
queueCollaborativeOperation(operationFromEdit(previous, nextContent, authorship));
|
||||||
editHistory.record(event.inputType || "");
|
editHistory.record(event.inputType || "");
|
||||||
|
|||||||
+1
-1
@@ -60,7 +60,7 @@ async function renderMediaPlayers() { const nodes = content.querySelectorAll("[d
|
|||||||
function lockPublicContent(allowTaskUpdates) {
|
function lockPublicContent(allowTaskUpdates) {
|
||||||
content.querySelectorAll('[contenteditable]').forEach(node => node.removeAttribute('contenteditable'));
|
content.querySelectorAll('[contenteditable]').forEach(node => node.removeAttribute('contenteditable'));
|
||||||
content.querySelectorAll('.preview-editable').forEach(node => node.classList.remove('preview-editable'));
|
content.querySelectorAll('.preview-editable').forEach(node => node.classList.remove('preview-editable'));
|
||||||
content.querySelectorAll('.task-checkbox').forEach(box => { box.disabled = !allowTaskUpdates; box.title = allowTaskUpdates ? t("public.task.update", {}, "Update this task") : t("public.task.disabled", {}, "Task updates are disabled by the owner"); });
|
content.querySelectorAll('.task-checkbox').forEach(box => { box.disabled = box.disabled || !allowTaskUpdates; box.title = box.disabled ? t("public.task.update", {}, "Update this task") : t("public.task.disabled", {}, "Task updates are disabled by the owner"); });
|
||||||
}
|
}
|
||||||
function publicAnchorTarget(hash) {
|
function publicAnchorTarget(hash) {
|
||||||
const rawId = String(hash || "").replace(/^#/, "");
|
const rawId = String(hash || "").replace(/^#/, "");
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
// Deterministic broadcast/access-check simulation. Not a live WebSocket/DB benchmark.
|
||||||
|
const durationSeconds = 10;
|
||||||
|
const editsPerUserPerSecond = 2;
|
||||||
|
for (const users of [5, 100, 300, 500]) {
|
||||||
|
const checks = new Int32Array(users);
|
||||||
|
const lastCheckSecond = new Int32Array(users).fill(-1);
|
||||||
|
const start = performance.now();
|
||||||
|
let fanouts = 0;
|
||||||
|
for (let second = 0; second < durationSeconds; second++) {
|
||||||
|
for (let event = 0; event < users * editsPerUserPerSecond; event++) {
|
||||||
|
for (let recipient = 0; recipient < users; recipient++) {
|
||||||
|
fanouts++;
|
||||||
|
if (lastCheckSecond[recipient] !== second) {
|
||||||
|
checks[recipient]++;
|
||||||
|
lastCheckSecond[recipient] = second;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const optimized = checks.reduce((a,b) => a+b, 0);
|
||||||
|
const baseline = fanouts;
|
||||||
|
assert.equal(optimized, users * durationSeconds);
|
||||||
|
assert.ok(optimized <= baseline);
|
||||||
|
console.log(`${users} clients | ${fanouts.toLocaleString('en-US')} broadcast deliveries | auth reads: ${baseline.toLocaleString('en-US')} -> ${optimized.toLocaleString('en-US')} | reduction ${(100*(1-optimized/baseline)).toFixed(2)}% | simulation ${(performance.now()-start).toFixed(1)} ms`);
|
||||||
|
}
|
||||||
@@ -51,3 +51,11 @@ html = renderMarkdown('Hello @a[1]\\x and @<f>&" but not @a[1]\\xy');
|
|||||||
assert.equal((html.match(/class="markdown-mention"/g)||[]).length, 2, 'escaped nicknames');
|
assert.equal((html.match(/class="markdown-mention"/g)||[]).length, 2, 'escaped nicknames');
|
||||||
assert.doesNotMatch(html, /<f>/, 'no raw HTML from nickname');
|
assert.doesNotMatch(html, /<f>/, 'no raw HTML from nickname');
|
||||||
console.log('PASS: special-character nicknames remain escaped');
|
console.log('PASS: special-character nicknames remain escaped');
|
||||||
|
|
||||||
|
html = renderMarkdown('- [ ] Open\n- [x] Done\n- [~] Cancelled\n - [~] Nested cancelled');
|
||||||
|
has(/task-list-item--cancelled/, html, 'cancelled task has its own class');
|
||||||
|
assert.equal((html.match(/task-list-item--cancelled/g) || []).length, 2, 'both cancelled tasks recognized');
|
||||||
|
has(/data-source-line="3" disabled aria-label="Cancelled task"/, html, 'cancelled checkbox cannot be clicked');
|
||||||
|
has(/data-source-prefix="- \[~\] "/, html, 'cancelled markdown marker preserved');
|
||||||
|
has(/data-source-line="1"(?! disabled)/, html, 'open task remains interactive');
|
||||||
|
console.log('PASS: cancelled tasks are disabled and retain their source markers');
|
||||||
Reference in new issue
Block a user