auto-migrate on boot; fix card background paste

f730ab0 · gmorell · 2026-06-23 18:05

14 files +163 -24
Message
{commit_body(@commit)}

Files changed

modified lib/waxx/kanban.ex
+92 −21
@@ -951,31 +951,47 @@ defmodule Waxx.Kanban do
951 951 """
952 952 def set_card_background(%Card{} = card, data_url, opts \\ []) when is_binary(data_url) do
953 953 with {:ok, content_type, bytes} <- decode_data_url(data_url) do
954 − result =
955 − %CardBackground{}
956 − |> CardBackground.changeset(%{
957 − card_id: card.id,
958 − content_type: content_type,
959 − image_data: bytes
960 − })
961 − |> Repo.insert(
962 − on_conflict: {:replace, [:content_type, :image_data, :updated_at]},
963 − conflict_target: :card_id
964 − )
954 + store_background(card, content_type, bytes, opts)
955 + end
956 + end
965 957
966 − with {:ok, _bg} <- result do
967 − log(card.board_id, opts[:actor], "card_updated",
968 − card_id: card.id,
969 − meta: %{changes: ["background"], title: card.title}
970 − )
958 + @doc """
959 + Sets a card's background from a remote image URL. The server fetches the
960 + bytes (the browser can't, cross-origin) and stores them, so a user can
961 + paste an image link rather than the image itself. Returns `{:ok, background}`
962 + or `{:error, reason}`.
963 + """
964 + def set_card_background_from_url(%Card{} = card, url, opts \\ []) when is_binary(url) do
965 + with {:ok, content_type, bytes} <- fetch_image(url) do
966 + store_background(card, content_type, bytes, opts)
967 + end
968 + end
971 969
972 − # Repaints the card for every viewer who has it open — `:cards_changed`
973 − # re-fetches the expanded card (and its newly-set background).
974 − broadcast_cards_changed(card.board_id)
975 − end
970 + defp store_background(%Card{} = card, content_type, bytes, opts) do
971 + result =
972 + %CardBackground{}
973 + |> CardBackground.changeset(%{
974 + card_id: card.id,
975 + content_type: content_type,
976 + image_data: bytes
977 + })
978 + |> Repo.insert(
979 + on_conflict: {:replace, [:content_type, :image_data, :updated_at]},
980 + conflict_target: :card_id
981 + )
976 982
977 − result
983 + with {:ok, _bg} <- result do
984 + log(card.board_id, opts[:actor], "card_updated",
985 + card_id: card.id,
986 + meta: %{changes: ["background"], title: card.title}
987 + )
988 +
989 + # Repaints the card for every viewer who has it open — `:cards_changed`
990 + # re-fetches the expanded card (and its newly-set background).
991 + broadcast_cards_changed(card.board_id)
978 992 end
993 +
994 + result
979 995 end
980 996
981 997 @doc "Removes a card's background image, if any. Always returns `:ok`."
@@ -1008,6 +1024,61 @@ defmodule Waxx.Kanban do
1008 1024
1009 1025 defp decode_data_url(_), do: {:error, :invalid_image}
1010 1026
1027 + # Fetches a remote image URL server-side and returns `{content_type, bytes}`.
1028 + #
1029 + # Guards: http(s) only, capped download size, and the response must declare an
1030 + # allowed image content type. NOTE: this is a server-side fetch of a
1031 + # user-supplied URL (a limited SSRF surface) — acceptable for a trusted-team
1032 + # tool, but worth a deny-list / egress policy if this is ever exposed widely.
1033 + defp fetch_image(url) do
1034 + case URI.parse(url) do
1035 + %URI{scheme: scheme, host: host}
1036 + when scheme in ["http", "https"] and is_binary(host) and host != "" ->
1037 + do_fetch_image(url)
1038 +
1039 + _ ->
1040 + {:error, :invalid_image}
1041 + end
1042 + end
1043 +
1044 + defp do_fetch_image(url) do
1045 + max = CardBackground.max_bytes()
1046 +
1047 + case Req.get(url,
1048 + max_redirects: 3,
1049 + receive_timeout: 10_000,
1050 + connect_options: [timeout: 5_000],
1051 + decode_body: false,
1052 + retry: false
1053 + ) do
1054 + {:ok, %Req.Response{status: 200, headers: headers, body: body}}
1055 + when is_binary(body) and byte_size(body) > 0 ->
1056 + content_type = headers |> header_value("content-type") |> normalize_content_type()
1057 +
1058 + cond do
1059 + byte_size(body) > max -> {:error, :invalid_image}
1060 + content_type in CardBackground.content_types() -> {:ok, content_type, body}
1061 + true -> {:error, :invalid_image}
1062 + end
1063 +
1064 + _ ->
1065 + {:error, :invalid_image}
1066 + end
1067 + rescue
1068 + _ -> {:error, :invalid_image}
1069 + end
1070 +
1071 + # Req lowercases header names and returns values as a list.
1072 + defp header_value(headers, name) do
1073 + headers |> Map.get(name, []) |> List.first()
1074 + end
1075 +
1076 + defp normalize_content_type(nil), do: nil
1077 +
1078 + defp normalize_content_type(ct) when is_binary(ct) do
1079 + ct |> String.split(";", parts: 2) |> hd() |> String.trim() |> String.downcase()
1080 + end
1081 +
1011 1082 def create_card(%Board{} = board, %User{id: user_id} = user, attrs) do
1012 1083 # Default to the first stage if none supplied. Reject stages that don't
1013 1084 # belong to this board.
modified lib/waxx_web/live/board_live/show.ex
+46 −3
@@ -213,6 +213,33 @@ defmodule WaxxWeb.BoardLive.Show do
213 213 end)
214 214 end
215 215
216 + # Pasting an image *link* (clipboard holds a URL, not image bytes). The
217 + # server fetches it — the browser can't, cross-origin.
218 + def handle_event("set_card_background_url", %{"url" => url}, socket) do
219 + guard_edit(socket, fn ->
220 + case socket.assigns.expanded_card do
221 + nil ->
222 + {:noreply, socket}
223 +
224 + card ->
225 + case Kanban.set_card_background_from_url(card, url,
226 + actor: socket.assigns.current_scope.user
227 + ) do
228 + {:ok, _} ->
229 + {:noreply, assign(socket, :expanded_card, Kanban.get_card(card.id))}
230 +
231 + {:error, _} ->
232 + {:noreply,
233 + put_flash(
234 + socket,
235 + :error,
236 + "Couldn't load that image link. It must point to a PNG, JPG, GIF, or WebP under 5 MB."
237 + )}
238 + end
239 + end
240 + end)
241 + end
242 +
216 243 # The hook short-circuits oversized pastes before sending them so we don't
217 244 # ship megabytes over the channel — just surface the reason.
218 245 def handle_event("card_background_too_large", _, socket) do
@@ -782,9 +809,16 @@ defmodule WaxxWeb.BoardLive.Show do
782 809 const MAX = 5_000_000;
783 810
784 811 this.onPaste = (e) => {
785 − const items = (e.clipboardData && e.clipboardData.items) || [];
786 − for (const item of items) {
787 − if (!item.type || !item.type.startsWith("image/")) continue;
812 + const items = e.clipboardData && e.clipboardData.items;
813 + if (!items) return;
814 +
815 + // `DataTransferItemList` is array-like but not reliably
816 + // iterable (no Symbol.iterator in Chrome), so index it by hand
817 + // rather than `for...of` — the latter throws and the paste
818 + // silently does nothing.
819 + for (let i = 0; i < items.length; i++) {
820 + const item = items[i];
821 + if (item.kind !== "file" || !item.type.startsWith("image/")) continue;
788 822
789 823 const file = item.getAsFile();
790 824 if (!file) continue;
@@ -803,6 +837,15 @@ defmodule WaxxWeb.BoardLive.Show do
803 837 reader.readAsDataURL(file);
804 838 return;
805 839 }
840 +
841 + // No image bytes on the clipboard — fall back to a pasted image
842 + // *link* (copying an image URL yields text/plain). The server
843 + // fetches it, since the browser can't cross-origin.
844 + const text = (e.clipboardData.getData("text/plain") || "").trim();
845 + if (/^https?:\/\/\S+$/i.test(text)) {
846 + e.preventDefault();
847 + this.pushEvent("set_card_background_url", { url: text });
848 + }
806 849 };
807 850
808 851 // Listen on the document so a paste lands even when nothing inside
modified rel/overlays/bin/server
+15 −0
@@ -2,4 +2,19 @@
2 2 set -eu
3 3
4 4 cd -P -- "$(dirname -- "$0")"
5 +
6 +# Auto-migrate on boot unless explicitly disabled. `Ecto.Migrator` takes a
7 +# Postgres advisory lock while migrating, so when several replicas start at
8 +# once (rolling update) they serialize safely: the first applies the pending
9 +# migrations and the rest find nothing to do. Migrations run in a separate
10 +# short-lived node and `set -e` aborts the boot if they fail, so we never
11 +# start a server against a half-migrated schema.
12 +#
13 +# Set WAXX_SKIP_MIGRATIONS=1 to opt out — e.g. if you'd rather run them from
14 +# a dedicated Job / initContainer.
15 +if [ "${WAXX_SKIP_MIGRATIONS:-0}" != "1" ]; then
16 + echo "[server] running pending migrations" >&2
17 + ./waxx eval Waxx.Release.migrate
18 +fi
19 +
5 20 PHX_SERVER=true exec ./waxx start
modified test/waxx/kanban_test.exs
+10 −0
@@ -821,6 +821,16 @@ defmodule Waxx.KanbanTest do
821 821 assert {:error, :invalid_image} = Kanban.set_card_background(card, "not-a-data-url")
822 822 end
823 823
824 + test "set_card_background_from_url rejects non-http(s) URLs without fetching", %{card: card} do
825 + assert {:error, :invalid_image} =
826 + Kanban.set_card_background_from_url(card, "ftp://example.com/x.png")
827 +
828 + assert {:error, :invalid_image} =
829 + Kanban.set_card_background_from_url(card, "file:///etc/passwd")
830 +
831 + assert {:error, :invalid_image} = Kanban.set_card_background_from_url(card, "not a url")
832 + end
833 +
824 834 test "clear removes the background", %{card: card} do
825 835 assert {:ok, _} = Kanban.set_card_background(card, @png_data_url)
826 836 assert :ok = Kanban.clear_card_background(card)

Parents: f28f3dd