From 8fad30cf959c3ab935e80a94da7d2ea2cbe00633 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Mon, 16 Mar 2026 18:37:24 -0400 Subject: [PATCH] v0.3.3: Rate limiting, IPv6 fix, schema versioning, video preload, engagement propagation Security & stability: - Incoming auth-fail rate limiting per source IP (3 attempts, then exponential backoff) - Schema versioning via PRAGMA user_version with migration framework Networking: - IPv6 http_addr fix: advertise actual public IPv6 instead of 0.0.0.0 - N2/N3 TTL reduced from 7 days to 5 hours - Full N1/N2 state re-broadcast every 4 hours - Bootstrap isolation recovery: 24h check with sticky N1 advertising - Bidirectional engagement propagation (upstream + downstream) - Auto downstream registration on pull sync and push notification - post_upstream table for CDN tree traversal Media & UI: - Video preload="auto" for share links and in-app blob URLs - Following: Online/Offline split with last-seen timestamps - DMs filtered from My Posts tab - Image lightbox, audio player, file attachments with download prompt - Share link unroutable address filtering Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 2 +- crates/core/src/http.rs | 2 +- crates/core/src/network.rs | 60 ++++++++++++++++++++++++++++++-- crates/core/src/storage.rs | 58 +++++++++++++++++++++++++++++- crates/tauri-app/Cargo.toml | 2 +- crates/tauri-app/tauri.conf.json | 2 +- frontend/app.js | 1 + website/download.html | 26 +++++++++----- 8 files changed, 136 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4625f90..1a9a4cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2550,7 +2550,7 @@ dependencies = [ [[package]] name = "itsgoin-desktop" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/crates/core/src/http.rs b/crates/core/src/http.rs index cab12bf..5efbe50 100644 --- a/crates/core/src/http.rs +++ b/crates/core/src/http.rs @@ -474,7 +474,7 @@ pub fn render_post_html(post: &crate::types::Post, _post_id: &[u8; 32], author_n let cid_hex = hex::encode(&att.cid); if att.mime_type.starts_with("video/") { attachments_html.push_str(&format!( - r#""#, + r#""#, cid_hex )); } else { diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index 2fb90ae..f6eab8d 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -360,9 +360,14 @@ impl Network { } return Some(bind.to_string()); } - // For public IPv6, the external addr is the bound socket addr + // For public IPv6, use the actual public address from iroh + bound port if self.has_public_v6 { - return self.endpoint.bound_sockets().first().map(|s| s.to_string()); + let port = self.endpoint.bound_sockets().first().map(|s| s.port()).unwrap_or(0); + if let Some(public_v6) = self.endpoint.addr().ip_addrs() + .find(|s| matches!(s.ip(), std::net::IpAddr::V6(_)) && is_publicly_routable(s)) + { + return Some(std::net::SocketAddr::new(public_v6.ip(), port).to_string()); + } } None } @@ -383,15 +388,55 @@ impl Network { /// only InitialExchange triggers mesh slot allocation. pub async fn run_accept_loop(self: Arc) -> anyhow::Result<()> { info!("Accepting incoming connections (v3 ephemeral)..."); + + // Rate limit: track auth failures per source IP + let fail_tracker: Arc>> = + Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())); + // Cleanup stale entries every 60s + let ft_cleanup: Arc>> = Arc::clone(&fail_tracker); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + loop { + interval.tick().await; + let mut ft = ft_cleanup.lock().await; + let now = tokio::time::Instant::now(); + ft.retain(|_, (_, last)| now.duration_since(*last).as_secs() < 300); + } + }); + while let Some(incoming) = self.endpoint.accept().await { let this = Arc::clone(&self); let remote_sock = crate::connection::normalize_addr(incoming.remote_address()); + let ft = Arc::clone(&fail_tracker); + + // Check if this IP is rate-limited before spawning + { + let tracker = ft.lock().await; + if let Some((count, last)) = tracker.get(&remote_sock.ip()) { + let elapsed = tokio::time::Instant::now().duration_since(*last); + // Exponential backoff: block for 2^(failures-3) seconds after 3+ failures + if *count >= 3 { + let block_secs = 1u64 << (*count - 3).min(8); // max ~256s + if elapsed.as_secs() < block_secs { + // Silently drop — don't even log to avoid log spam + continue; + } + } + } + } + tokio::spawn(async move { match incoming.await { Ok(conn) => { let remote = conn.remote_id(); let remote_node_id = *remote.as_bytes(); + // Successful connection — clear failure count + { + let mut tracker = ft.lock().await; + tracker.remove(&remote_sock.ip()); + } + // Store peer with their address { let storage = this.storage.lock().await; @@ -414,7 +459,16 @@ impl Network { .await; } Err(e) => { - warn!(error = %e, "Failed to accept connection"); + // Track auth failure for this IP + let mut tracker = ft.lock().await; + let entry = tracker.entry(remote_sock.ip()) + .or_insert((0, tokio::time::Instant::now())); + entry.0 += 1; + entry.1 = tokio::time::Instant::now(); + if entry.0 <= 3 { + warn!(error = %e, addr = %remote_sock.ip(), failures = entry.0, "Failed to accept connection"); + } + // After 3 failures, stop logging (rate limited silently) } } }); diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index 654a485..d8e1a54 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -28,16 +28,72 @@ pub struct Storage { conn: Connection, } +/// Current schema version. Bump this when making schema or data changes +/// that require migration. Old databases with a lower version will be migrated. +/// If the gap is too large (major version mismatch), the DB is reset instead. +const SCHEMA_VERSION: u32 = 2; + +/// Minimum schema version we can migrate from. Anything older gets a full reset. +const MIN_MIGRATABLE_VERSION: u32 = 1; + impl Storage { pub fn open(path: impl AsRef) -> anyhow::Result { - let conn = Connection::open(path)?; + let conn = Connection::open(path.as_ref())?; conn.execute_batch("PRAGMA journal_mode=WAL;")?; + + // Check schema version + let db_version: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; + + if db_version > 0 && db_version < MIN_MIGRATABLE_VERSION { + // Too old to migrate — reset the database + tracing::warn!( + db_version, current = SCHEMA_VERSION, + "Database schema too old to migrate, resetting" + ); + drop(conn); + std::fs::remove_file(path.as_ref())?; + let conn = Connection::open(path.as_ref())?; + conn.execute_batch("PRAGMA journal_mode=WAL;")?; + let storage = Self { conn }; + storage.init_tables()?; + storage.set_schema_version(SCHEMA_VERSION)?; + return Ok(storage); + } + let storage = Self { conn }; storage.init_tables()?; storage.migrate()?; + + if db_version < SCHEMA_VERSION { + // Run version-specific data migrations + storage.migrate_data(db_version)?; + storage.set_schema_version(SCHEMA_VERSION)?; + tracing::info!(from = db_version, to = SCHEMA_VERSION, "Database schema upgraded"); + } + Ok(storage) } + fn set_schema_version(&self, version: u32) -> anyhow::Result<()> { + self.conn.pragma_update(None, "user_version", version)?; + Ok(()) + } + + /// Data migrations that run once when upgrading between schema versions. + fn migrate_data(&self, from_version: u32) -> anyhow::Result<()> { + if from_version < 2 { + // v1 → v2: Clear stale N2/N3 entries and mesh_peers that may prevent + // bootstrap reconnection. The node will rediscover peers on next startup. + tracing::info!("Schema v2: clearing stale network state for fresh discovery"); + let _ = self.conn.execute_batch( + "DELETE FROM reachable_n2; + DELETE FROM reachable_n3; + DELETE FROM mesh_peers;" + ); + } + Ok(()) + } + fn init_tables(&self) -> anyhow::Result<()> { self.conn.execute_batch( "CREATE TABLE IF NOT EXISTS posts ( diff --git a/crates/tauri-app/Cargo.toml b/crates/tauri-app/Cargo.toml index f3f21e9..8f5c740 100644 --- a/crates/tauri-app/Cargo.toml +++ b/crates/tauri-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-desktop" -version = "0.3.2" +version = "0.3.3" edition = "2021" [lib] diff --git a/crates/tauri-app/tauri.conf.json b/crates/tauri-app/tauri.conf.json index 48bc821..a919512 100644 --- a/crates/tauri-app/tauri.conf.json +++ b/crates/tauri-app/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "itsgoin", - "version": "0.3.2", + "version": "0.3.3", "identifier": "com.itsgoin.app", "build": { "frontendDist": "../../frontend", diff --git a/frontend/app.js b/frontend/app.js index 83cccc1..5e94aca 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1644,6 +1644,7 @@ async function loadPostMedia(container) { const mime = vid.dataset.mime || 'video/mp4'; try { vid.src = await loadBlobAsObjectUrl(cid, postId, mime); + vid.preload = 'auto'; } catch (e) { vid.poster = ''; vid.insertAdjacentHTML('afterend', 'Video unavailable'); diff --git a/website/download.html b/website/download.html index 37ec8ff..337dc20 100644 --- a/website/download.html +++ b/website/download.html @@ -24,16 +24,16 @@

Download ItsGoin

Available for Android and Linux. Free and open source.

-

Version 0.3.2 — March 15, 2026

+

Version 0.3.3 — March 15, 2026

@@ -45,7 +45,7 @@

Android

  1. Download the APK — Tap the button above. Your browser may warn that this type of file can be harmful — tap Download anyway.
  2. -
  3. Open the file — When the download finishes, tap the notification or find itsgoin-0.3.2.apk in your Downloads folder and tap it.
  4. +
  5. Open the file — When the download finishes, tap the notification or find itsgoin-0.3.3.apk in your Downloads folder and tap it.
  6. Allow installation — Android will ask you to allow installs from this source. Tap Settings, toggle "Allow from this source", then go back and tap Install.
  7. Launch the app — Once installed, tap Open or find ItsGoin in your app drawer.
@@ -58,8 +58,8 @@

Linux (AppImage)

  1. Download the AppImage — Click the button above to download.
  2. -
  3. Make it executable — Open a terminal and run:
    chmod +x itsgoin_0.3.2_amd64.AppImage
  4. -
  5. Run it — Double-click the file, or from the terminal:
    ./itsgoin_0.3.2_amd64.AppImage
  6. +
  7. Make it executable — Open a terminal and run:
    chmod +x itsgoin_0.3.3_amd64.AppImage
  8. +
  9. Run it — Double-click the file, or from the terminal:
    ./itsgoin_0.3.3_amd64.AppImage
Note: If it doesn't launch, you may need to install FUSE:
sudo apt install libfuse2 (Debian/Ubuntu) or sudo dnf install fuse (Fedora). @@ -70,8 +70,16 @@

Changelog

-
v0.3.2 — March 15, 2026
+
v0.3.3 — March 16, 2026
    +
  • IPv6 HTTP address fix — Nodes with public IPv6 now correctly advertise their real address for direct browser access, instead of 0.0.0.0. Fixes share link video/image serving for IPv6-reachable nodes.
  • +
  • Video preload fix — Share link videos and in-app videos from peers now buffer properly for playback (preload="auto"). Previously only the first frame loaded.
  • +
  • Connection rate limiting — Incoming connections that fail authentication are rate-limited per source IP (3 attempts, then exponential backoff up to ~4 minutes). Prevents CPU exhaustion from rogue or stale nodes spamming auth failures.
  • +
  • Schema versioning — Database tracks schema version via PRAGMA user_version. Future upgrades can run data migrations automatically. Databases too old to migrate are reset cleanly.
  • +
  • N2/N3 freshness — TTL reduced from 7 days to 5 hours. Full N1/N2 state re-broadcast every 4 hours catches missed diffs.
  • +
  • Bootstrap isolation recovery — 24 hours after startup, nodes verify the bootstrap anchor is within N1/N2/N3 reach. If absent, they reconnect and request referrals. Bootstrap is added to sticky N1 for 24 hours so mesh peers discover it via diffs.
  • +
  • Following: Online/Offline — People tab splits followed peers into Online and Offline sections with “Last online” timestamps.
  • +
  • DM filter — Direct messages no longer appear in My Posts tab.
  • Bidirectional engagement propagation — Reactions and comments now flow both upstream (toward author) and downstream through the CDN tree. Previously only downstream propagation existed, so the post author often never received reactions.
  • Auto downstream registration — Nodes that receive a post via pull sync or push notification automatically register as downstream peers. This ensures engagement diffs reach all holders without manual registration.
  • Upstream tracking — New post_upstream table records which peer each post was received from, enabling engagement to flow back toward the author hop-by-hop through the CDN tree.