Rename repository

This commit is contained in:
Pim Kunis 2021-09-01 14:43:55 +02:00
parent 4aeb2d2cd8
commit 232df26b85
71 changed files with 348 additions and 345 deletions

View file

@ -0,0 +1,29 @@
defmodule Architex.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
def start(_type, _args) do
children = [
Architex.Repo,
ArchitexWeb.Telemetry,
{Phoenix.PubSub, name: Architex.PubSub},
ArchitexWeb.Endpoint,
{Registry, keys: :unique, name: Architex.RoomServer.Registry},
{DynamicSupervisor, name: Architex.RoomServer.Supervisor, strategy: :one_for_one},
Architex.KeyServer,
{Finch, name: ArchitexWeb.HTTPClient}
]
Supervisor.start_link(children, name: Architex.Supervisor, strategy: :one_for_one)
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
def config_change(changed, _new, removed) do
ArchitexWeb.Endpoint.config_change(changed, removed)
:ok
end
end

10
lib/architex/check.ex Normal file
View file

@ -0,0 +1,10 @@
defmodule Architex.Check do
import Ecto.Query
alias Architex.{Repo, Account, Room}
alias ArchitexWeb.Client.Request.CreateRoom
def create_room do
account = Repo.one!(from a in Account, limit: 1)
Room.create(account, %CreateRoom{})
end
end

View file

@ -0,0 +1,34 @@
# https://github.com/michalmuskala/jason/issues/69
defmodule Architex.EncodableMap do
alias Architex.EncodableMap
alias Architex.Types.{UserId, RoomId, EventId, GroupId, AliasId}
defstruct pairs: []
defimpl Jason.Encoder, for: EncodableMap do
def encode(%{pairs: pairs}, opts) do
Jason.Encode.keyword(pairs, opts)
end
end
def from_map(map) do
pairs =
map
|> Enum.map(fn
{k, v}
when is_struct(v, UserId) or is_struct(v, RoomId) or is_struct(v, EventId) or
is_struct(v, GroupId) or is_struct(v, AliasId) ->
# Simply convert IDs to a string.
{k, to_string(v)}
{k, v} when is_map(v) ->
{k, from_map(v)}
x ->
x
end)
|> Enum.sort()
%EncodableMap{pairs: pairs}
end
end

View file

@ -0,0 +1,95 @@
defmodule Architex.KeyServer do
@moduledoc """
A GenServer holding the homeserver's keys, and responsible for signing objects.
Currently, it only supports one key pair that cannot expire.
"""
use GenServer
# TODO: only support one signing key for now.
@signing_key_id "ed25519:1"
## Interface
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Sign the given object using the homeserver's signing keys.
Return the signature and the key ID used.
On error, return `:error`.
"""
@spec sign_object(map()) :: {:ok, String.t(), String.t()} | :error
def sign_object(object) do
GenServer.call(__MODULE__, {:sign_object, object})
end
@doc """
Get the homeserver's signing keys.
Return a list of tuples, each holding the key ID and the key itself.
"""
@spec get_own_signing_keys() :: list({String.t(), binary()})
def get_own_signing_keys() do
GenServer.call(__MODULE__, :get_own_signing_keys)
end
## Implementation
@impl true
def init(_opts) do
{public_key, private_key} = read_keys()
{:ok, %{public_key: public_key, private_key: private_key}}
end
@impl true
def handle_call({:sign_object, object}, _from, %{private_key: private_key} = state) do
case sign_object(object, private_key) do
{:ok, signature} -> {:reply, {:ok, signature, @signing_key_id}, state}
{:error, _reason} -> {:reply, :error, state}
end
end
def handle_call(:get_own_signing_keys, _from, %{public_key: public_key} = state) do
encoded_key = Architex.encode_unpadded_base64(public_key)
{:reply, [{@signing_key_id, encoded_key}], state}
end
# https://blog.swwomm.com/2020/09/elixir-ed25519-signatures-with-enacl.html
@spec sign_object(map(), binary()) :: {:ok, String.t()} | {:error, Jason.EncodeError.t()}
defp sign_object(object, private_key) do
object = Map.drop(object, [:signatures, :unsigned])
with {:ok, json} <- Architex.encode_canonical_json(object) do
signature =
json
|> :enacl.sign_detached(private_key)
|> Architex.encode_unpadded_base64()
{:ok, signature}
end
end
# TODO: not sure if there is a better way to do this...
@spec read_keys() :: {binary(), binary()}
defp read_keys do
raw_priv_key =
Application.get_env(:architex, :private_key_file)
|> File.read!()
"-----BEGIN OPENSSH PRIVATE KEY-----\n" <> rest = raw_priv_key
%{public: public, secret: private} =
String.split(rest, "\n")
|> Enum.take_while(&(&1 != "-----END OPENSSH PRIVATE KEY-----"))
|> Enum.join()
|> Base.decode64!()
|> :enacl.sign_seed_keypair()
{public, private}
end
end

5
lib/architex/repo.ex Normal file
View file

@ -0,0 +1,5 @@
defmodule Architex.Repo do
use Ecto.Repo,
otp_app: :architex,
adapter: Ecto.Adapters.Postgres
end

644
lib/architex/room_server.ex Normal file
View file

@ -0,0 +1,644 @@
defmodule Architex.RoomServer do
@moduledoc """
A GenServer to hold and manipulate the state of a Matrix room.
Each RoomServer corresponds to one Matrix room that the homeserver participates in.
The RoomServers are supervised by a DynamicSupervisor RoomServer.Supervisor.
"""
@typep t :: map()
use GenServer
import Ecto.Query
import Ecto.Changeset
alias Architex.{
Repo,
Room,
Event,
StateResolution,
Account,
JoinedRoom,
Device,
DeviceTransaction
}
alias Architex.StateResolution.Authorization
alias ArchitexWeb.Client.Request.{CreateRoom, Kick, Ban}
@registry Architex.RoomServer.Registry
@supervisor Architex.RoomServer.Supervisor
### Interface
def start_link(opts) do
{name, opts} = Keyword.pop(opts, :name)
GenServer.start_link(__MODULE__, opts, name: name)
end
@doc """
Get the PID of the RoomServer for a room.
If the given room has no running RoomServer yet, it is created.
If the given room does not exist, an error is returned.
"""
@spec get_room_server(String.t()) :: {:error, :not_found} | DynamicSupervisor.on_start_child()
def get_room_server(room_id) do
# TODO: Might be wise to use a transaction here to prevent race conditions.
case Repo.one(from r in Room, where: r.id == ^room_id) do
%Room{state: serialized_state_set} = room ->
case Registry.lookup(@registry, room_id) do
[{pid, _}] ->
{:ok, pid}
[] ->
opts = [
name: {:via, Registry, {@registry, room_id}},
room: room,
serialized_state_set: serialized_state_set
]
DynamicSupervisor.start_child(@supervisor, {__MODULE__, opts})
end
nil ->
{:error, :not_found}
end
end
@doc """
Create a new Matrix room.
The new room is created with the given `account` as creator.
Events are inserted into the new room depending on the input `input` and according
to the [Matrix documentation](https://matrix.org/docs/spec/client_server/r0.6.1#post-matrix-client-r0-createroom).
"""
@spec create_room(pid(), Account.t(), CreateRoom.t()) :: {:ok, String.t()} | {:error, atom()}
def create_room(pid, account, input) do
GenServer.call(pid, {:create_room, account, input})
end
@doc """
Check whether the given server participates in a room.
Check whether any participant of the room has a server name matching
the given `domain`.
"""
@spec server_in_room?(pid(), String.t()) :: boolean()
def server_in_room?(pid, domain) do
GenServer.call(pid, {:server_in_room?, domain})
end
@doc """
Get the state of a room, before the given event was inserted.
Return a list of all state events and the auth chain.
"""
@spec get_state_at_event(pid(), Event.t()) :: {[Event.t()], [Event.t()]}
def get_state_at_event(pid, event) do
GenServer.call(pid, {:get_state_at_event, event})
end
@doc """
Same as `get_state_at_event/2`, except returns the lists as event IDs.
"""
@spec get_state_ids_at_event(pid(), Event.t()) :: {[String.t()], [String.t()]}
def get_state_ids_at_event(pid, event) do
GenServer.call(pid, {:get_state_ids_at_event, event})
end
@doc """
Invite the a user to this room.
"""
@spec invite(pid(), Account.t(), String.t()) :: :ok | {:error, atom()}
def invite(pid, account, user_id) do
GenServer.call(pid, {:invite, account, user_id})
end
@doc """
Join a room.
"""
@spec join(pid(), Account.t()) :: {:ok, String.t()} | {:error, atom()}
def join(pid, account) do
GenServer.call(pid, {:join, account})
end
@doc """
Leave a room.
"""
@spec leave(pid(), Account.t()) :: :ok | {:error, atom()}
def leave(pid, account) do
GenServer.call(pid, {:leave, account})
end
@doc """
Kick a user from this room.
"""
@spec kick(pid(), Account.t(), Kick.t()) :: :ok | {:error, atom()}
def kick(pid, account, request) do
GenServer.call(pid, {:kick, account, request})
end
@doc """
Ban a user from this room.
"""
@spec ban(pid(), Account.t(), Ban.t()) :: :ok | {:error, atom()}
def ban(pid, account, request) do
GenServer.call(pid, {:ban, account, request})
end
@doc """
Unban a user from this room.
"""
@spec unban(pid(), Account.t(), String.t()) :: :ok | {:error, atom()}
def unban(pid, account, user_id) do
GenServer.call(pid, {:unban, account, user_id})
end
@doc """
Attempt to set the room's visibility.
"""
@spec set_visibility(pid(), Account.t(), atom()) :: :ok | {:error, atom()}
def set_visibility(pid, account, visibility) do
GenServer.call(pid, {:set_visibility, account, visibility})
end
@doc """
Send a message to this room.
"""
@spec send_message(pid(), Account.t(), Device.t(), String.t(), map(), String.t()) ::
{:ok, String.t()} | {:error, atom()}
def send_message(pid, account, device, event_type, content, txn_id) do
GenServer.call(pid, {:send_message, account, device, event_type, content, txn_id})
end
### Implementation
@impl true
def init(opts) do
room = Keyword.fetch!(opts, :room)
serialized_state_set = Keyword.fetch!(opts, :serialized_state_set)
state_event_ids = Enum.map(serialized_state_set, fn [_, _, event_id] -> event_id end)
state_set =
Event
|> where([e], e.event_id in ^state_event_ids)
|> Repo.all()
|> Enum.into(%{}, fn %Event{type: type, state_key: state_key} = event ->
{{type, state_key}, event}
end)
{:ok, %{room: room, state_set: state_set}}
end
@impl true
def handle_call(
{:create_room, account, input},
_from,
%{room: %Room{id: room_id} = room} = state
) do
# TODO: power_level_content_override, initial_state, invite, invite_3pid
case Repo.transaction(create_room_insert_events(room, account, input)) do
{:ok, {state_set, room}} ->
{:reply, {:ok, room_id}, %{state | state_set: state_set, room: room}}
{:error, reason} ->
{:reply, {:error, reason}, state}
_ ->
{:reply, {:error, :unknown}, state}
end
end
def handle_call({:server_in_room?, domain}, _from, %{state_set: state_set} = state) do
result =
Enum.any?(state_set, fn
{{"m.room.member", user_id}, %Event{content: %{"membership" => "join"}}} ->
Architex.get_domain(user_id) == domain
_ ->
false
end)
{:reply, result, state}
end
def handle_call({:get_state_at_event, %Event{room_id: room_id} = event}, _from, state) do
room_events =
Event
|> where([e], e.room_id == ^room_id)
|> select([e], {e.event_id, e})
|> Repo.all()
|> Enum.into(%{})
state_set = StateResolution.resolve(event, false)
state_events = Map.values(state_set)
auth_chain =
state_set
|> Map.values()
|> StateResolution.full_auth_chain(room_events)
|> Enum.map(&room_events[&1])
{:reply, {state_events, auth_chain}, state}
end
def handle_call({:get_state_ids_at_event, %Event{room_id: room_id} = event}, _from, state) do
room_events =
Event
|> where([e], e.room_id == ^room_id)
|> select([e], {e.event_id, e})
|> Repo.all()
|> Enum.into(%{})
state_set = StateResolution.resolve(event, false)
state_events = Enum.map(state_set, fn {_, %Event{event_id: event_id}} -> event_id end)
auth_chain =
state_set
|> Map.values()
|> StateResolution.full_auth_chain(room_events)
|> MapSet.to_list()
{:reply, {state_events, auth_chain}, state}
end
def handle_call({:invite, account, user_id}, _from, %{room: room, state_set: state_set} = state) do
invite_event = Event.Invite.new(room, account, user_id)
case Repo.transaction(insert_single_event(room, state_set, invite_event)) do
{:ok, {state_set, room, _}} -> {:reply, :ok, %{state | state_set: state_set, room: room}}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
def handle_call(
{:join, account},
_from,
%{room: %Room{id: room_id} = room, state_set: state_set} = state
) do
join_event = Event.Join.new(room, account)
case Repo.transaction(insert_single_event(room, state_set, join_event)) do
{:ok, {state_set, room, _}} ->
{:reply, {:ok, room_id}, %{state | state_set: state_set, room: room}}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
def handle_call({:leave, account}, _from, %{room: room, state_set: state_set} = state) do
leave_event = Event.Leave.new(room, account)
case Repo.transaction(insert_single_event(room, state_set, leave_event)) do
{:ok, {state_set, room, _}} -> {:reply, :ok, %{state | state_set: state_set, room: room}}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
def handle_call(
{:kick, account, %Kick{user_id: user_id, reason: reason}},
_from,
%{room: room, state_set: state_set} = state
) do
kick_event = Event.Kick.new(room, account, user_id, reason)
case Repo.transaction(insert_single_event(room, state_set, kick_event)) do
{:ok, {state_set, room, _}} -> {:reply, :ok, %{state | state_set: state_set, room: room}}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
def handle_call(
{:ban, account, %Kick{user_id: user_id, reason: reason}},
_from,
%{room: room, state_set: state_set} = state
) do
ban_event = Event.Ban.new(room, account, user_id, reason)
case Repo.transaction(insert_single_event(room, state_set, ban_event)) do
{:ok, {state_set, room, _}} -> {:reply, :ok, %{state | state_set: state_set, room: room}}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
def handle_call({:unban, account, user_id}, _from, %{room: room, state_set: state_set} = state) do
unban_event = Event.Unban.new(room, account, user_id)
case Repo.transaction(insert_single_event(room, state_set, unban_event)) do
{:ok, {state_set, room, _}} -> {:reply, :ok, %{state | state_set: state_set, room: room}}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
def handle_call(
{:set_visibility, account, visibility},
_from,
%{room: room, state_set: state_set} = state
) do
case state_set do
%{{"m.room.create", ""} => %Event{content: %{"creator" => creator}}} ->
if creator == Account.get_mxid(account) do
room = Repo.update!(change(room, visibility: visibility))
{:reply, :ok, %{state | room: room}}
else
{:reply, {:error, :unauthorized}, state}
end
_ ->
{:reply, {:error, :unknown}, state}
end
end
def handle_call(
{:send_message, account, device, event_type, content, txn_id},
_from,
%{room: room, state_set: state_set} = state
) do
message_event = Event.custom_message(room, account, event_type, content)
case Repo.transaction(insert_custom_message(state_set, room, device, message_event, txn_id)) do
{:ok, {state_set, room, event_id}} ->
{:reply, {:ok, event_id}, %{state | state_set: state_set, room: room}}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
defp insert_custom_message(
state_set,
room,
%Device{id: device_id} = device,
message_event,
txn_id
) do
fn ->
# Check if we already executed this transaction.
case Repo.one(
from dt in DeviceTransaction,
where: dt.txn_id == ^txn_id and dt.device_id == ^device_id
) do
%DeviceTransaction{event_id: event_id} ->
{state_set, room, event_id}
nil ->
with {state_set, room, %Event{event_id: event_id}} <-
insert_single_event(room, state_set, message_event).() do
# Mark this transaction as done.
Ecto.build_assoc(device, :device_transactions, txn_id: txn_id, event_id: event_id)
|> Repo.insert!()
{state_set, room, event_id}
end
end
end
end
@spec insert_single_event(Room.t(), t(), Event.t()) ::
(() -> {t(), Room.t(), Event.t()} | {:error, atom()})
defp insert_single_event(room, state_set, event) do
fn ->
case finalize_and_insert_event(event, state_set, room) do
{:ok, state_set, room, event} ->
_ = update_room_state_set(room, state_set)
{state_set, room, event}
{:error, reason} ->
Repo.rollback(reason)
end
end
end
# Get a function that inserts all events for room creation.
@spec create_room_insert_events(Room.t(), Account.t(), CreateRoom.t()) ::
(() -> {:ok, t(), Room.t()} | {:error, atom()})
defp create_room_insert_events(room, account, %CreateRoom{
room_version: room_version,
preset: preset,
name: name,
topic: topic
}) do
events =
([
Event.CreateRoom.new(room, account, room_version),
Event.Join.new(room, account),
Event.PowerLevels.new(room, account)
] ++
room_creation_preset(account, preset, room) ++
[
if(name, do: Event.Name.new(room, account, name)),
if(topic, do: Event.Topic.new(room, account, topic))
])
|> Enum.reject(&Kernel.is_nil/1)
fn ->
result =
Enum.reduce_while(events, {%{}, room}, fn event, {state_set, room} ->
case finalize_and_insert_event(event, state_set, room) do
{:ok, state_set, room, _} -> {:cont, {state_set, room}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
case result do
{:error, reason} ->
Repo.rollback(reason)
{state_set, room} ->
_ = update_room_state_set(room, state_set)
{state_set, room}
end
end
end
# Update the given room in the database with the given state set.
@spec update_room_state_set(Room.t(), t()) :: Room.t()
defp update_room_state_set(room, state_set) do
# TODO: We might as well hold state in the Room struct,
# instead of the state_set state.
# Create custom type for this.
serialized_state_set =
Enum.map(state_set, fn {{type, state_key}, %Event{event_id: event_id}} ->
[type, state_key, event_id]
end)
Repo.update!(change(room, state: serialized_state_set))
end
# Get the events for room creation as dictated by the given preset.
# TODO: trusted_private_chat:
# All invitees are given the same power level as the room creator.
@spec room_creation_preset(Account.t(), String.t() | nil, Room.t()) :: [Event.t()]
defp room_creation_preset(account, nil, %Room{visibility: visibility} = room) do
preset =
case visibility do
:public -> "public_chat"
:private -> "private_chat"
end
room_creation_preset(account, preset, room)
end
defp room_creation_preset(account, preset, room) do
{join_rule, his_vis, guest_access} =
case preset do
"private_chat" -> {"invite", "shared", "can_join"}
"trusted_private_chat" -> {"invite", "shared", "can_join"}
"public_chat" -> {"public", "shared", "forbidden"}
end
[
Event.JoinRules.new(room, account, join_rule),
Event.HistoryVisibility.new(room, account, his_vis),
Event.GuestAccess.new(room, account, guest_access)
]
end
# Finalize the event struct and insert it into the room's state using state resolution.
# The values that are automatically added are:
# - Auth events
# - Prev events
# - Content hash
# - Event ID
# - Signature
@spec finalize_and_insert_event(Event.t(), t(), Room.t()) ::
{:ok, t(), Room.t(), Event.t()} | {:error, atom()}
defp finalize_and_insert_event(
event,
state_set,
%Room{forward_extremities: forward_extremities} = room
) do
event =
event
|> Map.put(:auth_events, auth_events_for_event(event, state_set))
|> Map.put(:prev_events, forward_extremities)
case Event.post_process(event) do
{:ok, event} -> authenticate_and_insert_event(event, state_set, room)
_ -> {:error, :event_creation}
end
end
# Get the auth events for an events.
@spec auth_events_for_event(Event.t(), t()) :: [{String.t(), String.t()}]
defp auth_events_for_event(%Event{type: "m.room.create"}, _), do: []
defp auth_events_for_event(
%Event{sender: sender} = event,
state_set
) do
state_pairs =
[{"m.room.create", ""}, {"m.room.power_levels", ""}, {"m.room.member", to_string(sender)}] ++
auth_events_for_member_event(event)
state_set
|> Map.take(state_pairs)
|> Map.values()
|> Enum.map(fn %Event{event_id: event_id} -> event_id end)
end
# Get the auth events specific to m.room.member events.
@spec auth_events_for_member_event(Event.t()) :: [{String.t(), String.t()}]
defp auth_events_for_member_event(
%Event{
type: "m.room.member",
state_key: state_key,
content: %{"membership" => membership}
} = event
) do
[
{"m.room.member", state_key},
if(membership in ["join", "invite"], do: {"m.room.join_rules", ""}),
third_party_invite_state_pair(event)
]
|> Enum.reject(&Kernel.is_nil/1)
end
defp auth_events_for_member_event(_), do: []
# Get the third party invite state pair for an event, if it exists.
@spec third_party_invite_state_pair(Event.t()) :: {String.t(), String.t()} | nil
defp third_party_invite_state_pair(%Event{
content: %{
"membership" => "invite",
"third_party_invite" => %{"signed" => %{"token" => token}}
}
}) do
{"m.room.third_party_invite", token}
end
defp third_party_invite_state_pair(_), do: nil
# Authenticate and insert a new event using state resolution.
# Implements the checks as described in the
# [Matrix docs](https://matrix.org/docs/spec/server_server/latest#checks-performed-on-receipt-of-a-pdu).
@spec authenticate_and_insert_event(Event.t(), t(), Room.t()) ::
{:ok, t(), Room.t(), Event.t()} | {:error, atom()}
defp authenticate_and_insert_event(event, current_state_set, room) do
# TODO: Correctly handle soft fails.
# Check the following things:
# 1. TODO: Is a valid event, otherwise it is dropped.
# 2. TODO: Passes signature checks, otherwise it is dropped.
# 3. TODO: Passes hash checks, otherwise it is redacted before being processed further.
# 4. Passes authorization rules based on the event's auth events, otherwise it is rejected.
# 5. Passes authorization rules based on the state at the event, otherwise it is rejected.
# 6. Passes authorization rules based on the current state of the room, otherwise it is "soft failed".
with true <- Event.prevalidate(event),
true <- Authorization.authorized_by_auth_events?(event),
state_set <- StateResolution.resolve(event, false),
true <- Authorization.authorized?(event, state_set),
true <- Authorization.authorized?(event, current_state_set) do
room = Room.update_forward_extremities(event, room)
event = Repo.insert!(event)
state_set = StateResolution.resolve_forward_extremities(event)
# TODO: Do this as a background job, and not after every insert...
_ = update_joined_rooms(room, state_set)
{:ok, state_set, room, event}
else
_ -> {:error, :authorization}
end
end
# Update local accounts' room membership if applicable.
@spec update_joined_rooms(Room.t(), t()) :: JoinedRoom.t() | nil
defp update_joined_rooms(%Room{id: room_id}, state_set) do
server_name = Architex.server_name()
{joined, not_joined} =
state_set
|> Enum.filter(fn {{type, state_key}, _} ->
type == "m.room.member" and Architex.get_domain(state_key) == server_name
end)
|> Enum.split_with(fn {_, %Event{content: %{"membership" => membership}}} ->
membership == "join"
end)
map_localparts =
&Enum.map(&1, fn {{_, state_key}, _} -> Architex.get_localpart(state_key) end)
joined_localparts = map_localparts.(joined)
not_joined_localparts = map_localparts.(not_joined)
_ =
Repo.insert_all(
JoinedRoom,
from(a in Account,
where: a.localpart in ^joined_localparts,
select: %{account_id: a.id, room_id: ^room_id}
),
on_conflict: :nothing
)
Repo.delete_all(
from jr in JoinedRoom,
join: a in Account,
on: a.id == jr.account_id,
where: jr.room_id == ^room_id and a.localpart in ^not_joined_localparts
)
end
end

View file

@ -0,0 +1,157 @@
defmodule Architex.Account do
use Ecto.Schema
import Ecto.{Changeset, Query}
alias Architex.{Repo, Account, Device, Room, JoinedRoom}
alias ArchitexWeb.Client.Request.{Register, Login}
alias Ecto.{Multi, Changeset}
@type t :: %__MODULE__{
password_hash: String.t()
}
@max_mxid_length 255
schema "accounts" do
field :localpart, :string
field :password_hash, :string, redact: true
has_many :devices, Device
many_to_many :joined_rooms, Room,
join_through: JoinedRoom,
join_keys: [account_id: :id, room_id: :id]
timestamps(updated_at: false)
end
@doc """
Reports whether the given user localpart is available on this server.
"""
@spec available?(String.t()) :: :ok | {:error, :user_in_use | :invalid_username}
def available?(localpart) when is_binary(localpart) do
if Regex.match?(Architex.localpart_regex(), localpart) and
String.length(localpart) <= localpart_length() do
if Repo.one!(
Account
|> where([a], a.localpart == ^localpart)
|> select([a], count(a))
) == 0 do
:ok
else
{:error, :user_in_use}
end
else
{:error, :invalid_username}
end
end
@doc """
Return an multi to register a new user.
"""
@spec register(Register.t()) :: Multi.t()
def register(%Register{
username: username,
device_id: device_id,
initial_device_display_name: initial_device_display_name,
password: password
}) do
localpart = username || Architex.random_string(10, ?a..?z)
account_params = %{
localpart: localpart,
password_hash: Bcrypt.hash_pwd_salt(password)
}
Multi.new()
|> Multi.insert(:account, changeset(%Account{}, account_params))
|> Multi.insert(:device, fn %{account: account} ->
device_id = device_id || Device.generate_device_id(account.localpart)
access_token = Device.generate_access_token(localpart, device_id)
device_params = %{
display_name: initial_device_display_name,
device_id: device_id
}
Ecto.build_assoc(account, :devices, access_token: access_token)
|> Device.changeset(device_params)
end)
end
@doc """
Return a function to log a user in.
"""
@spec login(Login.t()) :: (Ecto.Repo.t() -> {:error, any()} | {:ok, {Account.t(), Device.t()}})
def login(%Login{password: password, identifier: %Login.Identifier{user: user}} = input) do
localpart = try_get_localpart(user)
fn repo ->
case repo.one(from a in Account, where: a.localpart == ^localpart) do
%Account{password_hash: hash} = account ->
if Bcrypt.verify_pass(password, hash) do
case Device.login(input, account) do
{:ok, device} ->
{account, device}
{:error, _cs} ->
repo.rollback(:forbidden)
end
else
repo.rollback(:forbidden)
end
nil ->
repo.rollback(:forbidden)
end
end
end
@doc """
Get a device and its associated account using the device's access token.
"""
@spec by_access_token(String.t()) :: {Account.t(), Device.t()} | nil
def by_access_token(access_token) do
Device
|> where([d], d.access_token == ^access_token)
|> join(:inner, [d], a in assoc(d, :account))
|> select([d, a], {a, d})
|> Repo.one()
end
@spec changeset(map(), map()) :: Changeset.t()
def changeset(account, params \\ %{}) do
# TODO: fix password_hash in params
account
|> cast(params, [:localpart, :password_hash])
|> validate_required([:localpart, :password_hash])
|> validate_length(:password_hash, max: 60)
|> validate_format(:localpart, Architex.localpart_regex())
|> validate_length(:localpart, max: localpart_length())
|> unique_constraint(:localpart, name: :accounts_localpart_index)
end
@spec localpart_length :: integer()
defp localpart_length do
# Subtract the "@" and ":" in the MXID.
@max_mxid_length - 2 - String.length(Architex.server_name())
end
@spec try_get_localpart(String.t()) :: String.t()
defp try_get_localpart("@" <> rest = user_id) do
case String.split(rest, ":", parts: 2) do
[localpart, _] -> localpart
_ -> user_id
end
end
defp try_get_localpart(localpart), do: localpart
@doc """
Get the matrix user ID of an account.
"""
@spec get_mxid(Account.t()) :: String.t()
def get_mxid(%Account{localpart: localpart}) do
"@" <> localpart <> ":" <> Architex.server_name()
end
end

View file

@ -0,0 +1,28 @@
defmodule Architex.Alias do
use Ecto.Schema
import Ecto.Changeset
alias Architex.{Repo, Alias, Room}
alias Ecto.Changeset
@primary_key {:alias, :string, []}
schema "aliases" do
belongs_to :room, Room, foreign_key: :room_id, references: :id, type: :string
end
def create(alias, room_id) do
change(%Alias{}, alias: alias, room_id: room_id)
|> assoc_constraint(:room)
|> unique_constraint(:alias, name: :aliases_pkey)
|> Repo.insert()
end
def get_error(%Changeset{errors: [error | _]}), do: get_error(error)
def get_error({:alias, {_, [{:constraint, :unique} | _]}}), do: :room_alias_exists
def get_error({:room, {_, [{:constraint, :assoc} | _]}}),
do: {:not_found, "The room was not found."}
def get_error(_), do: :bad_json
end

View file

@ -0,0 +1,69 @@
defmodule Architex.Device do
use Ecto.Schema
import Ecto.{Changeset, Query}
alias Architex.{Account, Device, Repo, DeviceTransaction}
alias ArchitexWeb.Client.Request.Login
@type t :: %__MODULE__{
device_id: String.t(),
access_token: String.t(),
display_name: String.t(),
account_id: integer()
}
schema "devices" do
field :device_id, :string
field :access_token, :string, redact: true
field :display_name, :string
belongs_to :account, Account
has_many :device_transactions, DeviceTransaction
end
def changeset(device, params \\ %{}) do
device
|> cast(params, [:display_name, :device_id])
|> validate_required([:device_id])
|> unique_constraint([:device_id, :account_id], name: :devices_device_id_account_id_index)
end
def generate_access_token(localpart, device_id) do
Phoenix.Token.encrypt(ArchitexWeb.Endpoint, "access_token", {localpart, device_id})
end
def generate_device_id(localpart) do
# TODO: use random string instead
"#{localpart}_#{System.os_time(:millisecond)}"
end
def login(
%Login{device_id: device_id, initial_device_display_name: initial_device_display_name},
%Account{localpart: localpart} = account
) do
device_id = device_id || generate_device_id(localpart)
access_token = generate_access_token(localpart, device_id)
update_query =
from(d in Device)
|> update(set: [access_token: ^access_token, device_id: ^device_id])
|> then(fn q ->
if initial_device_display_name do
update(q, set: [display_name: ^initial_device_display_name])
else
q
end
end)
device_params = %{
device_id: device_id,
display_name: initial_device_display_name
}
Ecto.build_assoc(account, :devices)
|> Device.changeset(device_params)
|> put_change(:access_token, access_token)
|> Repo.insert(on_conflict: update_query, conflict_target: [:account_id, :device_id])
end
end

View file

@ -0,0 +1,18 @@
defmodule Architex.DeviceTransaction do
use Ecto.Schema
alias Architex.Device
@type t :: %__MODULE__{
txn_id: String.t(),
event_id: String.t(),
device_id: integer()
}
@primary_key {:txn_id, :string, []}
schema "device_transactions" do
field :event_id, :string
belongs_to :device, Device
end
end

View file

@ -0,0 +1,330 @@
defmodule Architex.Event do
use Ecto.Schema
import Ecto.Query
alias Architex.{Repo, Room, Event, Account, EncodableMap, KeyServer}
alias Architex.Types.UserId
# TODO: Could refactor to also always set prev_events, but not necessary.
@type t :: %__MODULE__{
type: String.t(),
origin_server_ts: integer(),
state_key: String.t() | nil,
sender: UserId.t(),
content: map(),
prev_events: [String.t()] | nil,
auth_events: [String.t()],
unsigned: map() | nil,
signatures: map() | nil,
hashes: map() | nil
}
@primary_key {:event_id, :string, []}
schema "events" do
field :type, :string
field :origin_server_ts, :integer
field :state_key, :string
field :sender, UserId
field :content, :map
field :prev_events, {:array, :string}
field :auth_events, {:array, :string}
field :unsigned, :map
field :signatures, {:map, {:map, :string}}
field :hashes, {:map, :string}
belongs_to :room, Room, type: :string
end
defimpl Jason.Encoder, for: Event do
@pdu_keys [
:auth_events,
:content,
:depth,
:hashes,
:origin,
:origin_server_ts,
:prev_events,
:redacts,
:room_id,
:sender,
:signatures,
:state_key,
:type,
:unsigned
]
def encode(event, opts) do
event
|> Map.take(@pdu_keys)
|> Map.update!(:sender, &Kernel.to_string/1)
|> Jason.Encode.map(opts)
end
end
@spec new(Room.t(), Account.t()) :: %Event{}
def new(%Room{id: room_id}, %Account{localpart: localpart}) do
%Event{
room_id: room_id,
sender: %UserId{localpart: localpart, domain: Architex.server_name()},
origin_server_ts: DateTime.utc_now() |> DateTime.to_unix(:millisecond),
prev_events: [],
auth_events: []
}
end
@spec custom_message(Room.t(), Account.t(), String.t(), map()) :: t()
def custom_message(room, sender, type, content) do
%Event{
Event.new(room, sender)
| type: type,
content: content
}
end
@spec is_control_event(t()) :: boolean()
def is_control_event(%Event{type: "m.room.power_levels", state_key: ""}), do: true
def is_control_event(%Event{type: "m.room.join_rules", state_key: ""}), do: true
def is_control_event(%Event{
type: "m.room.member",
state_key: state_key,
sender: sender,
content: %{membership: membership}
}) do
to_string(sender) != state_key and membership in ["leave", "ban"]
end
def is_control_event(_), do: false
@spec is_state_event(t()) :: boolean()
def is_state_event(%Event{state_key: state_key}), do: state_key != nil
# Perform validations that can be done before state resolution.
# For example checking the domain of the sender.
# We assume that required keys, as well as in the content, is already validated.
# Rule 1.4 is left to changeset validation.
@spec prevalidate(t()) :: boolean()
def prevalidate(%Event{
type: "m.room.create",
prev_events: prev_events,
auth_events: auth_events,
room_id: room_id,
sender: %UserId{domain: domain}
}) do
# TODO: error check on domains?
# TODO: rule 1.3
# Check rules: 1.1, 1.2
prev_events == [] and
auth_events == [] and
domain == Architex.get_domain(room_id)
end
def prevalidate(%Event{auth_events: auth_event_ids, prev_events: prev_event_ids} = event) do
prev_events =
Event
|> where([e], e.event_id in ^prev_event_ids)
|> Repo.all()
auth_events =
Event
|> where([e], e.event_id in ^auth_event_ids)
|> Repo.all()
state_pairs = Enum.map(auth_events, &{&1.type, &1.state_key})
# Check rules: 2.1, 2.2, 3
length(auth_events) == length(auth_event_ids) and
length(prev_events) == length(prev_event_ids) and
not Architex.has_duplicates?(state_pairs) and
valid_auth_events?(event, auth_events) and
Enum.find_value(state_pairs, false, &(&1 == {"m.room.create", ""})) and
do_prevalidate(event, auth_events, prev_events)
end
# Rule 4.1 is left to changeset validation.
@spec do_prevalidate(t(), [t()], [t()]) :: boolean()
defp do_prevalidate(
%Event{type: "m.room.aliases", sender: %UserId{domain: domain}, state_key: state_key},
_,
_
) do
# Check rule: 4.2
domain == Architex.get_domain(state_key)
end
# Rule 5.1 is left to changeset validation.
# Rules 5.2.3, 5.2.4, 5.2.5 is left to state resolution.
# Check rule: 5.2.1
defp do_prevalidate(
%Event{
type: "m.room.member",
content: %{"membership" => "join"},
sender: %UserId{localpart: localpart, domain: domain}
},
_,
[%Event{type: "m.room.create", state_key: %UserId{localpart: localpart, domain: domain}}]
),
do: true
# Check rule: 5.2.2
defp do_prevalidate(
%Event{
type: "m.room.member",
content: %{"membership" => "join"},
sender: sender,
state_key: state_key
},
_,
_
) do
to_string(sender) == state_key
end
# All other rules will be checked during state resolution.
defp do_prevalidate(_, _, _), do: true
@spec valid_auth_events?(t(), [t()]) :: boolean()
defp valid_auth_events?(
%Event{type: type, sender: sender, state_key: state_key, content: content},
auth_events
) do
sender = to_string(sender)
Enum.all?(auth_events, fn
%Event{type: "m.room.create", state_key: ""} ->
true
%Event{type: "m.room.power_levels", state_key: ""} ->
true
%Event{type: "m.room.member", state_key: ^sender} ->
true
%Event{type: auth_type, state_key: auth_state_key} ->
if type == "m.room.member" do
%{"membership" => membership} = content
(auth_type == "m.room.member" and auth_state_key == state_key) or
(membership in ["join", "invite"] and auth_type == "m.room.join_rules" and
auth_state_key == "") or
(membership == "invite" and auth_type == "m.room.third_party_invite" and
auth_state_key == "")
else
false
end
end)
end
@spec calculate_content_hash(t()) :: {:ok, binary()} | {:error, Jason.EncodeError.t()}
defp calculate_content_hash(event) do
m =
event
|> Architex.to_serializable_map()
|> Map.drop([:unsigned, :signature, :hashes])
|> EncodableMap.from_map()
with {:ok, json} <- Jason.encode(m) do
{:ok, :crypto.hash(:sha256, json)}
end
end
@spec redact(t()) :: map()
defp redact(%Event{type: type, content: content} = event) do
redacted_event =
event
|> Architex.to_serializable_map()
|> Map.take([
:event_id,
:type,
:room_id,
:sender,
:state_key,
:content,
:hashes,
:signatures,
:depth,
:prev_events,
:prev_state,
:auth_events,
:origin,
:origin_server_ts,
:membership
])
%{redacted_event | content: redact_content(type, content)}
end
@spec redact_content(String.t(), map()) :: map()
defp redact_content("m.room.member", content), do: Map.take(content, ["membership"])
defp redact_content("m.room.create", content), do: Map.take(content, ["creator"])
defp redact_content("m.room.join_rules", content), do: Map.take(content, ["join_rule"])
defp redact_content("m.room.aliases", content), do: Map.take(content, ["aliases"])
defp redact_content("m.room.history_visibility", content),
do: Map.take(content, ["history_visibility"])
defp redact_content("m.room.power_levels", content),
do:
Map.take(content, [
"ban",
"events",
"events_default",
"kick",
"redact",
"state_default",
"users",
"users_default"
])
defp redact_content(_, _), do: %{}
# Adds content hash, adds signature and calculates event id.
@spec post_process(t()) :: {:ok, t()} | :error
def post_process(event) do
with {:ok, content_hash} <- calculate_content_hash(event) do
encoded_hash = Architex.encode_unpadded_base64(content_hash)
event = %Event{event | hashes: %{"sha256" => encoded_hash}}
with {:ok, sig, key_id} <- KeyServer.sign_object(redact(event)) do
event = %Event{event | signatures: %{Architex.server_name() => %{key_id => sig}}}
with {:ok, event} <- set_event_id(event) do
{:ok, event}
else
_ -> :error
end
end
else
_ -> :error
end
end
@spec set_event_id(t()) :: {:ok, t()} | {:error, Jason.EncodeError.t()}
def set_event_id(event) do
with {:ok, event_id} <- generate_event_id(event) do
{:ok, %Event{event | event_id: event_id}}
end
end
@spec generate_event_id(t()) :: {:ok, String.t()} | {:error, Jason.EncodeError.t()}
defp generate_event_id(event) do
with {:ok, hash} <- calculate_reference_hash(event) do
{:ok, "$" <> Architex.encode_url_safe_base64(hash)}
end
end
@spec calculate_reference_hash(t()) :: {:ok, binary()} | {:error, Jason.EncodeError.t()}
defp calculate_reference_hash(event) do
redacted_event =
event
|> redact()
|> Map.drop([:unsigned, :signature, :age_ts])
with {:ok, json} <- Architex.encode_canonical_json(redacted_event) do
{:ok, :crypto.hash(:sha256, json)}
end
end
end

View file

@ -0,0 +1,228 @@
defmodule Architex.Event.Join do
alias Architex.{Event, Account, Room}
@spec new(Room.t(), Account.t()) :: Event.t()
def new(room, %Account{localpart: localpart} = sender) do
mxid = Architex.get_mxid(localpart)
%Event{
Event.new(room, sender)
| type: "m.room.member",
state_key: mxid,
content: %{
"membership" => "join"
}
}
end
end
defmodule Architex.Event.CreateRoom do
alias Architex.{Event, Account, Room}
@spec new(Room.t(), Account.t(), String.t()) :: Event.t()
def new(room, %Account{localpart: localpart} = creator, room_version) do
mxid = Architex.get_mxid(localpart)
%Event{
Event.new(room, creator)
| type: "m.room.create",
state_key: "",
content: %{
"creator" => mxid,
"room_version" => room_version || Architex.default_room_version()
}
}
end
end
defmodule Architex.Event.PowerLevels do
alias Architex.{Event, Account, Room}
@spec new(Room.t(), Account.t()) :: Event.t()
def new(room, %Account{localpart: localpart} = sender) do
mxid = Architex.get_mxid(localpart)
%Event{
Event.new(room, sender)
| type: "m.room.power_levels",
state_key: "",
content: %{
"ban" => 50,
"events" => %{},
"events_default" => 0,
"invite" => 50,
"kick" => 50,
"redact" => 50,
"state_default" => 50,
"users" => %{
mxid => 50
},
"users_default" => 0,
"notifications" => %{
"room" => 50
}
}
}
end
end
defmodule Architex.Event.Name do
alias Architex.{Event, Account, Room}
@spec new(Room.t(), Account.t(), String.t()) :: Event.t()
def new(room, sender, name) do
%Event{
Event.new(room, sender)
| type: "m.room.name",
state_key: "",
content: %{
"name" => name
}
}
end
end
defmodule Architex.Event.Topic do
alias Architex.{Event, Account, Room}
@spec new(Room.t(), Account.t(), String.t()) :: Event.t()
def new(room, sender, topic) do
%Event{
Event.new(room, sender)
| type: "m.room.topic",
state_key: "",
content: %{
"topic" => topic
}
}
end
end
defmodule Architex.Event.JoinRules do
alias Architex.{Event, Account, Room}
@spec new(Room.t(), Account.t(), String.t()) :: Event.t()
def new(room, sender, join_rule) do
%Event{
Event.new(room, sender)
| type: "m.room.join_rules",
state_key: "",
content: %{
"join_rule" => join_rule
}
}
end
end
defmodule Architex.Event.HistoryVisibility do
alias Architex.{Event, Account, Room}
@spec new(Room.t(), Account.t(), String.t()) :: Event.t()
def new(room, sender, history_visibility) do
%Event{
Event.new(room, sender)
| type: "m.room.history_visibility",
state_key: "",
content: %{
"history_visibility" => history_visibility
}
}
end
end
defmodule Architex.Event.GuestAccess do
alias Architex.{Event, Account, Room}
@spec new(Room.t(), Account.t(), String.t()) :: Event.t()
def new(room, sender, guest_access) do
%Event{
Event.new(room, sender)
| type: "m.room.guest_access",
state_key: "",
content: %{
"guest_access" => guest_access
}
}
end
end
defmodule Architex.Event.Invite do
alias Architex.{Event, Account, Room}
@spec new(Room.t(), Account.t(), String.t()) :: Event.t()
def new(room, sender, user_id) do
%Event{
Event.new(room, sender)
| type: "m.room.member",
state_key: user_id,
content: %{
"membership" => "invite"
}
}
end
end
defmodule Architex.Event.Leave do
alias Architex.{Event, Account, Room}
@spec new(Room.t(), Account.t()) :: Event.t()
def new(room, sender) do
%Event{
Event.new(room, sender)
| type: "m.room.member",
state_key: Account.get_mxid(sender),
content: %{
"membership" => "leave"
}
}
end
end
defmodule Architex.Event.Kick do
alias Architex.{Event, Account, Room}
@spec new(Room.t(), Account.t(), String.t(), String.t() | nil) :: Event.t()
def new(room, sender, user_id, reason \\ nil) do
content = %{"membership" => "leave"}
content = if reason, do: Map.put(content, "reason", reason), else: content
%Event{
Event.new(room, sender)
| type: "m.room.member",
state_key: user_id,
content: content
}
end
end
defmodule Architex.Event.Ban do
alias Architex.{Event, Account, Room}
@spec new(Room.t(), Account.t(), String.t(), String.t() | nil) :: Event.t()
def new(room, sender, user_id, reason \\ nil) do
content = %{"membership" => "ban"}
content = if reason, do: Map.put(content, "reason", reason), else: content
%Event{
Event.new(room, sender)
| type: "m.room.member",
state_key: user_id,
content: content
}
end
end
defmodule Architex.Event.Unban do
alias Architex.{Event, Account, Room}
@spec new(Room.t(), Account.t(), String.t()) :: Event.t()
def new(room, sender, user_id) do
%Event{
Event.new(room, sender)
| type: "m.room.member",
state_key: user_id,
content: %{
"membership" => "leave"
}
}
end
end

View file

@ -0,0 +1,17 @@
defmodule Architex.JoinedRoom do
use Ecto.Schema
alias Architex.{Account, Room}
@type t :: %__MODULE__{
account_id: integer(),
room_id: String.t()
}
@primary_key false
schema "joined_rooms" do
belongs_to :account, Account, primary_key: true
belongs_to :room, Room, primary_key: true, type: :string
end
end

View file

@ -0,0 +1,65 @@
defmodule Architex.Room do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias Architex.{Repo, Room, Event, Alias, RoomServer}
alias ArchitexWeb.Client.Request.CreateRoom
@type t :: %__MODULE__{
visibility: :public | :private,
state: list(list(String.t())),
forward_extremities: list(String.t())
}
@primary_key {:id, :string, []}
schema "rooms" do
field :visibility, Ecto.Enum, values: [:public, :private]
field :state, {:array, {:array, :string}}
field :forward_extremities, {:array, :string}
has_many :events, Event, foreign_key: :event_id
has_many :aliases, Alias, foreign_key: :room_id
end
def changeset(room, params \\ %{}) do
cast(room, params, [:visibility])
end
def create_changeset(%CreateRoom{visibility: visibility}) do
visibility = visibility || :public
%Room{id: generate_room_id()}
|> changeset(%{visibility: visibility})
end
def generate_room_id do
"!" <> Architex.random_string(18) <> ":" <> Architex.server_name()
end
def update_forward_extremities(
%Event{
event_id: event_id,
prev_events: prev_event_ids
},
%Room{id: room_id, forward_extremities: forward_extremities}
) do
new_forward_extremities = [event_id | forward_extremities -- prev_event_ids]
# TODO: might not need to save to DB here.
{_, [room]} =
from(r in Room, where: r.id == ^room_id, select: r)
|> Repo.update_all(set: [forward_extremities: new_forward_extremities])
room
end
def create(account, input) do
with {:ok, %Room{id: room_id}} <- Repo.insert(create_changeset(input)),
{:ok, pid} <- RoomServer.get_room_server(room_id) do
RoomServer.create_room(pid, account, input)
else
_ -> {:error, :unknown}
end
end
end

View file

@ -0,0 +1,89 @@
defmodule Architex.ServerKeyInfo do
use Ecto.Schema
import Ecto.Query
alias Architex.{Repo, ServerKeyInfo, SigningKey}
alias ArchitexWeb.Federation.HTTPClient
alias ArchitexWeb.Federation.Request.GetSigningKeys
alias Ecto.Multi
@primary_key {:server_name, :string, []}
schema "server_key_info" do
field :valid_until, :integer
has_many :signing_keys, SigningKey, foreign_key: :server_name
end
def with_fresh_signing_keys(server_name) do
current_time = System.os_time(:millisecond)
case with_signing_keys(server_name) do
nil ->
# We have not encountered this server before, always request keys.
refresh_signing_keys(server_name)
%ServerKeyInfo{valid_until: valid_until} when valid_until <= current_time ->
# Keys are expired; request fresh ones from server.
refresh_signing_keys(server_name)
ski ->
{:ok, ski}
end
end
defp refresh_signing_keys(server_name) do
# TODO: Handle expired keys.
in_a_week = DateTime.utc_now() |> DateTime.add(60 * 60 * 24 * 7, :second)
client = HTTPClient.client(server_name)
with {:ok,
%GetSigningKeys{
server_name: server_name,
verify_keys: verify_keys,
valid_until_ts: valid_until_ts
}} <- HTTPClient.get_signing_keys(client),
{:ok, valid_until} <- DateTime.from_unix(valid_until_ts, :millisecond) do
signing_keys =
Enum.map(verify_keys, fn {key_id, %{"key" => key}} ->
[server_name: server_name, signing_key_id: key_id, signing_key: key]
end)
# Always check every week to prevent misuse.
ski = %ServerKeyInfo{
server_name: server_name,
valid_until:
Architex.min_datetime(in_a_week, valid_until) |> DateTime.to_unix(:millisecond)
}
case upsert_multi(server_name, ski, signing_keys) |> Repo.transaction() do
{:ok, %{new_ski: ski}} -> {:ok, ski}
{:error, _} -> :error
end
else
_ -> :error
end
end
defp upsert_multi(server_name, ski, signing_keys) do
Multi.new()
|> Multi.insert(:ski, ski,
on_conflict: {:replace, [:valid_until]},
conflict_target: [:server_name]
)
|> Multi.insert_all(:insert_keys, SigningKey, signing_keys, on_conflict: :nothing)
|> Multi.run(:new_ski, fn _, _ ->
case with_signing_keys(server_name) do
nil -> {:error, :ski}
ski -> {:ok, ski}
end
end)
end
defp with_signing_keys(server_name) do
ServerKeyInfo
|> where([ski], ski.server_name == ^server_name)
|> preload([ski], [:signing_keys])
|> Repo.one()
end
end

View file

@ -0,0 +1,33 @@
defmodule Architex.SigningKey do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias Architex.{Repo, SigningKey, ServerKeyInfo}
@primary_key false
schema "signing_keys" do
field :signing_key_id, :string, primary_key: true
field :signing_key, :binary
belongs_to :server_key_info, ServerKeyInfo,
foreign_key: :server_name,
references: :server_name,
type: :string,
primary_key: true
end
def changeset(signing_key, params \\ %{}) do
signing_key
|> cast(params, [:server_name, :signing_key_id, :signing_key])
|> validate_required([:server_name, :signing_key_id, :signing_key])
|> unique_constraint([:server_name, :signing_key_id], name: :signing_keys_pkey)
end
def for_server(server_name) do
SigningKey
|> where([s], s.server_name == ^server_name)
|> Repo.all()
end
end

View file

@ -0,0 +1,302 @@
defmodule Architex.StateResolution do
@moduledoc """
Functions for resolving the state of a Matrix room.
Currently, only state resolution from room version 2 is supported,
see [the Matrix docs](https://spec.matrix.org/unstable/rooms/v2/).
The current implementation of the state resolution algorithm performs
rather badly.
Each time state is resolved, all events in the room are fetched from
the database and loaded into memory.
This is mostly so I didn't have to worry about fetching events from the
database when developing this initial implementation.
Then, the state is calculated using the new event's previous events and auth
events.
To prevent loading all events into memory, and calculating the whole state each
time, we should make snapshots of the state of a room at regular intervals.
It looks like Dendrite does this too.
"""
import Ecto.Query
alias Architex.{Repo, Event, Room}
alias Architex.StateResolution.Authorization
@type state_set :: map()
def resolve(event), do: resolve(event, true)
def resolve(%Event{room_id: room_id} = event, apply_state) do
room_events =
Event
|> where([e], e.room_id == ^room_id)
|> select([e], {e.event_id, e})
|> Repo.all()
|> Enum.into(%{})
resolve(event, room_events, apply_state)
end
def resolve(
%Event{type: type, state_key: state_key, prev_events: prev_event_ids} = event,
room_events,
apply_state
) do
state_sets =
prev_event_ids
|> Enum.map(&room_events[&1])
|> Enum.map(&resolve(&1, room_events, true))
resolved_state = do_resolve(state_sets, room_events)
if apply_state and Event.is_state_event(event) do
Map.put(resolved_state, {type, state_key}, event)
else
resolved_state
end
end
def resolve_forward_extremities(%Event{room_id: room_id}) do
room_events =
Event
|> where([e], e.room_id == ^room_id)
|> select([e], {e.event_id, e})
|> Repo.all()
|> Enum.into(%{})
Event
|> where([e], e.room_id == ^room_id)
|> join(:inner, [e], r in Room, on: e.room_id == r.id)
|> where([e, r], e.event_id == fragment("ANY(?)", r.forward_extremities))
|> Repo.all()
|> Enum.map(&resolve/1)
|> do_resolve(room_events)
end
def full_auth_chain(events, room_events) do
events
|> Enum.map(&auth_chain(&1, room_events))
|> Enum.reduce(MapSet.new(), &MapSet.union/2)
end
def update_state_set(
%Event{type: event_type, state_key: state_key} = event,
state_set
) do
Map.put(state_set, {event_type, state_key}, event)
end
defp do_resolve([], _), do: %{}
defp do_resolve(state_sets, room_events) do
{unconflicted_state_map, conflicted_state_set} = calculate_conflict(state_sets, room_events)
if MapSet.size(conflicted_state_set) == 0 do
unconflicted_state_map
else
do_resolve(state_sets, room_events, unconflicted_state_map, conflicted_state_set)
end
end
defp do_resolve(state_sets, room_events, unconflicted_state_map, conflicted_state_set) do
full_conflicted_set =
MapSet.union(conflicted_state_set, auth_difference(state_sets, room_events))
conflicted_control_event_ids =
full_conflicted_set
|> Enum.filter(&Event.is_control_event(room_events[&1]))
|> MapSet.new()
conflicted_control_events_with_auth_ids =
conflicted_control_event_ids
|> Enum.map(&room_events[&1])
|> full_auth_chain(room_events)
|> MapSet.intersection(full_conflicted_set)
|> MapSet.union(conflicted_control_event_ids)
sorted_control_events =
conflicted_control_events_with_auth_ids
|> Enum.map(&room_events[&1])
|> Enum.sort(rev_top_pow_order(room_events))
partial_resolved_state =
iterative_auth_checks(sorted_control_events, unconflicted_state_map, room_events)
resolved_power_levels = partial_resolved_state[{"m.room.power_levels", ""}]
conflicted_control_events_with_auth_ids
|> MapSet.difference(full_conflicted_set)
|> Enum.sort(mainline_order(resolved_power_levels, room_events))
|> Enum.map(&room_events[&1])
|> iterative_auth_checks(partial_resolved_state, room_events)
|> Map.merge(unconflicted_state_map)
end
defp calculate_conflict(state_sets, room_events) do
{unconflicted, conflicted} =
state_sets
|> Enum.flat_map(&Map.keys/1)
|> MapSet.new()
|> Enum.into(%{}, fn state_pair ->
events =
Enum.map(state_sets, fn
state_set when is_map_key(state_set, state_pair) -> state_set[state_pair].event_id
_ -> nil
end)
|> MapSet.new()
{state_pair, events}
end)
|> Enum.split_with(fn {_, event_ids} ->
MapSet.size(event_ids) == 1
end)
unconflicted_state_map =
Enum.into(unconflicted, %{}, fn {state_pair, event_ids} ->
event_id = MapSet.to_list(event_ids) |> hd()
{state_pair, room_events[event_id]}
end)
conflicted_state_set =
Enum.reduce(conflicted, MapSet.new(), fn {_, events}, acc ->
MapSet.union(acc, events)
end)
|> MapSet.delete(nil)
{unconflicted_state_map, conflicted_state_set}
end
defp auth_difference(state_sets, room_events) do
full_auth_chains =
Enum.map(state_sets, fn state_set ->
state_set
|> Map.values()
|> full_auth_chain(room_events)
end)
auth_chain_union = Enum.reduce(full_auth_chains, MapSet.new(), &MapSet.union/2)
auth_chain_intersection = Enum.reduce(full_auth_chains, MapSet.new(), &MapSet.intersection/2)
MapSet.difference(auth_chain_union, auth_chain_intersection)
end
defp auth_chain(%Event{auth_events: auth_events}, room_events) do
auth_events
|> Enum.map(&room_events[&1])
|> Enum.reduce(MapSet.new(), fn %Event{event_id: auth_event_id} = auth_event, acc ->
auth_event
|> auth_chain(room_events)
|> MapSet.union(acc)
|> MapSet.put(auth_event_id)
end)
end
defp rev_top_pow_order(room_events) do
fn %Event{origin_server_ts: timestamp1, event_id: event_id1} = event1,
%Event{origin_server_ts: timestamp2, event_id: event_id2} = event2 ->
power1 = get_power_level(event1, room_events)
power2 = get_power_level(event2, room_events)
if power1 == power2 do
if timestamp1 == timestamp2 do
event_id1 <= event_id2
else
timestamp1 < timestamp2
end
else
power1 < power2
end
end
end
defp get_power_level(%Event{sender: sender, auth_events: auth_event_ids}, room_events) do
pl_event_id =
Enum.find(auth_event_ids, fn id ->
room_events[id].type == "m.room.power_levels"
end)
# TODO: refactor
case room_events[pl_event_id] do
%Event{content: %{"users" => pl_users}} -> Map.get(pl_users, to_string(sender), 0)
nil -> 0
end
end
defp mainline_order(event, room_events) do
mainline_map =
event
|> mainline(room_events)
|> Enum.with_index()
|> Enum.into(%{})
fn event_id1, event_id2 ->
%Event{origin_server_ts: timestamp1} = event1 = room_events[event_id1]
%Event{origin_server_ts: timestamp2} = event2 = room_events[event_id2]
mainline_depth1 = get_mainline_depth(mainline_map, event1, room_events)
mainline_depth2 = get_mainline_depth(mainline_map, event2, room_events)
if mainline_depth1 == mainline_depth2 do
if timestamp1 == timestamp2 do
event_id1 <= event_id2
else
timestamp1 < timestamp2
end
else
mainline_depth1 < mainline_depth2
end
end
end
defp get_mainline_depth(mainline_map, event, room_events) do
mainline = mainline(event, room_events)
result =
Enum.find_value(mainline, fn mainline_event ->
if Map.has_key?(mainline_map, mainline_event) do
{:ok, mainline_map[mainline_event]}
else
nil
end
end)
case result do
{:ok, index} -> -index
nil -> nil
end
end
defp mainline(event, room_events) do
event
|> mainline([], room_events)
|> Enum.reverse()
end
defp mainline(%Event{auth_events: auth_event_ids} = event, acc, room_events) do
pl_event_id =
Enum.find(auth_event_ids, fn id ->
room_events[id].type == "m.room.power_levels"
end)
case room_events[pl_event_id] do
%Event{} = pl_event -> mainline(pl_event, [event | acc], room_events)
nil -> [event | acc]
end
end
defp iterative_auth_checks(events, state_set, room_events) do
Enum.reduce(events, state_set, fn event, acc ->
if authorized?(event, acc, room_events), do: update_state_set(event, acc), else: acc
end)
end
defp authorized?(%Event{auth_events: auth_event_ids} = event, state_set, room_events) do
state_set =
auth_event_ids
|> Enum.map(&room_events[&1])
|> Enum.reduce(state_set, &update_state_set/2)
Authorization.authorized?(event, state_set)
end
end

View file

@ -0,0 +1,315 @@
defmodule Architex.StateResolution.Authorization do
@moduledoc """
Implementation of Matrix event authorization rules for state resolution.
Note that some authorization rules are already checked in
`Architex.Event.prevalidate/1` so they are skipped here.
"""
import Architex.StateResolution
import Ecto.Query
alias Architex.{Repo, Event}
alias Architex.Types.UserId
alias Architex.StateResolution, as: StateRes
@typep action :: :invite | :ban | :redact | :kick | {:event, Event.t()}
@spec authorized?(Event.t(), StateRes.state_set()) :: boolean()
def authorized?(%Event{type: "m.room.create", prev_events: prev_events}, %{}),
do: prev_events == []
# Check rule: 5.2.1
def authorized?(
%Event{type: "m.room.member", state_key: state_key, prev_events: [create_id]},
%{
{"m.room.create", ""} => %Event{event_id: create_id, content: %{"creator" => creator}}
}
),
do: state_key == creator
def authorized?(
%Event{type: "m.room.member", sender: sender, content: %{"membership" => "join"}},
state_set
) do
join_rule = get_join_rule(state_set)
membership = get_membership(to_string(sender), state_set)
# Check rules: 5.2.3, 5.2.4, 5.2.5
cond do
membership == "ban" -> false
join_rule == "invite" -> membership in ["invite", "join"]
join_rule == "public" -> true
true -> false
end
end
# TODO: rule 5.3.1
def authorized?(
%Event{
type: "m.room.member",
content: %{"membership" => "invite", "third_party_invite" => _}
},
_
),
do: false
def authorized?(
%Event{
type: "m.room.member",
sender: sender,
content: %{"membership" => "invite"},
state_key: state_key
},
state_set
) do
sender_membership = get_membership(to_string(sender), state_set)
target_membership = get_membership(state_key, state_set)
power_levels = get_power_levels(state_set)
# Check rules: 5.3.2, 5.3.3, 5.3.4
cond do
sender_membership != "join" -> false
target_membership in ["join", "ban"] -> false
has_power_level?(to_string(sender), power_levels, :invite) -> true
true -> false
end
end
def authorized?(
%Event{
type: "m.room.member",
sender: sender,
content: %{"membership" => "leave"},
state_key: state_key
},
state_set
) do
sender_membership = get_membership(to_string(sender), state_set)
if to_string(sender) == state_key do
# Check rule: 5.4.1
sender_membership in ["invite", "join"]
else
target_membership = get_membership(state_key, state_set)
power_levels = get_power_levels(state_set)
sender_pl = get_user_power_level(to_string(sender), power_levels)
target_pl = get_user_power_level(state_key, power_levels)
# Check rules: 5.4.2, 5.4.3, 5.4.4
cond do
sender_membership != "join" ->
false
target_membership == "ban" and not has_power_level?(to_string(sender), power_levels, :ban) ->
false
has_power_level?(to_string(sender), power_levels, :kick) and target_pl < sender_pl ->
true
true ->
false
end
end
end
def authorized?(
%Event{
type: "m.room.member",
sender: sender,
content: %{"membership" => "ban"},
state_key: state_key
},
state_set
) do
sender_membership = get_membership(to_string(sender), state_set)
power_levels = get_power_levels(state_set)
sender_pl = get_user_power_level(to_string(sender), power_levels)
target_pl = get_user_power_level(state_key, power_levels)
# Check rules: 5.5.1, 5.5.2
cond do
sender_membership != "join" -> false
has_power_level?(to_string(sender), power_levels, :ban) and target_pl < sender_pl -> true
true -> false
end
end
# Check rule: 5.6
def authorized?(%Event{type: "m.room.member"}, _), do: false
def authorized?(%Event{sender: sender} = event, state_set) do
# Check rule: 6
get_membership(to_string(sender), state_set) == "join" and _authorized?(event, state_set)
end
@spec _authorized?(Event.t(), StateRes.state_set()) :: boolean()
defp _authorized?(%Event{type: "m.room.third_party_invite", sender: sender}, state_set) do
power_levels = get_power_levels(state_set)
# Check rule: 7.1
has_power_level?(to_string(sender), power_levels, :invite)
end
defp _authorized?(%Event{state_key: state_key, sender: sender} = event, state_set) do
power_levels = get_power_levels(state_set)
# Check rules: 8, 9
cond do
not has_power_level?(to_string(sender), power_levels, {:event, event}) ->
false
is_binary(state_key) and String.starts_with?(state_key, "@") and state_key != sender ->
false
true ->
__authorized?(event, state_set)
end
end
@spec __authorized?(Event.t(), StateRes.state_set()) :: boolean()
defp __authorized?(
%Event{type: "m.room.power_levels", sender: sender, content: content},
state_set
) do
current_pls = get_power_levels(state_set)
new_pls = content
sender_pl = get_user_power_level(to_string(sender), new_pls)
# Check rules: 10.2, 10.3, 10.4, 10.5
cond do
not is_map_key(state_set, {"m.room.power_levels", ""}) -> true
not authorize_power_levels(sender, sender_pl, current_pls, new_pls) -> false
true -> true
end
end
# TODO: Rule 11
defp __authorized?(_, _), do: true
@spec get_power_levels(StateRes.state_set()) :: map() | nil
defp get_power_levels(state_set) do
with %Event{content: content} <- state_set[{"m.room.power_levels", ""}] do
content
end
end
@spec get_join_rule(StateRes.state_set()) :: String.t() | nil
defp get_join_rule(state_set) do
with %Event{content: %{"join_rule" => join_rule}} <- state_set[{"m.room.join_rules", ""}] do
join_rule
end
end
@spec get_membership(String.t(), StateRes.state_set()) :: String.t() | nil
defp get_membership(user, state_set) do
with %Event{content: %{"membership" => membership}} <- state_set[{"m.room.member", user}] do
membership
end
end
@spec has_power_level?(String.t(), map() | nil, action()) :: boolean()
defp has_power_level?(user, power_levels, action) do
user_pl = get_user_power_level(user, power_levels)
action_pl = get_action_power_level(action, power_levels)
user_pl >= action_pl
end
@spec get_user_power_level(String.t(), map() | nil) :: non_neg_integer()
defp get_user_power_level(user, %{"users" => users}) when is_map_key(users, user),
do: users[user]
defp get_user_power_level(_, %{"users_default" => pl}), do: pl
defp get_user_power_level(_, _), do: 0
@spec get_action_power_level(action(), map() | nil) :: non_neg_integer()
defp get_action_power_level(:invite, %{"invite" => pl}), do: pl
defp get_action_power_level(:invite, _), do: 50
defp get_action_power_level(:ban, %{"ban" => pl}), do: pl
defp get_action_power_level(:ban, _), do: 50
# defp get_action_power_level(:redact, %{"redact" => pl}), do: pl
# defp get_action_power_level(:redact, _), do: 50
defp get_action_power_level(:kick, %{"kick" => pl}), do: pl
defp get_action_power_level(:kick, _), do: 50
defp get_action_power_level({:event, %Event{type: type}}, %{"events" => events})
when is_map_key(events, type),
do: events[type]
defp get_action_power_level({:event, event}, power_levels) do
if Event.is_state_event(event) do
case power_levels do
%{"state_default" => pl} -> pl
%{} -> 50
nil -> 0
end
else
case power_levels do
%{"events_default" => pl} -> pl
_ -> 0
end
end
end
# TODO: Power_levels may not have all these keys defined.
@spec authorize_power_levels(UserId.t(), non_neg_integer(), map() | nil, map() | nil) ::
boolean()
defp authorize_power_levels(
user,
user_pl,
%{"events" => current_events, "users" => current_users} = current_pls,
%{"events" => new_events, "users" => new_users} = new_pls
) do
keys = ["users_default", "events_default", "state_default", "ban", "redact", "kick", "invite"]
valid_power_level_key_changes(Map.take(current_pls, keys), Map.take(new_pls, keys), user_pl) and
valid_power_level_key_changes(current_events, new_events, user_pl) and
valid_power_level_key_changes(current_users, new_users, user_pl) and
valid_power_level_users_changes(current_users, new_users, to_string(user), user_pl)
end
defp authorize_power_levels(_, _, _, _), do: false
@spec valid_power_level_key_changes(map(), map(), non_neg_integer()) :: boolean()
defp valid_power_level_key_changes(l1, l2, user_pl) do
set1 = MapSet.new(l1)
set2 = MapSet.new(l2)
MapSet.difference(
MapSet.union(set1, set2),
MapSet.intersection(set1, set2)
)
|> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
|> Enum.all?(fn {_k, values} ->
Enum.all?(values, &(&1 <= user_pl))
end)
end
defp valid_power_level_users_changes(current_users, new_users, user, user_pl) do
set1 = MapSet.new(current_users)
set2 = MapSet.new(new_users)
MapSet.difference(
MapSet.union(set1, set2),
MapSet.intersection(set1, set2)
)
|> Enum.all?(fn
{_k, values} when length(values) != 2 -> true
{k, _} when k == user -> true
{_k, [old_value, _]} -> old_value != user_pl
end)
end
def authorized_by_auth_events?(%Event{auth_events: auth_event_ids} = event) do
# We assume the auth events are validated beforehand.
state_set =
Event
|> where([e], e.event_id in ^auth_event_ids)
|> Repo.all()
|> Enum.reduce(%{}, &update_state_set/2)
authorized?(event, state_set)
end
end

View file

@ -0,0 +1,43 @@
defmodule Architex.Types.AliasId do
use Ecto.Type
alias Architex.Types.AliasId
defstruct [:localpart, :domain]
defimpl String.Chars, for: AliasId do
def to_string(%AliasId{localpart: localpart, domain: domain}) do
"#" <> localpart <> ":" <> domain
end
end
def type(), do: :string
def cast(s) when is_binary(s) do
with "#" <> rest <- s,
[localpart, domain] <- String.split(rest, ":", parts: 2) do
if String.length(localpart) + String.length(domain) + 2 <= 255 and
Architex.valid_domain?(domain) do
{:ok, %AliasId{localpart: localpart, domain: domain}}
else
:error
end
else
_ -> :error
end
end
def cast(_), do: :error
def load(s) when is_binary(s) do
"@" <> rest = s
[localpart, domain] = String.split(rest, ":", parts: 2)
{:ok, %AliasId{localpart: localpart, domain: domain}}
end
def load(_), do: :error
def dump(%AliasId{} = alias_id), do: {:ok, to_string(alias_id)}
def dump(_), do: :error
end

View file

@ -0,0 +1,42 @@
defmodule Architex.Types.EventId do
use Ecto.Type
alias Architex.Types.EventId
defstruct [:id]
@id_regex ~r/^[[:alnum:]-_]+$/
defimpl String.Chars, for: EventId do
def to_string(%EventId{id: id}) do
"$" <> id
end
end
def type(), do: :string
def cast(s) when is_binary(s) do
with "$" <> id <- s do
if Regex.match?(@id_regex, id) do
{:ok, %EventId{id: id}}
else
:error
end
else
_ -> :error
end
end
def cast(_), do: :error
def load(s) when is_binary(s) do
"$" <> rest = s
{:ok, %EventId{id: rest}}
end
def load(_), do: :error
def dump(%EventId{} = event_id), do: {:ok, to_string(event_id)}
def dump(_), do: :error
end

View file

@ -0,0 +1,46 @@
defmodule Architex.Types.GroupId do
use Ecto.Type
alias Architex.Types.GroupId
defstruct [:localpart, :domain]
@localpart_regex ~r/^[[:lower:][:digit:]._=\-\/]+$/
defimpl String.Chars, for: GroupId do
def to_string(%GroupId{localpart: localpart, domain: domain}) do
"+" <> localpart <> ":" <> domain
end
end
def type(), do: :string
def cast(s) when is_binary(s) do
with "+" <> rest <- s,
[localpart, domain] <- String.split(rest, ":", parts: 2) do
if String.length(localpart) + String.length(domain) + 2 <= 255 and
Regex.match?(@localpart_regex, localpart) and
Architex.valid_domain?(domain) do
{:ok, %GroupId{localpart: localpart, domain: domain}}
else
:error
end
else
_ -> :error
end
end
def cast(_), do: :error
def load(s) when is_binary(s) do
"@" <> rest = s
[localpart, domain] = String.split(rest, ":", parts: 2)
{:ok, %GroupId{localpart: localpart, domain: domain}}
end
def load(_), do: :error
def dump(%GroupId{} = group_id), do: {:ok, to_string(group_id)}
def dump(_), do: :error
end

View file

@ -0,0 +1,42 @@
defmodule Architex.Types.RoomId do
use Ecto.Type
alias Architex.Types.RoomId
defstruct [:localpart, :domain]
defimpl String.Chars, for: RoomId do
def to_string(%RoomId{localpart: localpart, domain: domain}) do
"!" <> localpart <> ":" <> domain
end
end
def type(), do: :string
def cast(s) when is_binary(s) do
with "!" <> rest <- s,
[localpart, domain] <- String.split(rest, ":", parts: 2) do
if Architex.valid_domain?(domain) do
{:ok, %RoomId{localpart: localpart, domain: domain}}
else
:error
end
else
_ -> :error
end
end
def cast(_), do: :error
def load(s) when is_binary(s) do
"!" <> rest = s
[localpart, domain] = String.split(rest, ":", parts: 2)
{:ok, %RoomId{localpart: localpart, domain: domain}}
end
def load(_), do: :error
def dump(%RoomId{} = room_id), do: {:ok, to_string(room_id)}
def dump(_), do: :error
end

View file

@ -0,0 +1,56 @@
defmodule Architex.Types.UserId do
use Ecto.Type
alias Architex.Types.UserId
@type t :: %__MODULE__{
localpart: String.t(),
domain: String.t()
}
defstruct [:localpart, :domain]
@localpart_regex ~r/^[a-z0-9._=\-\/]+$/
defimpl String.Chars, for: UserId do
def to_string(%UserId{localpart: localpart, domain: domain}) do
"@" <> localpart <> ":" <> domain
end
end
defimpl Jason.Encoder, for: UserId do
def encode(user_id, opts) do
Jason.Encode.string(to_string(user_id), opts)
end
end
def type(), do: :string
def cast(s) when is_binary(s) do
with "@" <> rest <- s,
[localpart, domain] <- String.split(rest, ":", parts: 2) do
if String.length(localpart) + String.length(domain) + 2 <= 255 and
Regex.match?(@localpart_regex, localpart) and Architex.valid_domain?(domain) do
{:ok, %UserId{localpart: localpart, domain: domain}}
else
:error
end
else
_ -> :error
end
end
def cast(_), do: :error
def load(s) when is_binary(s) do
"@" <> rest = s
[localpart, domain] = String.split(rest, ":", parts: 2)
{:ok, %UserId{localpart: localpart, domain: domain}}
end
def load(_), do: :error
def dump(%UserId{} = user_id), do: {:ok, to_string(user_id)}
def dump(_), do: :error
end