Android client phase 1a: connected devices and pairing QR

92c48b1 · Gabriel Morell · 2026-09-14 22:06

18 files +508 -5
Message
{commit_body(@commit)}

Files changed

modified docs/android.md
+17 −3
@@ -477,9 +477,23 @@ Two things worth knowing about what shipped:
477 477 copy can be far behind. The controller falls back to the row only when
478 478 no authority can be started.
479 479
480 −### Phase 1a — Web "Connected devices" + QR
481 −- `eqrcode` dep, public-URL derivation, token panel in `UserLive.Settings`,
482 − revoke action.
480 +### Phase 1a — Web "Connected devices" + QR ✅ shipped
481 +- `eqrcode` dep and `DiogramosWeb.QR.svg/2`.
482 +- `DiogramosWeb.PublicUrl.derive/1` for both a `Plug.Conn` and a
483 + `LiveView.Socket`; the LiveView socket now exposes `:uri` and
484 + `:x_headers` in `connect_info`, which is what the socket flavour reads.
485 +- "Connected devices" panel in `UserLive.Settings`: generate (with an
486 + optional label), one-time display of token + QR + `diogramos://pair`
487 + URI, active-token list, revoke. The whole page is already behind
488 + `:require_sudo_mode`, and the generate/revoke handlers re-assert
489 + `Accounts.sudo_mode?/1`.
490 +- 16 tests, including the forwarded-header matrix and a check that the
491 + token rendered on screen is the real bearer.
492 +
493 +The base URL is derived in `mount/3`, because LiveView discards
494 +connect_info afterwards. On the dead render that yields the endpoint URL;
495 +the connected mount replaces it with the real origin, and a QR is only
496 +ever generated after that.
483 497
484 498 ### Phase 1b — Android scaffold + both auth paths
485 499 - Gradle project, theme/typography lifted from waxx, `TokenStore`,
modified lib/diogramos_web/endpoint.ex
+4 −2
@@ -15,9 +15,11 @@ defmodule DiogramosWeb.Endpoint do
15 15 same_site: "Lax"
16 16 ]
17 17
18 + # :uri and :x_headers are what DiogramosWeb.PublicUrl needs to work out
19 + # the origin a paired device should dial back to.
18 20 socket "/live", Phoenix.LiveView.Socket,
19 − websocket: [connect_info: [session: @session_options]],
20 − longpoll: [connect_info: [session: @session_options]]
21 + websocket: [connect_info: [:uri, :x_headers, session: @session_options]],
22 + longpoll: [connect_info: [:uri, :x_headers, session: @session_options]]
21 23
22 24 # Native clients. Authenticates with an "api" bearer token in the query
23 25 # string rather than the browser session, so it deliberately does not
modified lib/diogramos_web/live/user_live/settings.ex
+139 −0
@@ -4,6 +4,7 @@ defmodule DiogramosWeb.UserLive.Settings do
4 4 on_mount {DiogramosWeb.UserAuth, :require_sudo_mode}
5 5
6 6 alias Diogramos.Accounts
7 + alias DiogramosWeb.{PublicUrl, QR}
7 8
8 9 @impl true
9 10 def render(assigns) do
@@ -65,6 +66,90 @@ defmodule DiogramosWeb.UserLive.Settings do
65 66 Save Password
66 67 </.button>
67 68 </.form>
69 +
70 + <div class="divider" />
71 +
72 + <section id="api-tokens" class="text-left">
73 + <.header>
74 + Connected devices
75 + <:subtitle>
76 + API tokens issued to native clients. Generate one here and scan the
77 + QR with the Android app to pair it.
78 + </:subtitle>
79 + </.header>
80 +
81 + <.form for={@token_form} id="token_form" phx-submit="generate_api_token" class="mt-4">
82 + <.input
83 + field={@token_form[:label]}
84 + type="text"
85 + label="Label (optional)"
86 + placeholder="e.g. Pixel 7, kitchen"
87 + maxlength="80"
88 + />
89 + <.button variant="primary" phx-disable-with="Generating...">
90 + Generate token
91 + </.button>
92 + </.form>
93 +
94 + <div :if={@just_created} id="new-token" class="card bg-base-200 p-4 mt-6 space-y-3">
95 + <div class="alert alert-warning">
96 + <.icon name="hero-exclamation-triangle" class="size-5" />
97 + <span>
98 + This is the only time the token is shown. Scan or copy it now.
99 + </span>
100 + </div>
101 +
102 + <div class="flex flex-col md:flex-row items-center gap-4">
103 + <div class="bg-white p-3 rounded">
104 + {raw(@just_created.qr_svg)}
105 + </div>
106 + <div class="space-y-2 text-sm break-all">
107 + <div class="font-semibold">Server URL</div>
108 + <code class="text-xs block">{@just_created.base_url}</code>
109 + <div class="font-semibold">Token</div>
110 + <code class="text-xs block">{@just_created.token}</code>
111 + <div class="font-semibold">Pairing URI</div>
112 + <code class="text-xs block">{@just_created.pair_uri}</code>
113 + </div>
114 + </div>
115 +
116 + <div>
117 + <button type="button" class="btn btn-ghost btn-sm" phx-click="dismiss_token_panel">
118 + Done — hide
119 + </button>
120 + </div>
121 + </div>
122 +
123 + <div class="mt-6">
124 + <h3 class="font-bold mb-2">Active tokens</h3>
125 + <div :if={@api_tokens == []} class="text-sm opacity-70">
126 + No tokens yet. Generate one above to pair a device.
127 + </div>
128 + <ul :if={@api_tokens != []} class="divide-y divide-base-300">
129 + <li
130 + :for={token <- @api_tokens}
131 + id={"api-token-#{token.id}"}
132 + class="flex items-center justify-between py-2"
133 + >
134 + <div class="text-sm">
135 + <div class="font-medium">{token.label || "(no label)"}</div>
136 + <div class="opacity-70 text-xs">
137 + Issued {format_ts(token.inserted_at)} · Last seen {format_ts(token.authenticated_at)}
138 + </div>
139 + </div>
140 + <button
141 + type="button"
142 + class="btn btn-ghost btn-sm text-error"
143 + phx-click="revoke_api_token"
144 + phx-value-id={token.id}
145 + data-confirm="Revoke this token? The paired device stops working."
146 + >
147 + Revoke
148 + </button>
149 + </li>
150 + </ul>
151 + </div>
152 + </section>
68 153 </Layouts.app>
69 154 """
70 155 end
@@ -94,6 +179,14 @@ defmodule DiogramosWeb.UserLive.Settings do
94 179 |> assign(:email_form, to_form(email_changeset))
95 180 |> assign(:password_form, to_form(password_changeset))
96 181 |> assign(:trigger_submit, false)
182 + |> assign(:api_tokens, Accounts.list_api_tokens(user))
183 + |> assign(:token_form, to_form(%{"label" => ""}, as: :token))
184 + |> assign(:just_created, nil)
185 + # Derived here because LiveView discards connect_info after mount.
186 + # On the dead render this falls back to the endpoint URL; the
187 + # connected mount that follows replaces it with the real origin,
188 + # and a QR is only ever generated after that.
189 + |> assign(:public_base_url, PublicUrl.derive(socket))
97 190
98 191 {:ok, socket}
99 192 end
@@ -157,4 +250,50 @@ defmodule DiogramosWeb.UserLive.Settings do
157 250 {:noreply, assign(socket, password_form: to_form(changeset, action: :insert))}
158 251 end
159 252 end
253 +
254 + def handle_event("generate_api_token", params, socket) do
255 + user = socket.assigns.current_scope.user
256 + true = Accounts.sudo_mode?(user)
257 +
258 + label = get_in(params, ["token", "label"])
259 + token = Accounts.create_api_token(user, %{label: label})
260 +
261 + base_url = socket.assigns.public_base_url
262 + pair_uri = "diogramos://pair?" <> URI.encode_query(%{base: base_url, token: token})
263 +
264 + just_created = %{
265 + token: token,
266 + base_url: base_url,
267 + pair_uri: pair_uri,
268 + qr_svg: QR.svg(pair_uri)
269 + }
270 +
271 + {:noreply,
272 + socket
273 + |> assign(:just_created, just_created)
274 + |> assign(:token_form, to_form(%{"label" => ""}, as: :token))
275 + |> assign(:api_tokens, Accounts.list_api_tokens(user))}
276 + end
277 +
278 + def handle_event("dismiss_token_panel", _params, socket) do
279 + {:noreply, assign(socket, :just_created, nil)}
280 + end
281 +
282 + def handle_event("revoke_api_token", %{"id" => id}, socket) do
283 + user = socket.assigns.current_scope.user
284 + true = Accounts.sudo_mode?(user)
285 +
286 + socket =
287 + case Accounts.delete_api_token(user, String.to_integer(id)) do
288 + :ok -> put_flash(socket, :info, "Device revoked.")
289 + {:error, :not_found} -> put_flash(socket, :error, "That token is already gone.")
290 + end
291 +
292 + # Re-read rather than filter in place: a revoked token must never
293 + # stay on screen looking like it still works.
294 + {:noreply, assign(socket, :api_tokens, Accounts.list_api_tokens(user))}
295 + end
296 +
297 + defp format_ts(nil), do: "never"
298 + defp format_ts(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC")
160 299 end
added lib/diogramos_web/public_url.ex
+130 −0
@@ -0,0 +1,130 @@
1 +defmodule DiogramosWeb.PublicUrl do
2 + @moduledoc """
3 + Derives the public-facing origin (`scheme://host[:port]`) a native
4 + client should dial back to.
5 +
6 + Resolution, highest precedence first:
7 +
8 + 1. `DIOGRAMOS_PUBLIC_URL` (explicit override for reverse-proxy setups).
9 + 2. Composed from forwarded headers with conn/socket fallbacks:
10 + * scheme: `X-Forwarded-Proto` if present, else the request's scheme
11 + * host: `X-Forwarded-Host` if present (it may already carry `:port`),
12 + else the request's host, with the port appended when non-default
13 + 3. The endpoint's configured URL, as a last resort.
14 +
15 + Scheme and host are sourced independently, which is the whole point: a
16 + proxy that sets only `X-Forwarded-Proto: https` and passes Host through
17 + unchanged — the common Traefik/nginx default — must still yield an
18 + `https` origin. Treating the two as a unit encodes `http://` into the
19 + pairing QR behind a TLS-terminating proxy, and the phone then fails its
20 + WebSocket upgrade.
21 +
22 + The result never has a trailing slash, so callers can concatenate
23 + `"\#{base}/api/v1/..."` without producing a double slash.
24 + """
25 +
26 + import Plug.Conn, only: [get_req_header: 2]
27 +
28 + @spec derive(Plug.Conn.t() | Phoenix.LiveView.Socket.t()) :: String.t()
29 + def derive(%Plug.Conn{} = conn) do
30 + env_override() || compose(scheme_from_conn(conn), host_from_conn(conn)) || endpoint_url()
31 + end
32 +
33 + @doc """
34 + LiveView flavour. Must be called from `mount/3` — LiveView discards
35 + connect_info afterwards — and the endpoint must expose `:uri` and
36 + `:x_headers` in the socket's `connect_info`.
37 +
38 + On the dead render there is no connect_info at all, so this falls
39 + through to the endpoint URL; callers that need the real origin should
40 + derive it once the socket is connected.
41 + """
42 + def derive(%Phoenix.LiveView.Socket{} = socket) do
43 + env_override() || compose(scheme_from_socket(socket), host_from_socket(socket)) ||
44 + endpoint_url()
45 + end
46 +
47 + ## Scheme and host extraction
48 +
49 + defp scheme_from_conn(conn) do
50 + forwarded_proto(get_req_header(conn, "x-forwarded-proto")) || to_string(conn.scheme)
51 + end
52 +
53 + defp host_from_conn(conn) do
54 + case get_req_header(conn, "x-forwarded-host") do
55 + [host | _] -> first_value(host)
56 + _ -> host_with_port(conn.host, to_string(conn.scheme), conn.port)
57 + end
58 + end
59 +
60 + defp scheme_from_socket(socket) do
61 + forwarded_proto([header(socket, "x-forwarded-proto")]) ||
62 + case connect_uri(socket) do
63 + %URI{scheme: scheme} when is_binary(scheme) -> scheme
64 + _ -> nil
65 + end
66 + end
67 +
68 + defp host_from_socket(socket) do
69 + case header(socket, "x-forwarded-host") do
70 + nil ->
71 + case connect_uri(socket) do
72 + %URI{host: host, scheme: scheme, port: port} when is_binary(host) ->
73 + host_with_port(host, scheme, port)
74 +
75 + _ ->
76 + nil
77 + end
78 +
79 + host ->
80 + first_value(host)
81 + end
82 + end
83 +
84 + defp connect_uri(socket), do: Phoenix.LiveView.get_connect_info(socket, :uri)
85 +
86 + defp header(socket, name) do
87 + socket
88 + |> Phoenix.LiveView.get_connect_info(:x_headers)
89 + |> Kernel.||([])
90 + |> Enum.find_value(fn
91 + {^name, value} -> value
92 + _ -> nil
93 + end)
94 + end
95 +
96 + ## Helpers
97 +
98 + defp compose(nil, _host), do: nil
99 + defp compose(_scheme, nil), do: nil
100 + defp compose(scheme, host), do: strip_trailing_slash("#{scheme}://#{host}")
101 +
102 + defp forwarded_proto([proto | _]) when is_binary(proto), do: first_value(proto)
103 + defp forwarded_proto(_), do: nil
104 +
105 + # Chained proxies append rather than replace, so these headers can be a
106 + # comma-separated list. The first value is the one closest to the client.
107 + defp first_value(value), do: value |> String.split(",") |> List.first() |> String.trim()
108 +
109 + defp host_with_port(host, _scheme, nil), do: host
110 +
111 + defp host_with_port(host, scheme, port) do
112 + if default_port?(scheme, port), do: host, else: "#{host}:#{port}"
113 + end
114 +
115 + defp default_port?("http", 80), do: true
116 + defp default_port?("https", 443), do: true
117 + defp default_port?(_scheme, _port), do: false
118 +
119 + defp env_override do
120 + case System.get_env("DIOGRAMOS_PUBLIC_URL") do
121 + nil -> nil
122 + "" -> nil
123 + url -> strip_trailing_slash(url)
124 + end
125 + end
126 +
127 + defp endpoint_url, do: strip_trailing_slash(DiogramosWeb.Endpoint.url())
128 +
129 + defp strip_trailing_slash(url), do: String.replace_suffix(url, "/", "")
130 +end
added lib/diogramos_web/qr.ex
+22 −0
@@ -0,0 +1,22 @@
1 +defmodule DiogramosWeb.QR do
2 + @moduledoc """
3 + Renders QR codes to inline SVG, for embedding in a HEEx template via
4 + `raw/1`.
5 + """
6 +
7 + @default_width 280
8 +
9 + @doc """
10 + Renders `payload` as an SVG QR code, `width` pixels square (default
11 + #{@default_width}).
12 +
13 + Raises if the payload exceeds what the encoder can hold — the limit is
14 + over 2KB, and a pairing URI is a couple of hundred bytes.
15 + """
16 + @spec svg(String.t(), keyword()) :: String.t()
17 + def svg(payload, opts \\ []) when is_binary(payload) do
18 + payload
19 + |> EQRCode.encode()
20 + |> EQRCode.svg(width: Keyword.get(opts, :width, @default_width))
21 + end
22 +end
modified mix.exs
+3 −0
@@ -69,6 +69,9 @@ defmodule Diogramos.MixProject do
69 69 # describe all of them, and a plugin rename between minors would only
70 70 # show up as an empty panel.
71 71 {:prom_ex, "~> 1.11.0"},
72 + # Renders the device-pairing QR on the settings page. Pure Elixir,
73 + # SVG output -- no binary or JS dependency for one small image.
74 + {:eqrcode, "~> 0.2.1"},
72 75 {:gettext, "~> 1.0"},
73 76 {:jason, "~> 1.2"},
74 77 {:dns_cluster, "~> 0.2.0"},
modified mix.lock
+1 −0
@@ -12,6 +12,7 @@
12 12 "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
13 13 "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
14 14 "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
15 + "eqrcode": {:hex, :eqrcode, "0.2.1", "d12838813e8fc87b8940cc05f9baadb189031f6009facdc56ff074375ec73b6e", [:mix], [], "hexpm", "d5828a222b904c68360e7dc2a40c3ef33a1328b7c074583898040f389f928025"},
15 16 "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
16 17 "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
17 18 "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
added test/diogramos_web/live/user_live/connected_devices_test.exs
+128 −0
@@ -0,0 +1,128 @@
1 +defmodule DiogramosWeb.UserLive.ConnectedDevicesTest do
2 + use DiogramosWeb.ConnCase, async: false
3 +
4 + import Phoenix.LiveViewTest
5 + import Diogramos.AccountsFixtures
6 +
7 + alias Diogramos.Accounts
8 +
9 + setup %{conn: conn} do
10 + on_exit(fn -> System.delete_env("DIOGRAMOS_PUBLIC_URL") end)
11 +
12 + user = user_fixture()
13 + %{conn: log_in_user(conn, user), user: user}
14 + end
15 +
16 + describe "the panel" do
17 + test "shows an empty state before any device is paired", %{conn: conn} do
18 + {:ok, _lv, html} = live(conn, ~p"/users/settings")
19 +
20 + assert html =~ "Connected devices"
21 + assert html =~ "No tokens yet"
22 + end
23 +
24 + test "lists existing tokens with their labels", %{conn: conn, user: user} do
25 + Accounts.create_api_token(user, %{label: "Pixel 7"})
26 +
27 + {:ok, _lv, html} = live(conn, ~p"/users/settings")
28 +
29 + assert html =~ "Pixel 7"
30 + refute html =~ "No tokens yet"
31 + end
32 +
33 + test "labels an unlabelled token rather than rendering a blank row", %{
34 + conn: conn,
35 + user: user
36 + } do
37 + Accounts.create_api_token(user)
38 +
39 + {:ok, _lv, html} = live(conn, ~p"/users/settings")
40 + assert html =~ "(no label)"
41 + end
42 +
43 + test "does not list another user's tokens", %{conn: conn} do
44 + Accounts.create_api_token(user_fixture(), %{label: "Someone else's phone"})
45 +
46 + {:ok, _lv, html} = live(conn, ~p"/users/settings")
47 +
48 + refute html =~ "Someone else's phone"
49 + assert html =~ "No tokens yet"
50 + end
51 + end
52 +
53 + describe "generating a token" do
54 + test "renders a working token, a QR, and the pairing URI", %{conn: conn, user: user} do
55 + System.put_env("DIOGRAMOS_PUBLIC_URL", "https://diagrams.example.com")
56 +
57 + {:ok, lv, _html} = live(conn, ~p"/users/settings")
58 +
59 + html =
60 + lv
61 + |> form("#token_form", token: %{label: "Pixel 7"})
62 + |> render_submit()
63 +
64 + assert html =~ "diogramos://pair?"
65 + assert html =~ "https%3A%2F%2Fdiagrams.example.com"
66 + assert html =~ "<svg"
67 + assert html =~ "only time the token is shown"
68 +
69 + # The token on screen is the real bearer, not a display stand-in.
70 + [_, token] = Regex.run(~r/token=([A-Za-z0-9_-]+)/, html)
71 + assert {fetched, _id} = Accounts.fetch_user_by_api_token(token)
72 + assert fetched.id == user.id
73 +
74 + assert [%{label: "Pixel 7"}] = Accounts.list_api_tokens(user)
75 + end
76 +
77 + test "an empty label is stored as none", %{conn: conn, user: user} do
78 + {:ok, lv, _html} = live(conn, ~p"/users/settings")
79 +
80 + lv |> form("#token_form", token: %{label: ""}) |> render_submit()
81 +
82 + assert [%{label: nil}] = Accounts.list_api_tokens(user)
83 + end
84 +
85 + test "the panel can be dismissed, and the token is not shown again", %{conn: conn} do
86 + {:ok, lv, _html} = live(conn, ~p"/users/settings")
87 +
88 + html = lv |> form("#token_form", token: %{label: "Pixel"}) |> render_submit()
89 + assert html =~ "diogramos://pair?"
90 +
91 + html = lv |> element("button", "Done — hide") |> render_click()
92 +
93 + refute html =~ "diogramos://pair?"
94 + # ...but the device itself is still listed.
95 + assert html =~ "Pixel"
96 + end
97 + end
98 +
99 + describe "revoking" do
100 + test "removes the row and kills the token", %{conn: conn, user: user} do
101 + token = Accounts.create_api_token(user, %{label: "Old phone"})
102 + [%{id: id}] = Accounts.list_api_tokens(user)
103 +
104 + {:ok, lv, html} = live(conn, ~p"/users/settings")
105 + assert html =~ "Old phone"
106 +
107 + html = lv |> element("#api-token-#{id} button", "Revoke") |> render_click()
108 +
109 + refute html =~ "Old phone"
110 + assert html =~ "No tokens yet"
111 + refute Accounts.fetch_user_by_api_token(token)
112 + end
113 +
114 + test "cannot revoke a token belonging to someone else", %{conn: conn, user: user} do
115 + other = user_fixture()
116 + their_token = Accounts.create_api_token(other)
117 + [%{id: their_id}] = Accounts.list_api_tokens(other)
118 +
119 + {:ok, lv, _html} = live(conn, ~p"/users/settings")
120 +
121 + render_click(lv, "revoke_api_token", %{"id" => to_string(their_id)})
122 +
123 + # Still valid: the id belongs to another user, so the delete is scoped out.
124 + assert Accounts.fetch_user_by_api_token(their_token)
125 + assert Accounts.list_api_tokens(user) == []
126 + end
127 + end
128 +end
added test/diogramos_web/public_url_test.exs
+64 −0
@@ -0,0 +1,64 @@
1 +defmodule DiogramosWeb.PublicUrlTest do
2 + use DiogramosWeb.ConnCase, async: false
3 +
4 + alias DiogramosWeb.PublicUrl
5 +
6 + setup do
7 + on_exit(fn -> System.delete_env("DIOGRAMOS_PUBLIC_URL") end)
8 + :ok
9 + end
10 +
11 + defp conn_at(host, port, scheme, headers \\ []) do
12 + conn = %{Phoenix.ConnTest.build_conn() | host: host, port: port, scheme: scheme}
13 +
14 + Enum.reduce(headers, conn, fn {k, v}, acc -> Plug.Conn.put_req_header(acc, k, v) end)
15 + end
16 +
17 + test "uses the env override above everything else" do
18 + System.put_env("DIOGRAMOS_PUBLIC_URL", "https://diagrams.example.com/")
19 +
20 + conn = conn_at("localhost", 4000, :http, [{"x-forwarded-proto", "http"}])
21 + assert PublicUrl.derive(conn) == "https://diagrams.example.com"
22 + end
23 +
24 + test "falls back to the request's own scheme and host" do
25 + assert PublicUrl.derive(conn_at("example.com", 80, :http)) == "http://example.com"
26 + assert PublicUrl.derive(conn_at("example.com", 443, :https)) == "https://example.com"
27 + end
28 +
29 + test "keeps a non-default port, which is what LAN and emulator use" do
30 + assert PublicUrl.derive(conn_at("192.168.1.50", 4000, :http)) == "http://192.168.1.50:4000"
31 + end
32 +
33 + test "honours x-forwarded-proto while the proxy passes Host through" do
34 + # The Traefik/nginx default, and the case that matters: getting this
35 + # wrong puts http:// in the QR behind a TLS-terminating proxy.
36 + conn = conn_at("diagrams.example.com", 80, :http, [{"x-forwarded-proto", "https"}])
37 + assert PublicUrl.derive(conn) == "https://diagrams.example.com"
38 + end
39 +
40 + test "honours x-forwarded-host" do
41 + conn =
42 + conn_at("10.0.0.5", 4000, :http, [
43 + {"x-forwarded-proto", "https"},
44 + {"x-forwarded-host", "diagrams.example.com"}
45 + ])
46 +
47 + assert PublicUrl.derive(conn) == "https://diagrams.example.com"
48 + end
49 +
50 + test "takes the client-most value from a chained proxy list" do
51 + conn =
52 + conn_at("10.0.0.5", 4000, :http, [
53 + {"x-forwarded-proto", "https, http"},
54 + {"x-forwarded-host", "edge.example.com, inner.example.com"}
55 + ])
56 +
57 + assert PublicUrl.derive(conn) == "https://edge.example.com"
58 + end
59 +
60 + test "an x-forwarded-host carrying its own port is passed through" do
61 + conn = conn_at("10.0.0.5", 4000, :http, [{"x-forwarded-host", "example.com:8443"}])
62 + assert PublicUrl.derive(conn) == "http://example.com:8443"
63 + end
64 +end

Parents: bfa2c00