generated from eblume/project-template
feat(hephd): CORS + optional static serving on the hub HTTP endpoint
Add a permissive CORS middleware (answers the browser OPTIONS preflight and stamps Access-Control-* on every response) and an optional --web-root static file handler with an index.html SPA fallback. Together these let a browser surface — the forthcoming heph-pwa mobile app — call /rpc cross-origin or be hosted same-origin straight from the hub. No new crate dependencies; file reads run on the blocking pool. Covered by tests/web_serve.rs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b75d7a8d7a
commit
ca8f7d1ab2
3 changed files with 287 additions and 4 deletions
|
|
@ -60,6 +60,12 @@ struct Cli {
|
|||
#[arg(long)]
|
||||
http_addr: Option<String>,
|
||||
|
||||
/// Directory of static files to serve for non-API paths (server mode). Point
|
||||
/// this at the `heph-pwa/` shell to host the mobile app same-origin from the
|
||||
/// hub. Unset: the hub serves only its API routes.
|
||||
#[arg(long)]
|
||||
web_root: Option<PathBuf>,
|
||||
|
||||
/// Server to proxy to (client mode only; required there).
|
||||
#[arg(long)]
|
||||
server_url: Option<String>,
|
||||
|
|
@ -190,7 +196,10 @@ async fn main() -> Result<()> {
|
|||
anyhow::bail!("--oidc-issuer and --oidc-audience must be set together")
|
||||
}
|
||||
};
|
||||
let app = sync::router(daemon.store(), verifier);
|
||||
if let Some(root) = cli.web_root.as_deref() {
|
||||
tracing::info!(web_root = %root.display(), "hub serving static PWA shell");
|
||||
}
|
||||
let app = sync::router_with_web(daemon.store(), verifier, cli.web_root.clone());
|
||||
let http_listener = TcpListener::bind(&addr)
|
||||
.await
|
||||
.with_context(|| format!("binding hub HTTP endpoint {addr}"))?;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@
|
|||
//! - `POST /rpc` — the full daemon API ([`crate::rpc::dispatch`]) over HTTP, for
|
||||
//! a no-replica `client`-mode [`crate::remote::RemoteStore`] to proxy against.
|
||||
//!
|
||||
//! All routes carry permissive CORS headers and answer the browser preflight
|
||||
//! (`OPTIONS`), so a browser surface (the `heph-pwa` mobile app) can call `/rpc`
|
||||
//! cross-origin. When the hub is given a `web_root`, unmatched paths fall back to
|
||||
//! serving that directory's static files (the PWA shell), so the app can be
|
||||
//! hosted same-origin straight from the hub.
|
||||
//!
|
||||
//! Exchange is **incremental by HLC cursor** (`sync_state`, [`heph_core::SyncCursors`]):
|
||||
//! each side transfers only the tail it hasn't sent/seen. Merge is idempotent,
|
||||
//! so a re-pushed op the hub already has is a harmless no-op. When the hub is
|
||||
|
|
@ -17,13 +23,14 @@
|
|||
//! OIDC bearer token whose `sub` owns the hub (tech-spec §13); spokes attach
|
||||
//! that token via the `bearer` argument to [`sync_once`].
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::extract::{Query, Request, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::{header, HeaderValue, Method, StatusCode, Uri};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::Response as AxumResponse;
|
||||
use axum::response::{IntoResponse, Response as AxumResponse};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -44,6 +51,9 @@ pub type SharedStore = Arc<Mutex<dyn Store + Send>>;
|
|||
struct HubState {
|
||||
store: SharedStore,
|
||||
verifier: Option<Arc<dyn TokenVerifier>>,
|
||||
/// When set, unmatched paths serve static files from this directory (the
|
||||
/// `heph-pwa` shell), so the app can be hosted same-origin from the hub.
|
||||
web_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// A batch of ops in flight (push body / pull response).
|
||||
|
|
@ -102,15 +112,116 @@ fn apply_batch(
|
|||
/// `verifier` is `Some`, every route requires a valid OIDC bearer token whose
|
||||
/// `sub` owns this hub (tech-spec §13); `None` leaves the hub open (local dev).
|
||||
pub fn router(store: SharedStore, verifier: Option<Arc<dyn TokenVerifier>>) -> Router {
|
||||
let state = HubState { store, verifier };
|
||||
router_with_web(store, verifier, None)
|
||||
}
|
||||
|
||||
/// [`router`] plus an optional `web_root`: when `Some(dir)`, paths that don't
|
||||
/// match an API route serve static files from `dir` (the `heph-pwa` shell),
|
||||
/// with a `index.html` fallback so the single-page app can deep-link. Static
|
||||
/// files are served without authentication — they are only the app shell; all
|
||||
/// data still flows through the auth-gated `/rpc` and `/sync/*` routes.
|
||||
pub fn router_with_web(
|
||||
store: SharedStore,
|
||||
verifier: Option<Arc<dyn TokenVerifier>>,
|
||||
web_root: Option<PathBuf>,
|
||||
) -> Router {
|
||||
let state = HubState {
|
||||
store,
|
||||
verifier,
|
||||
web_root,
|
||||
};
|
||||
Router::new()
|
||||
.route("/sync/pull", get(pull))
|
||||
.route("/sync/push", post(push))
|
||||
.route("/rpc", post(rpc_call))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
|
||||
// The static shell is unauthenticated and lives behind the API routes.
|
||||
.fallback(serve_static)
|
||||
// Outermost: stamp CORS headers on every response and short-circuit the
|
||||
// browser's `OPTIONS` preflight (before it reaches auth or routing).
|
||||
.layer(middleware::from_fn(cors))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// Permissive-CORS middleware. Answers the browser preflight (`OPTIONS`) with a
|
||||
/// 204 and stamps `Access-Control-*` headers on every response. The hub is a
|
||||
/// personal endpoint guarded by bearer tokens (not cookies), so a wildcard
|
||||
/// origin is safe — there are no ambient credentials for `*` to expose.
|
||||
async fn cors(request: Request, next: Next) -> AxumResponse {
|
||||
let is_preflight = request.method() == Method::OPTIONS;
|
||||
let mut response = if is_preflight {
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
} else {
|
||||
next.run(request).await
|
||||
};
|
||||
let h = response.headers_mut();
|
||||
h.insert(
|
||||
header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
HeaderValue::from_static("*"),
|
||||
);
|
||||
h.insert(
|
||||
header::ACCESS_CONTROL_ALLOW_METHODS,
|
||||
HeaderValue::from_static("GET, POST, OPTIONS"),
|
||||
);
|
||||
h.insert(
|
||||
header::ACCESS_CONTROL_ALLOW_HEADERS,
|
||||
HeaderValue::from_static("authorization, content-type"),
|
||||
);
|
||||
h.insert(
|
||||
header::ACCESS_CONTROL_MAX_AGE,
|
||||
HeaderValue::from_static("86400"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
/// Serve the PWA shell from `web_root` for any non-API path. Returns 404 when no
|
||||
/// `web_root` is configured. Unknown paths fall back to `index.html` so the SPA
|
||||
/// can own its own routing. Path traversal (`..`) is rejected.
|
||||
async fn serve_static(State(state): State<HubState>, uri: Uri) -> AxumResponse {
|
||||
let Some(root) = state.web_root.as_ref() else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
let rel = uri.path().trim_start_matches('/');
|
||||
if rel.split('/').any(|seg| seg == "..") {
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
let rel = if rel.is_empty() { "index.html" } else { rel };
|
||||
|
||||
let direct = root.join(rel);
|
||||
let index = root.join("index.html");
|
||||
// File reads run on the blocking pool (tokio's `fs` feature is off, and DB /
|
||||
// disk I/O never runs on an async worker, tech-spec §3).
|
||||
let read = tokio::task::spawn_blocking(move || {
|
||||
match std::fs::read(&direct) {
|
||||
Ok(bytes) => Some((content_type(&direct), bytes)),
|
||||
// SPA fallback: serve index.html for unknown (extension-less) routes.
|
||||
Err(_) => std::fs::read(&index)
|
||||
.ok()
|
||||
.map(|bytes| ("text/html; charset=utf-8", bytes)),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match read {
|
||||
Ok(Some((ctype, bytes))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
|
||||
_ => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort content type from a file extension (the handful the PWA serves).
|
||||
fn content_type(path: &std::path::Path) -> &'static str {
|
||||
match path.extension().and_then(|e| e.to_str()) {
|
||||
Some("html") => "text/html; charset=utf-8",
|
||||
Some("js" | "mjs") => "text/javascript; charset=utf-8",
|
||||
Some("css") => "text/css; charset=utf-8",
|
||||
Some("json" | "webmanifest") => "application/json; charset=utf-8",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("png") => "image/png",
|
||||
Some("ico") => "image/x-icon",
|
||||
Some("woff2") => "font/woff2",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject any request lacking a valid bearer token whose `sub` owns this hub.
|
||||
/// A no-op when the hub has no verifier configured (open dev mode).
|
||||
async fn require_auth(
|
||||
|
|
|
|||
163
crates/hephd/tests/web_serve.rs
Normal file
163
crates/hephd/tests/web_serve.rs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
//! The hub's browser-facing surface (for the `heph-pwa` mobile app): permissive
|
||||
//! CORS on every response, an `OPTIONS` preflight answer, and—when a `web_root`
|
||||
//! is configured—static serving of the app shell with an `index.html` SPA
|
||||
//! fallback. A tiny raw-HTTP client keeps this dependency-free and lets us drive
|
||||
//! arbitrary methods (`OPTIONS`) and inspect response headers directly.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::sync::mpsc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use heph_core::{FixedClock, LocalStore};
|
||||
use hephd::sync::{self, SharedStore};
|
||||
|
||||
const NOW: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z
|
||||
|
||||
/// One parsed HTTP response: status line code, lowercased headers, and body.
|
||||
struct Resp {
|
||||
status: u16,
|
||||
headers: Vec<(String, String)>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
impl Resp {
|
||||
fn header(&self, name: &str) -> Option<&str> {
|
||||
let name = name.to_ascii_lowercase();
|
||||
self.headers
|
||||
.iter()
|
||||
.find(|(k, _)| *k == name)
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue one HTTP/1.1 request over a fresh connection (`Connection: close`, so
|
||||
/// we can read the whole response to EOF) and parse the response.
|
||||
fn request(addr: &str, method: &str, path: &str) -> Resp {
|
||||
let mut stream = TcpStream::connect(addr).unwrap();
|
||||
let req = format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
|
||||
stream.write_all(req.as_bytes()).unwrap();
|
||||
let mut raw = String::new();
|
||||
stream.read_to_string(&mut raw).unwrap();
|
||||
|
||||
let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((&raw, ""));
|
||||
let mut lines = head.split("\r\n");
|
||||
let status = lines
|
||||
.next()
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.and_then(|c| c.parse().ok())
|
||||
.unwrap();
|
||||
let headers = lines
|
||||
.filter_map(|l| l.split_once(": "))
|
||||
.map(|(k, v)| (k.to_ascii_lowercase(), v.to_string()))
|
||||
.collect();
|
||||
Resp {
|
||||
status,
|
||||
headers,
|
||||
body: body.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the hub router (with the given `web_root`) over a temp `LocalStore` on
|
||||
/// an ephemeral port; return its `host:port`. The server thread + temp dirs live
|
||||
/// for the test's duration.
|
||||
fn start(web_root: Option<std::path::PathBuf>) -> String {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(async move {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store =
|
||||
LocalStore::open(dir.path().join("heph.db"), Box::new(FixedClock(NOW))).unwrap();
|
||||
let shared: SharedStore = Arc::new(Mutex::new(store));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
tx.send(listener.local_addr().unwrap()).unwrap();
|
||||
let _keep = dir;
|
||||
let app = sync::router_with_web(shared, None, web_root);
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
});
|
||||
rx.recv_timeout(Duration::from_secs(5)).unwrap().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cors_headers_on_rpc_and_preflight_answered() {
|
||||
let addr = start(None);
|
||||
|
||||
// The browser preflight gets a 204 with the CORS allowances, without auth.
|
||||
let pre = request(&addr, "OPTIONS", "/rpc");
|
||||
assert_eq!(pre.status, 204);
|
||||
assert_eq!(pre.header("access-control-allow-origin"), Some("*"));
|
||||
assert!(pre
|
||||
.header("access-control-allow-headers")
|
||||
.unwrap()
|
||||
.contains("authorization"));
|
||||
assert!(pre
|
||||
.header("access-control-allow-methods")
|
||||
.unwrap()
|
||||
.contains("POST"));
|
||||
|
||||
// A regular GET also carries the origin header (so XHR can read the body).
|
||||
let get = request(&addr, "GET", "/sync/pull");
|
||||
assert_eq!(get.header("access-control-allow-origin"), Some("*"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_static_shell_with_index_fallback() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("index.html"),
|
||||
"<!doctype html><title>heph</title>",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.path().join("app.js"), "export const x = 1;\n").unwrap();
|
||||
let addr = start(Some(dir.path().to_path_buf()));
|
||||
|
||||
// Root serves index.html as HTML.
|
||||
let root = request(&addr, "GET", "/");
|
||||
assert_eq!(root.status, 200);
|
||||
assert!(root.body.contains("<title>heph</title>"));
|
||||
assert_eq!(
|
||||
root.header("content-type"),
|
||||
Some("text/html; charset=utf-8")
|
||||
);
|
||||
|
||||
// A real asset is served with a JS content type.
|
||||
let js = request(&addr, "GET", "/app.js");
|
||||
assert_eq!(js.status, 200);
|
||||
assert!(js.body.contains("export const x"));
|
||||
assert_eq!(
|
||||
js.header("content-type"),
|
||||
Some("text/javascript; charset=utf-8")
|
||||
);
|
||||
|
||||
// An unknown (extension-less) route falls back to index.html for the SPA.
|
||||
let deep = request(&addr, "GET", "/inbox");
|
||||
assert_eq!(deep.status, 200);
|
||||
assert!(deep.body.contains("<title>heph</title>"));
|
||||
|
||||
// Path traversal never escapes web_root (whether the client/proxy normalizes
|
||||
// the `..` away or our guard rejects it, the crate's Cargo.toml never leaks).
|
||||
let escape = request(&addr, "GET", "/../../Cargo.toml");
|
||||
assert!(
|
||||
!escape.body.contains("[package]"),
|
||||
"must not serve files outside web_root"
|
||||
);
|
||||
|
||||
// The temp dir must outlive the server thread's reads.
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_web_root_yields_404_for_static_paths() {
|
||||
let addr = start(None);
|
||||
let resp = request(&addr, "GET", "/inbox");
|
||||
assert_eq!(resp.status, 404);
|
||||
// Even the 404 carries CORS headers (it passed through the layer).
|
||||
assert_eq!(resp.header("access-control-allow-origin"), Some("*"));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue