follows almost work with mastodon

This commit is contained in:
Moon Man 2024-08-26 21:07:50 +00:00
parent a434c693d5
commit 0168c366d5
10 changed files with 538 additions and 168 deletions

View File

@ -14,4 +14,6 @@ config :vonbraun,
summary: "I am the famous Fediverse Moon", summary: "I am the famous Fediverse Moon",
approve_followers: false, approve_followers: false,
# MUST be 32 # MUST be 32
id_key: "53F0CB234DA3DB1A43AE32DE4038516B" id_key: "53F0CB234DA3DB1A43AE32DE4038516B",
fluree_url: "http://localhost:58090/fluree/",
fluree_ledger: "vonbraun"

View File

@ -1,19 +1,27 @@
defmodule Vonbraun.ActivityPubReq do defmodule Vonbraun.ActivityPubReq do
require Logger
alias Vonbraun.HTTPSignature alias Vonbraun.HTTPSignature
defp key_id() do @spec actor_id() :: String.t()
def actor_id() do
domain = Application.fetch_env!(:vonbraun, :domain) domain = Application.fetch_env!(:vonbraun, :domain)
nickname = Application.fetch_env!(:vonbraun, :nickname) nickname = Application.fetch_env!(:vonbraun, :nickname)
"https://#{domain}/users/#{nickname}#main-key" "https://#{domain}/users/#{nickname}"
end 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 = %{ headers = %{
"host" => host,
"accept" => "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" "accept" => "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
} }
target = target =
if query do if query && query != "" do
path <> "?" <> query path <> "?" <> query
else else
path path
@ -24,13 +32,14 @@ defmodule Vonbraun.ActivityPubReq do
Req.get(url, headers: headers) Req.get(url, headers: headers)
end 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 = %{ headers = %{
"host" => host,
"accept" => "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" "accept" => "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
} }
target = target =
if query do if query && query != "" do
path <> "?" <> query path <> "?" <> query
else else
path path
@ -38,6 +47,39 @@ defmodule Vonbraun.ActivityPubReq do
headers = HTTPSignature.add_post_signature(headers, key_id(), target, body) 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
end end

View File

@ -1,5 +1,52 @@
defmodule Vonbraun.Control do 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 end
def unfollow(id) when is_binary(id) do def unfollow(id) when is_binary(id) do

View File

@ -2,6 +2,7 @@ defmodule Vonbraun.Ecto.Schema.Actor do
use Ecto.Schema use Ecto.Schema
alias Ecto.Changeset alias Ecto.Changeset
alias Vonbraun.Repo alias Vonbraun.Repo
import Ecto.Query, only: [from: 2]
@primary_key {:id, :string, autogenerate: false} @primary_key {:id, :string, autogenerate: false}
@ -17,12 +18,12 @@ defmodule Vonbraun.Ecto.Schema.Actor do
end end
@type t :: %__MODULE__{ @type t :: %__MODULE__{
muted: boolean(), muted: boolean() | nil,
blocked: DateTime.t(), blocked: DateTime.t() | nil,
follows_me_state: String.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_state: String.t() | nil,
following_ts: String.t() following_ts: String.t() | nil
} }
def changeset(struct, params \\ %{}) do def changeset(struct, params \\ %{}) do
@ -165,4 +166,24 @@ defmodule Vonbraun.Ecto.Schema.Actor do
{:error, error} {:error, error}
end end
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 end

90
lib/vonbraun/fluree.ex Normal file
View File

@ -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

View File

@ -3,17 +3,26 @@ defmodule Vonbraun.HTTPSignature do
Implements RFC 9421 HTTP Signatures using the Digest header. 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 # (request-body) is really only for signing not sending as a header, but send
# it anyway for broken implementations. # it anyway for broken implementations.
@post_headers ["(request-body)", "host", "date", "digest"] @post_headers ["(request-target)", "host", "date", "digest"]
@other_headers ["(request-body)", "host", "date"] @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() @spec add_post_signature(map(), String.t(), String.t(), String.t()) :: map()
def add_post_signature(headers, key_id, path, body) 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 when is_map(headers) and is_binary(key_id) and is_binary(body) and is_binary(path) do
request_target = "post #{path}" request_target = "post #{path}"
digest = compute_digest(body) digest = compute_digest(body)
date = Timex.format!(Timex.now(:utc), "{RFC1123}") date = signed_date()
headers = headers =
Map.put(headers, "digest", digest) Map.put(headers, "digest", digest)
@ -23,56 +32,105 @@ defmodule Vonbraun.HTTPSignature do
signing_string = construct_signing_string(headers, @post_headers) signing_string = construct_signing_string(headers, @post_headers)
signature = sign(signing_string) signature = sign(signing_string)
signature_header = build_signature_header(key_id, signature, @post_headers) 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 end
@spec add_get_signature(map(), String.t(), String.t()) :: map() @spec add_get_signature(map(), String.t(), String.t()) :: map()
def add_get_signature(headers, key_id, path) def add_get_signature(headers, key_id, path)
when is_map(headers) and is_binary(key_id) and is_binary(path) do when is_map(headers) and is_binary(key_id) and is_binary(path) do
request_target = "get #{path}" request_target = "get #{path}"
date = Timex.format!(Timex.now(:utc), "{RFC1123}") date = signed_date()
headers = Map.put(headers, "date", date) |> Map.put("(request-target)", request_target)
headers =
Map.put(headers, "date", date)
|> Map.put("(request-target)", request_target)
signing_string = construct_signing_string(headers, @other_headers) signing_string = construct_signing_string(headers, @other_headers)
signature = sign(signing_string) signature = sign(signing_string)
signature_header = build_signature_header(key_id, signature, @other_headers) 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 end
@spec verify_post_signature(map(), String.t()) :: boolean() def verify_post_signature(conn = %Conn{}, public_key) do
def verify_post_signature(headers, body) when is_map(headers) and is_binary(body) do target =
signature_header = Map.get(headers, "signature") 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 valid_request_target = "post #{target}"
{: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))
{:error, _error} -> with {:raw_body, {:ok, raw_body, _}} <- {:raw_body, Conn.read_body(conn)},
false {:signature_header, [signature_header]} <-
end {:signature_header, Conn.get_req_header(conn, "signature")},
end {: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() "digest", list ->
def verify_get_signature(headers) when is_map(headers) do with {:get, [header_value]} <- {:get, Conn.get_req_header(conn, "digest")},
signature_header = Map.get(headers, "signature") {: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 if provided_digest == new_digest do
{:ok, {_key_id, signature}} -> {:cont, ["digest: #{test_digest}" | list]}
signing_string = construct_signing_string(headers, @other_headers) else
expected_signature = sign(signing_string) Logger.warning("digest failed: #{provided_digest} <> #{test_digest}")
SecureCompare.compare(expected_signature, Base.decode64!(signature)) {: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 false
end end
end end
defp compute_digest(body) when is_binary(body) do defp compute_digest(body) when is_binary(body) do
encoded = :crypto.hash(:sha256, body) |> Base.encode64() encoded = :crypto.hash(:sha256, body) |> Base.encode64()
"sha-256=" <> encoded "SHA-256=" <> encoded
end end
defp construct_signing_string(headers, include_headers) do defp construct_signing_string(headers, include_headers) do
@ -85,20 +143,35 @@ defmodule Vonbraun.HTTPSignature do
defp sign(data) when is_binary(data) do defp sign(data) when is_binary(data) do
{:ok, signature} = ExPublicKey.sign(data, Vonbraun.KeyAgent.get_private_key()) {: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 end
defp build_signature_header(key_id, signature, headers) do 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 end
defp parse_signature_header(header_value) when is_binary(header_value) do defp parse_signature_header(header_value) when is_binary(header_value) do
with {:prefix, "Signature " <> pairs_string} <- {:prefix, header_value}, with {:parse, items} <- {:parse, Regex.scan(~r/([-a-z-A-Z]+)="(.+)"/U, header_value)},
{:parse, items} <- {:parse, Regex.scan(~r/([-a-z-A-Z]+)="(.+)"/U, pairs_string)}, {:reduce, map = %{}} <-
{:reduce, %{"keyId" => key_id, "signature" => signature}} <-
{:reduce, {:reduce,
Enum.reduce(items, Map.new(), fn [_, key, value], map -> Map.put(map, key, value) end)} do Enum.reduce(items, Map.new(), fn
{:ok, {key_id, signature}} [_, "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 else
_ -> {:error, :invalid} _ -> {:error, :invalid}
end end

View File

@ -1,4 +1,7 @@
defmodule Vonbraun.InboxRouter do defmodule Vonbraun.InboxRouter do
alias Vonbraun.HTTPSignature
alias Vonbraun.ActivityPubReq
alias Vonbraun.Ecto.Schema.Actor
use Plug.Router use Plug.Router
require Logger require Logger
@ -16,12 +19,119 @@ defmodule Vonbraun.InboxRouter do
end end
post "/" do 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 cond do
send_resp(conn, 200, "ok") conn.params["user"] in [nil, Application.fetch_env!(:vonbraun, :nickname)] ->
else with {:actor_url, {:ok, activity = %{"actor" => actor_url}}} <-
send_resp(conn, 404, "fuck off") {: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
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 end

View File

@ -1,6 +1,8 @@
defmodule Vonbraun.KeyAgent do defmodule Vonbraun.KeyAgent do
use Agent use Agent
require Logger
def start_link(_opts) do def start_link(_opts) do
Agent.start_link(fn -> load_keys() end, name: __MODULE__) Agent.start_link(fn -> load_keys() end, name: __MODULE__)
end end
@ -13,6 +15,10 @@ defmodule Vonbraun.KeyAgent do
ExPublicKey.pem_encode(public_key) ExPublicKey.pem_encode(public_key)
end end
public_pem = String.replace(public_pem, "\\n", "\n")
Logger.debug("Public key is: `#{public_pem}`")
%{ %{
private_key: private_key, private_key: private_key,
public_pem: public_pem public_pem: public_pem

View File

@ -3,135 +3,108 @@ defmodule Vonbraun.MyRouter do
require Logger require Logger
alias Vonbraun.Ecto.Schema.Pair alias Vonbraun.Ecto.Schema.Pair
alias Vonbraun.Ecto.Schema.Actor
alias Vonbraun.InboxRouter alias Vonbraun.InboxRouter
plug(:match) plug(:match)
plug(:dispatch) plug(:dispatch)
defp render_outbox(nickname, domain), defp render_outbox(nickname, domain),
do: """ do: %{
{ "@context" => "https://www.w3.org/ns/activitystreams",
"@context": [ "id" => "https://#{domain}/users/#{nickname}/outbox",
"https://www.w3.org/ns/activitystreams", "type" => "OrderedCollection",
"https://#{domain}/schemas/litepub-0.1.jsonld", "first" => "https://#{domain}/users/#{nickname}/outbox?page=true"
{
"@language": "und"
}
],
"first": "https://#{domain}/users/#{nickname}/outbox?page=true",
"id": "https://#{domain}/users/#{nickname}/outbox",
"type": "OrderedCollection"
} }
"""
defp render_outbox_page(nickname, domain), defp render_outbox_page(nickname, domain),
do: """ do: %{
{ "@context" => "https://www.w3.org/ns/activitystreams",
"@context": [ "id" => "https://#{domain}/users/#{nickname}/outbox?page=true",
"https://www.w3.org/ns/activitystreams", "type" => "OrderedCollectionPage",
"https://#{domain}/schemas/litepub-0.1.jsonld", "orderedItems" => [],
{ "partOf" => "https://#{domain}/users/#{nickname}/outbox"
"@language": "und"
}
],
"id": "https://#{domain}/users/#{nickname}/outbox?page=true",
"orderedItems": [
],
"partOf": "https://#{domain}/users/#{nickname}/outbox",
"type": "OrderedCollectionPage"
} }
"""
defp render_webfinger(nickname, domain), defp render_webfinger(nickname, domain),
do: """ do: %{
{ "subject" => "acct:#{nickname}@#{domain}",
"subject": "acct:#{nickname}@#{domain}", "aliases" => [
"aliases": [
"https://#{domain}/users/#{nickname}" "https://#{domain}/users/#{nickname}"
], ],
"links": [ "links" => [
{ %{
"rel": "http://webfinger.net/rel/profile-page", "rel" => "http://webfinger.net/rel/profile-page",
"type": "text/html", "type" => "text/html",
"href": "https://#{domain}/users/#{nickname}" "href" => "https://#{domain}/users/#{nickname}"
}, },
{ %{
"rel": "self", "rel" => "self",
"type": "application/activity+json", "type" => "application/activity+json",
"href": "https://#{domain}/users/#{nickname}" "href" => "https://#{domain}/users/#{nickname}"
} }
] ]
} }
"""
defp render_user(nickname, domain, name, summary, public_key), defp render_user(nickname, domain, name, summary, public_key),
do: """ do: %{
{ "@context" => "https://www.w3.org/ns/activitystreams",
"@context": [ "id" => "https://#{domain}/users/#{nickname}",
"https://www.w3.org/ns/activitystreams", "type" => "Person",
"https://#{domain}/schemas/litepub-0.1.jsonld", "url" => "https://#{domain}/users/#{nickname}",
{ "name" => name,
"@language": "und" "preferredUsername" => nickname,
} "alsoKnownAs" => [],
], "webfinger" => "acct:#{nickname}@#{domain}",
"alsoKnownAs": [], "summary" => summary,
"attachment": [], "attachment" => [],
"capabilities": { "discoverable" => false,
"acceptsChatMessages": true "endpoints" => %{
}, "sharedInbox" => "https://#{domain}/inbox"
"discoverable": false, },
"endpoints": { "followers" => "https://#{domain}/users/#{nickname}/followers",
"sharedInbox": "https://#{domain}/inbox" "following" => "https://#{domain}/users/#{nickname}/following",
}, "icon" => %{
"followers": "https://#{domain}/users/#{nickname}/followers", "type" => "Image",
"following": "https://#{domain}/users/#{nickname}/following", "url" => "https://#{domain}/icon.png"
"icon": { },
"type": "Image", "outbox" => "https://#{domain}/users/#{nickname}/outbox",
"url": "https://#{domain}/icon.png" "inbox" => "https://#{domain}/users/#{nickname}/inbox",
}, "manuallyApprovesFollowers" => false,
"id": "https://#{domain}/users/#{nickname}", "publicKey" => %{
"inbox": "https://#{domain}/users/#{nickname}/inbox", "id" => "https://#{domain}/users/#{nickname}#main-key",
"manuallyApprovesFollowers": false, "owner" => "https://#{domain}/users/#{nickname}",
"name": "#{name}", "publicKeyPem" => public_key
"outbox": "https://#{domain}/users/#{nickname}/outbox", },
"preferredUsername": "#{nickname}", "tag" => [],
"publicKey": { "vonbraun:props" => %{
"id": "https://#{domain}/users/#{nickname}#main-key", "me" => true
"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}"
} }
"""
defp render_following(nickname, domain), defp render_following(nickname, domain) do
do: """ following = Actor.get_my_follows()
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://#{domain}/users/#{nickname}/following",
"type": "OrderedCollection",
"totalItems": 0,
"orderedItems": [
]
}
"""
defp render_followers(nickname, domain), %{
do: """ "@context" => "https://www.w3.org/ns/activitystreams",
{ "id" => "https://#{domain}/users/#{nickname}/following",
"@context": "https://www.w3.org/ns/activitystreams", "type" => "OrderedCollection",
"id": "https://#{domain}/users/#{nickname}/followers", "totalItems" => length(following),
"type": "OrderedCollection", "orderedItems" => following
"totalItems": 0,
"orderedItems": [
]
} }
""" 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 get "/.well-known/webfinger" do
conn = Plug.Conn.fetch_query_params(conn) conn = Plug.Conn.fetch_query_params(conn)
@ -148,7 +121,7 @@ defmodule Vonbraun.MyRouter do
"https://#{domain}/users/#{nickname}" "https://#{domain}/users/#{nickname}"
]} do ]} do
conn = Plug.Conn.put_resp_content_type(conn, "application/jrd+json") 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 else
{:resource, resource} -> {:resource, resource} ->
Logger.error("not a binary resource: #{resource}") Logger.error("not a binary resource: #{resource}")
@ -167,10 +140,15 @@ defmodule Vonbraun.MyRouter do
domain = Application.fetch_env!(:vonbraun, :domain) domain = Application.fetch_env!(:vonbraun, :domain)
name = Application.fetch_env!(:vonbraun, :name) name = Application.fetch_env!(:vonbraun, :name)
summary = Application.fetch_env!(:vonbraun, :summary) 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") 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 else
send_resp(conn, 404, "fuck off") send_resp(conn, 404, "fuck off")
end end
@ -183,7 +161,7 @@ defmodule Vonbraun.MyRouter do
domain = Application.fetch_env!(:vonbraun, :domain) domain = Application.fetch_env!(:vonbraun, :domain)
conn = Plug.Conn.put_resp_content_type(conn, "application/activity+json") 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 else
send_resp(conn, 404, "fuck off") send_resp(conn, 404, "fuck off")
end end
@ -196,7 +174,7 @@ defmodule Vonbraun.MyRouter do
domain = Application.fetch_env!(:vonbraun, :domain) domain = Application.fetch_env!(:vonbraun, :domain)
conn = Plug.Conn.put_resp_content_type(conn, "application/activity+json") 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 else
send_resp(conn, 404, "fuck off") send_resp(conn, 404, "fuck off")
end end
@ -212,14 +190,14 @@ defmodule Vonbraun.MyRouter do
with {:user, ^nickname} <- {:user, user}, with {:user, ^nickname} <- {:user, user},
{:page, "true"} <- {:page, conn.params["page"]} do {:page, "true"} <- {:page, conn.params["page"]} do
conn = Plug.Conn.put_resp_content_type(conn, "application/activity+json") 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 else
{:user, _} -> {:user, _} ->
send_resp(conn, 404, "fuck off") send_resp(conn, 404, "fuck off")
{:page, _} -> {:page, _} ->
conn = Plug.Conn.put_resp_content_type(conn, "application/activity+json") 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
end end

View File

@ -13,6 +13,7 @@ defmodule Vonbraun.Repo.Migrations.Initial do
add(:id, :string, primary_key: true) add(:id, :string, primary_key: true)
add(:muted, :boolean) add(:muted, :boolean)
add(:blocked, :naive_datetime) add(:blocked, :naive_datetime)
add(:blocks_me, :naive_datetime)
add(:follows_me_state, :string) add(:follows_me_state, :string)
add(:follows_me_ts, :naive_datetime) add(:follows_me_ts, :naive_datetime)
add(:following_state, :string) add(:following_state, :string)