From 4edc51127237929d7c75778f4beb344baee7a54e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 30 Jul 2026 23:57:35 +0200 Subject: [PATCH] some changes --- .env.example | 3 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 +- docker/garage/garage.toml | 5 ++ src/api/files.rs | 97 ++++++++++++++++++++-- src/api/mod.rs | 28 ++++--- src/config/mod.rs | 4 + src/config/values.rs | 1 + src/file_urls.rs | 148 +++++++++++++++++++++++++++++++++ src/main.rs | 32 +++++++- src/state.rs | 3 + src/storage.rs | 5 +- static/css/styles.css | 105 ++++++++++++++++++++++++ static/editor.html | 6 ++ static/js/markdown.js | 31 +++++-- static/js/note-editor.js | 91 ++++++++++++++++++++- static/js/note-files.js | 15 +++- static/js/socket.js | 167 +++++++++++++++++++++++++++++++++----- systemd/rustpad.yaml | 2 + 20 files changed, 686 insertions(+), 65 deletions(-) create mode 100644 src/file_urls.rs diff --git a/.env.example b/.env.example index d7b94e4..ab2b3d7 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,9 @@ UPLOAD_MAX_SIZE_MB=20 # Attachment storage: local or s3 STORAGE_DRIVER=local FILES_DIR=/data/files +# Optional attachment origin. A bare domain is normalized to HTTPS. +# The administrator must proxy or serve /f/* on this domain. +# FILES_PUBLIC_URL=files.note.example.com # S3-compatible storage (AWS S3, Garage, Ceph, OpenStack, MinIO, R2...) # For Docker Garage run: docker compose --profile s3 up -d diff --git a/Cargo.lock b/Cargo.lock index 9600377..72f7eec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.7" +version = "0.2.8" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 728b206..afc698e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.7" +version = "0.2.8" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/README.md b/README.md index c59112c..d7bc9df 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,9 @@ RustPad supports two interchangeable attachment backends selected in `.env`: - `STORAGE_DRIVER=local` stores files under `FILES_DIR` and is the default. - `STORAGE_DRIVER=s3` uses an S3-compatible service such as AWS S3, Garage, Ceph RGW, OpenStack, or MinIO. -Public application URLs remain `/f/{token}/{filename}` for both backends. RustPad validates access and streams objects through the API, so the bucket does not need to be public and existing database records do not require migration. +Stored attachment paths remain `/f/{token}/{filename}` for both backends. RustPad validates access and streams objects through the API, so the bucket does not need to be public and existing database records do not require migration. + +Set `FILES_PUBLIC_URL=files.note.example.com` to return attachment links through a separate domain. Bare domains are normalized to HTTPS; `http://` can be used explicitly for local deployments. The external domain must serve or proxy the same `/f/{token}/{filename}` paths. Removing the variable immediately restores application-relative `/f/...` links, including for records created while a custom domain was enabled. Set `ASSET_CACHE_MAX_AGE_SECONDS=0` or `FILE_CACHE_MAX_AGE_SECONDS=0` to disable browser caching. RustPad then sends `Cache-Control: no-cache, no-store, must-revalidate`; positive values use `public, max-age=`. diff --git a/docker/garage/garage.toml b/docker/garage/garage.toml index dfb14f6..f51e949 100644 --- a/docker/garage/garage.toml +++ b/docker/garage/garage.toml @@ -12,3 +12,8 @@ root_domain = ".s3.garage.localhost" [admin] api_bind_addr = "0.0.0.0:3903" + +#[s3_web] +#bind_addr = "0.0.0.0:3902" +#root_domain = ".web.garage.localhost" +#index = "index.html" \ No newline at end of file diff --git a/src/api/files.rs b/src/api/files.rs index 755c0b3..89f2c91 100644 --- a/src/api/files.rs +++ b/src/api/files.rs @@ -99,7 +99,11 @@ pub async fn upload_pad_file( stored = format!("{stem}-{}{}", db::random_suffix(6), ext); key = crate::storage::object_key("pads", pad.id, &file_token, &stored); } - let url = format!("/f/{}/{}", file_token, stored); + let stored_url = crate::file_urls::stored_file_path(&file_token, &stored); + let public_url = crate::file_urls::public_file_url( + state.files_public_url.as_deref(), + &stored_url, + ); let mime = mime_guess::from_path(&stored) .first_or_octet_stream() .to_string(); @@ -109,8 +113,16 @@ pub async fn upload_pad_file( .put(&key, bytes.clone().into(), &mime, &cache_control) .await .map_err(|_| ApiError::internal("Failed to save the file"))?; - db::register_pad_file(&state.db, pad.id, &stored, &url, &mime, bytes.len() as i64).await?; - Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime}))) + db::register_pad_file( + &state.db, + pad.id, + &stored, + &stored_url, + &mime, + bytes.len() as i64, + ) + .await?; + Ok(Json(serde_json::json!({"name": stored, "url": public_url, "mime_type": mime}))) } pub(super) fn content_references_file(content: &str, filename: &str, url: &str) -> bool { @@ -136,6 +148,26 @@ pub(super) fn content_references_file(content: &str, filename: &str, url: &str) false } +pub(super) fn content_references_stored_file( + content: &str, + filename: &str, + stored_url: &str, + public_base: Option<&str>, +) -> bool { + if content_references_file(content, filename, stored_url) { + return true; + } + let canonical = crate::file_urls::canonical_file_path(stored_url); + if canonical + .as_deref() + .is_some_and(|url| url != stored_url && content.contains(url)) + { + return true; + } + let public_url = crate::file_urls::public_file_url(public_base, stored_url); + public_url != stored_url && content.contains(&public_url) +} + pub async fn pad_files( State(state): State, headers: HeaderMap, @@ -153,7 +185,12 @@ pub async fn pad_files( .await?; let mut files = db::list_pad_files(&state.db, pad.id).await?; for file in &mut files { - let attached = content_references_file(&pad.content, &file.filename, &file.url); + let attached = content_references_stored_file( + &pad.content, + &file.filename, + &file.url, + state.files_public_url.as_deref(), + ); if attached != file.is_attached { db::set_pad_file_attached(&state.db, file.id, attached).await?; file.is_attached = attached; @@ -164,6 +201,10 @@ pub async fn pad_files( }; } file.created_at = db::normalize_timestamp(&file.created_at); + file.url = crate::file_urls::public_file_url( + state.files_public_url.as_deref(), + &file.url, + ); } Ok(Json(files)) } @@ -298,7 +339,11 @@ pub async fn upload_note_file( stored = format!("{stem}-{}{}", db::random_suffix(6), ext); key = crate::storage::object_key("notes", note.id, &file_token, &stored); } - let url = format!("/f/{}/{}", file_token, stored); + let stored_url = crate::file_urls::stored_file_path(&file_token, &stored); + let public_url = crate::file_urls::public_file_url( + state.files_public_url.as_deref(), + &stored_url, + ); let mime = mime_guess::from_path(&stored) .first_or_octet_stream() .to_string(); @@ -308,8 +353,16 @@ pub async fn upload_note_file( .put(&key, bytes.clone().into(), &mime, &cache_control) .await .map_err(|_| ApiError::internal("Failed to save the file"))?; - db::register_note_file(&state.db, note.id, &stored, &url, &mime, bytes.len() as i64).await?; - Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime}))) + db::register_note_file( + &state.db, + note.id, + &stored, + &stored_url, + &mime, + bytes.len() as i64, + ) + .await?; + Ok(Json(serde_json::json!({"name": stored, "url": public_url, "mime_type": mime}))) } pub async fn delete_note( @@ -382,7 +435,12 @@ pub async fn note_files( .await?; let mut files = db::list_note_files(&state.db, note.id).await?; for file in &mut files { - let attached = content_references_file(¬e.content, &file.filename, &file.url); + let attached = content_references_stored_file( + ¬e.content, + &file.filename, + &file.url, + state.files_public_url.as_deref(), + ); if attached != file.is_attached { db::set_note_file_attached(&state.db, file.id, attached).await?; file.is_attached = attached; @@ -393,6 +451,10 @@ pub async fn note_files( }; } file.created_at = db::normalize_timestamp(&file.created_at); + file.url = crate::file_urls::public_file_url( + state.files_public_url.as_deref(), + &file.url, + ); } Ok(Json(files)) } @@ -580,7 +642,7 @@ fn sanitize_filename(value: &str) -> String { #[cfg(test)] mod tests { - use super::is_safe_inline_image_mime; + use super::{content_references_stored_file, is_safe_inline_image_mime}; #[test] fn only_raster_images_are_inline() { @@ -590,4 +652,21 @@ mod tests { assert!(!is_safe_inline_image_mime("text/html")); assert!(!is_safe_inline_image_mime("application/xml")); } + + #[test] + fn attachment_references_survive_origin_changes() { + let stored = "/f/token/image.png"; + assert!(content_references_stored_file( + "![diagram](https://old-files.example.com/f/token/image.png)", + "image.png", + stored, + None, + )); + assert!(content_references_stored_file( + "![diagram](/f/token/image.png)", + "image.png", + "https://old-files.example.com/f/token/image.png", + Some("https://new-files.example.com"), + )); + } } diff --git a/src/api/mod.rs b/src/api/mod.rs index 103fd11..29e4bf1 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -162,16 +162,6 @@ pub struct MarkdownFileReference { mime_type: String, } -impl From for MarkdownFileReference { - fn from(file: db::NoteFile) -> Self { - Self { - filename: file.filename, - url: file.url, - mime_type: file.mime_type, - } - } -} - pub(crate) async fn markdown_file_references( state: &SharedState, pad_id: Option, @@ -189,10 +179,24 @@ pub(crate) async fn markdown_file_references( .into_iter() .filter(|file| { content - .map(|value| files::content_references_file(value, &file.filename, &file.url)) + .map(|value| { + files::content_references_stored_file( + value, + &file.filename, + &file.url, + state.files_public_url.as_deref(), + ) + }) .unwrap_or(true) }) - .map(Into::into) + .map(|file| MarkdownFileReference { + filename: file.filename, + url: crate::file_urls::public_file_url( + state.files_public_url.as_deref(), + &file.url, + ), + mime_type: file.mime_type, + }) .collect()) } diff --git a/src/config/mod.rs b/src/config/mod.rs index 6a24168..83a2240 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -58,6 +58,7 @@ pub struct Config { pub asset_version: String, pub asset_cache_max_age_seconds: u64, pub file_cache_max_age_seconds: u64, + pub files_public_url: Option, pub smtp: Option, pub registration_enabled: bool, pub account_confirmation_required: bool, @@ -83,6 +84,8 @@ impl Config { let unconfirmed_account_ttl_days = values.positive_i64("UNCONFIRMED_ACCOUNT_TTL_DAYS", 3)?; let files_dir = values.get("FILES_DIR", "data/files"); + let files_public_url = + crate::file_urls::normalize_public_base(values.optional("FILES_PUBLIC_URL"))?; let storage = match values .get("STORAGE_DRIVER", "local") @@ -172,6 +175,7 @@ impl Config { .nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?, file_cache_max_age_seconds: values .nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?, + files_public_url, smtp, registration_enabled: values.bool("REGISTRATION_ENABLED", false)?, account_confirmation_required: values.bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?, diff --git a/src/config/values.rs b/src/config/values.rs index 8c82dd4..c1c18ba 100644 --- a/src/config/values.rs +++ b/src/config/values.rs @@ -16,6 +16,7 @@ const KNOWN_CONFIG_KEYS: &[&str] = &[ "DATABASE_MAX_CONNECTIONS", "STATIC_DIR", "FILES_DIR", + "FILES_PUBLIC_URL", "STORAGE_DRIVER", "UPLOAD_MAX_SIZE_MB", "ASSET_CACHE_MAX_AGE_SECONDS", diff --git a/src/file_urls.rs b/src/file_urls.rs new file mode 100644 index 0000000..fd65d2f --- /dev/null +++ b/src/file_urls.rs @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +use std::error::Error; + +const FILE_ROUTE_PREFIX: &str = "/f/"; + +pub fn normalize_public_base(value: Option) -> Result, Box> { + let Some(value) = value else { + return Ok(None); + }; + let value = value.trim(); + if value.is_empty() { + return Ok(None); + } + + let candidate = if value.starts_with("http://") || value.starts_with("https://") { + value.trim_end_matches('/').to_owned() + } else { + format!("https://{}", value.trim_end_matches('/')) + }; + let authority = candidate + .strip_prefix("https://") + .or_else(|| candidate.strip_prefix("http://")) + .ok_or("FILES_PUBLIC_URL must use http:// or https://")?; + + if authority.is_empty() + || authority.chars().any(|character| { + matches!(character, '/' | '\\' | '?' | '#' | '@') || character.is_whitespace() + }) + { + return Err( + "FILES_PUBLIC_URL must be a domain or HTTP(S) origin without a path, query, credentials, or fragment" + .into(), + ); + } + + Ok(Some(candidate)) +} + +pub fn canonical_file_path(value: &str) -> Option { + let value = value.trim(); + let path = if value.starts_with('/') { + value + } else { + let (_, after_scheme) = value.split_once("://")?; + let path_start = after_scheme.find('/')?; + &after_scheme[path_start..] + }; + let path = path + .split(|character| matches!(character, '?' | '#')) + .next() + .unwrap_or(path); + if !path.starts_with(FILE_ROUTE_PREFIX) { + return None; + } + + let mut parts = path.trim_start_matches('/').split('/'); + let route = parts.next()?; + let token = parts.next()?; + let filename = parts.next()?; + if route != "f" || token.is_empty() || filename.is_empty() || parts.next().is_some() { + return None; + } + + Some(format!("/f/{token}/{filename}")) +} + +pub fn stored_file_path(token: &str, filename: &str) -> String { + format!("/f/{token}/{filename}") +} + +pub fn public_file_url(public_base: Option<&str>, stored_url: &str) -> String { + let Some(canonical) = canonical_file_path(stored_url) else { + return stored_url.to_owned(); + }; + match public_base { + Some(base) => format!("{}{canonical}", base.trim_end_matches('/')), + None => canonical, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_bare_domain_and_http_origins() { + assert_eq!( + normalize_public_base(Some("files.note.example.com".into())).unwrap(), + Some("https://files.note.example.com".into()) + ); + assert_eq!( + normalize_public_base(Some("http://localhost:3001/".into())).unwrap(), + Some("http://localhost:3001".into()) + ); + assert_eq!(normalize_public_base(Some(" ".into())).unwrap(), None); + } + + #[test] + fn rejects_non_origin_public_urls() { + assert!(normalize_public_base(Some("ftp://files.example.com".into())).is_err()); + assert!(normalize_public_base(Some("https://files.example.com/path".into())).is_err()); + assert!(normalize_public_base(Some("https://user@files.example.com".into())).is_err()); + assert!(normalize_public_base(Some("files.example.com\\path".into())).is_err()); + } + + #[test] + fn extracts_canonical_path_from_relative_and_absolute_urls() { + assert_eq!( + canonical_file_path("/f/token/image.png"), + Some("/f/token/image.png".into()) + ); + assert_eq!( + canonical_file_path("https://files.example.com/f/token/image.png"), + Some("/f/token/image.png".into()) + ); + assert_eq!( + canonical_file_path("https://files.example.com/f/token/image.png?download=1"), + Some("/f/token/image.png".into()) + ); + assert_eq!(canonical_file_path("/files/token/image.png"), None); + } + + #[test] + fn switches_between_custom_origin_and_application_path() { + let stored = "/f/token/manual.pdf"; + assert_eq!(public_file_url(None, stored), stored); + assert_eq!( + public_file_url(Some("https://files.example.com"), stored), + "https://files.example.com/f/token/manual.pdf" + ); + assert_eq!( + public_file_url(None, "https://old.example.com/f/token/manual.pdf"), + stored + ); + assert_eq!( + public_file_url(Some("https://files.example.com"), "/invalid/path"), + "/invalid/path" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 4916cc5..5044d9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod cache; mod config; mod database; mod db; +mod file_urls; mod queries; mod row_decode; mod security; @@ -40,6 +41,7 @@ async fn main() -> Result<(), Box> { dotenvy::dotenv().ok(); let cli = parse_command()?; init_tracing(); + print_startup_credential(); let config = Config::load(cli.config.as_deref())?; if matches!(cli.command, Command::CheckConfig) { @@ -53,8 +55,6 @@ async fn main() -> Result<(), Box> { return Ok(()); } info!( - "RustPad version" = %config.asset_version, - Copyright="@linuxiarz.pl Mateusz Gruszczyński", host = %config.host, port = config.port, database_kind = %database_kind_label(&config.database_url), @@ -65,6 +65,7 @@ async fn main() -> Result<(), Box> { upload_max_size_bytes = config.upload_max_size_bytes, asset_cache_max_age_seconds = config.asset_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"), registration_enabled = config.registration_enabled, account_confirmation_required = config.account_confirmation_required, share_confirmation_required = config.share_confirmation_required, @@ -73,7 +74,6 @@ async fn main() -> Result<(), Box> { user_session_ttl_days = config.user_session_ttl_days, smtp_configured = config.smtp.is_some(), authorization_type = config.authorization_type.as_str(), - asset_version = %config.asset_version, "configuration loaded" ); if let Some(path) = config @@ -106,6 +106,7 @@ async fn main() -> Result<(), Box> { storage, config.upload_max_size_bytes, config.file_cache_max_age_seconds, + config.files_public_url.clone(), config.smtp.clone(), config.registration_enabled && config.ldap.is_none(), config.account_confirmation_required, @@ -152,7 +153,7 @@ async fn main() -> Result<(), Box> { let address = SocketAddr::new(config.host, config.port); let listener = TcpListener::bind(address).await?; - info!(%address, asset_version = %config.asset_version, "RustPad is running"); + info!(%address, "RustPad is running"); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await?; @@ -160,6 +161,17 @@ async fn main() -> Result<(), Box> { Ok(()) } +fn print_startup_credential() { + eprintln!("\n{}\n", startup_credential()); +} + +fn startup_credential() -> String { + format!( + "RustPad {}\nCopyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl", + env!("CARGO_PKG_VERSION") + ) +} + #[derive(Clone, Copy)] enum Command { Run, @@ -265,6 +277,18 @@ async fn shutdown_signal() { } } +#[cfg(test)] +mod startup_tests { + use super::startup_credential; + + #[test] + fn startup_credential_contains_product_identity() { + let credential = startup_credential(); + assert!(credential.contains(&format!("RustPad {}", env!("CARGO_PKG_VERSION")))); + assert!(credential.contains("Mateusz Gruszczyński @linuxiarz.pl")); + } +} + async fn run_migrations(db: &Database) -> Result<(), sqlx::migrate::MigrateError> { let path = match db.kind() { DatabaseKind::Sqlite => std::path::Path::new("migrations/sqlite"), diff --git a/src/state.rs b/src/state.rs index 09df48e..5e404cc 100644 --- a/src/state.rs +++ b/src/state.rs @@ -97,6 +97,7 @@ pub struct AppState { pub storage: crate::storage::Storage, pub upload_max_size_bytes: usize, pub file_cache_max_age_seconds: u64, + pub files_public_url: Option, pub smtp: Option, pub registration_enabled: bool, pub account_confirmation_required: bool, @@ -119,6 +120,7 @@ impl AppState { storage: crate::storage::Storage, upload_max_size_bytes: usize, file_cache_max_age_seconds: u64, + files_public_url: Option, smtp: Option, registration_enabled: bool, account_confirmation_required: bool, @@ -135,6 +137,7 @@ impl AppState { storage, upload_max_size_bytes, file_cache_max_age_seconds, + files_public_url, smtp, registration_enabled, account_confirmation_required, diff --git a/src/storage.rs b/src/storage.rs index 6dc9ae7..67db666 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -231,7 +231,10 @@ pub async fn delete_url_file( owner_id: i64, url: &str, ) -> Result<(), StorageError> { - let parts: Vec<&str> = url.trim_start_matches('/').split('/').collect(); + let Some(path) = crate::file_urls::canonical_file_path(url) else { + return Ok(()); + }; + let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect(); if parts.len() != 3 || parts[0] != "f" { return Ok(()); } diff --git a/static/css/styles.css b/static/css/styles.css index b8d4d6f..227843e 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -355,6 +355,96 @@ textarea:focus { background: var(--danger); } +.connection-notice { + position: absolute; + top: 64px; + left: 50%; + z-index: 18; + display: flex; + width: min(520px, calc(100% - 32px)); + box-sizing: border-box; + align-items: center; + gap: 12px; + padding: 11px 14px; + border: 1px solid color-mix(in srgb, var(--danger) 48%, var(--border-strong)); + border-radius: 12px; + background: color-mix(in srgb, var(--surface-strong, #11161e) 94%, var(--danger)); + box-shadow: 0 16px 42px rgb(0 0 0 / 38%); + opacity: 0; + pointer-events: none; + transform: translate(-50%, -10px) scale(.98); + transition: opacity .2s ease, transform .2s ease, border-color .2s ease; +} + +.connection-notice.is-visible { + opacity: 1; + transform: translate(-50%, 0) scale(1); +} + +.connection-notice.is-restored { + border-color: color-mix(in srgb, var(--success) 58%, var(--border-strong)); + background: color-mix(in srgb, var(--surface-strong, #11161e) 94%, var(--success)); +} + +.connection-notice__signal { + display: inline-flex; + width: 26px; + height: 26px; + align-items: center; + justify-content: center; + flex: 0 0 auto; + gap: 3px; + border-radius: 50%; + background: color-mix(in srgb, var(--danger) 18%, transparent); +} + +.connection-notice.is-restored .connection-notice__signal { + background: color-mix(in srgb, var(--success) 18%, transparent); +} + +.connection-notice__signal span { + width: 3px; + height: 10px; + border-radius: 3px; + background: var(--danger); + animation: connection-pulse .9s ease-in-out infinite; +} + +.connection-notice__signal span:nth-child(2) { + animation-delay: .12s; +} + +.connection-notice__signal span:nth-child(3) { + animation-delay: .24s; +} + +.connection-notice.is-restored .connection-notice__signal span { + background: var(--success); + animation: none; +} + +.connection-notice__content { + display: grid; + min-width: 0; + gap: 2px; +} + +.connection-notice__content strong { + color: var(--text); + font-size: .84rem; +} + +.connection-notice__content>span { + color: var(--muted); + font-size: .76rem; + line-height: 1.35; +} + +@keyframes connection-pulse { + 0%, 100% { transform: scaleY(.45); opacity: .48; } + 50% { transform: scaleY(1); opacity: 1; } +} + .editor-layout { position: relative; display: grid; @@ -369,6 +459,7 @@ textarea:focus { } .editor-panel { + position: relative; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-width: 0; @@ -4412,6 +4503,12 @@ dialog::backdrop { } @media (max-width: 760px) { + .connection-notice { + top: 76px; + width: calc(100% - 20px); + padding: 10px 12px; + } + .pad-page .editor-toolbar { grid-template-columns: minmax(0, 1fr); } @@ -4457,6 +4554,14 @@ dialog::backdrop { } } +@media (prefers-reduced-motion: reduce) { + .connection-notice, + .connection-notice__signal span { + animation: none; + transition: none; + } +} + @media (max-width: 720px) { .pad-page #save-state { display: none; diff --git a/static/editor.html b/static/editor.html index 8c21e1e..08024bc 100644 --- a/static/editor.html +++ b/static/editor.html @@ -116,6 +116,12 @@
+
Editor diff --git a/static/js/markdown.js b/static/js/markdown.js index 1634b18..02a9237 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -13,10 +13,25 @@ function escapeHtml(value) { return String(value).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } +const emoji = EMOJI_SHORTCODES; +let markdownFiles = new Map(); +let markdownFileRoutes = new Map(); + +function attachmentRoute(value) { + try { + const path = new URL(String(value || ""), location.origin).pathname; + return /^\/f\/[^/]+\/[^/]+$/.test(path) ? path : null; + } catch { + return null; + } +} + function safeUrl(value) { - const raw = String(value || "").trim(); + let raw = String(value || "").trim(); if (!raw || raw.startsWith("//")) return "#"; if (raw.startsWith("#")) return escapeHtml(raw); + const route = attachmentRoute(raw); + if (route && markdownFileRoutes.has(route)) raw = markdownFileRoutes.get(route); try { const url = new URL(raw, location.origin); if (url.protocol === "mailto:") return escapeHtml(url.href); @@ -27,16 +42,18 @@ function safeUrl(value) { } } -const emoji = EMOJI_SHORTCODES; -let markdownFiles = new Map(); - export function setMarkdownFiles(files) { - markdownFiles = new Map((Array.isArray(files) ? files : []) + const normalized = (Array.isArray(files) ? files : []) .filter(file => file && file.filename && file.url) - .map(file => [String(file.filename), { + .map(file => ({ + filename: String(file.filename), url: String(file.url), mimeType: String(file.mime_type || ""), - }])); + })); + markdownFiles = new Map(normalized.map(file => [file.filename, file])); + markdownFileRoutes = new Map(normalized + .map(file => [attachmentRoute(file.url), file.url]) + .filter(([route]) => route)); } export function unresolvedMarkdownFileAliases(value) { diff --git a/static/js/note-editor.js b/static/js/note-editor.js index c939c54..becf0a4 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -25,14 +25,14 @@ import { toast } from "@rustpad/toast"; export function startNoteEditor(adapter) { const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer"); const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog"); - const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"); + 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"); let unreadChat = 0; const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color"); const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle"); const shareToken = new URLSearchParams(location.search).get("share"); const notePreferenceKey = name => `rustpad:${name}:${location.pathname}`; let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = ""; - let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false; + let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false; const compactLayoutQuery = window.matchMedia("(max-width: 1499px)"); const singlePaneQuery = window.matchMedia("(max-width: 760px)"); let compactView = uiState.view === "preview" ? "preview" : "edit"; @@ -149,6 +149,38 @@ export function startNoteEditor(adapter) { function clearUnread() { unreadChat = 0; chatUnread.hidden = true; chatUnread.textContent = ""; if (mobileChatUnread) { mobileChatUnread.hidden = true; mobileChatUnread.textContent = ""; } document.title = document.title.replace(/^● /, ""); } function setStatus(kind, text) { const className = `status__dot${kind ? ` is-${kind}` : ""}`; document.querySelector("#status-dot").className = className; document.querySelector("#status-text").textContent = text; const mobileDot = document.querySelector("#mobile-status-dot"); const mobileText = document.querySelector("#mobile-status-text"); if (mobileDot) mobileDot.className = className; if (mobileText) mobileText.textContent = text; } + function showConnectionNotice(title, message, restored = false) { + clearTimeout(connectionNoticeTimer); + connectionNoticeTitle.textContent = title; + connectionNoticeMessage.textContent = message; + connectionNotice.hidden = false; + connectionNotice.classList.toggle("is-restored", restored); + requestAnimationFrame(() => connectionNotice.classList.add("is-visible")); + if (restored) connectionNoticeTimer = window.setTimeout(() => { + connectionNotice.classList.remove("is-visible", "is-restored"); + connectionNoticeTimer = window.setTimeout(() => { connectionNotice.hidden = true; }, 220); + }, 1800); + } + function hideConnectionNotice() { + clearTimeout(connectionNoticeTimer); + connectionNotice.classList.remove("is-visible", "is-restored"); + connectionNotice.hidden = true; + } + function handleSocketStatus(status, details = {}) { + if (status === "online") { + setStatus("online", "Connected"); + if (connectionWasInterrupted || details.restored) showConnectionNotice("Connection restored", "Live editing is active again.", true); + connectionWasInterrupted = false; + return; + } + if (status === "reconnecting") { + connectionWasInterrupted = true; + setStatus("offline", "Reconnecting…"); + showConnectionNotice("Connection interrupted", details.message || "Trying to reconnect automatically."); + return; + } + setStatus(null, "Connecting…"); + } function updateAddressLabel() { document.querySelector(adapter.addressSelector).textContent = `${location.pathname}${location.search}`; } async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '

Failed to load Mermaid.

')); } } async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } } @@ -619,7 +651,51 @@ export function startNoteEditor(adapter) { onFilesChanged: files => updateMarkdownFiles(files, { rerender: true }), }); refreshFilesForAliases = () => loadFiles(); - function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: null, guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { resourceUnlocked = true; if (passwordDialog.open) passwordDialog.close(); const readOnly = m.access_level === "read_only"; editor.readOnly = readOnly; accessLevel.textContent = readOnly ? "Access: read only" : "Access: full"; applyRemote(m.content, m.owner_map); if (!readOnly) editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { const friendly = /read-only access/i.test(m) ? "This note is read only. Enter the password or ask the owner to grant write access." : m; document.querySelector("#password-error").textContent = friendly; if (/read-only access/i.test(m)) { toast(friendly); accessLevel.textContent = "Access: read only"; editor.readOnly = true; return; } if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); } + function connect() { + socket?.stop(); + socket = adapter.createSocket({ + password, + accessToken, + nickname, + color: currentUserColor() || null, + sessionToken: null, + guestId: getGuestId(), + onStatus: handleSocketStatus, + onAuthenticated: message => { + resourceUnlocked = true; + if (passwordDialog.open) passwordDialog.close(); + const readOnly = message.access_level === "read_only"; + editor.readOnly = readOnly; + accessLevel.textContent = readOnly ? "Access: read only" : "Access: full"; + applyRemote(message.content, message.owner_map); + if (!readOnly) editor.focus(); + }, + onDocument: message => { + applyRemote(message.content, message.owner_map); + document.querySelector("#save-state").textContent = `${message.author ? `${message.author} · ` : ""}${new Date(message.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; + }, + onPresence: updatePresence, + onLatency: updateLatency, + onChat: appendChatMessage, + onError: message => { + hideConnectionNotice(); + const friendly = /read-only access/i.test(message) ? "This note is read only. Enter the password or ask the owner to grant write access." : message; + document.querySelector("#password-error").textContent = friendly; + if (/read-only access/i.test(message)) { + toast(friendly); + accessLevel.textContent = "Access: read only"; + editor.readOnly = true; + return; + } + if (/nickname|session|account/i.test(message)) { + if (!identityDialog.open) identityDialog.showModal(); + } else if (info?.protected && !passwordDialog.open) { + passwordDialog.showModal(); + } + }, + }); + socket.connect(); + } bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken && !getAuthToken()) passwordDialog.showModal(); else { loadFiles(); connect(); } } }); identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); }); async function showSystemNotFound() { @@ -698,6 +774,15 @@ export function startNoteEditor(adapter) { compactLayoutQuery.addEventListener("change", event => { if (!event.matches) setHeaderMenuOpen(false); }); + const mobileEditorOptions = document.querySelector("#mobile-editor-options"); + document.addEventListener("pointerdown", event => { + if (mobileEditorOptions?.open && !event.target.closest("#mobile-editor-options")) { + mobileEditorOptions.open = false; + } + }, { passive: true }); + document.addEventListener("keydown", event => { + if (event.key === "Escape" && mobileEditorOptions?.open) mobileEditorOptions.open = false; + }); modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); }); lineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("line-numbers"), lineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); scheduleEditorSettingsSave({ personal: true }); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("preview-line-numbers"), previewLineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); alignPreviewLineNumbers(preview); scheduleEditorSettingsSave({ personal: true }); }); diff --git a/static/js/note-files.js b/static/js/note-files.js index 1cf8b1b..bee8e26 100644 --- a/static/js/note-files.js +++ b/static/js/note-files.js @@ -11,7 +11,7 @@ import { api, uploadWithProgress } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; import { prepareImageFile } from "@rustpad/image-upload"; import { askConfirm } from "@rustpad/modal"; -import { safeAppUrl } from "@rustpad/security"; +import { safeAppUrl, safePublicUrl } from "@rustpad/security"; import { createUploadToast } from "@rustpad/toast"; function escapeHtml(value) { @@ -40,6 +40,13 @@ function markdownCode(url, label, mimeType) { return String(mimeType || "").startsWith("image/") ? `![${label}](${url})` : `[${label}](${url})`; } +function safeAttachmentUrl(value) { + const raw = String(value || "").trim(); + return raw.startsWith("/") + ? safeAppUrl(raw) + : safePublicUrl(raw, { allowMailto: false }); +} + export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, canUpload, toast, onFilesChanged = () => {} }) { const dialog = document.querySelector("#files-dialog"); const list = document.querySelector("#files-list"); @@ -143,13 +150,13 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca if (showButton) { const panel = showButton.closest(".file-row").querySelector(".file-code"); const output = panel.querySelector("textarea"); - const absolute = new URL(safeAppUrl(showButton.dataset.url), location.origin).href; + const safeUrl = safeAttachmentUrl(showButton.dataset.url); + const absolute = new URL(safeUrl, location.origin).href; let text = absolute; if (showButton.dataset.showFileCode === "alias") { text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime); } else if (showButton.dataset.showFileCode === "markdown") { - const relative = safeAppUrl(showButton.dataset.url); - text = markdownCode(relative, showButton.dataset.name, showButton.dataset.mime); + text = markdownCode(safeUrl, showButton.dataset.name, showButton.dataset.mime); } output.value = text; panel.hidden = false; output.focus(); output.select(); return; } diff --git a/static/js/socket.js b/static/js/socket.js index 981975e..c6fa499 100644 --- a/static/js/socket.js +++ b/static/js/socket.js @@ -9,33 +9,83 @@ import { logError, logInfo, logWarn } from "@rustpad/logger"; +const HEARTBEAT_INTERVAL_MS = 10000; +const HEARTBEAT_TIMEOUT_MS = 30000; +const MAX_RECONNECT_DELAY_MS = 12000; + class RoomSocket { constructor(options) { Object.assign(this, options); this.socket = null; - this.timer = null; + this.reconnectTimer = null; this.pingTimer = null; - this.closed = false; + this.stopped = false; + this.intentionalClose = false; + this.reconnectAttempt = 0; this.pendingPings = new Map(); + this.handleOnline = () => this.reconnectNow("Network connection restored."); + this.handleOffline = () => this.handleNetworkOffline(); + this.handleVisibility = () => this.checkHeartbeat(); + window.addEventListener("online", this.handleOnline); + window.addEventListener("offline", this.handleOffline); + document.addEventListener("visibilitychange", this.handleVisibility); } + get url() { throw new Error("Socket URL not implemented"); } get kind() { return "room"; } + connect() { - clearTimeout(this.timer); + clearTimeout(this.reconnectTimer); clearInterval(this.pingTimer); - this.closed = false; - this.onStatus?.("connecting"); - this.socket = new WebSocket(this.url); - this.socket.addEventListener("open", () => { - logInfo("websocket.open", { kind: this.kind }); - this.send({ type: "authenticate", password: this.password || null, access_token: this.accessToken || null, nickname: this.nickname || null, guest_id: this.guestId || null, color: this.color || null }); + if (this.stopped) return; + if (!navigator.onLine) { + this.scheduleReconnect("Your device is offline."); + return; + } + + this.intentionalClose = false; + this.onStatus?.(this.reconnectAttempt ? "reconnecting" : "connecting", { + attempt: this.reconnectAttempt, + message: this.reconnectAttempt ? "Re-establishing the live connection." : "Opening the live connection.", }); - this.socket.addEventListener("message", event => { + + let socket; + try { + socket = new WebSocket(this.url); + } catch (error) { + logError("websocket.create", error, { kind: this.kind }); + this.scheduleReconnect("The live connection could not be opened."); + return; + } + this.socket = socket; + + socket.addEventListener("open", () => { + if (socket !== this.socket || this.stopped) return; + logInfo("websocket.open", { kind: this.kind }); + this.send({ + type: "authenticate", + password: this.password || null, + access_token: this.accessToken || null, + nickname: this.nickname || null, + guest_id: this.guestId || null, + color: this.color || null, + }); + }); + + socket.addEventListener("message", event => { + if (socket !== this.socket || this.stopped) return; let message; try { message = JSON.parse(event.data); } catch { return; } - if (message.type === "error") { this.onError?.(message.message); this.closed = true; this.socket.close(); return; } + if (message.type === "error") { + this.intentionalClose = true; + this.onError?.(message.message); + socket.close(); + return; + } if (message.type === "authenticated") { - this.onStatus?.("online"); + const restored = this.reconnectAttempt > 0; + this.reconnectAttempt = 0; + this.onStatus?.("online", { restored }); this.onAuthenticated?.(message); this.startPing(); return; @@ -51,43 +101,116 @@ class RoomSocket { } } }); - this.socket.addEventListener("close", event => { + + socket.addEventListener("close", event => { + if (socket !== this.socket) return; clearInterval(this.pingTimer); this.pendingPings.clear(); this.onPresence?.([]); this.onLatency?.(null); - logWarn("websocket.close", { kind: this.kind, code: event.code, reason: event.reason || "", intentional: this.closed }); - if (!this.closed) { this.onStatus?.("offline"); this.timer = setTimeout(() => this.connect(), 1500); } + logWarn("websocket.close", { + kind: this.kind, + code: event.code, + reason: event.reason || "", + intentional: this.intentionalClose || this.stopped, + }); + if (!this.intentionalClose && !this.stopped) { + this.scheduleReconnect(this.closeMessage(event)); + } }); - this.socket.addEventListener("error", event => { + + socket.addEventListener("error", event => { + if (socket !== this.socket || this.stopped) return; logError("websocket.error", event, { kind: this.kind }); - this.onError?.("Failed to connect to the WebSocket server"); - this.socket.close(); + if (socket.readyState !== WebSocket.CLOSING && socket.readyState !== WebSocket.CLOSED) { + socket.close(); + } }); } + + closeMessage(event) { + if (!navigator.onLine) return "Your device is offline."; + if (event.reason === "heartbeat timeout") return "The connection stopped responding after the tab was inactive."; + return "The server connection was interrupted."; + } + + scheduleReconnect(message) { + if (this.stopped) return; + clearTimeout(this.reconnectTimer); + this.reconnectAttempt += 1; + const baseDelay = Math.min(MAX_RECONNECT_DELAY_MS, 750 * (2 ** Math.min(this.reconnectAttempt - 1, 4))); + const retryInMs = navigator.onLine ? baseDelay : 3000; + this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt, message, retryInMs }); + this.reconnectTimer = window.setTimeout(() => this.connect(), retryInMs); + } + + reconnectNow(message) { + if (this.stopped || this.socket?.readyState === WebSocket.OPEN || this.socket?.readyState === WebSocket.CONNECTING) return; + clearTimeout(this.reconnectTimer); + this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt, message, retryInMs: 0 }); + this.connect(); + } + + handleNetworkOffline() { + if (this.stopped) return; + this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt + 1, message: "Your device is offline.", retryInMs: 3000 }); + if (this.socket?.readyState === WebSocket.OPEN || this.socket?.readyState === WebSocket.CONNECTING) { + this.socket.close(); + } else { + this.scheduleReconnect("Your device is offline."); + } + } + + checkHeartbeat() { + if (this.stopped || this.socket?.readyState !== WebSocket.OPEN) return; + const now = performance.now(); + const expired = [...this.pendingPings.values()].some(started => now - started >= HEARTBEAT_TIMEOUT_MS); + if (expired) { + logWarn("websocket.heartbeat_timeout", { kind: this.kind }); + this.socket.close(4000, "heartbeat timeout"); + } + } + startPing() { clearInterval(this.pingTimer); const ping = () => { + this.checkHeartbeat(); if (this.socket?.readyState !== WebSocket.OPEN) return; const nonce = Date.now(); this.pendingPings.set(nonce, performance.now()); - for (const key of this.pendingPings.keys()) if (key < nonce - 30000) this.pendingPings.delete(key); this.send({ type: "ping", nonce }); }; ping(); - this.pingTimer = setInterval(ping, 10000); + this.pingTimer = window.setInterval(ping, HEARTBEAT_INTERVAL_MS); } - send(message) { if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message)); } + + send(message) { + if (this.socket?.readyState !== WebSocket.OPEN) return false; + this.socket.send(JSON.stringify(message)); + return true; + } + update(content, ownerMap = "[]") { this.send({ type: "update", content, owner_map: ownerMap }); } chat(text) { this.send({ type: "chat", text }); } setColor(color) { this.color = color || null; this.send({ type: "set_color", color: this.color }); } - stop() { this.closed = true; clearTimeout(this.timer); clearInterval(this.pingTimer); this.socket?.close(); } + + stop() { + this.stopped = true; + this.intentionalClose = true; + clearTimeout(this.reconnectTimer); + clearInterval(this.pingTimer); + window.removeEventListener("online", this.handleOnline); + window.removeEventListener("offline", this.handleOffline); + document.removeEventListener("visibilitychange", this.handleVisibility); + this.socket?.close(); + } } export class NoteSocket extends RoomSocket { get kind() { return "note"; } get url() { const p = location.protocol === "https:" ? "wss:" : "ws:"; return `${p}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`; } } + export class PadSocket extends RoomSocket { get kind() { return "pad"; } get url() { const p = location.protocol === "https:" ? "wss:" : "ws:"; return `${p}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`; } diff --git a/systemd/rustpad.yaml b/systemd/rustpad.yaml index 57ed796..c054be9 100644 --- a/systemd/rustpad.yaml +++ b/systemd/rustpad.yaml @@ -9,6 +9,8 @@ database: static_dir: /opt/rustpad/static files_dir: /var/lib/rustpad/files +# Optional; bare domains are normalized to HTTPS. Serve or proxy /f/* there. +# files_public_url: files.note.example.org storage_driver: local upload_max_size_mb: 20 asset_cache_max_age_seconds: 600