diff options
author | Hubert Chathi <hubert@uhoreg.ca> | 2020-08-26 18:28:18 -0400 |
---|---|---|
committer | Hubert Chathi <hubert@uhoreg.ca> | 2020-08-26 18:28:18 -0400 |
commit | 0cd4203bfcbb44a082ad6480fb71597f0fb8f3fa (patch) | |
tree | 7e890342c1f076d9f3e4e58ab952169c3f15fc18 | |
parent | replace login/logout examples with mix tasks (diff) |
add module for handling .well-known
-rw-r--r-- | lib/polyjuice/client/well_known.ex | 168 | ||||
-rw-r--r-- | test/polyjuice/client/well_known_test.exs | 164 |
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 |