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::capabilities::{Capabilities, Limits, ZripLevels};
61use libfw_core::claims::TokenClaims;
62use libfw_core::compress::CompressionFormat;
63use libfw_core::pathmap::{IdentityPathCodec, PathCodec, PathCodecError};
64use libfw_core::storage::StorageBackend;
65pub use libfw_core::pathmap::MountPathCodec;
66#[cfg(feature = "path-encrypt")]
67pub use libfw_core::pathmap::EncryptedPathCodec;
68use libfw_core::{protocol_compatible, protocol_header_value, DEFAULT_MAX_UPLOAD_SIZE, HEADER_PROTOCOL};
69pub use libfw_core::{
70    HEADER_COMPRESS, HEADER_COMPRESS_LEVEL, HEADER_FILE_META, HEADER_FINAL, HEADER_OFFSET,
71    HEADER_SESSION, HEADER_SESSION_STATUS,
72};
73
74/// Error resolving a client-supplied (shadow) path to a real storage path.
75///
76/// Returned by [`ServerState::resolve_client_path`]; map it to HTTP as:
77/// - [`Invalid`](PathResolveError::Invalid) / [`Codec`](PathResolveError::Codec)
78///   → `400 Bad Request` (malformed or tampered shadow path),
79/// - [`Auth`](PathResolveError::Auth) → `401` / `403` as usual.
80#[derive(Debug, thiserror::Error)]
81pub enum PathResolveError {
82    /// The shadow path failed basic shape validation (absolute, `..`,
83    /// NUL byte, empty segments).
84    #[error("invalid path: {0}")]
85    Invalid(&'static str),
86    /// The codec could not decode the shadow (unknown version, bad
87    /// base64, tampered ciphertext, unmapped namespace).
88    #[error("cannot decode path: {0}")]
89    Codec(#[from] PathCodecError),
90    /// The decoded real path is not authorized for this token.
91    #[error(transparent)]
92    Auth(#[from] AuthError),
93}
94
95/// Immutable server configuration shared by all handlers.
96pub struct ServerState {
97    /// The storage backend serving file content.
98    pub storage: Arc<dyn StorageBackend>,
99    /// Turns bearer tokens into claims.
100    pub verifier: Arc<dyn TokenVerifier>,
101    /// Decides whether claims may access a path.
102    pub validator: Arc<dyn Validator>,
103    /// Real storage paths ↔ client-visible shadow paths.
104    ///
105    /// Defaults to [`IdentityPathCodec`] (no translation).
106    pub path_codec: Arc<dyn PathCodec>,
107    /// Compression applied to downloads when the client asks for it.
108    pub compression: CompressionFormat,
109    /// Upper bound for a single upload body.
110    pub max_upload_size: u64,
111    /// Advertised tuning-parameter ranges; `None` exports the libfw
112    /// built-in defaults (and enforces nothing beyond existing hard caps).
113    pub limits: Option<Limits>,
114    /// Advertised zrip level range; `None` keeps the legacy behavior:
115    /// the level header is ignored and downloads always use
116    /// [`ZRIP_DEFAULT_LEVEL`](libfw_core::ZRIP_DEFAULT_LEVEL).
117    pub zrip_levels: Option<ZripLevels>,
118    /// Sweep period of the stale session-temp cleaner (tus `Expiration`),
119    /// see [`ServerState::spawn_stale_session_cleanup`].
120    ///
121    /// Defaults to 1 hour; `Duration::ZERO` disables the sweeper.
122    pub cleanup_interval: std::time::Duration,
123    /// Max age of an abandoned session upload temp before it is removed.
124    ///
125    /// Defaults to [`DEFAULT_SESSION_TTL`](libfw_core::DEFAULT_SESSION_TTL)
126    /// (24 hours).
127    pub session_ttl: std::time::Duration,
128}
129
130impl ServerState {
131    /// Start building a server state.
132    pub fn builder() -> ServerStateBuilder {
133        ServerStateBuilder::default()
134    }
135
136    /// Check whether `claims` may perform `action` on `path`.
137    pub fn authorize(
138        &self,
139        claims: &libfw_core::claims::TokenClaims,
140        path: &str,
141        action: libfw_core::auth::Action,
142    ) -> Result<(), AuthError> {
143        self.validator.validate(claims, path, action)
144    }
145}
146
147impl ServerState {
148    /// Resolve a client-supplied (shadow) path to the canonical real path.
149    ///
150    /// Pipeline: shape-validate the shadow → [`PathCodec::decode`] →
151    /// shape-validate the decoded real path (defense in depth) →
152    /// authorize `action` **against the real path**, so
153    /// `allowed_paths` keeps its exact pre-translation semantics.
154    ///
155    /// The returned real path is safe to hand to the storage backend;
156    /// it must never be echoed back to the client — responses go through
157    /// [`ServerState::expose_path`].
158    pub fn resolve_client_path(
159        &self,
160        claims: &TokenClaims,
161        shadow: &str,
162        action: libfw_core::auth::Action,
163    ) -> Result<String, PathResolveError> {
164        let shadow = validate_rel_path(shadow).map_err(PathResolveError::Invalid)?;
165        // The canonical root (``) is not a shadow: it maps to itself
166        // (the root handler `GET /dir` passes it directly). Everything
167        // else must round-trip through the codec.
168        let real = if shadow.is_empty() {
169            shadow
170        } else {
171            self.path_codec.decode(&shadow)?
172        };
173        let real = validate_rel_path(&real).map_err(PathResolveError::Invalid)?;
174        self.authorize(claims, &real, action)?;
175        Ok(real)
176    }
177
178    /// Translate a real storage path for client consumption (shadow).
179    pub fn expose_path(&self, real: &str) -> String {
180        self.path_codec.encode(real)
181    }
182}
183
184/// Builder for [`ServerState`].
185pub struct ServerStateBuilder {
186    storage: Option<Arc<dyn StorageBackend>>,
187    verifier: Option<Arc<dyn TokenVerifier>>,
188    validator: Option<Arc<dyn Validator>>,
189    path_codec: Option<Arc<dyn PathCodec>>,
190    compression: CompressionFormat,
191    max_upload_size: u64,
192    limits: Option<Limits>,
193    zrip_levels: Option<ZripLevels>,
194    cleanup_interval: std::time::Duration,
195    session_ttl: std::time::Duration,
196}
197
198impl Default for ServerStateBuilder {
199    fn default() -> Self {
200        ServerStateBuilder {
201            storage: None,
202            verifier: None,
203            validator: None,
204            path_codec: None,
205            compression: CompressionFormat::Zrip,
206            max_upload_size: DEFAULT_MAX_UPLOAD_SIZE,
207            limits: None,
208            zrip_levels: None,
209            cleanup_interval: std::time::Duration::from_secs(3600),
210            session_ttl: libfw_core::DEFAULT_SESSION_TTL,
211        }
212    }
213}
214
215impl ServerStateBuilder {
216    /// Required: the storage backend.
217    pub fn storage(mut self, storage: impl StorageBackend) -> Self {
218        self.storage = Some(Arc::new(storage));
219        self
220    }
221
222    /// Required: the token verifier.
223    pub fn verifier(mut self, verifier: impl TokenVerifier) -> Self {
224        self.verifier = Some(Arc::new(verifier));
225        self
226    }
227
228    /// Required: the path/permission validator.
229    pub fn validator(mut self, validator: impl Validator) -> Self {
230        self.validator = Some(Arc::new(validator));
231        self
232    }
233
234    /// Path translator between real storage paths and client-visible
235    /// shadow paths (default: identity — no translation).
236    ///
237    /// Handlers resolve inbound shadow paths through
238    /// [`ServerState::resolve_client_path`] (which also authorizes the
239    /// real path) and encode outbound paths via
240    /// [`ServerState::expose_path`]. See [`MountPathCodec`] for readable
241    /// aliases and [`EncryptedPathCodec`] for opaque encrypted paths
242    /// (`libfw-core/path-encrypt` feature).
243    pub fn path_codec(mut self, codec: impl PathCodec) -> Self {
244        self.path_codec = Some(Arc::new(codec));
245        self
246    }
247
248    /// Compression for downloads (default: `Zrip`).
249    pub fn compression(mut self, format: CompressionFormat) -> Self {
250        self.compression = format;
251        self
252    }
253
254    /// Maximum upload size in bytes (default: 100 GiB).
255    pub fn max_upload_size(mut self, size: u64) -> Self {
256        self.max_upload_size = size;
257        self
258    }
259
260    /// Tuning-parameter ranges advertised at `GET /capabilities`.
261    ///
262    /// `None` (default) exports the libfw built-in defaults. The server
263    /// never hard-enforces these beyond existing caps (frame size, upload
264    /// body limit); they are an advisory contract for adaptive clients.
265    pub fn limits(mut self, limits: Limits) -> Self {
266        self.limits = Some(limits);
267        self
268    }
269
270    /// Zrip level range for downloads: validates/clamps the
271    /// `x-libfw-compress-level` request header and echoes the actual level.
272    ///
273    /// `None` (default) keeps the legacy behavior — the header is ignored
274    /// and every zrip download uses [`ZRIP_DEFAULT_LEVEL`].
275    pub fn zrip_levels(mut self, levels: ZripLevels) -> Self {
276        self.zrip_levels = Some(levels);
277        self
278    }
279
280    /// Sweep period of the stale session-temp cleaner (default: 1 hour;
281    /// `Duration::ZERO` disables the sweeper).
282    pub fn cleanup_interval(mut self, interval: std::time::Duration) -> Self {
283        self.cleanup_interval = interval;
284        self
285    }
286
287    /// Max age of an abandoned session upload temp before removal
288    /// (default: 24 hours, [`DEFAULT_SESSION_TTL`](libfw_core::DEFAULT_SESSION_TTL)).
289    pub fn session_ttl(mut self, ttl: std::time::Duration) -> Self {
290        self.session_ttl = ttl;
291        self
292    }
293
294    /// Build the state, panicking if required fields are missing.
295    pub fn build(self) -> ServerState {
296        ServerState {
297            storage: self.storage.expect("storage is required"),
298            verifier: self.verifier.expect("verifier is required"),
299            validator: self.validator.expect("validator is required"),
300            path_codec: self.path_codec.unwrap_or_else(|| Arc::new(IdentityPathCodec)),
301            compression: self.compression,
302            max_upload_size: self.max_upload_size,
303            limits: self.limits,
304            zrip_levels: self.zrip_levels,
305            cleanup_interval: self.cleanup_interval,
306            session_ttl: self.session_ttl,
307        }
308    }
309}
310
311impl ServerState {
312    /// The capability advertisement served at `GET /capabilities`.
313    ///
314    /// Filters the compression formats by the configured download
315    /// compression and overlays the optional builder overrides; unset
316    /// dimensions fall back to the libfw built-in defaults.
317    pub fn capabilities(&self) -> Capabilities {
318        let mut caps = Capabilities::default();
319        if let Some(limits) = &self.limits {
320            caps.limits = limits.clone();
321        }
322        if let Some(levels) = &self.zrip_levels {
323            caps.compression.zrip_levels = *levels;
324        }
325        caps.compression.formats = match self.compression {
326            CompressionFormat::Zrip => {
327                vec![CompressionFormat::None, CompressionFormat::Zrip]
328            }
329            CompressionFormat::None => vec![CompressionFormat::None],
330        };
331        caps
332    }
333
334    /// Spawn the stale session-temp cleaner as a background task.
335    ///
336    /// Every [`cleanup_interval`](ServerState::cleanup_interval) it sweeps
337    /// the storage for abandoned session-upload temps and removes the ones
338    /// whose last write is older than [`session_ttl`](ServerState::session_ttl)
339    /// (tus `Expiration`; a client that vanished mid-upload never leaves its
340    /// `.libfw-sess-*` temp / `.blocks` sidecar behind forever).
341    ///
342    /// A zero `cleanup_interval` disables the sweeper: the returned handle
343    /// completes immediately. Backends that keep no long-lived session temps
344    /// (the [`StorageBackend`] trait default) simply report `0` removals.
345    pub fn spawn_stale_session_cleanup(
346        self: &Arc<Self>,
347    ) -> tokio::task::JoinHandle<()> {
348        let interval = self.cleanup_interval;
349        if interval.is_zero() {
350            return tokio::spawn(async {});
351        }
352        let storage = self.storage.clone();
353        let ttl = self.session_ttl;
354        let mut tick = tokio::time::interval(interval);
355        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
356        tokio::spawn(async move {
357            loop {
358                tick.tick().await;
359                match storage.cleanup_stale_sessions(ttl).await {
360                    Ok(n) if n > 0 => {
361                        tracing::info!("cleaned {n} stale upload session temp(s)")
362                    }
363                    _ => {}
364                }
365            }
366        })
367    }
368}
369
370/// Reject requests that explicitly advertise an incompatible protocol
371/// version with `426 Upgrade Required`.
372///
373/// Requests *without* the handshake header are allowed, so raw HTTP clients
374/// (curl, tests, older builds) keep working; the WASM/SDK client always
375/// sends the header so it is guaranteed to be matched with this server.
376async fn validate_protocol(req: Request, next: Next) -> Response {
377    if let Some(value) = req
378        .headers()
379        .get(HEADER_PROTOCOL)
380        .and_then(|v| v.to_str().ok())
381    {
382        if !protocol_compatible(value) {
383            return (
384                StatusCode::UPGRADE_REQUIRED,
385                format!(
386                    "unsupported protocol `{value}`; expected `{}`",
387                    protocol_header_value()
388                ),
389            )
390                .into_response();
391        }
392    }
393    next.run(req).await
394}
395
396/// Build the axum router with the libfw routes mounted.
397///
398/// Routes:
399/// - `GET  /file/{*path}` — download with Range / ETag / compression
400/// - `HEAD /file/{*path}` — metadata only
401/// - `POST /file/{*path}` — streaming upload (headers: `x-libfw-file-meta`,
402///   optional `x-libfw-offset`, optional `x-libfw-compress`)
403/// - `GET  /dir/{*path}`  — directory listing (JSON)
404/// - `GET  /capabilities` — capability advertisement (JSON, **public**:
405///   no auth — the payload is a non-sensitive contract for adaptive clients)
406///
407/// All routes first pass through [`validate_protocol`], which enforces the
408/// `x-libfw-protocol` handshake shared with the WASM client.
409pub fn router(state: Arc<ServerState>) -> Router {
410    use axum::routing::{get, post};
411
412    Router::new()
413        .route("/file/{*path}", get(handlers::download).head(handlers::head_file))
414        .route("/file/{*path}", post(handlers::upload))
415        .route("/dir", get(handlers::list_dir_root))
416        .route("/dir/{*path}", get(handlers::list_dir))
417        .route("/capabilities", get(handlers::capabilities))
418        .layer(axum::middleware::from_fn(validate_protocol))
419        .with_state(state)
420}
421
422/// Normalize and validate a virtual path from the URL.
423///
424/// Rejects absolute paths, `..` segments, NUL bytes and empty segments.
425pub fn validate_rel_path(path: &str) -> Result<String, &'static str> {
426    if path.contains('\0') {
427        return Err("path contains NUL byte");
428    }
429    if path.starts_with('/') {
430        return Err("path must be relative");
431    }
432    let mut out = String::with_capacity(path.len());
433    for segment in path.split('/') {
434        match segment {
435            "" | "." => {}
436            ".." => return Err("path escapes the mount root"),
437            seg => {
438                if !out.is_empty() {
439                    out.push('/');
440                }
441                out.push_str(seg);
442            }
443        }
444    }
445    Ok(out)
446}