some changes
This commit is contained in:
@@ -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
@@ -2581,7 +2581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.2.7"
|
||||
version = "0.2.8"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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>`.
|
||||
|
||||
|
||||
@@ -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
@@ -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(¬e.content, &file.filename, &file.url);
|
||||
let attached = content_references_stored_file(
|
||||
¬e.content,
|
||||
&file.filename,
|
||||
&file.url,
|
||||
state.files_public_url.as_deref(),
|
||||
);
|
||||
if attached != file.is_attached {
|
||||
db::set_note_file_attached(&state.db, file.id, attached).await?;
|
||||
file.is_attached = attached;
|
||||
@@ -393,6 +451,10 @@ pub async fn note_files(
|
||||
};
|
||||
}
|
||||
file.created_at = db::normalize_timestamp(&file.created_at);
|
||||
file.url = crate::file_urls::public_file_url(
|
||||
state.files_public_url.as_deref(),
|
||||
&file.url,
|
||||
);
|
||||
}
|
||||
Ok(Json(files))
|
||||
}
|
||||
@@ -580,7 +642,7 @@ fn sanitize_filename(value: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_safe_inline_image_mime;
|
||||
use super::{content_references_stored_file, is_safe_inline_image_mime};
|
||||
|
||||
#[test]
|
||||
fn only_raster_images_are_inline() {
|
||||
@@ -590,4 +652,21 @@ mod tests {
|
||||
assert!(!is_safe_inline_image_mime("text/html"));
|
||||
assert!(!is_safe_inline_image_mime("application/xml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_references_survive_origin_changes() {
|
||||
let stored = "/f/token/image.png";
|
||||
assert!(content_references_stored_file(
|
||||
"",
|
||||
"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,
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 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"),
|
||||
|
||||
@@ -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
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -13,10 +13,25 @@ function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
|
||||
const emoji = EMOJI_SHORTCODES;
|
||||
let markdownFiles = new Map();
|
||||
let markdownFileRoutes = new Map();
|
||||
|
||||
function attachmentRoute(value) {
|
||||
try {
|
||||
const path = new URL(String(value || ""), location.origin).pathname;
|
||||
return /^\/f\/[^/]+\/[^/]+$/.test(path) ? path : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeUrl(value) {
|
||||
const raw = String(value || "").trim();
|
||||
let raw = String(value || "").trim();
|
||||
if (!raw || raw.startsWith("//")) return "#";
|
||||
if (raw.startsWith("#")) return escapeHtml(raw);
|
||||
const route = attachmentRoute(raw);
|
||||
if (route && markdownFileRoutes.has(route)) raw = markdownFileRoutes.get(route);
|
||||
try {
|
||||
const url = new URL(raw, location.origin);
|
||||
if (url.protocol === "mailto:") return escapeHtml(url.href);
|
||||
@@ -27,16 +42,18 @@ function safeUrl(value) {
|
||||
}
|
||||
}
|
||||
|
||||
const emoji = EMOJI_SHORTCODES;
|
||||
let markdownFiles = new Map();
|
||||
|
||||
export function setMarkdownFiles(files) {
|
||||
markdownFiles = new Map((Array.isArray(files) ? files : [])
|
||||
const normalized = (Array.isArray(files) ? files : [])
|
||||
.filter(file => file && file.filename && file.url)
|
||||
.map(file => [String(file.filename), {
|
||||
.map(file => ({
|
||||
filename: String(file.filename),
|
||||
url: String(file.url),
|
||||
mimeType: String(file.mime_type || ""),
|
||||
}]));
|
||||
}));
|
||||
markdownFiles = new Map(normalized.map(file => [file.filename, file]));
|
||||
markdownFileRoutes = new Map(normalized
|
||||
.map(file => [attachmentRoute(file.url), file.url])
|
||||
.filter(([route]) => route));
|
||||
}
|
||||
|
||||
export function unresolvedMarkdownFileAliases(value) {
|
||||
|
||||
@@ -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
@@ -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})`;
|
||||
}
|
||||
|
||||
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
@@ -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)}`; }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user