neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Sources: the offering pages and import queue only reach the user's own
f92997e · Gabe Morell · 2026-11-04 22:03
Message
{commit_body(@commit)}
Files changed
modified
apps/room_sanctum/lib/room_sanctum/configuration.ex
+4
−0
@@ -55,6 +55,10 @@ defmodule RoomSanctum.Configuration do
| 55 | 55 | """ |
| 56 | 56 | def get_source!(:bare, id), do: Repo.get!(Source, id) |
| 57 | 57 | |
| 58 | + # Someone else's source raises the not-found a missing one does. | |
| 59 | + def get_source!({:user, uid}, id), | |
| 60 | + do: Source |> Repo.get_by!(id: id, user_id: uid) |> Repo.preload([:mailboxes, :webhooks]) | |
| 61 | + | |
| 58 | 62 | @doc """ |
| 59 | 63 | Subscribe to config changes for one record, so a worker can be told rather |
| 60 | 64 | than asking. |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/index.ex
+7
−4
@@ -29,7 +29,7 @@ defmodule RoomSanctumWeb.SourceLive.Index do
| 29 | 29 | defp apply_action(socket, :edit, %{"id" => id}) do |
| 30 | 30 | socket |
| 31 | 31 | |> assign(:page_title, "Modify Offering") |
| 32 | − |> assign(:source, Configuration.get_source!(id)) | |
| 32 | + |> assign(:source, Configuration.get_source!(owner(socket), id)) | |
| 33 | 33 | end |
| 34 | 34 | |
| 35 | 35 | defp apply_action(socket, :new, _params) do |
@@ -75,7 +75,7 @@ defmodule RoomSanctumWeb.SourceLive.Index do
| 75 | 75 | |
| 76 | 76 | @impl true |
| 77 | 77 | def handle_event("delete", %{"id" => id}, socket) do |
| 78 | − source = Configuration.get_source!(id) | |
| 78 | + source = Configuration.get_source!(owner(socket), id) | |
| 79 | 79 | {:ok, _} = Configuration.delete_source(source) |
| 80 | 80 | |
| 81 | 81 | {:noreply, stream_delete(socket, :cfg_sources, source)} |
@@ -83,8 +83,8 @@ defmodule RoomSanctumWeb.SourceLive.Index do
| 83 | 83 | |
| 84 | 84 | def handle_event("toggle-source-enabled", %{"id" => id, "current" => current}, socket) do |
| 85 | 85 | curr_bool = current |> String.to_existing_atom() |
| 86 | − src_og = Configuration.get_source!(id) | |
| 87 | − {:ok, src} = Configuration.toggle_source!(id, not curr_bool) | |
| 86 | + src_og = Configuration.get_source!(owner(socket), id) | |
| 87 | + {:ok, src} = Configuration.toggle_source!(src_og.id, not curr_bool) | |
| 88 | 88 | |
| 89 | 89 | icon = """ |
| 90 | 90 | <i class="fas #{get_icon(src.type)}"></i> |
@@ -272,4 +272,7 @@ defmodule RoomSanctumWeb.SourceLive.Index do
| 272 | 272 | true -> false |
| 273 | 273 | end |
| 274 | 274 | end |
| 275 | + | |
| 276 | + # Ids from the URL or the client are looked up among the user's own. | |
| 277 | + defp owner(socket), do: {:user, socket.assigns.current_user.id} | |
| 275 | 278 | end |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.ex
+14
−10
@@ -197,7 +197,7 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 197 | 197 | # Not inspected. This struct now carries a source's request headers, and an |
| 198 | 198 | # API key printed to the logs on every page load is a secret in a place |
| 199 | 199 | # nobody thinks to look for one. |
| 200 | − source = Configuration.get_source!(id) | |
| 200 | + source = Configuration.get_source!(owner(socket), id) | |
| 201 | 201 | |
| 202 | 202 | # Subscribe to relevant PubSub channels |
| 203 | 203 | case source.type do |
@@ -602,10 +602,10 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 602 | 602 | defp preview_treasury(socket), do: assign(socket, :treasury_preview, nil) |
| 603 | 603 | |
| 604 | 604 | @impl true |
| 605 | − def handle_event("do-update", %{"type" => type, "id" => id}, socket) do | |
| 606 | − id = | |
| 607 | − id | |
| 608 | − |> String.to_integer() | |
| 605 | + # The buttons send the page's own source id; the page's is what is used, so | |
| 606 | + # an event naming another source does not reach it. | |
| 607 | + def handle_event("do-update", %{"type" => type}, socket) do | |
| 608 | + id = socket.assigns.source_id | |
| 609 | 609 | |
| 610 | 610 | case type do |
| 611 | 611 | "gtfs" -> |
@@ -935,7 +935,8 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 935 | 935 | end |
| 936 | 936 | |
| 937 | 937 | @impl true |
| 938 | − def handle_event("do-stats", %{"type" => type, "id" => id}, socket) do | |
| 938 | + def handle_event("do-stats", %{"type" => type}, socket) do | |
| 939 | + id = to_string(socket.assigns.source_id) | |
| 939 | 940 | # IO.inspect({type, id}) |
| 940 | 941 | |
| 941 | 942 | stats = |
@@ -1011,13 +1012,13 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 1011 | 1012 | end |
| 1012 | 1013 | |
| 1013 | 1014 | @impl true |
| 1014 | − def handle_event("do-gbfs-search", %{"name" => name, "id" => id}, socket) do | |
| 1015 | − results = Storage.list_gbfs_station_information(id, name) | |
| 1015 | + def handle_event("do-gbfs-search", %{"name" => name}, socket) do | |
| 1016 | + results = Storage.list_gbfs_station_information(to_string(socket.assigns.source_id), name) | |
| 1016 | 1017 | {:noreply, socket |> assign(:tester_results, results)} |
| 1017 | 1018 | end |
| 1018 | 1019 | |
| 1019 | − def handle_event("do-gtfs-search", %{"name" => name, "id" => id}, socket) do | |
| 1020 | − results = Storage.list_stops(id, name) | |
| 1020 | + def handle_event("do-gtfs-search", %{"name" => name}, socket) do | |
| 1021 | + results = Storage.list_stops(to_string(socket.assigns.source_id), name) | |
| 1021 | 1022 | {:noreply, socket |> assign(:tester_results, results)} |
| 1022 | 1023 | end |
| 1023 | 1024 |
@@ -1617,4 +1618,7 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 1617 | 1618 | def get_icon_url(false) do |
| 1618 | 1619 | "/assets/img/marker.png" |
| 1619 | 1620 | end |
| 1621 | + | |
| 1622 | + # The source comes from the URL, so it is looked up among the user's own. | |
| 1623 | + defp owner(socket), do: {:user, socket.assigns.current_user.id} | |
| 1620 | 1624 | end |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/work.ex
+12
−3
@@ -80,10 +80,18 @@ defmodule RoomSanctumWeb.SourceLive.Work do
| 80 | 80 | end |
| 81 | 81 | end |
| 82 | 82 | |
| 83 | − | |
| 84 | 83 | @impl true |
| 85 | 84 | def handle_info(_msg, socket), do: {:noreply, socket} |
| 86 | 85 | |
| 86 | + # A job id the client sends is only acted on if it is one of the import jobs | |
| 87 | + # this page listed, for one of the user's own feeds -- not any Oban job. | |
| 88 | + defp own_job?(socket, id) do | |
| 89 | + case Enum.find(socket.assigns.jobs, &(to_string(&1.id) == to_string(id))) do | |
| 90 | + nil -> false | |
| 91 | + job -> mine?(socket, source_id(job)) | |
| 92 | + end | |
| 93 | + end | |
| 94 | + | |
| 87 | 95 | defp mine?(socket, id) do |
| 88 | 96 | case Integer.parse(to_string(id)) do |
| 89 | 97 | {id, ""} -> Map.has_key?(socket.assigns.names, id) |
@@ -92,8 +100,9 @@ defmodule RoomSanctumWeb.SourceLive.Work do
| 92 | 100 | end |
| 93 | 101 | |
| 94 | 102 | @impl true |
| 103 | + # Only the user's own feeds: the id is the client's. | |
| 95 | 104 | def handle_event("requeue", %{"id" => id}, socket) do |
| 96 | − case RoomGtfs.Worker.update_static_data(String.to_integer(id)) do | |
| 105 | + case mine?(socket, id) && RoomGtfs.Worker.update_static_data(String.to_integer(id)) do | |
| 97 | 106 | {:ok, %{conflict?: true}} -> |
| 98 | 107 | {:noreply, socket |> put_flash(:info, "That feed already has an import outstanding")} |
| 99 | 108 |
@@ -121,7 +130,7 @@ defmodule RoomSanctumWeb.SourceLive.Work do
| 121 | 130 | """ |
| 122 | 131 | @impl true |
| 123 | 132 | def handle_event("clear", %{"id" => id}, socket) do |
| 124 | − case Oban.cancel_job(String.to_integer(id)) do | |
| 133 | + case own_job?(socket, id) && Oban.cancel_job(String.to_integer(id)) do | |
| 125 | 134 | :ok -> |
| 126 | 135 | {:noreply, |
| 127 | 136 | socket |
modified
apps/room_sanctum/test/room_sanctum_web/live/ownership_test.exs
+98
−0
@@ -151,4 +151,102 @@ defmodule RoomSanctumWeb.OwnershipTest do
| 151 | 151 | assert Repo.reload!(their_screen).visions == [mine.id] |
| 152 | 152 | end |
| 153 | 153 | end |
| 154 | + | |
| 155 | + defp gtfs_source_for(user) do | |
| 156 | + {:ok, source} = | |
| 157 | + Configuration.create_source(%{ | |
| 158 | + name: "Their buses", | |
| 159 | + notes: "", | |
| 160 | + type: :gtfs, | |
| 161 | + enabled: true, | |
| 162 | + user_id: user.id, | |
| 163 | + config: %{"__type__" => "gtfs", "url" => "https://e.test/g.zip", "tz" => "UTC"} | |
| 164 | + }) | |
| 165 | + | |
| 166 | + source | |
| 167 | + end | |
| 168 | + | |
| 169 | + defp import_jobs(source_id, state \\ nil) do | |
| 170 | + import Ecto.Query | |
| 171 | + | |
| 172 | + from(j in "oban_jobs", | |
| 173 | + where: | |
| 174 | + j.queue == "gtfs_import" and fragment("(?->>'source_id')::int", j.args) == ^source_id, | |
| 175 | + select: j.state | |
| 176 | + ) | |
| 177 | + |> Repo.all() | |
| 178 | + |> Enum.filter(&(is_nil(state) or &1 == state)) | |
| 179 | + end | |
| 180 | + | |
| 181 | + describe "the source pages" do | |
| 182 | + test "will not show another user's source", ctx do | |
| 183 | + theirs = source_for(ctx.them, "Their markets") | |
| 184 | + | |
| 185 | + assert_raise Ecto.NoResultsError, fn -> | |
| 186 | + live(ctx.conn, Routes.source_show_path(ctx.conn, :show, theirs)) | |
| 187 | + end | |
| 188 | + end | |
| 189 | + | |
| 190 | + test "will not open another user's source for editing", ctx do | |
| 191 | + theirs = source_for(ctx.them, "Their markets") | |
| 192 | + | |
| 193 | + assert_raise Ecto.NoResultsError, fn -> | |
| 194 | + live(ctx.conn, Routes.source_index_path(ctx.conn, :edit, theirs)) | |
| 195 | + end | |
| 196 | + end | |
| 197 | + | |
| 198 | + test "will neither delete nor disable another user's source", ctx do | |
| 199 | + theirs = source_for(ctx.them, "Their markets") | |
| 200 | + {:ok, live, _html} = live(ctx.conn, Routes.source_index_path(ctx.conn, :index)) | |
| 201 | + | |
| 202 | + forge(live, "toggle-source-enabled", %{"id" => to_string(theirs.id), "current" => "true"}) | |
| 203 | + assert Repo.reload!(theirs).enabled | |
| 204 | + | |
| 205 | + {:ok, live, _html} = live(ctx.conn, Routes.source_index_path(ctx.conn, :index)) | |
| 206 | + forge(live, "delete", %{"id" => to_string(theirs.id)}) | |
| 207 | + assert Repo.reload(theirs) | |
| 208 | + end | |
| 209 | + | |
| 210 | + test "the import queue will not queue another user's feed", ctx do | |
| 211 | + theirs = gtfs_source_for(ctx.them) | |
| 212 | + {:ok, live, _html} = live(ctx.conn, "/cfg/offerings/work") | |
| 213 | + | |
| 214 | + render_click(live, "requeue", %{"id" => to_string(theirs.id)}) | |
| 215 | + | |
| 216 | + assert import_jobs(theirs.id) == [] | |
| 217 | + end | |
| 218 | + | |
| 219 | + test "the import queue will not clear another user's job", ctx do | |
| 220 | + theirs = gtfs_source_for(ctx.them) | |
| 221 | + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) | |
| 222 | + | |
| 223 | + {1, [%{id: job_id}]} = | |
| 224 | + Repo.insert_all( | |
| 225 | + "oban_jobs", | |
| 226 | + [ | |
| 227 | + %{ | |
| 228 | + state: "executing", | |
| 229 | + queue: "gtfs_import", | |
| 230 | + worker: "RoomGtfs.ImportJob", | |
| 231 | + args: %{"source_id" => theirs.id}, | |
| 232 | + errors: [], | |
| 233 | + tags: [], | |
| 234 | + meta: %{}, | |
| 235 | + attempt: 1, | |
| 236 | + max_attempts: 3, | |
| 237 | + priority: 0, | |
| 238 | + inserted_at: now, | |
| 239 | + scheduled_at: now, | |
| 240 | + attempted_at: now | |
| 241 | + } | |
| 242 | + ], | |
| 243 | + returning: [:id] | |
| 244 | + ) | |
| 245 | + | |
| 246 | + {:ok, live, _html} = live(ctx.conn, "/cfg/offerings/work") | |
| 247 | + render_click(live, "clear", %{"id" => to_string(job_id)}) | |
| 248 | + | |
| 249 | + assert import_jobs(theirs.id) == ["executing"] | |
| 250 | + end | |
| 251 | + end | |
| 154 | 252 | end |
Parents: 9eda73e