From 1c8088c13824e5264d42aef01e6923ef42b6b5f8 Mon Sep 17 00:00:00 2001 From: enki Date: Sun, 17 May 2026 20:25:52 -0700 Subject: [PATCH] fix: NIP compliance audit NIP-35: - Accept 64-char SHA256 info hashes (BitTorrent v2) in addition to 40-char SHA1 (v1) - Parse `size` tag as explicit total size; file-size sum is now a fallback only - Parse URL hint from third element of `x` tag as blossom_url fallback - Parse `magnet` tag on ingest and extract trackers from it - Emit `magnet` tag in published NIP-35 events NIP-56: - Handle `e` tags in kind 1984 reports: look up the torrent event's publisher and penalise them the same as a direct `p` tag report --- Cargo.lock | 7 ++++++ Cargo.toml | 1 + src/db/queries.rs | 10 ++++++++ src/nostr/parser.rs | 50 +++++++++++++++++++++++++++++++------ src/nostr/reader.rs | 61 ++++++++++++++++++++++++++++++++++----------- src/nostr/writer.rs | 19 ++++++++++++++ 6 files changed, 126 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8e6c8f8..4b809eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1250,6 +1250,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "urlencoding", ] [[package]] @@ -2921,6 +2922,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" diff --git a/Cargo.toml b/Cargo.toml index 237f044..7293284 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,3 +60,4 @@ chrono = { version = "0.4", features = ["serde"] } rand = "0.8" sha2 = "0.10" base64 = "0.22" +urlencoding = "2" diff --git a/src/db/queries.rs b/src/db/queries.rs index 3ec5034..d4a093b 100644 --- a/src/db/queries.rs +++ b/src/db/queries.rs @@ -669,6 +669,16 @@ pub async fn insert_curation_item( Ok(()) } +/// Look up the pubkey that published a given event_id (any kind stored in torrents table). +pub async fn get_event_pubkey(pool: &SqlitePool, event_id: &str) -> anyhow::Result> { + let row: Option<(String,)> = + sqlx::query_as("SELECT pubkey FROM torrents WHERE event_id = ? LIMIT 1") + .bind(event_id) + .fetch_optional(pool) + .await?; + Ok(row.map(|(pk,)| pk)) +} + // ─── Reports ────────────────────────────────────────────────────────────────── pub async fn insert_report( diff --git a/src/nostr/parser.rs b/src/nostr/parser.rs index 05ee274..330897e 100644 --- a/src/nostr/parser.rs +++ b/src/nostr/parser.rs @@ -10,7 +10,8 @@ use crate::torznab::categories::tcat_to_newznab; static INFO_HASH_RE: std::sync::OnceLock = std::sync::OnceLock::new(); fn info_hash_re() -> &'static regex::Regex { - INFO_HASH_RE.get_or_init(|| regex::Regex::new(r"^[0-9a-fA-F]{40}$").unwrap()) + // Accept 40-char SHA1 (v1 torrents) or 64-char SHA256 (v2 torrents). + INFO_HASH_RE.get_or_init(|| regex::Regex::new(r"^[0-9a-fA-F]{40}$|^[0-9a-fA-F]{64}$").unwrap()) } /// Parse a kind 2003 Nostr event into a TorrentRecord ready for storage. @@ -52,6 +53,7 @@ pub fn parse(event: &Event) -> anyhow::Result { let mut total_size: i64 = 0; let mut has_size = false; + let mut explicit_size: Option = None; // from `size` tag let mut tcat_cat: Option = None; let mut newznab_cat: Option = None; let mut tcat_str = String::new(); @@ -68,10 +70,25 @@ pub fn parse(event: &Event) -> anyhow::Result { bail!("invalid info_hash: {:?}", tag_vec[1]); } rec.info_hash = hash; + // Third element is an optional URL hint to the .torrent file (BUD-01 / direct link). + // Use as blossom_url fallback if not already set by a `url` tag. + if rec.blossom_url.is_empty() { + if let Some(hint) = tag_vec.get(2) { + if hint.starts_with("http://") || hint.starts_with("https://") { + rec.blossom_url = hint.clone(); + } + } + } } "title" => { rec.title = tag_vec[1].clone(); } + "size" => { + // Total torrent size in bytes declared explicitly. + if let Ok(sz) = tag_vec[1].parse::() { + explicit_size = Some(sz); + } + } "file" => { let path = tag_vec[1].clone(); let sz = tag_vec.get(2).and_then(|s| s.parse::().ok()); @@ -84,17 +101,20 @@ pub fn parse(event: &Event) -> anyhow::Result { "tracker" => { rec.trackers.push(tag_vec[1].clone()); } + "magnet" => { + // Parse trackers out of the magnet URI so we don't lose them. + parse_magnet_trackers(&tag_vec[1], &mut rec.trackers); + } "i" => { parse_i_tag(&tag_vec[1], &mut rec, &mut tcat_cat, &mut newznab_cat, &mut tcat_str); } "t" => { rec.tags.push(tag_vec[1].clone()); } - // `url` tag: Blossom URL pointing to the .torrent file (BUD-01) + // `url` tag: Blossom URL pointing to the .torrent file (BUD-01). + // Takes precedence over the x-tag hint. "url" => { - if rec.blossom_url.is_empty() { - rec.blossom_url = tag_vec[1].clone(); - } + rec.blossom_url = tag_vec[1].clone(); } _ => {} } @@ -104,6 +124,9 @@ pub fn parse(event: &Event) -> anyhow::Result { bail!("missing x (info_hash) tag"); } + // Explicit `size` tag wins; fall back to summed file sizes. + rec.size_bytes = explicit_size.or_else(|| if has_size { Some(total_size) } else { None }); + // newznab: overrides tcat: rec.newznab_cat = newznab_cat.or(tcat_cat); if !tcat_str.is_empty() { @@ -143,13 +166,24 @@ pub fn parse(event: &Event) -> anyhow::Result { } rec.description = event.content.clone(); - if has_size { - rec.size_bytes = Some(total_size); - } Ok(rec) } +/// Extract `tr=` tracker parameters from a magnet URI and add any new ones to the list. +fn parse_magnet_trackers(magnet: &str, trackers: &mut Vec) { + for part in magnet.split('&') { + if let Some(encoded) = part.strip_prefix("tr=") { + if let Ok(tr) = urlencoding::decode(encoded) { + let tr = tr.into_owned(); + if !trackers.contains(&tr) { + trackers.push(tr); + } + } + } + } +} + fn parse_i_tag( value: &str, rec: &mut TorrentRecord, diff --git a/src/nostr/reader.rs b/src/nostr/reader.rs index 38f4e7f..14afa6a 100644 --- a/src/nostr/reader.rs +++ b/src/nostr/reader.rs @@ -241,21 +241,54 @@ async fn handle_report(pool: &SqlitePool, cfg: &Config, event: &nostr_sdk::Event for tag in event.tags.iter() { let s = tag.as_slice(); - if s.len() >= 2 && s[0] == "p" { - let reported = &s[1]; - if let Err(e) = db::insert_report( - pool, - &report_event_id, - reported, - &reporter, - reason, - created_at, - cfg.curation.auto_block_threshold, - ).await { - debug!("report insert failed: {e}"); - } else { - debug!(reported = %reported, "report indexed"); + if s.len() < 2 { + continue; + } + match s[0].as_str() { + // NIP-56: `p` tag — report against a pubkey directly. + "p" => { + let reported = &s[1]; + if let Err(e) = db::insert_report( + pool, + &report_event_id, + reported, + &reporter, + reason, + created_at, + cfg.curation.auto_block_threshold, + ).await { + debug!("report insert failed: {e}"); + } else { + debug!(reported = %reported, "report indexed (p-tag)"); + } } + // NIP-56: `e` tag — report against a specific event. + // Look up the event's publisher and penalise them. + "e" => { + let event_id_ref = &s[1]; + match db::get_event_pubkey(pool, event_id_ref).await { + Ok(Some(pubkey)) => { + if let Err(e) = db::insert_report( + pool, + &report_event_id, + &pubkey, + &reporter, + reason, + created_at, + cfg.curation.auto_block_threshold, + ).await { + debug!("report insert failed: {e}"); + } else { + debug!(event_id = %event_id_ref, pubkey = %pubkey, "report indexed (e-tag)"); + } + } + Ok(None) => { + // Event not in our DB — ignore. + } + Err(e) => debug!("get_event_pubkey failed: {e}"), + } + } + _ => {} } } } diff --git a/src/nostr/writer.rs b/src/nostr/writer.rs index 90f8f26..09b2648 100644 --- a/src/nostr/writer.rs +++ b/src/nostr/writer.rs @@ -62,6 +62,10 @@ pub fn build_event(rec: &TorrentRecord) -> anyhow::Result { tags.push(Tag::parse(["url", rec.blossom_url.as_str()])?); } + // NIP-35: emit a `magnet` tag so clients that don't build their own magnet link can use it directly + let magnet = build_magnet(&rec.info_hash, &rec.title, &rec.trackers); + tags.push(Tag::parse(["magnet", &magnet])?); + Ok(EventBuilder::new(Kind::Custom(2003), rec.description.clone()).tags(tags)) } @@ -134,3 +138,18 @@ pub async fn publish(signer: &Signer, builder: EventBuilder, relays: &[String]) pub fn event_to_json(event: &Event) -> String { event.as_json() } + +/// Build a magnet URI from info_hash, display name, and tracker list. +fn build_magnet(info_hash: &str, title: &str, trackers: &[String]) -> String { + use url::form_urlencoded; + let mut s = format!("magnet:?xt=urn:btih:{info_hash}"); + if !title.is_empty() { + s.push_str("&dn="); + s.push_str(&form_urlencoded::byte_serialize(title.as_bytes()).collect::()); + } + for tr in trackers { + s.push_str("&tr="); + s.push_str(&form_urlencoded::byte_serialize(tr.as_bytes()).collect::()); + } + s +}