Skip to main content

crosslink/server/
mod.rs

1pub mod errors;
2pub mod handlers;
3pub mod routes;
4pub mod state;
5pub mod types;
6pub mod watcher;
7pub mod ws;
8
9use std::net::SocketAddr;
10use std::path::PathBuf;
11
12use anyhow::Result;
13use axum::extract::DefaultBodyLimit;
14use axum::http::{Request, StatusCode};
15use axum::middleware::{self, Next};
16use axum::response::Response;
17use tower_http::cors::CorsLayer;
18
19use crate::db::Database;
20use state::AppState;
21
22/// Maximum allowed request body size (10 MB).
23const MAX_BODY_SIZE: usize = 10 * 1024 * 1024;
24
25/// Bearer token authentication middleware.
26///
27/// Exempts `/api/v1/health` (read-only status) and `/ws` (WebSocket, uses
28/// its own protocol-level auth if needed). All other `/api/` routes require
29/// a valid `Authorization: Bearer <token>` header.
30async fn auth_middleware(
31    axum::extract::State(state): axum::extract::State<AppState>,
32    request: Request<axum::body::Body>,
33    next: Next,
34) -> Result<Response, StatusCode> {
35    let path = request.uri().path();
36
37    // Exempt health check and WebSocket from auth
38    if path == "/api/v1/health" || path == "/ws" || !path.starts_with("/api/") {
39        return Ok(next.run(request).await);
40    }
41
42    let authorized = request
43        .headers()
44        .get("authorization")
45        .and_then(|v| v.to_str().ok())
46        .and_then(|v| v.strip_prefix("Bearer "))
47        .is_some_and(|token| token == state.auth_token);
48
49    if authorized {
50        Ok(next.run(request).await)
51    } else {
52        Err(StatusCode::UNAUTHORIZED)
53    }
54}
55
56/// Start the crosslink web server.
57///
58/// Binds to `127.0.0.1:<port>`, configures CORS for the Vite dev server on
59/// `:5173`, serves the React dashboard from `dashboard_dir` (if provided),
60/// exposes the REST API under `/api/v1/`, and opens a WebSocket hub at `/ws`.
61///
62/// The filesystem watcher is started as a background task and broadcasts
63/// heartbeat events to all connected WebSocket clients.
64///
65/// # Errors
66///
67/// Returns an error if the server fails to bind or encounters a runtime error.
68pub async fn run(
69    port: u16,
70    dashboard_dir: Option<PathBuf>,
71    db: Database,
72    crosslink_dir: PathBuf,
73) -> Result<()> {
74    let state = AppState::new(db, crosslink_dir.clone());
75
76    // Start the heartbeat watcher in the background.
77    watcher::start_watcher(crosslink_dir, state.ws_tx.clone());
78
79    // Allow the Vite dev server (port 5173) and same-origin requests in
80    // development. In production the dashboard is served from the same origin
81    // so only the same-origin case matters, but permitting all origins here
82    // keeps the dev-only setup simple (this server is localhost-only by design).
83    let localhost: axum::http::HeaderValue = "http://localhost:5173".parse()?;
84    let loopback: axum::http::HeaderValue = "http://127.0.0.1:5173".parse()?;
85    let cors = CorsLayer::new()
86        .allow_origin([localhost, loopback])
87        .allow_methods(tower_http::cors::Any)
88        .allow_headers([
89            axum::http::header::CONTENT_TYPE,
90            axum::http::header::AUTHORIZATION,
91            axum::http::header::ACCEPT,
92        ]);
93
94    // Remember whether a dashboard is being served so we can print a
95    // clickable URL (with the bearer token baked in) at startup.
96    let has_dashboard = dashboard_dir.is_some();
97
98    let app = routes::build_router(state.clone(), dashboard_dir)
99        .layer(middleware::from_fn_with_state(
100            state.clone(),
101            auth_middleware,
102        ))
103        .layer(DefaultBodyLimit::max(MAX_BODY_SIZE))
104        .layer(cors);
105
106    let addr = SocketAddr::from(([127, 0, 0, 1], port));
107    println!("crosslink serve: listening on http://{addr}");
108    if has_dashboard {
109        // The dashboard reads `?token=<value>` on first load, persists it to
110        // sessionStorage, and strips it from the URL (see
111        // `dashboard/src/auth/bootstrap.ts`). Subsequent reloads in the same
112        // tab reuse the stored token.
113        println!("  Dashboard: http://{addr}/?token={}", state.auth_token);
114    }
115    println!("  API:       http://{addr}/api/v1/health");
116    println!("  WebSocket: ws://{addr}/ws");
117    println!("  Auth:      Bearer {}", state.auth_token);
118
119    let listener = tokio::net::TcpListener::bind(addr).await?;
120    axum::serve(listener, app).await?;
121
122    Ok(())
123}