first commit
This commit is contained in:
commit
4ac3919c8f
|
@ -0,0 +1,4 @@
|
|||
# Used by "mix format"
|
||||
[
|
||||
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||
]
|
|
@ -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
|
|
@ -0,0 +1,8 @@
|
|||
# BallsPDS
|
||||
|
||||
**TODO: Add description**
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
|
|
@ -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"
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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/(?<!\/)\/{1}(?!\/)/, message: "Multiple sequential slashes")
|
||||
|> 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
defmodule BallsPDS.Repo do
|
||||
use Ecto.Repo, otp_app: :balls_pds, adapter: Ecto.Adapters.SQLite3
|
||||
end
|
|
@ -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
|
|
@ -0,0 +1,136 @@
|
|||
defmodule BallsPDS.Util.ACL do
|
||||
import Ecto.Changeset
|
||||
alias BallsPDS.Util.Base58
|
||||
|
||||
@web_did_regex ~r/^did:web:(?<domain>(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:%3A(?<port>\d+))?(?:(?::|\/)+(?<path>[^:\/][^\/]*(?:[:\/][^\/]+)*))?$/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
|
|
@ -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(<<head, tail::binary>>, 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, <<List.first(@alnum)>>)
|
||||
new_len = String.length(binary)
|
||||
{String.duplicate(<<0>>, orig_len - new_len), binary}
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -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"},
|
||||
}
|
|
@ -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
|
|
@ -0,0 +1,8 @@
|
|||
defmodule BallsPdsTest do
|
||||
use ExUnit.Case
|
||||
doctest BallsPds
|
||||
|
||||
test "greets the world" do
|
||||
assert BallsPds.hello() == :world
|
||||
end
|
||||
end
|
|
@ -0,0 +1 @@
|
|||
ExUnit.start()
|
Loading…
Reference in New Issue