1use 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 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
78pub 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}