From b12b988432035d428f791404611600355fbec608 Mon Sep 17 00:00:00 2001 From: kengirie Date: Wed, 6 May 2026 02:28:24 -0700 Subject: [PATCH] feat: Add nip94 field to upload/mirror responses (BUD-08) Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- e2e/test_mirror.ml | 20 +++++++++++- e2e/test_upload.ml | 27 +++++++++++++++- lib/core/nip94.ml | 19 ++++++++++++ lib/shell/http_response.ml | 29 +++++++++++++----- lib/shell/http_server.ml | 3 +- test/dune | 2 +- test/test_http_response.ml | 63 ++++++++++++++++++++++++++++++++++++++ test/test_nip94.ml | 34 ++++++++++++++++++++ test/test_runner.ml | 1 + 10 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 lib/core/nip94.ml create mode 100644 test/test_nip94.ml diff --git a/README.md b/README.md index 25bb0ca..8a100db 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ blossoML is yet another [Blossom](https://github.com/hzrd149/blossom) server imp ### Other BUDs - [x] Nostr Authorization (`kind:24242`) ([BUD-11](https://github.com/hzrd149/blossom/blob/master/buds/11.md)) -- [ ] Nostr File Metadata Tags (`nip94` in blob descriptor) ([BUD-08](https://github.com/hzrd149/blossom/blob/master/buds/08.md)) +- [x] Nostr File Metadata Tags (`nip94` in blob descriptor) ([BUD-08](https://github.com/hzrd149/blossom/blob/master/buds/08.md)) - [ ] Payment Required (`402` + `X-Cashu` / `X-Lightning`) ([BUD-07](https://github.com/hzrd149/blossom/blob/master/buds/07.md)) BUD-03 (User Server List) and BUD-10 (Blossom URI Schema) are client-side specifications and do not apply to server implementations. diff --git a/e2e/test_mirror.ml b/e2e/test_mirror.ml index 75490ab..4fcea64 100644 --- a/e2e/test_mirror.ml +++ b/e2e/test_mirror.ml @@ -323,7 +323,25 @@ let test_mirror_response_format ~sw ~env = (* Check uploaded field *) (match List.assoc_opt "uploaded" fields with | Some (`Int _) -> () - | _ -> failwith "Missing or invalid 'uploaded' field") + | _ -> failwith "Missing or invalid 'uploaded' field"); + + (* BUD-08: Check nip94 field exists with required tags *) + (match List.assoc_opt "nip94" fields with + | Some (`List entries) -> + let pairs = List.map (fun entry -> + match entry with + | `List [`String k; `String v] -> (k, v) + | _ -> failwith "Invalid nip94 entry shape" + ) entries in + List.iter (fun key -> + if not (List.mem_assoc key pairs) then + failwith (Printf.sprintf "nip94 missing required '%s' tag" key) + ) ["url"; "m"; "x"; "ox"; "size"]; + (match List.assoc_opt "x" pairs with + | Some x when x = sha256 -> () + | Some x -> failwith (Printf.sprintf "nip94 x mismatch: expected %s, got %s" sha256 x) + | None -> failwith "nip94 missing 'x' tag") + | _ -> failwith "Missing or invalid 'nip94' field") | _ -> failwith "Response is not a JSON object" (** Helper: make an authenticated mirror request and return the response *) diff --git a/e2e/test_upload.ml b/e2e/test_upload.ml index f419ba3..2ebef3f 100644 --- a/e2e/test_upload.ml +++ b/e2e/test_upload.ml @@ -945,7 +945,32 @@ let test_upload_response_format ~sw ~env = (* Check uploaded field (timestamp) *) (match List.assoc_opt "uploaded" fields with | Some (`Int _) -> () - | _ -> failwith "Missing or invalid 'uploaded' field") + | _ -> failwith "Missing or invalid 'uploaded' field"); + + (* BUD-08: Check nip94 field *) + (match List.assoc_opt "nip94" fields with + | Some (`List entries) -> + let pairs = List.map (fun entry -> + match entry with + | `List [`String k; `String v] -> (k, v) + | _ -> failwith "Invalid nip94 entry shape" + ) entries in + (match List.assoc_opt "url" pairs with + | Some _ -> () + | None -> failwith "nip94 missing 'url' tag"); + (match List.assoc_opt "m" pairs with + | Some m when m = "text/plain" -> () + | Some m -> failwith (Printf.sprintf "nip94 m mismatch: expected text/plain, got %s" m) + | None -> failwith "nip94 missing 'm' tag"); + (match List.assoc_opt "x" pairs with + | Some x when x = sha256 -> () + | Some x -> failwith (Printf.sprintf "nip94 x mismatch: expected %s, got %s" sha256 x) + | None -> failwith "nip94 missing 'x' tag"); + (match List.assoc_opt "size" pairs with + | Some s when s = string_of_int (String.length content) -> () + | Some s -> failwith (Printf.sprintf "nip94 size mismatch: got %s" s) + | None -> failwith "nip94 missing 'size' tag") + | _ -> failwith "Missing or invalid 'nip94' field") | _ -> failwith "Response is not a JSON object" (** Test: Upload with very long Content-Type *) diff --git a/lib/core/nip94.ml b/lib/core/nip94.ml new file mode 100644 index 0000000..a1aca59 --- /dev/null +++ b/lib/core/nip94.ml @@ -0,0 +1,19 @@ +(** NIP-94 File Metadata tag generation (BUD-08). + + Pure logic that converts a [Domain.blob_descriptor] into the KV pairs + described in https://github.com/nostr-protocol/nips/blob/master/94.md. + + Only fields the server can derive from the blob itself are emitted: + [url], [m] (mime), [x] (sha256), [ox] (original sha256), [size]. + + [ox] is set to the same value as [x] because this server stores blobs + verbatim and never transforms them. *) + +let tags_of_descriptor (d : Domain.blob_descriptor) : (string * string) list = + [ + ("url", d.url); + ("m", d.mime_type); + ("x", d.sha256); + ("ox", d.sha256); + ("size", string_of_int d.size); + ] diff --git a/lib/shell/http_response.ml b/lib/shell/http_response.ml index 72a3f63..29d9667 100644 --- a/lib/shell/http_response.ml +++ b/lib/shell/http_response.ml @@ -61,19 +61,33 @@ let cors_headers = [ ("access-control-max-age", "86400"); ] -(** blob descriptorをYojson値に変換する純粋関数 *) -let descriptor_to_yojson (descriptor : Domain.blob_descriptor) : Yojson.Basic.t = - `Assoc [ +(** NIP-94タグ ([("k", "v"); ...]) を JSON 配列 [["k","v"],...] に変換 *) +let nip94_tags_to_yojson (tags : (string * string) list) : Yojson.Basic.t = + `List (List.map (fun (k, v) -> `List [`String k; `String v]) tags) + +(** blob descriptorをYojson値に変換する純粋関数 + + [include_nip94] が true のとき、BUD-08 に従って [nip94] フィールドを追加する。 + [/upload], [/mirror] のレスポンスでのみ true を渡す。 *) +let descriptor_to_yojson ?(include_nip94 = false) (descriptor : Domain.blob_descriptor) : Yojson.Basic.t = + let base = [ ("url", `String descriptor.url); ("sha256", `String descriptor.sha256); ("size", `Int descriptor.size); ("type", `String descriptor.mime_type); ("uploaded", `Int (Int64.to_int descriptor.uploaded)); - ] + ] in + let fields = + if include_nip94 then + base @ [("nip94", nip94_tags_to_yojson (Nip94.tags_of_descriptor descriptor))] + else + base + in + `Assoc fields (** blob descriptorをJSON文字列に変換する純粋関数 *) -let descriptor_to_json (descriptor : Domain.blob_descriptor) = - descriptor_to_yojson descriptor |> Yojson.Basic.to_string +let descriptor_to_json ?(include_nip94 = false) (descriptor : Domain.blob_descriptor) = + descriptor_to_yojson ~include_nip94 descriptor |> Yojson.Basic.to_string (** レスポンスの種類から実際のHTTPレスポンスを生成する純粋関数 @@ -104,7 +118,8 @@ let create = function Response.create ~headers `OK | Success_upload descriptor -> - let json = descriptor_to_json descriptor in + (* BUD-08: /upload と /mirror のレスポンスには nip94 フィールドを含める *) + let json = descriptor_to_json ~include_nip94:true descriptor in let headers = Headers.of_list (cors_headers @ [ ("content-type", "application/json"); ]) in diff --git a/lib/shell/http_server.ml b/lib/shell/http_server.ml index ee296a9..cc1ecb0 100644 --- a/lib/shell/http_server.ml +++ b/lib/shell/http_server.ml @@ -268,7 +268,7 @@ let request_handler ~sw ~env ~clock ~data_dir ~db ~base_url { Server.Handler.req mime_type = detected_mime_type; uploaded = Int64.of_float (Eio.Time.now clock); } in - Eio.traceln "Upload response: %s" (Http_response.descriptor_to_json descriptor); + Eio.traceln "Upload response: %s" (Http_response.descriptor_to_json ~include_nip94:true descriptor); Http_response.Success_upload descriptor)))))) | `DELETE, path -> @@ -354,6 +354,7 @@ let request_handler ~sw ~env ~clock ~data_dir ~db ~base_url { Server.Handler.req mime_type = detected_mime_type; uploaded = Int64.of_float (Eio.Time.now clock); } in + Eio.traceln "Mirror response: %s" (Http_response.descriptor_to_json ~include_nip94:true descriptor); Http_response.Success_upload descriptor))) | _ -> Http_response.Error_not_found "Not found" diff --git a/test/dune b/test/dune index c8466f1..f9d4049 100644 --- a/test/dune +++ b/test/dune @@ -1,4 +1,4 @@ (test (name test_runner) - (modules test_runner test_integrity test_policy test_auth test_bip340 test_http_response test_mime_detect test_content_type test_nostr_event test_nostr_signer test_mirror) + (modules test_runner test_integrity test_policy test_auth test_bip340 test_http_response test_mime_detect test_content_type test_nostr_event test_nostr_signer test_mirror test_nip94) (libraries blossoML.core blossoML.shell e2e alcotest base64)) diff --git a/test/test_http_response.ml b/test/test_http_response.ml index 05d0009..1c00682 100644 --- a/test/test_http_response.ml +++ b/test/test_http_response.ml @@ -54,6 +54,67 @@ let test_success_upload () = check int "Status code" 200 (get_status response); check_cors_headers response +(* BUD-08: Success_upload should include nip94 field and preserve base fields *) +let test_success_upload_includes_nip94 () = + let descriptor = { + Domain.url = "http://localhost:8082/abc123"; + sha256 = "abc123"; + size = 500; + mime_type = "application/pdf"; + uploaded = 1234567890L; + } in + let response = Http_response.create (Success_upload descriptor) in + let body = match Response.body response |> Body.to_string with + | Ok s -> s + | Error _ -> failwith "Failed to read body" + in + let json = Yojson.Basic.from_string body in + let open Yojson.Basic.Util in + (* Base descriptor fields must still be present alongside nip94 *) + check string "base url" "http://localhost:8082/abc123" (json |> member "url" |> to_string); + check string "base sha256" "abc123" (json |> member "sha256" |> to_string); + check int "base size" 500 (json |> member "size" |> to_int); + check string "base type" "application/pdf" (json |> member "type" |> to_string); + check int "base uploaded" 1234567890 (json |> member "uploaded" |> to_int); + let nip94 = json |> member "nip94" in + (match nip94 with + | `Null -> failwith "Missing nip94 field" + | _ -> ()); + let pairs = nip94 |> to_list |> List.map (fun item -> + match item |> to_list with + | [`String k; `String v] -> (k, v) + | _ -> failwith "Invalid nip94 entry" + ) in + let lookup k = + match List.assoc_opt k pairs with + | Some v -> v + | None -> Alcotest.failf "nip94 missing %s tag" k + in + check string "url tag" "http://localhost:8082/abc123" (lookup "url"); + check string "m tag" "application/pdf" (lookup "m"); + check string "x tag" "abc123" (lookup "x"); + check string "ox tag" "abc123" (lookup "ox"); + check string "size tag" "500" (lookup "size") + +(* BUD-08: Success_list should NOT include nip94 *) +let test_success_list_no_nip94 () = + let descriptors = [{ + Domain.url = "http://localhost:8082/aaa"; + sha256 = "aaa"; + size = 100; + mime_type = "text/plain"; + uploaded = 1000L; + }] in + let response = Http_response.create (Success_list descriptors) in + let body = match Response.body response |> Body.to_string with + | Ok s -> s + | Error _ -> failwith "Failed to read body" + in + let json = Yojson.Basic.from_string body in + let open Yojson.Basic.Util in + let first = json |> to_list |> List.hd in + check bool "list entries have no nip94" true (first |> member "nip94" = `Null) + (* Success_delete レスポンステスト *) let test_success_delete () = let response = Http_response.create Success_delete in @@ -224,6 +285,8 @@ let tests = [ test_case "Success_blob response" `Quick test_success_blob; test_case "Success_metadata response" `Quick test_success_metadata; test_case "Success_upload response" `Quick test_success_upload; + test_case "Success_upload includes nip94 (BUD-08)" `Quick test_success_upload_includes_nip94; + test_case "Success_list excludes nip94 (BUD-08)" `Quick test_success_list_no_nip94; test_case "Success_list empty response" `Quick test_success_list_empty; test_case "Success_list populated response" `Quick test_success_list_populated; test_case "Success_delete response" `Quick test_success_delete; diff --git a/test/test_nip94.ml b/test/test_nip94.ml new file mode 100644 index 0000000..8055e4d --- /dev/null +++ b/test/test_nip94.ml @@ -0,0 +1,34 @@ +open Alcotest +open Blossom_core + +let sample_descriptor : Domain.blob_descriptor = { + url = "https://cdn.example.com/abcdef"; + sha256 = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + size = 184292; + mime_type = "application/pdf"; + uploaded = 1725909682L; +} + +let find_tag tags key = + match List.assoc_opt key tags with + | Some v -> v + | None -> Alcotest.failf "missing %s tag" key + +let test_tags_basic () = + let tags = Nip94.tags_of_descriptor sample_descriptor in + check int "tag count" 5 (List.length tags); + check string "url tag" sample_descriptor.url (find_tag tags "url"); + check string "m tag" sample_descriptor.mime_type (find_tag tags "m"); + check string "x tag" sample_descriptor.sha256 (find_tag tags "x"); + check string "ox tag (= x for untransformed blobs)" sample_descriptor.sha256 (find_tag tags "ox"); + check string "size tag" "184292" (find_tag tags "size") + +let test_tags_size_is_string () = + let d = { sample_descriptor with size = 0 } in + let tags = Nip94.tags_of_descriptor d in + check string "size 0 stringified" "0" (find_tag tags "size") + +let tests = [ + test_case "tags_of_descriptor returns url/m/x/size" `Quick test_tags_basic; + test_case "size value is stringified" `Quick test_tags_size_is_string; +] diff --git a/test/test_runner.ml b/test/test_runner.ml index fc1ffc0..e086680 100644 --- a/test/test_runner.ml +++ b/test/test_runner.ml @@ -11,4 +11,5 @@ let () = "Nostr_event", Test_nostr_event.tests; "Nostr_signer", Test_nostr_signer.tests; "Mirror", Test_mirror.tests; + "Nip94", Test_nip94.tests; ]