diff --git a/config/dev.exs b/config/dev.exs index 31508fc..b9f3112 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -16,10 +16,14 @@ config :nulla, Nulla.Repo, # The watchers configuration can be used to run external # watchers to your application. For example, we can use it # to bundle .js and .css sources. +host = System.get_env("PHX_HOST") || "example.com" +port = String.to_integer(System.get_env("PORT") || "4000") + config :nulla, NullaWeb.Endpoint, # Binding to loopback ipv4 address prevents access from other machines. # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. - http: [ip: {127, 0, 0, 1}, port: 4000], + url: [host: host, port: port], + http: [ip: {127, 0, 0, 1}, port: port], check_origin: false, code_reloader: true, debug_errors: true, diff --git a/lib/nulla/activitypub.ex b/lib/nulla/activitypub.ex index 21f5507..e746002 100644 --- a/lib/nulla/activitypub.ex +++ b/lib/nulla/activitypub.ex @@ -51,7 +51,12 @@ defmodule Nulla.ActivityPub do indexable: actor.indexable, published: DateTime.to_iso8601(actor.published), memorial: actor.memorial, - publicKey: actor.publicKey, + publicKey: + Jason.OrderedObject.new( + id: actor.publicKey["id"], + owner: actor.publicKey["owner"], + publicKeyPem: actor.publicKey["publicKeyPem"] + ), tag: actor.tag, attachment: actor.attachment, endpoints: actor.endpoints, diff --git a/lib/nulla/keygen.ex b/lib/nulla/keygen.ex index 5cbaf06..8a75cb5 100644 --- a/lib/nulla/keygen.ex +++ b/lib/nulla/keygen.ex @@ -1,18 +1,23 @@ defmodule Nulla.KeyGen do def gen do - rsa_key = :public_key.generate_key({:rsa, 2048, 65537}) + key = :public_key.generate_key({:rsa, 2048, 65_537}) + entry = :public_key.pem_entry_encode(:RSAPrivateKey, key) - {:RSAPrivateKey, :"two-prime", n, e, _d, _p, _q, _dp, _dq, _qi, _other} = rsa_key - public_key = {:RSAPublicKey, n, e} + private_pem = + :public_key.pem_encode([entry]) + |> String.trim_trailing() + |> Kernel.<>("\n") - private_entry = - {:PrivateKeyInfo, :public_key.der_encode(:RSAPrivateKey, rsa_key), :not_encrypted} + [private_key_code] = :public_key.pem_decode(private_pem) + private_key = :public_key.pem_entry_decode(private_key_code) + {:RSAPrivateKey, _, modulus, exponent, _, _, _, _, _, _, _} = private_key + public_key = {:RSAPublicKey, modulus, exponent} + public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key) - public_entry = - {:SubjectPublicKeyInfo, :public_key.der_encode(:RSAPublicKey, public_key), :not_encrypted} - - private_pem = :public_key.pem_encode([private_entry]) - public_pem = :public_key.pem_encode([public_entry]) + public_pem = + :public_key.pem_encode([public_key]) + |> String.trim_trailing() + |> Kernel.<>("\n") {public_pem, private_pem} end diff --git a/lib/nulla/models/activity.ex b/lib/nulla/models/activity.ex index 71a192d..d09bb89 100644 --- a/lib/nulla/models/activity.ex +++ b/lib/nulla/models/activity.ex @@ -4,13 +4,13 @@ defmodule Nulla.Models.Activity do alias Nulla.Repo alias Nulla.Snowflake + @derive {Jason.Encoder, only: [:ap_id, :type, :actor, :object]} @primary_key {:id, :integer, autogenerate: false} schema "activities" do field :ap_id, :string field :type, :string field :actor, :string - field :object, :map - field :cc, {:array, :string}, default: [] + field :object, :string timestamps() end @@ -18,7 +18,7 @@ defmodule Nulla.Models.Activity do @doc false def changeset(activity, attrs) do activity - |> cast(attrs, [:ap_id, :type, :actor, :object, :to]) + |> cast(attrs, [:ap_id, :type, :actor, :object]) |> validate_required([:ap_id, :type, :actor, :object]) |> validate_inclusion(:type, ~w(Create Update Delete Undo Like Announce Follow Accept Reject)) end diff --git a/lib/nulla/models/actor.ex b/lib/nulla/models/actor.ex index e00929b..a8b8072 100644 --- a/lib/nulla/models/actor.ex +++ b/lib/nulla/models/actor.ex @@ -78,15 +78,8 @@ defmodule Nulla.Models.Actor do :followers, :inbox, :outbox, - :featured, - :featuredTags, :preferredUsername, :url, - :manuallyApprovesFollowers, - :discoverable, - :indexable, - :published, - :memorial, :publicKey, :endpoints ]) diff --git a/lib/nulla/models/relation.ex b/lib/nulla/models/relation.ex index 600abca..609ff0d 100644 --- a/lib/nulla/models/relation.ex +++ b/lib/nulla/models/relation.ex @@ -48,7 +48,7 @@ defmodule Nulla.Models.Relation do :local_actor_id, :remote_actor_id ]) - |> validate_required([:id, :local_actor_id, :remote_actor_id]) + |> validate_required([:local_actor_id, :remote_actor_id]) |> unique_constraint([:local_actor_id, :remote_actor_id]) end @@ -61,6 +61,10 @@ defmodule Nulla.Models.Relation do |> Repo.insert() end + def get_relation(by) when is_map(by) or is_list(by) do + Repo.get_by(__MODULE__, by) + end + def count_following(local_actor_id) do __MODULE__ |> where([r], r.local_actor_id == ^local_actor_id and r.following == true) diff --git a/lib/nulla/utils.ex b/lib/nulla/utils.ex index a0454d7..4915620 100644 --- a/lib/nulla/utils.ex +++ b/lib/nulla/utils.ex @@ -1,4 +1,6 @@ defmodule Nulla.Utils do + alias Finch + def fetch_remote_actor(uri) do headers = [ {"Accept", "application/activity+json"}, @@ -8,7 +10,7 @@ defmodule Nulla.Utils do request = Finch.build(:get, uri, headers) - case Finch.request(request, Finch) do + case Finch.request(request, Nulla.Finch) do {:ok, %Finch.Response{status: 200, body: body}} -> case Jason.decode(body) do {:ok, data} -> {:ok, data} diff --git a/lib/nulla_web/controllers/actor_controller.ex b/lib/nulla_web/controllers/actor_controller.ex index c4b8479..8e88551 100644 --- a/lib/nulla_web/controllers/actor_controller.ex +++ b/lib/nulla_web/controllers/actor_controller.ex @@ -17,7 +17,7 @@ defmodule NullaWeb.ActorController do |> json(%{error: "Not Found"}) %Actor{} = actor -> - if accept in ["application/activity+json", "application/ld+json"] do + if accept =~ "json" do conn |> put_resp_content_type("application/activity+json") |> send_resp(200, Jason.encode!(ActivityPub.actor(actor))) diff --git a/lib/nulla_web/controllers/follow_controller.ex b/lib/nulla_web/controllers/follow_controller.ex index 8a21c7d..d8ca5b4 100644 --- a/lib/nulla_web/controllers/follow_controller.ex +++ b/lib/nulla_web/controllers/follow_controller.ex @@ -18,7 +18,7 @@ defmodule NullaWeb.FollowController do _ -> 1 end - following_list = Relation.get_following(actor.id, page, limit) + following_list = Enum.map(Relation.get_following(actor.id, page, limit), & &1.ap_id) conn |> put_resp_content_type("application/activity+json") @@ -49,7 +49,7 @@ defmodule NullaWeb.FollowController do _ -> 1 end - followers_list = Relation.get_followers(actor.id, page, limit) + followers_list = Enum.map(Relation.get_followers(actor.id, page, limit), & &1.ap_id) conn |> put_resp_content_type("application/activity+json") diff --git a/lib/nulla_web/controllers/inbox_controller.ex b/lib/nulla_web/controllers/inbox_controller.ex index ef3c9a3..6bdecd3 100644 --- a/lib/nulla_web/controllers/inbox_controller.ex +++ b/lib/nulla_web/controllers/inbox_controller.ex @@ -1,106 +1,165 @@ defmodule NullaWeb.InboxController do use NullaWeb, :controller - alias Nulla.HTTPSignature alias Nulla.ActivityPub alias Nulla.Snowflake + alias Nulla.HTTPSignature alias Nulla.Utils alias Nulla.Models.User alias Nulla.Models.Actor alias Nulla.Models.Relation alias Nulla.Models.Activity - def inbox( - conn, - %{"id" => follow_id, "type" => "Follow", "actor" => actor_uri, "object" => target_uri} - ) do + def inbox(conn, %{ + "id" => follow_id, + "type" => "Follow", + "actor" => actor_uri, + "object" => target_uri + }) do accept_id = Snowflake.next_id() with local_actor <- Actor.get_actor(ap_id: target_uri), {:ok, remote_actor_json} <- Utils.fetch_remote_actor(actor_uri), :ok <- HTTPSignature.verify(conn, remote_actor_json), - remote_actor <- - Actor.create_actor( - remote_actor_json - |> Map.put("ap_id", remote_actor_json["id"]) - |> Map.delete("id") - |> Map.put("domain", URI.parse(remote_actor_json["id"]).host) - ), - follow_activity <- - Activity.create_activity(%{ - ap_id: follow_id, - type: "Follow", - actor: remote_actor.id, - object: target_uri - }), - accept_activity <- - Activity.create_activity(%{ - id: accept_id, - ap_id: "https://#{local_actor.domain}/activities/accept/#{accept_id}", - type: "Accept", - actor: local_actor.ap_id, - object: follow_activity - }), - _ <- - Relation.create_relation(%{ - followed_by: true, - local_actor_id: local_actor.id, - remote_actor_id: remote_actor.id - }) do - body = Jason.encode!(ActivityPub.activity(accept_activity)) - - digest = - :crypto.hash(:sha256, body) - |> Base.encode64() - |> then(&("SHA-256=" <> &1)) - - date = - DateTime.utc_now() - |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT") - - host = URI.parse(actor_uri).host - request_target = "post /inbox" - - signature_string = - """ - (request-target): #{request_target} - host: #{host} - date: #{date} - digest: #{digest} - """ - - user = User.get_user(id: local_actor.id) - privateKeyPem = user.privateKey["privateKeyPem"] - - private_key = - :public_key.pem_decode(privateKeyPem) - |> hd() - |> :public_key.pem_entry_decode() - - signature = - :public_key.sign(signature_string, :sha256, private_key) - |> Base.encode64() - - signature_header = - """ - keyId="#{local_actor.publicKey["id"]}", - algorithm="rsa-sha256", - headers="(request-target) host date digest content-type", - signature="#{signature}" - """ - |> String.replace("\n", "") - |> String.trim() - - conn - |> put_resp_content_type("application/activity+json") - |> put_resp_header("host", host) - |> put_resp_header("date", date) - |> put_resp_header("signature", signature_header) - |> put_resp_header("digest", digest) - |> send_resp(200, body) + {:ok, remote_actor} <- get_or_create_actor(remote_actor_json), + {:ok, follow_activity} <- + create_follow_activity(follow_id, remote_actor.ap_id, target_uri), + {:ok, accept_activity} <- + create_accept_activity(accept_id, local_actor, follow_activity), + {:ok, _relation} <- get_or_create_relation(local_actor.id, remote_actor.id), + :ok <- deliver_accept(accept_activity, remote_actor_json, local_actor) do + send_resp(conn, 200, "") else error -> IO.inspect(error, label: "Follow error") json(conn, %{"error" => "Failed to process Follow"}) end end + + defp get_or_create_actor(remote_actor_json) do + ap_id = remote_actor_json["id"] + + case Actor.get_actor(ap_id: ap_id) do + nil -> + params = + remote_actor_json + |> Map.put("ap_id", ap_id) + |> Map.delete("id") + |> Map.put("domain", URI.parse(ap_id).host) + + case Actor.create_actor(params) do + {:ok, actor} -> {:ok, actor} + {:error, changeset} -> {:error, {:actor_creation_failed, changeset}} + end + + actor -> + {:ok, actor} + end + end + + defp create_follow_activity(follow_id, actor_id, target_uri) do + Activity.create_activity(%{ + ap_id: follow_id, + type: "Follow", + actor: actor_id, + object: target_uri + }) + end + + defp create_accept_activity(accept_id, local_actor, follow_activity) do + Activity.create_activity(%{ + id: accept_id, + ap_id: "https://#{local_actor.domain}/activities/accept/#{accept_id}", + type: "Accept", + actor: local_actor.ap_id, + object: Jason.encode!(follow_activity) + }) + end + + defp get_or_create_relation(local_actor_id, remote_actor_id) do + case Relation.get_relation(local_actor_id: local_actor_id, remote_actor_id: remote_actor_id) do + nil -> + Relation.create_relation(%{ + followed_by: true, + local_actor_id: local_actor_id, + remote_actor_id: remote_actor_id + }) + + relation -> + {:ok, relation} + end + end + + defp deliver_accept(accept_activity, remote_actor_json, local_actor) do + inbox_url = remote_actor_json["inbox"] + accept_activity = %Activity{accept_activity | object: Jason.decode!(accept_activity.object)} + body = Jason.encode!(ActivityPub.activity(accept_activity)) + + digest = + :crypto.hash(:sha256, body) + |> Base.encode64() + |> then(&("SHA-256=" <> &1)) + + date = + DateTime.utc_now() + |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT") + + uri = URI.parse(inbox_url) + request_target = "post #{uri.path}" + + signature_string = """ + (request-target): #{request_target} + host: #{uri.host} + date: #{date} + digest: #{digest} + """ + + user = User.get_user(id: local_actor.id) + + private_key = + case :public_key.pem_decode(user.privateKeyPem) do + [{_type, der, _rest}] -> + :public_key.der_decode(:RSAPrivateKey, der) + + _ -> + raise "Invalid PEM format" + end + + signature = + :public_key.sign(signature_string, :sha256, private_key) + |> Base.encode64() + + signature_header = + """ + keyId="#{local_actor.publicKey["id"]}", + algorithm="rsa-sha256", + headers="(request-target) host date digest content-type", + signature="#{signature}" + """ + |> String.replace("\n", "") + |> String.trim() + + headers = [ + {"host", uri.host}, + {"date", date}, + {"digest", digest}, + {"signature", signature_header}, + {"content-type", "application/activity+json"} + ] + + request = Finch.build(:post, inbox_url, headers, body) + + case Finch.request(request, Nulla.Finch) do + {:ok, %Finch.Response{status: code}} when code in 200..299 -> + IO.puts("Accept delivered successfully") + :ok + + {:ok, %Finch.Response{status: code, body: resp}} -> + IO.inspect({:error, code, resp}, label: "Failed to deliver Accept") + {:error, {:http_error, code}} + + {:error, reason} -> + IO.inspect(reason, label: "Finch delivery failed") + {:error, reason} + end + end end diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index cc889c4..53d3633 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -2,12 +2,12 @@ defmodule NullaWeb.Router do use NullaWeb, :router pipeline :browser do - plug :accepts, ["html", "activity+json", "ld+json"] + plug :accepts, ["html", "json", "activity+json", "ld+json"] plug :fetch_session plug :fetch_live_flash plug :put_root_layout, html: {NullaWeb.Layouts, :root} - plug :protect_from_forgery - plug :put_secure_browser_headers + # plug :protect_from_forgery + # plug :put_secure_browser_headers end pipeline :api do diff --git a/priv/repo/migrations/20250615130714_create_actors.exs b/priv/repo/migrations/20250615130714_create_actors.exs index 116a120..bb403fd 100644 --- a/priv/repo/migrations/20250615130714_create_actors.exs +++ b/priv/repo/migrations/20250615130714_create_actors.exs @@ -15,7 +15,7 @@ defmodule Nulla.Repo.Migrations.CreateActors do add :featuredTags, :string add :preferredUsername, :string, null: false add :name, :string - add :summary, :string + add :summary, :text add :url, :string add :manuallyApprovesFollowers, :boolean add :discoverable, :boolean, default: true diff --git a/priv/repo/migrations/20250615131856_create_activities.exs b/priv/repo/migrations/20250615131856_create_activities.exs index b07c822..965df8f 100644 --- a/priv/repo/migrations/20250615131856_create_activities.exs +++ b/priv/repo/migrations/20250615131856_create_activities.exs @@ -6,14 +6,14 @@ defmodule Nulla.Repo.Migrations.CreateActivities do add :id, :bigint, primary_key: true add :ap_id, :string, null: false add :type, :string, null: false - add :actor_id, references(:actors, type: :bigint, on_delete: :nothing), null: false - add :object, :map, null: false - add :to, {:array, :string}, default: [] + add :actor, :string, null: false + add :object, :text, null: false timestamps() end + create index(:activities, [:ap_id]) create index(:activities, [:type]) - create index(:activities, [:actor_id]) + create index(:activities, [:actor]) end end