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:
parent
e6f55fb1d6
commit
8fad30cf95
8 changed files with 136 additions and 17 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -2550,7 +2550,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itsgoin-desktop"
|
name = "itsgoin-desktop"
|
||||||
version = "0.3.2"
|
version = "0.3.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
let cid_hex = hex::encode(&att.cid);
|
||||||
if att.mime_type.starts_with("video/") {
|
if att.mime_type.starts_with("video/") {
|
||||||
attachments_html.push_str(&format!(
|
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
|
cid_hex
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -360,9 +360,14 @@ impl Network {
|
||||||
}
|
}
|
||||||
return Some(bind.to_string());
|
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 {
|
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
|
None
|
||||||
}
|
}
|
||||||
|
|
@ -383,15 +388,55 @@ impl Network {
|
||||||
/// only InitialExchange triggers mesh slot allocation.
|
/// only InitialExchange triggers mesh slot allocation.
|
||||||
pub async fn run_accept_loop(self: Arc<Self>) -> anyhow::Result<()> {
|
pub async fn run_accept_loop(self: Arc<Self>) -> anyhow::Result<()> {
|
||||||
info!("Accepting incoming connections (v3 ephemeral)...");
|
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 {
|
while let Some(incoming) = self.endpoint.accept().await {
|
||||||
let this = Arc::clone(&self);
|
let this = Arc::clone(&self);
|
||||||
let remote_sock = crate::connection::normalize_addr(incoming.remote_address());
|
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 {
|
tokio::spawn(async move {
|
||||||
match incoming.await {
|
match incoming.await {
|
||||||
Ok(conn) => {
|
Ok(conn) => {
|
||||||
let remote = conn.remote_id();
|
let remote = conn.remote_id();
|
||||||
let remote_node_id = *remote.as_bytes();
|
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
|
// Store peer with their address
|
||||||
{
|
{
|
||||||
let storage = this.storage.lock().await;
|
let storage = this.storage.lock().await;
|
||||||
|
|
@ -414,7 +459,16 @@ impl Network {
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,16 +28,72 @@ pub struct Storage {
|
||||||
conn: Connection,
|
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 {
|
impl Storage {
|
||||||
pub fn open(path: impl AsRef<Path>) -> anyhow::Result<Self> {
|
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;")?;
|
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 };
|
let storage = Self { conn };
|
||||||
storage.init_tables()?;
|
storage.init_tables()?;
|
||||||
storage.migrate()?;
|
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)
|
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<()> {
|
fn init_tables(&self) -> anyhow::Result<()> {
|
||||||
self.conn.execute_batch(
|
self.conn.execute_batch(
|
||||||
"CREATE TABLE IF NOT EXISTS posts (
|
"CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "itsgoin-desktop"
|
name = "itsgoin-desktop"
|
||||||
version = "0.3.2"
|
version = "0.3.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"productName": "itsgoin",
|
"productName": "itsgoin",
|
||||||
"version": "0.3.2",
|
"version": "0.3.3",
|
||||||
"identifier": "com.itsgoin.app",
|
"identifier": "com.itsgoin.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../../frontend",
|
"frontendDist": "../../frontend",
|
||||||
|
|
|
||||||
|
|
@ -1644,6 +1644,7 @@ async function loadPostMedia(container) {
|
||||||
const mime = vid.dataset.mime || 'video/mp4';
|
const mime = vid.dataset.mime || 'video/mp4';
|
||||||
try {
|
try {
|
||||||
vid.src = await loadBlobAsObjectUrl(cid, postId, mime);
|
vid.src = await loadBlobAsObjectUrl(cid, postId, mime);
|
||||||
|
vid.preload = 'auto';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
vid.poster = '';
|
vid.poster = '';
|
||||||
vid.insertAdjacentHTML('afterend', '<span class="empty-hint">Video unavailable</span>');
|
vid.insertAdjacentHTML('afterend', '<span class="empty-hint">Video unavailable</span>');
|
||||||
|
|
|
||||||
|
|
@ -24,16 +24,16 @@
|
||||||
<section>
|
<section>
|
||||||
<h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.25rem;">Download ItsGoin</h1>
|
<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>Available for Android and Linux. Free and open source.</p>
|
||||||
<p style="color: var(--text-muted); font-size: 0.85rem;">Version 0.3.2 — March 15, 2026</p>
|
<p style="color: var(--text-muted); font-size: 0.85rem;">Version 0.3.3 — March 15, 2026</p>
|
||||||
|
|
||||||
<div class="downloads">
|
<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
|
Android APK
|
||||||
<span class="sub">v0.3.2</span>
|
<span class="sub">v0.3.3</span>
|
||||||
</a>
|
</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
|
Linux AppImage
|
||||||
<span class="sub">v0.3.2</span>
|
<span class="sub">v0.3.3</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
<h3 style="color: var(--accent);">Android</h3>
|
<h3 style="color: var(--accent);">Android</h3>
|
||||||
<ol class="steps">
|
<ol class="steps">
|
||||||
<li><strong>Download the APK</strong> — Tap the button above. Your browser may warn that this type of file can be harmful — tap <strong>Download anyway</strong>.</li>
|
<li><strong>Download the APK</strong> — Tap the button above. Your browser may warn that this type of file can be harmful — tap <strong>Download anyway</strong>.</li>
|
||||||
<li><strong>Open the file</strong> — 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> — 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> — 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>Allow installation</strong> — 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> — Once installed, tap <strong>Open</strong> or find ItsGoin in your app drawer.</li>
|
<li><strong>Launch the app</strong> — Once installed, tap <strong>Open</strong> or find ItsGoin in your app drawer.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
@ -58,8 +58,8 @@
|
||||||
<h3 style="color: var(--green);">Linux (AppImage)</h3>
|
<h3 style="color: var(--green);">Linux (AppImage)</h3>
|
||||||
<ol class="steps">
|
<ol class="steps">
|
||||||
<li><strong>Download the AppImage</strong> — Click the button above to download.</li>
|
<li><strong>Download the AppImage</strong> — Click the button above to download.</li>
|
||||||
<li><strong>Make it executable</strong> — Open a terminal and run:<br><code>chmod +x itsgoin_0.3.2_amd64.AppImage</code></li>
|
<li><strong>Make it executable</strong> — Open a terminal and run:<br><code>chmod +x itsgoin_0.3.3_amd64.AppImage</code></li>
|
||||||
<li><strong>Run it</strong> — Double-click the file, or from the terminal:<br><code>./itsgoin_0.3.2_amd64.AppImage</code></li>
|
<li><strong>Run it</strong> — Double-click the file, or from the terminal:<br><code>./itsgoin_0.3.3_amd64.AppImage</code></li>
|
||||||
</ol>
|
</ol>
|
||||||
<div class="note">
|
<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).
|
<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>
|
<section>
|
||||||
<h2>Changelog</h2>
|
<h2>Changelog</h2>
|
||||||
<div class="changelog">
|
<div class="changelog">
|
||||||
<div class="changelog-date">v0.3.2 — March 15, 2026</div>
|
<div class="changelog-date">v0.3.3 — March 16, 2026</div>
|
||||||
<ul>
|
<ul>
|
||||||
|
<li><strong>IPv6 HTTP address fix</strong> — 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> — 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> — 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> — 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> — 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> — 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> — People tab splits followed peers into Online and Offline sections with “Last online” timestamps.</li>
|
||||||
|
<li><strong>DM filter</strong> — Direct messages no longer appear in My Posts tab.</li>
|
||||||
<li><strong>Bidirectional engagement propagation</strong> — 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>Bidirectional engagement propagation</strong> — 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> — 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>Auto downstream registration</strong> — 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> — 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>
|
<li><strong>Upstream tracking</strong> — 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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue