first commit

This commit is contained in:
Moon Man 2024-12-02 06:03:06 -05:00
commit 4ac3919c8f
27 changed files with 1652 additions and 0 deletions

4
.formatter.exs Normal file
View File

@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

30
.gitignore vendored Normal file
View File

@ -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

8
README.md Normal file
View File

@ -0,0 +1,8 @@
# BallsPDS
**TODO: Add description**
## Installation

12
config/config.exs Normal file
View File

@ -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"

10
config/dev.exs Normal file
View File

@ -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

10
config/prod.exs Normal file
View File

@ -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

10
config/test.exs Normal file
View File

@ -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

18
lib/balls_pds.ex Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

70
lib/balls_pds/jwt.ex Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

3
lib/balls_pds/repo.ex Normal file
View File

@ -0,0 +1,3 @@
defmodule BallsPDS.Repo do
use Ecto.Repo, otp_app: :balls_pds, adapter: Ecto.Adapters.SQLite3
end

420
lib/balls_pds/router.ex Normal file
View File

@ -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

136
lib/balls_pds/util/acl.ex Normal file
View File

@ -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

View File

@ -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

170
lib/balls_pds/wac.ex Normal file
View File

@ -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

34
mix.exs Normal file
View File

@ -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

34
mix.lock Normal file
View File

@ -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"},
}

View File

@ -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

8
test/balls_pds_test.exs Normal file
View File

@ -0,0 +1,8 @@
defmodule BallsPdsTest do
use ExUnit.Case
doctest BallsPds
test "greets the world" do
assert BallsPds.hello() == :world
end
end

1
test/test_helper.exs Normal file
View File

@ -0,0 +1 @@
ExUnit.start()