From b035648b81142dee9a196c403966feb1d4905fb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 26 Oct 2026 09:30:10 +0100 Subject: [PATCH] v0.2.93 --- Cargo.lock | 2 +- Cargo.toml | 4 +- Dockerfile | 3 +- README.md | 17 ++- dev.sh | 0 docker-compose.files.yml | 7 ++ docker/example_files_vhost.conf | 28 ++--- docker/example_hahproxy_files.conf | 35 +++---- scripts/load-speed.sh | 0 scripts/update_browser_libs.py | 0 scripts/update_lib_hashes.py | 0 src/app/mod.rs | 24 ++++- src/config/mod.rs | 20 ++++ src/config/values.rs | 1 + src/main.rs | 40 +++++++- src/tests/app.rs | 160 +++++++++++++++++++++++++++++ src/tests/config.rs | 16 ++- static/css/styles.css | 20 ++-- static/home.html | 2 +- systemd/README.md | 7 ++ systemd/rustpad.yaml | 5 +- 21 files changed, 318 insertions(+), 73 deletions(-) mode change 100755 => 100644 dev.sh create mode 100644 docker-compose.files.yml mode change 100755 => 100644 scripts/load-speed.sh mode change 100755 => 100644 scripts/update_browser_libs.py mode change 100755 => 100644 scripts/update_lib_hashes.py diff --git a/Cargo.lock b/Cargo.lock index 30082c5..fa67799 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2674,7 +2674,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.92" +version = "0.2.93" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 93d1b1f..0330fe4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.92" +version = "0.2.93" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" @@ -27,7 +27,7 @@ serde_json = "1" slug = "0.1.6" sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "any", "sqlite", "postgres", "mysql", "chrono", "migrate"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "signal"] } -tower = "0.5.3" +tower = { version = "0.5.3", features = ["util"] } tower-http = { version = "0.7", features = ["fs", "trace", "set-header", "compression-br", "compression-gzip"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/Dockerfile b/Dockerfile index 1ff8fd8..1b889ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,13 +37,14 @@ COPY --from=builder /app/migrations ./migrations USER rustpad ENV APP_HOST=0.0.0.0 \ APP_PORT=3000 \ + FILES_PORT=3001 \ DATABASE_URL=sqlite:///data/db/rustpad.db?mode=rwc \ DATABASE_MAX_CONNECTIONS=8 \ STATIC_DIR=/app/static \ FILES_DIR=/data/files \ RUST_LOG=rustpad=info,tower_http=info -EXPOSE 3000 +EXPOSE 3000 3001 VOLUME ["/data"] HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl --fail --silent http://127.0.0.1:3000/health || exit 1 diff --git a/README.md b/README.md index e55d996..e74608d 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,22 @@ RustPad supports two interchangeable attachment backends selected in `.env`: 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. +RustPad automatically selects one of two file-serving modes: + +- **Single domain** (default): leave `FILES_PUBLIC_URL` unset. `/f/{token}/{filename}` is served on `APP_PORT` (default 3000), with application-relative attachment links. `FILES_PORT` is ignored and no second listener starts. +- **Dedicated file domain**: set `FILES_PUBLIC_URL=files.note.example.com`. RustPad generates attachment links on that origin and starts a separate attachment-only router on `FILES_PORT` (default 3001, same `APP_HOST`). The application router on `APP_PORT` no longer serves `/f/...`. The file router serves `GET`/`HEAD /f/{token}/{filename}` and the health checks `GET`/`HEAD /health` and `/healthz` (both return HTTP 200 and `ok` for GET); all other paths return an empty 404 response. The original link-based file access, local/S3 storage, and stored database paths remain unchanged. + +Bare domains are normalized to HTTPS; `http://` is allowed for local testing. For a dedicated origin, point its reverse proxy directly at `APP_HOST:FILES_PORT` (e.g. `rustpad:3001` from another container in the same network). There is no need for proxy path deny-lists. For monitoring, request `http://rustpad:3001/health` (or `/healthz`) from inside the network. The application origin continues to point at `APP_HOST:APP_PORT`. + +**Upgrading an existing file-subdomain deployment:** change the subdomain's proxy upstream from port 3000 to port 3001 before/when installing this release. Existing attachment URLs and database records do not change. Without the proxy change, old attachment requests will return 404 at the application port. `FILES_PORT` must be between 1 and 65535 and differ from `APP_PORT` when the separate domain is enabled. Both listeners must be reachable from their respective proxies. + +With Docker Compose, containers on the same network can proxy directly to `app:3001` without additional published ports. If your reverse proxy runs on the host/outside the Compose network, publish the file listener explicitly: + +```sh +docker compose -f docker-compose.yml -f docker-compose.files.yml up -d --build +``` + +This optional override maps `${RUSTPAD_FILES_PORT:-3001}` on the Docker host to `${FILES_PORT:-3001}` in the container. Do not use it if you do not need a host-published attachment port. Example reverse-proxy configurations are in `docker/example_files_vhost.conf` and `docker/example_hahproxy_files.conf`. Set `ASSET_CACHE_MAX_AGE_SECONDS=0` or `FILE_CACHE_MAX_AGE_SECONDS=0` to disable browser caching. RustPad then sends `Cache-Control: no-cache, no-store, must-revalidate`; positive values use `public, max-age=`. diff --git a/dev.sh b/dev.sh old mode 100755 new mode 100644 diff --git a/docker-compose.files.yml b/docker-compose.files.yml new file mode 100644 index 0000000..aa4b85e --- /dev/null +++ b/docker-compose.files.yml @@ -0,0 +1,7 @@ +# Optional: publish the attachment-only listener for a reverse proxy outside +# the Docker network. Use only with FILES_PUBLIC_URL configured. +# docker compose -f docker-compose.yml -f docker-compose.files.yml up -d --build +services: + app: + ports: + - "${RUSTPAD_FILES_PORT:-3001}:${FILES_PORT:-3001}" diff --git a/docker/example_files_vhost.conf b/docker/example_files_vhost.conf index 37b15ef..0945713 100644 --- a/docker/example_files_vhost.conf +++ b/docker/example_files_vhost.conf @@ -1,29 +1,13 @@ +# Set FILES_PUBLIC_URL=files.note.example.com in RustPad. +# RustPad listens on FILES_PORT=3001 with attachment routes only. server { listen 443 ssl; server_name files.note.example.com; - location ^~ /f/ { - proxy_pass http://rustpad:3000; - + # Add your ssl_certificate and ssl_certificate_key configuration here. + location / { + proxy_pass http://rustpad:3001; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; - proxy_set_header Cookie ""; - proxy_set_header Authorization ""; - - proxy_hide_header Set-Cookie; - - proxy_intercept_errors on; - error_page 404 = @file_not_found; } - - location @file_not_found { - internal; - default_type text/plain; - return 404 "Not Found\n"; - } - - location / { - default_type text/plain; - return 404 "Not Found\n"; - } -} \ No newline at end of file +} diff --git a/docker/example_hahproxy_files.conf b/docker/example_hahproxy_files.conf index 398b7ae..0a09863 100644 --- a/docker/example_hahproxy_files.conf +++ b/docker/example_hahproxy_files.conf @@ -1,26 +1,21 @@ -frontend http_front +# Merge with your existing TLS frontend; route by hostname, not URL path. +# Set FILES_PUBLIC_URL=files.note.example.com in RustPad. +# The example assumes the main site shares the same 443 frontend. +frontend https_front bind *:443 ssl crt /etc/haproxy/certs/example.pem mode http + acl files_host hdr(host) -i files.note.example.com + use_backend rustpad_files if files_host + default_backend rustpad_app - acl rustpad_path path_beg /f/ - - use_backend rustpad_backend if rustpad_path - - http-request return status 404 \ - content-type "text/plain" \ - string "Not Found" unless rustpad_path - - -backend rustpad_backend +backend rustpad_files mode http - + option httpchk GET /health + http-check expect status 200 http-request set-header X-Forwarded-Proto https - http-request del-header Cookie - http-request del-header Authorization + server rustpad-files rustpad:3001 check - http-response del-header Set-Cookie - http-response return status 404 \ - content-type "text/plain" \ - string "Not Found" if { status 404 } - - server rustpad rustpad:3000 check \ No newline at end of file +backend rustpad_app + mode http + http-request set-header X-Forwarded-Proto https + server rustpad-app rustpad:3000 check diff --git a/scripts/load-speed.sh b/scripts/load-speed.sh old mode 100755 new mode 100644 diff --git a/scripts/update_browser_libs.py b/scripts/update_browser_libs.py old mode 100755 new mode 100644 diff --git a/scripts/update_lib_hashes.py b/scripts/update_lib_hashes.py old mode 100755 new mode 100644 diff --git a/src/app/mod.rs b/src/app/mod.rs index 3ce8f7c..afeb60a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -50,6 +50,7 @@ pub fn router( upload_body_limit_bytes: usize, asset_cache_max_age_seconds: u64, http_compression: bool, + serve_files_on_application: bool, ) -> Router { let asset_version = state.asset_version.clone(); let asset_not_found = service_fn(move |_request| { @@ -71,7 +72,7 @@ pub fn router( HeaderValue::from_str(&crate::cache::cache_control(asset_cache_max_age_seconds)) .expect("valid asset cache-control header"); - Router::new() + let mut app = Router::new() .route("/", get(home)) .route("/auth/confirm/{token}", get(home)) .route("/auth/reset-password/{token}", get(home)) @@ -92,7 +93,6 @@ pub fn router( .route("/icons/favicon.svg", get(favicon_svg)) .route("/icons/favicon-32.png", get(favicon_png)) .route("/icons/apple-touch-icon.png", get(apple_touch_icon)) - .route("/f/{token}/{filename}", get(api::download_file)) .route("/api/auth/identity", post(auth::identity)) .route( "/api/security/csrf", @@ -256,12 +256,16 @@ pub fn router( .service(ServeDir::new(static_dir).not_found_service(asset_not_found)), ) .fallback(not_found) - .method_not_allowed_fallback(method_not_allowed) - .layer(DefaultBodyLimit::max(upload_body_limit_bytes)) + .method_not_allowed_fallback(method_not_allowed); + + if serve_files_on_application { + app = app.route("/f/{token}/{filename}", get(api::download_file)); + } + + app.layer(DefaultBodyLimit::max(upload_body_limit_bytes)) .layer(TraceLayer::new_for_http().make_span_with(PathOnlyMakeSpan)) .layer(middleware::from_fn(require_csrf_token)) .layer(middleware::from_fn(apply_response_header_policy)) - // Negotiate HTTP compression only when enabled; WebSocket frames are unaffected. .layer( CompressionLayer::new() .br(http_compression) @@ -270,6 +274,16 @@ pub fn router( .with_state(state) } +pub fn files_router(state: SharedState) -> Router { + Router::new() + .route("/health", get(health)) + .route("/f/{token}/{filename}", get(api::download_file)) + .fallback(|| async { StatusCode::NOT_FOUND }) + .layer(TraceLayer::new_for_http().make_span_with(PathOnlyMakeSpan)) + .layer(middleware::from_fn(apply_response_header_policy)) + .with_state(state) +} + async fn language_catalog() -> Response { let mut response = Json(crate::i18n::metadata()).into_response(); response.headers_mut().insert( diff --git a/src/config/mod.rs b/src/config/mod.rs index cfe2216..c141637 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -49,6 +49,7 @@ impl AuthorizationType { pub struct Config { pub host: IpAddr, pub port: u16, + pub files_port: Option, pub database_url: String, pub database_max_connections: u32, pub static_dir: String, @@ -91,6 +92,11 @@ impl Config { 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 files_port = if files_public_url.is_some() { + Some(parse_files_port(&values.get("FILES_PORT", "3001"), port)?) + } else { + None + }; let storage = match values .get("STORAGE_DRIVER", "local") @@ -163,6 +169,7 @@ impl Config { let config = Self { host, port, + files_port, database_url: values.get("DATABASE_URL", "sqlite:///data/db/rustpad.db?mode=rwc"), database_max_connections, static_dir: values.get("STATIC_DIR", "static"), @@ -228,6 +235,19 @@ impl Config { } } +fn parse_files_port(raw: &str, application_port: u16) -> Result> { + let files_port: u16 = raw + .parse() + .map_err(|_| "FILES_PORT must be an integer between 1 and 65535")?; + if files_port == 0 { + return Err("FILES_PORT must be between 1 and 65535".into()); + } + if files_port == application_port { + return Err("FILES_PORT must differ from APP_PORT when FILES_PUBLIC_URL is set".into()); + } + Ok(files_port) +} + fn multipart_body_limit_bytes( user_limit_bytes: usize, guest_upload_enabled: bool, diff --git a/src/config/values.rs b/src/config/values.rs index da77f6e..63cd4fd 100644 --- a/src/config/values.rs +++ b/src/config/values.rs @@ -12,6 +12,7 @@ use std::{collections::HashMap, env, path::Path}; const KNOWN_CONFIG_KEYS: &[&str] = &[ "APP_HOST", "APP_PORT", + "FILES_PORT", "DATABASE_URL", "DATABASE_MAX_CONNECTIONS", "STATIC_DIR", diff --git a/src/main.rs b/src/main.rs index c5a05d7..593abaa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,7 +30,7 @@ use std::{net::SocketAddr, path::PathBuf, sync::Arc}; use config::Config; use database::{Database, DatabaseKind}; use state::AppState; -use tokio::net::TcpListener; +use tokio::{net::TcpListener, sync::watch}; use tracing::{info, warn}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -59,6 +59,7 @@ async fn main() -> Result<(), Box> { info!( host = %config.host, port = config.port, + files_port = ?config.files_port, database_kind = %database_kind_label(&config.database_url), database_max_connections = config.database_max_connections, static_dir = %config.static_dir, @@ -170,23 +171,52 @@ async fn main() -> Result<(), Box> { } }); let app = app::router( - state, + state.clone(), &config.static_dir, config.upload_body_limit_bytes(), config.asset_cache_max_age_seconds, config.http_compression, + config.files_port.is_none(), ); let address = SocketAddr::new(config.host, config.port); let listener = TcpListener::bind(address).await?; + let files_listener = if let Some(port) = config.files_port { + let files_address = SocketAddr::new(config.host, port); + let listener = TcpListener::bind(files_address).await?; + info!(%files_address, "attachment-only listener ready"); + Some(listener) + } else { + None + }; + + let (shutdown_tx, shutdown_rx) = watch::channel(false); + tokio::spawn(async move { + shutdown_signal().await; + let _ = shutdown_tx.send(true); + }); info!(%address, "RustPad is running"); - axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal()) - .await?; + let app_server = + axum::serve(listener, app).with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone())); + if let Some(files_listener) = files_listener { + let files_server = axum::serve(files_listener, app::files_router(state)) + .with_graceful_shutdown(wait_for_shutdown(shutdown_rx)); + tokio::try_join!(async { app_server.await }, async { files_server.await })?; + } else { + app_server.await?; + } info!("RustPad stopped cleanly"); Ok(()) } +async fn wait_for_shutdown(mut receiver: watch::Receiver) { + loop { + if *receiver.borrow_and_update() || receiver.changed().await.is_err() { + break; + } + } +} + fn print_startup_credential() { eprintln!("\n{}\n", startup_credential()); } diff --git a/src/tests/app.rs b/src/tests/app.rs index 46a7a8d..f37e913 100644 --- a/src/tests/app.rs +++ b/src/tests/app.rs @@ -119,3 +119,163 @@ fn application_policy_preserves_handler_headers() { ); assert!(headers.contains_key("permissions-policy")); } + +async fn test_state() -> crate::state::SharedState { + use crate::{database::Database, state::AppState, storage::Storage}; + use std::{path::PathBuf, sync::Arc}; + + let db = Database::connect("sqlite::memory:", 1).await.unwrap(); + crate::run_migrations(&db).await.unwrap(); + Arc::new(AppState::new( + db, + "test".to_owned(), + Storage::Local { + root: PathBuf::from("/tmp/rustpad-router-tests"), + }, + 20 * 1024 * 1024, + false, + 5 * 1024 * 1024, + 300, + None, + None, + false, + false, + false, + "warn".to_owned(), + 7, + 3, + 3, + None, + )) +} + +#[tokio::test] +async fn file_listener_never_exposes_application_routes() { + use super::files_router; + use axum::{ + Router, + body::{Body, to_bytes}, + http::{Method, Request, StatusCode}, + }; + use tower::ServiceExt; + + let app: Router = files_router(test_state().await); + for path in [ + "/", + "/api/auth/me", + "/assets/css/styles.css", + "/f", + "/robots.txt", + "/ws/demo/demo", + ] { + let response = app + .clone() + .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}"); + assert!(response.headers().get("content-type").is_none(), "{path}"); + assert!( + to_bytes(response.into_body(), 256) + .await + .unwrap() + .is_empty(), + "{path}" + ); + } + + for path in ["/health"] { + for method in [Method::GET, Method::HEAD] { + let response = app + .clone() + .oneshot( + Request::builder() + .method(method.clone()) + .uri(path) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{method} {path}"); + let bytes = to_bytes(response.into_body(), 256).await.unwrap(); + if method == Method::GET { + assert_eq!(bytes.as_ref(), b"ok", "{path}"); + } else { + assert!(bytes.is_empty(), "{path}"); + } + } + } + + for method in [Method::GET, Method::HEAD] { + let response = app + .clone() + .oneshot( + Request::builder() + .method(method.clone()) + .uri("/f/missing/photo.png") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{method}"); + } + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/f/missing/photo.png") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); +} + +#[tokio::test] +async fn application_file_routes_follow_the_selected_mode() { + use super::router; + use axum::{ + body::Body, + http::{Request, StatusCode, header}, + }; + use tower::ServiceExt; + + let state = test_state().await; + for (serve_files, expected_content_type) in [(false, "text/html"), (true, "application/json")] { + let app = router( + state.clone(), + "static", + 22 * 1024 * 1024, + 600, + false, + serve_files, + ); + for path in ["/", "/health"] { + let response = app + .clone() + .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{path}"); + } + let file = app + .oneshot( + Request::builder() + .uri("/f/missing/photo.png") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(file.status(), StatusCode::NOT_FOUND); + assert!( + file.headers()[header::CONTENT_TYPE] + .to_str() + .unwrap() + .starts_with(expected_content_type) + ); + } +} diff --git a/src/tests/config.rs b/src/tests/config.rs index 867d27c..7e649e6 100644 --- a/src/tests/config.rs +++ b/src/tests/config.rs @@ -7,7 +7,7 @@ * See LICENSE file in repository root for details. */ -use super::{megabytes_to_bytes, multipart_body_limit_bytes}; +use super::{megabytes_to_bytes, multipart_body_limit_bytes, parse_files_port}; #[test] fn converts_upload_megabytes_to_bytes() { @@ -34,3 +34,17 @@ fn multipart_limit_uses_larger_enabled_guest_limit() { 51 * 1024 * 1024 ); } + +#[test] +fn dedicated_file_port_defaults_to_3001_and_can_be_overridden() { + assert_eq!(parse_files_port("3001", 3000).unwrap(), 3001); + assert_eq!(parse_files_port("8081", 8080).unwrap(), 8081); +} + +#[test] +fn dedicated_file_port_must_not_conflict_with_the_application() { + assert!(parse_files_port("3000", 3000).is_err()); + assert!(parse_files_port("0", 3000).is_err()); + assert!(parse_files_port("65536", 3000).is_err()); + assert!(parse_files_port("not-a-port", 3000).is_err()); +} diff --git a/static/css/styles.css b/static/css/styles.css index 02c37ad..97205d1 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -4489,10 +4489,8 @@ dialog::backdrop { width: min(100%, 270px); pointer-events: none; opacity: 1; - transition: opacity .18s ease, visibility .18s; } -.home-footer__loading-action, .home-footer__loading-line { display: block; height: 13px; @@ -4503,17 +4501,18 @@ dialog::backdrop { animation: home-account-wait 1.4s ease-in-out infinite; } -.home-footer__loading-action { - flex: 0 0 82px; - height: 34px; - border-radius: 8px; +.home-footer__loading-line--short { + flex: 0 0 64px; animation-delay: .15s; } +.home-footer__account-slot:not([data-state=ready])>.home-footer__account { + display: none; +} + .home-footer__account-slot[data-state=error] .home-footer__account-pending, .home-footer__account-slot[data-state=ready] .home-footer__account-pending { - opacity: 0; - visibility: hidden; + display: none; } .home-footer__account-slot[data-state=ready]>.home-footer__account:not([hidden]) { @@ -4547,14 +4546,9 @@ dialog::backdrop { @media(prefers-reduced-motion:reduce) { .home-footer__account-slot[data-state=ready]>.home-footer__account:not([hidden]), - .home-footer__loading-action, .home-footer__loading-line { animation: none; } - - .home-footer__account-pending { - transition: none; - } } @media(max-width:600px) { diff --git a/static/home.html b/static/home.html index 9f63c90..b737fff 100644 --- a/static/home.html +++ b/static/home.html @@ -78,7 +78,7 @@