From 4ac3919c8f836c3a90de9b432de19814d1d85d40 Mon Sep 17 00:00:00 2001 From: moon Date: Mon, 2 Dec 2024 06:03:06 -0500 Subject: [PATCH] first commit --- .formatter.exs | 4 + .gitignore | 30 ++ README.md | 8 + config/config.exs | 12 + config/dev.exs | 10 + config/prod.exs | 10 + config/test.exs | 10 + lib/balls_pds.ex | 18 + lib/balls_pds/application.ex | 20 + lib/balls_pds/ecto/schema/agent.ex | 44 ++ .../ecto/schema/collection_object.ex | 162 +++++++ lib/balls_pds/ecto/schema/object.ex | 88 ++++ .../ecto/schema/object_read_agent.ex | 128 ++++++ lib/balls_pds/jwt.ex | 70 +++ .../plug/object_authorization_plug.ex | 50 +++ lib/balls_pds/plug/object_plug.ex | 38 ++ lib/balls_pds/plug/wac_authentication_plug.ex | 54 +++ lib/balls_pds/repo.ex | 3 + lib/balls_pds/router.ex | 420 ++++++++++++++++++ lib/balls_pds/util/acl.ex | 136 ++++++ lib/balls_pds/util/base58.ex | 37 ++ lib/balls_pds/wac.ex | 170 +++++++ mix.exs | 34 ++ mix.lock | 34 ++ .../migrations/20241119124303_initial.exs | 53 +++ test/balls_pds_test.exs | 8 + test/test_helper.exs | 1 + 27 files changed, 1652 insertions(+) create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/config.exs create mode 100644 config/dev.exs create mode 100644 config/prod.exs create mode 100644 config/test.exs create mode 100644 lib/balls_pds.ex create mode 100644 lib/balls_pds/application.ex create mode 100644 lib/balls_pds/ecto/schema/agent.ex create mode 100644 lib/balls_pds/ecto/schema/collection_object.ex create mode 100644 lib/balls_pds/ecto/schema/object.ex create mode 100644 lib/balls_pds/ecto/schema/object_read_agent.ex create mode 100644 lib/balls_pds/jwt.ex create mode 100644 lib/balls_pds/plug/object_authorization_plug.ex create mode 100644 lib/balls_pds/plug/object_plug.ex create mode 100644 lib/balls_pds/plug/wac_authentication_plug.ex create mode 100644 lib/balls_pds/repo.ex create mode 100644 lib/balls_pds/router.ex create mode 100644 lib/balls_pds/util/acl.ex create mode 100644 lib/balls_pds/util/base58.ex create mode 100644 lib/balls_pds/wac.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 priv/repo/migrations/20241119124303_initial.exs create mode 100644 test/balls_pds_test.exs create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b5a733 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +balls_pds-*.tar + +# Temporary files, for example, from tests. +/tmp/ + +*.sqlite3 + +config/*.secret.exs diff --git a/README.md b/README.md new file mode 100644 index 0000000..a207374 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# BallsPDS + +**TODO: Add description** + +## Installation + + + diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..a34552b --- /dev/null +++ b/config/config.exs @@ -0,0 +1,12 @@ +import Config + +config :balls_pds, + ecto_repos: [BallsPDS.Repo] + +# config :balls_pds, +# Should be a did:web, did:key or activitypub ID URL +# owner_ap_id: "", +# Hexadecimal private ED25519 key +# owner_private_key: "000102" + +import_config "#{Mix.env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..cdf6ae7 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,10 @@ +import Config + +config :balls_pds, BallsPDS.Repo, database: "db.dev.sqlite3" + +config :balls_pds, + objects_dir: "./priv/dev_objects" + +if File.exists?("./config/dev.secret.exs") do + import_config "dev.secret.exs" +end diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..25f32c5 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,10 @@ +import Config + +config :balls_pds, BallsPDS.Repo, database: "./priv/db.prod.sqlite3" + +config :balls_pds, + objects_dir: "./priv/prod_objects" + +if File.exists?("./config/prod.secret.exs") do + import_config "prod.secret.exs" +end diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..096fe49 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,10 @@ +import Config + +config :balls_pds, BallsPDS.Repo, database: "./priv/db.test.sqlite3" + +config :balls_pds, + objects_dir: "./priv/test_objects" + +if File.exists?("./config/test.secret.exs") do + import_config "test.secret.exs" +end diff --git a/lib/balls_pds.ex b/lib/balls_pds.ex new file mode 100644 index 0000000..9cabdcd --- /dev/null +++ b/lib/balls_pds.ex @@ -0,0 +1,18 @@ +defmodule BallsPDS do + @moduledoc """ + Documentation for `BallsPDS`. + """ + + @doc """ + Hello world. + + ## Examples + + iex> BallsPDS.hello() + :world + + """ + def hello do + :world + end +end diff --git a/lib/balls_pds/application.ex b/lib/balls_pds/application.ex new file mode 100644 index 0000000..cbb2449 --- /dev/null +++ b/lib/balls_pds/application.ex @@ -0,0 +1,20 @@ +defmodule BallsPDS.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + {Cachex, [:balls_cache]}, + BallsPDS.Repo + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: BallsPds.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/lib/balls_pds/ecto/schema/agent.ex b/lib/balls_pds/ecto/schema/agent.ex new file mode 100644 index 0000000..d7b3a3a --- /dev/null +++ b/lib/balls_pds/ecto/schema/agent.ex @@ -0,0 +1,44 @@ +defmodule BallsPDS.Ecto.Schema.Agent do + use Ecto.Schema + import Ecto.Changeset + import BallsPDS.Util.ACL, only: [validate_acl: 2] + import Ecto.Query + alias BallsPDS.Repo + + schema "agents" do + field(:acl, :string) + field(:public_key, :string) + field(:disabled, :boolean, default: false) + + timestamps() + end + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [ + :acl, + :public_key, + :disabled + ]) + |> validate_required([:acl, :disabled]) + |> validate_acl(:acl) + |> validate_inclusion(:disabled, [true, false]) + end + + def get_by_acl(acl) when is_binary(acl) do + query = from a in __MODULE__, + where: a.acl == ^acl + + Repo.one(query) + end + + def get_or_create_by_acl(acl) when is_binary(acl) do + with {:existing, nil} <- {:existing, get_by_acl(acl)}, + {:insert, {:ok, new}} <- {:insert, Repo.insert(%__MODULE__{acl: acl, disabled: false})} do + {:ok, new} + else + {:existing, existing} -> {:ok, existing} + {:insert, {:error, _} = error} -> error + end + end +end diff --git a/lib/balls_pds/ecto/schema/collection_object.ex b/lib/balls_pds/ecto/schema/collection_object.ex new file mode 100644 index 0000000..128f2c7 --- /dev/null +++ b/lib/balls_pds/ecto/schema/collection_object.ex @@ -0,0 +1,162 @@ +defmodule BallsPDS.Ecto.Schema.CollectionObject do + use Ecto.Schema + import Ecto.Query + import Ecto.Changeset + alias BallsPDS.Repo + alias BallsPDS.Ecto.Schema.Object + + schema "collection_objects" do + belongs_to(:collection, BallsPDS.Ecto.Schema.Object, foreign_key: :collection_id) + belongs_to(:object, BallsPDS.Ecto.Schema.Object, foreign_key: :object_id) + field(:remote_id, :string) + field(:order_num, :integer) + + timestamps() + end + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [ + :collection_id, + :remote_id, + :object_id + ]) + |> validate_required([:collection_id]) + |> foreign_key_constraint(:collection_id) + |> foreign_key_constraint(:object_id) + end + + def delete(%Object{id: collection_id}, object_id) when is_integer(object_id) do + from(co in __MODULE__, + where: co.collection_id == ^collection_id, + where: co.object_id == ^object_id + ) + |> Repo.delete_all() + end + + # FIXME: handle if local id passed + def delete(%Object{id: collection_id}, remote_id) when is_binary(remote_id) do + from(co in __MODULE__, + where: co.collection_id == ^collection_id, + where: co.remote_id == ^remote_id + ) + |> Repo.delete_all() + end + + def delete_all(%Object{id: collection_id}) do + from(co in __MODULE__, + where: co.collection_id == ^collection_id + ) + |> Repo.delete_all() + end + + def delete_all(%Object{id: collection_id}, ids) when is_list(ids) do + {object_ids, remote_ids} = ids |> Enum.map(&resolve_id/1) |> Enum.reduce({[], []}, fn + nil, acc -> acc + + object_id, {object_ids, remote_ids} when is_integer(object_id) -> {[object_id | object_ids], remote_ids} + + remote_id, {object_ids, remote_ids} when is_binary(remote_id) -> {object_ids, [remote_id | remote_ids]} + end) + + from(co in __MODULE__, + where: co.collection_id == ^collection_id and (co.object_id in ^object_ids or co.remote_id in ^remote_ids) + ) + |> Repo.delete_all() + end + + def get_ids(%Object{id: object_id}) do + from(co in __MODULE__, + left_join: o in Object, + on: o.id == co.object_id, + where: co.collection_id == ^object_id, + order_by: co.order_num, + select: {o.id, o.path, co.remote_id} + ) + |> Repo.query() + end + + def get_highest_order_num(%Object{id: object_id}) do + from(co in __MODULE__, + where: co.collection_id == ^object_id, + select: max(co.order_num) + ) + |> Repo.one() + |> case do + nil -> 0 + order_num -> order_num + end + end + + def insert(%Object{id: collection_id} = collection_object, object_id, order_num) + when is_integer(object_id) do + next_order_num = + if order_num == nil do + current_highest_order_num = get_highest_order_num(collection_object) + current_highest_order_num + 100 + else + order_num + end + + cs = + changeset(%__MODULE__{}, %{ + collection_id: collection_id, + object_id: object_id, + order_num: next_order_num + }) + + Repo.insert(cs) + end + + def insert(%Object{id: collection_id} = collection_object, remote_id, order_num) + when is_binary(remote_id) do + next_order_num = + if order_num == nil do + current_highest_order_num = get_highest_order_num(collection_object) + current_highest_order_num + 100 + else + order_num + end + + cs = + changeset(%__MODULE__{}, %{ + collection_id: collection_id, + remote_id: remote_id, + order_num: next_order_num + }) + + Repo.insert(cs) + end + + def insert_all(%Object{} = collection_object, ids) when is_list(ids) do + ids = ids |> Enum.map(&resolve_id/1) |> Enum.filter(fn val -> val == nil end) + + current_highest_order_num = get_highest_order_num(collection_object) + next_order_num = current_highest_order_num + 100 + + Enum.reduce(ids, next_order_num, fn id, next_order_num -> + insert(collection_object, id, next_order_num) + next_order_num + 100 + end) + end + + defp resolve_id(ap_id) when is_binary(ap_id) do + owner_id = Application.get_env(:balls_pds, :owner_ap_id) + + case ap_id do + ^owner_id <> suffix -> + with {:uri, %URI{:query => query}} <- {:uri, URI.parse(suffix)}, + {:rel, %{"relativeRef" => relative_ref, "service" => service}} <- + {:rel, URI.decode_query(query)}, + {:service, true} <- + {:service, Application.get_env(:balls_pds, :did_service) == service} do + Object.get_by_path(relative_ref) + else + _ -> nil + end + + remote_id -> + remote_id + end + end +end diff --git a/lib/balls_pds/ecto/schema/object.ex b/lib/balls_pds/ecto/schema/object.ex new file mode 100644 index 0000000..6dc42f4 --- /dev/null +++ b/lib/balls_pds/ecto/schema/object.ex @@ -0,0 +1,88 @@ +defmodule BallsPDS.Ecto.Schema.Object do + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + alias BallsPDS.Ecto.Schema.ObjectReadAgent + alias BallsPDS.Repo + alias BallsPDS.Util.ACL + + schema "objects" do + field(:path, :string) + field(:content_type, :string) + field(:activitypub_type, :string) + field(:storage_key, :string) + field(:public, :boolean, default: false) + field(:total_items, :integer) + + timestamps() + end + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [ + :path, + :content_type, + :activitypub_type, + :storage_key, + :public, + :total_items + ]) + |> validate_required([ + :path, + :public + ]) + |> validate_format(:path, ~r/^[^<>:"\\|\?\*\0]+$/, message: "Invalid characters") + |> validate_format(:path, ~r/^\/$|^\/.*[^\/]$/, + message: "Must have leading slash but no trailing slash" + ) + |> validate_format(:path, ~r/(? validate_format( + :content_type, + ~r/^(application|audio|font|image|message|model|multipart|text|video)\/[\w\-\+\.]+(?:;\s*charset=[\w\-]+)?$/i, + allow_nil: true + ) + |> validate_format(:activitypub_type, ~r/[A-Z][a-zA-Z0-9]*/, allow_nil: true) + |> validate_format(:storage_key, ~r/^[0-9a-f]{64}$/, + allow_nil: true, + message: "Invalid 256-bit hexadecimal string" + ) + |> validate_inclusion(:public, [true, false]) + |> validate_number(:total_items, greater_than: -1, allow_nil: true) + |> unique_constraint(:path) + end + + def get_by_path(path) when is_binary(path) do + query = + from(o in __MODULE__, + where: o.path == ^path + ) + + Repo.one(query) + end + + def get_ap_id(object_id) do + from(o in __MODULE__, + where: o.id == ^object_id, + select: o.path + ) + |> Repo.one() + |> case do + nil -> nil + path -> ACL.make_object_url(path) + end + end + + def get_all_by_paths(paths) when is_list(paths) do + from(o in __MODULE__, + where: o.path in ^paths + ) + |> Repo.all() + end + + def is_authorized_read?(%__MODULE__{public: true}, _), do: true + + def is_authorized_read?(%__MODULE__{}, nil), do: false + + def is_authorized_read?(%__MODULE__{} = object, acl) when is_binary(acl), + do: ObjectReadAgent.is_authorized_read?(object, acl) +end diff --git a/lib/balls_pds/ecto/schema/object_read_agent.ex b/lib/balls_pds/ecto/schema/object_read_agent.ex new file mode 100644 index 0000000..ac0007d --- /dev/null +++ b/lib/balls_pds/ecto/schema/object_read_agent.ex @@ -0,0 +1,128 @@ +defmodule BallsPDS.Ecto.Schema.ObjectReadAgent do + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + import BallsPDS.Util.ACL, only: [is_valid_acl?: 1] + alias BallsPDS.Ecto.Schema.Object + alias BallsPDS.Ecto.Schema.Agent + alias BallsPDS.Repo + + @primary_key false + schema "object_read_agents" do + belongs_to(:object, BallsPDS.Ecto.Schema.Object, foreign_key: :object_id) + belongs_to(:agent, BallsPDS.Ecto.Schema.Agent, foreign_key: :agent_id) + end + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [ + :object_id, + :agent_id + ]) + |> validate_required([ + :object_id, + :agent_id + ]) + |> foreign_key_constraint(:object_id) + |> foreign_key_constraint(:agent_id) + end + + def delete(object_id, agent_id) when is_integer(object_id) and is_integer(agent_id) do + query = + from(ora in __MODULE__, + where: ora.object_id == ^object_id and ora.agent_id == ^agent_id + ) + + Repo.delete_all(query) + end + + def delete(object_id) when is_binary(object_id) do + query = + from(ora in __MODULE__, + where: ora.object_id == ^object_id + ) + + Repo.delete_all(query) + end + + def insert(object_id, agent_id) when is_integer(object_id) and is_integer(agent_id) do + Repo.insert( + %__MODULE__{object_id: object_id, agent_id: agent_id}, + on_conflict: :nothing + ) + end + + def revoke_read(%Object{id: object_id}, acl) when is_binary(acl) do + with {:exists, %Agent{id: agent_id}} <- {:exists, Agent.get_by_acl(acl)}, + {:delete, {:ok, _}} <- {:delete, delete(object_id, agent_id)} do + else + {:exists, nil} -> + {:error, :no_agent} + + {:delete, {:error, error}} -> + {:error, {:delete, error}} + end + end + + # still leaves public. + def revoke_all_read(%Object{id: object_id}) do + delete(object_id) + end + + def authorize_read(%Object{id: object_id}, acl) when is_binary(acl) do + with {:valid_acl, true} <- {:valid_acl, is_valid_acl?(acl)}, + {:agent, {:ok, %Agent{id: agent_id, disabled: false}}} <- + {:agent, Agent.get_or_create_by_acl(acl)}, + {:insert, {:ok, _}} <- {:insert, insert(object_id, agent_id)} do + :ok + else + {:valid_acl, false} -> {:error, :invalid_acl} + {:agent, {:error, _} = error} -> error + {:agent, {:ok, %{disabled: true}}} -> {:error, :disabled_agent} + {:insert, {:error, _} = error} -> error + end + end + + def authorize_read(%Object{} = object, acls = []) do + errors = + Enum.reduce(acls, [], fn + acl, errors when is_binary(acl) -> + case authorize_read(object, acl) do + :ok -> errors + {:error, error} -> [{acl, error} | errors] + end + + acl, errors -> + [{acl, :invalid_acl} | errors] + end) + + if length(errors == 0) do + :ok + else + {:error, errors} + end + end + + def is_authorized_read?(%Object{id: object_id}, acl) when is_binary(acl) do + query = + from(oa in __MODULE__, + join: a in Agent, + on: oa.agent_id == a.id, + where: a.acl == ^acl and oa.object_id == ^object_id and a.disabled == false + ) + + Repo.exists?(query) + end + + def get_read_agents(%Object{id: object_id}) do + query = + from(oa in __MODULE__, + join: a in Agent, + on: oa.agent_id == a.id, + where: oa.object_id == ^object_id and a.disabled == false, + select: a.acl + ) + + Repo.all(query) + end +end diff --git a/lib/balls_pds/jwt.ex b/lib/balls_pds/jwt.ex new file mode 100644 index 0000000..86d0a78 --- /dev/null +++ b/lib/balls_pds/jwt.ex @@ -0,0 +1,70 @@ +defmodule BallsPDS.JWT do + alias BallsPDS.WAC + require Logger + + def generate_my_jwk() do + raw_private_key = Base.decode16!(Application.get_env(:balls_pds, :owner_private_key)) + generate_jwk(raw_private_key) + end + + def generate_jwk(raw_private_key) when is_binary(raw_private_key) do + private_key = raw_private_key |> Base.encode64() + {:ok, public_key} = :crypto.generate_key(:eddsa, :ed25519, private_key) + JOSE.JWK.from_key({:okp, :Ed25519, public_key, private_key}) + end + + def query_public_jwk(ap_id, key_id) do + case WAC.cached_query_public_key(ap_id, key_id) do + {:ok, raw_public_key} -> + encoded_key = Base.url_encode64(raw_public_key, padding: false) + + {:ok, + %{ + # Key Type: Octet Key Pair + "kty" => "OKP", + # Curve: Ed25519 + "crv" => "Ed25519", + # Public Key (Base64Url-encoded) + "x" => encoded_key + }} + end + end + + def extract_key_info(jwt) when is_binary(jwt) do + with {:subject, {:ok, %{"sub" => subject}}} <- {:subject, JOSE.JWT.peek_payload(jwt)}, + {:kid, {:ok, %{"kid" => kid}}} <- {:kid, JOSE.JWT.peek_protected(jwt)} do + {:ok, %{subject: subject, id: kid}} + else + {err, {:error, error}} -> + Logger.error("extracting key info from JWT: #{err}: #{inspect(error)}") + {:error, error} + end + end + + def generate_jwt(days \\ 30) when is_integer(days) and days > 0 do + jwk = generate_my_jwk() + signer = Joken.Signer.create("EdDSA", jwk) + + id = Application.get_env(:balls_pds, :owner_ap_id) + + claims = %{ + "iss" => id, + "sub" => id, + "aud" => Application.get_env(:balls_pds, :owner_ap_id), + "iat" => DateTime.utc_now() |> DateTime.to_unix(), + "exp" => DateTime.utc_now() |> DateTime.add(30, :day) |> DateTime.to_unix() + } + + Joken.generate_and_sign!(claims, signer) + end + + def verify_jwt(jwt, jwk) do + public_jwk = Map.drop(jwk, ["d"]) + signer = Joken.Signer.create("EdDSA", public_jwk) + + case Joken.verify_and_validate(signer, jwt) do + {:ok, claims} -> {:ok, claims} + {:error, reason} -> {:error, reason} + end + end +end diff --git a/lib/balls_pds/plug/object_authorization_plug.ex b/lib/balls_pds/plug/object_authorization_plug.ex new file mode 100644 index 0000000..da86a14 --- /dev/null +++ b/lib/balls_pds/plug/object_authorization_plug.ex @@ -0,0 +1,50 @@ +defmodule BallsPDS.Plug.ObjectAuthorizationPlug do + require Logger + import Plug.Conn + import BallsPDS.Plug.ObjectPlug, only: [is_acl_get?: 1] + alias BallsPDS.Ecto.Schema.Object + + def init(opts), do: opts + + def call(%Plug.Conn{:method => "GET"} = conn, _opts) do + with {:acl, false} <- {:acl, is_acl_get?(conn)}, + {:object, object = %Object{}} <- {:object, conn.assigns[:object]}, + {:subject, subject} <- {:subject, conn.assigns[:subject]}, + {:authorized, true} <- {:authorized, Object.is_authorized_read?(object, subject)} do + conn + else + {:acl, true} -> + conn + + {:object, nil} -> + send_resp(conn, 404, "Not found") |> halt() + + {:authorized, false} -> + conn + |> put_resp_header("www-authenticate", "Bearer") + |> send_resp(401, "Get out") + |> halt() + end + end + + def call(%Plug.Conn{:method => "POST"} = conn, _opts) do + with {:object, %Object{}} <- {:object, conn.assigns[:object]}, + {:subject, subject} when not is_nil(subject) <- {:subject, conn.assigns[:subject]}, + {:authorized, true} <- + {:authorized, subject === Application.get_env(:balls_pds, :owner_ap_id)} do + conn + else + {:object, nil} -> + Logger.error("Missing object in connection.") + conn |> send_resp(500, "Error") |> halt() + + {:subject, nil} -> + Logger.error("Missing subject in connection.") + conn |> send_resp(401, "Get out") |> halt() + end + end + + def call(conn, _opts) do + conn |> send_resp(405, "Get out") |> halt() + end +end diff --git a/lib/balls_pds/plug/object_plug.ex b/lib/balls_pds/plug/object_plug.ex new file mode 100644 index 0000000..2fcd3b5 --- /dev/null +++ b/lib/balls_pds/plug/object_plug.ex @@ -0,0 +1,38 @@ +defmodule BallsPDS.Plug.ObjectPlug do + import Plug.Conn + alias BallsPDS.Ecto.Schema.Object + + def init(opts), do: opts + + def call(%Plug.Conn{:method => "GET", :request_path => request_path} = conn, _opts) do + with {:acl, false} <- {:acl, is_acl_get?(conn)}, + {:object, %Object{} = object} <- {:object, Object.get_by_path(request_path)} do + conn |> assign(:object, object) + else + {:acl, true} -> + conn + + {:object, nil} -> + send_resp(conn, 404, "Not found") |> halt() + end + end + + def call(%Plug.Conn{:method => "POST", :request_path => request_path} = conn, _opts) do + with {:object, %Object{} = object} <- + {:object, Object.get_by_path(request_path)} do + conn |> assign(:object, object) |> assign(:object_params, %{}) + else + {:object, nil} -> + conn |> assign(:object, %Object{}) |> assign(:object_params, %{path: request_path}) + end + end + + def call(conn, _opts) do + conn |> send_resp(405, "Get out") |> halt() + end + + def is_acl_get?(%Plug.Conn{:method => "GET", :request_path => request_path}), + do: String.ends_with?(request_path, ".acl.jsonld") + + def is_acl_get?(_), do: false +end diff --git a/lib/balls_pds/plug/wac_authentication_plug.ex b/lib/balls_pds/plug/wac_authentication_plug.ex new file mode 100644 index 0000000..fd45d38 --- /dev/null +++ b/lib/balls_pds/plug/wac_authentication_plug.ex @@ -0,0 +1,54 @@ +defmodule BallsPDS.Plug.WACAuthenticationPlug do + import Plug.Conn + alias BallsPDS.JWT + alias BallsPDS.WAC + + def init(opts), do: opts + + def call(conn, _opts) do + with {:subject, nil} <- {:subject, conn.assigns[:subject]}, + {:info, {:ok, subject}} <- {:info, verify_auth_info(conn)} do + conn + |> assign(:subject, subject) + else + {:subject, _} -> + # Already set somewhere, ignore + conn + end + end + + defp verify_auth_info(conn) do + with {:extract, {:ok, jwt}} when not is_nil(jwt) <- {:extract, extract_jwt(conn)}, + {:jwk, {:ok, jwk}} <- {:jwk, get_jwk(jwt)}, + {:verify, {:ok, %{"sub" => subject}}} <- {:verify, JWT.verify_jwt(jwt, jwk)} do + {:ok, subject} + else + {:extract, {:ok, nil}} -> + # No auth provided, okay for public stuff. + {:ok, nil} + + {:verify, {:error, _} = error} -> + error + end + end + + defp get_jwk(jwt) do + with {:peek, {:ok, %{:subject => subject, :id => key_id}}} <- + {:peek, JWT.extract_key_info(jwt)} do + if subject == Application.get_env(:balls_pds, :owner_ap_id) do + {:ok, JWT.generate_my_jwk()} + else + WAC.cached_query_public_key(subject, key_id) + end + end + end + + defp extract_jwt(conn) do + with ["Bearer " <> jwt] <- get_req_header(conn, "authorization") do + {:ok, jwt} + else + [] -> {:ok, nil} + unexpected_result -> {:error, unexpected_result} + end + end +end diff --git a/lib/balls_pds/repo.ex b/lib/balls_pds/repo.ex new file mode 100644 index 0000000..33423cc --- /dev/null +++ b/lib/balls_pds/repo.ex @@ -0,0 +1,3 @@ +defmodule BallsPDS.Repo do + use Ecto.Repo, otp_app: :balls_pds, adapter: Ecto.Adapters.SQLite3 +end diff --git a/lib/balls_pds/router.ex b/lib/balls_pds/router.ex new file mode 100644 index 0000000..8ff0cf2 --- /dev/null +++ b/lib/balls_pds/router.ex @@ -0,0 +1,420 @@ +defmodule BallsPDS.Router do + require Logger + use Plug.Router + alias BallsPDS.Ecto.Schema.Object + alias BallsPDS.Ecto.Schema.ObjectReadAgent + alias BallsPDS.Util.ACL + alias BallsPDS.Plug.WACAuthenticationPlug + alias BallsPDS.Plug.ObjectPlug + alias BallsPDS.Plug.ObjectAuthorizationPlug + alias BallsPDS.Repo + alias BallsPDS.Ecto.Schema.CollectionObject + import BallsPDS.Plug.ObjectPlug, only: [is_acl_get?: 1] + + plug(:match) + plug(:dispatch) + + plug(Plug.Parsers, + parsers: [:multipart], + pass: ["*/*"], + length: 100_000_000 + ) + + plug(WACAuthenticationPlug) + plug(ObjectPlug) + plug(ObjectAuthorizationPlug) + + match _, via: :get do + if is_acl_get?(conn) do + handle_acl_get(conn) + else + handle_authorized_get(conn) + end + end + + match _, via: :post do + handle_authorized_post(conn) + end + + match _ do + send_resp(conn, 405, "Method not allowed") + end + + def handle_acl_get(conn) do + object_path = String.replace_suffix(conn.request_path, ".acl.jsonld", "") + + case Object.get_by_path(object_path) do + nil -> + send_resp(conn, 404, "Not found") + + object -> + authorized_readers = ObjectReadAgent.get_read_agents(object) + document = ACL.render_acl_document(object_path, authorized_readers) + + conn + |> put_resp_content_type("application/ld+json") + |> send_resp(200, Jason.encode!(document)) + end + end + + def handle_authorized_get(conn) do + object = + %{ + :content_type => content_type, + :storage_key => storage_key, + :activitypub_type => type + } = conn.assigns[:object] + + case File.read("./priv/data/#{storage_key}") do + {:ok, file_contents} -> + is_collection? = type in ["Collection", "OrderedCollection"] + + file_contents = + if is_collection? do + # Inject back in the collection items. + + collection = Jason.decode!(file_contents) + + # TODO: not sure how to handle every weird way a collection might be represented not just list of ids. + ids = + CollectionObject.get_ids(object) + |> Enum.map(fn {_, ap_id} -> + ap_id + end) + + items_name = + if type == "OrderedCollection" do + "orderedItems" + else + "items" + end + + Map.put(collection, items_name, ids) |> Map.put("totalItems", length(ids)) + + Jason.encode(collection) + else + file_contents + end + + conn + |> put_resp_content_type(content_type) + |> send_resp(200, file_contents) + + {:error, _error} -> + send_resp(conn, 500, "Unknown error") + end + end + + defp extract_collection_info(%{"type" => "OrderedCollection", "orderedItems" => items}) + when is_list(items) do + ids = + Enum.reduce(items, [], fn item, acc -> + case dereference_id(item) do + nil -> acc + id -> [id | acc] + end + end) + |> Enum.reverse() + + {"OrderedCollection", ids} + end + + defp extract_collection_info(%{"type" => "Collection", "items" => items}) when is_list(items) do + ids = + Enum.reduce(items, [], fn item, acc -> + case dereference_id(item) do + nil -> acc + id -> [id | acc] + end + end) + |> Enum.reverse() + + {"Collection", ids} + end + + defp extract_collection_info(_), do: {nil, nil} + + defp dereference_id(object) when is_binary(object), do: object + defp dereference_id(%{"id" => id}) when is_binary(id), do: id + defp dereference_id(_), do: nil + + def handle_authorized_post(%Plug.Conn{:halted => false, :method => "POST"} = conn) do + conn + |> maybe_set_public() + |> maybe_set_content_type() + |> validate_read_acls() + |> maybe_write_object() + |> maybe_save_db() + |> maybe_set_read_acls() + |> maybe_save_collection_items() + |> send_resp(200, "OK") + |> halt() + end + + defp maybe_set_public(%Plug.Conn{:halted => false} = conn) do + with {:object, object} when not is_nil(object) <- {:object, conn.assigns[:object]}, + object_params <- conn.assigns[:object_params] || %{}, + {:public, %{"public" => public?}} when public? in ["true", "false"] <- + {:public, conn.body_params}, + public? <- public? == "true" do + Map.put(object_params, :public, public?) + Plug.Conn.assign(conn, :object_params, object_params) + else + {:object, nil} -> conn + {:public, _} -> send_resp(conn, 500, "Set public error") |> halt() + end + end + + defp maybe_set_public(%Plug.Conn{} = conn), do: conn + + defp maybe_set_content_type(%Plug.Conn{:halted => false} = conn) do + with {:object, object} when not is_nil(object) <- {:object, conn.assigns[:object]}, + object_params <- conn.assigns[:object_params] || %{} do + object_params = + case conn.body_params do + %{"file" => %{"content_type" => content_type}} -> + Map.put(object_params, :content_type, content_type) + + %{"content_type" => content_type} when is_binary(content_type) -> + Map.put(object_params, :content_type, content_type) + + _ -> + object_params + end + + Plug.Conn.assign(conn, :object_params, object_params) + else + {:object, nil} -> conn + end + end + + defp maybe_set_content_type(%Plug.Conn{} = conn), do: conn + + defp validate_read_acls(%Plug.Conn{:halted => false} = conn) do + read = + case Map.get(conn.body_params, "read") do + val when is_binary(val) -> [val] + val when is_list(val) -> val + val -> val + end + + case read do + nil -> + conn + + read when is_list(read) -> + all_valid? = + Enum.reduce_while(read, true, fn + acl, _ -> + if ACL.is_valid_acl?(acl) do + {:cont, true} + else + {:halt, false} + end + end) + + if all_valid? do + conn + else + send_resp(conn, 500, "Validate read error: invalid acl") |> halt() + end + + _ -> + send_resp(conn, 500, "Validate read error") |> halt() + end + end + + defp validate_read_acls(%Plug.Conn{} = conn), do: conn + + # This can't be called until object is already inserted. Assume already validated. + defp maybe_set_read_acls(%Plug.Conn{:halted => false} = conn) do + read = + case Map.get(conn.body_params, "read") do + val when is_binary(val) -> [val] + val when is_list(val) -> val + end + + with {:object, %Object{} = object} <- {:object, conn.assigns[:object]}, + {:reset_read, _} <- {:reset_read, ObjectReadAgent.revoke_all_read(object)}, + {:update_read, :ok} <- {:update_read, ObjectReadAgent.authorize_read(object, read)} do + conn + else + {:object, nil} -> + conn + + {:update_read, error} -> + Logger.error("Failed to authorized read: #{inspect(error)}") + send_resp(conn, 500, "Set read error") |> halt() + end + end + + defp maybe_set_read_acls(%Plug.Conn{} = conn), do: conn + + defp maybe_write_object( + %Plug.Conn{ + :halted => false, + :request_path => request_path, + :body_params => %{ + "file" => %{"path" => uploaded_file_path, "content_type" => content_type} + } + } = conn + ) do + with {:read, {:ok, body}} <- {:read, File.read(uploaded_file_path)}, + {:parse, + {:ok, + %{:body => body, :activitypub_type => activitypub_type, :ids => ap_collection_ids}}} <- + {:parse, maybe_parse_ap_object(body, content_type)}, + storage_key <- request_path |> Base.encode16(case: :lower), + filename <- "./priv/data/#{storage_key}", + {:write, :ok} <- {:write, File.write(filename, body)}, + object_params <- conn.assigns[:object_params], + object_params <- Map.put(object_params, :storage_key, storage_key), + conn <- Plug.Conn.assign(conn, :object_params, object_params) do + conn = + if activitypub_type == nil do + conn + else + object_params = Map.put(object_params, :activitypub_type, activitypub_type) + Plug.Conn.assign(conn, :object_params, object_params) + end + + conn = + if is_list(ap_collection_ids) and length(ap_collection_ids) > 0 do + conn |> assign(:ids, ap_collection_ids) + else + conn + end + + conn + else + {:read, error} -> + Logger.error("Failed to read uploaded file: #{inspect(error)}") + send_resp(conn, 500, "Error") |> halt() + + {:write, error} -> + Logger.error("Failed to write uploaded file to location: #{inspect(error)}") + send_resp(conn, 500, "Error") |> halt() + end + end + + defp maybe_write_object(%Plug.Conn{} = conn), do: conn + + defp maybe_parse_ap_object(body, content_type) + when is_binary(body) and is_binary(content_type) do + if String.starts_with?(content_type, "application/json") do + with {:parse, + {:ok, %{"@context" => _, "type" => activitypub_type, "id" => activitypub_id} = parsed}} + when is_binary(activitypub_id) and is_binary(activitypub_type) <- + {:parse, Jason.decode(body)}, + {:collection, {_, ids}} <- {:collection, extract_collection_info(parsed)} do + parsed = + if activitypub_type in ["Collection", "OrderedCollection"] do + parsed |> Map.drop(["items", "orderedItems"]) + else + parsed + end + + {:ok, + %{ + body: Jason.encode(parsed), + activitypub_type: activitypub_type, + ids: ids + }} + else + {:parse, {:ok, _parsed}} -> + {:ok, + %{ + body: body, + activitypub_type: nil, + ids: nil + }} + + {:parse, {:error, error}} -> + # Should I really save broken JSON? + {:error, error} + end + else + {:ok, + %{ + body: body, + activitypub_type: nil, + ids: nil + }} + end + end + + defp maybe_save_db(%Plug.Conn{:halted => false} = conn) do + with {:object, %Object{} = object} <- {:object, conn.assigns[:object]}, + {:params, %{} = object_params} when map_size(object_params) > 0 <- + {:params, conn.assigns[:object_params]}, + {:changeset, {:ok, changeset}} <- {:changeset, Object.changeset(object, object_params)}, + {:upsert, {:ok, object}} <- + {:upsert, Repo.insert(changeset, on_conflict: :replace_all)} do + conn |> assign(:object, object) |> assign(:object_params, %{}) + else + {:object, nil} -> + Logger.error("This should not happen, object was nil in save to db.") + send_resp(conn, 500, "Error") |> halt() + + {:params, _} -> + conn + + {:changeset, {:error, error}} -> + Logger.error("Changeset on save to db failed for some reason: #{inspect(error)}") + send_resp(conn, 500, "Error") |> halt() + + {:upsert, {:error, error}} -> + Logger.error("Upsert failed for some reason: #{inspect(error)}") + send_resp(conn, 500, "Error") |> halt() + end + end + + defp maybe_save_db(%Plug.Conn{} = conn), do: conn + + defp maybe_save_collection_items( + %Plug.Conn{:halted => false, :assigns => %{:ids => ids, :object => object}} = conn + ) do + CollectionObject.delete_all(object) + CollectionObject.insert_all(object, wrap(ids)) + conn + end + + defp maybe_save_collection_items( + %Plug.Conn{ + :halted => false, + :assigns => %{:object => object}, + :body_params => %{"ids" => ids} + } = conn + ) do + CollectionObject.delete_all(object) + CollectionObject.insert_all(object, wrap(ids)) + conn + end + + defp maybe_save_collection_items( + %Plug.Conn{ + :halted => false, + :assigns => %{:object => object}, + :body_params => %{"append_ids" => ids} + } = conn + ) do + CollectionObject.insert_all(object, wrap(ids)) + conn + end + + defp maybe_save_collection_items( + %Plug.Conn{ + :halted => false, + :assigns => %{:object => object}, + :body_params => %{"delete_ids" => ids} + } = conn + ) do + CollectionObject.delete_all(object, wrap(ids)) + conn + end + + defp maybe_save_collection_items(%Plug.Conn{} = conn), do: conn + + defp wrap(id) when is_binary(id), do: [id] + defp wrap(ids) when is_list(ids), do: ids +end diff --git a/lib/balls_pds/util/acl.ex b/lib/balls_pds/util/acl.ex new file mode 100644 index 0000000..503ec07 --- /dev/null +++ b/lib/balls_pds/util/acl.ex @@ -0,0 +1,136 @@ +defmodule BallsPDS.Util.ACL do + import Ecto.Changeset + alias BallsPDS.Util.Base58 + + @web_did_regex ~r/^did:web:(?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:%3A(?\d+))?(?:(?::|\/)+(?[^:\/][^\/]*(?:[:\/][^\/]+)*))?$/i + + def parse_web_did("did:web:" <> _ = did) do + case Regex.named_captures(@web_did_regex, did) do + nil -> + {:error, :invalid} + + %{ + "domain" => domain, + "port" => port, + "path" => colon_separated_path + } -> + {:ok, + %{ + domain: domain, + port: port, + path: colon_separated_path |> String.replace(":", "/") + }} + end + end + + def is_did_key_field?(field, "did:key:" <> key) do + case Base58.decode(key) do + {:ok, <<0xED, _key::binary-size(32)>>} -> + [] + + {:ok, <<0xED>> <> _} -> + [{field, "ACL ED25519 did:key invalid length"}] + + {:ok, _} -> + [{field, "ACL did:key not ED25519"}] + + {:error, _error} -> + [{field, "Invalid base58 for did:key ACL"}] + end + end + + def is_did?("did:web:" <> _ = did), do: Regex.match?(@web_did_regex, did) + def is_did?("did:key:" <> _ = did), do: is_did_key_field?(nil, did) == [] + + def is_valid_url?(url) when is_binary(url) do + with true <- String.match?(url, ~r/^https?:\/\//i), + %URI{scheme: scheme, host: host} when is_binary(host) and scheme in ["https", "http"] <- + URI.parse(url) do + true + else + _ -> false + end + end + + def validate_acl(changeset, field) do + validate_change(changeset, field, fn + _, "did:web:" <> _ = did -> + if Regex.match?(@web_did_regex, did) do + [] + else + [{field, "ACL invalid did:web"}] + end + + _, "did:key:" <> key -> + is_did_key_field?(field, key) + + # I just don't care if you send me a URL with all-caps protocol. + + _, "https://" <> _ = url -> + case URI.parse(url) do + %URI{scheme: "https", host: host} when is_binary(host) -> [] + _ -> [{field, "Invalid URL ACL"}] + end + + _, "http://" <> _ -> + [{field, "HTTP URL ACL"}] + + _, _ -> + [{field, "Unrecognized ACL type"}] + end) + end + + def is_valid_acl?(acl) when is_binary(acl), do: is_did?(acl) || is_valid_url?(acl) + def is_valid_acl?(_), do: false + + def render_acl_document(path, read_acls) when is_binary(path) and is_list(read_acls) do + payload = %{ + "@context" => %{ + "acl" => "http://www.w3.org/ns/auth/acl#", + "foaf" => "http://xmlns.com/foaf/0.1/" + } + } + + graph = Enum.with_index(read_acls) |> Enum.map(fn {acl, i} -> + make_read_authorization(path, acl, "#read_acl_#{i}") + end) + + owner_read = %{ + "@id" => "#owner-read", + "@type" => "acl:Authorization", + "acl:mode" => %{"@id" => "acl:Read"}, + "acl:agent" => %{"@id" => Application.get_env(:balls_pds, :owner_ap_id)}, + "acl:accessTo" => %{"@id" => make_object_url(path)} + } + + owner_write = %{ + "@id" => "#owner-write", + "@type" => "acl:Authorization", + "acl:mode" => %{"@id" => "acl:Write"}, + "acl:agent" => %{"@id" => Application.get_env(:balls_pds, :owner_ap_id)}, + "acl:accessTo" => %{"@id" => make_object_url(path)} + } + + graph = [owner_read | [owner_write | graph]] + + Map.put(payload, "@graph", graph) + end + + def make_object_url(path = "/" <> _) do + did = Application.get_env(:balls_pds, :owner_ap_id) + service = Application.get_env(:balls_pds, :did_service) + path = URI.encode(path, &URI.char_unreserved?/1) + + "#{did}?service=#{service}&relativeRef=#{path}" + end + + defp make_read_authorization(path, acl, id) + when is_binary(path) and is_binary(acl) and is_binary(id), + do: %{ + "@id" => id, + "@type" => "acl:Authorization", + "acl:mode" => %{"@id" => "acl:Read"}, + "acl:agent" => %{"@id" => acl}, + "acl:accessTo" => %{"@id" => make_object_url(path)} + } +end diff --git a/lib/balls_pds/util/base58.ex b/lib/balls_pds/util/base58.ex new file mode 100644 index 0000000..fbf9537 --- /dev/null +++ b/lib/balls_pds/util/base58.ex @@ -0,0 +1,37 @@ +defmodule BallsPDS.Util.Base58 do + # Rewrite of https://github.com/dwyl/base58/ to make it more idiomatic and + # some error checking. + + @alnum ~c(123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz) + + def decode(""), do: {:ok, ""} + def decode("\0"), do: {:ok, ""} + + def decode(binary) when is_binary(binary) do + {zeroes, binary} = handle_leading_zeroes(binary) + + case decode(binary, 0) do + {:ok, out} -> {:ok, zeroes <> out} + {:error, _} = error -> error + end + end + + def decode("", acc) when is_integer(acc), do: {:ok, :binary.encode_unsigned(acc)} + def decode("", 0), do: {:ok, ""} + + def decode(<>, acc) when is_integer(acc) do + index = Enum.find_index(@alnum, &(&1 == head)) + + case index do + nil -> {:error, :invalid_character} + index -> decode(tail, acc * 58 + index) + end + end + + defp handle_leading_zeroes(binary) when is_binary(binary) do + orig_len = String.length(binary) + binary = String.trim_leading(binary, <>) + new_len = String.length(binary) + {String.duplicate(<<0>>, orig_len - new_len), binary} + end +end diff --git a/lib/balls_pds/wac.ex b/lib/balls_pds/wac.ex new file mode 100644 index 0000000..27759f3 --- /dev/null +++ b/lib/balls_pds/wac.ex @@ -0,0 +1,170 @@ +defmodule BallsPDS.WAC do + alias BallsPDS.Util.ACL + alias BallsPDS.Util.Base58 + + # TODO: use key id in JWT to resolve the right key + + defp get_did_document_url(domain, port, path) when is_binary(domain) do + port = + if port == nil do + 443 + else + {parsed, _} = Integer.parse(port) + parsed + end + + path = + if path == nil || path == "/" do + "/.well-known/did.json" + else + path <> "/did.json" + end + + URI.parse("https://#{domain}:#{port}#{path}") + end + + def cached_query_public_key("did:web:" <> _ = did, id) do + cache_key = "KEY:#{did}" + + case Cachex.get(:balls_cache, cache_key) do + key when is_binary(key) -> + {:ok, key} + + nil -> + case query_public_key(did, id) do + {:ok, raw_key} -> + Cachex.put(:balls_cache, cache_key, expire: 60_000) + {:ok, raw_key} + + error -> + error + end + end + end + + def cached_query_public_key(url, id) when is_binary(url) do + cache_key = "KEY:#{url}" + + case Cachex.get(:balls_cache, cache_key) do + key when is_binary(key) -> + {:ok, key} + + nil -> + case query_public_key(url, id) do + {:ok, raw_key} -> + Cachex.put(:balls_cache, cache_key, expire: 60_000) + {:ok, raw_key} + + error -> + error + end + end + end + + def cached_query_public_key("did:key:" <> _ = did, _id), do: query_public_key(did, nil) + + def query_public_key("did:web:" <> _ = did, id) do + with {:parse, {:ok, %{:domain => domain, :port => port, :path => path}}} <- + {:parse, ACL.parse_web_did(did)}, + {:url, url} <- {:url, get_did_document_url(domain, port, path)}, + {:query, {:ok, %{"verificationMethod" => keys}}} <- {:query, query_did_document(url)}, + {:extract, {:ok, raw_public_key}} <- {:extract, extract_key(did, keys, id)} do + {:ok, raw_public_key} + else + {:parse, {:error, _} = error} -> error + {:query, {:error, _} = error} -> error + end + end + + def query_public_key("did:key:" <> multikey, _id) do + case Base58.decode(multikey) do + {:ok, <<0xED, raw_key::binary-size(32)>>} -> {:ok, raw_key} + {:ok, _} -> {:error, :invalid_key} + {:error, _} = error -> error + end + end + + def query_public_key(url, id) when is_binary(url) do + with {:valid_url, true} <- {:valid_url, ACL.is_valid_url?(url)}, + {:query, {:ok, %{"assertionMethod" => keys}}} <- query_activitypub_actor(url), + {:key, {:ok, raw_key}} <- {:key, extract_key(url, keys, id)} do + {:ok, raw_key} + else + {:valid_url, false} -> {:error, :invalid_acl} + {:query, {:error, _} = error} -> error + end + end + + # This can fail to match if the actor doesn't have an EC key, which is super common case. + defp query_activitypub_actor(url = %URI{}) do + with {:ok, + %{ + :status => 200, + :body => %{ + "@context" => _, + "id" => "https://" <> _, + "outbox" => _, + "inbox" => _, + "assertionMethod" => _keys + } + } = actor} <- Req.get(url) do + {:ok, actor} + else + {:ok, %{:status => http_error}} when http_error != 200 -> + {:error, http_error} + + {:error, _} = error -> + error + end + end + + defp query_did_document(url = %URI{}) do + with {:ok, %{:status => 200, :body => %{"verificationMethod" => _}} = did_document} <- + Req.get(url) do + {:ok, did_document} + else + {:ok, %{:status => http_error}} -> + {:error, http_error} + + {:ok, %{:body => body}} when is_binary(body) -> + case Jason.decode(body) do + {:ok, did_document = %{"verificationMethod" => _}} -> {:ok, did_document} + {:ok, %{}} -> {:error, :invalid_document} + {:error, _} = error -> error + end + + {:ok, %{:body => _body}} -> + {:error, :invalid_document} + end + end + + defp extract_key(controller, key = %{}, id) when is_binary(controller) and (is_binary(id) or is_nil(id)), + do: extract_key(controller, [key], id) + + defp extract_key(controller, keys, id) when is_list(keys) and is_binary(controller) and (is_binary(id) or is_nil(id)) do + Enum.reduce_while(keys, nil, fn + %{"controller" => ^controller, "publicKeyMultibase" => multikey, "id" => key_id}, _ + when is_binary(multikey) and is_binary(key_id) -> + if match_id?(key_id, id) do + status = + case Base58.decode(multikey) do + {:ok, <<0xED, raw_key::binary-size(32)>>} -> {:ok, raw_key} + _ -> {:error, :invalid_key} + end + + {:halt, status} + else + {:cont, nil} + end + + _, _ -> + {:cont, nil} + end) + end + + # Match the entire key id or else check if it's just a fragment. + defp match_id?(key_id, test_id) when is_binary(key_id) and is_binary(test_id), + do: key_id == test_id || String.ends_with?(key_id, "#" <> test_id) + # If no test id is passed then match whatever was passed. + defp match_id?(key_id, nil) when is_binary(key_id), do: true +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..bc05bcf --- /dev/null +++ b/mix.exs @@ -0,0 +1,34 @@ +defmodule BallsPds.MixProject do + use Mix.Project + + def project do + [ + app: :balls_pds, + version: "0.0.9", + elixir: "~> 1.17", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger], + mod: {BallsPDS.Application, []} + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:ecto_sqlite3, "~> 0.17"}, + {:bandit, "~> 1.0"}, + {:req, "~> 0.5.0"}, + {:jason, "~> 1.4"}, + {:joken, "~> 2.6"}, + {:jose, "~> 1.11"}, + {:cachex, "~> 4.0"} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..69563d8 --- /dev/null +++ b/mix.lock @@ -0,0 +1,34 @@ +%{ + "b58": {:hex, :b58, "1.0.3", "d300d6ae5a3de956a54b9e8220e924e4fee1a349de983df2340fe61e0e464202", [:mix], [], "hexpm", "af62a98a8661fd89978cf3a3a4b5b2ebe82209de6ac6164f0b112e36af72fc59"}, + "bandit": {:hex, :bandit, "1.6.0", "9cb6c67c27cecab2d0c93968cb957fa8decccb7275193c8bf33f97397b3ac25d", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "fd2491e564a7c5e11ff8496ebf530c342c742452c59de17ac0fb1f814a0ab01a"}, + "cachex": {:hex, :cachex, "4.0.2", "120f9c27b0a453c7cb3319d9dc6c61c050a480e5299fc1f8bded1e2e334992ab", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:ex_hash_ring, "~> 6.0", [hex: :ex_hash_ring, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "4f4890122bddd979f6c217d5e300d0c0d3eb858a976cbe1f65a94e6322bc5825"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"}, + "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, + "decimal": {:hex, :decimal, "2.2.0", "df3d06bb9517e302b1bd265c1e7f16cda51547ad9d99892049340841f3e15836", [:mix], [], "hexpm", "af8daf87384b51b7e611fb1a1f2c4d4876b65ef968fa8bd3adf44cff401c7f21"}, + "ecto": {:hex, :ecto, "3.12.4", "267c94d9f2969e6acc4dd5e3e3af5b05cdae89a4d549925f3008b2b7eb0b93c3", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ef04e4101688a67d061e1b10d7bc1fbf00d1d13c17eef08b71d070ff9188f747"}, + "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, + "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.17.4", "48dd9c6d0fc10875a64545d04f0478b142898b6f0e73ae969becf5726f834d22", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "f67372e0eae5e5cbdd1d145e78e670fc5064d5810adf99d104d364cb920e306a"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, + "ex_hash_ring": {:hex, :ex_hash_ring, "6.0.4", "bef9d2d796afbbe25ab5b5a7ed746e06b99c76604f558113c273466d52fa6d6b", [:mix], [], "hexpm", "89adabf31f7d3dfaa36802ce598ce918e9b5b33bae8909ac1a4d052e1e567d18"}, + "exbase58": {:hex, :exbase58, "1.0.2", "2caa5df4d769b5c555cde11b85e93199037ed8b41f1da23e812619c10e3a3424", [:mix], [], "hexpm", "fe6b6b465750bdc1bd01c7b33b265902dabd63061f7db24e663509b45b4bba3c"}, + "exqlite": {:hex, :exqlite, "0.27.0", "2ef6021862e74c6253d1fb1f5701bd47e4e779b035d34daf2a13ec83945a05ba", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "b947b9db15bb7aad11da6cd18a0d8b78f7fcce89508a27a5b9be18350fe12c59"}, + "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, + "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, + "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, + "jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"}, + "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, + "req": {:hex, :req, "0.5.7", "b722680e03d531a2947282adff474362a48a02aa54b131196fbf7acaff5e4cee", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "c6035374615120a8923e8089d0c21a3496cf9eda2d287b806081b8f323ceee29"}, + "sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "thousand_island": {:hex, :thousand_island, "1.3.6", "835a626a8a6f6a1e681b63e1132a8427e87ce443aaf4888fbf63b2df77539b97", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0ed8798084c8c49a223840b20598b022e4eb8c9f390fb6701864c307fc9aa2cd"}, + "unsafe": {:hex, :unsafe, "1.0.2", "23c6be12f6c1605364801f4b47007c0c159497d0446ad378b5cf05f1855c0581", [:mix], [], "hexpm", "b485231683c3ab01a9cd44cb4a79f152c6f3bb87358439c6f68791b85c2df675"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, +} diff --git a/priv/repo/migrations/20241119124303_initial.exs b/priv/repo/migrations/20241119124303_initial.exs new file mode 100644 index 0000000..b4a3788 --- /dev/null +++ b/priv/repo/migrations/20241119124303_initial.exs @@ -0,0 +1,53 @@ +defmodule BallsPDS.Repo.Migrations.Initial do + use Ecto.Migration + + def change do + create table(:agents) do + add :acl, :text, null: false + add :public_key, :text, null: false + add :disabled, :boolean, default: false, null: false + + timestamps() + end + + create unique_index(:agents, [:acl]) + + create table(:objects) do + # Fake path that always resembles a slash-separated, slash-prepended filesystem path. + add :path, :text, null: false + add :content_type, :text, null: false + # Mainly used to identify collections. + add :activitypub_type, :text + # In practice, filename on filesystem. Null for collections. + add :storage_key, :text + # Query shortcut for public objects to avoid join. + add :public, :boolean, default: false, null: false + + # Only used for collections to avoid counting. + add :total_items, :integer + + timestamps() + end + + create unique_index(:objects, [:path]) + create index(:objects, [:activitypub_type]) + + # Who can read an object (only the owner can write right now.) + create table(:object_read_agents, primary_key: false) do + add :object_id, references(:objects), null: false + add :agent_id, references(:agents), null: false + end + + create unique_index(:object_read_agents, [:object_id, :agent_id]) + + create table(:collection_objects) do + add :collection_id, references(:objects), null: false + add :object_id, references(:objects) + add :remote_id, :string + # Results are sorted in ascending order on this arbitrary value. + add :order_num, :integer + end + + create unique_index(:collection_objects, [:collection_id, :remote_id]) + end +end diff --git a/test/balls_pds_test.exs b/test/balls_pds_test.exs new file mode 100644 index 0000000..1c21206 --- /dev/null +++ b/test/balls_pds_test.exs @@ -0,0 +1,8 @@ +defmodule BallsPdsTest do + use ExUnit.Case + doctest BallsPds + + test "greets the world" do + assert BallsPds.hello() == :world + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()