Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 35s
GET /.well-known/webfinger?resource=acct:user@host lands in
http_server.erl next to the existing /.well-known/sx-capabilities
arm.
Dispatch chain:
route/2 -> dispatch/4 (matches webfinger path) -> handle_webfinger/1
-> webfinger_for_query/2
-> parse_resource_param/1 (matches "resource=" + collect via
take_until_amp/1)
-> discovery:parse_acct/1
-> webfinger_lookup/3 — host check + kernel actor lookup
-> 200 + discovery:webfinger_body/3 (application/activity+json)
-> 404 on any miss
Cfg surface:
{webfinger_host, Binary} optional; when set the acct's @host
must match exactly. Missing -> any.
{kernel, Atom} optional; when set, the user must be
a known actor in the registered kernel.
Missing -> every user is 'known' (pure
route tests).
route/2 already threads the Req's :query into Cfg as
:request_query (Step 4d), so the handler doesn't need to take
the Req directly.
10/10 in next/tests/webfinger_route.sh:
- GET happy path (no kernel cfg'd) -> 200
- body has subject prefix
- body has href substring
- missing ?resource= -> 404
- garbage 'resource=garbage' -> 404
- kernel cfg: alice 200, ghost 404
- :webfinger_host matches @host -> 200
- :webfinger_host mismatch -> 404
- POST -> 404 (only GET handled)
discovery.sh 12/12 unchanged, http_route.sh 11/11 unchanged.
1301 lines
50 KiB
Erlang
1301 lines
50 KiB
Erlang
-module(http_server).
|
|
-export([start/1, start/2]).
|
|
-export([route/1, route/2, route/3, ok_response/1, not_found_response/0,
|
|
welcome_body/0, capabilities_body/0,
|
|
capabilities_path/0,
|
|
match_prefix/2, actors_prefix/0, actor_doc_response/1,
|
|
artifacts_prefix/0, artifact_response/1,
|
|
projections_list_path/0, projections_prefix/0,
|
|
projections_list_response/0, projection_response/1,
|
|
activity_path/0, unauthorized_response/0,
|
|
post_activity_response/0,
|
|
validation_failed_response/0,
|
|
cid_response/1,
|
|
accept_format/1, accept_format_from/1,
|
|
capabilities_body_for/1,
|
|
content_type_for/1, ok_response/2,
|
|
cid_response_for/2, post_activity_response_for/1,
|
|
actor_doc_response_for/2, artifact_response_for/2,
|
|
projection_response_for/2, projections_list_response_for/1,
|
|
actor_outbox_response_for/2, actor_outbox_response_for/3,
|
|
actor_inbox_get_response_for/2,
|
|
actor_followers_response_for/2, actor_following_response_for/2,
|
|
actor_inbox_post_response/0, accepted_response/1,
|
|
split_first_slash/1]).
|
|
|
|
%% HTTP request router per design §16.1.
|
|
%%
|
|
%% Request shape (mirrors what the SX-side `http-listen` builds and
|
|
%% the http:listen/2 BIF bridge marshals into a proplist):
|
|
%% [{method, Binary}, {path, Binary}, {query, Binary},
|
|
%% {headers, [{Name, Value}, ...]}, {body, Binary}]
|
|
%%
|
|
%% Response shape:
|
|
%% [{status, Integer}, {headers, [{Name, Value}, ...]}, {body, Binary}]
|
|
%%
|
|
%% Real dispatch (actor docs, outbox listings, /activity POST,
|
|
%% /.well-known/sx-capabilities, etc.) lands in Step 8c+. Step 8b
|
|
%% wires the route/1 shape and a single hello-world handler that
|
|
%% proves the request→response round-trip.
|
|
%%
|
|
%% Method/path comparison uses integer-segment binaries because
|
|
%% `<<"GET">>` truncates to a single byte in this port.
|
|
|
|
%% Step 8b-start. `http:listen/2` blocks the calling process
|
|
%% forever (it's a native accept-loop on a TCP socket), so callers
|
|
%% wrap it in a spawned Erlang process. `start/1` is the bare form;
|
|
%% `start/2` accepts the same Cfg proplist that `route/2` uses so
|
|
%% the spawned handler closes over `:publish_token`, etc.
|
|
%%
|
|
%% Returns the Pid of the listener process; the caller can `link`
|
|
%% it or `monitor` it as needed. The handler always returns a
|
|
%% response — uncaught Erlang errors become a generic 500 via the
|
|
%% native primitive's try/with-fallback in sx_server.ml.
|
|
|
|
start(Port) ->
|
|
start(Port, []).
|
|
|
|
start(Port, Cfg) ->
|
|
spawn(fun () -> http:listen(Port, fun (Req) -> route(Req, Cfg) end) end).
|
|
|
|
route(Req) ->
|
|
route(Req, []).
|
|
|
|
%% route/2 — Cfg proplist carries optional `:publish_token` /
|
|
%% `:tokens` (POST /activity auth) and optional `:kernel`
|
|
%% (per-actor handlers — Step 4c). route/3 is sugar that puts
|
|
%% Kernel into Cfg.
|
|
route(Req, Cfg) ->
|
|
M = field(method, Req),
|
|
P = field(path, Req),
|
|
F = accept_format_from(Req),
|
|
Cfg1 = with_request_query(Req, Cfg),
|
|
case {M, P} of
|
|
{<<80,79,83,84>>, <<47,97,99,116,105,118,105,116,121>>} ->
|
|
handle_post_activity(Req, Cfg);
|
|
{<<71,69,84>>,
|
|
<<47,46,119,101,108,108,45,107,110,111,119,110,
|
|
47,115,120,45,99,97,112,97,98,105,108,105,116,105,101,115>>} ->
|
|
ok_response(capabilities_body_for(F));
|
|
{<<80,79,83,84>>, _} ->
|
|
case match_prefix(actors_prefix(), P) of
|
|
{ok, Rest} when byte_size(Rest) > 0 ->
|
|
handle_post_actor(Rest, Req, Cfg1);
|
|
_ ->
|
|
dispatch(M, P, F, Cfg1)
|
|
end;
|
|
_ ->
|
|
dispatch(M, P, F, Cfg1)
|
|
end.
|
|
|
|
%% handle_post_actor/3 — Step 5d ingest. Rest is the path after
|
|
%% "/actors/". Only `/<id>/inbox` is wired right now; other POST
|
|
%% sub-paths fall through to 404.
|
|
|
|
handle_post_actor(Rest, Req, Cfg) ->
|
|
case split_first_slash(Rest) of
|
|
{Id, <<105,110,98,111,120>>} -> handle_inbox_post(Id, Req, Cfg);
|
|
_ -> not_found_response()
|
|
end.
|
|
|
|
%% with_request_query/2 — bake the Req's :query binary into Cfg as
|
|
%% `{request_query, Q}` so sub-resource handlers can parse `?page=N`
|
|
%% etc without taking the Req as an extra argument.
|
|
with_request_query(Req, Cfg) ->
|
|
case field(query, Req) of
|
|
nil -> Cfg;
|
|
Q -> [{request_query, Q} | Cfg]
|
|
end.
|
|
|
|
%% route/3 — Step 4c convenience entry. Kernel is an opaque
|
|
%% reference (typically the registered `nx_kernel` atom). It's
|
|
%% folded into Cfg under `:kernel` so handlers can look it up
|
|
%% without a separate threading argument.
|
|
route(Req, Cfg, Kernel) ->
|
|
route(Req, [{kernel, Kernel} | Cfg]).
|
|
|
|
%% Backward-compat /2 wrapper — defaults to text format. Route
|
|
%% computes Format from the Accept header and calls dispatch/4
|
|
%% directly; dispatch/2 and dispatch/3 are kept for callers that
|
|
%% don't have a format / Cfg in scope.
|
|
dispatch(M, P) ->
|
|
dispatch(M, P, text, []).
|
|
|
|
dispatch(M, P, F) ->
|
|
dispatch(M, P, F, []).
|
|
|
|
%% 71 69 84 = "GET" | 47 = "/"
|
|
dispatch(<<71, 69, 84>>, <<47>>, _F, _Cfg) ->
|
|
ok_response(welcome_body());
|
|
%% GET /.well-known/sx-capabilities — Format threaded through
|
|
dispatch(<<71, 69, 84>>,
|
|
<<47,46,119,101,108,108,45,107,110,111,119,110,
|
|
47,115,120,45,99,97,112,97,98,105,108,105,116,105,101,115>>, F, _Cfg) ->
|
|
ok_response(capabilities_body_for(F));
|
|
%% GET /.well-known/webfinger — Step 10b
|
|
dispatch(<<71, 69, 84>>,
|
|
<<47,46,119,101,108,108,45,107,110,111,119,110,
|
|
47,119,101,98,102,105,110,103,101,114>>, _F, Cfg) ->
|
|
handle_webfinger(Cfg);
|
|
%% GET /projections — list stub. Comes before the /projections/{name}
|
|
%% prefix clause because the bare path has no trailing slash.
|
|
dispatch(<<71, 69, 84>>, <<47,112,114,111,106,101,99,116,105,111,110,115>>, F, _Cfg) ->
|
|
projections_list_response_for(F);
|
|
%% GET /actors/{id}[/sub] or /artifacts/{cid} or /projections/{name}
|
|
dispatch(<<71, 69, 84>>, Path, F, Cfg) ->
|
|
case match_prefix(actors_prefix(), Path) of
|
|
{ok, Rest} when byte_size(Rest) > 0 ->
|
|
actor_get(Rest, F, Cfg);
|
|
_ ->
|
|
case match_prefix(artifacts_prefix(), Path) of
|
|
{ok, Cid} when byte_size(Cid) > 0 ->
|
|
artifact_response_for(Cid, F);
|
|
_ ->
|
|
case match_prefix(projections_prefix(), Path) of
|
|
{ok, Name} when byte_size(Name) > 0 ->
|
|
projection_response_for(Name, F);
|
|
_ ->
|
|
not_found_response()
|
|
end
|
|
end
|
|
end;
|
|
%% POST handling moved to route/2 in Step 5d so the Req body and
|
|
%% full Cfg are in scope for the inbox pipeline. Anything that
|
|
%% reaches dispatch here is an unmatched method or path -> 404.
|
|
dispatch(_, _, _, _) ->
|
|
not_found_response().
|
|
|
|
%% actor_get/3 — Rest is the part after "/actors/". If it has no
|
|
%% inner slash, it's the bare actor doc. Otherwise dispatch on the
|
|
%% sub-segment. Cfg flows through so sub-resource handlers can
|
|
%% read `:kernel` for per-actor state lookup (Step 4c).
|
|
|
|
actor_get(Rest, F, Cfg) ->
|
|
case split_first_slash(Rest) of
|
|
{Id, <<>>} -> actor_doc_response_for(Id, F);
|
|
{Id, Sub} -> actor_subresource_get(Id, Sub, F, Cfg);
|
|
Id -> actor_doc_response_for(Id, F)
|
|
end.
|
|
|
|
%% 111 117 116 98 111 120 = "outbox"
|
|
actor_subresource_get(Id, <<111,117,116,98,111,120>>, F, Cfg) ->
|
|
actor_outbox_response_for(Id, F, Cfg);
|
|
%% 105 110 98 111 120 = "inbox"
|
|
actor_subresource_get(Id, <<105,110,98,111,120>>, F, _Cfg) ->
|
|
actor_inbox_get_response_for(Id, F);
|
|
%% 102 111 108 108 111 119 101 114 115 = "followers"
|
|
actor_subresource_get(Id, <<102,111,108,108,111,119,101,114,115>>, F, _Cfg) ->
|
|
actor_followers_response_for(Id, F);
|
|
%% 102 111 108 108 111 119 105 110 103 = "following"
|
|
actor_subresource_get(Id, <<102,111,108,108,111,119,105,110,103>>, F, _Cfg) ->
|
|
actor_following_response_for(Id, F);
|
|
actor_subresource_get(_, _, _, _) ->
|
|
not_found_response().
|
|
|
|
%% split_first_slash/1 — split a binary on the first slash. Returns
|
|
%% {Before, After} where After omits the slash itself. If no slash
|
|
%% is present, returns just Before. 47 = "/".
|
|
%%
|
|
%% <<"alice">> -> <<"alice">>
|
|
%% <<"alice/">> -> {<<"alice">>, <<>>}
|
|
%% <<"alice/inbox">> -> {<<"alice">>, <<"inbox">>}
|
|
%% <<"alice/inbox/x">> -> {<<"alice">>, <<"inbox/x">>}
|
|
|
|
split_first_slash(Bin) ->
|
|
split_first_slash(Bin, <<>>).
|
|
|
|
split_first_slash(<<>>, Acc) ->
|
|
Acc;
|
|
split_first_slash(<<47, Rest/binary>>, Acc) ->
|
|
{Acc, Rest};
|
|
split_first_slash(<<B, Rest/binary>>, Acc) ->
|
|
split_first_slash(Rest, <<Acc/binary, B>>).
|
|
|
|
%% "fed-sx kernel m1\n" — 17 bytes, hand-spelled.
|
|
%% f e d - s x _ k e r n e l _ m 1 \n
|
|
welcome_body() ->
|
|
<<102,101,100,45,115,120,32,107,101,114,110,101,108,32,109,49,10>>.
|
|
|
|
%% "/.well-known/sx-capabilities" — exposed for callers that build
|
|
%% requests in tests or that need the canonical path string.
|
|
capabilities_path() ->
|
|
<<47,46,119,101,108,108,45,107,110,111,119,110,
|
|
47,115,120,45,99,97,112,97,98,105,108,105,116,105,101,115>>.
|
|
|
|
%% Capability descriptor body. Returned as plain text per design
|
|
%% §16; future content-negotiation work (Step 8d) layers JSON /
|
|
%% dag-cbor / SX representations on top.
|
|
%%
|
|
%% Lines (each terminated by \n = 10):
|
|
%% "kernel: fed-sx-m1\n"
|
|
%% "version: 0.0.1\n"
|
|
%% "verbs: Create Update Delete\n"
|
|
capabilities_body() ->
|
|
<<107,101,114,110,101,108,58,32,102,101,100,45,115,120,45,109,49,10,
|
|
118,101,114,115,105,111,110,58,32,48,46,48,46,49,10,
|
|
118,101,114,98,115,58,32,67,114,101,97,116,101,32,85,112,100,97,116,101,32,68,101,108,101,116,101,10>>.
|
|
|
|
ok_response(Body) ->
|
|
[{status, 200}, {headers, []}, {body, Body}].
|
|
|
|
not_found_response() ->
|
|
[{status, 404}, {headers, []},
|
|
{body, <<110,111,116,32,102,111,117,110,100,10>>}]. % "not found\n"
|
|
|
|
%% Internal property-list field lookup. Returns nil when missing
|
|
%% so the route falls into the not_found arm gracefully.
|
|
field(K, [{K, V} | _]) -> V;
|
|
field(K, [_ | Rest]) -> field(K, Rest);
|
|
field(_, []) -> nil.
|
|
|
|
%% ── Dynamic-segment routing ─────────────────────────────────────
|
|
%%
|
|
%% match_prefix(Prefix, Path) — if Path starts with the entire
|
|
%% Prefix binary, return {ok, Rest} where Rest is the remaining
|
|
%% bytes; else return nomatch. Pure byte-level pattern match,
|
|
%% no regex / no parsing. Path-segment splitting comes in later
|
|
%% sub-deliverables (8c-art, 8c-proj) where it's needed.
|
|
|
|
match_prefix(<<>>, Rest) -> {ok, Rest};
|
|
match_prefix(<<B, PRest/binary>>, <<B, PathRest/binary>>) ->
|
|
match_prefix(PRest, PathRest);
|
|
match_prefix(_, _) -> nomatch.
|
|
|
|
%% "/actors/" — 8 bytes: 47 97 99 116 111 114 115 47
|
|
actors_prefix() ->
|
|
<<47,97,99,116,111,114,115,47>>.
|
|
|
|
%% Actor doc stub. Real implementation (Step 8c continuation) will
|
|
%% fetch the actor-state projection entry and serialise it; v1
|
|
%% returns the id as the body so route resolution can be exercised
|
|
%% end-to-end without the projection wiring.
|
|
actor_doc_response(Id) ->
|
|
%% "actor: " — 7 bytes
|
|
Pre = <<97,99,116,111,114,58,32>>,
|
|
Body = <<Pre/binary, Id/binary, 10>>,
|
|
ok_response(Body).
|
|
|
|
%% "/artifacts/" — 11 bytes
|
|
artifacts_prefix() ->
|
|
<<47,97,114,116,105,102,97,99,116,115,47>>.
|
|
|
|
%% Artifact stub. Real implementation will fetch the bytes from
|
|
%% the registry (or a CID-keyed store) and content-negotiate.
|
|
%% v1 echoes the CID so route resolution can be tested.
|
|
artifact_response(Cid) ->
|
|
%% "artifact: " — 10 bytes
|
|
Pre = <<97,114,116,105,102,97,99,116,58,32>>,
|
|
Body = <<Pre/binary, Cid/binary, 10>>,
|
|
ok_response(Body).
|
|
|
|
%% "/projections" — 12 bytes (no trailing slash; the list endpoint)
|
|
projections_list_path() ->
|
|
<<47,112,114,111,106,101,99,116,105,111,110,115>>.
|
|
|
|
%% "/projections/" — 13 bytes (the per-projection prefix)
|
|
projections_prefix() ->
|
|
<<47,112,114,111,106,101,99,116,105,111,110,115,47>>.
|
|
|
|
%% Stub list response — real implementation queries the registry
|
|
%% for active projections and serialises the name+CID list.
|
|
projections_list_response() ->
|
|
%% "projections: (empty)\n" — hand-spelled
|
|
Body = <<112,114,111,106,101,99,116,105,111,110,115,58,32,
|
|
40,101,109,112,116,121,41,10>>,
|
|
ok_response(Body).
|
|
|
|
projection_response(Name) ->
|
|
%% "projection: " — 12 bytes
|
|
Pre = <<112,114,111,106,101,99,116,105,111,110,58,32>>,
|
|
Body = <<Pre/binary, Name/binary, 10>>,
|
|
ok_response(Body).
|
|
|
|
%% "/activity" — 9 bytes
|
|
activity_path() ->
|
|
<<47,97,99,116,105,118,105,116,121>>.
|
|
|
|
%% 401 Unauthorized response. Body: "unauthorized\n" = 13 bytes.
|
|
unauthorized_response() ->
|
|
[{status, 401}, {headers, []},
|
|
{body, <<117,110,97,117,116,104,111,114,105,122,101,100,10>>}].
|
|
|
|
%% Stub success body for POST /activity. Real impl will return
|
|
%% the published activity's CID once outbox:publish is wired
|
|
%% through a server-state context (Step 8c-post-publish).
|
|
post_activity_response() ->
|
|
%% "published (stub)\n" — hand-spelled
|
|
Body = <<112,117,98,108,105,115,104,101,100,32,
|
|
40,115,116,117,98,41,10>>,
|
|
ok_response(Body).
|
|
|
|
%% Auth helpers.
|
|
|
|
handle_post_activity(Req, Cfg) ->
|
|
case check_bearer(Req, Cfg) of
|
|
{ok, ActorRef} ->
|
|
F = accept_format_from(Req),
|
|
publish_if_kernel(Req, F, ActorRef);
|
|
{error, _} ->
|
|
unauthorized_response()
|
|
end.
|
|
|
|
%% publish_if_kernel/3 — if the nx_kernel gen_server is registered,
|
|
%% delegate the publish there and translate the result. Otherwise
|
|
%% keep the stub response so the auth-only tests stay green without
|
|
%% having to spin up a kernel process. Format threads through to
|
|
%% both stub and CID responses so the Content-Type matches what
|
|
%% the client asked for via Accept.
|
|
%%
|
|
%% ActorRef is either an explicit ActorId atom (Step 4b token map
|
|
%% resolution: route through nx_kernel:publish_to/2) or the atom
|
|
%% `legacy` from a single :publish_token Cfg back-compat (route
|
|
%% through nx_kernel:publish/1, which fans out to bucket 0).
|
|
publish_if_kernel(Req, F, ActorRef) ->
|
|
case erlang:whereis(nx_kernel) of
|
|
undefined ->
|
|
post_activity_response_for(F);
|
|
_Pid ->
|
|
Body = field(body, Req),
|
|
Request = [{type, create}, {object, Body}],
|
|
Result = case ActorRef of
|
|
legacy -> nx_kernel:publish(Request);
|
|
_ -> nx_kernel:publish_to(ActorRef, Request)
|
|
end,
|
|
case Result of
|
|
{ok, R} ->
|
|
case envelope:get_field(cid, R) of
|
|
{ok, Cid} -> cid_response_for(Cid, F);
|
|
_ -> post_activity_response_for(F)
|
|
end;
|
|
{error, _} ->
|
|
validation_failed_response()
|
|
end
|
|
end.
|
|
|
|
%% 200 OK with body "cid: <cid>\n" (5 prefix bytes + cid + newline)
|
|
cid_response(Cid) ->
|
|
%% "cid: " — 99 105 100 58 32
|
|
Pre = <<99,105,100,58,32>>,
|
|
Body = <<Pre/binary, Cid/binary, 10>>,
|
|
ok_response(Body).
|
|
|
|
%% 422 Unprocessable Entity. Body "validation failed\n" — 18 bytes.
|
|
validation_failed_response() ->
|
|
[{status, 422}, {headers, []},
|
|
{body, <<118,97,108,105,100,97,116,105,111,110,32,
|
|
102,97,105,108,101,100,10>>}].
|
|
|
|
check_bearer(Req, Cfg) ->
|
|
case bearer_token(Req) of
|
|
{ok, Got} -> resolve_token(Got, Cfg);
|
|
not_found -> {error, no_auth}
|
|
end.
|
|
|
|
%% resolve_token/2 — map a bearer token to either an explicit
|
|
%% ActorId (via Cfg's :tokens proplist) or the back-compat `legacy`
|
|
%% atom (via the M1 single-actor :publish_token). The :tokens map
|
|
%% takes precedence; if both are configured, :publish_token is only
|
|
%% consulted when the token isn't present in :tokens.
|
|
resolve_token(Got, Cfg) ->
|
|
case field(tokens, Cfg) of
|
|
nil -> resolve_legacy_token(Got, Cfg);
|
|
Tokens ->
|
|
case lookup_token(Got, Tokens) of
|
|
{ok, ActorId} -> {ok, ActorId};
|
|
not_found -> resolve_legacy_token(Got, Cfg)
|
|
end
|
|
end.
|
|
|
|
resolve_legacy_token(Got, Cfg) ->
|
|
case field(publish_token, Cfg) of
|
|
nil -> {error, no_token_match};
|
|
Want when Got =:= Want -> {ok, legacy};
|
|
_ -> {error, bad_token}
|
|
end.
|
|
|
|
lookup_token(_, []) -> not_found;
|
|
lookup_token(K, [{K, V} | _]) -> {ok, V};
|
|
lookup_token(K, [_ | Rest]) -> lookup_token(K, Rest).
|
|
|
|
%% Look up the Authorization header, strip "Bearer ", return token.
|
|
bearer_token(Req) ->
|
|
case field(headers, Req) of
|
|
nil -> not_found;
|
|
Hs ->
|
|
%% "authorization" — 13 bytes, lowercase as the BIF wrapper
|
|
%% normalises headers to lowercase keys.
|
|
AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>,
|
|
case find_header(AuthKey, Hs) of
|
|
not_found -> not_found;
|
|
{ok, V} -> strip_bearer(V)
|
|
end
|
|
end.
|
|
|
|
find_header(_, []) -> not_found;
|
|
find_header(K, [{K, V} | _]) -> {ok, V};
|
|
find_header(K, [_ | Rest]) -> find_header(K, Rest).
|
|
|
|
%% "Bearer " — 7 bytes — strip and return the rest as the token.
|
|
%% Anything else returns not_found (treated as missing auth).
|
|
strip_bearer(V) ->
|
|
Prefix = <<66,101,97,114,101,114,32>>,
|
|
case match_prefix(Prefix, V) of
|
|
{ok, Token} when byte_size(Token) > 0 -> {ok, Token};
|
|
_ -> not_found
|
|
end.
|
|
|
|
|
|
%% ── Step 8d: Accept-header parsing ──────────────────────────────
|
|
%%
|
|
%% accept_format/1 — given an Accept header value, return the
|
|
%% content-negotiation atom the route should serialise into. The
|
|
%% first media-type prefix that matches wins, in this priority:
|
|
%% application/activity+json -> activity_json
|
|
%% application/json -> json
|
|
%% application/sx -> sx
|
|
%% application/cbor -> cbor
|
|
%% Anything else (including unrecognised, empty, or missing header)
|
|
%% returns text — current routes default to text/plain bodies.
|
|
%%
|
|
%% Per-prefix recognition uses `match_prefix`. The header value is
|
|
%% NOT split on `,` here; matching against the leading bytes is
|
|
%% enough for the v1 envelope shapes the kernel currently emits.
|
|
|
|
%% Media-type prefix byte sequences — hand-spelled because
|
|
%% `<<"...">>` string-segments truncate in this port.
|
|
|
|
%% "application/activity+json" — 25 bytes
|
|
activity_json_prefix() ->
|
|
<<97,112,112,108,105,99,97,116,105,111,110,47,
|
|
97,99,116,105,118,105,116,121,43,106,115,111,110>>.
|
|
|
|
%% "application/json" — 16 bytes
|
|
json_prefix() ->
|
|
<<97,112,112,108,105,99,97,116,105,111,110,47,106,115,111,110>>.
|
|
|
|
%% "application/sx" — 14 bytes
|
|
sx_prefix() ->
|
|
<<97,112,112,108,105,99,97,116,105,111,110,47,115,120>>.
|
|
|
|
%% "application/cbor" — 16 bytes
|
|
cbor_prefix() ->
|
|
<<97,112,112,108,105,99,97,116,105,111,110,47,99,98,111,114>>.
|
|
|
|
accept_format(nil) -> text;
|
|
accept_format(<<>>) -> text;
|
|
accept_format(V) when is_binary(V) ->
|
|
case match_prefix(activity_json_prefix(), V) of
|
|
{ok, _} -> activity_json;
|
|
_ ->
|
|
case match_prefix(json_prefix(), V) of
|
|
{ok, _} -> json;
|
|
_ ->
|
|
case match_prefix(sx_prefix(), V) of
|
|
{ok, _} -> sx;
|
|
_ ->
|
|
case match_prefix(cbor_prefix(), V) of
|
|
{ok, _} -> cbor;
|
|
_ -> text
|
|
end
|
|
end
|
|
end
|
|
end;
|
|
accept_format(_) -> text.
|
|
|
|
%% accept_format_from/1 — pull the Accept header out of a request
|
|
%% proplist and run accept_format on its value. Lowercase key name
|
|
%% (matches the BIF wrapper's normalisation).
|
|
accept_format_from(Req) ->
|
|
case field(headers, Req) of
|
|
nil -> text;
|
|
Hs ->
|
|
%% "accept" — 6 bytes
|
|
K = <<97,99,99,101,112,116>>,
|
|
case find_header(K, Hs) of
|
|
{ok, V} -> accept_format(V);
|
|
not_found -> text
|
|
end
|
|
end.
|
|
|
|
%% capabilities_body_for/1 — content-negotiated capability bodies.
|
|
%% Each format returns a distinct byte sequence so dispatch can be
|
|
%% observed end-to-end. Real serialisation (JSON-LD, dag-cbor, etc.)
|
|
%% lands once the corresponding encoder BIFs are wired; v1 uses
|
|
%% tagged stubs that are syntactically the right shape.
|
|
capabilities_body_for(text) ->
|
|
capabilities_body();
|
|
%% `{"caps":"fed-sx-m1"}\n` — 21 bytes
|
|
capabilities_body_for(json) ->
|
|
<<123,34,99,97,112,115,34,58,34,
|
|
102,101,100,45,115,120,45,109,49,34,125,10>>;
|
|
capabilities_body_for(activity_json) ->
|
|
%% Same payload as :json — the difference is the Content-Type
|
|
%% header (Step 8d-content-type follow-up); body shape matches.
|
|
capabilities_body_for(json);
|
|
%% `(caps "fed-sx-m1")\n` — 19 bytes
|
|
capabilities_body_for(sx) ->
|
|
<<40,99,97,112,115,32,34,
|
|
102,101,100,45,115,120,45,109,49,34,41,10>>;
|
|
%% A minimal CBOR map: 0xA1 0x64 "caps" 0x69 "fed-sx-m1"
|
|
%% A1 = map(1); 64 = text(4) "caps"; 69 = text(9) "fed-sx-m1"
|
|
capabilities_body_for(cbor) ->
|
|
<<161,100,99,97,112,115,105,
|
|
102,101,100,45,115,120,45,109,49>>;
|
|
capabilities_body_for(_) ->
|
|
capabilities_body().
|
|
|
|
%% content_type_for/1 — MIME type binary for each format atom.
|
|
%% "text/plain" — 10 bytes
|
|
content_type_for(text) ->
|
|
<<116,101,120,116,47,112,108,97,105,110>>;
|
|
%% "application/json" — 16 bytes
|
|
content_type_for(json) ->
|
|
<<97,112,112,108,105,99,97,116,105,111,110,47,
|
|
106,115,111,110>>;
|
|
%% "application/activity+json" — 25 bytes
|
|
content_type_for(activity_json) ->
|
|
<<97,112,112,108,105,99,97,116,105,111,110,47,
|
|
97,99,116,105,118,105,116,121,43,106,115,111,110>>;
|
|
%% "application/sx" — 14 bytes
|
|
content_type_for(sx) ->
|
|
<<97,112,112,108,105,99,97,116,105,111,110,47,
|
|
115,120>>;
|
|
%% "application/cbor" — 16 bytes
|
|
content_type_for(cbor) ->
|
|
<<97,112,112,108,105,99,97,116,105,111,110,47,
|
|
99,98,111,114>>;
|
|
content_type_for(_) ->
|
|
content_type_for(text).
|
|
|
|
%% ok_response/2 — 200 OK with a Content-Type header derived from
|
|
%% the Format atom. The header key is lowercase to match how the
|
|
%% BIF wrapper normalises request headers.
|
|
%% "content-type" — 12 bytes
|
|
ok_response(Body, Format) ->
|
|
CTKey = <<99,111,110,116,101,110,116,45,116,121,112,101>>,
|
|
[{status, 200},
|
|
{headers, [{CTKey, content_type_for(Format)}]},
|
|
{body, Body}].
|
|
|
|
%% cid_response_for/2 — format-aware version of cid_response/1.
|
|
%% Each variant emits a syntactically appropriate body for the
|
|
%% chosen format and tags the response with the matching
|
|
%% Content-Type via ok_response/2.
|
|
|
|
cid_response_for(Cid, text) ->
|
|
cid_response(Cid);
|
|
%% `{"cid":"<cid>"}\n` — 8-byte prefix + cid + 3-byte suffix
|
|
cid_response_for(Cid, json) ->
|
|
Pre = <<123,34,99,105,100,34,58,34>>, % '{"cid":"'
|
|
Suf = <<34,125,10>>, % '"}\n'
|
|
ok_response(<<Pre/binary, Cid/binary, Suf/binary>>, json);
|
|
cid_response_for(Cid, activity_json) ->
|
|
Pre = <<123,34,99,105,100,34,58,34>>,
|
|
Suf = <<34,125,10>>,
|
|
ok_response(<<Pre/binary, Cid/binary, Suf/binary>>, activity_json);
|
|
%% `(cid "<cid>")\n` — 6-byte prefix + cid + 3-byte suffix
|
|
cid_response_for(Cid, sx) ->
|
|
Pre = <<40,99,105,100,32,34>>, % '(cid "'
|
|
Suf = <<34,41,10>>, % '")\n'
|
|
ok_response(<<Pre/binary, Cid/binary, Suf/binary>>, sx);
|
|
%% v1 cbor stub: the raw CID bytes with the application/cbor CT.
|
|
%% Real cbor encoding (A1 63 cid 78 <len> ...) lands later.
|
|
cid_response_for(Cid, cbor) ->
|
|
ok_response(Cid, cbor);
|
|
cid_response_for(Cid, _) ->
|
|
cid_response(Cid).
|
|
|
|
%% post_activity_response_for/1 — format-aware version of
|
|
%% post_activity_response/0 (the kernel-absent stub).
|
|
|
|
post_activity_response_for(text) ->
|
|
post_activity_response();
|
|
%% `{"status":"stub"}\n` — hand-spelled
|
|
post_activity_response_for(json) ->
|
|
Body = <<123,34,115,116,97,116,117,115,34,58,34,
|
|
115,116,117,98,34,125,10>>,
|
|
ok_response(Body, json);
|
|
post_activity_response_for(activity_json) ->
|
|
Body = <<123,34,115,116,97,116,117,115,34,58,34,
|
|
115,116,117,98,34,125,10>>,
|
|
ok_response(Body, activity_json);
|
|
%% `(status "stub")\n`
|
|
post_activity_response_for(sx) ->
|
|
Body = <<40,115,116,97,116,117,115,32,34,
|
|
115,116,117,98,34,41,10>>,
|
|
ok_response(Body, sx);
|
|
post_activity_response_for(cbor) ->
|
|
%% Same body as text but with cbor CT — clients see the same
|
|
%% bytes as the text fallback. Step 8d-cbor encoder will replace.
|
|
[_, _, {body, Body}] = post_activity_response(),
|
|
ok_response(Body, cbor);
|
|
post_activity_response_for(_) ->
|
|
post_activity_response().
|
|
|
|
%% ── 8d-dispatch-get: format-aware GET responses ─────────────────
|
|
%%
|
|
%% Each builder mirrors its text-only counterpart but emits a
|
|
%% format-tagged body and Content-Type. json/activity_json share
|
|
%% the body shape but differ in CT; sx uses parenthesized form;
|
|
%% cbor returns the raw payload bytes (encoder follow-up).
|
|
|
|
%% actor_doc_response — text body `actor: <id>\n`.
|
|
|
|
actor_doc_response_for(Id, text) ->
|
|
actor_doc_response(Id);
|
|
actor_doc_response_for(Id, json) ->
|
|
Pre = <<123,34,97,99,116,111,114,34,58,34>>, % '{"actor":"'
|
|
Suf = <<34,125,10>>, % '"}\n'
|
|
ok_response(<<Pre/binary, Id/binary, Suf/binary>>, json);
|
|
actor_doc_response_for(Id, activity_json) ->
|
|
Pre = <<123,34,97,99,116,111,114,34,58,34>>,
|
|
Suf = <<34,125,10>>,
|
|
ok_response(<<Pre/binary, Id/binary, Suf/binary>>, activity_json);
|
|
actor_doc_response_for(Id, sx) ->
|
|
Pre = <<40,97,99,116,111,114,32,34>>, % '(actor "'
|
|
Suf = <<34,41,10>>, % '")\n'
|
|
ok_response(<<Pre/binary, Id/binary, Suf/binary>>, sx);
|
|
actor_doc_response_for(Id, cbor) ->
|
|
ok_response(Id, cbor);
|
|
actor_doc_response_for(Id, _) ->
|
|
actor_doc_response(Id).
|
|
|
|
%% ── Step 4a: per-actor sub-resource stubs ──────────────────────
|
|
%% Per design §16.1 each actor has /outbox /inbox /followers
|
|
%% /following routes. v1 returns text-stub bodies so route resolution
|
|
%% can be tested end-to-end; real serialisation of per-actor outbox
|
|
%% listings (Step 4d) + follower-graph projection bodies (Step 6+)
|
|
%% layer on top of these dispatch arms.
|
|
|
|
%% "outbox: " — 8 bytes
|
|
actor_outbox_response_for(Id, text) ->
|
|
Pre = <<111,117,116,98,111,120,58,32>>,
|
|
ok_response(<<Pre/binary, Id/binary, 10>>);
|
|
actor_outbox_response_for(Id, json) ->
|
|
Pre = <<123,34,111,117,116,98,111,120,34,58,34>>, % '{"outbox":"'
|
|
Suf = <<34,125,10>>,
|
|
ok_response(<<Pre/binary, Id/binary, Suf/binary>>, json);
|
|
actor_outbox_response_for(Id, activity_json) ->
|
|
Pre = <<123,34,111,117,116,98,111,120,34,58,34>>,
|
|
Suf = <<34,125,10>>,
|
|
ok_response(<<Pre/binary, Id/binary, Suf/binary>>, activity_json);
|
|
actor_outbox_response_for(Id, sx) ->
|
|
Pre = <<40,111,117,116,98,111,120,32,34>>, % '(outbox "'
|
|
Suf = <<34,41,10>>,
|
|
ok_response(<<Pre/binary, Id/binary, Suf/binary>>, sx);
|
|
actor_outbox_response_for(Id, _) ->
|
|
Pre = <<111,117,116,98,111,120,58,32>>,
|
|
ok_response(<<Pre/binary, Id/binary, 10>>).
|
|
|
|
%% actor_outbox_response_for/3 — Step 4c kernel-aware variant. When
|
|
%% Cfg carries a `:kernel` reference *and* the kernel has the actor,
|
|
%% include "tip: <N>\n" after the bare body so callers can verify
|
|
%% the route landed on the right bucket. Falls back to the /2 stub
|
|
%% otherwise — same shape, same content-negotiation arms.
|
|
|
|
actor_outbox_response_for(Id, F, Cfg) ->
|
|
case field(kernel, Cfg) of
|
|
nil ->
|
|
actor_outbox_response_for(Id, F);
|
|
Kernel ->
|
|
case kernel_actor_log_data(Kernel, Id) of
|
|
nil ->
|
|
actor_outbox_response_for(Id, F);
|
|
{Tip, Entries} ->
|
|
Page = parse_page(field(request_query, Cfg)),
|
|
Slice = page_slice(Entries, Page),
|
|
Cids = entry_cids(Slice),
|
|
actor_outbox_full_response_for(Id, F, Tip, Page, Cids)
|
|
end
|
|
end.
|
|
|
|
%% kernel_actor_log_data/2 — synchronous query to the kernel for
|
|
%% the actor's tip + flat entry list. nil when the kernel atom isn't
|
|
%% registered or the actor isn't present (mirrors kernel_log_tip/2's
|
|
%% guard pattern).
|
|
|
|
kernel_actor_log_data(Kernel, Id) when is_atom(Kernel) ->
|
|
case erlang:whereis(Kernel) of
|
|
undefined -> nil;
|
|
_ ->
|
|
L = binary_to_list(Id),
|
|
A = list_to_atom(L),
|
|
T = nx_kernel:log_tip_for(A),
|
|
case T of
|
|
N when is_integer(N) ->
|
|
case nx_kernel:log_state_for(A) of
|
|
{ok, LogState} -> {N, log:entries(LogState)};
|
|
_ -> {N, []}
|
|
end;
|
|
_ -> nil
|
|
end
|
|
end;
|
|
kernel_actor_log_data(_, _) -> nil.
|
|
|
|
%% page_size/0 — small for v2 (proof of concept). Real outboxes
|
|
%% pick a larger page size (Mastodon defaults to 20). Tests pin
|
|
%% this to 5 so 3 publishes fit in one page and 6 publishes
|
|
%% straddle two pages.
|
|
|
|
page_size() -> 5.
|
|
|
|
%% parse_page/1 — accept `?page=N` from the query string. `nil` or
|
|
%% missing param -> page 1. Non-positive values clamp to 1.
|
|
|
|
parse_page(nil) -> 1;
|
|
parse_page(Q) when is_binary(Q) ->
|
|
case match_prefix(<<112,97,103,101,61>>, Q) of % "page="
|
|
{ok, Rest} ->
|
|
case parse_int(Rest) of
|
|
{ok, N} when N >= 1 -> N;
|
|
_ -> 1
|
|
end;
|
|
_ -> 1
|
|
end;
|
|
parse_page(_) -> 1.
|
|
|
|
parse_int(Bin) ->
|
|
L = binary_to_list(Bin),
|
|
case L of
|
|
[] -> error;
|
|
_ ->
|
|
case all_digits(L) of
|
|
true -> {ok, list_to_integer(L)};
|
|
false -> error
|
|
end
|
|
end.
|
|
|
|
all_digits([]) -> true;
|
|
all_digits([C | Rest]) when C >= 48, C =< 57 -> all_digits(Rest);
|
|
all_digits(_) -> false.
|
|
|
|
%% page_slice/2 — extract a page-sized slice of Entries. Page is
|
|
%% 1-indexed; out-of-range pages yield [].
|
|
|
|
page_slice(Entries, Page) ->
|
|
Sz = page_size(),
|
|
Start = (Page - 1) * Sz,
|
|
drop_take(Entries, Start, Sz).
|
|
|
|
drop_take(_, _, 0) -> [];
|
|
drop_take([], _, _) -> [];
|
|
drop_take(L, 0, N) -> take(L, N);
|
|
drop_take([_ | Rest], K, N) -> drop_take(Rest, K - 1, N).
|
|
|
|
take(_, 0) -> [];
|
|
take([], _) -> [];
|
|
take([H | Rest], N) -> [H | take(Rest, N - 1)].
|
|
|
|
entry_cids([]) -> [];
|
|
entry_cids([E | Rest]) ->
|
|
case envelope:get_field(id, E) of
|
|
{ok, Cid} -> [Cid | entry_cids(Rest)];
|
|
_ -> entry_cids(Rest)
|
|
end.
|
|
|
|
%% kernel_log_tip/2 — query the kernel for an actor's log tip via
|
|
%% `nx_kernel:log_tip_for/1`. Returns the tip integer when the actor
|
|
%% exists, `nil` when the kernel atom isn't registered or the actor
|
|
%% isn't present. Catches everything so a stale Cfg can't break the
|
|
%% handler.
|
|
|
|
kernel_log_tip(Kernel, Id) when is_atom(Kernel) ->
|
|
case erlang:whereis(Kernel) of
|
|
undefined -> nil;
|
|
_ ->
|
|
L = binary_to_list(Id),
|
|
A = list_to_atom(L),
|
|
T = nx_kernel:log_tip_for(A),
|
|
case T of
|
|
N when is_integer(N) -> N;
|
|
_ -> nil
|
|
end
|
|
end;
|
|
kernel_log_tip(_, _) -> nil.
|
|
|
|
actor_outbox_with_tip_response_for(Id, text, Tip) ->
|
|
%% "outbox: <Id>\ntip: <Tip>\n"
|
|
Pre = <<111,117,116,98,111,120,58,32>>, % "outbox: "
|
|
Tipp = <<10,116,105,112,58,32>>, % "\ntip: "
|
|
TipBin = list_to_binary(integer_to_list(Tip)),
|
|
Body = <<Pre/binary, Id/binary, Tipp/binary, TipBin/binary, 10>>,
|
|
ok_response(Body);
|
|
actor_outbox_with_tip_response_for(Id, json, Tip) ->
|
|
Pre = <<123,34,111,117,116,98,111,120,34,58,34>>,
|
|
Mid = <<34,44,34,116,105,112,34,58>>, % '","tip":'
|
|
Suf = <<125,10>>, % '}\n'
|
|
TipBin = list_to_binary(integer_to_list(Tip)),
|
|
Body = <<Pre/binary, Id/binary, Mid/binary, TipBin/binary, Suf/binary>>,
|
|
ok_response(Body, json);
|
|
actor_outbox_with_tip_response_for(Id, activity_json, Tip) ->
|
|
Pre = <<123,34,111,117,116,98,111,120,34,58,34>>,
|
|
Mid = <<34,44,34,116,105,112,34,58>>,
|
|
Suf = <<125,10>>,
|
|
TipBin = list_to_binary(integer_to_list(Tip)),
|
|
Body = <<Pre/binary, Id/binary, Mid/binary, TipBin/binary, Suf/binary>>,
|
|
ok_response(Body, activity_json);
|
|
actor_outbox_with_tip_response_for(Id, sx, Tip) ->
|
|
Pre = <<40,111,117,116,98,111,120,32,34>>, % '(outbox "'
|
|
Mid = <<34,32,58,116,105,112,32>>, % '" :tip '
|
|
Suf = <<41,10>>, % ')\n'
|
|
TipBin = list_to_binary(integer_to_list(Tip)),
|
|
Body = <<Pre/binary, Id/binary, Mid/binary, TipBin/binary, Suf/binary>>,
|
|
ok_response(Body, sx);
|
|
actor_outbox_with_tip_response_for(Id, _, Tip) ->
|
|
actor_outbox_with_tip_response_for(Id, text, Tip).
|
|
|
|
%% actor_outbox_full_response_for/5 — Step 4d body shape includes
|
|
%% the actor id, tip, current page number, and the page's CID list.
|
|
%% Empty Cids degrades to the /tip/ variant — keeps the 4c body
|
|
%% shape stable when an actor has no entries (e.g. a Bob with zero
|
|
%% publishes).
|
|
|
|
actor_outbox_full_response_for(Id, F, Tip, _Page, []) ->
|
|
actor_outbox_with_tip_response_for(Id, F, Tip);
|
|
actor_outbox_full_response_for(Id, text, Tip, Page, Cids) ->
|
|
Pre = <<111,117,116,98,111,120,58,32>>, % "outbox: "
|
|
Tipp = <<10,116,105,112,58,32>>, % "\ntip: "
|
|
Pag = <<10,112,97,103,101,58,32>>, % "\npage: "
|
|
Itm = <<10,105,116,101,109,58,32>>, % "\nitem: "
|
|
TipBin = list_to_binary(integer_to_list(Tip)),
|
|
PageBin = list_to_binary(integer_to_list(Page)),
|
|
Head = <<Pre/binary, Id/binary,
|
|
Tipp/binary, TipBin/binary,
|
|
Pag/binary, PageBin/binary>>,
|
|
Body = lines_with_prefix(Head, Itm, Cids, <<10>>),
|
|
ok_response(Body);
|
|
actor_outbox_full_response_for(Id, json, Tip, Page, Cids) ->
|
|
Body = json_outbox_body(Id, Tip, Page, Cids),
|
|
ok_response(Body, json);
|
|
actor_outbox_full_response_for(Id, activity_json, Tip, Page, Cids) ->
|
|
Body = json_outbox_body(Id, Tip, Page, Cids),
|
|
ok_response(Body, activity_json);
|
|
actor_outbox_full_response_for(Id, sx, Tip, Page, Cids) ->
|
|
Body = sx_outbox_body(Id, Tip, Page, Cids),
|
|
ok_response(Body, sx);
|
|
actor_outbox_full_response_for(Id, _, Tip, Page, Cids) ->
|
|
actor_outbox_full_response_for(Id, text, Tip, Page, Cids).
|
|
|
|
lines_with_prefix(Acc, _, [], Tail) -> <<Acc/binary, Tail/binary>>;
|
|
lines_with_prefix(Acc, Itm, [C | Rest], Tail) ->
|
|
lines_with_prefix(<<Acc/binary, Itm/binary, C/binary>>, Itm, Rest, Tail).
|
|
|
|
%% {"outbox":"<id>","tip":N,"page":P,"items":["cid1","cid2",...]}
|
|
json_outbox_body(Id, Tip, Page, Cids) ->
|
|
Pre = <<123,34,111,117,116,98,111,120,34,58,34>>,
|
|
Mid1 = <<34,44,34,116,105,112,34,58>>, % '","tip":'
|
|
Mid2 = <<44,34,112,97,103,101,34,58>>, % ',"page":'
|
|
Mid3 = <<44,34,105,116,101,109,115,34,58,91>>, % ',"items":['
|
|
Suf = <<93,125,10>>, % ']}\n'
|
|
TipBin = list_to_binary(integer_to_list(Tip)),
|
|
PageBin = list_to_binary(integer_to_list(Page)),
|
|
Items = json_string_list(Cids),
|
|
<<Pre/binary, Id/binary,
|
|
Mid1/binary, TipBin/binary,
|
|
Mid2/binary, PageBin/binary,
|
|
Mid3/binary, Items/binary, Suf/binary>>.
|
|
|
|
json_string_list([]) -> <<>>;
|
|
json_string_list([C]) -> <<34, C/binary, 34>>;
|
|
json_string_list([C | Rest]) ->
|
|
Tail = json_string_list(Rest),
|
|
<<34, C/binary, 34, 44, Tail/binary>>.
|
|
|
|
%% (outbox "<id>" :tip N :page P :items ("cid1" "cid2" ...))
|
|
sx_outbox_body(Id, Tip, Page, Cids) ->
|
|
Pre = <<40,111,117,116,98,111,120,32,34>>, % '(outbox "'
|
|
Mid1 = <<34,32,58,116,105,112,32>>, % '" :tip '
|
|
Mid2 = <<32,58,112,97,103,101,32>>, % ' :page '
|
|
Mid3 = <<32,58,105,116,101,109,115,32,40>>, % ' :items ('
|
|
Suf = <<41,41,10>>, % '))\n'
|
|
TipBin = list_to_binary(integer_to_list(Tip)),
|
|
PageBin = list_to_binary(integer_to_list(Page)),
|
|
Items = sx_string_list(Cids),
|
|
<<Pre/binary, Id/binary,
|
|
Mid1/binary, TipBin/binary,
|
|
Mid2/binary, PageBin/binary,
|
|
Mid3/binary, Items/binary, Suf/binary>>.
|
|
|
|
sx_string_list([]) -> <<>>;
|
|
sx_string_list([C]) -> <<34, C/binary, 34>>;
|
|
sx_string_list([C | Rest]) ->
|
|
Tail = sx_string_list(Rest),
|
|
<<34, C/binary, 34, 32, Tail/binary>>.
|
|
|
|
%% "inbox: " — 7 bytes
|
|
actor_inbox_get_response_for(Id, text) ->
|
|
Pre = <<105,110,98,111,120,58,32>>,
|
|
ok_response(<<Pre/binary, Id/binary, 10>>);
|
|
actor_inbox_get_response_for(Id, json) ->
|
|
Pre = <<123,34,105,110,98,111,120,34,58,34>>, % '{"inbox":"'
|
|
Suf = <<34,125,10>>,
|
|
ok_response(<<Pre/binary, Id/binary, Suf/binary>>, json);
|
|
actor_inbox_get_response_for(Id, activity_json) ->
|
|
Pre = <<123,34,105,110,98,111,120,34,58,34>>,
|
|
Suf = <<34,125,10>>,
|
|
ok_response(<<Pre/binary, Id/binary, Suf/binary>>, activity_json);
|
|
actor_inbox_get_response_for(Id, sx) ->
|
|
Pre = <<40,105,110,98,111,120,32,34>>, % '(inbox "'
|
|
Suf = <<34,41,10>>,
|
|
ok_response(<<Pre/binary, Id/binary, Suf/binary>>, sx);
|
|
actor_inbox_get_response_for(Id, _) ->
|
|
Pre = <<105,110,98,111,120,58,32>>,
|
|
ok_response(<<Pre/binary, Id/binary, 10>>).
|
|
|
|
%% "followers: " — 11 bytes
|
|
actor_followers_response_for(Id, text) ->
|
|
Pre = <<102,111,108,108,111,119,101,114,115,58,32>>,
|
|
ok_response(<<Pre/binary, Id/binary, 10>>);
|
|
actor_followers_response_for(Id, json) ->
|
|
Pre = <<123,34,102,111,108,108,111,119,101,114,115,34,58,34>>,
|
|
Suf = <<34,125,10>>,
|
|
ok_response(<<Pre/binary, Id/binary, Suf/binary>>, json);
|
|
actor_followers_response_for(Id, activity_json) ->
|
|
Pre = <<123,34,102,111,108,108,111,119,101,114,115,34,58,34>>,
|
|
Suf = <<34,125,10>>,
|
|
ok_response(<<Pre/binary, Id/binary, Suf/binary>>, activity_json);
|
|
actor_followers_response_for(Id, sx) ->
|
|
Pre = <<40,102,111,108,108,111,119,101,114,115,32,34>>,
|
|
Suf = <<34,41,10>>,
|
|
ok_response(<<Pre/binary, Id/binary, Suf/binary>>, sx);
|
|
actor_followers_response_for(Id, _) ->
|
|
Pre = <<102,111,108,108,111,119,101,114,115,58,32>>,
|
|
ok_response(<<Pre/binary, Id/binary, 10>>).
|
|
|
|
%% "following: " — 11 bytes
|
|
actor_following_response_for(Id, text) ->
|
|
Pre = <<102,111,108,108,111,119,105,110,103,58,32>>,
|
|
ok_response(<<Pre/binary, Id/binary, 10>>);
|
|
actor_following_response_for(Id, json) ->
|
|
Pre = <<123,34,102,111,108,108,111,119,105,110,103,34,58,34>>,
|
|
Suf = <<34,125,10>>,
|
|
ok_response(<<Pre/binary, Id/binary, Suf/binary>>, json);
|
|
actor_following_response_for(Id, activity_json) ->
|
|
Pre = <<123,34,102,111,108,108,111,119,105,110,103,34,58,34>>,
|
|
Suf = <<34,125,10>>,
|
|
ok_response(<<Pre/binary, Id/binary, Suf/binary>>, activity_json);
|
|
actor_following_response_for(Id, sx) ->
|
|
Pre = <<40,102,111,108,108,111,119,105,110,103,32,34>>,
|
|
Suf = <<34,41,10>>,
|
|
ok_response(<<Pre/binary, Id/binary, Suf/binary>>, sx);
|
|
actor_following_response_for(Id, _) ->
|
|
Pre = <<102,111,108,108,111,119,105,110,103,58,32>>,
|
|
ok_response(<<Pre/binary, Id/binary, 10>>).
|
|
|
|
%% POST /actors/<id>/inbox stub — 202 Accepted with body "accepted\n".
|
|
%% Real ingestion pipeline (sig verify + envelope:get_field + log
|
|
%% append on the receiving actor's inbox bucket) lands in Step 5.
|
|
|
|
actor_inbox_post_response() ->
|
|
%% "accepted\n" — 9 bytes
|
|
Body = <<97,99,99,101,112,116,101,100,10>>,
|
|
accepted_response(Body).
|
|
|
|
accepted_response(Body) ->
|
|
[{status, 202}, {headers, []}, {body, Body}].
|
|
|
|
%% artifact_response — text body `artifact: <cid>\n`.
|
|
|
|
artifact_response_for(Cid, text) ->
|
|
artifact_response(Cid);
|
|
artifact_response_for(Cid, json) ->
|
|
Pre = <<123,34,97,114,116,105,102,97,99,116,34,58,34>>,
|
|
Suf = <<34,125,10>>,
|
|
ok_response(<<Pre/binary, Cid/binary, Suf/binary>>, json);
|
|
artifact_response_for(Cid, activity_json) ->
|
|
Pre = <<123,34,97,114,116,105,102,97,99,116,34,58,34>>,
|
|
Suf = <<34,125,10>>,
|
|
ok_response(<<Pre/binary, Cid/binary, Suf/binary>>, activity_json);
|
|
artifact_response_for(Cid, sx) ->
|
|
Pre = <<40,97,114,116,105,102,97,99,116,32,34>>,
|
|
Suf = <<34,41,10>>,
|
|
ok_response(<<Pre/binary, Cid/binary, Suf/binary>>, sx);
|
|
artifact_response_for(Cid, cbor) ->
|
|
ok_response(Cid, cbor);
|
|
artifact_response_for(Cid, _) ->
|
|
artifact_response(Cid).
|
|
|
|
%% projection_response (singular) — text body `projection: <name>\n`.
|
|
|
|
projection_response_for(Name, text) ->
|
|
projection_response(Name);
|
|
projection_response_for(Name, json) ->
|
|
Pre = <<123,34,112,114,111,106,101,99,116,105,111,110,34,58,34>>,
|
|
Suf = <<34,125,10>>,
|
|
ok_response(<<Pre/binary, Name/binary, Suf/binary>>, json);
|
|
projection_response_for(Name, activity_json) ->
|
|
Pre = <<123,34,112,114,111,106,101,99,116,105,111,110,34,58,34>>,
|
|
Suf = <<34,125,10>>,
|
|
ok_response(<<Pre/binary, Name/binary, Suf/binary>>, activity_json);
|
|
projection_response_for(Name, sx) ->
|
|
Pre = <<40,112,114,111,106,101,99,116,105,111,110,32,34>>,
|
|
Suf = <<34,41,10>>,
|
|
ok_response(<<Pre/binary, Name/binary, Suf/binary>>, sx);
|
|
projection_response_for(Name, cbor) ->
|
|
ok_response(Name, cbor);
|
|
projection_response_for(Name, _) ->
|
|
projection_response(Name).
|
|
|
|
%% projections_list_response — empty-list stub.
|
|
|
|
projections_list_response_for(text) ->
|
|
projections_list_response();
|
|
%% `{"projections":[]}\n`
|
|
projections_list_response_for(json) ->
|
|
Body = <<123,34,112,114,111,106,101,99,116,105,111,110,115,
|
|
34,58,91,93,125,10>>,
|
|
ok_response(Body, json);
|
|
projections_list_response_for(activity_json) ->
|
|
Body = <<123,34,112,114,111,106,101,99,116,105,111,110,115,
|
|
34,58,91,93,125,10>>,
|
|
ok_response(Body, activity_json);
|
|
%% `(projections)\n`
|
|
projections_list_response_for(sx) ->
|
|
Body = <<40,112,114,111,106,101,99,116,105,111,110,115,41,10>>,
|
|
ok_response(Body, sx);
|
|
projections_list_response_for(cbor) ->
|
|
[_, _, {body, Body}] = projections_list_response(),
|
|
ok_response(Body, cbor);
|
|
projections_list_response_for(_) ->
|
|
projections_list_response().
|
|
|
|
%% ── Step 5d: POST /actors/<id>/inbox real ingestion ────────────
|
|
%%
|
|
%% Wire format for v2: body is `term_codec:encode(SignedActivity)`,
|
|
%% which the receiver decodes into the activity proplist. Peer-AS
|
|
%% comes from Cfg's `:peer_actors` cache (a registered atom for the
|
|
%% peer_actors gen_server); on a cache miss the handler will fetch
|
|
%% via Cfg's `:peer_fetch_fn` if present, otherwise the peer is
|
|
%% considered unknown and the request is rejected as unauthorized.
|
|
%%
|
|
%% Status codes per design §16.1:
|
|
%% 202 Accepted — pipeline ok, activity appended to inbox
|
|
%% 401 Unauthorized — sig fail or peer unknown
|
|
%% 404 Not Found — target actor unknown
|
|
%% 422 Unprocessable — envelope / replay failure
|
|
|
|
handle_inbox_post(TargetId, Req, Cfg) ->
|
|
case kernel_has_actor(field(kernel, Cfg), TargetId) of
|
|
false -> not_found_response();
|
|
true ->
|
|
Body = field(body, Req),
|
|
case decode_activity(Body) of
|
|
{error, _} -> validation_failed_response();
|
|
{ok, Activity} ->
|
|
handle_inbox_decoded(TargetId, Activity, Cfg)
|
|
end
|
|
end.
|
|
|
|
handle_inbox_decoded(TargetId, Activity, Cfg) ->
|
|
case envelope:get_field(actor, Activity) of
|
|
not_found -> validation_failed_response();
|
|
{ok, PeerId} ->
|
|
case resolve_peer_as(PeerId, Cfg) of
|
|
{error, _} -> unauthorized_response();
|
|
{ok, PeerAS} ->
|
|
TargetAtom = list_to_atom(binary_to_list(TargetId)),
|
|
case nx_kernel:inbox_state_for(TargetAtom) of
|
|
{ok, InboxLog} ->
|
|
run_inbox_pipeline(TargetAtom, Activity,
|
|
PeerAS, InboxLog, Cfg);
|
|
_ -> not_found_response()
|
|
end
|
|
end
|
|
end.
|
|
|
|
run_inbox_pipeline(TargetAtom, Activity, PeerAS, InboxLog, Cfg) ->
|
|
case pipeline:validate_inbound(Activity, PeerAS, InboxLog) of
|
|
ok ->
|
|
nx_kernel:append_inbox(TargetAtom, Activity),
|
|
broadcast_to_inbox_projections(Activity, Cfg),
|
|
maybe_auto_accept(TargetAtom, Activity, Cfg),
|
|
actor_inbox_post_response();
|
|
{error, bad_signature} -> unauthorized_response();
|
|
{error, no_signature} -> unauthorized_response();
|
|
{error, _} -> validation_failed_response()
|
|
end.
|
|
|
|
%% maybe_auto_accept/3 — Step 6c. Per design §13.2 the v2 default
|
|
%% Follow policy is open-world: every successfully-ingested Follow
|
|
%% triggers an Accept publish from the target actor. Enabled per-Cfg
|
|
%% via `{auto_accept_follows, true}` so callers that prefer manual
|
|
%% moderation can leave it off (manual moderation queue is v3).
|
|
%%
|
|
%% The Accept's `:object` is the original Follow envelope as
|
|
%% received — peers will use that to identify which Follow was
|
|
%% accepted. The publish goes through nx_kernel:publish_to/2 which
|
|
%% routes through the full outbox pipeline (construct + sign + log
|
|
%% + projection broadcast), so the target's outbox projections see
|
|
%% the Accept too.
|
|
|
|
maybe_auto_accept(TargetAtom, Activity, Cfg) ->
|
|
case field(auto_accept_follows, Cfg) of
|
|
true ->
|
|
case envelope:get_field(type, Activity) of
|
|
{ok, follow} ->
|
|
AcceptRequest = [{type, accept}, {object, Activity}],
|
|
nx_kernel:publish_to(TargetAtom, AcceptRequest);
|
|
_ -> ok
|
|
end;
|
|
_ -> ok
|
|
end.
|
|
|
|
%% broadcast_to_inbox_projections/2 — Step 6b. Cfg may carry
|
|
%% `{inbox_projections, [Name, ...]}` listing projection gen_servers
|
|
%% that should see every successfully-ingested inbound activity.
|
|
%% Casts via projection:async_fold/2 — fire-and-forget so the inbox
|
|
%% handler doesn't block on projection processing.
|
|
%%
|
|
%% No-op when the field is absent. v2 v2 layers per-actor projection
|
|
%% routing on top (each actor's bucket can carry its own projection
|
|
%% list); for now the field is global.
|
|
|
|
broadcast_to_inbox_projections(Activity, Cfg) ->
|
|
case field(inbox_projections, Cfg) of
|
|
nil -> ok;
|
|
Names when is_list(Names) ->
|
|
broadcast_each(Activity, Names);
|
|
_ -> ok
|
|
end.
|
|
|
|
broadcast_each(_, []) -> ok;
|
|
broadcast_each(Activity, [Name | Rest]) ->
|
|
projection:async_fold(Name, Activity),
|
|
broadcast_each(Activity, Rest).
|
|
|
|
%% kernel_has_actor/2 — guard against unknown target actors. nil
|
|
%% kernel (e.g. tests without a kernel cfg'd) treats every Id as
|
|
%% present so the rest of the pipeline can still exercise.
|
|
|
|
kernel_has_actor(nil, _Id) -> true;
|
|
kernel_has_actor(Kernel, Id) when is_atom(Kernel) ->
|
|
case erlang:whereis(Kernel) of
|
|
undefined -> false;
|
|
_ ->
|
|
A = list_to_atom(binary_to_list(Id)),
|
|
Actors = nx_kernel:actors(),
|
|
lists_member(A, Actors)
|
|
end;
|
|
kernel_has_actor(_, _) -> false.
|
|
|
|
lists_member(_, []) -> false;
|
|
lists_member(X, [X | _]) -> true;
|
|
lists_member(X, [_ | Rest]) -> lists_member(X, Rest).
|
|
|
|
%% decode_activity/1 — body wire format. v2 uses term_codec; v3 may
|
|
%% layer JSON or content negotiation on top.
|
|
|
|
decode_activity(Body) ->
|
|
case term_codec:decode(Body) of
|
|
{ok, T, _} when is_list(T) -> {ok, T};
|
|
_ -> {error, bad_envelope}
|
|
end.
|
|
|
|
%% resolve_peer_as/2 — Cfg may carry:
|
|
%% {peer_actors, AtomName} registered peer_actors gen_server
|
|
%% {peer_fetch_fn, FetchFn} fallback FetchFn on cache miss
|
|
%% {peer_as, [{PeerId, AS}]} pure-fn pre-populated map (tests)
|
|
%% In priority order: explicit :peer_as map, then peer_actors srv
|
|
%% with optional FetchFn, then unknown.
|
|
|
|
resolve_peer_as(PeerId, Cfg) ->
|
|
case field(peer_as, Cfg) of
|
|
nil -> resolve_peer_as_srv(PeerId, Cfg);
|
|
Map ->
|
|
case find_peer(PeerId, Map) of
|
|
{ok, AS} -> {ok, AS};
|
|
_ -> resolve_peer_as_srv(PeerId, Cfg)
|
|
end
|
|
end.
|
|
|
|
resolve_peer_as_srv(PeerId, Cfg) ->
|
|
case field(peer_actors, Cfg) of
|
|
nil -> {error, no_peer_resolver};
|
|
Srv when is_atom(Srv) ->
|
|
case erlang:whereis(Srv) of
|
|
undefined -> {error, peer_actors_down};
|
|
_ -> resolve_via_srv(PeerId, Cfg)
|
|
end;
|
|
_ -> {error, bad_peer_actors_cfg}
|
|
end.
|
|
|
|
resolve_via_srv(PeerId, Cfg) ->
|
|
case field(peer_fetch_fn, Cfg) of
|
|
nil ->
|
|
case peer_actors:lookup_srv(PeerId) of
|
|
{ok, AS} -> {ok, AS};
|
|
not_found -> {error, unknown_peer}
|
|
end;
|
|
FetchFn when is_function(FetchFn, 1) ->
|
|
peer_actors:lookup_or_fetch_srv(PeerId, FetchFn);
|
|
_ -> {error, bad_fetch_fn_cfg}
|
|
end.
|
|
|
|
find_peer(_, []) -> not_found;
|
|
find_peer(K, [{K, V} | _]) -> {ok, V};
|
|
find_peer(K, [_ | Rest]) -> find_peer(K, Rest).
|
|
|
|
%% ── Step 10b: GET /.well-known/webfinger ───────────────────────
|
|
%%
|
|
%% Query: `?resource=acct:user@host`
|
|
%% Response: 200 with webfinger JSON when actor known + host matches;
|
|
%% 404 otherwise.
|
|
%%
|
|
%% Cfg may carry:
|
|
%% {kernel, Atom} registered kernel atom (per Step 4c)
|
|
%% {webfinger_host, Binary} expected @host; missing = any
|
|
%% Both optional — with no kernel, every actor is "known" so we
|
|
%% still serve a valid body (callers without a kernel are running
|
|
%% pure routing tests).
|
|
|
|
handle_webfinger(Cfg) ->
|
|
case field(request_query, Cfg) of
|
|
nil -> not_found_response();
|
|
Q -> webfinger_for_query(Q, Cfg)
|
|
end.
|
|
|
|
webfinger_for_query(Query, Cfg) ->
|
|
case parse_resource_param(Query) of
|
|
{ok, AcctBin} ->
|
|
case discovery:parse_acct(AcctBin) of
|
|
{ok, User, Host} -> webfinger_lookup(User, Host, Cfg);
|
|
_ -> not_found_response()
|
|
end;
|
|
_ -> not_found_response()
|
|
end.
|
|
|
|
%% "resource=" — 9 bytes
|
|
parse_resource_param(Query) ->
|
|
Prefix = <<114,101,115,111,117,114,99,101,61>>,
|
|
case match_prefix(Prefix, Query) of
|
|
{ok, Rest} -> {ok, take_until_amp(Rest)};
|
|
_ -> error
|
|
end.
|
|
|
|
%% take_until_amp/1 — collect bytes until the next "&" (38) or eob.
|
|
%% URL-decoding (percent-escapes) defers to v3; v2 inputs from
|
|
%% Mastodon-compatible clients are alphanumeric + .-_@: only.
|
|
|
|
take_until_amp(Bin) -> take_until_amp(Bin, <<>>).
|
|
take_until_amp(<<>>, Acc) -> Acc;
|
|
take_until_amp(<<38, _/binary>>, Acc) -> Acc;
|
|
take_until_amp(<<B, Rest/binary>>, Acc) -> take_until_amp(Rest, <<Acc/binary, B>>).
|
|
|
|
webfinger_lookup(User, Host, Cfg) ->
|
|
case host_matches(Host, field(webfinger_host, Cfg)) of
|
|
false -> not_found_response();
|
|
true ->
|
|
case kernel_has_actor(field(kernel, Cfg), User) of
|
|
true ->
|
|
Url = discovery:actor_url_for(User, Host),
|
|
Body = discovery:webfinger_body(User, Host, Url),
|
|
ok_response(Body, json);
|
|
false ->
|
|
not_found_response()
|
|
end
|
|
end.
|
|
|
|
host_matches(_, nil) -> true;
|
|
host_matches(H, H) -> true;
|
|
host_matches(_, _) -> false.
|