diff --git a/lib/matrix_server.ex b/lib/matrix_server.ex index cdc7364..0e302ea 100644 --- a/lib/matrix_server.ex +++ b/lib/matrix_server.ex @@ -1,35 +1,4 @@ defmodule MatrixServer do - import Ecto.Changeset - alias Ecto.Changeset - - def convert_change(changeset, old_name, new_name) do - convert_change(changeset, old_name, new_name, &Function.identity/1) - end - - def convert_change(changeset, old_name, new_name, f) do - case changeset do - %Changeset{valid?: true, changes: changes} -> - case Map.fetch(changes, old_name) do - {:ok, value} -> - changeset - |> put_change(new_name, f.(value)) - |> delete_change(old_name) - - :error -> - changeset - end - - _ -> - changeset - end - end - - def validate_api_schema(params, {types, allowed, required}) do - {%{}, types} - |> cast(params, allowed) - |> validate_required(required) - end - def get_mxid(localpart) when is_binary(localpart) do "@#{localpart}:#{server_name()}" end @@ -38,11 +7,11 @@ defmodule MatrixServer do Application.get_env(:matrix_server, :server_name) end - def update_map_entry(map, old_key, new_key) do - update_map_entry(map, old_key, new_key, &Function.identity/1) + def maybe_update_map(map, old_key, new_key) do + maybe_update_map(map, old_key, new_key, &Function.identity/1) end - def update_map_entry(map, old_key, new_key, fun) when is_map_key(map, old_key) do + def maybe_update_map(map, old_key, new_key, fun) when is_map_key(map, old_key) do value = Map.fetch!(map, old_key) map @@ -50,5 +19,12 @@ defmodule MatrixServer do |> Map.delete(old_key) end - def update_map_entry(map, _, _, _), do: map + def maybe_update_map(map, _, _, _), do: map + + def localpart_regex, do: ~r/^([a-z0-9\._=\/])+$/ + + @alphabet Enum.into(?a..?z, []) ++ Enum.into(?A..?Z, []) + def random_string(length) when length >= 1 do + for _ <- 1..length, into: "", do: <> + end end diff --git a/lib/matrix_server/account.ex b/lib/matrix_server/account.ex index 9be08cb..804fbcb 100644 --- a/lib/matrix_server/account.ex +++ b/lib/matrix_server/account.ex @@ -1,14 +1,12 @@ defmodule MatrixServer.Account do use Ecto.Schema - import MatrixServer import Ecto.{Changeset, Query} alias MatrixServer.{Repo, Account, Device} alias Ecto.Multi @max_mxid_length 255 - @localpart_regex ~r/^([a-z0-9\._=\/])+$/ @primary_key {:localpart, :string, []} schema "accounts" do @@ -18,7 +16,7 @@ defmodule MatrixServer.Account do end def available?(localpart) when is_binary(localpart) do - if Regex.match?(@localpart_regex, localpart) and + if Regex.match?(MatrixServer.localpart_regex(), localpart) and String.length(localpart) <= localpart_length() do if Repo.one!( Account @@ -40,6 +38,7 @@ defmodule MatrixServer.Account do |> Multi.insert(:device, fn %{account: account} -> device_id = Device.generate_device_id(account.localpart) + # TODO: fix device_id with UUID Ecto.build_assoc(account, :devices) |> Map.put(:device_id, device_id) |> Device.changeset(params) @@ -47,6 +46,28 @@ defmodule MatrixServer.Account do |> Multi.run(:device_with_access_token, &Device.insert_new_access_token/2) end + def login(%{localpart: localpart, password: password} = params) do + 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 + device_id = Map.get(params, :device_id, Device.generate_device_id(localpart)) + access_token = Device.generate_access_token(localpart, device_id) + + case Device.login(account, device_id, access_token, params) do + {:ok, device} -> device + {:error, _cs} -> repo.rollback(:forbidden) + end + else + repo.rollback(:forbidden) + end + + nil -> + repo.rollback(:forbidden) + end + end + end + def by_access_token(access_token) do Device |> where([d], d.access_token == ^access_token) @@ -60,13 +81,13 @@ defmodule MatrixServer.Account do |> cast(params, [:localpart, :password_hash]) |> validate_required([:localpart, :password_hash]) |> validate_length(:password_hash, max: 60) - |> validate_format(:localpart, @localpart_regex) + |> validate_format(:localpart, MatrixServer.localpart_regex()) |> validate_length(:localpart, max: localpart_length()) |> unique_constraint(:localpart, name: :accounts_pkey) end defp localpart_length do # Subtract the "@" and ":" in the MXID. - @max_mxid_length - 2 - String.length(server_name()) + @max_mxid_length - 2 - String.length(MatrixServer.server_name()) end end diff --git a/lib/matrix_server/application.ex b/lib/matrix_server/application.ex index 1b9ca12..a826484 100644 --- a/lib/matrix_server/application.ex +++ b/lib/matrix_server/application.ex @@ -7,14 +7,11 @@ defmodule MatrixServer.Application do def start(_type, _args) do children = [ - # Start the Ecto repository MatrixServer.Repo, - # Start the Telemetry supervisor MatrixServerWeb.Telemetry, - # Start the PubSub system {Phoenix.PubSub, name: MatrixServer.PubSub}, - # Start the Endpoint (http/https) - MatrixServerWeb.Endpoint + MatrixServerWeb.Endpoint, + MatrixServer.RoomServer # Start a worker by calling: MatrixServer.Worker.start_link(arg) # {MatrixServer.Worker, arg} ] diff --git a/lib/matrix_server/device.ex b/lib/matrix_server/device.ex index dcbc8d4..1381ed4 100644 --- a/lib/matrix_server/device.ex +++ b/lib/matrix_server/device.ex @@ -8,7 +8,7 @@ defmodule MatrixServer.Device do @primary_key false schema "devices" do field :device_id, :string, primary_key: true - field :access_token, :string + field :access_token, :string, redact: true field :display_name, :string belongs_to :account, Account, diff --git a/lib/matrix_server/event.ex b/lib/matrix_server/event.ex index d927296..8087836 100644 --- a/lib/matrix_server/event.ex +++ b/lib/matrix_server/event.ex @@ -1,466 +1,25 @@ -# https://matrix.uhoreg.ca/stateres/reloaded.html defmodule MatrixServer.Event do - @derive {Inspect, except: [:prev_events, :auth_events]} - defstruct [ - :event_id, - :event_type, - :timestamp, - :state_key, - :sender, - :content, - :prev_events, - :auth_events, - :power_levels - ] + use Ecto.Schema - alias __MODULE__, as: Event + import Ecto.Changeset - @type t :: %Event{event_id: String.t(), event_type: Atom.t(), timestamp: Integer.t()} + alias MatrixServer.Room - def new_state_event, do: %Event{new() | event_type: :state} - def new_message_event, do: %Event{new() | event_type: :message} - - # TODO: remove state_key default here - def new do - %Event{ - event_id: "", - timestamp: 0, - state_key: "", - sender: "", - content: "", - prev_events: [], - auth_events: [], - power_levels: %{} - } + schema "events" do + field :type, :string + field :timestamp, :naive_datetime + field :state_key, :string + field :sender, :string + field :content, :string + field :prev_events, {:array, :string} + field :auth_events, {:array, :string} + belongs_to :room, Room end - def join(user), do: %Event{membership(user) | content: "join"} - def leave(user), do: %Event{membership(user) | content: "leave"} - def invite(actor, subject), do: %Event{membership(actor, subject) | content: "invite"} - def kick(actor, subject), do: %Event{membership(actor, subject) | content: "leave"} - def ban(actor, subject), do: %Event{membership(actor, subject) | content: "ban"} - - def set_power_levels(user, power_levels) do - %Event{new() | event_type: :power_levels, sender: user, power_levels: power_levels} - end - - def set_topic(user, topic) do - %Event{new() | event_type: :topic, sender: user, content: topic} - end - - def get_state_set_from_event_list(events) do - Enum.reduce(events, %{}, fn - %Event{event_type: event_type, state_key: state_key} = event, acc -> - Map.put(acc, {event_type, state_key}, event) - end) - end - - def auth_chain(event), do: auth_chain(event, MapSet.new()) - - def auth_chain(%Event{auth_events: auth_events}, set) do - Enum.reduce(auth_events, set, fn event, acc -> - event - |> auth_chain() - |> MapSet.union(acc) - |> MapSet.put(event) - end) - end - - def in_room(user, state_set) when is_map_key(state_set, {:membership, user}) do - state_set[{:membership, user}].content == "join" - end - - def in_room(_, _), do: false - - def get_power_levels(state_set) when is_map_key(state_set, {:power_levels, ""}) do - state_set[{:power_levels, ""}].power_levels - end - - def get_power_levels(_), do: nil - - def has_power_level(_, nil, _), do: true - - def has_power_level(user, power_levels, level) do - Map.get(power_levels, user, 0) >= level - end - - # No join rules specified, allow joining for room creator only. - def allowed_to_join(user, state_set) when not is_map_key(state_set, {:join_rules, ""}) do - state_set[{:create, ""}].sender == user - end - - # TODO: join and power levels events - def is_authorized(%Event{event_type: :create, prev_events: prev_events}, _), - do: prev_events == [] - - def is_authorized(%Event{event_type: :membership, content: "join", state_key: user}, state_set) do - IO.puts("WORKING YO") - allowed_to_join(user, state_set) - end - - def is_authorized(%Event{sender: sender} = event, state_set) do - in_room(sender, state_set) and - has_power_level(sender, get_power_levels(state_set), get_event_power_level(event)) - end - - def is_authorized2(%Event{auth_events: auth_events} = event, state_set) do - state_set = - Enum.reduce(auth_events, state_set, fn %Event{event_type: event_type, state_key: state_key} = - event, - acc -> - Map.put_new(acc, {event_type, state_key}, event) - end) - - is_authorized(event, state_set) - end - - def iterative_auth_checks(events, state_set) do - Enum.reduce(events, state_set, fn event, acc -> - if is_authorized2(event, acc), do: insert_event(event, acc), else: acc - end) - end - - def insert_event(%Event{event_type: event_type, state_key: state_key} = event, state_set) do - Map.put(state_set, {event_type, state_key}, event) - end - - def is_control_event(%Event{event_type: :power_levels, state_key: ""}), do: true - - def is_control_event(%Event{event_type: :join_rules, state_key: ""}), do: true - - def is_control_event(%Event{ - event_type: :membership, - state_key: state_key, - sender: sender, - content: "ban" - }), - do: sender != state_key - - def is_control_event(%Event{ - event_type: :membership, - state_key: state_key, - sender: sender, - content: "leave" - }), - do: sender != state_key - - def is_control_event(_), do: false - - def calculate_conflict(state_sets) do - domain = - state_sets - |> Enum.map(&Map.keys/1) - |> List.flatten() - |> MapSet.new() - - full_state_map_list = - Enum.map(domain, fn k -> - events = - Enum.map(state_sets, &Map.get(&1, k)) - |> MapSet.new() - - {k, events} - end) - - {unconflicted, conflicted} = - Enum.split_with(full_state_map_list, fn {_k, events} -> - MapSet.size(events) == 1 - end) - - unconflicted_state_map = - Enum.map(unconflicted, fn {k, events} -> - event = - events - |> MapSet.to_list() - |> hd() - - {k, event} - end) - |> Enum.into(%{}) - - conflicted_state_map = - Enum.flat_map(conflicted, fn {_, events} -> - events - |> MapSet.delete(nil) - |> MapSet.to_list() - end) - |> MapSet.new() - - {unconflicted_state_map, conflicted_state_map} - end - - def full_auth_chain(events) do - events - |> Enum.map(&auth_chain/1) - |> Enum.reduce(MapSet.new(), &MapSet.union/2) - end - - def auth_difference(state_sets) do - full_auth_chains = - Enum.map(state_sets, fn state_set -> - state_set - |> Map.values() - |> full_auth_chain() - 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 - - def rev_top_pow_order( - %Event{timestamp: timestamp1, event_id: event_id1} = event1, - %Event{timestamp: timestamp2, event_id: event_id2} = event2 - ) do - {power1, power2} = {get_power_level(event1), get_power_level(event2)} - - if power1 == power2 do - if timestamp1 == timestamp2 do - event_id1 <= event_id2 - else - timestamp1 < timestamp2 - end - else - power1 < power2 - end - end - - def get_power_level(%Event{sender: sender, auth_events: auth_events}) do - pl_event = Enum.find(auth_events, &(&1.event_type == :power_levels)) - - case pl_event do - %Event{power_levels: power_levels} -> Map.get(power_levels, sender, 0) - _ -> 0 - end - end - - def mainline(event) do + def changeset(event, params \\ %{}) do + # TODO: prev/auth events? event - |> mainline([]) - |> Enum.reverse() + |> cast(params, [:type, :timestamp, :state_key, :sender, :content]) + |> validate_required([:type, :timestamp, :sender]) end - - def mainline(%Event{auth_events: auth_events} = event, acc) do - case Enum.find(auth_events, &(&1.event_type == :power_levels)) do - nil -> [event | acc] - pl_event -> mainline(pl_event, [event | acc]) - end - end - - def mainline_order(p) do - mainline_map = - p - |> mainline() - |> Enum.with_index() - |> Enum.into(%{}) - - fn %Event{timestamp: timestamp1, event_id: event_id1} = event1, - %Event{timestamp: timestamp2, event_id: event_id2} = event2 -> - mainline_depth1 = get_mainline_depth(mainline_map, event1) - mainline_depth2 = get_mainline_depth(mainline_map, event2) - - 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) do - mainline = mainline(event) - - 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 - - def resolve(state_sets) do - {unconflicted_state_map, conflicted_set} = calculate_conflict(state_sets) - full_conflicted_set = MapSet.union(conflicted_set, auth_difference(state_sets)) - - conflicted_control_events = - Enum.filter(full_conflicted_set, &is_control_event/1) |> MapSet.new() - - conflicted_control_events_with_auth = - MapSet.union( - conflicted_control_events, - MapSet.intersection( - full_conflicted_set, - full_auth_chain(MapSet.to_list(conflicted_control_events)) - ) - ) - - sorted_control_events = Enum.sort(conflicted_control_events_with_auth, &rev_top_pow_order/2) - partial_resolved_state = iterative_auth_checks(sorted_control_events, unconflicted_state_map) - - other_conflicted_events = MapSet.difference(full_conflicted_set, conflicted_control_events_with_auth) - - resolved_power_levels = partial_resolved_state[{:power_levels, ""}] - sorted_other_events = Enum.sort(other_conflicted_events, mainline_order(resolved_power_levels)) - nearly_final_state = iterative_auth_checks(sorted_other_events, partial_resolved_state) - - Map.merge(nearly_final_state, unconflicted_state_map) - end - - def example1 do - create = %Event{new() | event_id: "create", event_type: :create, sender: "@alice:example.com"} - - alice_joins = %Event{ - join("@alice:example.com") - | event_id: "alice joins", - prev_events: [create], - auth_events: [create] - } - - pl = %Event{ - set_power_levels("@alice:example.com", %{"@alice:example.com" => 100}) - | event_id: "power level", - prev_events: [alice_joins], - auth_events: [alice_joins, create] - } - - join_rules = %Event{ - new() - | event_id: "join rules", - event_type: :join_rules, - sender: "@alice:example.com", - content: "private", - prev_events: [pl], - auth_events: [pl, alice_joins, create] - } - - invite_bob = %Event{ - invite("@alice:example.com", "@bob:example.com") - | event_id: "invite bob", - prev_events: [join_rules], - auth_events: [pl, alice_joins, create] - } - - invite_carol = %Event{ - invite("@alice:example.com", "@carol:example.com") - | event_id: "invite carol", - prev_events: [invite_bob], - auth_events: [pl, alice_joins, create] - } - - bob_join = %Event{ - join("@bob:example.com") - | event_id: "bob joins", - prev_events: [invite_carol], - auth_events: [invite_bob, join_rules, create] - } - - [create, alice_joins, pl, join_rules, invite_bob, invite_carol, bob_join] - end - - def example2 do - create = %Event{ - new_state_event() - | event_id: "create", - event_type: :create, - sender: "@alice:example.com" - } - - alice_joins = join("@alice:example.com") - - pl1 = %Event{ - set_power_levels("@alice:example.com", %{"@alice:example.com" => 100}) - | event_id: "power levels 1" - } - - pl2 = %Event{ - set_power_levels("@alice:example.com", %{ - "@alice:example.com" => 100, - "@bob:example.com" => 50 - }) - | event_id: "power levels 2" - } - - topic = %Event{set_topic("@alice:example.com", "This is a topic") | event_id: "topic"} - - state_set1 = get_state_set_from_event_list([create, alice_joins, pl1]) - state_set2 = get_state_set_from_event_list([create, alice_joins, pl2, topic]) - state_set3 = get_state_set_from_event_list([create, alice_joins, pl2]) - [state_set1, state_set2, state_set3] - end - - def example3 do - pl1 = %Event{set_power_levels("alice", %{}) | event_id: "pl1", timestamp: 1} - - pl2 = %Event{ - set_power_levels("alice", %{}) - | event_id: "pl2", - auth_events: [pl1], - timestamp: 2 - } - - pl3 = %Event{ - set_power_levels("alice", %{}) - | event_id: "pl3", - auth_events: [pl1], - timestamp: 4 - } - - pl4 = %Event{ - set_power_levels("alice", %{}) - | event_id: "pl4", - auth_events: [pl2], - timestamp: 6 - } - - pl5 = %Event{ - set_power_levels("alice", %{}) - | event_id: "pl5", - auth_events: [pl4], - timestamp: 6 - } - - pl6 = %Event{ - set_power_levels("alice", %{}) - | event_id: "pl6", - auth_events: [pl4], - timestamp: 5 - } - - pl7 = %Event{ - set_power_levels("alice", %{}) - | event_id: "pl7", - auth_events: [pl2], - timestamp: 5 - } - - pl8 = %Event{ - set_power_levels("alice", %{}) - | event_id: "pl8", - auth_events: [pl7], - timestamp: 6 - } - - [pl1, pl2, pl3, pl4, pl5, pl6, pl7, pl8] - end - - defp membership(user), do: membership(user, user) - - defp membership(actor, subject) do - %Event{new() | event_type: :membership, sender: actor, state_key: subject} - end - - defp get_event_power_level(%Event{state_key: ""}), do: 0 - defp get_event_power_level(_), do: 50 end diff --git a/lib/matrix_server/room.ex b/lib/matrix_server/room.ex new file mode 100644 index 0000000..801d173 --- /dev/null +++ b/lib/matrix_server/room.ex @@ -0,0 +1,29 @@ +defmodule MatrixServer.Room do + use Ecto.Schema + + import Ecto.Changeset + + alias __MODULE__ + alias MatrixServerWeb.API.CreateRoom + alias Ecto.Multi + + @primary_key {:id, :string, []} + schema "rooms" do + field :visibility, Ecto.Enum, values: [:public, :private] + end + + def create(%CreateRoom{} = api) do + Multi.new() + |> Multi.insert(:room, Room.create_changeset(api)) + end + + def changeset(room, params \\ %{}) do + room + |> cast(params, [:visibility]) + end + + def create_changeset(%CreateRoom{} = api) do + %Room{visibility: api.visibility, id: MatrixServer.random_string(18)} + |> changeset() + end +end diff --git a/lib/matrix_server/room_server.ex b/lib/matrix_server/room_server.ex new file mode 100644 index 0000000..0c028ee --- /dev/null +++ b/lib/matrix_server/room_server.ex @@ -0,0 +1,27 @@ +defmodule MatrixServer.RoomServer do + use GenServer + + alias MatrixServer.{Repo, Room} + alias MatrixServerWeb.API.CreateRoom + + def start_link(_opts) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def create_room(params) do + GenServer.call(__MODULE__, {:create_room, params}) + end + + @impl true + def init(:ok) do + {:ok, %{}} + end + + @impl true + def handle_call({:create_room, %CreateRoom{} = api}, _from, state) do + Room.create(api) + |> Repo.transaction() + + {:reply, :ok, state} + end +end diff --git a/lib/matrix_server/state_resolution.ex b/lib/matrix_server/state_resolution.ex new file mode 100644 index 0000000..d6eeec2 --- /dev/null +++ b/lib/matrix_server/state_resolution.ex @@ -0,0 +1,469 @@ +# https://matrix.uhoreg.ca/stateres/reloaded.html +defmodule MatrixServer.StateResolution do + @derive {Inspect, except: [:prev_events, :auth_events]} + defstruct [ + :event_id, + :event_type, + :timestamp, + :state_key, + :sender, + :content, + :prev_events, + :auth_events, + :power_levels + ] + + alias __MODULE__, as: Event + + @type t :: %Event{event_id: String.t(), event_type: Atom.t(), timestamp: Integer.t()} + + def new_state_event, do: %Event{new() | event_type: :state} + def new_message_event, do: %Event{new() | event_type: :message} + + # TODO: remove state_key default here + def new do + %Event{ + event_id: "", + timestamp: 0, + state_key: "", + sender: "", + content: "", + prev_events: [], + auth_events: [], + power_levels: %{} + } + end + + def join(user), do: %Event{membership(user) | content: "join"} + def leave(user), do: %Event{membership(user) | content: "leave"} + def invite(actor, subject), do: %Event{membership(actor, subject) | content: "invite"} + def kick(actor, subject), do: %Event{membership(actor, subject) | content: "leave"} + def ban(actor, subject), do: %Event{membership(actor, subject) | content: "ban"} + + def set_power_levels(user, power_levels) do + %Event{new() | event_type: :power_levels, sender: user, power_levels: power_levels} + end + + def set_topic(user, topic) do + %Event{new() | event_type: :topic, sender: user, content: topic} + end + + def get_state_set_from_event_list(events) do + Enum.reduce(events, %{}, fn + %Event{event_type: event_type, state_key: state_key} = event, acc -> + Map.put(acc, {event_type, state_key}, event) + end) + end + + def auth_chain(event), do: auth_chain(event, MapSet.new()) + + def auth_chain(%Event{auth_events: auth_events}, set) do + Enum.reduce(auth_events, set, fn event, acc -> + event + |> auth_chain() + |> MapSet.union(acc) + |> MapSet.put(event) + end) + end + + def in_room(user, state_set) when is_map_key(state_set, {:membership, user}) do + state_set[{:membership, user}].content == "join" + end + + def in_room(_, _), do: false + + def get_power_levels(state_set) when is_map_key(state_set, {:power_levels, ""}) do + state_set[{:power_levels, ""}].power_levels + end + + def get_power_levels(_), do: nil + + def has_power_level(_, nil, _), do: true + + def has_power_level(user, power_levels, level) do + Map.get(power_levels, user, 0) >= level + end + + # No join rules specified, allow joining for room creator only. + def allowed_to_join(user, state_set) when not is_map_key(state_set, {:join_rules, ""}) do + state_set[{:create, ""}].sender == user + end + + # TODO: join and power levels events + def is_authorized(%Event{event_type: :create, prev_events: prev_events}, _), + do: prev_events == [] + + def is_authorized(%Event{event_type: :membership, content: "join", state_key: user}, state_set) do + allowed_to_join(user, state_set) + end + + def is_authorized(%Event{sender: sender} = event, state_set) do + in_room(sender, state_set) and + has_power_level(sender, get_power_levels(state_set), get_event_power_level(event)) + end + + def is_authorized2(%Event{auth_events: auth_events} = event, state_set) do + state_set = + Enum.reduce(auth_events, state_set, fn %Event{event_type: event_type, state_key: state_key} = + event, + acc -> + Map.put_new(acc, {event_type, state_key}, event) + end) + + is_authorized(event, state_set) + end + + def iterative_auth_checks(events, state_set) do + Enum.reduce(events, state_set, fn event, acc -> + if is_authorized2(event, acc), do: insert_event(event, acc), else: acc + end) + end + + def insert_event(%Event{event_type: event_type, state_key: state_key} = event, state_set) do + Map.put(state_set, {event_type, state_key}, event) + end + + def is_control_event(%Event{event_type: :power_levels, state_key: ""}), do: true + + def is_control_event(%Event{event_type: :join_rules, state_key: ""}), do: true + + def is_control_event(%Event{ + event_type: :membership, + state_key: state_key, + sender: sender, + content: "ban" + }), + do: sender != state_key + + def is_control_event(%Event{ + event_type: :membership, + state_key: state_key, + sender: sender, + content: "leave" + }), + do: sender != state_key + + def is_control_event(_), do: false + + def calculate_conflict(state_sets) do + domain = + state_sets + |> Enum.map(&Map.keys/1) + |> List.flatten() + |> MapSet.new() + + full_state_map_list = + Enum.map(domain, fn k -> + events = + Enum.map(state_sets, &Map.get(&1, k)) + |> MapSet.new() + + {k, events} + end) + + {unconflicted, conflicted} = + Enum.split_with(full_state_map_list, fn {_k, events} -> + MapSet.size(events) == 1 + end) + + unconflicted_state_map = + Enum.map(unconflicted, fn {k, events} -> + event = + events + |> MapSet.to_list() + |> hd() + + {k, event} + end) + |> Enum.into(%{}) + + conflicted_state_map = + Enum.flat_map(conflicted, fn {_, events} -> + events + |> MapSet.delete(nil) + |> MapSet.to_list() + end) + |> MapSet.new() + + {unconflicted_state_map, conflicted_state_map} + end + + def full_auth_chain(events) do + events + |> Enum.map(&auth_chain/1) + |> Enum.reduce(MapSet.new(), &MapSet.union/2) + end + + def auth_difference(state_sets) do + full_auth_chains = + Enum.map(state_sets, fn state_set -> + state_set + |> Map.values() + |> full_auth_chain() + 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 + + def rev_top_pow_order( + %Event{timestamp: timestamp1, event_id: event_id1} = event1, + %Event{timestamp: timestamp2, event_id: event_id2} = event2 + ) do + {power1, power2} = {get_power_level(event1), get_power_level(event2)} + + if power1 == power2 do + if timestamp1 == timestamp2 do + event_id1 <= event_id2 + else + timestamp1 < timestamp2 + end + else + power1 < power2 + end + end + + def get_power_level(%Event{sender: sender, auth_events: auth_events}) do + pl_event = Enum.find(auth_events, &(&1.event_type == :power_levels)) + + case pl_event do + %Event{power_levels: power_levels} -> Map.get(power_levels, sender, 0) + _ -> 0 + end + end + + def mainline(event) do + event + |> mainline([]) + |> Enum.reverse() + end + + def mainline(%Event{auth_events: auth_events} = event, acc) do + case Enum.find(auth_events, &(&1.event_type == :power_levels)) do + nil -> [event | acc] + pl_event -> mainline(pl_event, [event | acc]) + end + end + + def mainline_order(p) do + mainline_map = + p + |> mainline() + |> Enum.with_index() + |> Enum.into(%{}) + + fn %Event{timestamp: timestamp1, event_id: event_id1} = event1, + %Event{timestamp: timestamp2, event_id: event_id2} = event2 -> + mainline_depth1 = get_mainline_depth(mainline_map, event1) + mainline_depth2 = get_mainline_depth(mainline_map, event2) + + 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) do + mainline = mainline(event) + + 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 + + def resolve(state_sets) do + {unconflicted_state_map, conflicted_set} = calculate_conflict(state_sets) + full_conflicted_set = MapSet.union(conflicted_set, auth_difference(state_sets)) + + conflicted_control_events = + Enum.filter(full_conflicted_set, &is_control_event/1) |> MapSet.new() + + conflicted_control_events_with_auth = + MapSet.union( + conflicted_control_events, + MapSet.intersection( + full_conflicted_set, + full_auth_chain(MapSet.to_list(conflicted_control_events)) + ) + ) + + sorted_control_events = Enum.sort(conflicted_control_events_with_auth, &rev_top_pow_order/2) + partial_resolved_state = iterative_auth_checks(sorted_control_events, unconflicted_state_map) + + other_conflicted_events = + MapSet.difference(full_conflicted_set, conflicted_control_events_with_auth) + + resolved_power_levels = partial_resolved_state[{:power_levels, ""}] + + sorted_other_events = + Enum.sort(other_conflicted_events, mainline_order(resolved_power_levels)) + + nearly_final_state = iterative_auth_checks(sorted_other_events, partial_resolved_state) + + Map.merge(nearly_final_state, unconflicted_state_map) + end + + def example1 do + create = %Event{new() | event_id: "create", event_type: :create, sender: "@alice:example.com"} + + alice_joins = %Event{ + join("@alice:example.com") + | event_id: "alice joins", + prev_events: [create], + auth_events: [create] + } + + pl = %Event{ + set_power_levels("@alice:example.com", %{"@alice:example.com" => 100}) + | event_id: "power level", + prev_events: [alice_joins], + auth_events: [alice_joins, create] + } + + join_rules = %Event{ + new() + | event_id: "join rules", + event_type: :join_rules, + sender: "@alice:example.com", + content: "private", + prev_events: [pl], + auth_events: [pl, alice_joins, create] + } + + invite_bob = %Event{ + invite("@alice:example.com", "@bob:example.com") + | event_id: "invite bob", + prev_events: [join_rules], + auth_events: [pl, alice_joins, create] + } + + invite_carol = %Event{ + invite("@alice:example.com", "@carol:example.com") + | event_id: "invite carol", + prev_events: [invite_bob], + auth_events: [pl, alice_joins, create] + } + + bob_join = %Event{ + join("@bob:example.com") + | event_id: "bob joins", + prev_events: [invite_carol], + auth_events: [invite_bob, join_rules, create] + } + + [create, alice_joins, pl, join_rules, invite_bob, invite_carol, bob_join] + end + + def example2 do + create = %Event{ + new_state_event() + | event_id: "create", + event_type: :create, + sender: "@alice:example.com" + } + + alice_joins = join("@alice:example.com") + + pl1 = %Event{ + set_power_levels("@alice:example.com", %{"@alice:example.com" => 100}) + | event_id: "power levels 1" + } + + pl2 = %Event{ + set_power_levels("@alice:example.com", %{ + "@alice:example.com" => 100, + "@bob:example.com" => 50 + }) + | event_id: "power levels 2" + } + + topic = %Event{set_topic("@alice:example.com", "This is a topic") | event_id: "topic"} + + state_set1 = get_state_set_from_event_list([create, alice_joins, pl1]) + state_set2 = get_state_set_from_event_list([create, alice_joins, pl2, topic]) + state_set3 = get_state_set_from_event_list([create, alice_joins, pl2]) + [state_set1, state_set2, state_set3] + end + + def example3 do + pl1 = %Event{set_power_levels("alice", %{}) | event_id: "pl1", timestamp: 1} + + pl2 = %Event{ + set_power_levels("alice", %{}) + | event_id: "pl2", + auth_events: [pl1], + timestamp: 2 + } + + pl3 = %Event{ + set_power_levels("alice", %{}) + | event_id: "pl3", + auth_events: [pl1], + timestamp: 4 + } + + pl4 = %Event{ + set_power_levels("alice", %{}) + | event_id: "pl4", + auth_events: [pl2], + timestamp: 6 + } + + pl5 = %Event{ + set_power_levels("alice", %{}) + | event_id: "pl5", + auth_events: [pl4], + timestamp: 6 + } + + pl6 = %Event{ + set_power_levels("alice", %{}) + | event_id: "pl6", + auth_events: [pl4], + timestamp: 5 + } + + pl7 = %Event{ + set_power_levels("alice", %{}) + | event_id: "pl7", + auth_events: [pl2], + timestamp: 5 + } + + pl8 = %Event{ + set_power_levels("alice", %{}) + | event_id: "pl8", + auth_events: [pl7], + timestamp: 6 + } + + [pl1, pl2, pl3, pl4, pl5, pl6, pl7, pl8] + end + + defp membership(user), do: membership(user, user) + + defp membership(actor, subject) do + %Event{new() | event_type: :membership, sender: actor, state_key: subject} + end + + defp get_event_power_level(%Event{state_key: ""}), do: 0 + defp get_event_power_level(_), do: 50 +end diff --git a/lib/matrix_server_web/api/create_room.ex b/lib/matrix_server_web/api/create_room.ex new file mode 100644 index 0000000..2eaa0d6 --- /dev/null +++ b/lib/matrix_server_web/api/create_room.ex @@ -0,0 +1,28 @@ +defmodule MatrixServerWeb.API.CreateRoom do + use Ecto.Schema + + import Ecto.Changeset + + alias Ecto.Changeset + + @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 + # TODO: unimplemented: + # creation_content, initial_state, invite_3pid, initial_state, preset, + # is_direct, power_level_content_override + end + + def changeset(params) do + %__MODULE__{} + |> cast(params, [:visibility, :room_alias_name, :name, :topic, :invite, :room_version]) + end + + def get_error(%Changeset{errors: [error | _]}), do: get_error(error) + def get_error(_), do: :bad_json +end diff --git a/lib/matrix_server_web/api/login.ex b/lib/matrix_server_web/api/login.ex index 322e683..81b74d3 100644 --- a/lib/matrix_server_web/api/login.ex +++ b/lib/matrix_server_web/api/login.ex @@ -4,6 +4,8 @@ defmodule MatrixServerWeb.API.Login do import Ecto.Changeset + # TODO: Maybe use inline embedded schema here + # https://hexdocs.pm/ecto/Ecto.Schema.html#embeds_one/3 defmodule MatrixServerWeb.API.Login.Identifier do use Ecto.Schema @@ -15,9 +17,9 @@ defmodule MatrixServerWeb.API.Login do field :user, :string end - def changeset(identifier, attrs) do + def changeset(identifier, params) do identifier - |> cast(attrs, [:type, :user]) + |> cast(params, [:type, :user]) |> validate_required([:type, :user]) end end @@ -33,9 +35,9 @@ defmodule MatrixServerWeb.API.Login do embeds_one :identifier, Identifier end - def changeset(attrs) do + def changeset(params) do %__MODULE__{} - |> cast(attrs, [:type, :password, :device_id, :initial_device_display_name]) + |> cast(params, [:type, :password, :device_id, :initial_device_display_name]) |> cast_embed(:identifier, with: &Identifier.changeset/2, required: true) |> validate_required([:type, :password]) end diff --git a/lib/matrix_server_web/api/register.ex b/lib/matrix_server_web/api/register.ex index 25a6b74..4291979 100644 --- a/lib/matrix_server_web/api/register.ex +++ b/lib/matrix_server_web/api/register.ex @@ -2,7 +2,6 @@ defmodule MatrixServerWeb.API.Register do use Ecto.Schema import Ecto.Changeset - import MatrixServerWeb.Plug.Error alias Ecto.Changeset @@ -27,12 +26,8 @@ defmodule MatrixServerWeb.API.Register do |> validate_required([:password, :username]) end - def handle_error(conn, cs) do - put_error(conn, get_register_error(cs)) - end - - defp get_register_error(%Changeset{errors: [error | _]}), do: get_register_error(error) - defp get_register_error({:localpart, {_, [{:constraint, :unique} | _]}}), do: :user_in_use - defp get_register_error({:localpart, {_, [{:validation, _} | _]}}), do: :invalid_username - defp get_register_error(_), do: :bad_json + def get_error(%Changeset{errors: [error | _]}), do: get_error(error) + def get_error({:localpart, {_, [{:constraint, :unique} | _]}}), do: :user_in_use + def get_error({:localpart, {_, [{:validation, _} | _]}}), do: :invalid_username + def get_error(_), do: :bad_json end diff --git a/lib/matrix_server_web/controllers/auth_controller.ex b/lib/matrix_server_web/controllers/auth_controller.ex index 292664c..110ebb4 100644 --- a/lib/matrix_server_web/controllers/auth_controller.ex +++ b/lib/matrix_server_web/controllers/auth_controller.ex @@ -1,12 +1,10 @@ defmodule MatrixServerWeb.AuthController do use MatrixServerWeb, :controller - import MatrixServer import MatrixServerWeb.Plug.Error - import Ecto.Changeset, only: [apply_changes: 1] - import Ecto.Query + import Ecto.Changeset - alias MatrixServer.{Repo, Account, Device} + alias MatrixServer.{Repo, Account} alias MatrixServerWeb.API.{Register, Login} alias Ecto.Changeset @@ -19,13 +17,13 @@ defmodule MatrixServerWeb.AuthController do input = apply_changes(cs) |> Map.from_struct() - |> update_map_entry(:initial_device_display_name, :display_name) - |> update_map_entry(:username, :localpart) - |> update_map_entry(:password, :password_hash, &Bcrypt.hash_pwd_salt/1) + |> MatrixServer.maybe_update_map(:initial_device_display_name, :display_name) + |> MatrixServer.maybe_update_map(:username, :localpart) + |> MatrixServer.maybe_update_map(:password, :password_hash, &Bcrypt.hash_pwd_salt/1) case Account.register(input) |> Repo.transaction() do {:ok, %{device_with_access_token: device}} -> - data = %{user_id: get_mxid(device.localpart)} + data = %{user_id: MatrixServer.get_mxid(device.localpart)} data = if not input.inhibit_login do @@ -41,7 +39,8 @@ defmodule MatrixServerWeb.AuthController do |> json(data) {:error, _, cs, _} -> - Register.handle_error(conn, cs) + IO.inspect(cs) + put_error(conn, Register.get_error(cs)) end _ -> @@ -83,8 +82,8 @@ defmodule MatrixServerWeb.AuthController do input = apply_changes(cs) |> Map.from_struct() - |> update_map_entry(:initial_device_display_name, :display_name) - |> update_map_entry(:identifier, :localpart, fn + |> MatrixServer.maybe_update_map(:initial_device_display_name, :display_name) + |> MatrixServer.maybe_update_map(:identifier, :localpart, fn %{user: "@" <> rest} -> case String.split(rest) do [localpart, _] -> localpart @@ -96,10 +95,10 @@ defmodule MatrixServerWeb.AuthController do user end) - case Repo.transaction(login_transaction(input)) do + case Account.login(input) |> Repo.transaction() do {:ok, device} -> data = %{ - user_id: get_mxid(device.localpart), + user_id: MatrixServer.get_mxid(device.localpart), access_token: device.access_token, device_id: device.device_id } @@ -121,26 +120,4 @@ defmodule MatrixServerWeb.AuthController do # Other login types and identifiers are unsupported for now. put_error(conn, :unknown) end - - defp login_transaction(%{localpart: localpart, password: password} = params) do - 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 - device_id = Map.get(params, :device_id, Device.generate_device_id(localpart)) - access_token = Device.generate_access_token(localpart, device_id) - - case Device.login(account, device_id, access_token, params) do - {:ok, device} -> device - {:error, _cs} -> repo.rollback(:forbidden) - end - else - repo.rollback(:forbidden) - end - - nil -> - repo.rollback(:forbidden) - end - end - end end diff --git a/lib/matrix_server_web/controllers/room_controller.ex b/lib/matrix_server_web/controllers/room_controller.ex new file mode 100644 index 0000000..b246513 --- /dev/null +++ b/lib/matrix_server_web/controllers/room_controller.ex @@ -0,0 +1,25 @@ +defmodule MatrixServerWeb.RoomController do + use MatrixServerWeb, :controller + + import MatrixServerWeb.Plug.Error + import Ecto.Changeset + + alias MatrixServerWeb.API.{CreateRoom} + alias Ecto.Changeset + + def create(conn, params) do + case CreateRoom.changeset(params) do + %Changeset{valid?: true} = cs -> + api_struct = apply_changes(cs) + + MatrixServer.RoomServer.create_room(api_struct) + + conn + |> put_status(200) + |> json(%{}) + + _ -> + put_error(conn, :bad_json) + end + end +end diff --git a/lib/matrix_server_web/router.ex b/lib/matrix_server_web/router.ex index b26711f..09ec6c7 100644 --- a/lib/matrix_server_web/router.ex +++ b/lib/matrix_server_web/router.ex @@ -32,6 +32,7 @@ defmodule MatrixServerWeb.Router do get "/account/whoami", AccountController, :whoami post "/logout", AccountController, :logout post "/logout/all", AccountController, :logout_all + post "/createRoom", RoomController, :create end end diff --git a/priv/repo/migrations/20210707123852_add_events_and_rooms_table.exs b/priv/repo/migrations/20210707123852_add_events_and_rooms_table.exs new file mode 100644 index 0000000..ef25268 --- /dev/null +++ b/priv/repo/migrations/20210707123852_add_events_and_rooms_table.exs @@ -0,0 +1,22 @@ +defmodule MatrixServer.Repo.Migrations.AddEventsAndRoomsTable do + use Ecto.Migration + + def change do + create table(:rooms, primary_key: false) do + add :id, :string, primary_key: true, null: false + add :visibility, :string, null: false, default: "public" + end + + create table(:events, primary_key: false) do + add :id, :string, primary_key: true, null: false + add :type, :string, null: false + add :timestamp, :naive_datetime, null: false + add :state_key, :string + add :sender, :string, null: false + add :content, :string + add :prev_events, {:array, :string}, null: false + add :auth_events, {:array, :string}, null: false + add :room_id, references(:rooms, type: :string), null: false + end + end +end