itsgoin/crates/core/src/web.rs
Scott Reimers 43adbbdf7d v0.4.3: Lock contention overhaul, StoragePool, mobile bottom nav, text scaling
Eliminate all conn_mgr lock holds during network I/O across 14 actor commands
and bi-stream handlers. PostFetch, TcpPunch, PullFromPeer, FetchEngagement,
ResolveAddress, AnchorProbe use brief locks for data gathering only. WormLookup,
ContentSearch, WormQuery use connection snapshots for lock-free cascade fan-out.
RelayIntroduce extracts forwarding data under brief lock, does I/O outside.
BlobRequest, PostFetchRequest, ManifestRefresh use Arc clones instead of conn_mgr
lock. ConnectionActor hoists shared Arcs (storage, blob_store, endpoint) for
lock-free access. ResolveAddress adds 5s per-query timeout (was unbounded).

Initial exchange failure now aborts mesh upgrade (was silently continuing with
broken connection). connect_to_peer/connect_to_anchor use consistent 15s timeout.
Rebalance connects outside the lock via pending_connects pattern.

StoragePool: 8 concurrent SQLite connections in WAL mode replace single
Mutex<Storage>. Reads run fully parallel; writes serialize at SQLite level only.
PRAGMA busy_timeout=5000 for graceful write contention.

Mobile bottom nav bar (<=768px) with icon tabs. Text sizes: XS/S/M/L/XL
(75%/100%/125%/150%/200%), default M. localStorage persistence for instant
restore. Toast repositioned above mobile nav.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:35:38 -04:00

460 lines
17 KiB
Rust

//! itsgoin.net web handler — serves shared posts by proxying content through
//! the anchor node. On-demand: connects to the author via QUIC, pulls the post,
//! renders HTML, serves blobs. No permanent storage of fetched content.
//!
//! Routes (behind Apache reverse proxy):
//! GET /p/<postid_hex>/<author_hex> → render post HTML (fetched on-demand)
//! GET /b/<blobid_hex> → serve blob (images/videos)
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tracing::{debug, info, warn};
use crate::http::render_post_html;
use crate::node::Node;
use crate::types::{NodeId, PostId, PostVisibility};
/// Run the web handler on the given port. Blocks forever.
pub async fn run_web_handler(
port: u16,
node: Arc<Node>,
) -> anyhow::Result<()> {
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
let listener = TcpListener::bind(addr).await?;
info!("Web handler listening on {}", addr);
loop {
let (stream, _peer_addr) = match listener.accept().await {
Ok(v) => v,
Err(e) => {
debug!("Web accept error: {}", e);
continue;
}
};
let node = Arc::clone(&node);
tokio::spawn(async move {
handle_web_request(stream, &node).await;
});
}
}
async fn handle_web_request(mut stream: TcpStream, node: &Arc<Node>) {
let mut buf = vec![0u8; 4096];
let n = match tokio::time::timeout(
std::time::Duration::from_secs(5),
stream.read(&mut buf),
)
.await
{
Ok(Ok(0)) | Ok(Err(_)) | Err(_) => return,
Ok(Ok(n)) => n,
};
let request_bytes = &buf[..n];
let (method, path) = match parse_request_line(request_bytes) {
Some(v) => v,
None => return,
};
if method != "GET" {
return;
}
// Extract X-Forwarded-For header (set by Apache reverse proxy)
let browser_ip = extract_header(request_bytes, "x-forwarded-for")
.and_then(|v| v.split(',').next().map(|s| s.trim().to_string()));
if path.starts_with("/p/") {
serve_post(&mut stream, path, node, browser_ip.as_deref()).await;
} else if path.starts_with("/b/") {
serve_blob(&mut stream, path, node).await;
}
}
/// Extract a header value from raw HTTP request bytes (case-insensitive).
fn extract_header<'a>(buf: &'a [u8], name: &str) -> Option<&'a str> {
let text = std::str::from_utf8(buf).ok()?;
let name_lower = name.to_lowercase();
for line in text.split("\r\n") {
if let Some(colon) = line.find(':') {
if line[..colon].to_lowercase() == name_lower {
return Some(line[colon + 1..].trim());
}
}
}
None
}
/// Handle GET /p/<postid_hex>/<author_hex>
///
/// Three-tier serving:
/// 1. Redirect to a CDN holder with a public/punchable HTTP server
/// 2. TCP hole-punch + redirect for EIM NAT holders
/// 3. QUIC proxy fallback (fetch post + render HTML here)
async fn serve_post(stream: &mut TcpStream, path: &str, node: &Arc<Node>, browser_ip: Option<&str>) {
let rest = &path[3..]; // strip "/p/"
// Parse post_id (64 hex chars)
if rest.len() < 64 {
let _ = write_http_response(stream, 404, "text/plain", b"Not found").await;
return;
}
let post_hex = &rest[..64];
if !post_hex.chars().all(|c| c.is_ascii_hexdigit()) {
return;
}
let post_id: PostId = match hex::decode(post_hex) {
Ok(b) if b.len() == 32 => b.try_into().unwrap(),
_ => return,
};
// Parse optional author_id (after the slash)
let author_id: Option<NodeId> = if rest.len() > 65 {
let author_hex = &rest[65..];
if author_hex.len() == 64 && author_hex.chars().all(|c| c.is_ascii_hexdigit()) {
hex::decode(author_hex).ok().and_then(|b| b.try_into().ok())
} else {
None
}
} else {
None
};
// Single lock: gather holders, local post, AND author name if local
let (holders, local_post, local_author_name) = {
let store = node.storage.get().await;
let mut holders = Vec::new();
if let Some(author) = author_id {
holders.push(author);
}
if let Ok(downstream) = store.get_post_downstream(&post_id) {
for peer in downstream {
if !holders.contains(&peer) {
holders.push(peer);
}
}
}
let local = store.get_post_with_visibility(&post_id).ok().flatten();
// If we have the post locally and it's public, get author name now
let author_name = if let Some((ref post, ref vis)) = local {
if matches!(vis, PostVisibility::Public) {
store.get_profile(&post.author).ok().flatten()
.map(|p| p.display_name).unwrap_or_default()
} else {
String::new()
}
} else {
String::new()
};
(holders, local, author_name)
};
// --- Tier 1 & 2: Try direct redirect to an HTTP-capable holder ---
if let Some(redirect_url) = try_redirect(node, &holders, &post_id, browser_ip).await {
let header = format!(
"HTTP/1.1 302 Found\r\nLocation: {}\r\nConnection: close\r\n\r\n",
redirect_url
);
let _ = stream.write_all(header.as_bytes()).await;
info!("Web: redirected post {} to {}", post_hex, redirect_url);
return;
}
// --- Tier 3: QUIC proxy fallback ---
// Check local storage first (author_name already fetched above)
if let Some((post, visibility)) = local_post {
if matches!(visibility, PostVisibility::Public) {
let html = render_post_html(&post, &post_id, &local_author_name);
let _ = write_http_response(stream, 200, "text/html; charset=utf-8", html.as_bytes()).await;
return;
}
}
// Fetch via content search + PostFetch
let author = author_id.unwrap_or([0u8; 32]);
info!("Web: proxying post {} via QUIC (no redirect candidate found)", post_hex);
let search_result = tokio::time::timeout(
std::time::Duration::from_secs(15),
fetch_post_from_network(node, &author, &post_id),
).await;
match search_result {
Ok(Ok(Some(sync_post))) => {
// Single lock: store post AND get author name
let author_name = {
let store = node.storage.get().await;
let _ = store.store_post_with_visibility(
&sync_post.id, &sync_post.post, &sync_post.visibility,
);
store.get_profile(&sync_post.post.author).ok().flatten()
.map(|p| p.display_name).unwrap_or_default()
};
let html = render_post_html(&sync_post.post, &post_id, &author_name);
let _ = write_http_response(stream, 200, "text/html; charset=utf-8", html.as_bytes()).await;
return;
}
Ok(Ok(None)) => {
debug!("Web: post not found via network search");
}
Ok(Err(e)) => {
warn!("Web: network search failed: {}", e);
}
Err(_) => {
warn!("Web: network search timed out (15s)");
}
}
let html = render_unavailable_screen();
let _ = write_http_response(stream, 200, "text/html; charset=utf-8", html.as_bytes()).await;
}
/// Try to redirect to an HTTP-capable holder of this post.
/// Returns a redirect URL (http://ip:port/p/<hex>) if a suitable holder is found.
///
/// Tier 1: Holders with http_capable=true and a known http_addr (publicly reachable).
/// Tier 2: Holders with EIM NAT — send TCP punch request, then redirect.
async fn try_redirect(
node: &Arc<Node>,
holders: &[NodeId],
post_id: &PostId,
browser_ip: Option<&str>,
) -> Option<String> {
use crate::types::NatMapping;
let post_hex = hex::encode(post_id);
let store = node.storage.get().await;
// Classify holders into tiers
let mut direct_candidates: Vec<(NodeId, String)> = Vec::new(); // http_addr known
let mut punch_candidates: Vec<NodeId> = Vec::new(); // EIM NAT, connected
for holder in holders {
let (capable, addr) = store.get_peer_http_info(holder);
if capable {
if let Some(ref addr) = addr {
direct_candidates.push((*holder, addr.clone()));
continue;
}
}
// Check if this peer has EIM NAT (punchable)
let profile = store.get_peer_nat_profile(holder);
if profile.mapping == NatMapping::EndpointIndependent {
punch_candidates.push(*holder);
}
}
drop(store);
// Tier 1: Direct redirect to a publicly-reachable holder
for (_holder, addr) in &direct_candidates {
// Skip unroutable addresses (0.0.0.0, 127.x, etc.)
if let Some(ip_str) = addr.split(':').next() {
if let Ok(ip) = ip_str.parse::<std::net::IpAddr>() {
if ip.is_unspecified() || ip.is_loopback() {
continue;
}
}
}
// Verify holder is actually connected (likely still alive)
if node.network.is_connected(_holder).await
|| node.network.has_session(_holder).await
{
return Some(format!("http://{}/p/{}", addr, post_hex));
}
}
// Tier 2: TCP punch + redirect for EIM NAT holders
if let Some(browser_ip) = browser_ip {
for holder in &punch_candidates {
// Must be connected to send the punch request
if !node.network.is_connected(holder).await
&& !node.network.has_session(holder).await
{
continue;
}
match node.network.tcp_punch(holder, browser_ip.to_string(), post_id).await {
Ok(Some(http_addr)) => {
info!("Web: TCP punch succeeded for {}, redirecting to {}",
hex::encode(holder), http_addr);
return Some(format!("http://{}/p/{}", http_addr, post_hex));
}
Ok(None) => {
debug!("Web: TCP punch failed for {}", hex::encode(holder));
}
Err(e) => {
debug!("Web: TCP punch error for {}: {}", hex::encode(holder), e);
}
}
}
}
None
}
/// Search the network for a post using extended worm search, then fetch it.
async fn fetch_post_from_network(
node: &Arc<Node>,
author: &NodeId,
post_id: &PostId,
) -> anyhow::Result<Option<crate::protocol::SyncPost>> {
// Step 1: Try direct connect to author + pull (fast path)
if *author != [0u8; 32] {
if let Ok(()) = node.connect_by_node_id(*author).await {
// Try PostFetch directly from author
if let Ok(Some(sp)) = node.network.post_fetch(author, post_id).await {
return Ok(Some(sp));
}
}
}
// Step 2: Content search — worm with post_id
let search = node.network.content_search(author, Some(*post_id), None).await?;
if let Some(result) = search {
// Try the post_holder first, then the found node
let holders: Vec<NodeId> = [result.post_holder, Some(result.node_id)]
.into_iter()
.flatten()
.collect();
for holder in holders {
// Connect if needed
let _ = node.connect_by_node_id(holder).await;
if let Ok(Some(sp)) = node.network.post_fetch(&holder, post_id).await {
return Ok(Some(sp));
}
}
}
Ok(None)
}
/// Handle GET /b/<blobid_hex>
async fn serve_blob(stream: &mut TcpStream, path: &str, node: &Arc<Node>) {
let blob_hex = &path[3..]; // strip "/b/"
if blob_hex.len() != 64 || !blob_hex.chars().all(|c| c.is_ascii_hexdigit()) {
return;
}
let blob_id: [u8; 32] = match hex::decode(blob_hex) {
Ok(b) if b.len() == 32 => b.try_into().unwrap(),
_ => return,
};
// Find which public post owns this blob and get the mime type + author.
// Check blobs table first, then scan post attachments (for posts stored via PostFetch
// which don't populate the blobs table).
let (mime_type, author_id) = {
let store = node.storage.get().await;
// Try blobs table first
if let Some(mime) = find_public_blob_mime(&store, &blob_id) {
let author = store.get_blob_post_id(&blob_id).ok().flatten().and_then(|pid| {
store.get_post_with_visibility(&pid).ok().flatten().map(|(p, _)| p.author)
});
(mime, author)
} else {
// Scan recent posts for this blob CID in their attachments
match find_blob_in_posts(&store, &blob_id) {
Some((mime, author)) => (mime, Some(author)),
None => return,
}
}
};
// Try local blob store first
if let Ok(Some(data)) = node.blob_store.get(&blob_id) {
let _ = write_http_response(stream, 200, &mime_type, &data).await;
return;
}
// Blob not on disk — fetch from author via BlobRequest
if let Some(author) = author_id {
info!("Web: fetching blob {} from author {}", blob_hex, hex::encode(author));
// Connect to author if needed
let _ = node.connect_by_node_id(author).await;
if let Ok(Some(blob_data)) = node.network.fetch_blob(&blob_id, &author).await {
let _ = write_http_response(stream, 200, &mime_type, &blob_data).await;
return;
}
}
// Not found
let _ = write_http_response(stream, 404, "text/plain", b"Blob not found").await;
}
/// Search post attachments for a blob CID. Returns (mime_type, author).
/// Used when the blobs table doesn't have an entry (e.g. posts stored via PostFetch).
fn find_blob_in_posts(store: &crate::storage::Storage, blob_id: &[u8; 32]) -> Option<(String, NodeId)> {
store.find_blob_in_post_attachments(blob_id).ok()?
}
/// Find a blob's mime type, verifying it belongs to a public post.
fn find_public_blob_mime(store: &crate::storage::Storage, blob_id: &[u8; 32]) -> Option<String> {
let post_id = store.get_blob_post_id(blob_id).ok()??;
let (post, visibility) = store.get_post_with_visibility(&post_id).ok()??;
if !matches!(visibility, PostVisibility::Public) {
return None;
}
for att in &post.attachments {
if att.cid == *blob_id {
return Some(att.mime_type.clone());
}
}
None
}
fn render_unavailable_screen() -> String {
r##"<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ItsGoin</title>
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0d0d0d;color:#e0e0e0;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0}
.card{background:#1a1a1a;border-radius:16px;padding:2.5rem;max-width:400px;text-align:center}
.hex{font-size:3rem;margin-bottom:1rem}h2{color:#5b8def;margin:0 0 0.5rem;font-size:1.3rem}
p{color:#888;font-size:0.9rem;line-height:1.5;margin:0.5rem 0}
.btn{display:inline-block;padding:0.7rem 1.5rem;border-radius:8px;text-decoration:none;font-weight:600;font-size:0.9rem;margin-top:1rem;background:#5b8def;color:#fff}
.btn:hover{background:#4a7cde}</style></head><body>
<div class="card"><div class="hex">&#x2B21;</div>
<h2>This content isn't currently reachable.</h2>
<p>It may be available again when someone who has it comes back online.</p>
<a class="btn" href="https://itsgoin.com">Install ItsGoin to find it when it resurfaces</a>
</div></body></html>"##.to_string()
}
/// Parse "GET /path HTTP/1.x\r\n..." → ("GET", "/path")
fn parse_request_line(buf: &[u8]) -> Option<(&str, &str)> {
let line_end = buf.iter().position(|&b| b == b'\r' || b == b'\n')?;
let line = std::str::from_utf8(&buf[..line_end]).ok()?;
let mut parts = line.split(' ');
let method = parts.next()?;
let path = parts.next()?;
let version = parts.next()?;
if !version.starts_with("HTTP/") {
return None;
}
Some((method, path))
}
async fn write_http_response(stream: &mut TcpStream, status: u16, content_type: &str, body: &[u8]) -> bool {
let status_text = match status {
200 => "OK",
404 => "Not Found",
_ => "Error",
};
let header = format!(
"HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n",
status, status_text, content_type, body.len()
);
if stream.write_all(header.as_bytes()).await.is_err() {
return false;
}
stream.write_all(body).await.is_ok()
}