diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..d1987c7 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,13 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. + // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp + + // List of extensions which should be recommended for users of this workspace. + "recommendations": [ + + ], + // List of extensions recommended by VS Code that should not be recommended for users of this workspace. + "unwantedRecommendations": [ + + ] +} \ No newline at end of file diff --git a/README.md b/README.md index aca7ae7..fbedbf7 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,25 @@ -# Postland +## Backend -- Posting -- Deleting posts +- [ ] Posting +- [ ] Timeline +- [ ] Deleting posts +- [x] Following +- [ ] Unfollowing +- [x] Being followed +- [x] Accepting follows - Approving / declining follows and authorized instance list -- Following -- Unfollowing -- Liking -- Unliking +- [ ] Liking +- [ ] Unliking + +## UX + +- [ ] Posting +- [ ] Timeline +- [ ] Deleting posts +- [ ] Following +- [ ] Unfollowing +- [ ] Being followed +- [ ] Accepting follows +- Approving / declining follows and authorized instance list +- [ ] Liking +- [ ] Unliking diff --git a/lib/activity_pub.ex b/lib/activity_pub.ex new file mode 100644 index 0000000..ef64556 --- /dev/null +++ b/lib/activity_pub.ex @@ -0,0 +1,15 @@ +defmodule ActivityPub do + def fetch_actor(actor_id) do + request = + Req.new(url: actor_id) + |> Req.Request.put_header("accept", "application/json") + + case Req.get(request) do + {:ok, result} -> + {:ok, result.body} + + error -> + error + end + end +end diff --git a/lib/activity_pub/headers.ex b/lib/activity_pub/headers.ex index 04fed7e..9524a53 100644 --- a/lib/activity_pub/headers.ex +++ b/lib/activity_pub/headers.ex @@ -10,6 +10,23 @@ defmodule ActivityPub.Headers do ] def signing_headers(method, url, body, actor_url, private_key) do + url = case url do + url when is_struct(url, URI) -> + url + url when is_binary(url) -> + URI.parse(url) + end + + private_key = case private_key do + "-----BEGIN" <> _ = key_pem -> + key_pem + |> :public_key.pem_decode() + |> hd() + |> :public_key.pem_entry_decode() + private_key -> + private_key + end + method = String.downcase("#{method}") date = DateTime.utc_now() |> Calendar.strftime(@http_date_format) host = url.host @@ -65,6 +82,7 @@ defmodule ActivityPub.Headers do value = find_value(header_pairs, header_name) "#{header_name}: #{value}\n" end) + |> String.trim() end def split_signature_header(signature_header) do @@ -79,19 +97,17 @@ defmodule ActivityPub.Headers do end defp find_value(headers, key) do + key = String.downcase(key) + Enum.find_value(headers, fn {key_candidate, value} -> String.downcase(key_candidate) == String.downcase(key) && value end) end def fetch_actor_key(key_id) do - request = - Req.new(url: key_id) - |> Req.Request.put_header("accept", "application/json") - - case Req.get(request) do - {:ok, result} -> - key_map = result.body["publicKey"] + case ActivityPub.fetch_actor(key_id) do + {:ok, body} -> + key_map = body["publicKey"] if key_map["id"] == key_id do public_key = diff --git a/lib/postland.ex b/lib/postland.ex index e5571fe..56a62fd 100644 --- a/lib/postland.ex +++ b/lib/postland.ex @@ -7,6 +7,8 @@ defmodule Postland do if it comes from the database, an external API or others. """ + use Phoenix.VerifiedRoutes, endpoint: PostlandWeb.Endpoint, router: PostlandWeb.Router + alias Postland.Accounts def temporary_password() do @@ -51,4 +53,8 @@ defmodule Postland do def setup?() do Accounts.solo_user() != nil end + + def my_actor_id do + url(~p"/actor") + end end diff --git a/lib/postland/activities.ex b/lib/postland/activities.ex index 0b216be..e97b4b3 100644 --- a/lib/postland/activities.ex +++ b/lib/postland/activities.ex @@ -1,10 +1,64 @@ defmodule Postland.Activities do + use Phoenix.VerifiedRoutes, endpoint: PostlandWeb.Endpoint, router: PostlandWeb.Router + + require Logger + alias Postland.Activity alias Postland.Repo + alias Postland.Follows + + def process_activity(params) do + case record_activity(params) do + {:ok, activity} -> + cause_effects(activity) + + other -> + other + end + end def record_activity(params) do params |> Activity.changeset() |> Repo.insert() end + + def cause_effects(%Activity{type: "Accept", data: %{"object" => %{"type" => "Follow", "id" => follow_id}}} = activity) do + pattern = ~r|/follows/([^/]+)| + + actor_id = case Regex.run(pattern, follow_id) do + [_, encoded_actor_id] -> + Base.url_decode64!(encoded_actor_id, padding: false) + + _other -> + nil + end + + case Follows.get(Postland.my_actor_id(), actor_id) do + nil -> + Logger.warning("Got accept for a follow we don't have in the db: #{actor_id}") + + {:ok, activity} + + request -> + Follows.confirm_request(request) + end + + {:ok, activity} + end + + def cause_effects(%Activity{actor_id: actor_id, type: "Follow", data: %{"object" => object}} = activity) do + if object == Postland.my_actor_id() do + case Postland.Follows.record_inbound_request(actor_id) do + {:ok, _follow} -> + {:ok, activity} + other -> + other + end + else + {:ok, activity} + end + end + + def cause_effects(activity), do: {:ok, activity} end diff --git a/lib/postland/activity.ex b/lib/postland/activity.ex index 2da246a..08fcbff 100644 --- a/lib/postland/activity.ex +++ b/lib/postland/activity.ex @@ -12,13 +12,15 @@ defmodule Postland.Activity do def changeset(attrs) do attrs = %{ + "id" => Map.get(attrs, "id", Ecto.UUID.autogenerate()), "actor_id" => Map.get(attrs, "actor"), "type" => Map.get(attrs, "type"), "data" => attrs } %__MODULE__{} - |> cast(attrs, [:actor_id, :type, :data]) + |> cast(attrs, [:id, :actor_id, :type, :data]) + |> validate_required(:id) |> validate_required(:data) |> validate_required(:type) end diff --git a/lib/postland/follow.ex b/lib/postland/follow.ex new file mode 100644 index 0000000..cc5f6bc --- /dev/null +++ b/lib/postland/follow.ex @@ -0,0 +1,30 @@ +defmodule Postland.Follow do + use Ecto.Schema + + import Ecto.Changeset + + @primary_key false + schema "follows" do + field :follower, :string, primary_key: true + field :followee, :string, primary_key: true + field :confirmed_at, :naive_datetime + end + + def changeset(follower, followee, confirmed \\ false) do + attrs = %{ + follower: follower, + followee: followee, + confirmed_at: (if confirmed, do: NaiveDateTime.utc_now()) + } + + %__MODULE__{} + |> cast(attrs, [:follower, :followee, :confirmed_at]) + |> validate_required(:followee) + |> validate_required(:follower) + end + + def confirm_changeset(request) do + request + |> cast(%{confirmed_at: NaiveDateTime.utc_now()}, [:confirmed_at]) + end +end diff --git a/lib/postland/follows.ex b/lib/postland/follows.ex new file mode 100644 index 0000000..6903534 --- /dev/null +++ b/lib/postland/follows.ex @@ -0,0 +1,113 @@ +defmodule Postland.Follows do + use Phoenix.VerifiedRoutes, endpoint: PostlandWeb.Endpoint, router: PostlandWeb.Router + + import Ecto.Query, warn: false + + alias Postland.Accounts + alias Postland.Activity + alias Postland.Follow + alias Postland.Repo + alias ActivityPub.Headers + + alias Ecto.Multi + + def record_and_send_acceptance(request) do + Multi.new() + |> Multi.run(:confirm_timestamp, fn _data, _repo -> confirm_request(request) end) + |> Multi.run(:send_acceptance, fn _data, _repo -> + send_acceptance(request) + end) + |> Repo.transaction() + end + + def send_acceptance(request) do + {:ok, actor} = ActivityPub.fetch_actor(request.follower) + inbox = Map.get(actor, "inbox") + + case get_follow_activity(request.follower) do + nil -> + {:error, "could not find Follow activity to Accept"} + + follow_activity -> + body = + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "type" => "Accept", + "actor" => Postland.my_actor_id(), + "object" => %{ + "actor" => request.follower, + "id" => Map.get(follow_activity.data, "id"), + "object" => Postland.my_actor_id(), + "type" => "Follow" + } + } + |> Jason.encode!() + + headers = Headers.signing_headers("POST", inbox, body, Postland.my_actor_id(), Accounts.solo_user().private_key) + + Req.post(inbox, headers: headers, body: body) + end + end + + def get_follow_activity(follower) do + from(a in Activity, where: a.type == "Follow", where: a.actor_id == ^follower, limit: 1, order_by: [desc: :inserted_at]) |> Repo.one() + end + + def record_and_send_follow_request(to_actor_id) do + Multi.new() + |> Multi.run(:follow_record, fn _data, _repo -> record_outbound_request(to_actor_id) end) + |> Multi.run(:send_request, fn _data, _repo -> + send_follow_request(to_actor_id) + end) + |> Repo.transaction() + end + + def send_follow_request(to_actor_id) do + encoded_followee = Base.url_encode64(to_actor_id, padding: false) + encoded_follower = Base.url_encode64(Postland.my_actor_id(), padding: false) + + {:ok, actor} = ActivityPub.fetch_actor(to_actor_id) + inbox = Map.get(actor, "inbox") + follow_request = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => url(~p"/follows/#{encoded_followee}/#{encoded_follower}"), + "type" => "Follow", + "actor" => Postland.my_actor_id(), + "object" => to_actor_id + } + |> Jason.encode!() + + headers = Headers.signing_headers("POST", inbox, follow_request, Postland.my_actor_id(), Accounts.solo_user().private_key) + + Req.post(inbox, headers: headers, body: follow_request) + end + + def get(follower, followee) do + from(f in Follow, where: f.followee == ^followee, where: f.follower == ^follower) |> Repo.one() + end + + def pending_inbound_requests() do + my_actor_id = Postland.my_actor_id() + + from(f in Follow, where: f.followee == ^my_actor_id, where: is_nil(f.confirmed_at)) + |> Repo.all() + end + + def record_outbound_request(to_actor_id) do + Postland.my_actor_id() + |> Follow.changeset(to_actor_id) + |> Repo.insert(conflict_target: [:followee, :follower], on_conflict: :nothing) + end + + def record_inbound_request(from_actor_id) do + from_actor_id + |> Follow.changeset(Postland.my_actor_id()) + |> Repo.insert(conflict_target: [:followee, :follower], on_conflict: :nothing) + end + + def confirm_request(request) do + request + |> Follow.confirm_changeset() + |> Repo.update() + end +end diff --git a/lib/postland_web/controllers/inbox_controller.ex b/lib/postland_web/controllers/inbox_controller.ex index 729be7d..bf76b36 100644 --- a/lib/postland_web/controllers/inbox_controller.ex +++ b/lib/postland_web/controllers/inbox_controller.ex @@ -8,9 +8,9 @@ defmodule PostlandWeb.InboxController do def post(conn, params) do if Headers.verify(conn.method, conn.request_path, conn.req_headers) do - case Activities.record_activity(params) do + case Activities.process_activity(params) do {:ok, _activity} -> - send_resp(conn, 200, "ok") + send_resp(conn, 200, Jason.encode!(params)) error -> Logger.error(error) diff --git a/lib/postland_web/controllers/outbox_controller.ex b/lib/postland_web/controllers/outbox_controller.ex index 0800f9b..de62bea 100644 --- a/lib/postland_web/controllers/outbox_controller.ex +++ b/lib/postland_web/controllers/outbox_controller.ex @@ -15,7 +15,7 @@ defmodule PostlandWeb.OutboxController do } if Headers.verify(conn.method, conn.request_path, conn.req_headers) do - render(conn, json) + Plug.Conn.send_resp(conn, 200, Jason.encode!(json)) else send_resp(conn, 403, "forbidden") end diff --git a/lib/postland_web/controllers/webfinger_json.ex b/lib/postland_web/controllers/webfinger_json.ex index a6c53a6..e4dbb47 100644 --- a/lib/postland_web/controllers/webfinger_json.ex +++ b/lib/postland_web/controllers/webfinger_json.ex @@ -6,7 +6,6 @@ defmodule PostlandWeb.WebfingerJSON do def render("webfinger.json", _assigns) do user = Accounts.solo_user() - # TODO: Check that the host here is correct after deploy %{ subject: "acct:#{user.username}@#{PostlandWeb.Endpoint.host()}", links: [ diff --git a/mix.lock b/mix.lock index 6753c6c..e115060 100644 --- a/mix.lock +++ b/mix.lock @@ -18,7 +18,7 @@ "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"}, "gettext": {:hex, :gettext, "0.26.1", "38e14ea5dcf962d1fc9f361b63ea07c0ce715a8ef1f9e82d3dfb8e67e0416715", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "01ce56f188b9dc28780a52783d6529ad2bc7124f9744e571e1ee4ea88bf08734"}, - "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized"]}, + "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, diff --git a/priv/repo/migrations/20241009214840_add_follows.exs b/priv/repo/migrations/20241009214840_add_follows.exs new file mode 100644 index 0000000..639c728 --- /dev/null +++ b/priv/repo/migrations/20241009214840_add_follows.exs @@ -0,0 +1,11 @@ +defmodule Postland.Repo.Migrations.AddActors do + use Ecto.Migration + + def change do + create table("follows", primary_key: false) do + add :follower, :string, primary_key: true + add :followee, :string, primary_key: true + add :confirmed_at, :naive_datetime + end + end +end diff --git a/priv/repo/migrations/20241011023003_add_timestamps_to_activities.exs b/priv/repo/migrations/20241011023003_add_timestamps_to_activities.exs new file mode 100644 index 0000000..2b454d1 --- /dev/null +++ b/priv/repo/migrations/20241011023003_add_timestamps_to_activities.exs @@ -0,0 +1,9 @@ +defmodule Postland.Repo.Migrations.AddTimestampsToActivities do + use Ecto.Migration + + def change do + alter table("activities") do + timestamps() + end + end +end