some changes
This commit is contained in:
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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>`.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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(¬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 {
|
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(
|
||||||
|
"",
|
||||||
|
"image.png",
|
||||||
|
stored,
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
assert!(content_references_stored_file(
|
||||||
|
"",
|
||||||
|
"image.png",
|
||||||
|
"https://old-files.example.com/f/token/image.png",
|
||||||
|
Some("https://new-files.example.com"),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-12
@@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)?,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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"),
|
||||||
|
|||||||
@@ -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
@@ -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(());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -13,10 +13,25 @@ function escapeHtml(value) {
|
|||||||
return String(value).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
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) {
|
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) {
|
||||||
|
|||||||
@@ -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
@@ -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})`;
|
return String(mimeType || "").startsWith("image/") ? `` : `[${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
@@ -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,
|
||||||
|
});
|
||||||
|
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 });
|
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)}`; }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user