Sources: the offering pages and import queue only reach the user's own

f92997e · Gabe Morell · 2026-11-04 22:03

15 files +135 -17
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