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>
460 lines
17 KiB
Rust
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">⬡</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()
|
|
}
|