Compare commits

...

6 Commits

11 changed files with 246 additions and 10 deletions

View File

@ -1,6 +1,9 @@
defmodule Clacks.Activity do
require Logger
use Ecto.Schema
import Ecto.Changeset
alias Clacks.Repo
import Ecto.Query
@type t() :: %__MODULE__{}
@ -10,7 +13,6 @@ defmodule Clacks.Activity do
field :data, :map
field :local, :boolean
field :actor, :string
field :recipients, {:array, :string}, default: []
has_one :object, Clacks.Object, on_delete: :nothing, foreign_key: :id
@ -19,7 +21,52 @@ defmodule Clacks.Activity do
def changeset(%__MODULE__{} = schema, attrs) do
schema
|> cast(attrs, [:data, :local, :actor, :recipients])
|> validate_required([:data, :local, :actor, :recipients])
|> cast(attrs, [:data, :local, :actor])
|> validate_required([:data, :local, :actor])
end
@spec get_by_ap_id(ap_id :: String.t(), force_refetch :: boolean()) :: t() | nil
def get_by_ap_id(ap_id, force_refetch \\ false) do
if force_refetch do
fetch(ap_id)
else
get_cached_by_ap_id(ap_id) || fetch(ap_id)
end
end
@spec get_cached_by_ap_id(ap_id :: String.t()) :: t() | nil
def get_cached_by_ap_id(ap_id) do
Repo.one(from a in __MODULE__, where: fragment("?->>'id'", a.data) == ^ap_id)
end
@spec fetch(ap_id :: String.t()) :: t() | nil
def fetch(ap_id) do
case Clacks.ActivityPub.Fetcher.fetch_activity(ap_id) do
nil ->
nil
data ->
actor = data["actor"] || data["attributedTo"]
existing = get_cached_by_ap_id(data["id"])
changeset =
changeset(existing || %__MODULE__{}, %{
data: data,
local: false,
actor: actor
})
case Repo.insert_or_update(changeset) do
{:ok, activity} ->
_ = Clacks.Actor.get_by_ap_id(actor)
activity
{:error, changeset} ->
Logger.error("Couldn't store remote activity #{ap_id}: #{inspect(changeset)}")
nil
end
end
end
end

View File

@ -2,6 +2,32 @@ defmodule Clacks.ActivityPub do
@context ["https://www.w3.org/ns/activitystreams"]
@public "https://www.w3.org/ns/activitystreams#Public"
@spec actor(
id :: String.t(),
username :: String.t(),
display_name :: String.t(),
pem :: String.t()
) :: map()
def actor(id, username, display_name, pem) do
%{
"@context" => @context,
"type" => "Person",
"id" => id,
"url" => id,
"preferredUsername" => username,
"name" => display_name,
"followers" => Path.join(id, "followers"),
"following" => Path.join(id, "following"),
"inbox" => Path.join(id, "inbox"),
"outbox" => Path.join(id, "outbox"),
"publicKey" => %{
"id" => id <> "#main-key",
"owner" => id,
"publicKeyPem" => pem
}
}
end
@spec note(
actor :: String.t(),
html :: String.t(),
@ -57,6 +83,18 @@ defmodule Clacks.ActivityPub do
}
end
@spec synthesized_create(object :: map()) :: map()
def synthesized_create(object) do
%{
"@context" => @context,
"type" => "Create",
"object" => object,
"actor" => object["actor"] || object["attributedTo"],
"to" => object["to"],
"cc" => object["cc"]
}
end
@spec object_id(id :: String.t()) :: String.t()
def object_id(id) do
url = Application.get_env(:clacks, ClacksWeb.Endpoint)[:url]

View File

@ -28,6 +28,11 @@ defmodule Clacks.ActivityPub.Fetcher do
end
end
@spec fetch_activity(id :: String.t()) :: map() | nil
def fetch_activity(id) do
fetch_object(id)
end
@spec fetch(uri :: String.t()) :: map() | nil
defp fetch(uri) do
Logger.debug("Attempting to fetch AP object at #{uri}")

View File

@ -14,6 +14,7 @@ defmodule Clacks.Actor do
field :nickname, :string
field :local, :boolean
field :data, :map
field :followers, {:array, :string}, default: []
belongs_to :user, Clacks.User
@ -22,12 +23,17 @@ defmodule Clacks.Actor do
def changeset(%__MODULE__{} = schema, attrs) do
schema
|> cast(attrs, [:ap_id, :nickname, :local, :data])
|> cast(attrs, [:ap_id, :nickname, :local, :data, :followers])
|> validate_required([:ap_id, :nickname, :local, :data])
end
@spec get_by_nickanme(nickname :: String.t()) :: t() | nil
def get_by_nickanme(nickname) do
Repo.one(from a in __MODULE__, where: a.nickname == ^nickname)
end
@spec get_by_ap_id(ap_id :: String.t(), force_refetch :: boolean()) :: t() | nil
def get_by_ap_id(ap_id, force_refetch \\ false) when is_binary(ap_id) do
def get_by_ap_id(ap_id, force_refetch \\ false) do
if force_refetch do
fetch(ap_id)
else
@ -36,12 +42,12 @@ defmodule Clacks.Actor do
end
@spec get_cached_by_ap_id(ap_id :: String.t()) :: t() | nil
def get_cached_by_ap_id(ap_id) when is_binary(ap_id) do
def get_cached_by_ap_id(ap_id) do
Repo.one(from a in __MODULE__, where: a.ap_id == ^ap_id)
end
@spec fetch(ap_id :: String.t()) :: t() | nil
def fetch(ap_id) when is_binary(ap_id) do
def fetch(ap_id) do
case Clacks.ActivityPub.Fetcher.fetch_actor(ap_id) do
nil ->
nil

View File

@ -16,4 +16,10 @@ defmodule Clacks.Keys do
{:error, error}
end
end
def public_key_pem({:RSAPublicKey, _, _} = key) do
entry = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, key)
pem = :public_key.pem_encode([entry])
{:ok, pem}
end
end

View File

@ -1,6 +1,9 @@
defmodule Clacks.Object do
require Logger
use Ecto.Schema
import Ecto.Changeset
alias Clacks.Repo
import Ecto.Query
@type t() :: %__MODULE__{}
@ -15,4 +18,63 @@ defmodule Clacks.Object do
|> cast(attrs, [:data])
|> validate_required([:data])
end
@spec get_by_ap_id(
ap_id :: String.t(),
force_refetch :: boolean(),
synthesize_create :: boolean()
) :: t() | nil
def get_by_ap_id(ap_id, force_refetch \\ false, synthesize_create \\ true) do
if force_refetch do
fetch(ap_id, synthesize_create)
else
get_cached_by_ap_id(ap_id) || fetch(ap_id, synthesize_create)
end
end
@spec get_cached_by_ap_id(ap_id :: String.t()) :: t() | nil
def get_cached_by_ap_id(ap_id) do
Repo.one(from o in __MODULE__, where: fragment("?->>'id'", o.data) == ^ap_id)
end
@spec fetch(ap_id :: String.t(), synthesize_create :: boolean()) :: t() | nil
def fetch(ap_id, synthesize_create \\ true) do
case Clacks.ActivityPub.Fetcher.fetch_object(ap_id) do
nil ->
nil
data ->
existing = get_cached_by_ap_id(data["id"])
changeset =
changeset(existing || %__MODULE__{}, %{
data: data
})
case Repo.insert_or_update(changeset) do
{:ok, object} ->
actor = data["actor"] || data["attributedTo"]
_ = Clacks.Actor.get_by_ap_id(actor)
if synthesize_create do
create = Clacks.ActivityPub.synthesized_create(data)
changeset =
Clacks.Activity.changeset(%Clacks.Activity{}, %{
data: create,
local: false,
actor: actor
})
{:ok, _create} = Repo.insert_or_update(changeset)
end
object
{:error, changeset} ->
Logger.error("Couldn't store remote object #{ap_id}: #{inspect(changeset)}")
nil
end
end
end
end

View File

@ -0,0 +1,22 @@
defmodule ClacksWeb.ActorController do
use ClacksWeb, :controller
alias Clacks.{Repo, Actor}
import Ecto.Query
def get(conn, %{"nickname" => nickname}) do
case Actor.get_by_nickanme(nickname) do
%Actor{local: true, data: data} ->
conn
|> put_resp_header("content-type", "application/activity+json")
|> json(data)
%Actor{local: false, ap_id: ap_id} ->
conn
|> redirect(external: ap_id)
_ ->
conn
|> put_status(404)
end
end
end

View File

@ -15,14 +15,13 @@ defmodule ClacksWeb.Router do
scope "/", ClacksWeb do
pipe_through :browser
get "/", PageController, :index
end
scope "/", ClacksWeb do
pipe_through :activitypub
get "/objects/:id", ObjectsController, :get
get "/users/:nickname", ActorController, :get
end
# Other scopes may use custom stacks.

View File

@ -0,0 +1,43 @@
defmodule Mix.Tasks.Clacks.User do
use Mix.Task
alias Clacks.{Repo, User, Actor, Keys, ActivityPub}
@shortdoc "Creates a new user"
def run(["create"]) do
username = IO.gets("Username: ") |> String.trim()
# password = IO.gets("Password: ") |> String.trim()
{:ok, pem} = Keys.generate_rsa_pem()
{:ok, _private, public} = Keys.keys_from_pem(pem)
{:ok, public_key_pem} = Keys.public_key_pem(public)
changeset = User.changeset(%User{}, %{username: username, private_key: pem})
# start the app so the DB connection is established
Mix.Task.run("app.start")
{:ok, user} = Repo.insert(changeset)
url = Application.get_env(:clacks, ClacksWeb.Endpoint)[:url]
uri =
URI.to_string(%URI{
scheme: url[:scheme],
host: url[:host],
port: url[:port],
path: Path.join("/users", username)
})
actor =
Ecto.build_assoc(user, :actor, %{
ap_id: uri,
nickname: username,
local: true,
data: ActivityPub.actor(uri, username, username, public_key_pem)
})
{:ok, actor} = Repo.insert(actor)
IO.puts("User #{username} successfully created")
end
end

View File

@ -8,7 +8,6 @@ defmodule Clacks.Repo.Migrations.CreateActivities do
add :data, :jsonb, null: false
add :local, :boolean
add :actor, :string
add :recipients, {:array, :string}
timestamps()
end

View File

@ -0,0 +1,9 @@
defmodule Clacks.Repo.Migrations.ActorsAddFollowers do
use Ecto.Migration
def change do
alter table(:actors) do
add :followers, {:array, :string}, default: []
end
end
end