v0.2.93
This commit is contained in:
1 parent
b85201889e
commit
b035648b81
21 files changed
+317
-72
No files matched your search
Generated
+1
-1
@@ -2674,7 +2674,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.2.92"
|
||||
version = "0.2.93"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+2
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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>`.
|
||||
|
||||
|
||||
@@ -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}"
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
File mode changed.
Executable → Regular
File mode changed.
Executable → Regular
File mode changed.
+19
-5
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user