2026-01-24 22:59:20 +00:00
|
|
|
use crate::api::rpc::RpcHandler;
|
2026-03-09 07:43:12 +00:00
|
|
|
use crate::content_server;
|
2026-02-17 15:03:34 +00:00
|
|
|
use crate::electrs_status;
|
|
|
|
|
use crate::node_message as node_msg;
|
2026-01-24 22:59:20 +00:00
|
|
|
use crate::config::Config;
|
2026-03-06 03:26:56 +00:00
|
|
|
use crate::session::{self, SessionStore};
|
2026-01-27 23:06:18 +00:00
|
|
|
use crate::state::StateManager;
|
2026-01-24 22:59:20 +00:00
|
|
|
use anyhow::Result;
|
2026-01-27 22:37:08 +00:00
|
|
|
use futures_util::{SinkExt, StreamExt};
|
2026-01-24 22:59:20 +00:00
|
|
|
use hyper::{Method, Request, Response, StatusCode};
|
2026-01-27 22:47:51 +00:00
|
|
|
use hyper_ws_listener::WsStream;
|
2026-01-24 22:59:20 +00:00
|
|
|
use std::sync::Arc;
|
2026-02-01 13:24:03 +00:00
|
|
|
use tokio::sync::broadcast;
|
2026-01-27 22:47:51 +00:00
|
|
|
use tokio_tungstenite::tungstenite::Message;
|
|
|
|
|
use tracing::{debug, info};
|
2026-01-24 22:59:20 +00:00
|
|
|
|
|
|
|
|
pub struct ApiHandler {
|
2026-03-06 03:26:56 +00:00
|
|
|
config: Config,
|
2026-01-24 22:59:20 +00:00
|
|
|
rpc_handler: Arc<RpcHandler>,
|
2026-01-27 23:06:18 +00:00
|
|
|
state_manager: Arc<StateManager>,
|
2026-03-06 03:26:56 +00:00
|
|
|
session_store: SessionStore,
|
2026-01-24 22:59:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ApiHandler {
|
2026-01-27 23:06:18 +00:00
|
|
|
pub async fn new(config: Config, state_manager: Arc<StateManager>) -> Result<Self> {
|
2026-03-06 03:26:56 +00:00
|
|
|
let session_store = SessionStore::new();
|
|
|
|
|
let rpc_handler = Arc::new(
|
|
|
|
|
RpcHandler::new(config.clone(), state_manager.clone(), session_store.clone()).await?,
|
|
|
|
|
);
|
2026-01-24 22:59:20 +00:00
|
|
|
|
|
|
|
|
Ok(Self {
|
2026-03-06 03:26:56 +00:00
|
|
|
config,
|
2026-01-24 22:59:20 +00:00
|
|
|
rpc_handler,
|
2026-01-27 23:06:18 +00:00
|
|
|
state_manager,
|
2026-03-06 03:26:56 +00:00
|
|
|
session_store,
|
2026-01-24 22:59:20 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 03:26:56 +00:00
|
|
|
/// Check if the request has a valid session cookie.
|
|
|
|
|
async fn is_authenticated(&self, headers: &hyper::HeaderMap) -> bool {
|
|
|
|
|
match session::extract_session_cookie(headers) {
|
|
|
|
|
Some(token) => self.session_store.validate(&token).await,
|
|
|
|
|
None => false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Build a 401 Unauthorized JSON response.
|
|
|
|
|
fn unauthorized() -> Response<hyper::Body> {
|
|
|
|
|
let body = serde_json::json!({ "error": "Unauthorized" });
|
|
|
|
|
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
|
|
|
|
Response::builder()
|
|
|
|
|
.status(StatusCode::UNAUTHORIZED)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(hyper::Body::from(body_bytes))
|
|
|
|
|
.unwrap()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Derive the allowed CORS origin from the config host IP.
|
|
|
|
|
fn cors_origin(&self) -> String {
|
|
|
|
|
format!("http://{}", self.config.host_ip)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 23:20:54 +00:00
|
|
|
pub async fn handle_request(
|
|
|
|
|
&self,
|
Update archipelago: API, auth, container, parmanode, performance, security
- API handler, RPC, and server updates
- Auth and coding rules
- Container data manager, dev orchestrator, health monitor, podman client
- Parmanode script runner
- Performance resource manager
- Security container policies and secrets manager
- Add build scripts and documentation
2026-01-27 22:27:17 +00:00
|
|
|
req: Request<hyper::Body>,
|
|
|
|
|
) -> Result<Response<hyper::Body>> {
|
2026-01-24 23:20:54 +00:00
|
|
|
let path = req.uri().path().to_string();
|
|
|
|
|
let method = req.method().clone();
|
2026-01-27 22:37:08 +00:00
|
|
|
|
2026-03-06 03:26:56 +00:00
|
|
|
// Handle CORS preflight for all routes
|
|
|
|
|
if method == Method::OPTIONS {
|
|
|
|
|
let origin = self.cors_origin();
|
|
|
|
|
return Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::NO_CONTENT)
|
|
|
|
|
.header("Access-Control-Allow-Origin", &origin)
|
|
|
|
|
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
|
|
|
.header("Access-Control-Allow-Headers", "Content-Type")
|
|
|
|
|
.header("Access-Control-Allow-Credentials", "true")
|
|
|
|
|
.header("Vary", "Origin")
|
|
|
|
|
.body(hyper::Body::empty())
|
|
|
|
|
.unwrap());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WebSocket upgrade — validate session before upgrading
|
2026-01-27 22:37:08 +00:00
|
|
|
if method == Method::GET && path == "/ws/db" {
|
2026-03-06 03:26:56 +00:00
|
|
|
if !self.is_authenticated(req.headers()).await {
|
|
|
|
|
return Ok(Self::unauthorized());
|
|
|
|
|
}
|
2026-01-27 23:06:18 +00:00
|
|
|
return Self::handle_websocket(req, self.state_manager.clone()).await;
|
2026-01-27 22:37:08 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert body to bytes for non-WS routes
|
2026-03-06 03:26:56 +00:00
|
|
|
let headers = req.headers().clone();
|
2026-01-24 22:59:20 +00:00
|
|
|
let (parts, body) = req.into_parts();
|
Update archipelago: API, auth, container, parmanode, performance, security
- API handler, RPC, and server updates
- Auth and coding rules
- Container data manager, dev orchestrator, health monitor, podman client
- Parmanode script runner
- Performance resource manager
- Security container policies and secrets manager
- Add build scripts and documentation
2026-01-27 22:27:17 +00:00
|
|
|
let body_bytes = hyper::body::to_bytes(body).await
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?;
|
2026-02-17 15:03:34 +00:00
|
|
|
let req_with_bytes = Request::from_parts(parts, hyper::Body::from(body_bytes.clone()));
|
2026-01-24 22:59:20 +00:00
|
|
|
|
|
|
|
|
debug!("{} {}", method, path);
|
|
|
|
|
|
2026-01-24 23:09:46 +00:00
|
|
|
match (method, path.as_str()) {
|
2026-03-06 03:26:56 +00:00
|
|
|
// RPC — auth is handled inside rpc handler per-method
|
2026-01-27 22:37:08 +00:00
|
|
|
(Method::POST, "/rpc/v1") => self.rpc_handler.handle(req_with_bytes).await,
|
2026-03-06 03:26:56 +00:00
|
|
|
|
|
|
|
|
// Health — unauthenticated
|
2026-01-27 22:37:08 +00:00
|
|
|
(Method::GET, "/health") => Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.body(hyper::Body::from("OK"))
|
|
|
|
|
.unwrap()),
|
2026-03-06 03:26:56 +00:00
|
|
|
|
|
|
|
|
// Node message — P2P endpoint (authenticated by source validation, not cookie)
|
2026-02-17 15:03:34 +00:00
|
|
|
(Method::POST, "/archipelago/node-message") => {
|
|
|
|
|
Self::handle_node_message(body_bytes).await
|
|
|
|
|
}
|
2026-03-06 03:26:56 +00:00
|
|
|
|
2026-03-09 07:43:12 +00:00
|
|
|
// Content serving — peers access shared content over Tor (no session auth)
|
|
|
|
|
(Method::GET, p) if p.starts_with("/content/") => {
|
|
|
|
|
Self::handle_content_request(p, &headers, &self.config).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Content catalog — list available content (no session auth, for peers)
|
|
|
|
|
(Method::GET, "/content") => {
|
|
|
|
|
Self::handle_content_catalog(&self.config).await
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 03:26:56 +00:00
|
|
|
// Electrs status — unauthenticated (read-only sync status)
|
2026-02-17 15:03:34 +00:00
|
|
|
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
2026-03-06 03:26:56 +00:00
|
|
|
|
|
|
|
|
// Container logs — requires session
|
2026-02-14 16:44:20 +00:00
|
|
|
(Method::GET, path) if path.starts_with("/api/container/logs") => {
|
2026-03-06 03:26:56 +00:00
|
|
|
if !self.is_authenticated(&headers).await {
|
|
|
|
|
return Ok(Self::unauthorized());
|
|
|
|
|
}
|
|
|
|
|
Self::handle_container_logs_http(self.rpc_handler.clone(), path, &self.cors_origin()).await
|
2026-02-14 16:44:20 +00:00
|
|
|
}
|
2026-03-06 03:26:56 +00:00
|
|
|
|
|
|
|
|
// LND proxy — requires session
|
2026-02-14 16:44:20 +00:00
|
|
|
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
|
2026-03-06 03:26:56 +00:00
|
|
|
if !self.is_authenticated(&headers).await {
|
|
|
|
|
return Ok(Self::unauthorized());
|
|
|
|
|
}
|
|
|
|
|
Self::handle_lnd_proxy(path, &self.cors_origin()).await
|
2026-02-14 16:44:20 +00:00
|
|
|
}
|
2026-03-06 03:26:56 +00:00
|
|
|
|
2026-01-27 22:37:08 +00:00
|
|
|
_ => Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::NOT_FOUND)
|
|
|
|
|
.body(hyper::Body::from("Not Found"))
|
|
|
|
|
.unwrap()),
|
2026-01-24 22:59:20 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-27 22:37:08 +00:00
|
|
|
|
2026-02-14 16:44:20 +00:00
|
|
|
async fn handle_container_logs_http(
|
|
|
|
|
rpc: Arc<RpcHandler>,
|
|
|
|
|
path: &str,
|
2026-03-06 03:26:56 +00:00
|
|
|
cors_origin: &str,
|
2026-02-14 16:44:20 +00:00
|
|
|
) -> Result<Response<hyper::Body>> {
|
|
|
|
|
let query = path
|
|
|
|
|
.strip_prefix("/api/container/logs")
|
|
|
|
|
.and_then(|s| s.strip_prefix('?'))
|
|
|
|
|
.unwrap_or("");
|
|
|
|
|
let params: std::collections::HashMap<String, String> =
|
|
|
|
|
query
|
|
|
|
|
.split('&')
|
|
|
|
|
.filter_map(|p| {
|
|
|
|
|
let mut it = p.splitn(2, '=');
|
|
|
|
|
let k = it.next()?.to_string();
|
|
|
|
|
let v = it.next()?.to_string();
|
|
|
|
|
Some((k, v))
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
2026-03-06 03:26:56 +00:00
|
|
|
|
2026-02-14 16:44:20 +00:00
|
|
|
let app_id = params.get("app_id").map(|s| s.as_str()).unwrap_or("lnd");
|
2026-03-06 03:26:56 +00:00
|
|
|
|
|
|
|
|
// Validate app_id format
|
|
|
|
|
if !is_valid_app_id(app_id) {
|
|
|
|
|
let body = serde_json::json!({ "error": "Invalid app_id" });
|
|
|
|
|
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
|
|
|
|
return Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::BAD_REQUEST)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(hyper::Body::from(body_bytes))
|
|
|
|
|
.unwrap());
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 16:44:20 +00:00
|
|
|
let lines = params
|
|
|
|
|
.get("lines")
|
|
|
|
|
.and_then(|s| s.parse::<u32>().ok())
|
|
|
|
|
.unwrap_or(200);
|
|
|
|
|
|
|
|
|
|
match rpc.get_container_logs_value(app_id, lines).await {
|
|
|
|
|
Ok(value) => {
|
|
|
|
|
let body = serde_json::json!({ "result": value });
|
|
|
|
|
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
|
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header("Content-Type", "application/json")
|
2026-03-06 03:26:56 +00:00
|
|
|
.header("Access-Control-Allow-Origin", cors_origin)
|
|
|
|
|
.header("Access-Control-Allow-Credentials", "true")
|
|
|
|
|
.header("Vary", "Origin")
|
2026-02-14 16:44:20 +00:00
|
|
|
.body(hyper::Body::from(body_bytes))
|
|
|
|
|
.unwrap())
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
let body = serde_json::json!({ "error": e.to_string() });
|
|
|
|
|
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
|
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
|
|
|
.header("Content-Type", "application/json")
|
2026-03-06 03:26:56 +00:00
|
|
|
.header("Access-Control-Allow-Origin", cors_origin)
|
|
|
|
|
.header("Access-Control-Allow-Credentials", "true")
|
|
|
|
|
.header("Vary", "Origin")
|
2026-02-14 16:44:20 +00:00
|
|
|
.body(hyper::Body::from(body_bytes))
|
|
|
|
|
.unwrap())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 15:03:34 +00:00
|
|
|
async fn handle_node_message(body: hyper::body::Bytes) -> Result<Response<hyper::Body>> {
|
|
|
|
|
#[derive(serde::Deserialize)]
|
|
|
|
|
struct Incoming {
|
|
|
|
|
from_pubkey: Option<String>,
|
|
|
|
|
message: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
|
|
|
|
|
from_pubkey: None,
|
|
|
|
|
message: None,
|
|
|
|
|
});
|
|
|
|
|
if let (Some(from), Some(msg)) = (incoming.from_pubkey, incoming.message) {
|
2026-03-06 03:26:56 +00:00
|
|
|
// Validate from_pubkey is a valid hex ed25519 pubkey
|
|
|
|
|
if !is_valid_pubkey_hex(&from) {
|
|
|
|
|
return Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::BAD_REQUEST)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(hyper::Body::from(r#"{"error":"Invalid pubkey format"}"#))
|
|
|
|
|
.unwrap());
|
|
|
|
|
}
|
|
|
|
|
// Sanitize log output to prevent log injection
|
|
|
|
|
let safe_from = sanitize_log_string(&from);
|
|
|
|
|
let safe_msg = sanitize_log_string(&msg);
|
|
|
|
|
tracing::info!("Received message from {}: {}", safe_from, safe_msg);
|
|
|
|
|
// Sanitize stored message content (strip HTML entities)
|
|
|
|
|
let clean_from = sanitize_html(&from);
|
|
|
|
|
let clean_msg = sanitize_html(&msg);
|
|
|
|
|
node_msg::store_received(&clean_from, &clean_msg).await;
|
2026-02-17 15:03:34 +00:00
|
|
|
}
|
|
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(hyper::Body::from(r#"{"ok":true}"#))
|
|
|
|
|
.unwrap())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn handle_electrs_status() -> Result<Response<hyper::Body>> {
|
|
|
|
|
let status = electrs_status::get_electrs_sync_status().await;
|
|
|
|
|
let body = serde_json::to_vec(&status).unwrap_or_default();
|
|
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(hyper::Body::from(body))
|
|
|
|
|
.unwrap())
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 03:26:56 +00:00
|
|
|
async fn handle_lnd_proxy(path: &str, cors_origin: &str) -> Result<Response<hyper::Body>> {
|
2026-02-14 16:44:20 +00:00
|
|
|
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
|
|
|
|
let url = format!("http://127.0.0.1:8080{}", suffix);
|
|
|
|
|
match reqwest::get(&url).await {
|
|
|
|
|
Ok(resp) => {
|
|
|
|
|
let status = resp.status().as_u16();
|
|
|
|
|
let headers = resp.headers().clone();
|
|
|
|
|
let body = resp.bytes().await.unwrap_or_default();
|
|
|
|
|
let mut builder = Response::builder().status(status);
|
|
|
|
|
if let Some(ct) = headers.get("content-type") {
|
|
|
|
|
if let Ok(s) = ct.to_str() {
|
|
|
|
|
builder = builder.header("Content-Type", s);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
builder
|
2026-03-06 03:26:56 +00:00
|
|
|
.header("Access-Control-Allow-Origin", cors_origin)
|
|
|
|
|
.header("Access-Control-Allow-Credentials", "true")
|
|
|
|
|
.header("Vary", "Origin")
|
2026-02-14 16:44:20 +00:00
|
|
|
.body(hyper::Body::from(body))
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("response build: {}", e))
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
let body = serde_json::json!({ "error": e.to_string() });
|
|
|
|
|
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
|
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::BAD_GATEWAY)
|
|
|
|
|
.header("Content-Type", "application/json")
|
2026-03-06 03:26:56 +00:00
|
|
|
.header("Access-Control-Allow-Origin", cors_origin)
|
|
|
|
|
.header("Access-Control-Allow-Credentials", "true")
|
|
|
|
|
.header("Vary", "Origin")
|
2026-02-14 16:44:20 +00:00
|
|
|
.body(hyper::Body::from(body_bytes))
|
|
|
|
|
.unwrap())
|
2026-03-09 07:43:12 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn handle_content_catalog(config: &Config) -> Result<Response<hyper::Body>> {
|
|
|
|
|
match content_server::load_catalog(&config.data_dir).await {
|
|
|
|
|
Ok(catalog) => {
|
|
|
|
|
// Only expose public metadata, not file paths
|
|
|
|
|
let items: Vec<serde_json::Value> = catalog
|
|
|
|
|
.items
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|i| {
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"id": i.id,
|
|
|
|
|
"filename": i.filename,
|
|
|
|
|
"mime_type": i.mime_type,
|
|
|
|
|
"size_bytes": i.size_bytes,
|
|
|
|
|
"description": i.description,
|
|
|
|
|
"access": i.access,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
let body = serde_json::to_vec(&serde_json::json!({ "items": items }))
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(hyper::Body::from(body))
|
|
|
|
|
.unwrap())
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
let body = serde_json::json!({ "error": e.to_string() });
|
|
|
|
|
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
|
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(hyper::Body::from(body_bytes))
|
|
|
|
|
.unwrap())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn handle_content_request(
|
|
|
|
|
path: &str,
|
|
|
|
|
headers: &hyper::HeaderMap,
|
|
|
|
|
config: &Config,
|
|
|
|
|
) -> Result<Response<hyper::Body>> {
|
|
|
|
|
let content_id = path.strip_prefix("/content/").unwrap_or("");
|
|
|
|
|
if content_id.is_empty() || !is_valid_app_id(content_id) {
|
|
|
|
|
return Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::BAD_REQUEST)
|
|
|
|
|
.body(hyper::Body::from("Invalid content ID"))
|
|
|
|
|
.unwrap());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Extract payment token from X-Payment-Token header
|
|
|
|
|
let payment_token = headers
|
|
|
|
|
.get("x-payment-token")
|
|
|
|
|
.and_then(|v| v.to_str().ok())
|
|
|
|
|
.map(|s| s.to_string());
|
|
|
|
|
|
|
|
|
|
// Parse Range header for streaming support
|
|
|
|
|
let range = headers
|
|
|
|
|
.get("range")
|
|
|
|
|
.and_then(|v| v.to_str().ok())
|
|
|
|
|
.and_then(content_server::parse_range_header);
|
|
|
|
|
|
|
|
|
|
match content_server::serve_content(
|
|
|
|
|
&config.data_dir,
|
|
|
|
|
content_id,
|
|
|
|
|
payment_token.as_deref(),
|
|
|
|
|
range,
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(content_server::ServeResult::Ok(bytes, mime_type)) => {
|
|
|
|
|
let len = bytes.len();
|
|
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header("Content-Type", mime_type)
|
|
|
|
|
.header("Content-Length", len.to_string())
|
|
|
|
|
.header("Accept-Ranges", "bytes")
|
|
|
|
|
.body(hyper::Body::from(bytes))
|
|
|
|
|
.unwrap())
|
|
|
|
|
}
|
|
|
|
|
Ok(content_server::ServeResult::Partial {
|
|
|
|
|
bytes,
|
|
|
|
|
mime_type,
|
|
|
|
|
start,
|
|
|
|
|
end,
|
|
|
|
|
total,
|
|
|
|
|
}) => {
|
|
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::PARTIAL_CONTENT)
|
|
|
|
|
.header("Content-Type", mime_type)
|
|
|
|
|
.header("Content-Length", bytes.len().to_string())
|
|
|
|
|
.header("Content-Range", format!("bytes {}-{}/{}", start, end, total))
|
|
|
|
|
.header("Accept-Ranges", "bytes")
|
|
|
|
|
.body(hyper::Body::from(bytes))
|
|
|
|
|
.unwrap())
|
|
|
|
|
}
|
|
|
|
|
Ok(content_server::ServeResult::PaymentRequired(price_sats)) => {
|
|
|
|
|
let body = serde_json::json!({
|
|
|
|
|
"error": "Payment required",
|
|
|
|
|
"price_sats": price_sats,
|
|
|
|
|
"payment_header": "X-Payment-Token",
|
|
|
|
|
});
|
|
|
|
|
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
|
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::PAYMENT_REQUIRED)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(hyper::Body::from(body_bytes))
|
|
|
|
|
.unwrap())
|
|
|
|
|
}
|
|
|
|
|
Ok(content_server::ServeResult::NotFound) | Err(_) => {
|
|
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::NOT_FOUND)
|
|
|
|
|
.body(hyper::Body::from("Content not found"))
|
|
|
|
|
.unwrap())
|
2026-02-14 16:44:20 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 22:37:08 +00:00
|
|
|
async fn handle_websocket(
|
|
|
|
|
req: Request<hyper::Body>,
|
2026-01-27 23:06:18 +00:00
|
|
|
state_manager: Arc<StateManager>,
|
2026-01-27 22:37:08 +00:00
|
|
|
) -> Result<Response<hyper::Body>> {
|
2026-01-27 22:47:51 +00:00
|
|
|
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
2026-01-27 22:37:08 +00:00
|
|
|
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
|
|
|
|
|
2026-01-27 22:47:51 +00:00
|
|
|
if let Some(ws_fut) = ws_fut_opt {
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
let ws_stream: WsStream = match ws_fut.await {
|
|
|
|
|
Ok(Ok(s)) => s,
|
|
|
|
|
Ok(Err(e)) => {
|
|
|
|
|
debug!("WebSocket handshake failed (hyper): {}", e);
|
|
|
|
|
return;
|
2026-01-27 22:37:08 +00:00
|
|
|
}
|
|
|
|
|
Err(e) => {
|
2026-01-27 22:47:51 +00:00
|
|
|
debug!("WebSocket task join failed: {}", e);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
info!("WebSocket /ws/db connected");
|
2026-03-06 03:26:56 +00:00
|
|
|
|
2026-01-27 22:47:51 +00:00
|
|
|
let (mut tx, mut rx) = ws_stream.split();
|
2026-03-06 03:26:56 +00:00
|
|
|
|
2026-01-27 23:06:18 +00:00
|
|
|
let initial_msg = state_manager.get_initial_message().await;
|
|
|
|
|
if let Ok(json_msg) = serde_json::to_string(&initial_msg) {
|
|
|
|
|
if let Err(e) = tx.send(Message::Text(json_msg)).await {
|
|
|
|
|
debug!("Failed to send initial data: {}", e);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
debug!("Sent initial data dump at revision {}", initial_msg.rev);
|
|
|
|
|
}
|
2026-03-06 03:26:56 +00:00
|
|
|
|
2026-02-01 13:24:03 +00:00
|
|
|
let mut state_rx = state_manager.subscribe();
|
2026-01-27 22:55:20 +00:00
|
|
|
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
|
|
|
|
tokio::pin!(ping_interval);
|
2026-03-06 03:26:56 +00:00
|
|
|
|
2026-01-27 22:55:20 +00:00
|
|
|
loop {
|
|
|
|
|
tokio::select! {
|
|
|
|
|
_ = ping_interval.tick() => {
|
|
|
|
|
if tx.send(Message::Ping(vec![])).await.is_err() {
|
|
|
|
|
debug!("Failed to send ping, connection likely closed");
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-01-27 22:47:51 +00:00
|
|
|
}
|
2026-02-01 13:24:03 +00:00
|
|
|
update = state_rx.recv() => {
|
|
|
|
|
match update {
|
|
|
|
|
Ok(msg) => {
|
|
|
|
|
if let Ok(json_msg) = serde_json::to_string(&msg) {
|
|
|
|
|
if let Err(e) = tx.send(Message::Text(json_msg)).await {
|
|
|
|
|
debug!("Failed to send state update: {}", e);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
debug!("Sent state update at revision {}", msg.rev);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
|
|
|
|
debug!("Client lagged behind, skipped {} messages", skipped);
|
|
|
|
|
}
|
|
|
|
|
Err(broadcast::error::RecvError::Closed) => {
|
|
|
|
|
debug!("Broadcast channel closed");
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-27 22:55:20 +00:00
|
|
|
msg = rx.next() => {
|
|
|
|
|
match msg {
|
|
|
|
|
Some(Ok(Message::Close(_))) => break,
|
|
|
|
|
Some(Ok(Message::Pong(_))) => {
|
|
|
|
|
debug!("Received pong");
|
|
|
|
|
}
|
|
|
|
|
Some(Ok(Message::Ping(data))) => {
|
|
|
|
|
let _ = tx.send(Message::Pong(data)).await;
|
|
|
|
|
}
|
|
|
|
|
Some(Ok(_)) => {}
|
|
|
|
|
Some(Err(e)) => {
|
|
|
|
|
debug!("WebSocket stream error: {}", e);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
None => break,
|
|
|
|
|
}
|
2026-01-27 22:47:51 +00:00
|
|
|
}
|
2026-01-27 22:37:08 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-27 22:47:51 +00:00
|
|
|
info!("WebSocket /ws/db disconnected");
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-01-27 22:37:08 +00:00
|
|
|
|
|
|
|
|
Ok(response)
|
|
|
|
|
}
|
2026-01-24 22:59:20 +00:00
|
|
|
}
|
2026-03-06 03:26:56 +00:00
|
|
|
|
|
|
|
|
/// Validate that an app ID matches the safe pattern: lowercase alphanumeric + hyphens.
|
|
|
|
|
fn is_valid_app_id(id: &str) -> bool {
|
|
|
|
|
!id.is_empty()
|
|
|
|
|
&& id.len() <= 64
|
|
|
|
|
&& id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
|
|
|
|
&& id.as_bytes()[0] != b'-'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validate that a pubkey is a 64-char hex string.
|
|
|
|
|
fn is_valid_pubkey_hex(s: &str) -> bool {
|
|
|
|
|
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Strip newlines and ANSI escape sequences from strings before logging.
|
|
|
|
|
fn sanitize_log_string(s: &str) -> String {
|
|
|
|
|
s.replace('\n', "\\n")
|
|
|
|
|
.replace('\r', "\\r")
|
|
|
|
|
.replace('\x1b', "")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Strip HTML-sensitive characters to prevent XSS when stored/rendered.
|
|
|
|
|
fn sanitize_html(s: &str) -> String {
|
|
|
|
|
s.replace('&', "&")
|
|
|
|
|
.replace('<', "<")
|
|
|
|
|
.replace('>', ">")
|
|
|
|
|
.replace('"', """)
|
|
|
|
|
.replace('\'', "'")
|
|
|
|
|
}
|