//! 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// → render post HTML (fetched on-demand) //! GET /b/ → 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, ) -> 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) { 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// /// /// 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, 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 = 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/) 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, holders: &[NodeId], post_id: &PostId, browser_ip: Option<&str>, ) -> Option { 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 = 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::() { 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, author: &NodeId, post_id: &PostId, ) -> anyhow::Result> { // 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 = [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/ async fn serve_blob(stream: &mut TcpStream, path: &str, node: &Arc) { 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 { 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##" ItsGoin

This content isn't currently reachable.

It may be available again when someone who has it comes back online.

Install ItsGoin to find it when it resurfaces
"##.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() }