v0.2.93
This commit is contained in:
1 parent
b85201889e
commit
b035648b81
21 files changed
+318
-73
No files matched your search
+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());
|
||||
}
|
||||
Reference in new issue
Block a user