ItsGoin v0.3.2 — Decentralized social media network
No central server, user-owned data, reverse-chronological feed. Rust core + Tauri desktop + Android app + plain HTML/CSS/JS frontend. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
800388cda4
146 changed files with 53227 additions and 0 deletions
456
crates/core/src/web.rs
Normal file
456
crates/core/src/web.rs
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
//! 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
|
||||
};
|
||||
|
||||
// Gather all known holders: author + CDN downstream peers
|
||||
let (holders, local_post) = {
|
||||
let store = node.storage.lock().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();
|
||||
(holders, local)
|
||||
};
|
||||
|
||||
// --- 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
|
||||
if let Some((post, visibility)) = local_post {
|
||||
if matches!(visibility, PostVisibility::Public) {
|
||||
let author_name = {
|
||||
let store = node.storage.lock().await;
|
||||
store.get_profile(&post.author).ok().flatten()
|
||||
.map(|p| p.display_name).unwrap_or_default()
|
||||
};
|
||||
let html = render_post_html(&post, &post_id, &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))) => {
|
||||
{
|
||||
let store = node.storage.lock().await;
|
||||
let _ = store.store_post_with_visibility(
|
||||
&sync_post.id, &sync_post.post, &sync_post.visibility,
|
||||
);
|
||||
}
|
||||
let author_name = {
|
||||
let store = node.storage.lock().await;
|
||||
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.lock().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.lock().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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue