some changes

This commit is contained in:
Mateusz Gruszczyński
2026-07-30 23:57:35 +02:00
parent c5ca8e8d0b
commit 4edc511272
20 changed files with 686 additions and 65 deletions
+3
View File
@@ -36,6 +36,9 @@ UPLOAD_MAX_SIZE_MB=20
# Attachment storage: local or s3 # Attachment storage: local or s3
STORAGE_DRIVER=local STORAGE_DRIVER=local
FILES_DIR=/data/files 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...) # S3-compatible storage (AWS S3, Garage, Ceph, OpenStack, MinIO, R2...)
# For Docker Garage run: docker compose --profile s3 up -d # For Docker Garage run: docker compose --profile s3 up -d
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.2.7" version = "0.2.8"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.2.7" version = "0.2.8"
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"
+3 -1
View File
@@ -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=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. - `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=<seconds>`. 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=<seconds>`.
+5
View File
@@ -12,3 +12,8 @@ root_domain = ".s3.garage.localhost"
[admin] [admin]
api_bind_addr = "0.0.0.0:3903" 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"
+88 -9
View File
@@ -99,7 +99,11 @@ pub async fn upload_pad_file(
stored = format!("{stem}-{}{}", db::random_suffix(6), ext); stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
key = crate::storage::object_key("pads", pad.id, &file_token, &stored); 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) let mime = mime_guess::from_path(&stored)
.first_or_octet_stream() .first_or_octet_stream()
.to_string(); .to_string();
@@ -109,8 +113,16 @@ pub async fn upload_pad_file(
.put(&key, bytes.clone().into(), &mime, &cache_control) .put(&key, bytes.clone().into(), &mime, &cache_control)
.await .await
.map_err(|_| ApiError::internal("Failed to save the file"))?; .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?; db::register_pad_file(
Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime}))) &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 { 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 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( pub async fn pad_files(
State(state): State<SharedState>, State(state): State<SharedState>,
headers: HeaderMap, headers: HeaderMap,
@@ -153,7 +185,12 @@ pub async fn pad_files(
.await?; .await?;
let mut files = db::list_pad_files(&state.db, pad.id).await?; let mut files = db::list_pad_files(&state.db, pad.id).await?;
for file in &mut files { 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 { if attached != file.is_attached {
db::set_pad_file_attached(&state.db, file.id, attached).await?; db::set_pad_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached; file.is_attached = attached;
@@ -164,6 +201,10 @@ pub async fn pad_files(
}; };
} }
file.created_at = db::normalize_timestamp(&file.created_at); 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)) Ok(Json(files))
} }
@@ -298,7 +339,11 @@ pub async fn upload_note_file(
stored = format!("{stem}-{}{}", db::random_suffix(6), ext); stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
key = crate::storage::object_key("notes", note.id, &file_token, &stored); 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) let mime = mime_guess::from_path(&stored)
.first_or_octet_stream() .first_or_octet_stream()
.to_string(); .to_string();
@@ -308,8 +353,16 @@ pub async fn upload_note_file(
.put(&key, bytes.clone().into(), &mime, &cache_control) .put(&key, bytes.clone().into(), &mime, &cache_control)
.await .await
.map_err(|_| ApiError::internal("Failed to save the file"))?; .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?; db::register_note_file(
Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime}))) &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( pub async fn delete_note(
@@ -382,7 +435,12 @@ pub async fn note_files(
.await?; .await?;
let mut files = db::list_note_files(&state.db, note.id).await?; let mut files = db::list_note_files(&state.db, note.id).await?;
for file in &mut files { for file in &mut files {
let attached = content_references_file(&note.content, &file.filename, &file.url); let attached = content_references_stored_file(
&note.content,
&file.filename,
&file.url,
state.files_public_url.as_deref(),
);
if attached != file.is_attached { if attached != file.is_attached {
db::set_note_file_attached(&state.db, file.id, attached).await?; db::set_note_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached; file.is_attached = attached;
@@ -393,6 +451,10 @@ pub async fn note_files(
}; };
} }
file.created_at = db::normalize_timestamp(&file.created_at); 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)) Ok(Json(files))
} }
@@ -580,7 +642,7 @@ fn sanitize_filename(value: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::is_safe_inline_image_mime; use super::{content_references_stored_file, is_safe_inline_image_mime};
#[test] #[test]
fn only_raster_images_are_inline() { 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("text/html"));
assert!(!is_safe_inline_image_mime("application/xml")); 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"),
));
}
} }
+16 -12
View File
@@ -162,16 +162,6 @@ pub struct MarkdownFileReference {
mime_type: String, mime_type: String,
} }
impl From<db::NoteFile> 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( pub(crate) async fn markdown_file_references(
state: &SharedState, state: &SharedState,
pad_id: Option<i64>, pad_id: Option<i64>,
@@ -189,10 +179,24 @@ pub(crate) async fn markdown_file_references(
.into_iter() .into_iter()
.filter(|file| { .filter(|file| {
content 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) .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()) .collect())
} }
+4
View File
@@ -58,6 +58,7 @@ pub struct Config {
pub asset_version: String, pub asset_version: String,
pub asset_cache_max_age_seconds: u64, pub asset_cache_max_age_seconds: u64,
pub file_cache_max_age_seconds: u64, pub file_cache_max_age_seconds: u64,
pub files_public_url: Option<String>,
pub smtp: Option<crate::state::SmtpConfig>, pub smtp: Option<crate::state::SmtpConfig>,
pub registration_enabled: bool, pub registration_enabled: bool,
pub account_confirmation_required: bool, pub account_confirmation_required: bool,
@@ -83,6 +84,8 @@ impl Config {
let unconfirmed_account_ttl_days = let unconfirmed_account_ttl_days =
values.positive_i64("UNCONFIRMED_ACCOUNT_TTL_DAYS", 3)?; values.positive_i64("UNCONFIRMED_ACCOUNT_TTL_DAYS", 3)?;
let files_dir = values.get("FILES_DIR", "data/files"); 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 let storage = match values
.get("STORAGE_DRIVER", "local") .get("STORAGE_DRIVER", "local")
@@ -172,6 +175,7 @@ impl Config {
.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
.nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?, .nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?,
files_public_url,
smtp, smtp,
registration_enabled: values.bool("REGISTRATION_ENABLED", false)?, registration_enabled: values.bool("REGISTRATION_ENABLED", false)?,
account_confirmation_required: values.bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?, account_confirmation_required: values.bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?,
+1
View File
@@ -16,6 +16,7 @@ const KNOWN_CONFIG_KEYS: &[&str] = &[
"DATABASE_MAX_CONNECTIONS", "DATABASE_MAX_CONNECTIONS",
"STATIC_DIR", "STATIC_DIR",
"FILES_DIR", "FILES_DIR",
"FILES_PUBLIC_URL",
"STORAGE_DRIVER", "STORAGE_DRIVER",
"UPLOAD_MAX_SIZE_MB", "UPLOAD_MAX_SIZE_MB",
"ASSET_CACHE_MAX_AGE_SECONDS", "ASSET_CACHE_MAX_AGE_SECONDS",
+148
View File
@@ -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<String>) -> Result<Option<String>, Box<dyn Error>> {
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<String> {
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"
);
}
}
+28 -4
View File
@@ -15,6 +15,7 @@ mod cache;
mod config; mod config;
mod database; mod database;
mod db; mod db;
mod file_urls;
mod queries; mod queries;
mod row_decode; mod row_decode;
mod security; mod security;
@@ -40,6 +41,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
let cli = parse_command()?; let cli = parse_command()?;
init_tracing(); init_tracing();
print_startup_credential();
let config = Config::load(cli.config.as_deref())?; let config = Config::load(cli.config.as_deref())?;
if matches!(cli.command, Command::CheckConfig) { if matches!(cli.command, Command::CheckConfig) {
@@ -53,8 +55,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
return Ok(()); return Ok(());
} }
info!( info!(
"RustPad version" = %config.asset_version,
Copyright="@linuxiarz.pl Mateusz Gruszczyński",
host = %config.host, host = %config.host,
port = config.port, port = config.port,
database_kind = %database_kind_label(&config.database_url), database_kind = %database_kind_label(&config.database_url),
@@ -65,6 +65,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
upload_max_size_bytes = config.upload_max_size_bytes, upload_max_size_bytes = config.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,
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"),
registration_enabled = config.registration_enabled, registration_enabled = config.registration_enabled,
account_confirmation_required = config.account_confirmation_required, account_confirmation_required = config.account_confirmation_required,
share_confirmation_required = config.share_confirmation_required, share_confirmation_required = config.share_confirmation_required,
@@ -73,7 +74,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
user_session_ttl_days = config.user_session_ttl_days, user_session_ttl_days = config.user_session_ttl_days,
smtp_configured = config.smtp.is_some(), smtp_configured = config.smtp.is_some(),
authorization_type = config.authorization_type.as_str(), authorization_type = config.authorization_type.as_str(),
asset_version = %config.asset_version,
"configuration loaded" "configuration loaded"
); );
if let Some(path) = config if let Some(path) = config
@@ -106,6 +106,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
storage, storage,
config.upload_max_size_bytes, config.upload_max_size_bytes,
config.file_cache_max_age_seconds, config.file_cache_max_age_seconds,
config.files_public_url.clone(),
config.smtp.clone(), config.smtp.clone(),
config.registration_enabled && config.ldap.is_none(), config.registration_enabled && config.ldap.is_none(),
config.account_confirmation_required, config.account_confirmation_required,
@@ -152,7 +153,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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?;
info!(%address, asset_version = %config.asset_version, "RustPad is running"); info!(%address, "RustPad is running");
axum::serve(listener, app) axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal()) .with_graceful_shutdown(shutdown_signal())
.await?; .await?;
@@ -160,6 +161,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(()) 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)] #[derive(Clone, Copy)]
enum Command { enum Command {
Run, 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> { async fn run_migrations(db: &Database) -> Result<(), sqlx::migrate::MigrateError> {
let path = match db.kind() { let path = match db.kind() {
DatabaseKind::Sqlite => std::path::Path::new("migrations/sqlite"), DatabaseKind::Sqlite => std::path::Path::new("migrations/sqlite"),
+3
View File
@@ -97,6 +97,7 @@ pub struct AppState {
pub storage: crate::storage::Storage, pub storage: crate::storage::Storage,
pub upload_max_size_bytes: usize, pub upload_max_size_bytes: usize,
pub file_cache_max_age_seconds: u64, pub file_cache_max_age_seconds: u64,
pub files_public_url: Option<String>,
pub smtp: Option<SmtpConfig>, pub smtp: Option<SmtpConfig>,
pub registration_enabled: bool, pub registration_enabled: bool,
pub account_confirmation_required: bool, pub account_confirmation_required: bool,
@@ -119,6 +120,7 @@ impl AppState {
storage: crate::storage::Storage, storage: crate::storage::Storage,
upload_max_size_bytes: usize, upload_max_size_bytes: usize,
file_cache_max_age_seconds: u64, file_cache_max_age_seconds: u64,
files_public_url: Option<String>,
smtp: Option<SmtpConfig>, smtp: Option<SmtpConfig>,
registration_enabled: bool, registration_enabled: bool,
account_confirmation_required: bool, account_confirmation_required: bool,
@@ -135,6 +137,7 @@ impl AppState {
storage, storage,
upload_max_size_bytes, upload_max_size_bytes,
file_cache_max_age_seconds, file_cache_max_age_seconds,
files_public_url,
smtp, smtp,
registration_enabled, registration_enabled,
account_confirmation_required, account_confirmation_required,
+4 -1
View File
@@ -231,7 +231,10 @@ pub async fn delete_url_file(
owner_id: i64, owner_id: i64,
url: &str, url: &str,
) -> Result<(), StorageError> { ) -> 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" { if parts.len() != 3 || parts[0] != "f" {
return Ok(()); return Ok(());
} }
+105
View File
@@ -355,6 +355,96 @@ textarea:focus {
background: var(--danger); 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 { .editor-layout {
position: relative; position: relative;
display: grid; display: grid;
@@ -369,6 +459,7 @@ textarea:focus {
} }
.editor-panel { .editor-panel {
position: relative;
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr) auto;
min-width: 0; min-width: 0;
@@ -4412,6 +4503,12 @@ dialog::backdrop {
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.connection-notice {
top: 76px;
width: calc(100% - 20px);
padding: 10px 12px;
}
.pad-page .editor-toolbar { .pad-page .editor-toolbar {
grid-template-columns: minmax(0, 1fr); 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) { @media (max-width: 720px) {
.pad-page #save-state { .pad-page #save-state {
display: none; display: none;
+6
View File
@@ -116,6 +116,12 @@
<div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" <div class="view-switch"><button data-view="edit">Edit</button><button data-view="split"
class="active">Split</button><button data-view="preview">Preview</button></div> class="active">Split</button><button data-view="preview">Preview</button></div>
</div> </div>
<div id="connection-notice" class="connection-notice" role="status" aria-live="polite" hidden>
<span class="connection-notice__signal" aria-hidden="true"><span></span><span></span><span></span></span>
<span class="connection-notice__content"><strong id="connection-notice-title">Connection
interrupted</strong><span id="connection-notice-message">Trying to reconnect
automatically.</span></span>
</div>
<div id="editor-workspace" class="workspace view-split"> <div id="editor-workspace" class="workspace view-split">
<div class="editor-column"> <div class="editor-column">
<div class="column-label editor-column-label"><span>Editor</span> <div class="column-label editor-column-label"><span>Editor</span>
+24 -7
View File
@@ -13,10 +13,25 @@ function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[c])); return String(value).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[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) { function safeUrl(value) {
const raw = String(value || "").trim(); let raw = String(value || "").trim();
if (!raw || raw.startsWith("//")) return "#"; if (!raw || raw.startsWith("//")) return "#";
if (raw.startsWith("#")) return escapeHtml(raw); if (raw.startsWith("#")) return escapeHtml(raw);
const route = attachmentRoute(raw);
if (route && markdownFileRoutes.has(route)) raw = markdownFileRoutes.get(route);
try { try {
const url = new URL(raw, location.origin); const url = new URL(raw, location.origin);
if (url.protocol === "mailto:") return escapeHtml(url.href); 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) { export function setMarkdownFiles(files) {
markdownFiles = new Map((Array.isArray(files) ? files : []) const normalized = (Array.isArray(files) ? files : [])
.filter(file => file && file.filename && file.url) .filter(file => file && file.filename && file.url)
.map(file => [String(file.filename), { .map(file => ({
filename: String(file.filename),
url: String(file.url), url: String(file.url),
mimeType: String(file.mime_type || ""), 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) { export function unresolvedMarkdownFileAliases(value) {
+88 -3
View File
@@ -25,14 +25,14 @@ import { toast } from "@rustpad/toast";
export function startNoteEditor(adapter) { export function startNoteEditor(adapter) {
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer"); const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog"); const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"); 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; let unreadChat = 0;
const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color"); const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle"); const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle");
const shareToken = new URLSearchParams(location.search).get("share"); const shareToken = new URLSearchParams(location.search).get("share");
const notePreferenceKey = name => `rustpad:${name}:${location.pathname}`; 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 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 compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
const singlePaneQuery = window.matchMedia("(max-width: 760px)"); const singlePaneQuery = window.matchMedia("(max-width: 760px)");
let compactView = uiState.view === "preview" ? "preview" : "edit"; 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 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 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}`; } 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", '<p class="error">Failed to load Mermaid.</p>')); } } 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", '<p class="error">Failed to load Mermaid.</p>')); } }
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 { } } 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 }), onFilesChanged: files => updateMarkdownFiles(files, { rerender: true }),
}); });
refreshFilesForAliases = () => loadFiles(); 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(); } } }); 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(); }); }); identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
async function showSystemNotFound() { async function showSystemNotFound() {
@@ -698,6 +774,15 @@ export function startNoteEditor(adapter) {
compactLayoutQuery.addEventListener("change", event => { compactLayoutQuery.addEventListener("change", event => {
if (!event.matches) setHeaderMenuOpen(false); 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 }); }); 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 }); }); 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 }); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("preview-line-numbers"), previewLineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); alignPreviewLineNumbers(preview); scheduleEditorSettingsSave({ personal: true }); });
+11 -4
View File
@@ -11,7 +11,7 @@ import { api, uploadWithProgress } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard"; import { copyText } from "@rustpad/clipboard";
import { prepareImageFile } from "@rustpad/image-upload"; import { prepareImageFile } from "@rustpad/image-upload";
import { askConfirm } from "@rustpad/modal"; import { askConfirm } from "@rustpad/modal";
import { safeAppUrl } from "@rustpad/security"; import { safeAppUrl, safePublicUrl } from "@rustpad/security";
import { createUploadToast } from "@rustpad/toast"; import { createUploadToast } from "@rustpad/toast";
function escapeHtml(value) { function escapeHtml(value) {
@@ -40,6 +40,13 @@ function markdownCode(url, label, mimeType) {
return String(mimeType || "").startsWith("image/") ? `![${label}](${url})` : `[${label}](${url})`; 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 = () => {} }) { export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, canUpload, toast, onFilesChanged = () => {} }) {
const dialog = document.querySelector("#files-dialog"); const dialog = document.querySelector("#files-dialog");
const list = document.querySelector("#files-list"); const list = document.querySelector("#files-list");
@@ -143,13 +150,13 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
if (showButton) { if (showButton) {
const panel = showButton.closest(".file-row").querySelector(".file-code"); const panel = showButton.closest(".file-row").querySelector(".file-code");
const output = panel.querySelector("textarea"); 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; let text = absolute;
if (showButton.dataset.showFileCode === "alias") { if (showButton.dataset.showFileCode === "alias") {
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime); text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime);
} else if (showButton.dataset.showFileCode === "markdown") { } else if (showButton.dataset.showFileCode === "markdown") {
const relative = safeAppUrl(showButton.dataset.url); text = markdownCode(safeUrl, showButton.dataset.name, showButton.dataset.mime);
text = markdownCode(relative, showButton.dataset.name, showButton.dataset.mime);
} }
output.value = text; panel.hidden = false; output.focus(); output.select(); return; output.value = text; panel.hidden = false; output.focus(); output.select(); return;
} }
+145 -22
View File
@@ -9,33 +9,83 @@
import { logError, logInfo, logWarn } from "@rustpad/logger"; 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 { class RoomSocket {
constructor(options) { constructor(options) {
Object.assign(this, options); Object.assign(this, options);
this.socket = null; this.socket = null;
this.timer = null; this.reconnectTimer = null;
this.pingTimer = null; this.pingTimer = null;
this.closed = false; this.stopped = false;
this.intentionalClose = false;
this.reconnectAttempt = 0;
this.pendingPings = new Map(); 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 url() { throw new Error("Socket URL not implemented"); }
get kind() { return "room"; } get kind() { return "room"; }
connect() { connect() {
clearTimeout(this.timer); clearTimeout(this.reconnectTimer);
clearInterval(this.pingTimer); clearInterval(this.pingTimer);
this.closed = false; if (this.stopped) return;
this.onStatus?.("connecting"); if (!navigator.onLine) {
this.socket = new WebSocket(this.url); this.scheduleReconnect("Your device is offline.");
this.socket.addEventListener("open", () => { 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 });
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; let message;
try { message = JSON.parse(event.data); } catch { return; } 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") { if (message.type === "authenticated") {
this.onStatus?.("online"); const restored = this.reconnectAttempt > 0;
this.reconnectAttempt = 0;
this.onStatus?.("online", { restored });
this.onAuthenticated?.(message); this.onAuthenticated?.(message);
this.startPing(); this.startPing();
return; return;
@@ -51,43 +101,116 @@ class RoomSocket {
} }
} }
}); });
this.socket.addEventListener("close", event => {
socket.addEventListener("close", event => {
if (socket !== this.socket) return;
clearInterval(this.pingTimer); clearInterval(this.pingTimer);
this.pendingPings.clear(); this.pendingPings.clear();
this.onPresence?.([]); this.onPresence?.([]);
this.onLatency?.(null); this.onLatency?.(null);
logWarn("websocket.close", { kind: this.kind, code: event.code, reason: event.reason || "", intentional: this.closed }); logWarn("websocket.close", {
if (!this.closed) { this.onStatus?.("offline"); this.timer = setTimeout(() => this.connect(), 1500); } kind: this.kind,
code: event.code,
reason: event.reason || "",
intentional: this.intentionalClose || this.stopped,
}); });
this.socket.addEventListener("error", event => { if (!this.intentionalClose && !this.stopped) {
this.scheduleReconnect(this.closeMessage(event));
}
});
socket.addEventListener("error", event => {
if (socket !== this.socket || this.stopped) return;
logError("websocket.error", event, { kind: this.kind }); logError("websocket.error", event, { kind: this.kind });
this.onError?.("Failed to connect to the WebSocket server"); if (socket.readyState !== WebSocket.CLOSING && socket.readyState !== WebSocket.CLOSED) {
this.socket.close(); 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() { startPing() {
clearInterval(this.pingTimer); clearInterval(this.pingTimer);
const ping = () => { const ping = () => {
this.checkHeartbeat();
if (this.socket?.readyState !== WebSocket.OPEN) return; if (this.socket?.readyState !== WebSocket.OPEN) return;
const nonce = Date.now(); const nonce = Date.now();
this.pendingPings.set(nonce, performance.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 }); this.send({ type: "ping", nonce });
}; };
ping(); 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 }); } update(content, ownerMap = "[]") { this.send({ type: "update", content, owner_map: ownerMap }); }
chat(text) { this.send({ type: "chat", text }); } chat(text) { this.send({ type: "chat", text }); }
setColor(color) { this.color = color || null; this.send({ type: "set_color", color: this.color }); } 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 { export class NoteSocket extends RoomSocket {
get kind() { return "note"; } get kind() { return "note"; }
get url() { const p = location.protocol === "https:" ? "wss:" : "ws:"; return `${p}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`; } 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 { export class PadSocket extends RoomSocket {
get kind() { return "pad"; } get kind() { return "pad"; }
get url() { const p = location.protocol === "https:" ? "wss:" : "ws:"; return `${p}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`; } get url() { const p = location.protocol === "https:" ? "wss:" : "ws:"; return `${p}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`; }
+2
View File
@@ -9,6 +9,8 @@ database:
static_dir: /opt/rustpad/static static_dir: /opt/rustpad/static
files_dir: /var/lib/rustpad/files 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 storage_driver: local
upload_max_size_mb: 20 upload_max_size_mb: 20
asset_cache_max_age_seconds: 600 asset_cache_max_age_seconds: 600