This commit is contained in:
Mirai Kumiko 2025-06-23 09:16:27 +00:00
parent aac9bcb6e4
commit 3f329cf59e
Signed by: miraikumiko
GPG key ID: 3F178B1B5E0CB278
13 changed files with 191 additions and 119 deletions

View file

@ -16,10 +16,14 @@ config :nulla, Nulla.Repo,
# The watchers configuration can be used to run external # The watchers configuration can be used to run external
# watchers to your application. For example, we can use it # watchers to your application. For example, we can use it
# to bundle .js and .css sources. # 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, config :nulla, NullaWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines. # Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow 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, check_origin: false,
code_reloader: true, code_reloader: true,
debug_errors: true, debug_errors: true,

View file

@ -51,7 +51,12 @@ defmodule Nulla.ActivityPub do
indexable: actor.indexable, indexable: actor.indexable,
published: DateTime.to_iso8601(actor.published), published: DateTime.to_iso8601(actor.published),
memorial: actor.memorial, 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, tag: actor.tag,
attachment: actor.attachment, attachment: actor.attachment,
endpoints: actor.endpoints, endpoints: actor.endpoints,

View file

@ -1,18 +1,23 @@
defmodule Nulla.KeyGen do defmodule Nulla.KeyGen do
def gen 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 private_pem =
public_key = {:RSAPublicKey, n, e} :public_key.pem_encode([entry])
|> String.trim_trailing()
|> Kernel.<>("\n")
private_entry = [private_key_code] = :public_key.pem_decode(private_pem)
{:PrivateKeyInfo, :public_key.der_encode(:RSAPrivateKey, rsa_key), :not_encrypted} 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 = public_pem =
{:SubjectPublicKeyInfo, :public_key.der_encode(:RSAPublicKey, public_key), :not_encrypted} :public_key.pem_encode([public_key])
|> String.trim_trailing()
private_pem = :public_key.pem_encode([private_entry]) |> Kernel.<>("\n")
public_pem = :public_key.pem_encode([public_entry])
{public_pem, private_pem} {public_pem, private_pem}
end end

View file

@ -4,13 +4,13 @@ defmodule Nulla.Models.Activity do
alias Nulla.Repo alias Nulla.Repo
alias Nulla.Snowflake alias Nulla.Snowflake
@derive {Jason.Encoder, only: [:ap_id, :type, :actor, :object]}
@primary_key {:id, :integer, autogenerate: false} @primary_key {:id, :integer, autogenerate: false}
schema "activities" do schema "activities" do
field :ap_id, :string field :ap_id, :string
field :type, :string field :type, :string
field :actor, :string field :actor, :string
field :object, :map field :object, :string
field :cc, {:array, :string}, default: []
timestamps() timestamps()
end end
@ -18,7 +18,7 @@ defmodule Nulla.Models.Activity do
@doc false @doc false
def changeset(activity, attrs) do def changeset(activity, attrs) do
activity activity
|> cast(attrs, [:ap_id, :type, :actor, :object, :to]) |> cast(attrs, [:ap_id, :type, :actor, :object])
|> validate_required([: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)) |> validate_inclusion(:type, ~w(Create Update Delete Undo Like Announce Follow Accept Reject))
end end

View file

@ -78,15 +78,8 @@ defmodule Nulla.Models.Actor do
:followers, :followers,
:inbox, :inbox,
:outbox, :outbox,
:featured,
:featuredTags,
:preferredUsername, :preferredUsername,
:url, :url,
:manuallyApprovesFollowers,
:discoverable,
:indexable,
:published,
:memorial,
:publicKey, :publicKey,
:endpoints :endpoints
]) ])

View file

@ -48,7 +48,7 @@ defmodule Nulla.Models.Relation do
:local_actor_id, :local_actor_id,
:remote_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]) |> unique_constraint([:local_actor_id, :remote_actor_id])
end end
@ -61,6 +61,10 @@ defmodule Nulla.Models.Relation do
|> Repo.insert() |> Repo.insert()
end 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 def count_following(local_actor_id) do
__MODULE__ __MODULE__
|> where([r], r.local_actor_id == ^local_actor_id and r.following == true) |> where([r], r.local_actor_id == ^local_actor_id and r.following == true)

View file

@ -1,4 +1,6 @@
defmodule Nulla.Utils do defmodule Nulla.Utils do
alias Finch
def fetch_remote_actor(uri) do def fetch_remote_actor(uri) do
headers = [ headers = [
{"Accept", "application/activity+json"}, {"Accept", "application/activity+json"},
@ -8,7 +10,7 @@ defmodule Nulla.Utils do
request = Finch.build(:get, uri, headers) 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}} -> {:ok, %Finch.Response{status: 200, body: body}} ->
case Jason.decode(body) do case Jason.decode(body) do
{:ok, data} -> {:ok, data} {:ok, data} -> {:ok, data}

View file

@ -17,7 +17,7 @@ defmodule NullaWeb.ActorController do
|> json(%{error: "Not Found"}) |> json(%{error: "Not Found"})
%Actor{} = actor -> %Actor{} = actor ->
if accept in ["application/activity+json", "application/ld+json"] do if accept =~ "json" do
conn conn
|> put_resp_content_type("application/activity+json") |> put_resp_content_type("application/activity+json")
|> send_resp(200, Jason.encode!(ActivityPub.actor(actor))) |> send_resp(200, Jason.encode!(ActivityPub.actor(actor)))

View file

@ -18,7 +18,7 @@ defmodule NullaWeb.FollowController do
_ -> 1 _ -> 1
end 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 conn
|> put_resp_content_type("application/activity+json") |> put_resp_content_type("application/activity+json")
@ -49,7 +49,7 @@ defmodule NullaWeb.FollowController do
_ -> 1 _ -> 1
end 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 conn
|> put_resp_content_type("application/activity+json") |> put_resp_content_type("application/activity+json")

View file

@ -1,51 +1,97 @@
defmodule NullaWeb.InboxController do defmodule NullaWeb.InboxController do
use NullaWeb, :controller use NullaWeb, :controller
alias Nulla.HTTPSignature
alias Nulla.ActivityPub alias Nulla.ActivityPub
alias Nulla.Snowflake alias Nulla.Snowflake
alias Nulla.HTTPSignature
alias Nulla.Utils alias Nulla.Utils
alias Nulla.Models.User alias Nulla.Models.User
alias Nulla.Models.Actor alias Nulla.Models.Actor
alias Nulla.Models.Relation alias Nulla.Models.Relation
alias Nulla.Models.Activity alias Nulla.Models.Activity
def inbox( def inbox(conn, %{
conn, "id" => follow_id,
%{"id" => follow_id, "type" => "Follow", "actor" => actor_uri, "object" => target_uri} "type" => "Follow",
) do "actor" => actor_uri,
"object" => target_uri
}) do
accept_id = Snowflake.next_id() accept_id = Snowflake.next_id()
with local_actor <- Actor.get_actor(ap_id: target_uri), with local_actor <- Actor.get_actor(ap_id: target_uri),
{:ok, remote_actor_json} <- Utils.fetch_remote_actor(actor_uri), {:ok, remote_actor_json} <- Utils.fetch_remote_actor(actor_uri),
:ok <- HTTPSignature.verify(conn, remote_actor_json), :ok <- HTTPSignature.verify(conn, remote_actor_json),
remote_actor <- {:ok, remote_actor} <- get_or_create_actor(remote_actor_json),
Actor.create_actor( {: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 remote_actor_json
|> Map.put("ap_id", remote_actor_json["id"]) |> Map.put("ap_id", ap_id)
|> Map.delete("id") |> Map.delete("id")
|> Map.put("domain", URI.parse(remote_actor_json["id"]).host) |> Map.put("domain", URI.parse(ap_id).host)
),
follow_activity <- 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(%{ Activity.create_activity(%{
ap_id: follow_id, ap_id: follow_id,
type: "Follow", type: "Follow",
actor: remote_actor.id, actor: actor_id,
object: target_uri object: target_uri
}), })
accept_activity <- end
defp create_accept_activity(accept_id, local_actor, follow_activity) do
Activity.create_activity(%{ Activity.create_activity(%{
id: accept_id, id: accept_id,
ap_id: "https://#{local_actor.domain}/activities/accept/#{accept_id}", ap_id: "https://#{local_actor.domain}/activities/accept/#{accept_id}",
type: "Accept", type: "Accept",
actor: local_actor.ap_id, actor: local_actor.ap_id,
object: follow_activity 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(%{ Relation.create_relation(%{
followed_by: true, followed_by: true,
local_actor_id: local_actor.id, local_actor_id: local_actor_id,
remote_actor_id: remote_actor.id remote_actor_id: remote_actor_id
}) do })
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)) body = Jason.encode!(ActivityPub.activity(accept_activity))
digest = digest =
@ -57,24 +103,26 @@ defmodule NullaWeb.InboxController do
DateTime.utc_now() DateTime.utc_now()
|> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT") |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT")
host = URI.parse(actor_uri).host uri = URI.parse(inbox_url)
request_target = "post /inbox" request_target = "post #{uri.path}"
signature_string = signature_string = """
"""
(request-target): #{request_target} (request-target): #{request_target}
host: #{host} host: #{uri.host}
date: #{date} date: #{date}
digest: #{digest} digest: #{digest}
""" """
user = User.get_user(id: local_actor.id) user = User.get_user(id: local_actor.id)
privateKeyPem = user.privateKey["privateKeyPem"]
private_key = private_key =
:public_key.pem_decode(privateKeyPem) case :public_key.pem_decode(user.privateKeyPem) do
|> hd() [{_type, der, _rest}] ->
|> :public_key.pem_entry_decode() :public_key.der_decode(:RSAPrivateKey, der)
_ ->
raise "Invalid PEM format"
end
signature = signature =
:public_key.sign(signature_string, :sha256, private_key) :public_key.sign(signature_string, :sha256, private_key)
@ -90,17 +138,28 @@ defmodule NullaWeb.InboxController do
|> String.replace("\n", "") |> String.replace("\n", "")
|> String.trim() |> String.trim()
conn headers = [
|> put_resp_content_type("application/activity+json") {"host", uri.host},
|> put_resp_header("host", host) {"date", date},
|> put_resp_header("date", date) {"digest", digest},
|> put_resp_header("signature", signature_header) {"signature", signature_header},
|> put_resp_header("digest", digest) {"content-type", "application/activity+json"}
|> send_resp(200, body) ]
else
error -> request = Finch.build(:post, inbox_url, headers, body)
IO.inspect(error, label: "Follow error")
json(conn, %{"error" => "Failed to process Follow"}) 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 end
end end

View file

@ -2,12 +2,12 @@ defmodule NullaWeb.Router do
use NullaWeb, :router use NullaWeb, :router
pipeline :browser do pipeline :browser do
plug :accepts, ["html", "activity+json", "ld+json"] plug :accepts, ["html", "json", "activity+json", "ld+json"]
plug :fetch_session plug :fetch_session
plug :fetch_live_flash plug :fetch_live_flash
plug :put_root_layout, html: {NullaWeb.Layouts, :root} plug :put_root_layout, html: {NullaWeb.Layouts, :root}
plug :protect_from_forgery # plug :protect_from_forgery
plug :put_secure_browser_headers # plug :put_secure_browser_headers
end end
pipeline :api do pipeline :api do

View file

@ -15,7 +15,7 @@ defmodule Nulla.Repo.Migrations.CreateActors do
add :featuredTags, :string add :featuredTags, :string
add :preferredUsername, :string, null: false add :preferredUsername, :string, null: false
add :name, :string add :name, :string
add :summary, :string add :summary, :text
add :url, :string add :url, :string
add :manuallyApprovesFollowers, :boolean add :manuallyApprovesFollowers, :boolean
add :discoverable, :boolean, default: true add :discoverable, :boolean, default: true

View file

@ -6,14 +6,14 @@ defmodule Nulla.Repo.Migrations.CreateActivities do
add :id, :bigint, primary_key: true add :id, :bigint, primary_key: true
add :ap_id, :string, null: false add :ap_id, :string, null: false
add :type, :string, null: false add :type, :string, null: false
add :actor_id, references(:actors, type: :bigint, on_delete: :nothing), null: false add :actor, :string, null: false
add :object, :map, null: false add :object, :text, null: false
add :to, {:array, :string}, default: []
timestamps() timestamps()
end end
create index(:activities, [:ap_id])
create index(:activities, [:type]) create index(:activities, [:type])
create index(:activities, [:actor_id]) create index(:activities, [:actor])
end end
end end