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
STORAGE_DRIVER=local
FILES_DIR=/data/files
# Optional attachment origin. A bare domain is normalized to HTTPS.
# The administrator must proxy or serve /f/* on this domain.
# FILES_PUBLIC_URL=files.note.example.com
# S3-compatible storage (AWS S3, Garage, Ceph, OpenStack, MinIO, R2...)
# For Docker Garage run: docker compose --profile s3 up -d
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.2.7"
version = "0.2.8"
dependencies = [
"argon2",
"aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
version = "0.2.7"
version = "0.2.8"
edition = "2024"
rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+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=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>`.
+5
View File
@@ -12,3 +12,8 @@ root_domain = ".s3.garage.localhost"
[admin]
api_bind_addr = "0.0.0.0:3903"
#[s3_web]
#bind_addr = "0.0.0.0:3902"
#root_domain = ".web.garage.localhost"
#index = "index.html"
+88 -9
View File
@@ -99,7 +99,11 @@ pub async fn upload_pad_file(
stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
key = crate::storage::object_key("pads", pad.id, &file_token, &stored);
}
let url = format!("/f/{}/{}", file_token, stored);
let stored_url = crate::file_urls::stored_file_path(&file_token, &stored);
let public_url = crate::file_urls::public_file_url(
state.files_public_url.as_deref(),
&stored_url,
);
let mime = mime_guess::from_path(&stored)
.first_or_octet_stream()
.to_string();
@@ -109,8 +113,16 @@ pub async fn upload_pad_file(
.put(&key, bytes.clone().into(), &mime, &cache_control)
.await
.map_err(|_| ApiError::internal("Failed to save the file"))?;
db::register_pad_file(&state.db, pad.id, &stored, &url, &mime, bytes.len() as i64).await?;
Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime})))
db::register_pad_file(
&state.db,
pad.id,
&stored,
&stored_url,
&mime,
bytes.len() as i64,
)
.await?;
Ok(Json(serde_json::json!({"name": stored, "url": public_url, "mime_type": mime})))
}
pub(super) fn content_references_file(content: &str, filename: &str, url: &str) -> bool {
@@ -136,6 +148,26 @@ pub(super) fn content_references_file(content: &str, filename: &str, url: &str)
false
}
pub(super) fn content_references_stored_file(
content: &str,
filename: &str,
stored_url: &str,
public_base: Option<&str>,
) -> bool {
if content_references_file(content, filename, stored_url) {
return true;
}
let canonical = crate::file_urls::canonical_file_path(stored_url);
if canonical
.as_deref()
.is_some_and(|url| url != stored_url && content.contains(url))
{
return true;
}
let public_url = crate::file_urls::public_file_url(public_base, stored_url);
public_url != stored_url && content.contains(&public_url)
}
pub async fn pad_files(
State(state): State<SharedState>,
headers: HeaderMap,
@@ -153,7 +185,12 @@ pub async fn pad_files(
.await?;
let mut files = db::list_pad_files(&state.db, pad.id).await?;
for file in &mut files {
let attached = content_references_file(&pad.content, &file.filename, &file.url);
let attached = content_references_stored_file(
&pad.content,
&file.filename,
&file.url,
state.files_public_url.as_deref(),
);
if attached != file.is_attached {
db::set_pad_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached;
@@ -164,6 +201,10 @@ pub async fn pad_files(
};
}
file.created_at = db::normalize_timestamp(&file.created_at);
file.url = crate::file_urls::public_file_url(
state.files_public_url.as_deref(),
&file.url,
);
}
Ok(Json(files))
}
@@ -298,7 +339,11 @@ pub async fn upload_note_file(
stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
key = crate::storage::object_key("notes", note.id, &file_token, &stored);
}
let url = format!("/f/{}/{}", file_token, stored);
let stored_url = crate::file_urls::stored_file_path(&file_token, &stored);
let public_url = crate::file_urls::public_file_url(
state.files_public_url.as_deref(),
&stored_url,
);
let mime = mime_guess::from_path(&stored)
.first_or_octet_stream()
.to_string();
@@ -308,8 +353,16 @@ pub async fn upload_note_file(
.put(&key, bytes.clone().into(), &mime, &cache_control)
.await
.map_err(|_| ApiError::internal("Failed to save the file"))?;
db::register_note_file(&state.db, note.id, &stored, &url, &mime, bytes.len() as i64).await?;
Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime})))
db::register_note_file(
&state.db,
note.id,
&stored,
&stored_url,
&mime,
bytes.len() as i64,
)
.await?;
Ok(Json(serde_json::json!({"name": stored, "url": public_url, "mime_type": mime})))
}
pub async fn delete_note(
@@ -382,7 +435,12 @@ pub async fn note_files(
.await?;
let mut files = db::list_note_files(&state.db, note.id).await?;
for file in &mut files {
let attached = content_references_file(&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 {
db::set_note_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached;
@@ -393,6 +451,10 @@ pub async fn note_files(
};
}
file.created_at = db::normalize_timestamp(&file.created_at);
file.url = crate::file_urls::public_file_url(
state.files_public_url.as_deref(),
&file.url,
);
}
Ok(Json(files))
}
@@ -580,7 +642,7 @@ fn sanitize_filename(value: &str) -> String {
#[cfg(test)]
mod tests {
use super::is_safe_inline_image_mime;
use super::{content_references_stored_file, is_safe_inline_image_mime};
#[test]
fn only_raster_images_are_inline() {
@@ -590,4 +652,21 @@ mod tests {
assert!(!is_safe_inline_image_mime("text/html"));
assert!(!is_safe_inline_image_mime("application/xml"));
}
#[test]
fn attachment_references_survive_origin_changes() {
let stored = "/f/token/image.png";
assert!(content_references_stored_file(
"![diagram](https://old-files.example.com/f/token/image.png)",
"image.png",
stored,
None,
));
assert!(content_references_stored_file(
"![diagram](/f/token/image.png)",
"image.png",
"https://old-files.example.com/f/token/image.png",
Some("https://new-files.example.com"),
));
}
}
+16 -12
View File
@@ -162,16 +162,6 @@ pub struct MarkdownFileReference {
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(
state: &SharedState,
pad_id: Option<i64>,
@@ -189,10 +179,24 @@ pub(crate) async fn markdown_file_references(
.into_iter()
.filter(|file| {
content
.map(|value| files::content_references_file(value, &file.filename, &file.url))
.map(|value| {
files::content_references_stored_file(
value,
&file.filename,
&file.url,
state.files_public_url.as_deref(),
)
})
.unwrap_or(true)
})
.map(Into::into)
.map(|file| MarkdownFileReference {
filename: file.filename,
url: crate::file_urls::public_file_url(
state.files_public_url.as_deref(),
&file.url,
),
mime_type: file.mime_type,
})
.collect())
}
+4
View File
@@ -58,6 +58,7 @@ pub struct Config {
pub asset_version: String,
pub asset_cache_max_age_seconds: u64,
pub file_cache_max_age_seconds: u64,
pub files_public_url: Option<String>,
pub smtp: Option<crate::state::SmtpConfig>,
pub registration_enabled: bool,
pub account_confirmation_required: bool,
@@ -83,6 +84,8 @@ impl Config {
let unconfirmed_account_ttl_days =
values.positive_i64("UNCONFIRMED_ACCOUNT_TTL_DAYS", 3)?;
let files_dir = values.get("FILES_DIR", "data/files");
let files_public_url =
crate::file_urls::normalize_public_base(values.optional("FILES_PUBLIC_URL"))?;
let storage = match values
.get("STORAGE_DRIVER", "local")
@@ -172,6 +175,7 @@ impl Config {
.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
file_cache_max_age_seconds: values
.nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?,
files_public_url,
smtp,
registration_enabled: values.bool("REGISTRATION_ENABLED", false)?,
account_confirmation_required: values.bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?,
+1
View File
@@ -16,6 +16,7 @@ const KNOWN_CONFIG_KEYS: &[&str] = &[
"DATABASE_MAX_CONNECTIONS",
"STATIC_DIR",
"FILES_DIR",
"FILES_PUBLIC_URL",
"STORAGE_DRIVER",
"UPLOAD_MAX_SIZE_MB",
"ASSET_CACHE_MAX_AGE_SECONDS",
+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 database;
mod db;
mod file_urls;
mod queries;
mod row_decode;
mod security;
@@ -40,6 +41,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenvy::dotenv().ok();
let cli = parse_command()?;
init_tracing();
print_startup_credential();
let config = Config::load(cli.config.as_deref())?;
if matches!(cli.command, Command::CheckConfig) {
@@ -53,8 +55,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
return Ok(());
}
info!(
"RustPad version" = %config.asset_version,
Copyright="@linuxiarz.pl Mateusz Gruszczyński",
host = %config.host,
port = config.port,
database_kind = %database_kind_label(&config.database_url),
@@ -65,6 +65,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
upload_max_size_bytes = config.upload_max_size_bytes,
asset_cache_max_age_seconds = config.asset_cache_max_age_seconds,
file_cache_max_age_seconds = config.file_cache_max_age_seconds,
files_public_url = config.files_public_url.as_deref().unwrap_or("application origin"),
registration_enabled = config.registration_enabled,
account_confirmation_required = config.account_confirmation_required,
share_confirmation_required = config.share_confirmation_required,
@@ -73,7 +74,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
user_session_ttl_days = config.user_session_ttl_days,
smtp_configured = config.smtp.is_some(),
authorization_type = config.authorization_type.as_str(),
asset_version = %config.asset_version,
"configuration loaded"
);
if let Some(path) = config
@@ -106,6 +106,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
storage,
config.upload_max_size_bytes,
config.file_cache_max_age_seconds,
config.files_public_url.clone(),
config.smtp.clone(),
config.registration_enabled && config.ldap.is_none(),
config.account_confirmation_required,
@@ -152,7 +153,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let address = SocketAddr::new(config.host, config.port);
let listener = TcpListener::bind(address).await?;
info!(%address, asset_version = %config.asset_version, "RustPad is running");
info!(%address, "RustPad is running");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
@@ -160,6 +161,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
fn print_startup_credential() {
eprintln!("\n{}\n", startup_credential());
}
fn startup_credential() -> String {
format!(
"RustPad {}\nCopyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl",
env!("CARGO_PKG_VERSION")
)
}
#[derive(Clone, Copy)]
enum Command {
Run,
@@ -265,6 +277,18 @@ async fn shutdown_signal() {
}
}
#[cfg(test)]
mod startup_tests {
use super::startup_credential;
#[test]
fn startup_credential_contains_product_identity() {
let credential = startup_credential();
assert!(credential.contains(&format!("RustPad {}", env!("CARGO_PKG_VERSION"))));
assert!(credential.contains("Mateusz Gruszczyński @linuxiarz.pl"));
}
}
async fn run_migrations(db: &Database) -> Result<(), sqlx::migrate::MigrateError> {
let path = match db.kind() {
DatabaseKind::Sqlite => std::path::Path::new("migrations/sqlite"),
+3
View File
@@ -97,6 +97,7 @@ pub struct AppState {
pub storage: crate::storage::Storage,
pub upload_max_size_bytes: usize,
pub file_cache_max_age_seconds: u64,
pub files_public_url: Option<String>,
pub smtp: Option<SmtpConfig>,
pub registration_enabled: bool,
pub account_confirmation_required: bool,
@@ -119,6 +120,7 @@ impl AppState {
storage: crate::storage::Storage,
upload_max_size_bytes: usize,
file_cache_max_age_seconds: u64,
files_public_url: Option<String>,
smtp: Option<SmtpConfig>,
registration_enabled: bool,
account_confirmation_required: bool,
@@ -135,6 +137,7 @@ impl AppState {
storage,
upload_max_size_bytes,
file_cache_max_age_seconds,
files_public_url,
smtp,
registration_enabled,
account_confirmation_required,
+4 -1
View File
@@ -231,7 +231,10 @@ pub async fn delete_url_file(
owner_id: i64,
url: &str,
) -> Result<(), StorageError> {
let parts: Vec<&str> = url.trim_start_matches('/').split('/').collect();
let Some(path) = crate::file_urls::canonical_file_path(url) else {
return Ok(());
};
let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
if parts.len() != 3 || parts[0] != "f" {
return Ok(());
}
+105
View File
@@ -355,6 +355,96 @@ textarea:focus {
background: var(--danger);
}
.connection-notice {
position: absolute;
top: 64px;
left: 50%;
z-index: 18;
display: flex;
width: min(520px, calc(100% - 32px));
box-sizing: border-box;
align-items: center;
gap: 12px;
padding: 11px 14px;
border: 1px solid color-mix(in srgb, var(--danger) 48%, var(--border-strong));
border-radius: 12px;
background: color-mix(in srgb, var(--surface-strong, #11161e) 94%, var(--danger));
box-shadow: 0 16px 42px rgb(0 0 0 / 38%);
opacity: 0;
pointer-events: none;
transform: translate(-50%, -10px) scale(.98);
transition: opacity .2s ease, transform .2s ease, border-color .2s ease;
}
.connection-notice.is-visible {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
.connection-notice.is-restored {
border-color: color-mix(in srgb, var(--success) 58%, var(--border-strong));
background: color-mix(in srgb, var(--surface-strong, #11161e) 94%, var(--success));
}
.connection-notice__signal {
display: inline-flex;
width: 26px;
height: 26px;
align-items: center;
justify-content: center;
flex: 0 0 auto;
gap: 3px;
border-radius: 50%;
background: color-mix(in srgb, var(--danger) 18%, transparent);
}
.connection-notice.is-restored .connection-notice__signal {
background: color-mix(in srgb, var(--success) 18%, transparent);
}
.connection-notice__signal span {
width: 3px;
height: 10px;
border-radius: 3px;
background: var(--danger);
animation: connection-pulse .9s ease-in-out infinite;
}
.connection-notice__signal span:nth-child(2) {
animation-delay: .12s;
}
.connection-notice__signal span:nth-child(3) {
animation-delay: .24s;
}
.connection-notice.is-restored .connection-notice__signal span {
background: var(--success);
animation: none;
}
.connection-notice__content {
display: grid;
min-width: 0;
gap: 2px;
}
.connection-notice__content strong {
color: var(--text);
font-size: .84rem;
}
.connection-notice__content>span {
color: var(--muted);
font-size: .76rem;
line-height: 1.35;
}
@keyframes connection-pulse {
0%, 100% { transform: scaleY(.45); opacity: .48; }
50% { transform: scaleY(1); opacity: 1; }
}
.editor-layout {
position: relative;
display: grid;
@@ -369,6 +459,7 @@ textarea:focus {
}
.editor-panel {
position: relative;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
min-width: 0;
@@ -4412,6 +4503,12 @@ dialog::backdrop {
}
@media (max-width: 760px) {
.connection-notice {
top: 76px;
width: calc(100% - 20px);
padding: 10px 12px;
}
.pad-page .editor-toolbar {
grid-template-columns: minmax(0, 1fr);
}
@@ -4457,6 +4554,14 @@ dialog::backdrop {
}
}
@media (prefers-reduced-motion: reduce) {
.connection-notice,
.connection-notice__signal span {
animation: none;
transition: none;
}
}
@media (max-width: 720px) {
.pad-page #save-state {
display: none;
+6
View File
@@ -116,6 +116,12 @@
<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>
</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 class="editor-column">
<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]));
}
const emoji = EMOJI_SHORTCODES;
let markdownFiles = new Map();
let markdownFileRoutes = new Map();
function attachmentRoute(value) {
try {
const path = new URL(String(value || ""), location.origin).pathname;
return /^\/f\/[^/]+\/[^/]+$/.test(path) ? path : null;
} catch {
return null;
}
}
function safeUrl(value) {
const raw = String(value || "").trim();
let raw = String(value || "").trim();
if (!raw || raw.startsWith("//")) return "#";
if (raw.startsWith("#")) return escapeHtml(raw);
const route = attachmentRoute(raw);
if (route && markdownFileRoutes.has(route)) raw = markdownFileRoutes.get(route);
try {
const url = new URL(raw, location.origin);
if (url.protocol === "mailto:") return escapeHtml(url.href);
@@ -27,16 +42,18 @@ function safeUrl(value) {
}
}
const emoji = EMOJI_SHORTCODES;
let markdownFiles = new Map();
export function setMarkdownFiles(files) {
markdownFiles = new Map((Array.isArray(files) ? files : [])
const normalized = (Array.isArray(files) ? files : [])
.filter(file => file && file.filename && file.url)
.map(file => [String(file.filename), {
.map(file => ({
filename: String(file.filename),
url: String(file.url),
mimeType: String(file.mime_type || ""),
}]));
}));
markdownFiles = new Map(normalized.map(file => [file.filename, file]));
markdownFileRoutes = new Map(normalized
.map(file => [attachmentRoute(file.url), file.url])
.filter(([route]) => route));
}
export function unresolvedMarkdownFileAliases(value) {
+88 -3
View File
@@ -25,14 +25,14 @@ import { toast } from "@rustpad/toast";
export function startNoteEditor(adapter) {
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread");
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"), connectionNotice = document.querySelector("#connection-notice"), connectionNoticeTitle = document.querySelector("#connection-notice-title"), connectionNoticeMessage = document.querySelector("#connection-notice-message");
let unreadChat = 0;
const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle");
const shareToken = new URLSearchParams(location.search).get("share");
const notePreferenceKey = name => `rustpad:${name}:${location.pathname}`;
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "";
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false;
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
const singlePaneQuery = window.matchMedia("(max-width: 760px)");
let compactView = uiState.view === "preview" ? "preview" : "edit";
@@ -149,6 +149,38 @@ export function startNoteEditor(adapter) {
function clearUnread() { unreadChat = 0; chatUnread.hidden = true; chatUnread.textContent = ""; if (mobileChatUnread) { mobileChatUnread.hidden = true; mobileChatUnread.textContent = ""; } document.title = document.title.replace(/^● /, ""); }
function setStatus(kind, text) { const className = `status__dot${kind ? ` is-${kind}` : ""}`; document.querySelector("#status-dot").className = className; document.querySelector("#status-text").textContent = text; const mobileDot = document.querySelector("#mobile-status-dot"); const mobileText = document.querySelector("#mobile-status-text"); if (mobileDot) mobileDot.className = className; if (mobileText) mobileText.textContent = text; }
function showConnectionNotice(title, message, restored = false) {
clearTimeout(connectionNoticeTimer);
connectionNoticeTitle.textContent = title;
connectionNoticeMessage.textContent = message;
connectionNotice.hidden = false;
connectionNotice.classList.toggle("is-restored", restored);
requestAnimationFrame(() => connectionNotice.classList.add("is-visible"));
if (restored) connectionNoticeTimer = window.setTimeout(() => {
connectionNotice.classList.remove("is-visible", "is-restored");
connectionNoticeTimer = window.setTimeout(() => { connectionNotice.hidden = true; }, 220);
}, 1800);
}
function hideConnectionNotice() {
clearTimeout(connectionNoticeTimer);
connectionNotice.classList.remove("is-visible", "is-restored");
connectionNotice.hidden = true;
}
function handleSocketStatus(status, details = {}) {
if (status === "online") {
setStatus("online", "Connected");
if (connectionWasInterrupted || details.restored) showConnectionNotice("Connection restored", "Live editing is active again.", true);
connectionWasInterrupted = false;
return;
}
if (status === "reconnecting") {
connectionWasInterrupted = true;
setStatus("offline", "Reconnecting…");
showConnectionNotice("Connection interrupted", details.message || "Trying to reconnect automatically.");
return;
}
setStatus(null, "Connecting…");
}
function updateAddressLabel() { document.querySelector(adapter.addressSelector).textContent = `${location.pathname}${location.search}`; }
async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<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 { } }
@@ -619,7 +651,51 @@ export function startNoteEditor(adapter) {
onFilesChanged: files => updateMarkdownFiles(files, { rerender: true }),
});
refreshFilesForAliases = () => loadFiles();
function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: null, guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { resourceUnlocked = true; if (passwordDialog.open) passwordDialog.close(); const readOnly = m.access_level === "read_only"; editor.readOnly = readOnly; accessLevel.textContent = readOnly ? "Access: read only" : "Access: full"; applyRemote(m.content, m.owner_map); if (!readOnly) editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { const friendly = /read-only access/i.test(m) ? "This note is read only. Enter the password or ask the owner to grant write access." : m; document.querySelector("#password-error").textContent = friendly; if (/read-only access/i.test(m)) { toast(friendly); accessLevel.textContent = "Access: read only"; editor.readOnly = true; return; } if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); }
function connect() {
socket?.stop();
socket = adapter.createSocket({
password,
accessToken,
nickname,
color: currentUserColor() || null,
sessionToken: null,
guestId: getGuestId(),
onStatus: handleSocketStatus,
onAuthenticated: message => {
resourceUnlocked = true;
if (passwordDialog.open) passwordDialog.close();
const readOnly = message.access_level === "read_only";
editor.readOnly = readOnly;
accessLevel.textContent = readOnly ? "Access: read only" : "Access: full";
applyRemote(message.content, message.owner_map);
if (!readOnly) editor.focus();
},
onDocument: message => {
applyRemote(message.content, message.owner_map);
document.querySelector("#save-state").textContent = `${message.author ? `${message.author} · ` : ""}${new Date(message.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`;
},
onPresence: updatePresence,
onLatency: updateLatency,
onChat: appendChatMessage,
onError: message => {
hideConnectionNotice();
const friendly = /read-only access/i.test(message) ? "This note is read only. Enter the password or ask the owner to grant write access." : message;
document.querySelector("#password-error").textContent = friendly;
if (/read-only access/i.test(message)) {
toast(friendly);
accessLevel.textContent = "Access: read only";
editor.readOnly = true;
return;
}
if (/nickname|session|account/i.test(message)) {
if (!identityDialog.open) identityDialog.showModal();
} else if (info?.protected && !passwordDialog.open) {
passwordDialog.showModal();
}
},
});
socket.connect();
}
bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken && !getAuthToken()) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
async function showSystemNotFound() {
@@ -698,6 +774,15 @@ export function startNoteEditor(adapter) {
compactLayoutQuery.addEventListener("change", event => {
if (!event.matches) setHeaderMenuOpen(false);
});
const mobileEditorOptions = document.querySelector("#mobile-editor-options");
document.addEventListener("pointerdown", event => {
if (mobileEditorOptions?.open && !event.target.closest("#mobile-editor-options")) {
mobileEditorOptions.open = false;
}
}, { passive: true });
document.addEventListener("keydown", event => {
if (event.key === "Escape" && mobileEditorOptions?.open) mobileEditorOptions.open = false;
});
modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); });
lineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("line-numbers"), lineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); scheduleEditorSettingsSave({ personal: true }); });
previewLineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("preview-line-numbers"), previewLineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); alignPreviewLineNumbers(preview); scheduleEditorSettingsSave({ personal: true }); });
+11 -4
View File
@@ -11,7 +11,7 @@ import { api, uploadWithProgress } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { prepareImageFile } from "@rustpad/image-upload";
import { askConfirm } from "@rustpad/modal";
import { safeAppUrl } from "@rustpad/security";
import { safeAppUrl, safePublicUrl } from "@rustpad/security";
import { createUploadToast } from "@rustpad/toast";
function escapeHtml(value) {
@@ -40,6 +40,13 @@ function markdownCode(url, label, mimeType) {
return String(mimeType || "").startsWith("image/") ? `![${label}](${url})` : `[${label}](${url})`;
}
function safeAttachmentUrl(value) {
const raw = String(value || "").trim();
return raw.startsWith("/")
? safeAppUrl(raw)
: safePublicUrl(raw, { allowMailto: false });
}
export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, canUpload, toast, onFilesChanged = () => {} }) {
const dialog = document.querySelector("#files-dialog");
const list = document.querySelector("#files-list");
@@ -143,13 +150,13 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
if (showButton) {
const panel = showButton.closest(".file-row").querySelector(".file-code");
const output = panel.querySelector("textarea");
const absolute = new URL(safeAppUrl(showButton.dataset.url), location.origin).href;
const safeUrl = safeAttachmentUrl(showButton.dataset.url);
const absolute = new URL(safeUrl, location.origin).href;
let text = absolute;
if (showButton.dataset.showFileCode === "alias") {
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime);
} else if (showButton.dataset.showFileCode === "markdown") {
const relative = safeAppUrl(showButton.dataset.url);
text = markdownCode(relative, showButton.dataset.name, showButton.dataset.mime);
text = markdownCode(safeUrl, showButton.dataset.name, showButton.dataset.mime);
}
output.value = text; panel.hidden = false; output.focus(); output.select(); return;
}
+145 -22
View File
@@ -9,33 +9,83 @@
import { logError, logInfo, logWarn } from "@rustpad/logger";
const HEARTBEAT_INTERVAL_MS = 10000;
const HEARTBEAT_TIMEOUT_MS = 30000;
const MAX_RECONNECT_DELAY_MS = 12000;
class RoomSocket {
constructor(options) {
Object.assign(this, options);
this.socket = null;
this.timer = null;
this.reconnectTimer = null;
this.pingTimer = null;
this.closed = false;
this.stopped = false;
this.intentionalClose = false;
this.reconnectAttempt = 0;
this.pendingPings = new Map();
this.handleOnline = () => this.reconnectNow("Network connection restored.");
this.handleOffline = () => this.handleNetworkOffline();
this.handleVisibility = () => this.checkHeartbeat();
window.addEventListener("online", this.handleOnline);
window.addEventListener("offline", this.handleOffline);
document.addEventListener("visibilitychange", this.handleVisibility);
}
get url() { throw new Error("Socket URL not implemented"); }
get kind() { return "room"; }
connect() {
clearTimeout(this.timer);
clearTimeout(this.reconnectTimer);
clearInterval(this.pingTimer);
this.closed = false;
this.onStatus?.("connecting");
this.socket = new WebSocket(this.url);
this.socket.addEventListener("open", () => {
logInfo("websocket.open", { kind: this.kind });
this.send({ type: "authenticate", password: this.password || null, access_token: this.accessToken || null, nickname: this.nickname || null, guest_id: this.guestId || null, color: this.color || null });
if (this.stopped) return;
if (!navigator.onLine) {
this.scheduleReconnect("Your device is offline.");
return;
}
this.intentionalClose = false;
this.onStatus?.(this.reconnectAttempt ? "reconnecting" : "connecting", {
attempt: this.reconnectAttempt,
message: this.reconnectAttempt ? "Re-establishing the live connection." : "Opening the live connection.",
});
this.socket.addEventListener("message", event => {
let socket;
try {
socket = new WebSocket(this.url);
} catch (error) {
logError("websocket.create", error, { kind: this.kind });
this.scheduleReconnect("The live connection could not be opened.");
return;
}
this.socket = socket;
socket.addEventListener("open", () => {
if (socket !== this.socket || this.stopped) return;
logInfo("websocket.open", { kind: this.kind });
this.send({
type: "authenticate",
password: this.password || null,
access_token: this.accessToken || null,
nickname: this.nickname || null,
guest_id: this.guestId || null,
color: this.color || null,
});
});
socket.addEventListener("message", event => {
if (socket !== this.socket || this.stopped) return;
let message;
try { message = JSON.parse(event.data); } catch { return; }
if (message.type === "error") { this.onError?.(message.message); this.closed = true; this.socket.close(); return; }
if (message.type === "error") {
this.intentionalClose = true;
this.onError?.(message.message);
socket.close();
return;
}
if (message.type === "authenticated") {
this.onStatus?.("online");
const restored = this.reconnectAttempt > 0;
this.reconnectAttempt = 0;
this.onStatus?.("online", { restored });
this.onAuthenticated?.(message);
this.startPing();
return;
@@ -51,43 +101,116 @@ class RoomSocket {
}
}
});
this.socket.addEventListener("close", event => {
socket.addEventListener("close", event => {
if (socket !== this.socket) return;
clearInterval(this.pingTimer);
this.pendingPings.clear();
this.onPresence?.([]);
this.onLatency?.(null);
logWarn("websocket.close", { kind: this.kind, code: event.code, reason: event.reason || "", intentional: this.closed });
if (!this.closed) { this.onStatus?.("offline"); this.timer = setTimeout(() => this.connect(), 1500); }
logWarn("websocket.close", {
kind: this.kind,
code: event.code,
reason: event.reason || "",
intentional: this.intentionalClose || this.stopped,
});
if (!this.intentionalClose && !this.stopped) {
this.scheduleReconnect(this.closeMessage(event));
}
});
this.socket.addEventListener("error", event => {
socket.addEventListener("error", event => {
if (socket !== this.socket || this.stopped) return;
logError("websocket.error", event, { kind: this.kind });
this.onError?.("Failed to connect to the WebSocket server");
this.socket.close();
if (socket.readyState !== WebSocket.CLOSING && socket.readyState !== WebSocket.CLOSED) {
socket.close();
}
});
}
closeMessage(event) {
if (!navigator.onLine) return "Your device is offline.";
if (event.reason === "heartbeat timeout") return "The connection stopped responding after the tab was inactive.";
return "The server connection was interrupted.";
}
scheduleReconnect(message) {
if (this.stopped) return;
clearTimeout(this.reconnectTimer);
this.reconnectAttempt += 1;
const baseDelay = Math.min(MAX_RECONNECT_DELAY_MS, 750 * (2 ** Math.min(this.reconnectAttempt - 1, 4)));
const retryInMs = navigator.onLine ? baseDelay : 3000;
this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt, message, retryInMs });
this.reconnectTimer = window.setTimeout(() => this.connect(), retryInMs);
}
reconnectNow(message) {
if (this.stopped || this.socket?.readyState === WebSocket.OPEN || this.socket?.readyState === WebSocket.CONNECTING) return;
clearTimeout(this.reconnectTimer);
this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt, message, retryInMs: 0 });
this.connect();
}
handleNetworkOffline() {
if (this.stopped) return;
this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt + 1, message: "Your device is offline.", retryInMs: 3000 });
if (this.socket?.readyState === WebSocket.OPEN || this.socket?.readyState === WebSocket.CONNECTING) {
this.socket.close();
} else {
this.scheduleReconnect("Your device is offline.");
}
}
checkHeartbeat() {
if (this.stopped || this.socket?.readyState !== WebSocket.OPEN) return;
const now = performance.now();
const expired = [...this.pendingPings.values()].some(started => now - started >= HEARTBEAT_TIMEOUT_MS);
if (expired) {
logWarn("websocket.heartbeat_timeout", { kind: this.kind });
this.socket.close(4000, "heartbeat timeout");
}
}
startPing() {
clearInterval(this.pingTimer);
const ping = () => {
this.checkHeartbeat();
if (this.socket?.readyState !== WebSocket.OPEN) return;
const nonce = Date.now();
this.pendingPings.set(nonce, performance.now());
for (const key of this.pendingPings.keys()) if (key < nonce - 30000) this.pendingPings.delete(key);
this.send({ type: "ping", nonce });
};
ping();
this.pingTimer = setInterval(ping, 10000);
this.pingTimer = window.setInterval(ping, HEARTBEAT_INTERVAL_MS);
}
send(message) { if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message)); }
send(message) {
if (this.socket?.readyState !== WebSocket.OPEN) return false;
this.socket.send(JSON.stringify(message));
return true;
}
update(content, ownerMap = "[]") { this.send({ type: "update", content, owner_map: ownerMap }); }
chat(text) { this.send({ type: "chat", text }); }
setColor(color) { this.color = color || null; this.send({ type: "set_color", color: this.color }); }
stop() { this.closed = true; clearTimeout(this.timer); clearInterval(this.pingTimer); this.socket?.close(); }
stop() {
this.stopped = true;
this.intentionalClose = true;
clearTimeout(this.reconnectTimer);
clearInterval(this.pingTimer);
window.removeEventListener("online", this.handleOnline);
window.removeEventListener("offline", this.handleOffline);
document.removeEventListener("visibilitychange", this.handleVisibility);
this.socket?.close();
}
}
export class NoteSocket extends RoomSocket {
get kind() { return "note"; }
get url() { const p = location.protocol === "https:" ? "wss:" : "ws:"; return `${p}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`; }
}
export class PadSocket extends RoomSocket {
get kind() { return "pad"; }
get url() { const p = location.protocol === "https:" ? "wss:" : "ws:"; return `${p}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`; }
+2
View File
@@ -9,6 +9,8 @@ database:
static_dir: /opt/rustpad/static
files_dir: /var/lib/rustpad/files
# Optional; bare domains are normalized to HTTPS. Serve or proxy /f/* there.
# files_public_url: files.note.example.org
storage_driver: local
upload_max_size_mb: 20
asset_cache_max_age_seconds: 600