This commit is contained in:
Mirai Kumiko 2025-06-22 06:34:34 +02:00
parent 124149129e
commit ee68ec870d
Signed by: miraikumiko
GPG key ID: 3F178B1B5E0CB278
9 changed files with 92 additions and 41 deletions

View file

@ -1,5 +1,70 @@
defmodule Nulla.HTTPSignature do defmodule Nulla.HTTPSignature do
def verify(_conn, _actor) do import Plug.Conn
def verify(conn, actor_json) do
with [sig_header] <- get_req_header(conn, "signature"),
signature_map <- parse_signature_header(sig_header),
{:ok, signed_string} <- build_signature_string(signature_map["headers"], conn),
{:ok, public_key_pem} <- extract_public_key(actor_json),
true <- verify_signature(public_key_pem, signed_string, signature_map["signature"]) do
:ok :ok
else
_ -> {:error, :invalid_signature}
end
end
defp parse_signature_header(header) do
header
|> String.split(",")
|> Enum.map(fn pair ->
[k, v] = String.split(pair, "=", parts: 2)
{String.trim(k), String.trim(v, ~s("))}
end)
|> Enum.into(%{})
end
defp build_signature_string(nil, _conn), do: {:error, :missing_headers}
defp build_signature_string(headers_str, conn) do
headers = String.split(headers_str, " ")
result =
Enum.map(headers, fn header ->
line =
case header do
"(request-target)" ->
method = String.downcase(conn.method)
path =
conn.request_path <>
if conn.query_string != "", do: "?" <> conn.query_string, else: ""
"(request-target): #{method} #{path}"
_ ->
value = get_req_header(conn, header) |> List.first()
if value, do: "#{header}: #{value}", else: nil
end
line
end)
|> Enum.reject(&is_nil/1)
|> Enum.join("\n")
{:ok, result}
end
defp extract_public_key(%{"publicKey" => %{"publicKeyPem" => pem}}), do: {:ok, pem}
defp extract_public_key(_), do: {:error, :no_public_key}
defp verify_signature(public_key_pem, signed_string, signature_base64) do
public_key =
:public_key.pem_decode(public_key_pem)
|> hd()
|> :public_key.pem_entry_decode()
signature = Base.decode64!(signature_base64)
:public_key.verify(signed_string, :sha256, signature, public_key)
end end
end end

View file

@ -138,7 +138,7 @@ defmodule Nulla.Models.Actor do
|> Repo.insert() |> Repo.insert()
end end
def get_actor(username, domain) do def get_actor(by) when is_map(by) or is_list(by) do
Repo.get_by(__MODULE__, preferredUsername: username, domain: domain) Repo.get_by(__MODULE__, by)
end end
end end

View file

@ -3,7 +3,6 @@ defmodule Nulla.Models.User do
import Ecto.Changeset import Ecto.Changeset
import Ecto.Query import Ecto.Query
alias Nulla.Repo alias Nulla.Repo
alias Nulla.Models.User
alias Nulla.Models.Session alias Nulla.Models.Session
@primary_key {:id, :integer, autogenerate: false} @primary_key {:id, :integer, autogenerate: false}
@ -44,17 +43,17 @@ defmodule Nulla.Models.User do
end end
def get_user(by) when is_map(by) or is_list(by) do def get_user(by) when is_map(by) or is_list(by) do
Repo.get_by(User, by) Repo.get_by(__MODULE__, by)
end end
def get_total_users_count() do def get_total_users_count() do
Repo.aggregate(from(u in User), :count, :id) Repo.aggregate(from(u in __MODULE__), :count, :id)
end end
def get_active_users_count(days) do def get_active_users_count(days) do
cutoff = DateTime.add(DateTime.utc_now(), -days * 86400, :second) cutoff = DateTime.add(DateTime.utc_now(), -days * 86400, :second)
from(u in User, where: u.last_active_at > ^cutoff) from(u in __MODULE__, where: u.last_active_at > ^cutoff)
|> Repo.aggregate(:count, :id) |> Repo.aggregate(:count, :id)
end end

View file

@ -1,28 +1,12 @@
defmodule Nulla.Utils do defmodule Nulla.Utils do
alias Nulla.Models.Actor
alias Nulla.Models.InstanceSettings
def resolve_local_actor("https://" <> _ = uri) do
case URI.parse(uri).path do
"/@" <> username ->
instance_settings = InstanceSettings.get_instance_settings!()
domain = instance_settings.domain
case Actor.get_actor(username, domain) do
nil -> {:error, :not_found}
user -> user
end
_ ->
{:error, :invalid_actor}
end
end
def fetch_remote_actor(uri) do def fetch_remote_actor(uri) do
request = headers = [
Finch.build(:get, uri, [ {"Accept", "application/activity+json"},
{"Accept", "application/activity+json"} {"User-Agent", "Nulla/1.0"},
]) {"Host", URI.parse(uri).host}
]
request = Finch.build(:get, uri, headers)
case Finch.request(request, Finch) do case Finch.request(request, Finch) do
{:ok, %Finch.Response{status: 200, body: body}} -> {:ok, %Finch.Response{status: 200, body: body}} ->
@ -31,6 +15,9 @@ defmodule Nulla.Utils do
_ -> {:error, :invalid_json} _ -> {:error, :invalid_json}
end end
{:ok, %Finch.Response{status: code}} when code in 300..399 ->
{:error, :redirect_not_followed}
_ -> _ ->
{:error, :actor_fetch_failed} {:error, :actor_fetch_failed}
end end

View file

@ -10,7 +10,7 @@ defmodule NullaWeb.ActorController do
instance_settings = InstanceSettings.get_instance_settings!() instance_settings = InstanceSettings.get_instance_settings!()
domain = instance_settings.domain domain = instance_settings.domain
case Actor.get_actor(username, domain) do case Actor.get_actor(preferredUsername: username, domain: domain) do
nil -> nil ->
conn conn
|> put_status(:not_found) |> put_status(:not_found)

View file

@ -9,7 +9,7 @@ defmodule NullaWeb.FollowController do
instance_settings = InstanceSettings.get_instance_settings!() instance_settings = InstanceSettings.get_instance_settings!()
domain = instance_settings.domain domain = instance_settings.domain
limit = instance_settings.api_limit limit = instance_settings.api_limit
actor = Actor.get_actor(username, domain) actor = Actor.get_actor(preferredUsername: username, domain: domain)
total = Relation.count_following(actor.id) total = Relation.count_following(actor.id)
page = page =
@ -28,7 +28,7 @@ defmodule NullaWeb.FollowController do
def following(conn, %{"username" => username}) do def following(conn, %{"username" => username}) do
instance_settings = InstanceSettings.get_instance_settings!() instance_settings = InstanceSettings.get_instance_settings!()
domain = instance_settings.domain domain = instance_settings.domain
actor = Actor.get_actor(username, domain) actor = Actor.get_actor(preferredUsername: username, domain: domain)
total = Relation.count_following(actor.id) total = Relation.count_following(actor.id)
conn conn
@ -40,7 +40,7 @@ defmodule NullaWeb.FollowController do
instance_settings = InstanceSettings.get_instance_settings!() instance_settings = InstanceSettings.get_instance_settings!()
domain = instance_settings.domain domain = instance_settings.domain
limit = instance_settings.api_limit limit = instance_settings.api_limit
actor = Actor.get_actor(username, domain) actor = Actor.get_actor(preferredUsername: username, domain: domain)
total = Relation.count_followers(actor.id) total = Relation.count_followers(actor.id)
page = page =
@ -59,7 +59,7 @@ defmodule NullaWeb.FollowController do
def followers(conn, %{"username" => username}) do def followers(conn, %{"username" => username}) do
instance_settings = InstanceSettings.get_instance_settings!() instance_settings = InstanceSettings.get_instance_settings!()
domain = instance_settings.domain domain = instance_settings.domain
actor = Actor.get_actor(username, domain) actor = Actor.get_actor(preferredUsername: username, domain: domain)
total = Relation.count_followers(actor.id) total = Relation.count_followers(actor.id)
conn conn

View file

@ -11,7 +11,7 @@ defmodule NullaWeb.InboxController do
conn, conn,
%{"id" => follow_id, "type" => "Follow", "actor" => actor_uri, "object" => target_uri} %{"id" => follow_id, "type" => "Follow", "actor" => actor_uri, "object" => target_uri}
) do ) do
with {:ok, local_actor} <- Utils.resolve_local_actor(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 <- remote_actor <-
@ -31,7 +31,7 @@ defmodule NullaWeb.InboxController do
accept_activity <- accept_activity <-
Activity.create_activity(%{ Activity.create_activity(%{
type: "Accept", type: "Accept",
actor: local_actor.id, actor: local_actor.ap_id,
object: follow_activity object: follow_activity
}), }),
_ <- _ <-

View file

@ -10,7 +10,7 @@ defmodule NullaWeb.OutboxController do
"true" -> "true" ->
instance_settings = InstanceSettings.get_instance_settings!() instance_settings = InstanceSettings.get_instance_settings!()
domain = instance_settings.domain domain = instance_settings.domain
actor = Actor.get_actor(username, domain) actor = Actor.get_actor(preferredUsername: username, domain: domain)
max_id = params["max_id"] && String.to_integer(params["max_id"]) max_id = params["max_id"] && String.to_integer(params["max_id"])
notes = notes =
@ -44,7 +44,7 @@ defmodule NullaWeb.OutboxController do
_ -> _ ->
instance_settings = InstanceSettings.get_instance_settings!() instance_settings = InstanceSettings.get_instance_settings!()
domain = instance_settings.domain domain = instance_settings.domain
actor = Actor.get_actor(username, domain) actor = Actor.get_actor(preferredUsername: username, domain: domain)
total = Note.get_total_notes_count(actor.id) total = Note.get_total_notes_count(actor.id)
conn conn

View file

@ -7,7 +7,7 @@ defmodule NullaWeb.WebfingerController do
def index(conn, %{"resource" => resource}) do def index(conn, %{"resource" => resource}) do
case Regex.run(~r/^acct:(.+)@(.+)$/, resource) do case Regex.run(~r/^acct:(.+)@(.+)$/, resource) do
[_, username, domain] -> [_, username, domain] ->
case Actor.get_actor(username, domain) do case Actor.get_actor(preferredUsername: username, domain: domain) do
nil -> nil ->
conn conn
|> put_resp_content_type("text/plain") |> put_resp_content_type("text/plain")