From 0168c366d5b3bacd7b55e13991f8ca9a0ee2ec1d Mon Sep 17 00:00:00 2001 From: Moon Man Date: Mon, 26 Aug 2024 21:07:50 +0000 Subject: [PATCH] follows almost work with mastodon --- config/dev.exs | 4 +- lib/vonbraun/activitypub_req.ex | 56 ++++- lib/vonbraun/control.ex | 49 ++++- lib/vonbraun/ecto/schema/actor.ex | 29 ++- lib/vonbraun/fluree.ex | 90 ++++++++ lib/vonbraun/http_signature.ex | 149 +++++++++---- lib/vonbraun/inbox_router.ex | 120 ++++++++++- lib/vonbraun/key_agent.ex | 6 + lib/vonbraun/router.ex | 202 ++++++++---------- .../migrations/20240824085128_initial.exs | 1 + 10 files changed, 538 insertions(+), 168 deletions(-) create mode 100644 lib/vonbraun/fluree.ex diff --git a/config/dev.exs b/config/dev.exs index 940e20f..a6ee730 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -14,4 +14,6 @@ config :vonbraun, summary: "I am the famous Fediverse Moon", approve_followers: false, # MUST be 32 - id_key: "53F0CB234DA3DB1A43AE32DE4038516B" + id_key: "53F0CB234DA3DB1A43AE32DE4038516B", + fluree_url: "http://localhost:58090/fluree/", + fluree_ledger: "vonbraun" diff --git a/lib/vonbraun/activitypub_req.ex b/lib/vonbraun/activitypub_req.ex index 0e5cef9..339d464 100644 --- a/lib/vonbraun/activitypub_req.ex +++ b/lib/vonbraun/activitypub_req.ex @@ -1,19 +1,27 @@ defmodule Vonbraun.ActivityPubReq do + require Logger alias Vonbraun.HTTPSignature - defp key_id() do + @spec actor_id() :: String.t() + def actor_id() do domain = Application.fetch_env!(:vonbraun, :domain) nickname = Application.fetch_env!(:vonbraun, :nickname) - "https://#{domain}/users/#{nickname}#main-key" + "https://#{domain}/users/#{nickname}" end - def get(url = %URI{:path => path, :query => query}) do + @spec key_id() :: String.t() + def key_id() do + "#{actor_id()}#main-key" + end + + def get(url = %URI{:path => path, :query => query, :host => host}) do headers = %{ + "host" => host, "accept" => "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" } target = - if query do + if query && query != "" do path <> "?" <> query else path @@ -24,13 +32,14 @@ defmodule Vonbraun.ActivityPubReq do Req.get(url, headers: headers) end - def post(url = %URI{:path => path, :query => query}, body) when is_binary(body) do + def post(url = %URI{:path => path, :query => query, :host => host}, body) when is_binary(body) do headers = %{ + "host" => host, "accept" => "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" } target = - if query do + if query && query != "" do path <> "?" <> query else path @@ -38,6 +47,39 @@ defmodule Vonbraun.ActivityPubReq do headers = HTTPSignature.add_post_signature(headers, key_id(), target, body) - Req.post(url, headers: headers) + Logger.debug("POST payload is: `#{body}`") + + Req.post(url, headers: headers, body: body) + end + + def get_actor(actor_url) when is_binary(actor_url) do + get_actor(URI.parse(actor_url)) + end + + def get_actor(actor_url = %URI{}) do + case get(actor_url) do + {:ok, %{:status => 200, :body => %{} = actor}} -> + {:ok, actor} + + {:error, error} -> + Logger.warning("Failed to get actor: #{actor_url} error: #{inspect(error)}") + {:error, error} + + response -> + Logger.warning("Failed to get actor: #{actor_url} response: #{inspect(response)}") + {:error, response} + end + end + + def extract_actor_inbox(%{"inbox" => inbox}) when is_binary(inbox) do + {:ok, inbox} + end + + def extract_actor_inbox(%{"endpoints" => %{"sharedInbox" => inbox}}) when is_binary(inbox) do + {:ok, inbox} + end + + def extract_actor_inbox(%{}) do + {:error, :notfound} end end diff --git a/lib/vonbraun/control.ex b/lib/vonbraun/control.ex index 653243a..0c7f35a 100644 --- a/lib/vonbraun/control.ex +++ b/lib/vonbraun/control.ex @@ -1,5 +1,52 @@ defmodule Vonbraun.Control do - def follow(id) when is_binary(id) do + require Logger + alias Vonbraun.ActivityPubReq + + defp self_id() do + domain = Application.fetch_env!(:vonbraun, :domain) + nickname = Application.fetch_env!(:vonbraun, :nickname) + "https://#{domain}/users/#{nickname}" + end + + def follow(followee_actor_id) when is_binary(followee_actor_id) do + with {:actor, {:ok, actor}} <- {:actor, ActivityPubReq.get_actor(followee_actor_id)}, + {:inbox, {:ok, raw_inbox}} <- {:inbox, ActivityPubReq.extract_actor_inbox(actor)}, + {:parse_inbox, inbox = %URI{}} <- {:parse_inbox, URI.parse(raw_inbox)} do + domain = Application.fetch_env!(:vonbraun, :domain) + # Is it a bad idea making this absolute? + activity_id = "https://#{domain}/id/follow:#{URI.encode(followee_actor_id)}" + + payload = + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => activity_id, + "to" => [ + followee_actor_id + ], + "cc" => [], + "bcc" => [], + "bto" => [], + "type" => "Follow", + "actor" => self_id(), + "object" => followee_actor_id + } + |> Jason.encode!() + + case ActivityPubReq.post(inbox, payload) do + {:ok, %{:status => status, :body => body}} when status >= 200 and status <= 299 -> + Logger.debug(inspect(body)) + Logger.info("Follow request sent successfully, now it's up to the remote server.") + :ok + + {:ok, %{:status => status, :body => body}} -> + Logger.debug(inspect(body)) + Logger.warning("Got a status of: #{status}, probably not good.") + + {:error, error} -> + Logger.error("Failed to send follow request: #{inspect(error)}") + {:error, error} + end + end end def unfollow(id) when is_binary(id) do diff --git a/lib/vonbraun/ecto/schema/actor.ex b/lib/vonbraun/ecto/schema/actor.ex index 5dab41a..bc697e3 100644 --- a/lib/vonbraun/ecto/schema/actor.ex +++ b/lib/vonbraun/ecto/schema/actor.ex @@ -2,6 +2,7 @@ defmodule Vonbraun.Ecto.Schema.Actor do use Ecto.Schema alias Ecto.Changeset alias Vonbraun.Repo + import Ecto.Query, only: [from: 2] @primary_key {:id, :string, autogenerate: false} @@ -17,12 +18,12 @@ defmodule Vonbraun.Ecto.Schema.Actor do end @type t :: %__MODULE__{ - muted: boolean(), - blocked: DateTime.t(), + muted: boolean() | nil, + blocked: DateTime.t() | nil, follows_me_state: String.t() | nil, - follows_me_ts: DateTime.t(), + follows_me_ts: DateTime.t() | nil, following_state: String.t() | nil, - following_ts: String.t() + following_ts: String.t() | nil } def changeset(struct, params \\ %{}) do @@ -165,4 +166,24 @@ defmodule Vonbraun.Ecto.Schema.Actor do {:error, error} end end + + @spec get_my_followers() :: list(String.t()) + def get_my_followers() do + query = from u in __MODULE__, + where: u.follows_me_state == "accepted", + select: u.id, + order_by: u.follows_me_ts + + Repo.all(query) + end + + @spec get_my_follows() :: list(String.t()) + def get_my_follows() do + query = from u in __MODULE__, + where: u.following_state == "accepted", + select: u.id, + order_by: u.following_ts + + Repo.all(query) + end end diff --git a/lib/vonbraun/fluree.ex b/lib/vonbraun/fluree.ex new file mode 100644 index 0000000..8c1c516 --- /dev/null +++ b/lib/vonbraun/fluree.ex @@ -0,0 +1,90 @@ +defmodule Vonbraun.Fluree do + defp strip_at_from_properties(object = %{}) do + Enum.reduce(object, Map.new(), fn + {"@context", value}, map -> Map.put(map, "@context", value) + {"@" <> bare_key, value}, map -> Map.put(map, bare_key, value) + {key, value}, map -> Map.put(map, key, value) + end) + end + + defp restore_at_to_properties(object = %{}) do + Enum.reduce(object, Map.new(), fn + {"type", value}, map -> Map.put(map, "@type", value) + {"id", value}, map -> Map.put(map, "@id", value) + end) + end + + defp fluree_post(path, body = %{}) when is_binary(path) do + body = Jason.encode!(body) + url = Application.fetch_env!(:vonbraun, :fluree_url) <> path + headers = [{"Content-Type", "application/json"}] + Req.post(url, headers: headers, body: body) + end + + @spec get(binary()) :: {:error, any()} | {:ok, map() | nil} + def get(id) when is_binary(id) do + payload = %{ + "select" => %{ + id => ["*"] + }, + "from" => Application.fetch_env!(:vonbraun, :fluree_ledger) + } + + case fluree_post("query", payload) do + {:ok, %{:body => %{"error" => error}}} -> + {:error, error} + + {:ok, %{:status => status, :body => []}} when status >= 200 and status <= 299 -> + {:ok, nil} + + {:ok, %{:status => status, :body => [object]}} when status >= 200 and status <= 299 -> + {:ok, strip_at_from_properties(object)} + + {:error, error} -> + {:error, error} + end + end + + def insert_actor(actor = %{}, options \\ []) do + actor = restore_at_to_properties(actor) + + me? = Keyword.get(options, :me, false) + is_follower? = Keyword.get(options, :follower, false) + i_follow? = Keyword.get(options, :following, false) + + props = Map.new() + props = if me? do + Map.put(props, "me", true) + else + props + end + props = if is_follower? do + Map.put(props, "isFollower", true) + else + props + end + props = if i_follow? do + Map.put(props, "following", true) + else + props + end + + Map.put(actor, "vonbraun:props", props) + + payload = %{ + "ledger" => "vonbraun", + "insert" => actor + } + + case fluree_post("/transact", payload) do + {:ok, %{:status => status}} when status >= 200 and status <= 299 -> + :ok + + {:ok, %{:status => status, :body => body}} -> + {:error, %{status: status, body: body}} + + {:error, error} -> + {:error, error} + end + end +end diff --git a/lib/vonbraun/http_signature.ex b/lib/vonbraun/http_signature.ex index c95aa97..50d8e9d 100644 --- a/lib/vonbraun/http_signature.ex +++ b/lib/vonbraun/http_signature.ex @@ -3,17 +3,26 @@ defmodule Vonbraun.HTTPSignature do Implements RFC 9421 HTTP Signatures using the Digest header. """ + require Logger + alias Plug.Conn + # (request-body) is really only for signing not sending as a header, but send # it anyway for broken implementations. - @post_headers ["(request-body)", "host", "date", "digest"] - @other_headers ["(request-body)", "host", "date"] + @post_headers ["(request-target)", "host", "date", "digest"] + @other_headers ["(request-target)", "host", "date"] + + def signed_date, do: signed_date(NaiveDateTime.utc_now()) + + def signed_date(%NaiveDateTime{} = date) do + Timex.format!(date, "{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT") + end @spec add_post_signature(map(), String.t(), String.t(), String.t()) :: map() def add_post_signature(headers, key_id, path, body) when is_map(headers) and is_binary(key_id) and is_binary(body) and is_binary(path) do request_target = "post #{path}" digest = compute_digest(body) - date = Timex.format!(Timex.now(:utc), "{RFC1123}") + date = signed_date() headers = Map.put(headers, "digest", digest) @@ -23,56 +32,105 @@ defmodule Vonbraun.HTTPSignature do signing_string = construct_signing_string(headers, @post_headers) signature = sign(signing_string) signature_header = build_signature_header(key_id, signature, @post_headers) - Map.put(headers, "signature", signature_header) + Map.put(headers, "signature", signature_header) |> Map.delete("(request-target)") end @spec add_get_signature(map(), String.t(), String.t()) :: map() def add_get_signature(headers, key_id, path) when is_map(headers) and is_binary(key_id) and is_binary(path) do request_target = "get #{path}" - date = Timex.format!(Timex.now(:utc), "{RFC1123}") - headers = Map.put(headers, "date", date) |> Map.put("(request-target)", request_target) + date = signed_date() + + headers = + Map.put(headers, "date", date) + |> Map.put("(request-target)", request_target) + signing_string = construct_signing_string(headers, @other_headers) signature = sign(signing_string) signature_header = build_signature_header(key_id, signature, @other_headers) - Map.put(headers, "signature", signature_header) + Map.put(headers, "signature", signature_header) |> Map.delete("(request-target)") end - @spec verify_post_signature(map(), String.t()) :: boolean() - def verify_post_signature(headers, body) when is_map(headers) and is_binary(body) do - signature_header = Map.get(headers, "signature") + def verify_post_signature(conn = %Conn{}, public_key) do + target = + if conn.query_string && conn.query_string != "" do + "#{conn.request_path}?#{conn.query_string}" + else + conn.request_path + end - case parse_signature_header(signature_header) do - {:ok, {_key_id, signature}} -> - digest = compute_digest(body) - test_headers = Map.put(headers, "digest", digest) - signing_string = construct_signing_string(test_headers, @post_headers) - expected_signature = sign(signing_string) - SecureCompare.compare(expected_signature, Base.decode64!(signature)) + valid_request_target = "post #{target}" - {:error, _error} -> - false - end - end + with {:raw_body, {:ok, raw_body, _}} <- {:raw_body, Conn.read_body(conn)}, + {:signature_header, [signature_header]} <- + {:signature_header, Conn.get_req_header(conn, "signature")}, + {:parse, + {:ok, %{ + "keyId" => _key_id, + "headers" => signing_headers, + "signature" => {:ok, signature} + } + }} <- {:parse, parse_signature_header(signature_header)} do + # Reconstruct the signing data + reconstructed_header_list = Enum.reduce_while(signing_headers |> Enum.reverse(), [], fn + "(request-target)", list -> + {:cont, ["(request-target): #{valid_request_target}" | list]} - @spec verify_get_signature(map()) :: boolean() - def verify_get_signature(headers) when is_map(headers) do - signature_header = Map.get(headers, "signature") + "digest", list -> + with {:get, [header_value]} <- {:get, Conn.get_req_header(conn, "digest")}, + {:split, [digest_algo, encoded_digest]} <- {:split, String.split(header_value, "=", parts: 2)}, + {:decode, {:ok, provided_digest}} <- {:decode, Base.decode64(encoded_digest)} do + new_digest = :crypto.hash(:sha256, raw_body) + test_digest = digest_algo <> "=" <> (new_digest |> Base.encode64()) - case parse_signature_header(signature_header) do - {:ok, {_key_id, signature}} -> - signing_string = construct_signing_string(headers, @other_headers) - expected_signature = sign(signing_string) - SecureCompare.compare(expected_signature, Base.decode64!(signature)) + if provided_digest == new_digest do + {:cont, ["digest: #{test_digest}" | list]} + else + Logger.warning("digest failed: #{provided_digest} <> #{test_digest}") + {:halt, false} + end + else + error -> + Logger.warning("Failed to handle digest: #{inspect(error)}") + {:halt, false} + end - {:error, _error} -> + header_name, list -> + case Conn.get_req_header(conn, header_name) do + [] -> + Logger.warning("header not found for http signature verification: #{header_name}") + {:halt, false} + + [header_value] -> + {:cont, ["#{header_name}: #{header_value}" | list]} + end + end) + + case reconstructed_header_list do + false -> false + + _ -> + # Hope they use the same newline chars + test_str = Enum.join(reconstructed_header_list, "\n") + Logger.debug("Here is the test string for signature verification: `#{test_str}`") + + case ExPublicKey.verify(test_str, signature, public_key) do + {:ok, val} -> val + error -> + Logger.warning("Error verifying signature: #{inspect(error)}") + false + end + end + else + error -> + Logger.warning("Verify http sig unmatch: #{inspect(error)}") false end end defp compute_digest(body) when is_binary(body) do encoded = :crypto.hash(:sha256, body) |> Base.encode64() - "sha-256=" <> encoded + "SHA-256=" <> encoded end defp construct_signing_string(headers, include_headers) do @@ -85,20 +143,35 @@ defmodule Vonbraun.HTTPSignature do defp sign(data) when is_binary(data) do {:ok, signature} = ExPublicKey.sign(data, Vonbraun.KeyAgent.get_private_key()) - Base.encode64(signature) + encoded_signature = Base.encode64(signature) + Logger.debug("Data to be signed: `#{data}`") + Logger.debug("Signature: #{encoded_signature}") + encoded_signature end defp build_signature_header(key_id, signature, headers) do - "Signature keyId=\"#{key_id}\",headers=\"#{Enum.join(headers, " ")}\",signature=\"#{signature}\"" + "keyId=\"#{key_id}\",headers=\"#{Enum.join(headers, " ")}\",algorithm=\"rsa-sha256\",signature=\"#{signature}\"" end defp parse_signature_header(header_value) when is_binary(header_value) do - with {:prefix, "Signature " <> pairs_string} <- {:prefix, header_value}, - {:parse, items} <- {:parse, Regex.scan(~r/([-a-z-A-Z]+)="(.+)"/U, pairs_string)}, - {:reduce, %{"keyId" => key_id, "signature" => signature}} <- + with {:parse, items} <- {:parse, Regex.scan(~r/([-a-z-A-Z]+)="(.+)"/U, header_value)}, + {:reduce, map = %{}} <- {:reduce, - Enum.reduce(items, Map.new(), fn [_, key, value], map -> Map.put(map, key, value) end)} do - {:ok, {key_id, signature}} + Enum.reduce(items, Map.new(), fn + [_, "headers", headers_str], map -> + Map.put(map, "headers", String.split(headers_str, " ")) + + [_, "signature", encoded_signature], map -> + Map.put(map, "signature", Base.decode64(encoded_signature)) + + [_, "digest", value], map -> + [algo, encoded_digest] = String.split(value, "=", parts: 2) + Map.put(map, "digest", {algo, Base.decode64(encoded_digest)}) + + [_, key, value], map -> + Map.put(map, key, value) + end)} do + {:ok, map} else _ -> {:error, :invalid} end diff --git a/lib/vonbraun/inbox_router.ex b/lib/vonbraun/inbox_router.ex index 3bdea85..a2497c5 100644 --- a/lib/vonbraun/inbox_router.ex +++ b/lib/vonbraun/inbox_router.ex @@ -1,4 +1,7 @@ defmodule Vonbraun.InboxRouter do + alias Vonbraun.HTTPSignature + alias Vonbraun.ActivityPubReq + alias Vonbraun.Ecto.Schema.Actor use Plug.Router require Logger @@ -16,12 +19,119 @@ defmodule Vonbraun.InboxRouter do end post "/" do - nickname = Application.fetch_env!(:vonbraun, :nickname) + {:ok, body, _conn} = Plug.Conn.read_body(conn) + Logger.debug("Got inbox POST: #{body}") - if conn.params["user"] == nickname do - send_resp(conn, 200, "ok") - else - send_resp(conn, 404, "fuck off") + cond do + conn.params["user"] in [nil, Application.fetch_env!(:vonbraun, :nickname)] -> + with {:actor_url, {:ok, activity = %{"actor" => actor_url}}} <- + {:actor_url, Jason.decode(body)}, + {:actor, {:ok, actor = %{"publicKey" => %{"publicKeyPem" => public_key_pem}}}} <- + {:actor, ActivityPubReq.get_actor(actor_url)}, + {:load, {:ok, public_key}} <- {:load, ExPublicKey.loads(public_key_pem)}, + {:verify, true} <- {:verify, HTTPSignature.verify_post_signature(conn, public_key)}, + {:send, {:ok, response}} <- {:send, handle_activity(activity, actor)} do + status_code = + case response do + :ignored -> + 200 + + :unauthorized -> + 401 + + :accepted -> + 200 + + :rejected -> + 200 + end + + send_resp(conn, status_code, "boop") + else + error -> + Logger.warning("Some kind of failure: #{inspect(error)}") + send_resp(conn, 401, "fuck off") + end + + true -> + send_resp(conn, 404, "fuck off") end end + + defp handle_activity( + %{"type" => "Follow", "actor" => actor_id, "object" => follow_target}, + actor = %{} + ) do + with {:valid_target, true} <- {:valid_target, ActivityPubReq.actor_id() == follow_target}, + {:add, {:ok, %Actor{:blocked => nil, :follows_me_state => follows_me_state}}} + when not is_nil(follows_me_state) <- {:add, Actor.maybe_add_follower(actor_id)} do + domain = Application.fetch_env!(:vonbraun, :domain) + activity_id = "https://#{domain}/id/follow-reply:#{URI.encode(actor_id)}" + + payload = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => activity_id, + "to" => [actor_id], + "actor" => ActivityPubReq.actor_id(), + "object" => %{ + "type" => "Follow", + "actor" => actor_id + } + } + + activity_type = + case follows_me_state do + "accepted" -> + "Accept" + + "rejected" -> + "Reject" + + "pending" -> + nil + end + + if activity_type do + payload = Map.put(payload, "type", activity_type) |> Jason.encode!() + + Logger.debug("Replying to follow request with: #{activity_type}") + Logger.debug("And payload: `#{payload}`") + + with {:inbox, {:ok, inbox}} <- {:inbox, ActivityPubReq.extract_actor_inbox(actor)}, + {:inbox_uri, inbox = %URI{}} <- {:inbox_uri, URI.parse(inbox)} do + Task.start(fn -> + case ActivityPubReq.post(inbox, payload) do + {:ok, %{:status => status, :body => body}} -> + Logger.debug("Accept response status: #{status} body: #{inspect(body)}") + {:error, error} -> + Logger.error("Failed to Accept: #{inspect(error)}") + end + end) + {:ok, String.to_atom(follows_me_state)} + else + {:inbox, {:error, _}} -> + {:error, :inbox} + end + else + {:ok, :ignored} + end + else + {:valid_target, false} -> + {:ok, :unauthorized} + + {:add, {:ok, %Actor{:blocked => blocked_ts}}} when not is_nil(blocked_ts) -> + {:ok, :unauthorized} + + {:add, {:ok, %Actor{:follows_me_state => nil}}} -> + Logger.error("follows-me state was nil, this should never happen") + {:error, :impossible} + + {:add, {:error, error}} -> + {:error, error} + end + end + + defp handle_activity(_activity = %{}, _) do + {:ok, :ignored} + end end diff --git a/lib/vonbraun/key_agent.ex b/lib/vonbraun/key_agent.ex index f1a02ac..ecc308c 100644 --- a/lib/vonbraun/key_agent.ex +++ b/lib/vonbraun/key_agent.ex @@ -1,6 +1,8 @@ defmodule Vonbraun.KeyAgent do use Agent + require Logger + def start_link(_opts) do Agent.start_link(fn -> load_keys() end, name: __MODULE__) end @@ -13,6 +15,10 @@ defmodule Vonbraun.KeyAgent do ExPublicKey.pem_encode(public_key) end + public_pem = String.replace(public_pem, "\\n", "\n") + + Logger.debug("Public key is: `#{public_pem}`") + %{ private_key: private_key, public_pem: public_pem diff --git a/lib/vonbraun/router.ex b/lib/vonbraun/router.ex index 9689f9d..917b2bb 100644 --- a/lib/vonbraun/router.ex +++ b/lib/vonbraun/router.ex @@ -3,135 +3,108 @@ defmodule Vonbraun.MyRouter do require Logger alias Vonbraun.Ecto.Schema.Pair + alias Vonbraun.Ecto.Schema.Actor alias Vonbraun.InboxRouter plug(:match) plug(:dispatch) defp render_outbox(nickname, domain), - do: """ - { - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://#{domain}/schemas/litepub-0.1.jsonld", - { - "@language": "und" - } - ], - "first": "https://#{domain}/users/#{nickname}/outbox?page=true", - "id": "https://#{domain}/users/#{nickname}/outbox", - "type": "OrderedCollection" + do: %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "https://#{domain}/users/#{nickname}/outbox", + "type" => "OrderedCollection", + "first" => "https://#{domain}/users/#{nickname}/outbox?page=true" } - """ defp render_outbox_page(nickname, domain), - do: """ - { - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://#{domain}/schemas/litepub-0.1.jsonld", - { - "@language": "und" - } - ], - "id": "https://#{domain}/users/#{nickname}/outbox?page=true", - "orderedItems": [ - ], - "partOf": "https://#{domain}/users/#{nickname}/outbox", - "type": "OrderedCollectionPage" + do: %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "https://#{domain}/users/#{nickname}/outbox?page=true", + "type" => "OrderedCollectionPage", + "orderedItems" => [], + "partOf" => "https://#{domain}/users/#{nickname}/outbox" } - """ defp render_webfinger(nickname, domain), - do: """ - { - "subject": "acct:#{nickname}@#{domain}", - "aliases": [ + do: %{ + "subject" => "acct:#{nickname}@#{domain}", + "aliases" => [ "https://#{domain}/users/#{nickname}" ], - "links": [ - { - "rel": "http://webfinger.net/rel/profile-page", - "type": "text/html", - "href": "https://#{domain}/users/#{nickname}" + "links" => [ + %{ + "rel" => "http://webfinger.net/rel/profile-page", + "type" => "text/html", + "href" => "https://#{domain}/users/#{nickname}" }, - { - "rel": "self", - "type": "application/activity+json", - "href": "https://#{domain}/users/#{nickname}" + %{ + "rel" => "self", + "type" => "application/activity+json", + "href" => "https://#{domain}/users/#{nickname}" } ] } - """ defp render_user(nickname, domain, name, summary, public_key), - do: """ - { - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://#{domain}/schemas/litepub-0.1.jsonld", - { - "@language": "und" - } - ], - "alsoKnownAs": [], - "attachment": [], - "capabilities": { - "acceptsChatMessages": true - }, - "discoverable": false, - "endpoints": { - "sharedInbox": "https://#{domain}/inbox" - }, - "followers": "https://#{domain}/users/#{nickname}/followers", - "following": "https://#{domain}/users/#{nickname}/following", - "icon": { - "type": "Image", - "url": "https://#{domain}/icon.png" - }, - "id": "https://#{domain}/users/#{nickname}", - "inbox": "https://#{domain}/users/#{nickname}/inbox", - "manuallyApprovesFollowers": false, - "name": "#{name}", - "outbox": "https://#{domain}/users/#{nickname}/outbox", - "preferredUsername": "#{nickname}", - "publicKey": { - "id": "https://#{domain}/users/#{nickname}#main-key", - "owner": "https://#{domain}/users/#{nickname}", - "publicKeyPem": "#{public_key}" - }, - "summary": "#{summary}", - "tag": [], - "type": "Person", - "url": "https://#{domain}/users/#{nickname}", - "vcard:bday": null, - "webfinger": "acct:#{nickname}@#{domain}" + do: %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "https://#{domain}/users/#{nickname}", + "type" => "Person", + "url" => "https://#{domain}/users/#{nickname}", + "name" => name, + "preferredUsername" => nickname, + "alsoKnownAs" => [], + "webfinger" => "acct:#{nickname}@#{domain}", + "summary" => summary, + "attachment" => [], + "discoverable" => false, + "endpoints" => %{ + "sharedInbox" => "https://#{domain}/inbox" + }, + "followers" => "https://#{domain}/users/#{nickname}/followers", + "following" => "https://#{domain}/users/#{nickname}/following", + "icon" => %{ + "type" => "Image", + "url" => "https://#{domain}/icon.png" + }, + "outbox" => "https://#{domain}/users/#{nickname}/outbox", + "inbox" => "https://#{domain}/users/#{nickname}/inbox", + "manuallyApprovesFollowers" => false, + "publicKey" => %{ + "id" => "https://#{domain}/users/#{nickname}#main-key", + "owner" => "https://#{domain}/users/#{nickname}", + "publicKeyPem" => public_key + }, + "tag" => [], + "vonbraun:props" => %{ + "me" => true + } } - """ - defp render_following(nickname, domain), - do: """ - { - "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://#{domain}/users/#{nickname}/following", - "type": "OrderedCollection", - "totalItems": 0, - "orderedItems": [ - ] - } - """ + defp render_following(nickname, domain) do + following = Actor.get_my_follows() - defp render_followers(nickname, domain), - do: """ - { - "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://#{domain}/users/#{nickname}/followers", - "type": "OrderedCollection", - "totalItems": 0, - "orderedItems": [ - ] + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "https://#{domain}/users/#{nickname}/following", + "type" => "OrderedCollection", + "totalItems" => length(following), + "orderedItems" => following } - """ + end + + defp render_followers(nickname, domain) do + followers = Actor.get_my_followers() + + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "https://#{domain}/users/#{nickname}/followers", + "type" => "OrderedCollection", + "totalItems" => length(followers), + "orderedItems" => followers + } + end get "/.well-known/webfinger" do conn = Plug.Conn.fetch_query_params(conn) @@ -148,7 +121,7 @@ defmodule Vonbraun.MyRouter do "https://#{domain}/users/#{nickname}" ]} do conn = Plug.Conn.put_resp_content_type(conn, "application/jrd+json") - send_resp(conn, 200, render_webfinger(nickname, domain)) + send_resp(conn, 200, render_webfinger(nickname, domain) |> Jason.encode!()) else {:resource, resource} -> Logger.error("not a binary resource: #{resource}") @@ -167,10 +140,15 @@ defmodule Vonbraun.MyRouter do domain = Application.fetch_env!(:vonbraun, :domain) name = Application.fetch_env!(:vonbraun, :name) summary = Application.fetch_env!(:vonbraun, :summary) - public_pem = Vonbraun.KeyAgent.get_public_pem() |> String.replace("\n", "\\n") + public_pem = Vonbraun.KeyAgent.get_public_pem() conn = Plug.Conn.put_resp_content_type(conn, "application/activity+json") - send_resp(conn, 200, render_user(nickname, domain, name, summary, public_pem)) + + send_resp( + conn, + 200, + render_user(nickname, domain, name, summary, public_pem) |> Jason.encode!() + ) else send_resp(conn, 404, "fuck off") end @@ -183,7 +161,7 @@ defmodule Vonbraun.MyRouter do domain = Application.fetch_env!(:vonbraun, :domain) conn = Plug.Conn.put_resp_content_type(conn, "application/activity+json") - send_resp(conn, 200, render_following(nickname, domain)) + send_resp(conn, 200, render_following(nickname, domain) |> Jason.encode!()) else send_resp(conn, 404, "fuck off") end @@ -196,7 +174,7 @@ defmodule Vonbraun.MyRouter do domain = Application.fetch_env!(:vonbraun, :domain) conn = Plug.Conn.put_resp_content_type(conn, "application/activity+json") - send_resp(conn, 200, render_followers(nickname, domain)) + send_resp(conn, 200, render_followers(nickname, domain) |> Jason.encode!()) else send_resp(conn, 404, "fuck off") end @@ -212,14 +190,14 @@ defmodule Vonbraun.MyRouter do with {:user, ^nickname} <- {:user, user}, {:page, "true"} <- {:page, conn.params["page"]} do conn = Plug.Conn.put_resp_content_type(conn, "application/activity+json") - send_resp(conn, 200, render_outbox_page(nickname, domain)) + send_resp(conn, 200, render_outbox_page(nickname, domain) |> Jason.encode!()) else {:user, _} -> send_resp(conn, 404, "fuck off") {:page, _} -> conn = Plug.Conn.put_resp_content_type(conn, "application/activity+json") - send_resp(conn, 200, render_outbox(nickname, domain)) + send_resp(conn, 200, render_outbox(nickname, domain) |> Jason.encode!()) end end diff --git a/priv/repo/migrations/20240824085128_initial.exs b/priv/repo/migrations/20240824085128_initial.exs index 367d11e..2334564 100644 --- a/priv/repo/migrations/20240824085128_initial.exs +++ b/priv/repo/migrations/20240824085128_initial.exs @@ -13,6 +13,7 @@ defmodule Vonbraun.Repo.Migrations.Initial do add(:id, :string, primary_key: true) add(:muted, :boolean) add(:blocked, :naive_datetime) + add(:blocks_me, :naive_datetime) add(:follows_me_state, :string) add(:follows_me_ts, :naive_datetime) add(:following_state, :string)