diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28659f9..90b22e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,9 @@ permissions: # Pin sibling path-dep so Cargo.lock --locked stays valid in CI. # Bump this when regenerating the lockfile against a newer rsReticulum. +# Stacked on ratspeak/rsReticulum#26 (ReplyFile); switch back to a main SHA after merge. env: - RSRETICULUM_REF: d6d59dc6b506f13d8a0707e887716f847c7ac07f + RSRETICULUM_REF: 36456230cc29be5722c6f57c95f52c3b655e97f6 jobs: test: diff --git a/Cargo.lock b/Cargo.lock index 1d00ec3..60d5708 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -774,6 +774,7 @@ dependencies = [ "thiserror 2.0.20", "tokio", "tracing", + "zeroize", ] [[package]] diff --git a/README.md b/README.md index f1cc38b..7b89714 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ Mapping: Paths are resolved under each root without following symlink components; `..`, absolute escapes, NUL/backslash, and control characters are rejected. Default -size caps are **512 KiB** for pages and **4 MiB** for files. +size caps are **512 KiB** for pages and **32 MiB** for files. **Trust model:** content directories are trusted local storage. Operators must ensure they are not writable by untrusted local users. Symlink components are @@ -194,6 +194,10 @@ rescan the filesystem — call `reload_routes()` after content CRUD. currently ignores the request body (static hosting only) - Large responses: use normal `Reply` bytes; `LinkManager` upgrades to a response Resource when the packed reply exceeds the Link MDU +- File responses: `/file/...` uses `ReplyFile` — a response Resource with raw + bytes and msgpack metadata `{"name": }` (NomadNet `serve_file` + parity). Images and other binaries are ordinary files under `files/`; there is + no `/image/` route or MIME layer on the wire - Announce app data: raw UTF-8 display name, capped at 256 bytes (also accepted by mesh-client discovery) - Hidden paths: dotfiles and `*.allowed` are not listed or served (NomadNet parity) @@ -206,7 +210,7 @@ rescan the filesystem — call `reload_routes()` after content CRUD. | Area | Current behavior | | --- | --- | | Static pages | Serve `.mu` (and other text) from `pages/` with 512 KiB default cap | -| Static files | Serve binaries from `files/` with 4 MiB default cap | +| Static files | Serve binaries from `files/` with 32 MiB default cap as response Resources with filename metadata | | Announce | Startup + periodic + transport reannounce with display name | | Form payload decode | Helper only (`decode_request_fields`); not wired into serving | | Default index | Placeholder Micron page when `index.mu` is missing | @@ -236,11 +240,12 @@ Follow-ups (not required for basic hosting): 1. Optional `nomad-tools` binary (`nomad-serve-rs`) for headless static hosting 2. Identity-restricted pages (`.mu.allowed` lists) without process execution 3. Richer Micron helpers / builders -4. Upstream Resource filename metadata improvements in rsReticulum if needed -5. Transfer repository ownership to the Ratspeak organization when permissions allow +4. Transfer repository ownership to the Ratspeak organization when permissions allow -Application-layer CMS, chat rooms, and forums belong in clients such as -mesh-client, not in this protocol crate. +Application-layer CMS, chat rooms, forums, LXMF image/file attachments, and +Micron rendering belong in clients such as mesh-client / rsLXMF, not in this +protocol crate. Images on Nomad nodes are `/file/...` binaries with Resource +filename metadata (already implemented). ## Contributing diff --git a/ROADMAP.md b/ROADMAP.md index d63feb8..4e5d825 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,6 +12,8 @@ static hosting release used by mesh-client (#613). - MessagePack form encode/decode helpers (`encode_request_fields` / `decode_request_fields`) with shared size caps (decode not yet wired into the built-in serve handler) +- `/file/...` response Resource filename metadata (`ReplyFile`, NomadNet + `serve_file` parity); default file cap 32 MiB ## Near-term @@ -27,7 +29,6 @@ static hosting release used by mesh-client (#613). - Optional `nomad-tools` crate with `nomad-serve-rs` headless binary - Wire form/`field_*` bodies into serving when dynamic pages are designed - Stronger interop fixtures against Python NomadNet page fetches -- Resource response filename metadata parity (may require rsReticulum upstream) - Async / `spawn_blocking` serve path if LinkManager gains an async handler API ## Later (application / mesh-client) @@ -38,12 +39,15 @@ These belong in clients such as mesh-client, not in the protocol crate: - Theme and navigation editors - NomadNet-style chat room apps - Forums and other dynamic Nomad apps +- LXMF conversation image/file attachments (rsLXMF + mesh-client UI) +- Nomad browser image preview for `/file/...` rasters ## Explicit non-goals (v1) - CGI / executable `.mu` page scripts (arbitrary code execution risk) - Embedding hosting inside `rsLXMF` - Depending on non-Ratspeak RNS stacks (`nomadnet-rs` / `rns-net`) +- Server-side MIME/`/image/` routes (images are ordinary `/file/...` binaries) ## Ownership diff --git a/crates/nomad-core/src/node.rs b/crates/nomad-core/src/node.rs index 6333bd2..a64e7cf 100644 --- a/crates/nomad-core/src/node.rs +++ b/crates/nomad-core/src/node.rs @@ -11,7 +11,9 @@ use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant}; use rns_identity::identity::Identity; -use rns_runtime::link_manager::{LinkManager, RequestOutcome, register_destination}; +use rns_runtime::link_manager::{ + LinkManager, RequestOutcome, pack_file_name_metadata, register_destination, +}; use rns_transport::messages::TransportMessage; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; @@ -35,6 +37,9 @@ const MAX_REQUESTS_PER_WINDOW: u64 = 60; const REQUEST_WINDOW: Duration = Duration::from_secs(10); /// Timeout for awaited announce sends on the periodic ticker. const ANNOUNCE_SEND_TIMEOUT: Duration = Duration::from_secs(5); +/// NomadNet registers file handlers with `auto_compress = 32_000_000` +/// (compress responses under this size). +const FILE_AUTO_COMPRESS_MAX_BYTES: usize = 32_000_000; /// Configuration for [`NomadNode::spawn`]. #[derive(Debug, Clone)] @@ -493,7 +498,16 @@ fn handle_request(shared: &SharedState, path_hash_bytes: [u8; 16]) -> RequestOut match shared.store.read_file_route(&route) { Ok(bytes) => { shared.stats.file_hits.fetch_add(1, Ordering::Relaxed); - RequestOutcome::Reply(bytes) + let rel_name = route + .strip_prefix(FILE_PREFIX) + .unwrap_or(route.as_str()) + .to_string(); + let auto_compress = bytes.len() < FILE_AUTO_COMPRESS_MAX_BYTES; + RequestOutcome::ReplyFile { + data: bytes, + metadata: Some(pack_file_name_metadata(&rel_name)), + auto_compress, + } } Err(NomadError::NotFound(_)) => { // Files have no Micron 404 body — drop silently (NomadNet parity). @@ -562,10 +576,20 @@ mod tests { let file_hash = path_hash("/file/readme.txt"); match handle_request(&shared, file_hash) { - RequestOutcome::Reply(bytes) => { - assert_eq!(bytes, b"file-bytes"); + RequestOutcome::ReplyFile { + data, + metadata, + auto_compress, + } => { + assert_eq!(data, b"file-bytes"); + assert!(auto_compress); + let meta = metadata.expect("file responses include filename metadata"); + let value = rmpv::decode::read_value(&mut &meta[..]).unwrap(); + let map = value.as_map().expect("metadata map"); + assert_eq!(map[0].0.as_str(), Some("name")); + assert_eq!(map[0].1.as_slice(), Some(b"readme.txt".as_slice())); } - _ => panic!("expected file reply from link request handler"), + _ => panic!("expected ReplyFile from link request handler"), } let stats = shared.stats.snapshot(); @@ -574,6 +598,26 @@ mod tests { assert_eq!(stats.request_count, 2); } + #[test] + fn file_reply_preserves_nested_relative_name() { + let dir = TempDir::new().unwrap(); + let shared = shared_with_content( + &dir, + &[("index.mu", b"> ok\n")], + &[("photos/pic.png", b"PNG")], + ); + match handle_request(&shared, path_hash("/file/photos/pic.png")) { + RequestOutcome::ReplyFile { metadata, data, .. } => { + assert_eq!(data, b"PNG"); + let meta = metadata.expect("metadata"); + let value = rmpv::decode::read_value(&mut &meta[..]).unwrap(); + let map = value.as_map().unwrap(); + assert_eq!(map[0].1.as_slice(), Some(b"photos/pic.png".as_slice())); + } + _ => panic!("expected ReplyFile for nested file"), + } + } + #[test] fn unknown_path_hash_does_not_clear_or_rescan_routes() { let dir = TempDir::new().unwrap(); diff --git a/crates/nomad-core/src/storage.rs b/crates/nomad-core/src/storage.rs index b28d97c..ae2ff0a 100644 --- a/crates/nomad-core/src/storage.rs +++ b/crates/nomad-core/src/storage.rs @@ -20,8 +20,8 @@ use crate::paths::{ /// Default max page body (matches mesh-client client limit). pub const DEFAULT_MAX_PAGE_BYTES: usize = 512 * 1024; -/// Default max file body (matches mesh-client client limit). -pub const DEFAULT_MAX_FILE_BYTES: usize = 4 * 1024 * 1024; +/// Default max file body (NomadNet `auto_compress = 32_000_000` bound). +pub const DEFAULT_MAX_FILE_BYTES: usize = 32 * 1024 * 1024; /// Cap directory walk size to bound enumeration DoS. pub const MAX_LISTED_ENTRIES: usize = 10_000; /// Cap recursion depth when listing content.