Update
This commit is contained in:
parent
aac9bcb6e4
commit
3f329cf59e
13 changed files with 191 additions and 119 deletions
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -78,15 +78,8 @@ defmodule Nulla.Models.Actor do
|
|||
:followers,
|
||||
:inbox,
|
||||
:outbox,
|
||||
:featured,
|
||||
:featuredTags,
|
||||
:preferredUsername,
|
||||
:url,
|
||||
:manuallyApprovesFollowers,
|
||||
:discoverable,
|
||||
:indexable,
|
||||
:published,
|
||||
:memorial,
|
||||
:publicKey,
|
||||
:endpoints
|
||||
])
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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)))
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue