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
# 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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,51 +1,97 @@
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(
{: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", remote_actor_json["id"])
|> Map.put("ap_id", ap_id)
|> Map.delete("id")
|> Map.put("domain", URI.parse(remote_actor_json["id"]).host)
),
follow_activity <-
|> 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: remote_actor.id,
actor: actor_id,
object: target_uri
}),
accept_activity <-
})
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: 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(%{
followed_by: true,
local_actor_id: local_actor.id,
remote_actor_id: remote_actor.id
}) do
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 =
@ -57,24 +103,26 @@ defmodule NullaWeb.InboxController do
DateTime.utc_now()
|> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT")
host = URI.parse(actor_uri).host
request_target = "post /inbox"
uri = URI.parse(inbox_url)
request_target = "post #{uri.path}"
signature_string =
"""
signature_string = """
(request-target): #{request_target}
host: #{host}
host: #{uri.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()
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)
@ -90,17 +138,28 @@ defmodule NullaWeb.InboxController do
|> 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)
else
error ->
IO.inspect(error, label: "Follow error")
json(conn, %{"error" => "Failed to process Follow"})
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

View file

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

View file

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

View file

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