1use 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
34pub const KIND_REPORT: Kind = Kind::REPORTING;
36
37#[derive(Debug, Clone, PartialEq, Eq, Hash)]
42pub enum ReportType {
43 Nudity,
45 Malware,
47 Profanity,
49 Illegal,
51 Spam,
53 Impersonation,
55 Other,
57 Custom(String),
59}
60
61impl ReportType {
62 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum ReportTarget {
105 Event {
108 id: EventId,
110 author: PublicKey,
113 report_type: Option<ReportType>,
115 },
116 Profile {
118 pubkey: PublicKey,
120 report_type: Option<ReportType>,
123 },
124 Blob {
128 hash: String,
130 report_type: Option<ReportType>,
132 host_event: EventId,
134 host_report_type: Option<ReportType>,
137 servers: Vec<Url>,
139 },
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct Report {
145 pub target: ReportTarget,
147 pub content: String,
149 pub extra_tags: Vec<Tag>,
151}
152
153impl Report {
154 #[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 #[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 #[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 #[must_use]
203 pub fn content(mut self, content: impl Into<String>) -> Self {
204 self.content = content.into();
205 self
206 }
207
208 #[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 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#[derive(Debug, Error)]
321#[non_exhaustive]
322pub enum ReportError {
323 #[error("expected kind 1984 (report), got kind {}", .0.as_u16())]
325 WrongKind(Kind),
326 #[error("report must include at least one of `p`, `e`, or `x` tags")]
328 MissingTarget,
329 #[error("`x` blob report must accompany an `e` host event tag")]
331 MissingHostEvent,
332 #[error("`p` tag missing pubkey")]
334 MalformedPubkey,
335 #[error("`e` tag missing event id")]
337 MalformedEvent,
338 #[error("`server` tag missing URL")]
340 MalformedServer,
341 #[error(transparent)]
343 InvalidPublicKey(#[from] PublicKeyError),
344 #[error(transparent)]
346 InvalidEventId(#[from] EventIdError),
347 #[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 #[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}