This commit is contained in:
Mateusz Gruszczyński
2026-08-01 09:41:10 +02:00
parent 50adb291d6
commit 7006b4ea09
11 changed files with 42 additions and 143 deletions
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.2.16"
version = "0.2.17"
dependencies = [
"argon2",
"aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
version = "0.2.16"
version = "0.2.17"
edition = "2024"
rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+1 -42
View File
@@ -33,14 +33,6 @@ struct ClientDiagnostics {
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)]
@@ -48,7 +40,6 @@ struct RequestClientContext {
client_id: String,
user_agent: Option<String>,
accept_language: Option<String>,
request_scheme: Option<String>,
}
impl RequestClientContext {
@@ -58,13 +49,6 @@ impl RequestClientContext {
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))
}),
}
}
}
@@ -81,23 +65,13 @@ 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,
@@ -169,18 +143,11 @@ enum ServerMessage {
}
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,
@@ -194,17 +161,9 @@ fn connection_diagnostics(
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,
},
}
}
@@ -488,7 +447,7 @@ async fn handle_socket(
if send_split(
&mut sender,
&ServerMessage::Diagnostics {
diagnostics: connection_diagnostics(connection_id, &client_context, client_diagnostics),
diagnostics: connection_diagnostics(&client_context, client_diagnostics),
},
)
.await
+1 -1
View File
@@ -246,7 +246,7 @@ async fn handle_pad_socket(
if send_pad_split(
&mut sender,
&PadServerMessage::Diagnostics {
diagnostics: connection_diagnostics(connection_id, &client_context, client_diagnostics),
diagnostics: connection_diagnostics(&client_context, client_diagnostics),
},
)
.await
+2 -2
View File
@@ -2809,7 +2809,7 @@ dialog::backdrop {
z-index: 55;
left: 0;
bottom: calc(100% + 9px);
width: min(430px, calc(100vw - 24px));
width: min(400px, calc(100vw - 24px));
padding: 12px;
border: 1px solid var(--border-strong);
border-radius: 10px;
@@ -5297,7 +5297,7 @@ dialog::backdrop {
right: 12px;
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
left: auto;
width: min(430px, calc(100vw - 24px));
width: min(400px, calc(100vw - 24px));
max-height: calc(100dvh - 88px);
overflow-y: auto;
overscroll-behavior: contain;
+6 -38
View File
@@ -173,7 +173,7 @@
<div class="connection-diagnostics-popover">
<strong>Connection diagnostics</strong>
<dl class="connection-diagnostics-grid">
<div>
<div class="connection-diagnostics-grid__wide">
<dt>Quality</dt>
<dd data-connection-diagnostic="quality">Waiting</dd>
</div>
@@ -193,26 +193,10 @@
<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>
<div class="connection-diagnostics-grid__wide">
<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>
@@ -292,9 +276,9 @@
guest</button><button id="show-register" class="text-button" type="button">Register</button><button
id="show-login" class="text-button" type="button">Log in</button></div>
<section id="auth-panel" class="auth-panel" hidden>
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail / LDAP or AD Username<input
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field"><span id="auth-email-label">E-mail</span><input
id="auth-email" name="username" type="email" maxlength="320" autocomplete="username"
placeholder="you@example.com or name.second-name"></label><label>Password<input
placeholder="you@example.com"></label><label>Password<input
id="auth-password" name="password" type="password" minlength="8" maxlength="128"
autocomplete="current-password"></label><button id="auth-submit" class="primary-button"
type="submit">Log in and continue</button>
@@ -357,7 +341,7 @@
<div class="connection-diagnostics-popover mobile-connection-diagnostics-popover">
<strong>Connection diagnostics</strong>
<dl class="connection-diagnostics-grid">
<div>
<div class="connection-diagnostics-grid__wide">
<dt>Quality</dt>
<dd data-connection-diagnostic="quality">Waiting</dd>
</div>
@@ -377,26 +361,10 @@
<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>
<div class="connection-diagnostics-grid__wide">
<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>
+2 -2
View File
@@ -101,8 +101,8 @@
<div class="identity-fields">
<label>Nickname<input id="nickname" name="nickname" maxlength="40" autocomplete="off" data-bwignore="true"
placeholder="Your nickname"></label>
<label id="auth-email-field">E-mail / LDAP or AD Username<input id="auth-email" name="username" type="email"
maxlength="320" autocomplete="username" required placeholder="you@example.com or name.second-name"></label>
<label id="auth-email-field"><span id="auth-email-label">E-mail</span><input id="auth-email" name="username"
type="email" maxlength="320" autocomplete="username" required placeholder="you@example.com"></label>
<label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128"
autocomplete="current-password" required placeholder="At least 8 characters"></label>
</div>
+27 -25
View File
@@ -20,6 +20,26 @@ const clearAuthSession = sessionStore.clearAuthSession || (() => {
sessionStorage.removeItem("rustpad:nickname");
});
function configureCredentialFields({ emailLabel, email, password, loginMode, externalAuth }) {
const directoryLogin = loginMode && externalAuth;
emailLabel.textContent = directoryLogin ? "E-mail / LDAP or AD Username" : "E-mail";
email.type = directoryLogin ? "text" : "email";
email.placeholder = directoryLogin ? "you@example.com or username" : "you@example.com";
email.autocomplete = loginMode ? "username" : "off";
password.autocomplete = loginMode ? "current-password" : "off";
if (directoryLogin) password.removeAttribute("minlength");
else password.minLength = 8;
if (loginMode) {
email.removeAttribute("data-bwignore");
password.removeAttribute("data-bwignore");
} else {
email.dataset.bwignore = "true";
password.dataset.bwignore = "true";
}
}
export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }) {
if (dialog.querySelector("#auth-panel")) return bindLegacyIdentityDialog({ dialog, onIdentity });
const form = dialog.querySelector("#identity-form");
@@ -28,6 +48,7 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
const title = dialog.querySelector("#identity-title");
const copy = dialog.querySelector("#identity-copy");
const emailField = dialog.querySelector("#auth-email-field");
const emailLabel = dialog.querySelector("#auth-email-label");
const email = dialog.querySelector("#auth-email");
const password = dialog.querySelector("#auth-password");
const submit = dialog.querySelector("#auth-submit");
@@ -48,7 +69,9 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
? "Enter the e-mail address assigned to your local account."
: registering
? "Register your account with an e-mail address and password."
: "Use your e-mail address or organization login and password.";
: externalAuth
? "Use your e-mail address or LDAP/AD username and password."
: "Use your e-mail address and password.";
nickname.closest("label").hidden = !registering;
nickname.disabled = !registering;
@@ -62,21 +85,10 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
resetButton.hidden = resetting || registering || externalAuth;
backButton.hidden = !resetting;
const loginMode = mode === "login";
email.type = externalAuth && loginMode ? "text" : "email";
if (externalAuth && loginMode) password.removeAttribute("minlength");
else password.minLength = 8;
configureCredentialFields({ emailLabel, email, password, loginMode, externalAuth });
form.autocomplete = loginMode ? "on" : "off";
nickname.autocomplete = "off";
nickname.dataset.bwignore = "true";
email.autocomplete = loginMode ? "username" : "off";
password.autocomplete = loginMode ? "current-password" : "off";
if (loginMode) {
email.removeAttribute("data-bwignore");
password.removeAttribute("data-bwignore");
} else {
email.dataset.bwignore = "true";
password.dataset.bwignore = "true";
}
message.textContent = "";
queueMicrotask(() => {
@@ -168,6 +180,7 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
const message = dialog.querySelector("#identity-error");
const modeTitle = dialog.querySelector("#auth-mode-title");
const emailField = dialog.querySelector("#auth-email-field");
const emailLabel = dialog.querySelector("#auth-email-label");
const email = dialog.querySelector("#auth-email");
const password = dialog.querySelector("#auth-password");
const authSubmit = dialog.querySelector("#auth-submit");
@@ -205,21 +218,10 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
modeTitle.textContent = mode === "register" ? "Register" : "Log in";
authSubmit.textContent = mode === "register" ? "Register and continue" : "Log in and continue";
const loginMode = mode === "login";
email.type = externalAuth && loginMode ? "text" : "email";
if (externalAuth && loginMode) password.removeAttribute("minlength");
else password.minLength = 8;
configureCredentialFields({ emailLabel, email, password, loginMode, externalAuth });
form.autocomplete = loginMode ? "on" : "off";
nickname.autocomplete = "off";
nickname.dataset.bwignore = "true";
email.autocomplete = loginMode ? "username" : "off";
password.autocomplete = loginMode ? "current-password" : "off";
if (loginMode) {
email.removeAttribute("data-bwignore");
password.removeAttribute("data-bwignore");
} else {
email.dataset.bwignore = "true";
password.dataset.bwignore = "true";
}
message.textContent = "";
queueMicrotask(() => (mode === "register" ? nickname : email).focus());
};
-12
View File
@@ -249,20 +249,8 @@ export function startNoteEditor(adapter) {
? 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";
-18
View File
@@ -30,7 +30,6 @@ class RoomSocket {
this.latencySampleWindow = DEFAULT_LATENCY_SAMPLE_WINDOW;
this.latencySamples = [];
this.totalReconnects = 0;
this.connectedAt = null;
this.authenticatedAt = null;
this.lastConnectionUptimeMs = 0;
this.lastMessageAt = null;
@@ -51,15 +50,10 @@ class RoomSocket {
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,
};
}
@@ -93,7 +87,6 @@ class RoomSocket {
socket.addEventListener("open", () => {
if (socket !== this.socket || this.stopped) return;
logInfo("websocket.open", { kind: this.kind });
this.connectedAt = Date.now();
this.serverDiagnostics = null;
this.lastClose = null;
this.send({
@@ -161,7 +154,6 @@ class RoomSocket {
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();
@@ -260,7 +252,6 @@ class RoomSocket {
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();
}
@@ -282,31 +273,22 @@ class RoomSocket {
}
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(),
},
});
+1 -1
View File
@@ -55,7 +55,7 @@
</div>
<section id="auth-panel" class="auth-panel" hidden>
<h3 id="auth-mode-title">Log in</h3>
<label id="auth-email-field">E-mail / organization login
<label id="auth-email-field"><span id="auth-email-label">E-mail</span>
<input id="auth-email" name="username" type="email" maxlength="320" autocomplete="username"
placeholder="you@example.com">
</label>