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
4 changes: 3 additions & 1 deletion src/dev_b32_name.erl
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
%%% @doc Allows Arweave message IDs to be used via their base32 encoding as
%%% subdomains on a HyperBEAM node.
-module(dev_b32_name).
-export([info/1]).
-export([info/1, decode/1]).
%%% Public helpers.
-export([ encode/1]).
-include("include/hb.hrl").
-include_lib("eunit/include/eunit.hrl").

Expand Down
106 changes: 100 additions & 6 deletions src/dev_cache.erl
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,39 @@ expected_response(Base, Req, Opts) ->
{ok, Response} ?= hb_maps:find(<<"body">>, Req, Opts),
{ok, Expected} ?= hb_maps:find(<<"expected">>, Base, Opts),
true ?= check_response_matches_expected(Response, Expected, Opts),
dev_hook:on(
[<<"~cache@1.0">>, <<"admissible-response">>],
Response,
Opts
),
fire_admissible_response_hook(Base, Opts),
{ok, true}
else Reason ->
?event(debug_admissible, {expected_response_error, Reason}),
{ok, false}
end.

%% @doc Fire the `admissible-response' hook. Spawns by default so the cache
%% read returns without waiting for downstream handlers.
%% Set `admissible_response_hook_async => false' in `Opts' to run synchronously
fire_admissible_response_hook(Base, Opts) ->
Run =
fun() ->
dev_hook:on(
[<<"~cache@1.0">>, <<"admissible-response">>],
#{ <<"body">> => admissible_response_body(Base, Opts) },
Opts
)
end,
case hb_opts:get(admissible_response_hook_async, true, Opts) of
false -> Run();
_ ->
spawn(fun() ->
try Run()
catch C:R:S ->
?event(error,
{admissible_response_hook_async_error,
{class, C}, {reason, R},
{stacktrace, {trace, S}}})
end
end)
end.

%% @doc Verify that a response from a remote cache matches the expected ID.
%% There are three cases:
%% 1. The response is a raw binary (e.g., a direct content-addressed read
Expand Down Expand Up @@ -84,6 +106,23 @@ check_response_matches_expected(Response, Expected, Opts) ->
true
end.

%% @doc Build the body for the `admissible-response' hook. Optionally signs it
%% when `commit_hook_response' is set, so downstream handlers can verify the
%% node attested to admissibility.
admissible_response_body(Base, Opts) ->
Ref = hb_maps:get(<<"http-reference">>, Base, <<>>, Opts),
Body = #{
<<"reference">> => Ref,
<<"status-class">> => <<"success">>,
<<"event">> => <<"is_admissible">>
},
case hb_opts:get(commit_hook_response, false, Opts) of
true ->
hb_message:commit(Body, Opts#{ priv_wallet => hb:wallet() });
_ ->
Body
end.

%% @doc Read data from the cache.
%% Retrieves data corresponding to a key from a local store.
%% The key is extracted from the incoming message under &lt;&lt;"target"&gt;&gt;.
Expand Down Expand Up @@ -424,4 +463,59 @@ cache_write_binary_test() ->
?event(dev_cache, {cache_api_test, {data_read, ReadData}}),
?assertEqual(TestData, ReadData),
?event(dev_cache, {cache_api_test}),
ok.
ok.

%% @doc Compare wall-clock time of `fire_admissible_response_hook/2' with the
%% async flag on vs off. The hook handler sleeps `HookDelayMs' before signalling
%% so sync mode must take >= HookDelayMs and async mode must return immediately.
fire_admissible_response_hook_async_speed_test() ->
HookDelayMs = 500,
Self = self(),
Handler = #{
<<"path">> => <<"hook">>,
<<"device">> => #{
hook =>
fun(_, Req, _) ->
timer:sleep(HookDelayMs),
Self ! hook_ran,
{ok, Req}
end
}
},
BaseOpts = #{
on => #{
<<"~cache@1.0">> =>
#{ <<"admissible-response">> => Handler }
}
},
Base = #{},
%% --- Sync run: fire_admissible_response_hook blocks for the full sleep.
{SyncMicros, _} =
timer:tc(fun() ->
fire_admissible_response_hook(Base,
BaseOpts#{ admissible_response_hook_async => false })
end),
SyncMs = SyncMicros div 1000,
receive hook_ran -> ok
after HookDelayMs * 4 -> error(sync_hook_never_ran)
end,
%% --- Async run: returns immediately; hook runs in spawned process.
{AsyncMicros, _} =
timer:tc(fun() ->
fire_admissible_response_hook(Base,
BaseOpts#{ admissible_response_hook_async => true })
end),
AsyncMs = AsyncMicros div 1000,
receive hook_ran -> ok
after HookDelayMs * 4 -> error(async_hook_never_ran)
end,
Speedup = case AsyncMs of 0 -> infinity; _ -> SyncMs div AsyncMs end,
io:format(
user,
"~nadmissible_response_hook_speed: "
"hook_delay=~pms sync=~pms async=~pms speedup=~px~n",
[HookDelayMs, SyncMs, AsyncMs, Speedup]
),
?assert(SyncMs >= HookDelayMs),
?assert(AsyncMs < HookDelayMs div 2),
?assert(SyncMs > AsyncMs * 4).
30 changes: 30 additions & 0 deletions src/dev_chance.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
%%% @doc A generic probabilistic gate device. When accessed as
%%% `~chance@1.0/N', the key `N' is parsed as an integer rate.
%%% A 1-in-N random check is performed:
%%%
%%% {ok, Req} -- check passed, hook chain continues
%%% {error, _} -- check failed, hook chain halts
%%%
%%% This device uses the 4-arity default handler pattern so that the
%%% rate is supplied via the URL path rather than a config key.
-module(dev_chance).
-export([info/1]).
-include("include/hb.hrl").
-include_lib("eunit/include/eunit.hrl").

info(_) -> #{ default => fun default/4 }.

default(Key, _Base, Req, _Opts) ->
try binary_to_integer(Key) of
Rate when Rate > 0 ->
?event({request, {rate, Rate}}),
case rand:uniform(Rate) =:= 1 of
true -> {ok, Req};
false -> {error, <<"Filtered by chance gate.">>}
end;
_ ->
{error, <<"chance@1.0 rate must be a positive integer.">>}
catch
error:badarg ->
{error, <<"chance@1.0 rate must be a valid integer.">>}
end.
10 changes: 8 additions & 2 deletions src/dev_codec_httpsig_siginfo.erl
Original file line number Diff line number Diff line change
Expand Up @@ -284,9 +284,15 @@ decoding_nested_map_binary(Bin) ->
lists:foldl(
fun (X, Acc) ->
case binary:split(X, <<":">>, [global]) of
[ID, Key, Value] ->
[ID | Rest] when length(Rest) >= 2 ->
{KeyParts, [Value]} =
lists:split(length(Rest) - 1, Rest),
Key =
iolist_to_binary(
lists:join(<<":">>, KeyParts)
),
Acc#{
ID => #{
ID => #{
<<"name">> => Key,
<<"value">> => hb_util:decode(Value)
}
Expand Down
89 changes: 58 additions & 31 deletions src/dev_manifest.erl
Original file line number Diff line number Diff line change
Expand Up @@ -87,50 +87,77 @@ route(Key, M1, M2, Opts) ->

%% @doc Implement the `on/request' hook for the `manifest@1.0' device, finding
%% requests for legacy (non-device-tagged) manifests and casting them to
%% `manifest@1.0' before execution. Allowing `/ID/path` style access for old data.
%% `manifest@1.0' before execution. When a manifest is hit on the root domain
%% it redirects to its canonical b32 subdomain so browsers resolve absolute
%% asset paths correctly.
request(Base, Req, Opts) ->
?event({on_req_manifest_detector, {base, Base}, {req, Req}}),
maybe
{ok, [PrimaryMsg|Rest]} ?= hb_maps:find(<<"body">>, Req, Opts),
{ok, Loaded} ?= load(PrimaryMsg, Opts),
?event(debug_manifest, {loaded, Loaded}),
% Must handle three cases:
% 1. The maybe_cast is not a manifest, so we return the *loaded* request,
% such that the work to load it is not wasted.
% 2. The maybe_cast is a manifest, and there are no other elements of
% the path, so we add the `index' path and return.
% 3. The maybe_cast is a manifest, and there are other elements of
% the path, so we return the original request sequence with the first
% message replaced with the casted manifest.
case {Rest, maybe_cast_manifest(Loaded, Opts)} of
{_, ignored} ->
?event(
debug_manifest,
{non_manifest_returning_loaded, {loaded, Loaded}, {rest, Rest}}),
case maybe_cast_manifest(Loaded, Opts) of
ignored ->
{ok, Req#{ <<"body">> => [Loaded|Rest] }};
{[], {ok, Casted}} ->
?event(debug_manifest, {manifest_returning_index, {req, Req}}),
{ok, Req#{ <<"body">> => [Casted, #{<<"path">> => <<"index">>}] }};
{_, {ok, Casted}} ->
?event(debug_manifest, {manifest_returning_subpath, {req, Req}}),
{ok, Req#{ <<"body">> => [Casted|Rest] }}
{ok, Casted} ->
serve_or_redirect(PrimaryMsg, Casted, Rest, Req, Opts)
end
else
{error, not_found} ->
?event(debug_manifest, {not_found_on_load, {req, Req}}),
{
error,
#{
<<"status">> => 404,
<<"body">> => <<"Not Found">>
}
};
Error ->
?event(debug_manifest, {request_ignored, {unexpected, Error}}),
% On other errors, we return the original request.
{error, #{<<"status">> => 404, <<"body">> => <<"Not Found">>}};
_ ->
{ok, Req}
end.

serve_or_redirect(TxId, Casted, Rest, Req, Opts) ->
case needs_b32_redirect(TxId, Rest, Req, Opts) of
{redirect, Url} ->
redirect_response(Url);
no_redirect when Rest =:= [] ->
{ok, Req#{<<"body">> => [Casted, #{<<"path">> => <<"index">>}]}};
no_redirect ->
{ok, Req#{<<"body">> => [Casted|Rest]}}
end.

%% @doc A redirect is needed when the URL is `/<tx>/...' on the root domain
%% and no device invocations in the tail, host present, and not already on the
%% canonical `<b32>.<node-host>' subdomain.
needs_b32_redirect(TxId, Rest, Req, Opts) when ?IS_ID(TxId) ->
ReqInner = hb_maps:get(<<"request">>, Req, #{}, Opts),
Host = hb_maps:get(<<"host">>, ReqInner, <<>>, Opts),
B32 = dev_b32_name:encode(TxId),
Already = dev_name:name_from_host(Host, hb_opts:get(node_host, no_host, Opts)),
PlainTail = lists:all(fun(X) -> plain_path_segment(X, Opts) end, Rest),
case {Host =/= <<>>, PlainTail, Already} of
{true, true, NotCanonical} when NotCanonical =/= {ok, B32} ->
{redirect, b32_url(B32, ReqInner, Opts)};
_ -> no_redirect
end;
needs_b32_redirect(_, _, _, _) -> no_redirect.

%% @doc A plain path segment is a bare ID binary or a map without a device key.
plain_path_segment(X, _Opts) when is_binary(X) -> true;
plain_path_segment(M, Opts) when is_map(M) ->
not hb_maps:is_key(<<"device">>, M, Opts);
plain_path_segment(_,_) -> false.

b32_url(B32, ReqInner, Opts) ->
Host = hb_maps:get(<<"host">>, ReqInner, <<>>, Opts),
Path = hb_maps:get(<<"path">>, ReqInner, <<"/">>, Opts),
HostPort = case hb_maps:get(<<"port">>, ReqInner, undefined, Opts) of
P when P == undefined; P == 80; P == 443 -> Host;
P -> <<Host/binary, ":", (hb_util:bin(P))/binary>>
end,
<<"//", B32/binary, ".", HostPort/binary, Path/binary>>.

redirect_response(Url) ->
?event(debug_manifest, {b32_redirect, {url, Url}}),
{error, #{
<<"status">> => 302,
<<"location">> => Url,
<<"body">> => <<"Redirecting to canonical manifest subdomain: ", Url/binary>>
}}.

%% @doc Cast a message to `manifest@1.0` if it has the correct content-type but
%% no other device is specified.
load(Msg, _Opts) when is_map(Msg) -> {ok, Msg};
Expand Down
4 changes: 2 additions & 2 deletions src/dev_name.erl
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
%%% match the key against each resolver in turn, and return the value of the
%%% first resolver that matches.
-module(dev_name).
-export([info/1, request/3]).
-export([info/1, request/3, name_from_host/2]).
%%% Public helpers.
-export([test_arns_opts/0]).
-export([test_arns_opts/0, name_from_host/2]).
-include("include/hb.hrl").
-include_lib("eunit/include/eunit.hrl").

Expand Down
6 changes: 3 additions & 3 deletions src/dev_relay.erl
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ call(M1, RawM2, Opts) ->
RelayPath =
hb_ao:get_first(
[
{M1, <<"path">>},
{{as, <<"message@1.0">>, BaseTarget}, <<"path">>},
{RawM2, <<"relay-path">>},
{M1, <<"relay-path">>}
{M1, <<"relay-path">>},
{M1, <<"path">>},
{{as, <<"message@1.0">>, BaseTarget}, <<"path">>}
],
Opts
),
Expand Down
Loading