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]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.2.92" version = "0.2.93"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.2.92" version = "0.2.93"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
@@ -27,7 +27,7 @@ serde_json = "1"
slug = "0.1.6" slug = "0.1.6"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "any", "sqlite", "postgres", "mysql", "chrono", "migrate"] } 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"] } 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"] } tower-http = { version = "0.7", features = ["fs", "trace", "set-header", "compression-br", "compression-gzip"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+2 -1
View File
@@ -37,13 +37,14 @@ COPY --from=builder /app/migrations ./migrations
USER rustpad USER rustpad
ENV APP_HOST=0.0.0.0 \ ENV APP_HOST=0.0.0.0 \
APP_PORT=3000 \ APP_PORT=3000 \
FILES_PORT=3001 \
DATABASE_URL=sqlite:///data/db/rustpad.db?mode=rwc \ DATABASE_URL=sqlite:///data/db/rustpad.db?mode=rwc \
DATABASE_MAX_CONNECTIONS=8 \ DATABASE_MAX_CONNECTIONS=8 \
STATIC_DIR=/app/static \ STATIC_DIR=/app/static \
FILES_DIR=/data/files \ FILES_DIR=/data/files \
RUST_LOG=rustpad=info,tower_http=info RUST_LOG=rustpad=info,tower_http=info
EXPOSE 3000 EXPOSE 3000 3001
VOLUME ["/data"] VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl --fail --silent http://127.0.0.1:3000/health || exit 1 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. 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>`. 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 { server {
listen 443 ssl; listen 443 ssl;
server_name files.note.example.com; server_name files.note.example.com;
location ^~ /f/ { # Add your ssl_certificate and ssl_certificate_key configuration here.
proxy_pass http://rustpad:3000; location / {
proxy_pass http://rustpad:3001;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https; 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 bind *:443 ssl crt /etc/haproxy/certs/example.pem
mode http 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/ backend rustpad_files
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
mode http mode http
option httpchk GET /health
http-check expect status 200
http-request set-header X-Forwarded-Proto https http-request set-header X-Forwarded-Proto https
http-request del-header Cookie server rustpad-files rustpad:3001 check
http-request del-header Authorization
http-response del-header Set-Cookie backend rustpad_app
http-response return status 404 \ mode http
content-type "text/plain" \ http-request set-header X-Forwarded-Proto https
string "Not Found" if { status 404 } server rustpad-app rustpad:3000 check
server rustpad 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, upload_body_limit_bytes: usize,
asset_cache_max_age_seconds: u64, asset_cache_max_age_seconds: u64,
http_compression: bool, http_compression: bool,
serve_files_on_application: bool,
) -> Router { ) -> Router {
let asset_version = state.asset_version.clone(); let asset_version = state.asset_version.clone();
let asset_not_found = service_fn(move |_request| { 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)) HeaderValue::from_str(&crate::cache::cache_control(asset_cache_max_age_seconds))
.expect("valid asset cache-control header"); .expect("valid asset cache-control header");
Router::new() let mut app = Router::new()
.route("/", get(home)) .route("/", get(home))
.route("/auth/confirm/{token}", get(home)) .route("/auth/confirm/{token}", get(home))
.route("/auth/reset-password/{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.svg", get(favicon_svg))
.route("/icons/favicon-32.png", get(favicon_png)) .route("/icons/favicon-32.png", get(favicon_png))
.route("/icons/apple-touch-icon.png", get(apple_touch_icon)) .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/auth/identity", post(auth::identity))
.route( .route(
"/api/security/csrf", "/api/security/csrf",
@@ -256,12 +256,16 @@ pub fn router(
.service(ServeDir::new(static_dir).not_found_service(asset_not_found)), .service(ServeDir::new(static_dir).not_found_service(asset_not_found)),
) )
.fallback(not_found) .fallback(not_found)
.method_not_allowed_fallback(method_not_allowed) .method_not_allowed_fallback(method_not_allowed);
.layer(DefaultBodyLimit::max(upload_body_limit_bytes))
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(TraceLayer::new_for_http().make_span_with(PathOnlyMakeSpan))
.layer(middleware::from_fn(require_csrf_token)) .layer(middleware::from_fn(require_csrf_token))
.layer(middleware::from_fn(apply_response_header_policy)) .layer(middleware::from_fn(apply_response_header_policy))
// Negotiate HTTP compression only when enabled; WebSocket frames are unaffected.
.layer( .layer(
CompressionLayer::new() CompressionLayer::new()
.br(http_compression) .br(http_compression)
@@ -270,6 +274,16 @@ pub fn router(
.with_state(state) .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 { async fn language_catalog() -> Response {
let mut response = Json(crate::i18n::metadata()).into_response(); let mut response = Json(crate::i18n::metadata()).into_response();
response.headers_mut().insert( response.headers_mut().insert(
+20
View File
@@ -49,6 +49,7 @@ impl AuthorizationType {
pub struct Config { pub struct Config {
pub host: IpAddr, pub host: IpAddr,
pub port: u16, pub port: u16,
pub files_port: Option<u16>,
pub database_url: String, pub database_url: String,
pub database_max_connections: u32, pub database_max_connections: u32,
pub static_dir: String, pub static_dir: String,
@@ -91,6 +92,11 @@ impl Config {
let files_dir = values.get("FILES_DIR", "data/files"); let files_dir = values.get("FILES_DIR", "data/files");
let files_public_url = let files_public_url =
crate::file_urls::normalize_public_base(values.optional("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 let storage = match values
.get("STORAGE_DRIVER", "local") .get("STORAGE_DRIVER", "local")
@@ -163,6 +169,7 @@ impl Config {
let config = Self { let config = Self {
host, host,
port, port,
files_port,
database_url: values.get("DATABASE_URL", "sqlite:///data/db/rustpad.db?mode=rwc"), database_url: values.get("DATABASE_URL", "sqlite:///data/db/rustpad.db?mode=rwc"),
database_max_connections, database_max_connections,
static_dir: values.get("STATIC_DIR", "static"), 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( fn multipart_body_limit_bytes(
user_limit_bytes: usize, user_limit_bytes: usize,
guest_upload_enabled: bool, guest_upload_enabled: bool,
+1
View File
@@ -12,6 +12,7 @@ use std::{collections::HashMap, env, path::Path};
const KNOWN_CONFIG_KEYS: &[&str] = &[ const KNOWN_CONFIG_KEYS: &[&str] = &[
"APP_HOST", "APP_HOST",
"APP_PORT", "APP_PORT",
"FILES_PORT",
"DATABASE_URL", "DATABASE_URL",
"DATABASE_MAX_CONNECTIONS", "DATABASE_MAX_CONNECTIONS",
"STATIC_DIR", "STATIC_DIR",
+35 -5
View File
@@ -30,7 +30,7 @@ use std::{net::SocketAddr, path::PathBuf, sync::Arc};
use config::Config; use config::Config;
use database::{Database, DatabaseKind}; use database::{Database, DatabaseKind};
use state::AppState; use state::AppState;
use tokio::net::TcpListener; use tokio::{net::TcpListener, sync::watch};
use tracing::{info, warn}; use tracing::{info, warn};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@@ -59,6 +59,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
info!( info!(
host = %config.host, host = %config.host,
port = config.port, port = config.port,
files_port = ?config.files_port,
database_kind = %database_kind_label(&config.database_url), database_kind = %database_kind_label(&config.database_url),
database_max_connections = config.database_max_connections, database_max_connections = config.database_max_connections,
static_dir = %config.static_dir, static_dir = %config.static_dir,
@@ -170,23 +171,52 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
}); });
let app = app::router( let app = app::router(
state, state.clone(),
&config.static_dir, &config.static_dir,
config.upload_body_limit_bytes(), config.upload_body_limit_bytes(),
config.asset_cache_max_age_seconds, config.asset_cache_max_age_seconds,
config.http_compression, config.http_compression,
config.files_port.is_none(),
); );
let address = SocketAddr::new(config.host, config.port); let address = SocketAddr::new(config.host, config.port);
let listener = TcpListener::bind(address).await?; let listener = TcpListener::bind(address).await?;
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"); info!(%address, "RustPad is running");
axum::serve(listener, app) let app_server =
.with_graceful_shutdown(shutdown_signal()) axum::serve(listener, app).with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone()));
.await?; 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"); info!("RustPad stopped cleanly");
Ok(()) 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() { fn print_startup_credential() {
eprintln!("\n{}\n", 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")); 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. * 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] #[test]
fn converts_upload_megabytes_to_bytes() { fn converts_upload_megabytes_to_bytes() {
@@ -34,3 +34,17 @@ fn multipart_limit_uses_larger_enabled_guest_limit() {
51 * 1024 * 1024 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); width: min(100%, 270px);
pointer-events: none; pointer-events: none;
opacity: 1; opacity: 1;
transition: opacity .18s ease, visibility .18s;
} }
.home-footer__loading-action,
.home-footer__loading-line { .home-footer__loading-line {
display: block; display: block;
height: 13px; height: 13px;
@@ -4503,17 +4501,18 @@ dialog::backdrop {
animation: home-account-wait 1.4s ease-in-out infinite; animation: home-account-wait 1.4s ease-in-out infinite;
} }
.home-footer__loading-action { .home-footer__loading-line--short {
flex: 0 0 82px; flex: 0 0 64px;
height: 34px;
border-radius: 8px;
animation-delay: .15s; 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=error] .home-footer__account-pending,
.home-footer__account-slot[data-state=ready] .home-footer__account-pending { .home-footer__account-slot[data-state=ready] .home-footer__account-pending {
opacity: 0; display: none;
visibility: hidden;
} }
.home-footer__account-slot[data-state=ready]>.home-footer__account:not([hidden]) { .home-footer__account-slot[data-state=ready]>.home-footer__account:not([hidden]) {
@@ -4547,14 +4546,9 @@ dialog::backdrop {
@media(prefers-reduced-motion:reduce) { @media(prefers-reduced-motion:reduce) {
.home-footer__account-slot[data-state=ready]>.home-footer__account:not([hidden]), .home-footer__account-slot[data-state=ready]>.home-footer__account:not([hidden]),
.home-footer__loading-action,
.home-footer__loading-line { .home-footer__loading-line {
animation: none; animation: none;
} }
.home-footer__account-pending {
transition: none;
}
} }
@media(max-width:600px) { @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-slot" class="home-footer__account-slot" data-state="loading" aria-busy="true">
<div id="footer-account-pending" class="home-footer__account-pending" role="status"> <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-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> <span class="sr-only" data-i18n="home.account.loading">Checking your account...</span>
</div> </div>
<a id="footer-account-retry" href="/" class="footer-action" data-i18n="home.account.retry" hidden>Reload <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`. 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. 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 static_dir: /opt/rustpad/static
files_dir: /var/lib/rustpad/files files_dir: /var/lib/rustpad/files
# Optional; bare domains are normalized to HTTPS. Serve or proxy /f/* there. # 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_public_url: files.note.example.org
# files_port: 3001
storage_driver: local storage_driver: local
upload_max_size_mb: 20 upload_max_size_mb: 20
guest_upload_enabled: false guest_upload_enabled: false