Rename repository
This commit is contained in:
parent
4aeb2d2cd8
commit
232df26b85
71 changed files with 348 additions and 345 deletions
29
lib/architex/application.ex
Normal file
29
lib/architex/application.ex
Normal 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
10
lib/architex/check.ex
Normal 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
|
34
lib/architex/encodable_map.ex
Normal file
34
lib/architex/encodable_map.ex
Normal 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
|
95
lib/architex/key_server.ex
Normal file
95
lib/architex/key_server.ex
Normal 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
5
lib/architex/repo.ex
Normal 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
644
lib/architex/room_server.ex
Normal 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
|
157
lib/architex/schema/account.ex
Normal file
157
lib/architex/schema/account.ex
Normal 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
|
28
lib/architex/schema/alias.ex
Normal file
28
lib/architex/schema/alias.ex
Normal 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
|
69
lib/architex/schema/device.ex
Normal file
69
lib/architex/schema/device.ex
Normal 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
|
18
lib/architex/schema/device_transaction.ex
Normal file
18
lib/architex/schema/device_transaction.ex
Normal 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
|
330
lib/architex/schema/event.ex
Normal file
330
lib/architex/schema/event.ex
Normal 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
|
228
lib/architex/schema/event/generators.ex
Normal file
228
lib/architex/schema/event/generators.ex
Normal 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
|
17
lib/architex/schema/joined_room.ex
Normal file
17
lib/architex/schema/joined_room.ex
Normal 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
|
65
lib/architex/schema/room.ex
Normal file
65
lib/architex/schema/room.ex
Normal 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
|
89
lib/architex/schema/server_key_info.ex
Normal file
89
lib/architex/schema/server_key_info.ex
Normal 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
|
33
lib/architex/schema/signing_key.ex
Normal file
33
lib/architex/schema/signing_key.ex
Normal 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
|
302
lib/architex/state_resolution.ex
Normal file
302
lib/architex/state_resolution.ex
Normal 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
|
315
lib/architex/state_resolution/authorization.ex
Normal file
315
lib/architex/state_resolution/authorization.ex
Normal 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
|
43
lib/architex/types/alias_id.ex
Normal file
43
lib/architex/types/alias_id.ex
Normal 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
|
42
lib/architex/types/event_id.ex
Normal file
42
lib/architex/types/event_id.ex
Normal 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
|
46
lib/architex/types/group_id.ex
Normal file
46
lib/architex/types/group_id.ex
Normal 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
|
42
lib/architex/types/room_id.ex
Normal file
42
lib/architex/types/room_id.ex
Normal 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
|
56
lib/architex/types/user_id.ex
Normal file
56
lib/architex/types/user_id.ex
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue