Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 19 additions & 1 deletion e2e/test_mirror.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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 *)
Expand Down
27 changes: 26 additions & 1 deletion e2e/test_upload.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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 *)
Expand Down
19 changes: 19 additions & 0 deletions lib/core/nip94.ml
Original file line number Diff line number Diff line change
@@ -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);
]
29 changes: 22 additions & 7 deletions lib/shell/http_response.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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レスポンスを生成する純粋関数

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion lib/shell/http_server.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion test/dune
Original file line number Diff line number Diff line change
@@ -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))
63 changes: 63 additions & 0 deletions test/test_http_response.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
34 changes: 34 additions & 0 deletions test/test_nip94.ml
Original file line number Diff line number Diff line change
@@ -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;
]
1 change: 1 addition & 0 deletions test/test_runner.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
]
Loading