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
+318 -73

No files matched your search

+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());
}