changes

5e612cd · gmorell · 2026-05-25 03:27

15 files +331 -67

Files changed

modified lib/waxx_web/api/v1/board_json.ex
+2 −0
@@ -119,6 +119,8 @@ defmodule WaxxWeb.Api.V1.BoardJSON do
119 119 %{id: sb.id, name: sb.name, position: sb.position}
120 120 end
121 121
122 + def subboard_response(%Subboard{} = sb), do: %{subboard: subboard(sb)}
123 +
122 124 ## Cards ---------------------------------------------------------------
123 125
124 126 def cards(cards) when is_list(cards) do
modified lib/waxx_web/api/v1/card_controller.ex
+31 −5
@@ -20,7 +20,7 @@ defmodule WaxxWeb.Api.V1.CardController do
20 20 use WaxxWeb, :controller
21 21
22 22 alias Waxx.{Accounts, Kanban, Repo}
23 − alias Waxx.Kanban.{BoardField, BoardLabel, Card}
23 + alias Waxx.Kanban.{BoardField, BoardLabel, Card, Subboard}
24 24 alias WaxxWeb.Api.V1.BoardJSON
25 25
26 26 action_fallback WaxxWeb.Api.FallbackController
@@ -75,16 +75,42 @@ defmodule WaxxWeb.Api.V1.CardController do
75 75 {:ok, target_stage_id} <- require_param(params, "board_stage_id") do
76 76 target_index = parse_position(params["position"])
77 77
78 − case Kanban.move_card(card, target_stage_id, target_index, actor: user) do
79 − {:ok, moved} ->
80 − json(conn, BoardJSON.card_response(reload_card(moved)))
78 + with {:ok, moved} <- Kanban.move_card(card, target_stage_id, target_index, actor: user),
79 + {:ok, final} <- apply_subboard_change(moved, params, user) do
80 + json(conn, BoardJSON.card_response(reload_card(final)))
81 + else
82 + {:error, reason}
83 + when reason in [:invalid_transition, :invalid_stage, :invalid_subboard] ->
84 + {:error, reason}
81 85
82 − {:error, reason} when reason in [:invalid_transition, :invalid_stage] ->
86 + {:error, reason} ->
83 87 {:error, reason}
84 88 end
85 89 end
86 90 end
87 91
92 + # Optional second leg of a move: re-assign the card's subboard. Omitting
93 + # the key leaves it alone; `null` clears it to the default row.
94 + defp apply_subboard_change(card, params, user) do
95 + if Map.has_key?(params, "subboard_id") do
96 + case params["subboard_id"] do
97 + nil ->
98 + Kanban.set_card_subboard(card, nil, actor: user)
99 +
100 + id when is_binary(id) ->
101 + case Repo.get(Subboard, id) do
102 + nil -> {:error, :not_found}
103 + %Subboard{} = sb -> Kanban.set_card_subboard(card, sb, actor: user)
104 + end
105 +
106 + _ ->
107 + {:error, :validation_failed}
108 + end
109 + else
110 + {:ok, card}
111 + end
112 + end
113 +
88 114 ## Delete -------------------------------------------------------------
89 115
90 116 def delete(conn, %{"id" => id}) do
added lib/waxx_web/api/v1/subboard_controller.ex
+56 −0
@@ -0,0 +1,56 @@
1 +defmodule WaxxWeb.Api.V1.SubboardController do
2 + @moduledoc """
3 + Subboards = rows in the 2-D kanban grid. Owners only.
4 +
5 + POST /api/v1/boards/:board_id/subboards {name}
6 + DELETE /api/v1/subboards/:id
7 +
8 + Cards previously assigned to a deleted subboard fall back to the
9 + default row via the FK's `on_delete: :nilify_all`.
10 + """
11 +
12 + use WaxxWeb, :controller
13 +
14 + alias Waxx.{Kanban, Repo}
15 + alias Waxx.Kanban.Subboard
16 + alias WaxxWeb.Api.V1.BoardJSON
17 +
18 + action_fallback WaxxWeb.Api.FallbackController
19 +
20 + def create(conn, %{"board_id" => board_id} = params) do
21 + user = conn.assigns.current_scope.user
22 +
23 + with {:ok, board} <- fetch_owner_board(board_id, user),
24 + attrs <- Map.take(params, ["name"]),
25 + {:ok, sb} <- Kanban.create_subboard(board, attrs) do
26 + conn
27 + |> put_status(:created)
28 + |> json(BoardJSON.subboard_response(sb))
29 + end
30 + end
31 +
32 + def delete(conn, %{"id" => id}) do
33 + user = conn.assigns.current_scope.user
34 +
35 + with %Subboard{board_id: board_id} = sb <-
36 + Repo.get(Subboard, id) || {:error, :not_found},
37 + {:ok, _board} <- fetch_owner_board(board_id, user),
38 + {:ok, _} <- Kanban.delete_subboard(sb) do
39 + send_resp(conn, :no_content, "")
40 + else
41 + {:error, _} = err -> err
42 + end
43 + end
44 +
45 + defp fetch_owner_board(board_id, user) do
46 + case Kanban.get_board_for_user(board_id, user) do
47 + nil ->
48 + {:error, :not_found}
49 +
50 + board ->
51 + if Kanban.can_manage?(Kanban.role_for(board.id, user)),
52 + do: {:ok, board},
53 + else: {:error, :forbidden}
54 + end
55 + end
56 +end
modified lib/waxx_web/public_url.ex
+90 −62
@@ -1,15 +1,23 @@
1 1 defmodule WaxxWeb.PublicUrl do
2 2 @moduledoc """
3 3 Derives the public-facing origin (`scheme://host[:port]`) the native
4 − client should dial back to, given a `%Plug.Conn{}`.
4 + client should dial back to.
5 5
6 − Precedence — mirrors `derive_base_url/2` in
7 − `dms/src/handlers/api_tokens.rs`:
6 + Resolution (highest precedence first):
8 7
9 8 1. `WAXX_PUBLIC_URL` env (explicit override for reverse-proxy setups).
10 − 2. `X-Forwarded-Proto` + `X-Forwarded-Host` (standard proxy headers).
11 − 3. The request's `Host` header (covers LAN access on `0.0.0.0`).
12 − 4. The endpoint's configured URL as a last resort.
9 + 2. Composed from forwarded headers + conn fallbacks:
10 + - scheme: `X-Forwarded-Proto` (first value) if present, else `conn.scheme`
11 + - host: `X-Forwarded-Host` (first value, may carry `:port`) if present,
12 + else `conn.host` (with `conn.port` appended if non-default)
13 + 3. The endpoint's configured URL as a last resort.
14 +
15 + Proto and host are sourced independently so a proxy that only sets
16 + `X-Forwarded-Proto: https` (the common Traefik / nginx default —
17 + Host is preserved unchanged) still yields the right scheme. Treating
18 + them as a unit caused QRs to encode `http://` behind TLS-terminated
19 + proxies, which then broke WebSocket upgrades when Plug.SSL bounced
20 + the upgrade with a 301.
13 21
14 22 Returned URL has no trailing slash so callers can concatenate
15 23 `"\#{base}/api/..."` without worrying about double slashes.
@@ -19,62 +27,101 @@ defmodule WaxxWeb.PublicUrl do
19 27
20 28 @spec derive(Plug.Conn.t()) :: String.t()
21 29 def derive(%Plug.Conn{} = conn) do
22 − cond do
23 − override = env_override() -> override
24 − forwarded = from_forwarded_headers(conn) -> forwarded
25 − host = from_conn_host(conn) -> host
26 − true -> endpoint_url()
30 + case env_override() do
31 + nil ->
32 + compose(scheme_from_conn(conn), host_from_conn(conn)) || endpoint_url()
33 +
34 + url ->
35 + url
27 36 end
28 37 end
29 38
30 39 @doc """
31 − LiveView-flavoured derivation. Pulls `:uri` and `:x_headers` from
32 − `Phoenix.LiveView.get_connect_info/2` so the same precedence applies on
33 − the connected socket. Falls back to the endpoint URL when the info
34 − isn't available (e.g. disconnected first render).
40 + LiveView-flavoured derivation. Must be called from `mount/3` (LiveView
41 + destroys connect_info after mount). The endpoint must expose `:uri`
42 + and `:x_headers` in `connect_info`.
35 43 """
36 44 @spec derive(Phoenix.LiveView.Socket.t()) :: String.t()
37 45 def derive(%Phoenix.LiveView.Socket{} = socket) do
38 − cond do
39 − override = env_override() ->
40 − override
46 + case env_override() do
47 + nil ->
48 + compose(scheme_from_socket(socket), host_from_socket(socket)) || endpoint_url()
49 +
50 + url ->
51 + url
52 + end
53 + end
41 54
42 − forwarded = forwarded_from_socket(socket) ->
43 − forwarded
55 + ## Scheme + host extraction ------------------------------------------
44 56
45 − uri = uri_from_socket(socket) ->
46 − from_uri(uri)
57 + defp scheme_from_conn(conn) do
58 + forwarded_proto(get_req_header(conn, "x-forwarded-proto")) ||
59 + to_string(conn.scheme)
60 + end
47 61
48 − true ->
49 − endpoint_url()
62 + defp host_from_conn(conn) do
63 + case get_req_header(conn, "x-forwarded-host") do
64 + [h | _] -> normalise_host(h)
65 + _ -> host_with_port(conn.host, to_string(conn.scheme), conn.port)
50 66 end
51 67 end
52 68
53 − defp forwarded_from_socket(socket) do
69 + defp scheme_from_socket(socket) do
70 + headers = Phoenix.LiveView.get_connect_info(socket, :x_headers) || []
71 +
72 + forwarded_proto([find_header(headers, "x-forwarded-proto")]) ||
73 + uri_field(socket, :scheme)
74 + end
75 +
76 + defp host_from_socket(socket) do
54 77 headers = Phoenix.LiveView.get_connect_info(socket, :x_headers) || []
55 − proto = find_header(headers, "x-forwarded-proto")
56 − host = find_header(headers, "x-forwarded-host")
57 78
58 − if proto && host do
59 − scheme = proto |> String.split(",") |> List.first() |> String.trim()
60 − strip_trailing_slash("#{scheme}://#{host}")
79 + case find_header(headers, "x-forwarded-host") do
80 + nil -> uri_host(socket)
81 + host -> normalise_host(host)
61 82 end
62 83 end
63 84
64 − defp uri_from_socket(socket), do: Phoenix.LiveView.get_connect_info(socket, :uri)
85 + defp uri_field(socket, key) do
86 + case Phoenix.LiveView.get_connect_info(socket, :uri) do
87 + %URI{} = uri -> Map.get(uri, key) |> to_string_safe()
88 + _ -> nil
89 + end
90 + end
65 91
66 − defp from_uri(%URI{scheme: scheme, host: host, port: port}) when is_binary(host) do
67 − base =
68 − if default_port?(scheme, port) do
69 − "#{scheme}://#{host}"
70 − else
71 − "#{scheme}://#{host}:#{port}"
72 − end
92 + defp uri_host(socket) do
93 + case Phoenix.LiveView.get_connect_info(socket, :uri) do
94 + %URI{host: host, scheme: scheme, port: port} when is_binary(host) ->
95 + host_with_port(host, scheme, port)
73 96
74 − strip_trailing_slash(base)
97 + _ ->
98 + nil
99 + end
75 100 end
76 101
77 − defp from_uri(_), do: nil
102 + ## Helpers ------------------------------------------------------------
103 +
104 + defp compose(nil, _), do: nil
105 + defp compose(_, nil), do: nil
106 + defp compose(scheme, host), do: strip_trailing_slash("#{scheme}://#{host}")
107 +
108 + defp forwarded_proto([proto | _]) when is_binary(proto) do
109 + proto |> String.split(",") |> List.first() |> String.trim()
110 + end
111 +
112 + defp forwarded_proto(_), do: nil
113 +
114 + # X-Forwarded-Host may itself be a comma-separated list (chained proxies).
115 + # Take the first value verbatim — it may already include `:port`.
116 + defp normalise_host(h) when is_binary(h) do
117 + h |> String.split(",") |> List.first() |> String.trim()
118 + end
119 +
120 + defp host_with_port(host, _scheme, nil), do: host
121 +
122 + defp host_with_port(host, scheme, port) do
123 + if default_port?(scheme, port), do: host, else: "#{host}:#{port}"
124 + end
78 125
79 126 defp default_port?("http", 80), do: true
80 127 defp default_port?("https", 443), do: true
@@ -95,31 +142,12 @@ defmodule WaxxWeb.PublicUrl do
95 142 end
96 143 end
97 144
98 − defp from_forwarded_headers(conn) do
99 − with [proto | _] <- get_req_header(conn, "x-forwarded-proto"),
100 − [host | _] <- get_req_header(conn, "x-forwarded-host") do
101 − scheme = proto |> String.split(",") |> List.first() |> String.trim()
102 − strip_trailing_slash("#{scheme}://#{host}")
103 − else
104 − _ -> nil
105 − end
106 − end
107 −
108 − # Plug normalises the Host header into `conn.host` and `conn.port`.
109 − defp from_conn_host(%Plug.Conn{host: host, scheme: scheme, port: port})
110 − when is_binary(host) and host != "" do
111 − if default_port?(to_string(scheme), port) do
112 − strip_trailing_slash("#{scheme}://#{host}")
113 − else
114 − strip_trailing_slash("#{scheme}://#{host}:#{port}")
115 − end
116 − end
117 −
118 − defp from_conn_host(_), do: nil
119 −
120 145 defp endpoint_url do
121 146 WaxxWeb.Endpoint.url() |> strip_trailing_slash()
122 147 end
123 148
149 + defp to_string_safe(nil), do: nil
150 + defp to_string_safe(v), do: to_string(v)
151 +
124 152 defp strip_trailing_slash(s), do: String.replace_suffix(s, "/", "")
125 153 end
modified lib/waxx_web/router.ex
+3 −0
@@ -148,6 +148,9 @@ defmodule WaxxWeb.Router do
148 148 get "/users/invites", AppInviteController, :index
149 149 post "/users/invites", AppInviteController, :create
150 150 delete "/users/invites/:id", AppInviteController, :delete
151 +
152 + post "/boards/:board_id/subboards", SubboardController, :create
153 + delete "/subboards/:id", SubboardController, :delete
151 154 end
152 155 end
153 156 end
added test/waxx_web/api/v1/subboards_test.exs
+133 −0
@@ -0,0 +1,133 @@
1 +defmodule WaxxWeb.Api.V1.SubboardsTest do
2 + use WaxxWeb.ConnCase, async: true
3 +
4 + import Waxx.AccountsFixtures
5 + import Waxx.KanbanFixtures
6 +
7 + alias Waxx.{Kanban, Repo}
8 +
9 + defp setup_owner do
10 + user = confirmed_user_fixture()
11 + board = board_fixture(user)
12 + %{user: user, board: board, token: api_token_fixture(user)}
13 + end
14 +
15 + defp auth(conn, token), do: put_req_header(conn, "authorization", "Bearer " <> token)
16 +
17 + describe "POST /api/v1/boards/:id/subboards" do
18 + test "owner can create a subboard", %{conn: conn} do
19 + %{token: token, board: board} = setup_owner()
20 +
21 + conn =
22 + conn
23 + |> auth(token)
24 + |> post(~p"/api/v1/boards/#{board.id}/subboards", %{name: "Backend"})
25 +
26 + assert %{"subboard" => sb} = json_response(conn, 201)
27 + assert sb["name"] == "Backend"
28 + assert sb["position"] == 0
29 + end
30 +
31 + test "non-owner gets 403", %{conn: conn} do
32 + owner = confirmed_user_fixture()
33 + editor = confirmed_user_fixture()
34 + board = board_fixture(owner)
35 + {:ok, _} = Kanban.add_member(board, editor, "editor")
36 + token = api_token_fixture(editor)
37 +
38 + conn =
39 + conn
40 + |> auth(token)
41 + |> post(~p"/api/v1/boards/#{board.id}/subboards", %{name: "X"})
42 +
43 + assert json_response(conn, 403)
44 + end
45 + end
46 +
47 + describe "DELETE /api/v1/subboards/:id" do
48 + test "owner can delete; cards fall back to default row", %{conn: conn} do
49 + %{token: token, board: board, user: user} = setup_owner()
50 + {:ok, sb} = Kanban.create_subboard(board, %{"name" => "QA"})
51 + card = card_fixture(board, user)
52 + {:ok, _} = Kanban.set_card_subboard(card, sb)
53 +
54 + conn = conn |> auth(token) |> delete(~p"/api/v1/subboards/#{sb.id}")
55 + assert response(conn, 204) == ""
56 +
57 + refreshed = Repo.get!(Waxx.Kanban.Card, card.id)
58 + assert is_nil(refreshed.subboard_id)
59 + end
60 +
61 + test "non-owner gets 403", %{conn: conn} do
62 + owner = confirmed_user_fixture()
63 + editor = confirmed_user_fixture()
64 + board = board_fixture(owner)
65 + {:ok, _} = Kanban.add_member(board, editor, "editor")
66 + {:ok, sb} = Kanban.create_subboard(board, %{"name" => "X"})
67 + token = api_token_fixture(editor)
68 +
69 + conn = conn |> auth(token) |> delete(~p"/api/v1/subboards/#{sb.id}")
70 + assert json_response(conn, 403)
71 + end
72 + end
73 +
74 + describe "POST /api/v1/cards/:id/move with subboard_id" do
75 + test "moves the card to the target subboard", %{conn: conn} do
76 + %{token: token, board: board, user: user} = setup_owner()
77 + {:ok, sb} = Kanban.create_subboard(board, %{"name" => "Backend"})
78 + card = card_fixture(board, user)
79 + [_todo, done] = board.stages
80 +
81 + conn =
82 + conn
83 + |> auth(token)
84 + |> post(~p"/api/v1/cards/#{card.id}/move", %{
85 + board_stage_id: done.id,
86 + subboard_id: sb.id
87 + })
88 +
89 + assert %{"card" => c} = json_response(conn, 200)
90 + assert c["board_stage_id"] == done.id
91 + assert c["subboard_id"] == sb.id
92 + end
93 +
94 + test "null subboard_id clears the assignment", %{conn: conn} do
95 + %{token: token, board: board, user: user} = setup_owner()
96 + {:ok, sb} = Kanban.create_subboard(board, %{"name" => "Backend"})
97 + card = card_fixture(board, user)
98 + {:ok, _} = Kanban.set_card_subboard(card, sb)
99 + [_todo, done] = board.stages
100 +
101 + conn =
102 + conn
103 + |> auth(token)
104 + |> post(~p"/api/v1/cards/#{card.id}/move", %{
105 + board_stage_id: done.id,
106 + subboard_id: nil
107 + })
108 +
109 + assert %{"card" => c} = json_response(conn, 200)
110 + assert c["subboard_id"] == nil
111 + end
112 +
113 + test "rejects a subboard from a different board", %{conn: conn} do
114 + %{token: token, board: board, user: user} = setup_owner()
115 + card = card_fixture(board, user)
116 + [_todo, done] = board.stages
117 +
118 + other = confirmed_user_fixture()
119 + other_board = board_fixture(other)
120 + {:ok, foreign_sb} = Kanban.create_subboard(other_board, %{"name" => "X"})
121 +
122 + conn =
123 + conn
124 + |> auth(token)
125 + |> post(~p"/api/v1/cards/#{card.id}/move", %{
126 + board_stage_id: done.id,
127 + subboard_id: foreign_sb.id
128 + })
129 +
130 + assert json_response(conn, 422)["error"]["code"] == "invalid_subboard"
131 + end
132 + end
133 +end
modified test/waxx_web/public_url_test.exs
+16 −0
@@ -36,6 +36,22 @@ defmodule WaxxWeb.PublicUrlTest do
36 36 assert PublicUrl.derive(conn) == "https://proxy.example.com"
37 37 end
38 38
39 + test "X-Forwarded-Proto alone upgrades the scheme; host comes from conn" do
40 + conn =
41 + build_conn(host: "waxx.neiam.co", scheme: :http, port: 80)
42 + |> Plug.Conn.put_req_header("x-forwarded-proto", "https")
43 +
44 + assert PublicUrl.derive(conn) == "https://waxx.neiam.co"
45 + end
46 +
47 + test "X-Forwarded-Host alone keeps conn scheme; host from header" do
48 + conn =
49 + build_conn(host: "internal", scheme: :https, port: 443)
50 + |> Plug.Conn.put_req_header("x-forwarded-host", "edge.example.com")
51 +
52 + assert PublicUrl.derive(conn) == "https://edge.example.com"
53 + end
54 +
39 55 test "X-Forwarded-Proto with comma-separated values picks the first" do
40 56 conn =
41 57 build_conn(host: "internal.example.com", scheme: :http, port: 80)

Parents: 1405e6a