Skip to content

Commit b2776d6

Browse files
authored
Merge pull request #6 from kengirie/feat/bud-09-report
2 parents 8cbb137 + 3147d46 commit b2776d6

14 files changed

Lines changed: 697 additions & 7 deletions

e2e/dune

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
(library
22
(name e2e)
3-
(modules config nostr_signer http_client test_upload test_delete test_cors test_get test_head_upload test_mirror test_list)
3+
(modules config nostr_signer http_client test_upload test_delete test_cors test_get test_head_upload test_mirror test_list test_report)
44
(libraries
55
blossoML.core
66
piaf

e2e/main.ml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,7 @@ let () =
3838
Eio.traceln "\nList Tests (BUD-12):";
3939
List.iter (run_test ~sw ~env) E2e.Test_list.tests;
4040

41+
Eio.traceln "\nReport Tests (BUD-09):";
42+
List.iter (run_test ~sw ~env) E2e.Test_report.tests;
43+
4144
Eio.traceln "\nDone."

e2e/nostr_signer.ml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,29 @@ let create_list_auth ~keypair ~created_at ~expiration =
9999
| Ok (sig_, _) ->
100100
{ Nostr_event.id; pubkey = keypair.pubkey; created_at; kind; tags; content; sig_ }
101101

102+
(** Create a BUD-09 / NIP-56 report event (kind 1984).
103+
[entries] is a list of (sha256, report_type) pairs. *)
104+
let create_report ~keypair ~created_at ~entries ?(content = "") ?e_tag ?p_tag () =
105+
let created_at = Int64.of_float created_at in
106+
let x_tags = List.map (fun (sha, rt) -> ["x"; sha; rt]) entries in
107+
let extra_tags =
108+
(match e_tag with Some v -> [["e"; v]] | None -> [])
109+
@ (match p_tag with Some v -> [["p"; v]] | None -> [])
110+
in
111+
let tags = x_tags @ extra_tags in
112+
let kind = 1984 in
113+
let id = Nostr_event.compute_id
114+
~pubkey:keypair.pubkey
115+
~created_at
116+
~kind
117+
~tags
118+
~content
119+
in
120+
match Bip340.sign ~secret_key:keypair.secret_key ~msg:id with
121+
| Error _ -> failwith "Failed to sign report event"
122+
| Ok (sig_, _) ->
123+
{ Nostr_event.id; pubkey = keypair.pubkey; created_at; kind; tags; content; sig_ }
124+
102125
(** Convert a Nostr event to JSON string. *)
103126
let event_to_json event =
104127
let open Nostr_event in

e2e/test_get.ml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -254,12 +254,12 @@ let test_get_binary_blob ~sw ~env =
254254
let test_get_invalid_paths ~sw ~env =
255255
let base_url = Config.base_url in
256256

257+
(* Note: GET / is no longer invalid — it returns 200 with Terms of Service (BUD-09).
258+
See test_report.ml for that case. *)
257259
let invalid_paths = [
258-
"/"; (* Root path *)
259260
"/upload"; (* Upload endpoint *)
260261
"/../etc/passwd"; (* Path traversal attempt *)
261262
"/a/b/c"; (* Multiple segments *)
262-
""; (* Empty path *)
263263
] in
264264

265265
List.iter (fun path ->
@@ -638,9 +638,9 @@ let test_head_get_consistent_headers ~sw ~env =
638638
let test_head_invalid_paths ~sw ~env =
639639
let base_url = Config.base_url in
640640

641-
(* Note: /upload is now a valid endpoint (BUD-06), so it's not included here *)
641+
(* Note: /upload is now a valid endpoint (BUD-06), so it's not included here.
642+
GET / is now Terms of Service (BUD-09); HEAD / would still 404. *)
642643
let invalid_paths = [
643-
"/";
644644
"/../etc/passwd";
645645
"/a/b/c";
646646
] in

e2e/test_report.ml

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
(** E2E tests for BUD-09 PUT /report and GET / (Terms of Service). *)
2+
3+
let sha256_hex content =
4+
let hash = Digestif.SHA256.digest_string content in
5+
Digestif.SHA256.to_hex hash
6+
7+
(** Test: GET / returns 200 and text/plain Terms of Service *)
8+
let test_get_root_terms_of_service ~sw ~env =
9+
let base_url = Config.base_url in
10+
let result = Http_client.get ~sw ~env ~url:base_url () in
11+
match result with
12+
| Error e -> failwith ("GET / failed: " ^ e)
13+
| Ok response ->
14+
if response.status <> 200 then
15+
failwith (Printf.sprintf "Expected 200, got %d" response.status);
16+
let ct = List.find_opt
17+
(fun (k, _) -> String.lowercase_ascii k = "content-type") response.headers
18+
in
19+
(match ct with
20+
| None -> failwith "Missing Content-Type"
21+
| Some (_, v) ->
22+
if not (String.starts_with ~prefix:"text/plain" v) then
23+
failwith (Printf.sprintf "Expected text/plain, got %s" v));
24+
if String.length response.body = 0 then
25+
failwith "Empty ToS body";
26+
(* must mention the report endpoint *)
27+
let contains s sub =
28+
try
29+
let len_sub = String.length sub in
30+
let len_s = String.length s in
31+
let rec loop i =
32+
if i + len_sub > len_s then false
33+
else if String.sub s i len_sub = sub then true
34+
else loop (i + 1)
35+
in loop 0
36+
with _ -> false
37+
in
38+
if not (contains response.body "report") then
39+
failwith "ToS should mention reporting"
40+
41+
(** Test: PUT /report with a valid signed report returns 200 *)
42+
let test_put_report_accepted ~sw ~env =
43+
let base_url = Config.base_url in
44+
let clock = Eio.Stdenv.clock env in
45+
let now = Eio.Time.now clock in
46+
let keypair = Nostr_signer.generate_keypair () in
47+
let some_sha = sha256_hex "irrelevant content for report-only test" in
48+
let event = Nostr_signer.create_report
49+
~keypair ~created_at:now
50+
~entries:[(some_sha, "spam")]
51+
~content:"this is spam"
52+
()
53+
in
54+
let body = Nostr_signer.event_to_json event in
55+
let result = Http_client.put ~sw ~env ~url:(base_url ^ "/report")
56+
~headers:[("Content-Type", "application/json")] ~body ()
57+
in
58+
match result with
59+
| Error e -> failwith ("PUT /report failed: " ^ e)
60+
| Ok response ->
61+
if response.status <> 200 then
62+
failwith (Printf.sprintf "Expected 200, got %d (body: %s)"
63+
response.status response.body)
64+
65+
(** Test: PUT /report with multiple x tags is accepted *)
66+
let test_put_report_multiple_x_tags ~sw ~env =
67+
let base_url = Config.base_url in
68+
let clock = Eio.Stdenv.clock env in
69+
let now = Eio.Time.now clock in
70+
let keypair = Nostr_signer.generate_keypair () in
71+
let sha1 = sha256_hex "blob 1" in
72+
let sha2 = sha256_hex "blob 2" in
73+
let event = Nostr_signer.create_report
74+
~keypair ~created_at:now
75+
~entries:[(sha1, "malware"); (sha2, "illegal")]
76+
~content:""
77+
()
78+
in
79+
let body = Nostr_signer.event_to_json event in
80+
let result = Http_client.put ~sw ~env ~url:(base_url ^ "/report")
81+
~headers:[("Content-Type", "application/json")] ~body ()
82+
in
83+
match result with
84+
| Error e -> failwith ("PUT /report failed: " ^ e)
85+
| Ok response ->
86+
if response.status <> 200 then
87+
failwith (Printf.sprintf "Expected 200, got %d (body: %s)"
88+
response.status response.body)
89+
90+
(** Test: PUT /report rejects an invalid (non-NIP-56) body *)
91+
let test_put_report_rejects_invalid_body ~sw ~env =
92+
let base_url = Config.base_url in
93+
let result = Http_client.put ~sw ~env ~url:(base_url ^ "/report")
94+
~headers:[("Content-Type", "application/json")]
95+
~body:"{\"not\":\"a report\"}" ()
96+
in
97+
match result with
98+
| Error e -> failwith ("Request failed: " ^ e)
99+
| Ok response ->
100+
if response.status <> 400 then
101+
failwith (Printf.sprintf "Expected 400 for invalid body, got %d" response.status)
102+
103+
(** Test: PUT /report rejects an unknown report type *)
104+
let test_put_report_rejects_unknown_type ~sw ~env =
105+
let base_url = Config.base_url in
106+
let clock = Eio.Stdenv.clock env in
107+
let now = Eio.Time.now clock in
108+
let keypair = Nostr_signer.generate_keypair () in
109+
let some_sha = sha256_hex "x" in
110+
let event = Nostr_signer.create_report
111+
~keypair ~created_at:now
112+
~entries:[(some_sha, "vandalism")]
113+
~content:""
114+
()
115+
in
116+
let body = Nostr_signer.event_to_json event in
117+
let result = Http_client.put ~sw ~env ~url:(base_url ^ "/report")
118+
~headers:[("Content-Type", "application/json")] ~body ()
119+
in
120+
match result with
121+
| Error e -> failwith ("Request failed: " ^ e)
122+
| Ok response ->
123+
if response.status <> 400 then
124+
failwith (Printf.sprintf "Expected 400 for invalid report type, got %d" response.status)
125+
126+
let tests = [
127+
("GET / returns ToS", test_get_root_terms_of_service);
128+
("PUT /report accepted", test_put_report_accepted);
129+
("PUT /report multiple x tags", test_put_report_multiple_x_tags);
130+
("PUT /report rejects invalid body", test_put_report_rejects_invalid_body);
131+
("PUT /report rejects unknown type", test_put_report_rejects_unknown_type);
132+
]

lib/core/domain.ml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type error =
1919
| Mirror_invalid_url of string (* invalid mirror URL -> 400 *)
2020
| Mirror_fetch_error of string (* remote fetch failed -> 502 *)
2121
| Mirror_ssrf_blocked of string (* SSRF protection blocked URL -> 400, detail for logging *)
22+
| Report_error of string (* invalid NIP-56 report event -> 400 *)
2223

2324
(** Mirror request body *)
2425
type mirror_request = {

lib/core/report.ml

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
(** NIP-56 blob report event validation (BUD-09).
2+
3+
A report is a kind:1984 Nostr event with one or more `x` tags, each
4+
containing the sha256 of a reported blob and a report type. *)
5+
6+
type report_type =
7+
| Nudity
8+
| Malware
9+
| Profanity
10+
| Illegal
11+
| Spam
12+
| Impersonation
13+
| Other
14+
15+
let report_type_of_string = function
16+
| "nudity" -> Some Nudity
17+
| "malware" -> Some Malware
18+
| "profanity" -> Some Profanity
19+
| "illegal" -> Some Illegal
20+
| "spam" -> Some Spam
21+
| "impersonation" -> Some Impersonation
22+
| "other" -> Some Other
23+
| _ -> None
24+
25+
let report_type_to_string = function
26+
| Nudity -> "nudity"
27+
| Malware -> "malware"
28+
| Profanity -> "profanity"
29+
| Illegal -> "illegal"
30+
| Spam -> "spam"
31+
| Impersonation -> "impersonation"
32+
| Other -> "other"
33+
34+
type entry = {
35+
sha256 : string;
36+
report_type : report_type;
37+
}
38+
39+
type t = {
40+
event_id : string;
41+
reporter_pubkey : string;
42+
created_at : int64;
43+
entries : entry list;
44+
content : string;
45+
e_tag : string option;
46+
p_tag : string option;
47+
raw_event_json : string;
48+
}
49+
50+
(** Parse a JSON string as a Nostr event. *)
51+
let parse_event_json (body : string) : (Nostr_event.t, Domain.error) result =
52+
try
53+
let json = Yojson.Safe.from_string body in
54+
let open Yojson.Safe.Util in
55+
let event : Nostr_event.t = {
56+
id = json |> member "id" |> to_string;
57+
pubkey = json |> member "pubkey" |> to_string;
58+
created_at = json |> member "created_at" |> to_int |> Int64.of_int;
59+
kind = json |> member "kind" |> to_int;
60+
tags = json |> member "tags" |> to_list |> List.map (fun tag -> tag |> to_list |> List.map to_string);
61+
content = json |> member "content" |> to_string;
62+
sig_ = json |> member "sig" |> to_string;
63+
} in
64+
Ok event
65+
with
66+
| Yojson.Json_error msg ->
67+
Error (Domain.Report_error ("JSON parse error: " ^ msg))
68+
| Yojson.Safe.Util.Type_error (msg, _) ->
69+
Error (Domain.Report_error ("JSON type error: " ^ msg))
70+
71+
(** Extract all `x` tag entries (sha256, type). Each x tag MUST be
72+
["x", "<sha256>", "<report_type>"]. *)
73+
let extract_entries (event : Nostr_event.t) : (entry list, Domain.error) result =
74+
let rec collect acc = function
75+
| [] -> Ok (List.rev acc)
76+
| tag :: rest ->
77+
(match tag with
78+
| "x" :: sha :: type_str :: _ ->
79+
if not (Integrity.validate_hash sha) then
80+
Error (Domain.Report_error ("Invalid sha256 in x tag: " ^ sha))
81+
else
82+
(match report_type_of_string type_str with
83+
| None ->
84+
Error (Domain.Report_error
85+
(Printf.sprintf "Invalid report type '%s' (expected nudity/malware/profanity/illegal/spam/impersonation/other)" type_str))
86+
| Some rt ->
87+
collect ({ sha256 = sha; report_type = rt } :: acc) rest)
88+
| "x" :: _ ->
89+
Error (Domain.Report_error "x tag must contain sha256 and report type")
90+
| _ -> collect acc rest)
91+
in
92+
match collect [] event.tags with
93+
| Error e -> Error e
94+
| Ok [] -> Error (Domain.Report_error "Report event must contain at least one x tag")
95+
| Ok entries -> Ok entries
96+
97+
(** Validate the Nostr event's id and signature. *)
98+
let verify_event (event : Nostr_event.t) : (unit, Domain.error) result =
99+
if not (Nostr_event.verify_id event) then
100+
Error (Domain.Report_error "Event ID does not match computed hash")
101+
else
102+
match Nostr_event.verify_signature event with
103+
| Ok () -> Ok ()
104+
| Error Nostr_event.Invalid_id_format ->
105+
Error (Domain.Report_error "Invalid event ID format")
106+
| Error Nostr_event.Invalid_pubkey_format ->
107+
Error (Domain.Report_error "Invalid pubkey format")
108+
| Error Nostr_event.Invalid_signature_format ->
109+
Error (Domain.Report_error "Invalid signature format")
110+
| Error Nostr_event.Signature_mismatch ->
111+
Error (Domain.Report_error "Invalid signature")
112+
113+
(** Validate a NIP-56 report body for BUD-09 PUT /report.
114+
[current_time] is accepted for future use (e.g., rejecting events
115+
created far in the future). Currently we do not require an
116+
`expiration` tag, as NIP-56 does not mandate one. *)
117+
let validate ~current_time (body : string) : (t, Domain.error) result =
118+
let _ = current_time in
119+
match parse_event_json body with
120+
| Error e -> Error e
121+
| Ok event ->
122+
if event.kind <> 1984 then
123+
Error (Domain.Report_error "Invalid event kind, must be 1984")
124+
else
125+
match extract_entries event with
126+
| Error e -> Error e
127+
| Ok entries ->
128+
match verify_event event with
129+
| Error e -> Error e
130+
| Ok () ->
131+
let e_tag = Nostr_event.find_tag event "e" in
132+
let p_tag = Nostr_event.find_tag event "p" in
133+
Ok {
134+
event_id = event.id;
135+
reporter_pubkey = event.pubkey;
136+
created_at = event.created_at;
137+
entries;
138+
content = event.content;
139+
e_tag;
140+
p_tag;
141+
raw_event_json = body;
142+
}

0 commit comments

Comments
 (0)