juicehost/
server.rs

1//! Router builder and TCP server starter for `juicehost`.
2
3use std::net::SocketAddr;
4use std::sync::Arc;
5use std::time::Duration;
6
7use axum::{
8    body::Body,
9    extract::{DefaultBodyLimit, State},
10    http::{Request, StatusCode},
11    middleware,
12    response::{IntoResponse, Redirect},
13    routing::{delete, get, post},
14    Router,
15};
16use tower_http::{
17    compression::CompressionLayer,
18    timeout::TimeoutLayer,
19    trace::TraceLayer,
20};
21
22use crate::config::Config;
23use crate::handlers;
24use crate::middleware::add_security_headers;
25use crate::state::AppState;
26use crate::utils::shutdown_signal;
27
28async fn fallback_redirect(
29    State(state): State<Arc<AppState>>,
30) -> Redirect {
31    Redirect::temporary(&state.backend_url)
32}
33
34async fn require_api_key(
35    State(state): State<Arc<AppState>>,
36    req: Request<Body>,
37    next: middleware::Next,
38) -> impl IntoResponse {
39    let provided_key = req.headers()
40        .get("x-juicehost-api-key")
41        .and_then(|v| v.to_str().ok())
42        .unwrap_or("");
43
44    if !state.api_key.is_empty() && state.api_key != provided_key {
45        tracing::warn!(
46            "auth: invalid api key attempt from {:?}",
47            req.headers().get("x-forwarded-for")
48                .and_then(|v| v.to_str().ok())
49                .unwrap_or("unknown")
50        );
51        return (StatusCode::FORBIDDEN, "invalid api key").into_response();
52    }
53
54    // Origin-based access control for multi-instance topology.
55    // If ALLOWED_ORIGINS is non-empty, the request must include an
56    // X-Juiceback-Origin header whose value is in the whitelist.
57    if !state.allowed_origins.is_empty() {
58        let origin = req.headers()
59            .get("x-juiceback-origin")
60            .and_then(|v| v.to_str().ok())
61            .unwrap_or("");
62        if !state.allowed_origins.iter().any(|a| a == origin) {
63            tracing::warn!(
64                "auth: rejected origin '{}' from {:?} (allowed: {:?})",
65                origin,
66                req.headers().get("x-forwarded-for")
67                    .and_then(|v| v.to_str().ok())
68                    .unwrap_or("unknown"),
69                state.allowed_origins,
70            );
71            return (StatusCode::FORBIDDEN, "origin not allowed").into_response();
72        }
73    }
74
75    next.run(req).await
76}
77
78/// Build the router with internal and public route groups.
79///
80/// Internal routes (under /internal/):
81/// POST /internal/file -- multipart file store
82/// POST /internal/file/stream/:id/:filename -- streaming file store
83/// DELETE /internal/file/:id -- delete a file
84/// POST /internal/file/:id/rename -- rename a file
85///
86/// Public routes:
87/// GET /f/:id -- serve a file with ETag caching
88/// GET /api/health -- health check
89/// GET /api/storage -- storage metrics
90///
91/// Internal routes are protected by the require_api_key middleware.
92pub fn build_router(state: Arc<AppState>) -> Router {
93    let internal = Router::new()
94        .route("/internal/file", post(handlers::store_file))
95        .route("/internal/file/stream/:id/:filename", post(handlers::store_file_streaming))
96        .route("/internal/file/:id", delete(handlers::delete_file))
97        .route("/internal/file/:id/rename", post(handlers::rename_file))
98        .layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024))
99        .layer(middleware::from_fn_with_state(
100            Arc::clone(&state),
101            require_api_key,
102        ));
103
104    let public = Router::new()
105        .route("/f/:id", get(handlers::serve_file))
106        .route("/api/health", get(handlers::health))
107        .route("/api/storage", get(handlers::storage_handler));
108
109    Router::new()
110        .merge(public)
111        .merge(internal)
112        .fallback(fallback_redirect)
113        .layer(middleware::from_fn(add_security_headers))
114        .layer(CompressionLayer::new())
115        .layer(TimeoutLayer::new(Duration::from_secs(300)))
116        .layer(TraceLayer::new_for_http())
117        .with_state(state)
118}
119
120pub async fn start_server(app: Router, addr: SocketAddr) {
121    let listener = tokio::net::TcpListener::bind(addr)
122        .await
123        .expect("Failed to bind server");
124
125    tracing::info!("juicehost listening on {}", addr);
126
127    axum::serve(listener, app)
128        .with_graceful_shutdown(shutdown_signal())
129        .tcp_nodelay(true)
130        .await
131        .expect("Server error");
132}
133
134pub fn print_startup_banner(config: &Config) {
135    tracing::info!("juicehost is starting");
136    tracing::info!("files: {:?}", config.files_dir);
137    tracing::info!("backend: {}", config.backend_url);
138    let min_gb = config.min_free_space_bytes / (1024 * 1024 * 1024);
139    tracing::info!("min free space: {} GB", min_gb);
140    if !config.allowed_origins.is_empty() {
141        tracing::info!("allowed origins: {:?}", config.allowed_origins);
142    }
143}