summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHubert Chathi <hubert@uhoreg.ca>2020-08-26 18:28:18 -0400
committerHubert Chathi <hubert@uhoreg.ca>2020-08-26 18:28:18 -0400
commit0cd4203bfcbb44a082ad6480fb71597f0fb8f3fa (patch)
tree7e890342c1f076d9f3e4e58ab952169c3f15fc18
parentreplace login/logout examples with mix tasks (diff)
add module for handling .well-known
-rw-r--r--lib/polyjuice/client/well_known.ex168
-rw-r--r--test/polyjuice/client/well_known_test.exs164
2 files changed, 332 insertions, 0 deletions
diff --git a/lib/polyjuice/client/well_known.ex b/lib/polyjuice/client/well_known.ex
new file mode 100644
index 0000000..c63803f
--- /dev/null
+++ b/lib/polyjuice/client/well_known.ex
@@ -0,0 +1,168 @@
+# Copyright 2020 Hubert Chathi <hubert@uhoreg.ca>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+defmodule Polyjuice.Client.WellKnown do
+ @moduledoc """
+ Look up and interpret a server's `.well-known` file.
+
+ https://matrix.org/docs/spec/client_server/latest#well-known-uri
+ """
+
+ @doc """
+ Get and parse a server's `.well-known` file based on its server name.
+
+ If the homeserver information is correct, returns a tuple of the form `{:ok,
+ homeserver_url, identity_server_url, well_known}`, where
+ `identity_server_url` is either a string giving the identity server's URL,
+ `nil` indicating no identity server was specified, or `:fail_error`
+ indicating an error in fetching the identity server URL; and `well_known` is
+ the full contents of the well_known file.
+
+ This function is essentially equivalent to calling `get_for_server` followed
+ by `get_homeserver` and `get_identity_server`.
+
+ """
+ @spec discover_server(server_name :: String.t()) ::
+ {:ok, String.t(), String.t() | nil | :fail_error, map}
+ | {:error, :ignore}
+ | {:error, :fail_prompt}
+ | {:error, :fail_error}
+ def discover_server(server_name) when is_binary(server_name) do
+ with {:ok, json} <- get_for_server(server_name),
+ {:ok, hs_url} <- get_homeserver(json) do
+ case get_identity_server(json) do
+ {:ok, is_url} ->
+ {:ok, hs_url, is_url, json}
+
+ {:error, err} ->
+ {:ok, hs_url, err, json}
+ end
+ else
+ err ->
+ err
+ end
+ end
+
+ @doc """
+ Get a server's `.well-known` file based on its server name.
+ """
+ @spec get_for_server(server_name :: String.t()) ::
+ map
+ | {:error, :ignore}
+ | {:error, :fail_prompt}
+ | {:error, :fail_error}
+ def get_for_server(server_name) when is_binary(server_name) do
+ url =
+ %URI{
+ scheme: "https",
+ host: server_name,
+ authority: server_name,
+ path: "/.well-known/matrix/client",
+ port: 443
+ }
+ |> to_string()
+
+ case :hackney.request(:get, url, [], "", follow_redirect: true) do
+ {:ok, 404, _, _} ->
+ {:error, :ignore}
+
+ {:ok, code, _headers, _client_ref} when code != 200 ->
+ {:error, :fail_prompt}
+
+ {:ok, 200, _headers, client_ref} ->
+ with {:ok, body} <- :hackney.body(client_ref),
+ {:ok, %{}} = ret <- Jason.decode(body) do
+ ret
+ else
+ _ ->
+ {:error, :fail_prompt}
+ end
+
+ _ ->
+ # if we can't find the server, treat it like a 404
+ {:error, :ignore}
+ end
+ end
+
+ @doc """
+ Get the homeserver that's specified in a `.well-known` file.
+ """
+ @spec get_homeserver(map) :: {:ok, String.t()} | {:error, :fail_error} | {:error, :fail_prompt}
+ def get_homeserver(%{} = well_known) do
+ with %{"m.homeserver" => %{"base_url" => base_url}} <- well_known do
+ with %{host: host, scheme: scheme, path: path} = parsed_url
+ when host != nil and (scheme == "http" or scheme == "https") <- URI.parse(base_url),
+ slashed_url =
+ (if path != nil and !String.ends_with?(path, "/") do
+ %{parsed_url | path: path <> "/"}
+ else
+ parsed_url
+ end),
+ {:ok, %{"versions" => v}} when is_list(v) <-
+ URI.merge(slashed_url, "_matrix/client/versions") |> validate_server() do
+ {:ok, to_string(slashed_url)}
+ else
+ _ -> {:error, :fail_error}
+ end
+ else
+ _ ->
+ {:error, :fail_prompt}
+ end
+ end
+
+ @doc """
+ Get the identity server that's specified in a `.well-known` file.
+ """
+ @spec get_identity_server(map) :: {:ok, String.t()} | {:error, :fail_error}
+ def get_identity_server(%{} = well_known) do
+ case well_known do
+ %{"m.identity_server" => is_info} ->
+ with %{"base_url" => base_url} <- is_info,
+ %{host: host, scheme: scheme, path: path} = parsed_url
+ when host != nil and (scheme == "http" or scheme == "https") <- URI.parse(base_url),
+ slashed_url =
+ (if path != nil and !String.ends_with?(path, "/") do
+ %{parsed_url | path: path <> "/"}
+ else
+ parsed_url
+ end),
+ {:ok, %{}} <- URI.merge(slashed_url, "_matrix/identity/api/v1") |> validate_server() do
+ {:ok, to_string(slashed_url)}
+ else
+ _ -> {:error, :fail_error}
+ end
+
+ _ ->
+ {:ok, nil}
+ end
+ end
+
+ # check that a given URL returns something JSON-y
+ defp validate_server(%URI{} = url) do
+ case :hackney.request(:get, to_string(url), [], "", []) do
+ {:ok, 200, headers, client_ref} ->
+ with "application/json" <- Polyjuice.Client.Endpoint.get_header(headers, "content-type"),
+ {:ok, body} <- :hackney.body(client_ref),
+ {:ok, json} <- Jason.decode(body) do
+ {:ok, json}
+ else
+ _ ->
+ {:error, :fail_error}
+ end
+
+ _ ->
+ {:error, :fail_error}
+ end
+ end
+end
diff --git a/test/polyjuice/client/well_known_test.exs b/test/polyjuice/client/well_known_test.exs
new file mode 100644
index 0000000..8b30a68
--- /dev/null
+++ b/test/polyjuice/client/well_known_test.exs
@@ -0,0 +1,164 @@
+# Copyright 2020 Hubert Chathi <hubert@uhoreg.ca>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+defmodule Polyjuice.Client.WellKnownTest do
+ use ExUnit.Case
+ alias Polyjuice.Client.WellKnown
+
+ defmodule Httpd do
+ def _matrix(session_id, env, _input) do
+ [path | _] =
+ Keyword.get(env, :path_info)
+ |> to_string()
+ |> String.split("?", parts: 2)
+
+ case path do
+ "client/versions" ->
+ :mod_esi.deliver(
+ session_id,
+ 'Content-Type: application/json\r\n\r\n{"versions": ["foobar"]}'
+ )
+
+ "identity/api/v1" ->
+ :mod_esi.deliver(
+ session_id,
+ 'Content-Type: application/json\r\n\r\n{}'
+ )
+
+ _ ->
+ :mod_esi.deliver(
+ session_id,
+ 'Status: 404 Not Found\r\nContent-Type: application/json\r\n\r\n{"errcode":"M_NOT_FOUND","error":"Not found"}'
+ )
+ end
+ end
+
+ # various bad responses
+ def notfound(session_id, _, _) do
+ :mod_esi.deliver(
+ session_id,
+ 'Status: 404 Not Found\r\nContent-Type: application/json\r\n\r\n{"errcode":"M_NOT_FOUND","error":"Not found"}'
+ )
+ end
+
+ def missingversions(session_id, _, _) do
+ :mod_esi.deliver(
+ session_id,
+ 'Content-Type: application/json\r\n\r\n{}'
+ )
+ end
+
+ def notjson(session_id, _, _) do
+ :mod_esi.deliver(
+ session_id,
+ 'Content-Type: text/plain\r\n\r\nfoo'
+ )
+ end
+ end
+
+ test "get homeserver" do
+ {:ok, tmpdir} = TestUtil.mktmpdir("client-call-")
+
+ try do
+ tmpdir_charlist = to_charlist(tmpdir)
+
+ :inets.start()
+
+ {:ok, httpd_pid} =
+ :inets.start(
+ :httpd,
+ port: 0,
+ server_name: 'sync.test',
+ server_root: tmpdir_charlist,
+ document_root: tmpdir_charlist,
+ bind_address: {127, 0, 0, 1},
+ modules: [:mod_esi],
+ erl_script_alias: {'', [Polyjuice.Client.WellKnownTest.Httpd]}
+ )
+
+ port = :httpd.info(httpd_pid) |> Keyword.fetch!(:port)
+
+ assert WellKnown.get_homeserver(%{}) == {:error, :fail_prompt}
+ assert WellKnown.get_homeserver(%{"m.homeserver" => %{}}) == {:error, :fail_prompt}
+
+ assert WellKnown.get_homeserver(%{
+ "m.homeserver" => %{
+ "base_url" =>
+ "http://127.0.0.1:#{port}/Elixir.Polyjuice.Client.WellKnownTest.Httpd"
+ }
+ }) == {:ok, "http://127.0.0.1:#{port}/Elixir.Polyjuice.Client.WellKnownTest.Httpd/"}
+
+ assert WellKnown.get_homeserver(%{
+ "m.homeserver" => %{
+ "base_url" =>
+ "http://127.0.0.1:#{port}/Elixir.Polyjuice.Client.WellKnownTest.Httpd/notfound/"
+ }
+ }) == {:error, :fail_error}
+
+ assert WellKnown.get_homeserver(%{
+ "m.homeserver" => %{
+ "base_url" =>
+ "http://127.0.0.1:#{port}/Elixir.Polyjuice.Client.WellKnownTest.Httpd/missingversions"
+ }
+ }) == {:error, :fail_error}
+
+ assert WellKnown.get_homeserver(%{
+ "m.homeserver" => %{
+ "base_url" =>
+ "http://127.0.0.1:#{port}/Elixir.Polyjuice.Client.WellKnownTest.Httpd/notjson"
+ }
+ }) == {:error, :fail_error}
+
+ assert WellKnown.get_identity_server(%{}) == {:ok, nil}
+
+ assert WellKnown.get_identity_server(%{"m.identity_server" => "foo"}) ==
+ {:error, :fail_error}
+
+ assert WellKnown.get_identity_server(%{
+ "m.identity_server" => %{
+ "base_url" =>
+ "http://127.0.0.1:#{port}/Elixir.Polyjuice.Client.WellKnownTest.Httpd"
+ }
+ }) == {:ok, "http://127.0.0.1:#{port}/Elixir.Polyjuice.Client.WellKnownTest.Httpd/"}
+
+ assert WellKnown.get_identity_server(%{
+ "m.identity_server" => %{
+ "base_url" =>
+ "http://127.0.0.1:#{port}/Elixir.Polyjuice.Client.WellKnownTest.Httpd/notfound/"
+ }
+ }) == {:error, :fail_error}
+
+ # lacking a "versions" property in identity server is normal
+ assert WellKnown.get_identity_server(%{
+ "m.identity_server" => %{
+ "base_url" =>
+ "http://127.0.0.1:#{port}/Elixir.Polyjuice.Client.WellKnownTest.Httpd/missingversions"
+ }
+ }) ==
+ {:ok,
+ "http://127.0.0.1:#{port}/Elixir.Polyjuice.Client.WellKnownTest.Httpd/missingversions/"}
+
+ assert WellKnown.get_identity_server(%{
+ "m.identity_server" => %{
+ "base_url" =>
+ "http://127.0.0.1:#{port}/Elixir.Polyjuice.Client.WellKnownTest.Httpd/notjson"
+ }
+ }) == {:error, :fail_error}
+
+ :inets.stop(:httpd, httpd_pid)
+ after
+ File.rm_rf(tmpdir)
+ end
+ end
+end