This commit is contained in:
Mateusz Gruszczyński committed 2026-10-26 09:30:10 +01:00
1 parent b85201889e
commit b035648b81
21 files changed
+317 -72

No files matched your search

Generated
+1 -1
View File
@@ -2674,7 +2674,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.2.92"
version = "0.2.93"
dependencies = [
"argon2",
"aws-config",
+2 -2
View File
@@ -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"] }
+2 -1
View File
@@ -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
+16 -1
View File
@@ -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=<seconds>`.
Executable → Regular
View File
File mode changed.
+7
View File
@@ -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}"
+5 -21
View File
@@ -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";
}
}
+15 -20
View File
@@ -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
backend rustpad_app
mode http
http-request set-header X-Forwarded-Proto https
server rustpad-app rustpad:3000 check
Executable → Regular
View File
File mode changed.
Executable → Regular
View File
File mode changed.
Executable → Regular
View File
File mode changed.
+19 -5
View File
@@ -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(
+20
View File
@@ -49,6 +49,7 @@ impl AuthorizationType {
pub struct Config {
pub host: IpAddr,
pub port: u16,
pub files_port: Option<u16>,
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<u16, Box<dyn std::error::Error>> {
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,
+1
View File
@@ -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",
+35 -5
View File
@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
}
});
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<bool>) {
loop {
if *receiver.borrow_and_update() || receiver.changed().await.is_err() {
break;
}
}
}
fn print_startup_credential() {
eprintln!("\n{}\n", startup_credential());
}
+160
View File
@@ -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)
);
}
}
+15 -1
View File
@@ -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());
}
+7 -13
View File
@@ -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) {
+1 -1
View File
@@ -78,7 +78,7 @@
<div id="footer-account-slot" class="home-footer__account-slot" data-state="loading" aria-busy="true">
<div id="footer-account-pending" class="home-footer__account-pending" role="status">
<span class="home-footer__loading-line" aria-hidden="true"></span>
<span class="home-footer__loading-action" aria-hidden="true"></span>
<span class="home-footer__loading-line home-footer__loading-line--short" aria-hidden="true"></span>
<span class="sr-only" data-i18n="home.account.loading">Checking your account...</span>
</div>
<a id="footer-account-retry" href="/" class="footer-action" data-i18n="home.account.retry" hidden>Reload
+7
View File
@@ -8,3 +8,10 @@
6. Run `systemctl daemon-reload && systemctl enable --now rustpad`.
Environment variables and an optional `.env` file override the YAML values. This allows secrets to be supplied by the service manager without editing the main configuration file.
To serve attachments from a separate domain, set `files_public_url` in `rustpad.yaml`.
The attachment-only listener uses `files_port: 3001` by default (same `app.host`).
Point the file subdomain's reverse proxy at that port; no path-based access
restrictions are needed. `/health` (and `/healthz`) returns `ok` on the file listener
for health monitoring. Without `files_public_url`, files use `app.port` and
no separate listener starts.
+4 -1
View File
@@ -9,8 +9,11 @@ database:
static_dir: /opt/rustpad/static
files_dir: /var/lib/rustpad/files
# Optional; bare domains are normalized to HTTPS. Serve or proxy /f/* there.
# Optional: when set, a second listener serves only /f/* attachments.
# The separate file listener defaults to port 3001 (and shares app.host).
# When unset, files stay on app.port and files_port is ignored.
# files_public_url: files.note.example.org
# files_port: 3001
storage_driver: local
upload_max_size_mb: 20
guest_upload_enabled: false