Compare commits
No commits in common. "f94c156f950ddea675cf7d592974b82ff1b63ff1" and "8ab147190e1bc6de644a69bf2672d5da1b025288" have entirely different histories.
f94c156f95
...
8ab147190e
17 changed files with 334 additions and 793 deletions
|
@ -50,7 +50,7 @@ Here, implemented and some unimplemented features are listed.
|
|||
- GET /_matrix/client/r0/login
|
||||
- POST /_matrix/client/r0/login: Only with password flow.
|
||||
- POST /_matrix/client/r0/register: Only with dummy flow.
|
||||
- POST /_matrix/client/r0/createRoom: Except with option invite_3pid.
|
||||
- POST /_matrix/client/r0/createRoom: Only with optional parameters name, topic, preset and invite.
|
||||
- GET /_matrix/client/r0/joined_rooms
|
||||
- POST /_matrix/client/r0/rooms/{roomId}/invite
|
||||
- POST /_matrix/client/r0/rooms/{roomId}/join: Except with third party invite.
|
||||
|
@ -61,8 +61,6 @@ Here, implemented and some unimplemented features are listed.
|
|||
- PUT /_matrix/client/r0/rooms/{roomId}/state/{eventType}/{stateKey}
|
||||
- PUT /_matrix/client/r0/rooms/{roomId}/send/{eventType}/{txnId}
|
||||
- GET /_matrix/client/r0/rooms/{roomId}/messages: Except filtering.
|
||||
- GET /_matrix/client/r0/rooms/{roomId}/state
|
||||
- GET /_matrix/client/r0/rooms/{roomId}/state/{eventType}/{stateKey}
|
||||
- GET /_matrix/client/r0/directory/list/room/{roomId}
|
||||
- PUT /_matrix/client/r0/directory/list/room/{roomId}
|
||||
- GET /_matrix/client/r0/capabilities
|
||||
|
@ -78,7 +76,7 @@ Here, implemented and some unimplemented features are listed.
|
|||
- GET /_matrix/federation/v1/state/{roomId}
|
||||
- GET /_matrix/federation/v1/state_ids/{roomId}
|
||||
- GET /_matrix/key/v2/server/{keyId}
|
||||
- GET /_matrix/federation/v1/query/profile
|
||||
- GET /_matrix/federation/v1/query/profile: Except displayname and avatar_url is not implemented.
|
||||
|
||||
### Major unimplemented features
|
||||
|
||||
|
|
|
@ -6,6 +6,8 @@ defmodule Architex.RoomServer do
|
|||
The RoomServers are supervised by a DynamicSupervisor RoomServer.Supervisor.
|
||||
"""
|
||||
|
||||
@typep t :: map()
|
||||
|
||||
use GenServer
|
||||
|
||||
import Ecto.Query
|
||||
|
@ -19,12 +21,9 @@ defmodule Architex.RoomServer do
|
|||
Account,
|
||||
Device,
|
||||
DeviceTransaction,
|
||||
Membership,
|
||||
Alias
|
||||
Membership
|
||||
}
|
||||
|
||||
alias Architex.Types.{UserId, StateSet}
|
||||
|
||||
alias Architex.StateResolution.Authorization
|
||||
alias ArchitexWeb.Client.Request.{CreateRoom, Kick, Ban}
|
||||
|
||||
|
@ -47,13 +46,8 @@ defmodule Architex.RoomServer do
|
|||
@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.
|
||||
query =
|
||||
Room
|
||||
|> where([r], r.id == ^room_id)
|
||||
|> select([:id, :forward_extremities, :state_set, :visibility])
|
||||
|
||||
case Repo.one(query) do
|
||||
%Room{} = room ->
|
||||
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}
|
||||
|
@ -61,7 +55,8 @@ defmodule Architex.RoomServer do
|
|||
[] ->
|
||||
opts = [
|
||||
name: {:via, Registry, {@registry, room_id}},
|
||||
room: room
|
||||
room: room,
|
||||
serialized_state_set: serialized_state_set
|
||||
]
|
||||
|
||||
DynamicSupervisor.start_child(@supervisor, {__MODULE__, opts})
|
||||
|
@ -116,10 +111,9 @@ defmodule Architex.RoomServer do
|
|||
@doc """
|
||||
Invite the a user to this room.
|
||||
"""
|
||||
@spec invite(pid(), Account.t(), String.t(), String.t() | nil, String.t() | nil) ::
|
||||
:ok | {:error, atom()}
|
||||
def invite(pid, account, user_id, avatar_url, displayname) do
|
||||
GenServer.call(pid, {:invite, account, user_id, avatar_url, displayname})
|
||||
@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 """
|
||||
|
@ -141,28 +135,25 @@ defmodule Architex.RoomServer do
|
|||
@doc """
|
||||
Kick a user from this room.
|
||||
"""
|
||||
@spec kick(pid(), Account.t(), Kick.t(), String.t() | nil, String.t() | nil) ::
|
||||
:ok | {:error, atom()}
|
||||
def kick(pid, account, request, avatar_url, displayname) do
|
||||
GenServer.call(pid, {:kick, account, request, avatar_url, displayname})
|
||||
@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(), String.t() | nil, String.t() | nil) ::
|
||||
:ok | {:error, atom()}
|
||||
def ban(pid, account, request, avatar_url, displayname) do
|
||||
GenServer.call(pid, {:ban, account, request, avatar_url, displayname})
|
||||
@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(), String.t() | nil, String.t() | nil) ::
|
||||
:ok | {:error, atom()}
|
||||
def unban(pid, account, user_id, avatar_url, displayname) do
|
||||
GenServer.call(pid, {:unban, account, user_id, avatar_url, displayname})
|
||||
@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 """
|
||||
|
@ -191,64 +182,44 @@ defmodule Architex.RoomServer do
|
|||
GenServer.call(pid, {:send_state_event, account, event_type, content, state_key})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get the current state of a room.
|
||||
If the requesting user is not a member of the room,
|
||||
get the state when the user left the room.
|
||||
If the user has never been in the room, return an error.
|
||||
"""
|
||||
@spec get_current_state(pid(), Account.t()) :: {:ok, [Event.t()]} | :error
|
||||
def get_current_state(pid, account) do
|
||||
GenServer.call(pid, {:get_current_state, account})
|
||||
end
|
||||
|
||||
@spec get_state_event(pid(), Account.t(), String.t(), String.t()) ::
|
||||
{:ok, map()} | {:error, :unauthorized} | {:error, :not_found}
|
||||
def get_state_event(pid, account, event_type, state_key) do
|
||||
GenServer.call(pid, {:get_state_event, account, event_type, state_key})
|
||||
end
|
||||
|
||||
### Implementation
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
{:ok, %{room: Keyword.fetch!(opts, :room)}}
|
||||
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.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, %CreateRoom{room_alias_name: room_alias_name} = request},
|
||||
{:create_room, account, request},
|
||||
_from,
|
||||
%{room: %Room{id: room_id} = room} = state
|
||||
) do
|
||||
create_alias_result =
|
||||
if room_alias_name do
|
||||
Alias.create(room_alias_name, room_id)
|
||||
else
|
||||
{:ok, nil}
|
||||
end
|
||||
case Repo.transaction(create_room_insert_events(room, account, request)) do
|
||||
{:ok, {state_set, room}} ->
|
||||
{:reply, {:ok, room_id}, %{state | state_set: state_set, room: room}}
|
||||
|
||||
case create_alias_result do
|
||||
{:ok, alias_} ->
|
||||
events = create_room_events(room, account, request, alias_)
|
||||
{:error, reason} ->
|
||||
{:reply, {:error, reason}, state}
|
||||
|
||||
case Repo.transaction(process_events(room, events)) do
|
||||
{:ok, room} ->
|
||||
{:reply, {:ok, room_id}, %{state | room: room}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:reply, {:error, reason}, state}
|
||||
|
||||
_ ->
|
||||
{:reply, {:error, :unknown}, state}
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
{:reply, {:error, :alias}, state}
|
||||
_ ->
|
||||
{:reply, {:error, :unknown}, state}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_call({:server_in_room?, domain}, _from, %{room: %Room{state_set: state_set}} = state) do
|
||||
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"}}} ->
|
||||
|
@ -301,15 +272,11 @@ defmodule Architex.RoomServer do
|
|||
{:reply, {state_events, auth_chain}, state}
|
||||
end
|
||||
|
||||
def handle_call(
|
||||
{:invite, account, user_id, avatar_url, displayname},
|
||||
_from,
|
||||
%{room: room} = state
|
||||
) do
|
||||
invite_event = Event.Invite.new(room, account, user_id, avatar_url, displayname)
|
||||
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(process_event(room, invite_event)) do
|
||||
{:ok, {room, _}} -> {:reply, :ok, %{state | room: room}}
|
||||
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
|
||||
|
@ -317,64 +284,59 @@ defmodule Architex.RoomServer do
|
|||
def handle_call(
|
||||
{:join, account},
|
||||
_from,
|
||||
%{room: %Room{id: room_id} = room} = state
|
||||
%{room: %Room{id: room_id} = room, state_set: state_set} = state
|
||||
) do
|
||||
join_event = Event.Join.new(room, account)
|
||||
|
||||
case Repo.transaction(process_event(room, join_event)) do
|
||||
{:ok, {room, _}} ->
|
||||
{:reply, {:ok, room_id}, %{state | room: room}}
|
||||
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) do
|
||||
def handle_call({:leave, account}, _from, %{room: room, state_set: state_set} = state) do
|
||||
leave_event = Event.Leave.new(room, account)
|
||||
|
||||
case Repo.transaction(process_event(room, leave_event)) do
|
||||
{:ok, {room, _}} -> {:reply, :ok, %{state | room: room}}
|
||||
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}, avatar_url, displayname},
|
||||
{:kick, account, %Kick{user_id: user_id, reason: reason}},
|
||||
_from,
|
||||
%{room: room} = state
|
||||
%{room: room, state_set: state_set} = state
|
||||
) do
|
||||
kick_event =
|
||||
Event.Kick.new(room, account, to_string(user_id), avatar_url, displayname, reason)
|
||||
kick_event = Event.Kick.new(room, account, user_id, reason)
|
||||
|
||||
case Repo.transaction(process_event(room, kick_event)) do
|
||||
{:ok, {room, _}} -> {:reply, :ok, %{state | room: room}}
|
||||
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, %Ban{user_id: user_id, reason: reason}, avatar_url, displayname},
|
||||
{:ban, account, %Kick{user_id: user_id, reason: reason}},
|
||||
_from,
|
||||
%{room: room} = state
|
||||
%{room: room, state_set: state_set} = state
|
||||
) do
|
||||
ban_event = Event.Ban.new(room, account, to_string(user_id), avatar_url, displayname, reason)
|
||||
ban_event = Event.Ban.new(room, account, user_id, reason)
|
||||
|
||||
case Repo.transaction(process_event(room, ban_event)) do
|
||||
{:ok, {room, _}} -> {:reply, :ok, %{state | room: room}}
|
||||
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, avatar_url, displayname},
|
||||
_from,
|
||||
%{room: room} = state
|
||||
) do
|
||||
unban_event = Event.Unban.new(room, account, user_id, avatar_url, displayname)
|
||||
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(process_event(room, unban_event)) do
|
||||
{:ok, {room, _}} -> {:reply, :ok, %{state | room: room}}
|
||||
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
|
||||
|
@ -382,7 +344,7 @@ defmodule Architex.RoomServer do
|
|||
def handle_call(
|
||||
{:set_visibility, account, visibility},
|
||||
_from,
|
||||
%{room: %Room{state_set: state_set} = room} = state
|
||||
%{room: room, state_set: state_set} = state
|
||||
) do
|
||||
case state_set do
|
||||
%{{"m.room.create", ""} => %Event{content: %{"creator" => creator}}} ->
|
||||
|
@ -402,13 +364,13 @@ defmodule Architex.RoomServer do
|
|||
def handle_call(
|
||||
{:send_message_event, account, device, event_type, content, txn_id},
|
||||
_from,
|
||||
%{room: room} = state
|
||||
%{room: room, state_set: state_set} = state
|
||||
) do
|
||||
message_event = Event.custom_event(room, account, event_type, content)
|
||||
|
||||
case Repo.transaction(process_event_with_txn(room, device, message_event, txn_id)) do
|
||||
{:ok, {room, event_id}} ->
|
||||
{:reply, {:ok, event_id}, %{state | room: room}}
|
||||
case Repo.transaction(insert_event_with_txn(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}
|
||||
|
@ -418,76 +380,23 @@ defmodule Architex.RoomServer do
|
|||
def handle_call(
|
||||
{:send_state_event, account, event_type, content, state_key},
|
||||
_from,
|
||||
%{room: room} = state
|
||||
%{room: room, state_set: state_set} = state
|
||||
) do
|
||||
state_event = Event.custom_state_event(room, account, event_type, content, state_key)
|
||||
|
||||
case Repo.transaction(process_event(room, state_event)) do
|
||||
{:ok, {room, %Event{id: event_id}}} ->
|
||||
{:reply, {:ok, event_id}, %{state | room: room}}
|
||||
case Repo.transaction(insert_single_event(room, state_set, state_event)) do
|
||||
{:ok, {state_set, room, %Event{id: event_id}}} ->
|
||||
{:reply, {:ok, event_id}, %{state | state_set: state_set, room: room}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:reply, {:error, reason}, state}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_call(
|
||||
{:get_current_state, account},
|
||||
_from,
|
||||
%{room: %Room{state_set: state_set}} = state
|
||||
) do
|
||||
mxid = Account.get_mxid(account)
|
||||
|
||||
case state_set[{"m.room.member", mxid}] do
|
||||
%Event{content: %{"membership" => "join"}} ->
|
||||
# Get the current state of the room.
|
||||
{:reply, {:ok, Map.values(state_set)}, state}
|
||||
|
||||
%Event{content: %{"membership" => "leave"}} = event ->
|
||||
# Get the state of the room, after leaving.
|
||||
# TODO: This does not work properly, as a user's membership can change to "leave"
|
||||
# even after they left/are banned.
|
||||
# I think it is best to seperately keep track when a user left, maybe in the
|
||||
# Membership table.
|
||||
state_set = StateResolution.resolve(event)
|
||||
{:reply, {:ok, Map.values(state_set)}, state}
|
||||
|
||||
_ ->
|
||||
{:reply, :error, state}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_call(
|
||||
{:get_state_event, account, event_type, state_key},
|
||||
_from,
|
||||
%{room: %Room{state_set: state_set}} = state
|
||||
) do
|
||||
mxid = Account.get_mxid(account)
|
||||
|
||||
case state_set[{"m.room.member", mxid}] do
|
||||
%Event{content: %{"membership" => "join"}} ->
|
||||
case state_set[{event_type, state_key}] do
|
||||
%Event{content: content} -> {:reply, {:ok, content}, state}
|
||||
nil -> {:reply, {:error, :not_found}, state}
|
||||
end
|
||||
|
||||
%Event{content: %{"membership" => "leave"}} = event ->
|
||||
# TODO: See get_current_state.
|
||||
state_set = StateResolution.resolve(event)
|
||||
|
||||
case state_set[{event_type, state_key}] do
|
||||
%Event{content: content} -> {:reply, {:ok, content}, state}
|
||||
nil -> {:reply, {:error, :not_found}, state}
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:reply, {:error, :unauthorized}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@spec process_event_with_txn(Room.t(), Device.t(), %Event{}, String.t()) ::
|
||||
(() -> {Room.t(), String.t()} | {:error, atom()})
|
||||
defp process_event_with_txn(
|
||||
@spec insert_event_with_txn(t(), Room.t(), Device.t(), %Event{}, String.t()) ::
|
||||
(() -> {t(), Room.t(), String.t()} | {:error, atom()})
|
||||
defp insert_event_with_txn(
|
||||
state_set,
|
||||
room,
|
||||
%Device{nid: device_nid} = device,
|
||||
message_event,
|
||||
|
@ -500,107 +409,98 @@ defmodule Architex.RoomServer do
|
|||
where: dt.txn_id == ^txn_id and dt.device_nid == ^device_nid
|
||||
) do
|
||||
%DeviceTransaction{event_id: event_id} ->
|
||||
{room, event_id}
|
||||
{state_set, room, event_id}
|
||||
|
||||
nil ->
|
||||
with {room, %Event{id: event_id}} <- process_event(room, message_event).() do
|
||||
with {state_set, room, %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!()
|
||||
|
||||
{room, event_id}
|
||||
{state_set, room, event_id}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec process_event(Room.t(), %Event{}) :: (() -> {Room.t(), Event.t()} | {:error, atom()})
|
||||
defp process_event(room, event) do
|
||||
@spec insert_single_event(Room.t(), t(), %Event{}) ::
|
||||
(() -> {t(), Room.t(), Event.t()} | {:error, atom()})
|
||||
defp insert_single_event(room, state_set, event) do
|
||||
fn ->
|
||||
case finalize_and_process_event(event, room) do
|
||||
{:ok, room, event} -> {room, event}
|
||||
{:error, reason} -> Repo.rollback(reason)
|
||||
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
|
||||
|
||||
@spec process_events(Room.t(), [%Event{}]) :: (() -> Room.t() | {:error, atom()})
|
||||
defp process_events(room, events) do
|
||||
# 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,
|
||||
invite: invite,
|
||||
power_level_content_override: power_level_content_override
|
||||
}) do
|
||||
|
||||
events =
|
||||
([
|
||||
Event.CreateRoom.new(room, account, room_version),
|
||||
Event.Join.new(room, account),
|
||||
Event.PowerLevels.create_room_new(room, account, power_level_content_override)
|
||||
] ++
|
||||
room_creation_preset(account, preset, room) ++
|
||||
[
|
||||
if(name, do: Event.Name.new(room, account, name)),
|
||||
if(topic, do: Event.Topic.new(room, account, topic))
|
||||
] ++ room_creation_invite_events(account, invite, room))
|
||||
|> Enum.reject(&Kernel.is_nil/1)
|
||||
|
||||
fn ->
|
||||
Enum.reduce_while(events, room, fn event, room ->
|
||||
case finalize_and_process_event(event, room) do
|
||||
{:ok, room, _} -> {:cont, room}
|
||||
{:error, reason} -> {:halt, {:error, reason}}
|
||||
end
|
||||
end)
|
||||
|> then(fn
|
||||
{:error, reason} -> Repo.rollback(reason)
|
||||
room -> room
|
||||
end)
|
||||
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
|
||||
|
||||
@spec create_room_events(Room.t(), Account.t(), CreateRoom.t(), Alias.t() | nil) :: [%Event{}]
|
||||
defp create_room_events(
|
||||
room,
|
||||
account,
|
||||
%CreateRoom{
|
||||
room_version: room_version,
|
||||
preset: preset,
|
||||
name: name,
|
||||
topic: topic,
|
||||
invite: invite,
|
||||
power_level_content_override: power_level_content_override,
|
||||
is_direct: is_direct,
|
||||
creation_content: creation_content,
|
||||
initial_state: initial_state
|
||||
},
|
||||
alias_
|
||||
) do
|
||||
invite_events = room_creation_invite_events(account, invite, room, is_direct)
|
||||
|
||||
# Spec doesn't specify where to insert canonical_alias event, do it after topic event.
|
||||
name_and_topic_events =
|
||||
Enum.reject(
|
||||
[
|
||||
if(name, do: Event.Name.new(room, account, name)),
|
||||
if(topic, do: Event.Topic.new(room, account, topic)),
|
||||
if(alias_, do: Event.CanonicalAlias.new(room, account, alias_.alias))
|
||||
],
|
||||
&Kernel.is_nil/1
|
||||
)
|
||||
|
||||
initial_state_pairs =
|
||||
if initial_state, do: Enum.map(initial_state, &{&1.type, &1.state_key}), else: []
|
||||
|
||||
initial_state_events =
|
||||
room_creation_initial_state_events(account, initial_state, room)
|
||||
|> Enum.reject(fn %Event{type: type, state_key: state_key} ->
|
||||
({type, state_key} == {"m.room.name", ""} and name) ||
|
||||
({type, state_key} == {"m.room.topic", ""} and topic)
|
||||
# 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{id: event_id}} ->
|
||||
[type, state_key, event_id]
|
||||
end)
|
||||
|
||||
preset_events =
|
||||
room_creation_preset(account, preset, room)
|
||||
|> Enum.reject(&({&1.type, &1.state_key} in initial_state_pairs))
|
||||
|
||||
basic_events = [
|
||||
Event.CreateRoom.new(room, account, room_version, creation_content),
|
||||
Event.Join.new(room, account),
|
||||
Event.PowerLevels.create_room_new(
|
||||
room,
|
||||
account,
|
||||
power_level_content_override,
|
||||
invite,
|
||||
preset
|
||||
)
|
||||
]
|
||||
|
||||
basic_events ++
|
||||
preset_events ++ initial_state_events ++ name_and_topic_events ++ invite_events
|
||||
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{}]
|
||||
defp room_creation_preset(account, nil, %Room{visibility: visibility} = room) do
|
||||
preset =
|
||||
|
@ -628,24 +528,11 @@ defmodule Architex.RoomServer do
|
|||
end
|
||||
|
||||
# Get the events for room creation for inviting other users.
|
||||
@spec room_creation_invite_events(Account.t(), [UserId.t()] | nil, Room.t(), boolean() | nil) ::
|
||||
[%Event{}]
|
||||
defp room_creation_invite_events(_, nil, _, _), do: []
|
||||
@spec room_creation_invite_events(Account.t(), [String.t()] | nil, Room.t()) :: [%Event{}]
|
||||
defp room_creation_invite_events(_, nil, _), do: []
|
||||
|
||||
defp room_creation_invite_events(account, invite_user_ids, room, is_direct) do
|
||||
Enum.map(invite_user_ids, fn user_id ->
|
||||
{avatar_url, displayname} = UserId.try_get_user_information(user_id)
|
||||
|
||||
Event.Invite.new(room, account, to_string(user_id), avatar_url, displayname, is_direct)
|
||||
end)
|
||||
end
|
||||
|
||||
defp room_creation_initial_state_events(_, nil, _), do: []
|
||||
|
||||
defp room_creation_initial_state_events(account, initial_state, room) do
|
||||
Enum.map(initial_state, fn %{type: type, content: content, state_key: state_key} ->
|
||||
Event.custom_state_event(room, account, type, content, state_key)
|
||||
end)
|
||||
defp room_creation_invite_events(account, invite_user_ids, room) do
|
||||
Enum.map(invite_user_ids, &Event.Invite.new(room, account, &1))
|
||||
end
|
||||
|
||||
# Finalize the event struct and insert it into the room's state using state resolution.
|
||||
|
@ -655,11 +542,12 @@ defmodule Architex.RoomServer do
|
|||
# - Content hash
|
||||
# - Event ID
|
||||
# - Signature
|
||||
@spec finalize_and_process_event(%Event{}, Room.t()) ::
|
||||
{:ok, Room.t(), Event.t()} | {:error, atom()}
|
||||
defp finalize_and_process_event(
|
||||
@spec finalize_and_insert_event(%Event{}, t(), Room.t()) ::
|
||||
{:ok, t(), Room.t(), Event.t()} | {:error, atom()}
|
||||
defp finalize_and_insert_event(
|
||||
event,
|
||||
%Room{forward_extremities: forward_extremities, state_set: state_set} = room
|
||||
state_set,
|
||||
%Room{forward_extremities: forward_extremities} = room
|
||||
) do
|
||||
event =
|
||||
event
|
||||
|
@ -668,7 +556,7 @@ defmodule Architex.RoomServer do
|
|||
|> Map.put(:depth, get_depth(forward_extremities))
|
||||
|
||||
case Event.post_process(event) do
|
||||
{:ok, event} -> authenticate_and_process_event(event, room)
|
||||
{:ok, event} -> authenticate_and_insert_event(event, state_set, room)
|
||||
_ -> {:error, :event_creation}
|
||||
end
|
||||
end
|
||||
|
@ -683,7 +571,7 @@ defmodule Architex.RoomServer do
|
|||
end
|
||||
|
||||
# Get the auth events for an events.
|
||||
@spec auth_events_for_event(%Event{}, StateSet.t()) :: [Event.t()]
|
||||
@spec auth_events_for_event(%Event{}, t()) :: [Event.t()]
|
||||
defp auth_events_for_event(%Event{type: "m.room.create"}, _), do: []
|
||||
|
||||
defp auth_events_for_event(
|
||||
|
@ -735,9 +623,9 @@ defmodule Architex.RoomServer do
|
|||
# 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_process_event(Event.t(), Room.t()) ::
|
||||
{:ok, Room.t(), Event.t()} | {:error, atom()}
|
||||
defp authenticate_and_process_event(event, %Room{state_set: current_state_set} = room) do
|
||||
@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.
|
||||
|
@ -755,9 +643,8 @@ defmodule Architex.RoomServer do
|
|||
event = Repo.insert!(event)
|
||||
state_set = StateResolution.resolve_forward_extremities(event)
|
||||
:ok = update_membership(room, state_set)
|
||||
room = Repo.update!(change(room, state_set: state_set))
|
||||
|
||||
{:ok, room, event}
|
||||
{:ok, state_set, room, event}
|
||||
else
|
||||
_ -> {:error, :authorization}
|
||||
end
|
||||
|
@ -768,7 +655,7 @@ defmodule Architex.RoomServer do
|
|||
# could access rooms they are not allowed to. Then again, maybe we should perform
|
||||
# the "normal" authorization flow for local users as well, and treat the Membership
|
||||
# table only as informational.
|
||||
@spec update_membership(Room.t(), StateSet.t()) :: :ok
|
||||
@spec update_membership(Room.t(), t()) :: :ok
|
||||
defp update_membership(%Room{id: room_id}, state_set) do
|
||||
server_name = Architex.server_name()
|
||||
|
||||
|
|
|
@ -6,18 +6,13 @@ defmodule Architex.Alias do
|
|||
alias Architex.{Repo, Alias, Room}
|
||||
alias Ecto.Changeset
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
alias: String.t(),
|
||||
room_id: String.t()
|
||||
}
|
||||
|
||||
@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)
|
||||
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()
|
||||
|
|
|
@ -314,15 +314,4 @@ defmodule Architex.Event do
|
|||
{:ok, :crypto.hash(:sha256, json)}
|
||||
end
|
||||
end
|
||||
|
||||
@spec default_membership_content(String.t() | nil, String.t() | nil) :: %{
|
||||
optional(String.t()) => String.t()
|
||||
}
|
||||
def default_membership_content(avatar_url, displayname) do
|
||||
content = %{}
|
||||
content = if avatar_url, do: Map.put(content, "avatar_url", avatar_url), else: content
|
||||
content = if displayname, do: Map.put(content, "displayname", displayname), else: content
|
||||
|
||||
content
|
||||
end
|
||||
end
|
||||
|
|
|
@ -4,10 +4,33 @@ defmodule Architex.Event.Formatters do
|
|||
"""
|
||||
alias Architex.Event
|
||||
|
||||
@doc """
|
||||
Event format with keys that all formats have in common.
|
||||
"""
|
||||
def base_client_response(%Event{
|
||||
@spec messages_response(Event.t()) :: map()
|
||||
def messages_response(%Event{
|
||||
content: content,
|
||||
type: type,
|
||||
id: event_id,
|
||||
sender: sender,
|
||||
origin_server_ts: origin_server_ts,
|
||||
unsigned: unsigned,
|
||||
room_id: room_id,
|
||||
state_key: state_key
|
||||
}) do
|
||||
data = %{
|
||||
content: content,
|
||||
type: type,
|
||||
event_id: event_id,
|
||||
sender: to_string(sender),
|
||||
origin_server_ts: origin_server_ts,
|
||||
room_id: room_id
|
||||
}
|
||||
|
||||
data = if unsigned, do: Map.put(data, :unsigned, unsigned), else: data
|
||||
data = if state_key, do: Map.put(data, :state_key, state_key), else: data
|
||||
|
||||
data
|
||||
end
|
||||
|
||||
def sync_response(%Event{
|
||||
content: content,
|
||||
type: type,
|
||||
id: event_id,
|
||||
|
@ -23,37 +46,11 @@ defmodule Architex.Event.Formatters do
|
|||
origin_server_ts: origin_server_ts
|
||||
}
|
||||
|
||||
if unsigned, do: Map.put(data, :unsigned, unsigned), else: data
|
||||
data = if unsigned, do: Map.put(data, :unsigned, unsigned), else: data
|
||||
|
||||
data
|
||||
end
|
||||
|
||||
@doc """
|
||||
The base event format, with room_id and state_key added.
|
||||
Used in the client /messages response.
|
||||
"""
|
||||
@spec messages_response(Event.t()) :: map()
|
||||
def messages_response(%Event{room_id: room_id, state_key: state_key} = event) do
|
||||
data =
|
||||
base_client_response(event)
|
||||
|> Map.put(:room_id, room_id)
|
||||
|
||||
if state_key, do: Map.put(data, :state_key, state_key), else: data
|
||||
end
|
||||
|
||||
@doc """
|
||||
The event format used in the client /state response.
|
||||
TODO: prev_content
|
||||
"""
|
||||
def state_response(event), do: messages_response(event)
|
||||
|
||||
@doc """
|
||||
The base event format, used in the client /sync response.
|
||||
"""
|
||||
@spec sync_response(Event.t()) :: map()
|
||||
def sync_response(event), do: base_client_response(event)
|
||||
|
||||
@doc """
|
||||
The PDU format used for federation.
|
||||
"""
|
||||
@spec as_pdu(Event.t()) :: map()
|
||||
def as_pdu(%Event{
|
||||
auth_events: auth_events,
|
||||
|
|
|
@ -2,21 +2,16 @@ defmodule Architex.Event.Join do
|
|||
alias Architex.{Event, Account, Room}
|
||||
|
||||
@spec new(Room.t(), Account.t()) :: %Event{}
|
||||
def new(
|
||||
room,
|
||||
%Account{localpart: localpart, avatar_url: avatar_url, displayname: displayname} = sender
|
||||
) do
|
||||
def new(room, %Account{localpart: localpart} = sender) do
|
||||
mxid = Architex.get_mxid(localpart)
|
||||
|
||||
content =
|
||||
Event.default_membership_content(avatar_url, displayname)
|
||||
|> Map.put("membership", "join")
|
||||
|
||||
%Event{
|
||||
Event.new(room, sender)
|
||||
| type: "m.room.member",
|
||||
state_key: mxid,
|
||||
content: content
|
||||
content: %{
|
||||
"membership" => "join"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
@ -24,38 +19,24 @@ end
|
|||
defmodule Architex.Event.CreateRoom do
|
||||
alias Architex.{Event, Account, Room}
|
||||
|
||||
@clobber_content_keys ["creator", "room_version"]
|
||||
|
||||
@spec new(Room.t(), Account.t(), String.t(), %{optional(String.t()) => any()} | nil) :: %Event{}
|
||||
def new(room, %Account{localpart: localpart} = creator, room_version, creation_content) do
|
||||
@spec new(Room.t(), Account.t(), String.t()) :: %Event{}
|
||||
def new(room, %Account{localpart: localpart} = creator, room_version) do
|
||||
mxid = Architex.get_mxid(localpart)
|
||||
|
||||
content = %{
|
||||
"creator" => mxid,
|
||||
"room_version" => room_version || Architex.default_room_version()
|
||||
}
|
||||
|
||||
content =
|
||||
if creation_content do
|
||||
creation_content
|
||||
|> Map.drop(@clobber_content_keys)
|
||||
|> Map.merge(content)
|
||||
else
|
||||
content
|
||||
end
|
||||
|
||||
%Event{
|
||||
Event.new(room, creator)
|
||||
| type: "m.room.create",
|
||||
state_key: "",
|
||||
content: content
|
||||
content: %{
|
||||
"creator" => mxid,
|
||||
"room_version" => room_version || Architex.default_room_version()
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Architex.Event.PowerLevels do
|
||||
alias Architex.{Event, Account, Room}
|
||||
alias Architex.Types.UserId
|
||||
alias ArchitexWeb.Client.Request.CreateRoom
|
||||
alias ArchitexWeb.Client.Request.CreateRoom.PowerLevelContentOverride
|
||||
|
||||
|
@ -69,49 +50,26 @@ defmodule Architex.Event.PowerLevels do
|
|||
@users_default 0
|
||||
@notifications_room 50
|
||||
|
||||
@spec create_room_new(
|
||||
Room.t(),
|
||||
Account.t(),
|
||||
CreateRoom.PowerLevelContentOverride.t(),
|
||||
[UserId.t()] | nil,
|
||||
String.t() | nil
|
||||
) :: %Event{}
|
||||
def create_room_new(room, sender, nil, invite_ids, preset) do
|
||||
create_room_new(room, sender, %PowerLevelContentOverride{}, invite_ids, preset)
|
||||
@spec create_room_new(Room.t(), Account.t(), CreateRoom.plco_t()) :: %Event{}
|
||||
def create_room_new(room, sender, nil) do
|
||||
create_room_new(room, sender, %PowerLevelContentOverride{})
|
||||
end
|
||||
|
||||
def create_room_new(
|
||||
room,
|
||||
%Account{localpart: localpart} = sender,
|
||||
%PowerLevelContentOverride{
|
||||
ban: ban_override,
|
||||
events: events_override,
|
||||
events_default: events_default_override,
|
||||
invite: invite_override,
|
||||
kick: kick_override,
|
||||
redact: redact_override,
|
||||
state_default: state_default_override,
|
||||
users: users_override,
|
||||
users_default: users_default_override,
|
||||
notifications: notifications_override
|
||||
},
|
||||
invite_ids,
|
||||
preset
|
||||
) do
|
||||
def create_room_new(room, %Account{localpart: localpart} = sender, %PowerLevelContentOverride{
|
||||
ban: ban_override,
|
||||
events: events_override,
|
||||
events_default: events_default_override,
|
||||
invite: invite_override,
|
||||
kick: kick_override,
|
||||
redact: redact_override,
|
||||
state_default: state_default_override,
|
||||
users: users_override,
|
||||
users_default: users_default_override,
|
||||
notifications: notifications_override
|
||||
}) do
|
||||
mxid = Architex.get_mxid(localpart)
|
||||
users = %{mxid => @creator}
|
||||
users = if users_override, do: Map.merge(users, users_override), else: users
|
||||
creator_pl = users[mxid]
|
||||
|
||||
# Give each invitee the same power level as the creator.
|
||||
# This overrides the content override, but the spec is not clear on this.
|
||||
users =
|
||||
if preset == "trusted_private_chat" and invite_ids do
|
||||
invite_users_pls = Enum.into(invite_ids, %{}, &{to_string(&1), creator_pl})
|
||||
Map.merge(users, invite_users_pls)
|
||||
else
|
||||
users
|
||||
end
|
||||
|
||||
notifications =
|
||||
case notifications_override do
|
||||
|
@ -222,26 +180,15 @@ end
|
|||
defmodule Architex.Event.Invite do
|
||||
alias Architex.{Event, Account, Room}
|
||||
|
||||
@spec new(
|
||||
Room.t(),
|
||||
Account.t(),
|
||||
String.t(),
|
||||
String.t() | nil,
|
||||
String.t() | nil,
|
||||
boolean() | nil
|
||||
) :: %Event{}
|
||||
def new(room, sender, user_id, avatar_url, displayname, is_direct \\ nil) do
|
||||
content =
|
||||
Event.default_membership_content(avatar_url, displayname)
|
||||
|> Map.put("membership", "invite")
|
||||
|
||||
content = if is_direct != nil, do: Map.put(content, "is_direct", is_direct), else: content
|
||||
|
||||
@spec new(Room.t(), Account.t(), String.t()) :: %Event{}
|
||||
def new(room, sender, user_id) do
|
||||
%Event{
|
||||
Event.new(room, sender)
|
||||
| type: "m.room.member",
|
||||
state_key: user_id,
|
||||
content: content
|
||||
content: %{
|
||||
"membership" => "invite"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
@ -250,16 +197,14 @@ defmodule Architex.Event.Leave do
|
|||
alias Architex.{Event, Account, Room}
|
||||
|
||||
@spec new(Room.t(), Account.t()) :: %Event{}
|
||||
def new(room, %Account{avatar_url: avatar_url, displayname: displayname} = sender) do
|
||||
content =
|
||||
Event.default_membership_content(avatar_url, displayname)
|
||||
|> Map.put("membership", "leave")
|
||||
|
||||
def new(room, sender) do
|
||||
%Event{
|
||||
Event.new(room, sender)
|
||||
| type: "m.room.member",
|
||||
state_key: Account.get_mxid(sender),
|
||||
content: content
|
||||
content: %{
|
||||
"membership" => "leave"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
@ -267,19 +212,9 @@ end
|
|||
defmodule Architex.Event.Kick do
|
||||
alias Architex.{Event, Account, Room}
|
||||
|
||||
@spec new(
|
||||
Room.t(),
|
||||
Account.t(),
|
||||
String.t(),
|
||||
String.t() | nil,
|
||||
String.t() | nil,
|
||||
String.t() | nil
|
||||
) :: %Event{}
|
||||
def new(room, sender, user_id, avatar_url, displayname, reason \\ nil) do
|
||||
content =
|
||||
Event.default_membership_content(avatar_url, displayname)
|
||||
|> Map.put("membership", "leave")
|
||||
|
||||
@spec new(Room.t(), Account.t(), String.t(), String.t() | nil) :: %Event{}
|
||||
def new(room, sender, user_id, reason \\ nil) do
|
||||
content = %{"membership" => "leave"}
|
||||
content = if reason, do: Map.put(content, "reason", reason), else: content
|
||||
|
||||
%Event{
|
||||
|
@ -294,19 +229,9 @@ end
|
|||
defmodule Architex.Event.Ban do
|
||||
alias Architex.{Event, Account, Room}
|
||||
|
||||
@spec new(
|
||||
Room.t(),
|
||||
Account.t(),
|
||||
String.t(),
|
||||
String.t() | nil,
|
||||
String.t() | nil,
|
||||
String.t() | nil
|
||||
) :: %Event{}
|
||||
def new(room, sender, user_id, avatar_url, displayname, reason \\ nil) do
|
||||
content =
|
||||
Event.default_membership_content(avatar_url, displayname)
|
||||
|> Map.put("membership", "ban")
|
||||
|
||||
@spec new(Room.t(), Account.t(), String.t(), String.t() | nil) :: %Event{}
|
||||
def new(room, sender, user_id, reason \\ nil) do
|
||||
content = %{"membership" => "ban"}
|
||||
content = if reason, do: Map.put(content, "reason", reason), else: content
|
||||
|
||||
%Event{
|
||||
|
@ -320,36 +245,15 @@ end
|
|||
|
||||
defmodule Architex.Event.Unban do
|
||||
alias Architex.{Event, Account, Room}
|
||||
|
||||
@spec new(Room.t(), Account.t(), String.t(), String.t() | nil, String.t() | nil) :: %Event{}
|
||||
def new(room, sender, avatar_url, displayname, user_id) do
|
||||
content =
|
||||
Event.default_membership_content(avatar_url, displayname)
|
||||
|> Map.put("membership", "leave")
|
||||
|
||||
@spec new(Room.t(), Account.t(), String.t()) :: %Event{}
|
||||
def new(room, sender, user_id) do
|
||||
%Event{
|
||||
Event.new(room, sender)
|
||||
| type: "m.room.member",
|
||||
state_key: user_id,
|
||||
content: content
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Architex.Event.CanonicalAlias do
|
||||
alias Architex.{Event, Account, Room}
|
||||
|
||||
@spec new(Room.t(), Account.t(), String.t() | nil, [String.t()] | nil) :: %Event{}
|
||||
def new(room, sender, alias_ \\ nil, alt_aliases \\ nil) do
|
||||
content = %{}
|
||||
content = if alias_, do: Map.put(content, "alias", alias_), else: content
|
||||
content = if alt_aliases, do: Map.put(content, "alt_aliases", alt_aliases), else: content
|
||||
|
||||
%Event{
|
||||
Event.new(room, sender)
|
||||
| type: "m.room.canonical_alias",
|
||||
state_key: "",
|
||||
content: content
|
||||
content: %{
|
||||
"membership" => "leave"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,19 +5,18 @@ defmodule Architex.Room do
|
|||
import Ecto.Query
|
||||
|
||||
alias Architex.{Repo, Room, Event, Alias, RoomServer, Account}
|
||||
alias Architex.Types.StateSet
|
||||
alias ArchitexWeb.Client.Request.{CreateRoom, Messages}
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
visibility: :public | :private,
|
||||
state_set: StateSet.t(),
|
||||
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_set, StateSet, load_in_query: false
|
||||
field :state, {:array, {:array, :string}}
|
||||
field :forward_extremities, {:array, :string}
|
||||
has_many :events, Event, foreign_key: :room_id
|
||||
has_many :aliases, Alias, foreign_key: :room_id
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
defmodule Architex.Types.StateSet do
|
||||
use Ecto.Type
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Architex.{Repo, Event}
|
||||
|
||||
@type t :: %{optional({String.t(), String.t()}) => Event.t()}
|
||||
|
||||
def type(), do: {:array, :string}
|
||||
|
||||
def cast(_), do: :error
|
||||
|
||||
def load(event_ids) when is_list(event_ids) do
|
||||
events =
|
||||
Event
|
||||
|> where([e], e.id in ^event_ids)
|
||||
|> Repo.all()
|
||||
|> IO.inspect()
|
||||
|
||||
if length(events) == length(event_ids) do
|
||||
state_set =
|
||||
Enum.into(events, %{}, fn %Event{type: type, state_key: state_key} = event ->
|
||||
{{type, state_key}, event}
|
||||
end)
|
||||
|
||||
{:ok, state_set}
|
||||
else
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
def load(_), do: :error
|
||||
|
||||
def dump(state_set) when is_map(state_set) do
|
||||
dumped =
|
||||
Enum.map(state_set, fn {_, %Event{id: event_id}} ->
|
||||
event_id
|
||||
end)
|
||||
|
||||
{:ok, dumped}
|
||||
end
|
||||
|
||||
def dump(_), do: :error
|
||||
end
|
|
@ -1,11 +1,7 @@
|
|||
defmodule Architex.Types.UserId do
|
||||
use Ecto.Type
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Architex.{Repo, Account}
|
||||
alias Architex.Types.UserId
|
||||
alias ArchitexWeb.Federation.HTTPClient
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
localpart: String.t(),
|
||||
|
@ -57,37 +53,4 @@ defmodule Architex.Types.UserId do
|
|||
|
||||
def dump(%UserId{} = user_id), do: {:ok, to_string(user_id)}
|
||||
def dump(_), do: :error
|
||||
|
||||
# TODO: Seems out of place here.
|
||||
@spec try_get_user_information(UserId.t()) :: {String.t() | nil, String.t() | nil}
|
||||
def try_get_user_information(%UserId{localpart: localpart, domain: domain} = user_id) do
|
||||
if domain == Architex.server_name() do
|
||||
# Get information about a user on this homeserver.
|
||||
query =
|
||||
Account
|
||||
|> where([a], a.localpart == ^localpart)
|
||||
|> select([a], {a.avatar_url, a.displayname})
|
||||
|
||||
case Repo.one(query) do
|
||||
nil -> {nil, nil}
|
||||
info -> info
|
||||
end
|
||||
else
|
||||
# Get information about a user on another homeserver.
|
||||
client = HTTPClient.client(domain)
|
||||
|
||||
case HTTPClient.query_profile(client, to_string(user_id)) do
|
||||
{:ok, response} ->
|
||||
avatar_url = Map.get(response, "avatar_url")
|
||||
avatar_url = if is_binary(avatar_url), do: avatar_url
|
||||
displayname = Map.get(response, "displayname")
|
||||
displayname = if is_binary(displayname), do: displayname
|
||||
|
||||
{avatar_url, displayname}
|
||||
|
||||
{:error, _, _} ->
|
||||
{nil, nil}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,16 +1,14 @@
|
|||
defmodule ArchitexWeb.Client.Request.Ban do
|
||||
use ArchitexWeb.APIRequest
|
||||
|
||||
alias Architex.Types.UserId
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
user_id: UserId.t(),
|
||||
user_id: String.t(),
|
||||
reason: String.t() | nil
|
||||
}
|
||||
|
||||
@primary_key false
|
||||
embedded_schema do
|
||||
field :user_id, UserId
|
||||
field :user_id, :string
|
||||
field :reason, :string
|
||||
end
|
||||
|
||||
|
|
|
@ -1,43 +1,45 @@
|
|||
defmodule ArchitexWeb.Client.Request.CreateRoom do
|
||||
use ArchitexWeb.APIRequest
|
||||
|
||||
alias Architex.Types.UserId
|
||||
@type t :: %__MODULE__{
|
||||
visibility: String.t() | nil,
|
||||
room_alias_name: String.t() | nil,
|
||||
name: String.t() | nil,
|
||||
topic: String.t() | nil,
|
||||
invite: list(String.t()) | nil,
|
||||
room_version: String.t() | nil,
|
||||
preset: String.t() | nil,
|
||||
power_level_content_override: plco_t() | nil
|
||||
}
|
||||
|
||||
defmodule PowerLevelContentOverride do
|
||||
use Ecto.Schema
|
||||
@type plco_t :: %__MODULE__.PowerLevelContentOverride{
|
||||
ban: integer() | nil,
|
||||
events: %{optional(String.t()) => integer()} | nil,
|
||||
events_default: integer() | nil,
|
||||
invite: integer() | nil,
|
||||
kick: integer() | nil,
|
||||
redact: integer() | nil,
|
||||
state_default: integer() | nil,
|
||||
users: %{optional(String.t()) => integer()} | nil,
|
||||
users_default: integer() | nil,
|
||||
notifications: plco_n_t() | nil
|
||||
}
|
||||
|
||||
defmodule Notifications do
|
||||
use Ecto.Schema
|
||||
@type plco_n_t :: %__MODULE__.PowerLevelContentOverride.Notifications{
|
||||
room: integer() | nil
|
||||
}
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
room: integer() | nil
|
||||
}
|
||||
@primary_key false
|
||||
embedded_schema do
|
||||
field :visibility, :string
|
||||
field :room_alias_name, :string
|
||||
field :name, :string
|
||||
field :topic, :string
|
||||
field :invite, {:array, :string}
|
||||
field :room_version, :string
|
||||
field :preset, :string
|
||||
|
||||
@primary_key false
|
||||
embedded_schema do
|
||||
field :room, :integer
|
||||
end
|
||||
|
||||
def changeset(data, params) do
|
||||
cast(data, params, [:room])
|
||||
end
|
||||
end
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
ban: integer() | nil,
|
||||
events: %{optional(String.t()) => integer()} | nil,
|
||||
events_default: integer() | nil,
|
||||
invite: integer() | nil,
|
||||
kick: integer() | nil,
|
||||
redact: integer() | nil,
|
||||
state_default: integer() | nil,
|
||||
users: %{optional(String.t()) => integer()} | nil,
|
||||
users_default: integer() | nil,
|
||||
notifications: Notifications.t() | nil
|
||||
}
|
||||
|
||||
@primary_key false
|
||||
embedded_schema do
|
||||
embeds_one :power_level_content_override, PowerLevelContentOverride, primary_key: false do
|
||||
field :ban, :integer
|
||||
field :events, {:map, :integer}
|
||||
field :events_default, :integer
|
||||
|
@ -48,81 +50,13 @@ defmodule ArchitexWeb.Client.Request.CreateRoom do
|
|||
field :users, {:map, :integer}
|
||||
field :users_default, :integer
|
||||
|
||||
embeds_one :notifications, Notifications
|
||||
embeds_one :notifications, Notifications, primary_key: false do
|
||||
field :room, :integer
|
||||
end
|
||||
end
|
||||
|
||||
def changeset(data, params) do
|
||||
data
|
||||
|> cast(params, [
|
||||
:ban,
|
||||
:events,
|
||||
:events_default,
|
||||
:invite,
|
||||
:kick,
|
||||
:redact,
|
||||
:state_default,
|
||||
:users,
|
||||
:users_default
|
||||
])
|
||||
|> cast_embed(:notifications,
|
||||
with: &Notifications.changeset/2,
|
||||
required: false
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defmodule StateEvent do
|
||||
use Ecto.Schema
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
type: String.t(),
|
||||
state_key: String.t(),
|
||||
content: %{optional(String.t()) => any()}
|
||||
}
|
||||
|
||||
@primary_key false
|
||||
embedded_schema do
|
||||
field :type, :string
|
||||
field :state_key, :string, default: ""
|
||||
field :content, :map
|
||||
end
|
||||
|
||||
def changeset(data, params) do
|
||||
data
|
||||
|> cast(params, [:type, :state_key, :content])
|
||||
|> validate_required([:type, :content])
|
||||
end
|
||||
end
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
visibility: String.t() | nil,
|
||||
room_alias_name: String.t() | nil,
|
||||
name: String.t() | nil,
|
||||
topic: String.t() | nil,
|
||||
invite: [UserId.t()] | nil,
|
||||
room_version: String.t() | nil,
|
||||
preset: String.t() | nil,
|
||||
is_direct: boolean() | nil,
|
||||
creation_content: %{optional(String.t()) => any()} | nil,
|
||||
power_level_content_override: PowerLevelContentOverride.t() | nil,
|
||||
initial_state: [StateEvent.t()] | nil
|
||||
}
|
||||
|
||||
@primary_key false
|
||||
embedded_schema do
|
||||
# TODO: unimplemented: invite_3pid and room_alias_name
|
||||
field :visibility, :string
|
||||
field :room_alias_name, :string
|
||||
field :name, :string
|
||||
field :topic, :string
|
||||
field :invite, {:array, UserId}
|
||||
field :room_version, :string
|
||||
field :preset, :string
|
||||
field :is_direct, :boolean
|
||||
field :creation_content, :map
|
||||
|
||||
embeds_many :initial_state, StateEvent
|
||||
embeds_one :power_level_content_override, PowerLevelContentOverride
|
||||
# TODO: unimplemented:
|
||||
# creation_content, initial_state, invite_3pid, initial_state, is_direct
|
||||
end
|
||||
|
||||
def changeset(data, params) do
|
||||
|
@ -134,15 +68,35 @@ defmodule ArchitexWeb.Client.Request.CreateRoom do
|
|||
:topic,
|
||||
:invite,
|
||||
:room_version,
|
||||
:preset,
|
||||
:is_direct,
|
||||
:creation_content
|
||||
:preset
|
||||
])
|
||||
|> cast_embed(:power_level_content_override,
|
||||
with: &PowerLevelContentOverride.changeset/2,
|
||||
with: &power_level_content_override_changeset/2,
|
||||
required: false
|
||||
)
|
||||
|> cast_embed(:initial_state, with: &StateEvent.changeset/2, required: false)
|
||||
|> validate_inclusion(:preset, ["private_chat", "public_chat", "trusted_private_chat"])
|
||||
end
|
||||
|
||||
def power_level_content_override_changeset(data, params) do
|
||||
data
|
||||
|> cast(params, [
|
||||
:ban,
|
||||
:events,
|
||||
:events_default,
|
||||
:invite,
|
||||
:kick,
|
||||
:redact,
|
||||
:state_default,
|
||||
:users,
|
||||
:users_default
|
||||
])
|
||||
|> cast_embed(:notifications,
|
||||
with: &power_level_content_override_notifications_changeset/2,
|
||||
required: false
|
||||
)
|
||||
end
|
||||
|
||||
def power_level_content_override_notifications_changeset(data, params) do
|
||||
cast(data, params, [:room])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,16 +1,14 @@
|
|||
defmodule ArchitexWeb.Client.Request.Kick do
|
||||
use ArchitexWeb.APIRequest
|
||||
|
||||
alias Architex.Types.UserId
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
user_id: UserId.t(),
|
||||
user_id: String.t(),
|
||||
reason: String.t() | nil
|
||||
}
|
||||
|
||||
@primary_key false
|
||||
embedded_schema do
|
||||
field :user_id, UserId
|
||||
field :user_id, :string
|
||||
field :reason, :string
|
||||
end
|
||||
|
||||
|
|
|
@ -25,9 +25,6 @@ defmodule ArchitexWeb.Client.RoomController do
|
|||
{:error, :authorization} ->
|
||||
put_error(conn, :invalid_room_state)
|
||||
|
||||
{:error, :alias} ->
|
||||
put_error(conn, :room_in_use, "The requested alias is already in use.")
|
||||
|
||||
{:error, :unknown} ->
|
||||
put_error(conn, :unknown)
|
||||
end
|
||||
|
@ -63,11 +60,9 @@ defmodule ArchitexWeb.Client.RoomController do
|
|||
"room_id" => room_id,
|
||||
"user_id" => user_id
|
||||
}) do
|
||||
with {:ok, user_id_struct} <- UserId.cast(user_id),
|
||||
with {:ok, _} <- UserId.cast(user_id),
|
||||
{:ok, pid} <- RoomServer.get_room_server(room_id) do
|
||||
{avatar_url, displayname} = UserId.try_get_user_information(user_id_struct)
|
||||
|
||||
case RoomServer.invite(pid, account, user_id, avatar_url, displayname) do
|
||||
case RoomServer.invite(pid, account, user_id) do
|
||||
:ok ->
|
||||
conn
|
||||
|> send_resp(200, [])
|
||||
|
@ -137,11 +132,9 @@ defmodule ArchitexWeb.Client.RoomController do
|
|||
Action for POST /_matrix/client/r0/rooms/{roomId}/kick.
|
||||
"""
|
||||
def kick(%Conn{assigns: %{account: account}} = conn, %{"room_id" => room_id} = params) do
|
||||
with {:ok, %Kick{user_id: user_id} = request} <- Kick.parse(params),
|
||||
with {:ok, request} <- Kick.parse(params),
|
||||
{:ok, pid} <- RoomServer.get_room_server(room_id) do
|
||||
{avatar_url, displayname} = UserId.try_get_user_information(user_id)
|
||||
|
||||
case RoomServer.kick(pid, account, request, avatar_url, displayname) do
|
||||
case RoomServer.kick(pid, account, request) do
|
||||
:ok ->
|
||||
conn
|
||||
|> send_resp(200, [])
|
||||
|
@ -162,11 +155,9 @@ defmodule ArchitexWeb.Client.RoomController do
|
|||
Action for POST /_matrix/client/r0/rooms/{roomId}/ban.
|
||||
"""
|
||||
def ban(%Conn{assigns: %{account: account}} = conn, %{"room_id" => room_id} = params) do
|
||||
with {:ok, %Ban{user_id: user_id} = request} <- Ban.parse(params),
|
||||
with {:ok, request} <- Ban.parse(params),
|
||||
{:ok, pid} <- RoomServer.get_room_server(room_id) do
|
||||
{avatar_url, displayname} = UserId.try_get_user_information(user_id)
|
||||
|
||||
case RoomServer.ban(pid, account, request, avatar_url, displayname) do
|
||||
case RoomServer.ban(pid, account, request) do
|
||||
:ok ->
|
||||
conn
|
||||
|> send_resp(200, [])
|
||||
|
@ -190,22 +181,20 @@ defmodule ArchitexWeb.Client.RoomController do
|
|||
"room_id" => room_id,
|
||||
"user_id" => user_id
|
||||
}) do
|
||||
with {:ok, user_id_struct} <- UserId.cast(user_id),
|
||||
{:ok, pid} <- RoomServer.get_room_server(room_id) do
|
||||
{avatar_url, displayname} = UserId.try_get_user_information(user_id_struct)
|
||||
case RoomServer.get_room_server(room_id) do
|
||||
{:ok, pid} ->
|
||||
case RoomServer.unban(pid, account, user_id) do
|
||||
:ok ->
|
||||
conn
|
||||
|> send_resp(200, [])
|
||||
|> halt()
|
||||
|
||||
case RoomServer.unban(pid, account, user_id, avatar_url, displayname) do
|
||||
:ok ->
|
||||
conn
|
||||
|> send_resp(200, [])
|
||||
|> halt()
|
||||
{:error, _} ->
|
||||
put_error(conn, :unknown)
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
put_error(conn, :unknown)
|
||||
end
|
||||
else
|
||||
:error -> put_error(conn, :invalid_param, "Given user ID is invalid.")
|
||||
{:error, :not_found} -> put_error(conn, :not_found, "The given room was not found.")
|
||||
{:error, :not_found} ->
|
||||
put_error(conn, :not_found, "The given room was not found.")
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -311,75 +300,4 @@ defmodule ArchitexWeb.Client.RoomController do
|
|||
{:error, _} -> put_error(conn, :bad_json)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get the state events for the current state of a room.
|
||||
|
||||
Action for GET /_matrix/client/r0/rooms/{roomId}/state.
|
||||
"""
|
||||
def get_state(%Conn{assigns: %{account: account}} = conn, %{"room_id" => room_id}) do
|
||||
case RoomServer.get_room_server(room_id) do
|
||||
{:ok, pid} ->
|
||||
case RoomServer.get_current_state(pid, account) do
|
||||
{:ok, events} ->
|
||||
events = Enum.map(events, &Event.Formatters.state_response/1)
|
||||
|
||||
conn
|
||||
|> put_status(200)
|
||||
|> json(events)
|
||||
|
||||
:error ->
|
||||
put_error(
|
||||
conn,
|
||||
:forbidden,
|
||||
"You aren't a member of the room and weren't previously a member of the room."
|
||||
)
|
||||
end
|
||||
|
||||
{:error, :not_found} ->
|
||||
put_error(conn, :not_found, "The given room was not found.")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Looks up the contents of a state event in a room.
|
||||
|
||||
Action for GET /_matrix/client/r0/rooms/{roomId}/state/{eventType}/{stateKey}.
|
||||
"""
|
||||
def get_state_event(conn, %{"state_key" => [state_key | _]} = params) do
|
||||
do_get_state_event(conn, params, state_key)
|
||||
end
|
||||
|
||||
def get_state_event(conn, params) do
|
||||
do_get_state_event(conn, params, "")
|
||||
end
|
||||
|
||||
defp do_get_state_event(
|
||||
%Conn{assigns: %{account: account}} = conn,
|
||||
%{"room_id" => room_id, "event_type" => event_type},
|
||||
state_key
|
||||
) do
|
||||
case RoomServer.get_room_server(room_id) do
|
||||
{:ok, pid} ->
|
||||
case RoomServer.get_state_event(pid, account, event_type, state_key) do
|
||||
{:ok, content} ->
|
||||
conn
|
||||
|> put_status(200)
|
||||
|> json(content)
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
put_error(
|
||||
conn,
|
||||
:forbidden,
|
||||
"You aren't a member of the room and weren't previously a member of the room."
|
||||
)
|
||||
|
||||
{:error, :not_found} ->
|
||||
put_error(conn, :not_found, "The room has no state with the given type or key.")
|
||||
end
|
||||
|
||||
{:error, :not_found} ->
|
||||
put_error(conn, :not_found, "The given room was not found.")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -14,7 +14,6 @@ defmodule ArchitexWeb.Error do
|
|||
unauthorized: {400, "M_UNAUTHORIZED", "The request was unauthorized."},
|
||||
invalid_param: {400, "M_INVALID_PARAM", "A request parameter was invalid."},
|
||||
missing_param: {400, "M_MISSING_PARAM", "A request parameter is missing."},
|
||||
room_in_use: {400, "M_ROOM_IN_USE", "The room is already in use."},
|
||||
unknown_token: {401, "M_UNKNOWN_TOKEN", "Invalid access token."},
|
||||
missing_token: {401, "M_MISSING_TOKEN", "Access token required."},
|
||||
not_found: {404, "M_NOT_FOUND", "The requested resource was not found."},
|
||||
|
|
|
@ -33,8 +33,6 @@ defmodule ArchitexWeb.Federation.HTTPClient do
|
|||
"""
|
||||
@spec client(String.t()) :: Tesla.Client.t()
|
||||
def client(server_name) do
|
||||
# TODO: When implementing resolving homeservers, probably create
|
||||
# a homeserver struct instead of using domain names directly.
|
||||
Tesla.client(
|
||||
[
|
||||
{Tesla.Middleware.Opts, [server_name: server_name]},
|
||||
|
|
|
@ -61,16 +61,13 @@ defmodule ArchitexWeb.Router do
|
|||
|
||||
scope "/r0" do
|
||||
get "/account/whoami", AccountController, :whoami
|
||||
post "/logout", AccountController, :logout
|
||||
post "/logout/all", AccountController, :logout_all
|
||||
post "/createRoom", RoomController, :create
|
||||
get "/joined_rooms", RoomController, :joined_rooms
|
||||
get "/capabilities", InfoController, :capabilities
|
||||
get "/sync", SyncController, :sync
|
||||
|
||||
scope "/logout" do
|
||||
post "/", AccountController, :logout
|
||||
post "/all", AccountController, :logout_all
|
||||
end
|
||||
|
||||
scope "/profile/:user_id" do
|
||||
put "/avatar_url", ProfileController, :set_avatar_url
|
||||
put "/displayname", ProfileController, :set_displayname
|
||||
|
@ -90,15 +87,7 @@ defmodule ArchitexWeb.Router do
|
|||
post "/unban", RoomController, :unban
|
||||
put "/send/:event_type/:txn_id", RoomController, :send_message_event
|
||||
get "/messages", RoomController, :messages
|
||||
|
||||
scope "/state" do
|
||||
get "/", RoomController, :get_state
|
||||
|
||||
scope "/:event_type/*state_key" do
|
||||
get "/", RoomController, :get_state_event
|
||||
put "/", RoomController, :send_state_event
|
||||
end
|
||||
end
|
||||
put "/state/:event_type/*state_key", RoomController, :send_state_event
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -14,7 +14,7 @@ defmodule Architex.Repo.Migrations.CreateInitialTables do
|
|||
|
||||
create table(:rooms, primary_key: false) do
|
||||
add :id, :string, primary_key: true
|
||||
add :state_set, {:array, :string}, default: [], null: false
|
||||
add :state, {:array, {:array, :string}}, default: [], null: false
|
||||
add :forward_extremities, {:array, :string}, default: [], null: false
|
||||
add :visibility, :string, null: false, default: "public"
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue