Skip to main content

nula_core/nips/
nip56.rs

1//! [NIP-56] Reporting.
2//!
3//! A `kind: 1984` event signals that some referenced content is
4//! objectionable. The spec requires:
5//!
6//! - one `p` tag identifying the user being reported, and
7//! - optionally an `e` tag pointing at a specific note when the
8//!   report concerns that note, plus an `x` tag for blob hashes,
9//! - a *report type* token as the 3rd column of the `p` / `e` / `x`
10//!   target tag (one of the documented strings: `nudity`, `malware`,
11//!   `profanity`, `illegal`, `spam`, `impersonation`, `other`).
12//!
13//! NIP-32 `L`/`l` tags MAY co-exist; we expose them through
14//! [`crate::nips::nip32::labels_from_tags`].
15//!
16//! # Forward compatibility
17//!
18//! - [`ReportType`] keeps a [`ReportType::Custom`] variant so future
19//!   tokens decode cleanly.
20//! - Unknown tags survive a round-trip through [`Report::extra_tags`].
21//! - `x` blob reports may pin both an `e` host event and one or more
22//!   `server` URLs, matching the spec §"Tags".
23//!
24//! [NIP-56]: https://github.com/nostr-protocol/nips/blob/master/56.md
25
26use thiserror::Error;
27
28use crate::event::{
29    Alphabet, Event, EventBuilder, EventId, EventIdError, Kind, SingleLetterTag, Tag, TagKind,
30};
31use crate::key::{PublicKey, PublicKeyError};
32use crate::types::{Url, UrlError};
33
34/// `kind: 1984` — reporting event.
35pub const KIND_REPORT: Kind = Kind::REPORTING;
36
37/// Wire-defined report types (spec §"Tags").
38///
39/// Unknown tokens decode as [`Self::Custom`] so the parser tolerates
40/// new categories introduced by future spec revisions.
41#[derive(Debug, Clone, PartialEq, Eq, Hash)]
42pub enum ReportType {
43    /// `nudity` — depictions of nudity, porn, etc.
44    Nudity,
45    /// `malware` — virus, worm, ransomware, spyware, etc.
46    Malware,
47    /// `profanity` — hateful speech.
48    Profanity,
49    /// `illegal` — content that may be illegal in some jurisdiction.
50    Illegal,
51    /// `spam`.
52    Spam,
53    /// `impersonation` — pretending to be someone else (profile-only).
54    Impersonation,
55    /// `other` — generic catch-all defined by the spec.
56    Other,
57    /// Forward-compatible passthrough for unrecognised tokens.
58    Custom(String),
59}
60
61impl ReportType {
62    /// Wire token.
63    ///
64    /// Returns the spec-defined lowercase string or, for
65    /// [`Self::Custom`], the inner string slice. The borrow on the
66    /// inner string prevents this from being `const`.
67    #[must_use]
68    #[expect(
69        clippy::missing_const_for_fn,
70        reason = "`Self::Custom` borrows from a heap `String`"
71    )]
72    pub fn as_str(&self) -> &str {
73        match self {
74            Self::Nudity => "nudity",
75            Self::Malware => "malware",
76            Self::Profanity => "profanity",
77            Self::Illegal => "illegal",
78            Self::Spam => "spam",
79            Self::Impersonation => "impersonation",
80            Self::Other => "other",
81            Self::Custom(s) => s.as_str(),
82        }
83    }
84
85    /// Parse a wire token. Always succeeds: unknown tokens decode as
86    /// [`Self::Custom`].
87    #[must_use]
88    pub fn parse(token: &str) -> Self {
89        match token {
90            "nudity" => Self::Nudity,
91            "malware" => Self::Malware,
92            "profanity" => Self::Profanity,
93            "illegal" => Self::Illegal,
94            "spam" => Self::Spam,
95            "impersonation" => Self::Impersonation,
96            "other" => Self::Other,
97            _ => Self::Custom(token.to_owned()),
98        }
99    }
100}
101
102/// What is being reported.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum ReportTarget {
105    /// `e` tag — reports a specific event. The `p` tag still
106    /// identifies the event's author per spec.
107    Event {
108        /// Note id being reported.
109        id: EventId,
110        /// Note author. The `p` tag is required by spec even when an
111        /// `e` tag is present.
112        author: PublicKey,
113        /// Optional `ReportType` carried on the `e` tag.
114        report_type: Option<ReportType>,
115    },
116    /// `p`-only tag — reports a profile.
117    Profile {
118        /// Pubkey being reported.
119        pubkey: PublicKey,
120        /// `ReportType` token. `impersonation` is only meaningful on
121        /// profile reports per spec.
122        report_type: Option<ReportType>,
123    },
124    /// `x` tag — reports a blob by hash. Per spec, an `e` tag with
125    /// the host event id MUST accompany the blob report; `servers`
126    /// are optional URL hints pointing at media stores.
127    Blob {
128        /// Blob hash (typically SHA-256 hex).
129        hash: String,
130        /// Type token associated with the blob.
131        report_type: Option<ReportType>,
132        /// Host event id (`e` tag), required by spec.
133        host_event: EventId,
134        /// Host event's report type token (may differ from
135        /// [`Self::Blob::report_type`]).
136        host_report_type: Option<ReportType>,
137        /// `server` URL hints pointing at media stores.
138        servers: Vec<Url>,
139    },
140}
141
142/// Typed bundle for a NIP-56 `kind: 1984` event.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct Report {
145    /// What is being reported.
146    pub target: ReportTarget,
147    /// `.content` — free-form rationale from the reporter.
148    pub content: String,
149    /// Forward-compatible passthrough for unknown tags.
150    pub extra_tags: Vec<Tag>,
151}
152
153impl Report {
154    /// Construct a profile-only report.
155    #[must_use]
156    pub const fn profile(pubkey: PublicKey, report_type: Option<ReportType>) -> Self {
157        Self {
158            target: ReportTarget::Profile {
159                pubkey,
160                report_type,
161            },
162            content: String::new(),
163            extra_tags: Vec::new(),
164        }
165    }
166
167    /// Construct an event report. `author` must be the note's pubkey.
168    #[must_use]
169    pub const fn event(id: EventId, author: PublicKey, report_type: Option<ReportType>) -> Self {
170        Self {
171            target: ReportTarget::Event {
172                id,
173                author,
174                report_type,
175            },
176            content: String::new(),
177            extra_tags: Vec::new(),
178        }
179    }
180
181    /// Construct a blob-hash report.
182    #[must_use]
183    pub fn blob(
184        hash: impl Into<String>,
185        host_event: EventId,
186        report_type: Option<ReportType>,
187    ) -> Self {
188        Self {
189            target: ReportTarget::Blob {
190                hash: hash.into(),
191                report_type: report_type.clone(),
192                host_event,
193                host_report_type: report_type,
194                servers: Vec::new(),
195            },
196            content: String::new(),
197            extra_tags: Vec::new(),
198        }
199    }
200
201    /// Attach a free-form rationale.
202    #[must_use]
203    pub fn content(mut self, content: impl Into<String>) -> Self {
204        self.content = content.into();
205        self
206    }
207
208    /// Append a `server` URL hint to a blob report.
209    ///
210    /// Has no effect for non-blob targets.
211    #[must_use]
212    pub fn server(mut self, url: Url) -> Self {
213        if let ReportTarget::Blob { servers, .. } = &mut self.target {
214            servers.push(url);
215        }
216        self
217    }
218
219    /// Parse a `kind: 1984` event into a typed bundle.
220    ///
221    /// # Errors
222    ///
223    /// - [`ReportError::WrongKind`] when the event is not
224    ///   `kind: 1984`.
225    /// - [`ReportError::MissingTarget`] when no target tag is
226    ///   present.
227    /// - [`ReportError::MissingHostEvent`] when an `x` tag has no
228    ///   matching `e` host tag.
229    /// - [`ReportError::InvalidPublicKey`] /
230    ///   [`ReportError::InvalidEventId`] /
231    ///   [`ReportError::InvalidUrl`] when fields fail to parse.
232    pub fn from_event(event: &Event) -> Result<Self, ReportError> {
233        if event.kind != KIND_REPORT {
234            return Err(ReportError::WrongKind(event.kind));
235        }
236
237        let mut p_tag: Option<(PublicKey, Option<ReportType>)> = None;
238        let mut e_tag: Option<(EventId, Option<ReportType>)> = None;
239        let mut x_tag: Option<(String, Option<ReportType>)> = None;
240        let mut servers: Vec<Url> = Vec::new();
241        let mut extra_tags: Vec<Tag> = Vec::new();
242
243        for tag in &event.tags {
244            match tag.kind() {
245                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::P => {
246                    p_tag = Some(parse_p_tag(tag)?);
247                }
248                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::E => {
249                    e_tag = Some(parse_e_tag(tag)?);
250                }
251                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::X => {
252                    x_tag = Some(parse_x_tag(tag));
253                }
254                _ if tag.name() == "server" => {
255                    let url_str = tag.get(1).ok_or(ReportError::MalformedServer)?;
256                    servers.push(Url::parse(url_str)?);
257                }
258                _ => extra_tags.push(tag.clone()),
259            }
260        }
261
262        let target = build_target(p_tag, e_tag, x_tag, servers)?;
263        Ok(Self {
264            target,
265            content: event.content.clone(),
266            extra_tags,
267        })
268    }
269}
270
271fn build_target(
272    p_tag: Option<(PublicKey, Option<ReportType>)>,
273    e_tag: Option<(EventId, Option<ReportType>)>,
274    x_tag: Option<(String, Option<ReportType>)>,
275    servers: Vec<Url>,
276) -> Result<ReportTarget, ReportError> {
277    match (x_tag, e_tag, p_tag) {
278        (Some((hash, x_type)), Some((host_event, e_type)), _) => Ok(ReportTarget::Blob {
279            hash,
280            report_type: x_type,
281            host_event,
282            host_report_type: e_type,
283            servers,
284        }),
285        (Some(_), None, _) => Err(ReportError::MissingHostEvent),
286        (None, Some((id, e_type)), Some((author, _))) => Ok(ReportTarget::Event {
287            id,
288            author,
289            report_type: e_type,
290        }),
291        (None, None, Some((pubkey, p_type))) => Ok(ReportTarget::Profile {
292            pubkey,
293            report_type: p_type,
294        }),
295        _ => Err(ReportError::MissingTarget),
296    }
297}
298
299fn parse_p_tag(tag: &Tag) -> Result<(PublicKey, Option<ReportType>), ReportError> {
300    let pk_hex = tag.get(1).ok_or(ReportError::MalformedPubkey)?;
301    let pubkey = PublicKey::parse(pk_hex)?;
302    let report_type = tag.get(2).filter(|s| !s.is_empty()).map(ReportType::parse);
303    Ok((pubkey, report_type))
304}
305
306fn parse_e_tag(tag: &Tag) -> Result<(EventId, Option<ReportType>), ReportError> {
307    let id_hex = tag.get(1).ok_or(ReportError::MalformedEvent)?;
308    let id = EventId::parse(id_hex)?;
309    let report_type = tag.get(2).filter(|s| !s.is_empty()).map(ReportType::parse);
310    Ok((id, report_type))
311}
312
313fn parse_x_tag(tag: &Tag) -> (String, Option<ReportType>) {
314    let hash = tag.get(1).unwrap_or_default().to_owned();
315    let report_type = tag.get(2).filter(|s| !s.is_empty()).map(ReportType::parse);
316    (hash, report_type)
317}
318
319/// Errors raised by [`Report::from_event`].
320#[derive(Debug, Error)]
321#[non_exhaustive]
322pub enum ReportError {
323    /// The event was not `kind: 1984`.
324    #[error("expected kind 1984 (report), got kind {}", .0.as_u16())]
325    WrongKind(Kind),
326    /// No target tag was present.
327    #[error("report must include at least one of `p`, `e`, or `x` tags")]
328    MissingTarget,
329    /// An `x` tag was present without a host `e` tag.
330    #[error("`x` blob report must accompany an `e` host event tag")]
331    MissingHostEvent,
332    /// `p` tag is missing the pubkey column.
333    #[error("`p` tag missing pubkey")]
334    MalformedPubkey,
335    /// `e` tag is missing the event id column.
336    #[error("`e` tag missing event id")]
337    MalformedEvent,
338    /// `server` tag is missing the URL column.
339    #[error("`server` tag missing URL")]
340    MalformedServer,
341    /// `p` pubkey is malformed.
342    #[error(transparent)]
343    InvalidPublicKey(#[from] PublicKeyError),
344    /// `e` event id is malformed.
345    #[error(transparent)]
346    InvalidEventId(#[from] EventIdError),
347    /// `server` URL is malformed.
348    #[error(transparent)]
349    InvalidUrl(#[from] UrlError),
350}
351
352fn p_target(pubkey: PublicKey, report_type: Option<&ReportType>) -> Tag {
353    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
354    report_type.map_or_else(
355        || Tag::with(&head, [pubkey.to_hex()]),
356        |rt| Tag::with(&head, [pubkey.to_hex(), rt.as_str().to_owned()]),
357    )
358}
359
360fn e_target(id: EventId, report_type: Option<&ReportType>) -> Tag {
361    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
362    report_type.map_or_else(
363        || Tag::with(&head, [id.to_hex()]),
364        |rt| Tag::with(&head, [id.to_hex(), rt.as_str().to_owned()]),
365    )
366}
367
368fn x_target(hash: &str, report_type: Option<&ReportType>) -> Tag {
369    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::X));
370    report_type.map_or_else(
371        || Tag::with(&head, [hash.to_owned()]),
372        |rt| Tag::with(&head, [hash.to_owned(), rt.as_str().to_owned()]),
373    )
374}
375
376impl EventBuilder {
377    /// Author a NIP-56 `kind: 1984` report event.
378    ///
379    /// Tag order matches the spec examples:
380    ///
381    /// 1. Primary target tag (`p`, `e`, or `x`).
382    /// 2. Secondary tags (the `p` author for an `e` report or the
383    ///    host `e` for an `x` report).
384    /// 3. `server` URL hints, if any.
385    /// 4. Caller-supplied [`Report::extra_tags`].
386    #[must_use]
387    pub fn report(report: &Report) -> Self {
388        let mut builder = Self::new(KIND_REPORT, report.content.clone());
389        match &report.target {
390            ReportTarget::Profile {
391                pubkey,
392                report_type,
393            } => {
394                builder = builder.tag(p_target(*pubkey, report_type.as_ref()));
395            }
396            ReportTarget::Event {
397                id,
398                author,
399                report_type,
400            } => {
401                builder = builder.tag(e_target(*id, report_type.as_ref()));
402                builder = builder.tag(p_target(*author, None));
403            }
404            ReportTarget::Blob {
405                hash,
406                report_type,
407                host_event,
408                host_report_type,
409                servers,
410            } => {
411                builder = builder.tag(x_target(hash, report_type.as_ref()));
412                builder = builder.tag(e_target(*host_event, host_report_type.as_ref()));
413                for server in servers {
414                    builder = builder.tag(Tag::with(
415                        &TagKind::from_wire("server"),
416                        [server.as_str().to_owned()],
417                    ));
418                }
419            }
420        }
421        for tag in &report.extra_tags {
422            builder = builder.tag(tag.clone());
423        }
424        builder
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::Keys;
432
433    fn keys() -> Keys {
434        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
435    }
436
437    fn other_pubkey() -> PublicKey {
438        *Keys::parse("0000000000000000000000000000000000000000000000000000000000000004")
439            .unwrap()
440            .public_key()
441    }
442
443    #[test]
444    fn report_type_wire_tokens_round_trip() {
445        for token in [
446            "nudity",
447            "malware",
448            "profanity",
449            "illegal",
450            "spam",
451            "impersonation",
452            "other",
453        ] {
454            assert_eq!(ReportType::parse(token).as_str(), token);
455        }
456        assert_eq!(ReportType::parse("new-category").as_str(), "new-category",);
457    }
458
459    #[test]
460    fn profile_report_round_trip() {
461        let report = Report::profile(other_pubkey(), Some(ReportType::Impersonation))
462            .content("not the king");
463        let event = EventBuilder::report(&report)
464            .sign_with_keys(&keys())
465            .unwrap();
466        let parsed = Report::from_event(&event).unwrap();
467        assert_eq!(parsed, report);
468    }
469
470    #[test]
471    fn event_report_round_trip() {
472        let id = EventId::from_byte_array([0x07; 32]);
473        let report = Report::event(id, other_pubkey(), Some(ReportType::Illegal))
474            .content("contains illegal speech");
475        let event = EventBuilder::report(&report)
476            .sign_with_keys(&keys())
477            .unwrap();
478        let parsed = Report::from_event(&event).unwrap();
479        assert_eq!(parsed, report);
480    }
481
482    #[test]
483    fn blob_report_round_trip_with_server_hints() {
484        let host = EventId::from_byte_array([0x09; 32]);
485        let report = Report::blob("abc123def", host, Some(ReportType::Malware))
486            .server(Url::parse("https://media.example.com/blob.bin").unwrap())
487            .content("contains malware");
488        let event = EventBuilder::report(&report)
489            .sign_with_keys(&keys())
490            .unwrap();
491        let parsed = Report::from_event(&event).unwrap();
492        assert_eq!(parsed, report);
493    }
494
495    #[test]
496    fn wrong_kind_is_rejected() {
497        let event = EventBuilder::text_note("nope")
498            .sign_with_keys(&keys())
499            .unwrap();
500        assert!(matches!(
501            Report::from_event(&event),
502            Err(ReportError::WrongKind(_)),
503        ));
504    }
505
506    #[test]
507    fn missing_target_is_rejected() {
508        let event = EventBuilder::new(KIND_REPORT, "")
509            .sign_with_keys(&keys())
510            .unwrap();
511        assert!(matches!(
512            Report::from_event(&event),
513            Err(ReportError::MissingTarget),
514        ));
515    }
516
517    #[test]
518    fn blob_without_host_event_is_rejected() {
519        let event = EventBuilder::new(KIND_REPORT, "")
520            .tag(Tag::with(
521                &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::X)),
522                ["abc", "malware"],
523            ))
524            .sign_with_keys(&keys())
525            .unwrap();
526        assert!(matches!(
527            Report::from_event(&event),
528            Err(ReportError::MissingHostEvent),
529        ));
530    }
531
532    #[test]
533    fn unknown_report_type_decodes_as_custom() {
534        let report = Report::profile(other_pubkey(), Some(ReportType::Custom("doxx".into())));
535        let event = EventBuilder::report(&report)
536            .sign_with_keys(&keys())
537            .unwrap();
538        let parsed = Report::from_event(&event).unwrap();
539        assert_eq!(parsed, report);
540    }
541
542    #[test]
543    fn extra_tags_are_preserved_on_round_trip() {
544        let custom = Tag::with(&TagKind::Custom("note".to_owned()), ["context"]);
545        let report = Report::profile(other_pubkey(), None);
546        let mut builder = EventBuilder::report(&report);
547        builder = builder.tag(custom.clone());
548        let event = builder.sign_with_keys(&keys()).unwrap();
549        let parsed = Report::from_event(&event).unwrap();
550        assert_eq!(parsed.extra_tags, vec![custom]);
551    }
552}