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
This commit is contained in:
2026-05-17 20:25:52 -07:00
parent a3db453942
commit 1c8088c138
6 changed files with 126 additions and 22 deletions
Generated
+7
View File
@@ -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"
+1
View File
@@ -60,3 +60,4 @@ chrono = { version = "0.4", features = ["serde"] }
rand = "0.8"
sha2 = "0.10"
base64 = "0.22"
urlencoding = "2"
+10
View File
@@ -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<Option<String>> {
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(
+42 -8
View File
@@ -10,7 +10,8 @@ use crate::torznab::categories::tcat_to_newznab;
static INFO_HASH_RE: std::sync::OnceLock<regex::Regex> = 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<TorrentRecord> {
let mut total_size: i64 = 0;
let mut has_size = false;
let mut explicit_size: Option<i64> = None; // from `size` tag
let mut tcat_cat: Option<i32> = None;
let mut newznab_cat: Option<i32> = None;
let mut tcat_str = String::new();
@@ -68,10 +70,25 @@ pub fn parse(event: &Event) -> anyhow::Result<TorrentRecord> {
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::<i64>() {
explicit_size = Some(sz);
}
}
"file" => {
let path = tag_vec[1].clone();
let sz = tag_vec.get(2).and_then(|s| s.parse::<i64>().ok());
@@ -84,17 +101,20 @@ pub fn parse(event: &Event) -> anyhow::Result<TorrentRecord> {
"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<TorrentRecord> {
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<TorrentRecord> {
}
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<String>) {
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,
+47 -14
View File
@@ -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}"),
}
}
_ => {}
}
}
}
+19
View File
@@ -62,6 +62,10 @@ pub fn build_event(rec: &TorrentRecord) -> anyhow::Result<EventBuilder> {
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::<String>());
}
for tr in trackers {
s.push_str("&tr=");
s.push_str(&form_urlencoded::byte_serialize(tr.as_bytes()).collect::<String>());
}
s
}