Skip to main content

libfw_server/
lib.rs

1//! Embeddable `libfw` server: axum routing, bearer-token authorization,
2//! HTTP range handling and streaming upload/download.
3//!
4//! # Example
5//!
6//! ```no_run
7//! use std::sync::Arc;
8//! use axum::Router;
9//! use libfw_core::auth::{Action, PathValidator, TokenVerifier, Validator};
10//! use libfw_core::claims::{Permission, TokenClaims};
11//! use libfw_server::{router, ServerState};
12//!
13//! // 1. Token verifier: parse & verify bearer tokens into claims.
14//! #[derive(Clone)]
15//! struct MyVerifier;
16//! impl TokenVerifier for MyVerifier {
17//!     fn verify(&self, token: &str) -> Result<TokenClaims, libfw_core::auth::AuthError> {
18//!         Ok(TokenClaims {
19//!             sub: token.to_string(),
20//!             exp: None,
21//!             permissions: vec![Permission::Read, Permission::Write],
22//!             allowed_paths: vec!["/".to_string()],
23//!         })
24//!     }
25//! }
26//!
27//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
28//! let state = Arc::new(ServerState::builder()
29//!     .storage(libfw_server::FsStorage::new("/srv/files"))
30//!     .verifier(MyVerifier)
31//!     .validator(PathValidator::new())
32//!     .build());
33//!
34//! let app: Router = router(state);
35//! // ... serve `app` with your preferred hyper/tokio setup
36//! # Ok(())
37//! # }
38//! ```
39
40mod auth;
41mod handlers;
42mod http;
43mod storage;
44mod ws;
45
46pub use auth::{AuthRejection, BearerClaims};
47pub use http::{
48    content_range_none_value, content_range_value, etag_matches_if_none_match, if_range_matches,
49    parse_range_header, ParsedRange, RangeParseError,
50};
51pub use storage::{FsStorage, FsSink};
52pub use ws::ws_handler;
53
54use std::sync::Arc;
55
56use axum::extract::Request;
57use axum::http::StatusCode;
58use axum::middleware::Next;
59use axum::response::{IntoResponse, Response};
60use axum::Router;
61use libfw_core::auth::{AuthError, TokenVerifier, Validator};
62use libfw_core::compress::CompressionFormat;
63use libfw_core::storage::StorageBackend;
64use libfw_core::{protocol_compatible, protocol_header_value, DEFAULT_MAX_UPLOAD_SIZE, HEADER_PROTOCOL};
65pub use libfw_core::{
66    HEADER_COMPRESS, HEADER_FILE_META, HEADER_FINAL, HEADER_OFFSET, HEADER_SESSION,
67    HEADER_SESSION_STATUS,
68};
69
70/// Immutable server configuration shared by all handlers.
71pub struct ServerState {
72    /// The storage backend serving file content.
73    pub storage: Arc<dyn StorageBackend>,
74    /// Turns bearer tokens into claims.
75    pub verifier: Arc<dyn TokenVerifier>,
76    /// Decides whether claims may access a path.
77    pub validator: Arc<dyn Validator>,
78    /// Compression applied to downloads when the client asks for it.
79    pub compression: CompressionFormat,
80    /// Upper bound for a single upload body.
81    pub max_upload_size: u64,
82}
83
84impl ServerState {
85    /// Start building a server state.
86    pub fn builder() -> ServerStateBuilder {
87        ServerStateBuilder::default()
88    }
89
90    /// Check whether `claims` may perform `action` on `path`.
91    pub fn authorize(
92        &self,
93        claims: &libfw_core::claims::TokenClaims,
94        path: &str,
95        action: libfw_core::auth::Action,
96    ) -> Result<(), AuthError> {
97        self.validator.validate(claims, path, action)
98    }
99}
100
101/// Builder for [`ServerState`].
102pub struct ServerStateBuilder {
103    storage: Option<Arc<dyn StorageBackend>>,
104    verifier: Option<Arc<dyn TokenVerifier>>,
105    validator: Option<Arc<dyn Validator>>,
106    compression: CompressionFormat,
107    max_upload_size: u64,
108}
109
110impl Default for ServerStateBuilder {
111    fn default() -> Self {
112        ServerStateBuilder {
113            storage: None,
114            verifier: None,
115            validator: None,
116            compression: CompressionFormat::Zrip,
117            max_upload_size: DEFAULT_MAX_UPLOAD_SIZE,
118        }
119    }
120}
121
122impl ServerStateBuilder {
123    /// Required: the storage backend.
124    pub fn storage(mut self, storage: impl StorageBackend) -> Self {
125        self.storage = Some(Arc::new(storage));
126        self
127    }
128
129    /// Required: the token verifier.
130    pub fn verifier(mut self, verifier: impl TokenVerifier) -> Self {
131        self.verifier = Some(Arc::new(verifier));
132        self
133    }
134
135    /// Required: the path/permission validator.
136    pub fn validator(mut self, validator: impl Validator) -> Self {
137        self.validator = Some(Arc::new(validator));
138        self
139    }
140
141    /// Compression for downloads (default: `Zrip`).
142    pub fn compression(mut self, format: CompressionFormat) -> Self {
143        self.compression = format;
144        self
145    }
146
147    /// Maximum upload size in bytes (default: 100 GiB).
148    pub fn max_upload_size(mut self, size: u64) -> Self {
149        self.max_upload_size = size;
150        self
151    }
152
153    /// Build the state, panicking if required fields are missing.
154    pub fn build(self) -> ServerState {
155        ServerState {
156            storage: self.storage.expect("storage is required"),
157            verifier: self.verifier.expect("verifier is required"),
158            validator: self.validator.expect("validator is required"),
159            compression: self.compression,
160            max_upload_size: self.max_upload_size,
161        }
162    }
163}
164
165/// Reject requests that explicitly advertise an incompatible protocol
166/// version with `426 Upgrade Required`.
167///
168/// Requests *without* the handshake header are allowed, so raw HTTP clients
169/// (curl, tests, older builds) keep working; the WASM/SDK client always
170/// sends the header so it is guaranteed to be matched with this server.
171async fn validate_protocol(req: Request, next: Next) -> Response {
172    if let Some(value) = req
173        .headers()
174        .get(HEADER_PROTOCOL)
175        .and_then(|v| v.to_str().ok())
176    {
177        if !protocol_compatible(value) {
178            return (
179                StatusCode::UPGRADE_REQUIRED,
180                format!(
181                    "unsupported protocol `{value}`; expected `{}`",
182                    protocol_header_value()
183                ),
184            )
185                .into_response();
186        }
187    }
188    next.run(req).await
189}
190
191/// Build the axum router with the libfw routes mounted.
192///
193/// Routes:
194/// - `GET  /file/{*path}` — download with Range / ETag / compression
195/// - `HEAD /file/{*path}` — metadata only
196/// - `POST /file/{*path}` — streaming upload (headers: `x-libfw-file-meta`,
197///   optional `x-libfw-offset`, optional `x-libfw-compress`)
198/// - `GET  /dir/{*path}`  — directory listing (JSON)
199///
200/// All routes first pass through [`validate_protocol`], which enforces the
201/// `x-libfw-protocol` handshake shared with the WASM client.
202pub fn router(state: Arc<ServerState>) -> Router {
203    use axum::routing::{get, post};
204
205    Router::new()
206        .route("/file/{*path}", get(handlers::download).head(handlers::head_file))
207        .route("/file/{*path}", post(handlers::upload))
208        .route("/dir", get(handlers::list_dir_root))
209        .route("/dir/{*path}", get(handlers::list_dir))
210        .route("/ws", get(ws_handler))
211        .layer(axum::middleware::from_fn(validate_protocol))
212        .with_state(state)
213}
214
215/// Normalize and validate a virtual path from the URL.
216///
217/// Rejects absolute paths, `..` segments, NUL bytes and empty segments.
218pub fn validate_rel_path(path: &str) -> Result<String, &'static str> {
219    if path.contains('\0') {
220        return Err("path contains NUL byte");
221    }
222    if path.starts_with('/') {
223        return Err("path must be relative");
224    }
225    let mut out = String::with_capacity(path.len());
226    for segment in path.split('/') {
227        match segment {
228            "" | "." => {}
229            ".." => return Err("path escapes the mount root"),
230            seg => {
231                if !out.is_empty() {
232                    out.push('/');
233                }
234                out.push_str(seg);
235            }
236        }
237    }
238    Ok(out)
239}