neiam
/waxx
waxx
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
auto-migrate on boot; fix card background paste
f730ab0 · gmorell · 2026-06-23 18:05
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