Rename repository

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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