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:
Generated
+7
@@ -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"
|
||||
|
||||
@@ -60,3 +60,4 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
rand = "0.8"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
urlencoding = "2"
|
||||
|
||||
@@ -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
@@ -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
@@ -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}"),
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user