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