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) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-03-16 18:37:24 -04:00
parent e6f55fb1d6
commit 8fad30cf95
8 changed files with 136 additions and 17 deletions

2
Cargo.lock generated
View file

@ -2550,7 +2550,7 @@ dependencies = [
[[package]]
name = "itsgoin-desktop"
version = "0.3.2"
version = "0.3.3"
dependencies = [
"anyhow",
"base64 0.22.1",

View file

@ -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#"<video src="/b/{}" controls style="max-width:100%;margin:0.5rem 0;border-radius:8px"></video>"#,
r#"<video src="/b/{}" controls preload="auto" playsinline style="max-width:100%;margin:0.5rem 0;border-radius:8px"></video>"#,
cid_hex
));
} else {

View file

@ -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<Self>) -> anyhow::Result<()> {
info!("Accepting incoming connections (v3 ephemeral)...");
// Rate limit: track auth failures per source IP
let fail_tracker: Arc<tokio::sync::Mutex<std::collections::HashMap<std::net::IpAddr, (u32, tokio::time::Instant)>>> =
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
// Cleanup stale entries every 60s
let ft_cleanup: Arc<tokio::sync::Mutex<std::collections::HashMap<std::net::IpAddr, (u32, tokio::time::Instant)>>> = 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)
}
}
});

View file

@ -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<Path>) -> anyhow::Result<Self> {
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 (

View file

@ -1,6 +1,6 @@
[package]
name = "itsgoin-desktop"
version = "0.3.2"
version = "0.3.3"
edition = "2021"
[lib]

View file

@ -1,6 +1,6 @@
{
"productName": "itsgoin",
"version": "0.3.2",
"version": "0.3.3",
"identifier": "com.itsgoin.app",
"build": {
"frontendDist": "../../frontend",

View file

@ -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', '<span class="empty-hint">Video unavailable</span>');

View file

@ -24,16 +24,16 @@
<section>
<h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.25rem;">Download ItsGoin</h1>
<p>Available for Android and Linux. Free and open source.</p>
<p style="color: var(--text-muted); font-size: 0.85rem;">Version 0.3.2 &mdash; March 15, 2026</p>
<p style="color: var(--text-muted); font-size: 0.85rem;">Version 0.3.3 &mdash; March 15, 2026</p>
<div class="downloads">
<a href="itsgoin-0.3.2.apk" class="download-btn btn-android">
<a href="itsgoin-0.3.3.apk" class="download-btn btn-android">
Android APK
<span class="sub">v0.3.2</span>
<span class="sub">v0.3.3</span>
</a>
<a href="itsgoin_0.3.2_amd64.AppImage" class="download-btn btn-linux">
<a href="itsgoin_0.3.3_amd64.AppImage" class="download-btn btn-linux">
Linux AppImage
<span class="sub">v0.3.2</span>
<span class="sub">v0.3.3</span>
</a>
</div>
</section>
@ -45,7 +45,7 @@
<h3 style="color: var(--accent);">Android</h3>
<ol class="steps">
<li><strong>Download the APK</strong> &mdash; Tap the button above. Your browser may warn that this type of file can be harmful &mdash; tap <strong>Download anyway</strong>.</li>
<li><strong>Open the file</strong> &mdash; When the download finishes, tap the notification or find <code>itsgoin-0.3.2.apk</code> in your Downloads folder and tap it.</li>
<li><strong>Open the file</strong> &mdash; When the download finishes, tap the notification or find <code>itsgoin-0.3.3.apk</code> in your Downloads folder and tap it.</li>
<li><strong>Allow installation</strong> &mdash; Android will ask you to allow installs from this source. Tap <strong>Settings</strong>, toggle <strong>"Allow from this source"</strong>, then go back and tap <strong>Install</strong>.</li>
<li><strong>Launch the app</strong> &mdash; Once installed, tap <strong>Open</strong> or find ItsGoin in your app drawer.</li>
</ol>
@ -58,8 +58,8 @@
<h3 style="color: var(--green);">Linux (AppImage)</h3>
<ol class="steps">
<li><strong>Download the AppImage</strong> &mdash; Click the button above to download.</li>
<li><strong>Make it executable</strong> &mdash; Open a terminal and run:<br><code>chmod +x itsgoin_0.3.2_amd64.AppImage</code></li>
<li><strong>Run it</strong> &mdash; Double-click the file, or from the terminal:<br><code>./itsgoin_0.3.2_amd64.AppImage</code></li>
<li><strong>Make it executable</strong> &mdash; Open a terminal and run:<br><code>chmod +x itsgoin_0.3.3_amd64.AppImage</code></li>
<li><strong>Run it</strong> &mdash; Double-click the file, or from the terminal:<br><code>./itsgoin_0.3.3_amd64.AppImage</code></li>
</ol>
<div class="note">
<strong>Note:</strong> If it doesn't launch, you may need to install FUSE:<br><code>sudo apt install libfuse2</code> (Debian/Ubuntu) or <code>sudo dnf install fuse</code> (Fedora).
@ -70,8 +70,16 @@
<section>
<h2>Changelog</h2>
<div class="changelog">
<div class="changelog-date">v0.3.2 &mdash; March 15, 2026</div>
<div class="changelog-date">v0.3.3 &mdash; March 16, 2026</div>
<ul>
<li><strong>IPv6 HTTP address fix</strong> &mdash; Nodes with public IPv6 now correctly advertise their real address for direct browser access, instead of <code>0.0.0.0</code>. Fixes share link video/image serving for IPv6-reachable nodes.</li>
<li><strong>Video preload fix</strong> &mdash; Share link videos and in-app videos from peers now buffer properly for playback (<code>preload="auto"</code>). Previously only the first frame loaded.</li>
<li><strong>Connection rate limiting</strong> &mdash; 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.</li>
<li><strong>Schema versioning</strong> &mdash; Database tracks schema version via <code>PRAGMA user_version</code>. Future upgrades can run data migrations automatically. Databases too old to migrate are reset cleanly.</li>
<li><strong>N2/N3 freshness</strong> &mdash; TTL reduced from 7 days to 5 hours. Full N1/N2 state re-broadcast every 4 hours catches missed diffs.</li>
<li><strong>Bootstrap isolation recovery</strong> &mdash; 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.</li>
<li><strong>Following: Online/Offline</strong> &mdash; People tab splits followed peers into Online and Offline sections with &ldquo;Last online&rdquo; timestamps.</li>
<li><strong>DM filter</strong> &mdash; Direct messages no longer appear in My Posts tab.</li>
<li><strong>Bidirectional engagement propagation</strong> &mdash; 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.</li>
<li><strong>Auto downstream registration</strong> &mdash; 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.</li>
<li><strong>Upstream tracking</strong> &mdash; New <code>post_upstream</code> table records which peer each post was received from, enabling engagement to flow back toward the author hop-by-hop through the CDN tree.</li>