Scribus: a scribus has an owner, and the pages only reach the user's own

feea568 · Gabe Morell · 2026-11-04 22:51

27 files +286 -19
Message
{commit_body(@commit)}

Files changed

modified apps/room_sanctum/lib/room_sanctum/configuration.ex
+15 −5
@@ -932,6 +932,10 @@ defmodule RoomSanctum.Configuration do
932 932 Repo.all(Scribus)
933 933 end
934 934
935 + def list_cfg_scribus({:user, uid}) do
936 + Repo.all(from s in Scribus, where: s.user_id == ^uid)
937 + end
938 +
935 939 @doc """
936 940 Gets a single scribus.
937 941
@@ -948,20 +952,26 @@ defmodule RoomSanctum.Configuration do
948 952 """
949 953 def get_scribus!(id), do: Repo.get!(Scribus, id)
950 954
955 + # Someone else's scribus raises the not-found a missing one does.
956 + def get_scribus!({:user, uid}, id), do: Repo.get_by!(Scribus, id: id, user_id: uid)
957 +
951 958 @doc """
952 − Creates a scribus.
959 + Creates a scribus belonging to the given user.
960 +
961 + The owner is set here rather than taken from `attrs`, so a form cannot
962 + make one for somebody else.
953 963
954 964 ## Examples
955 965
956 − iex> create_scribus(%{field: value})
966 + iex> create_scribus({:user, uid}, %{field: value})
957 967 {:ok, %Scribus{}}
958 968
959 − iex> create_scribus(%{field: bad_value})
969 + iex> create_scribus({:user, uid}, %{field: bad_value})
960 970 {:error, %Ecto.Changeset{}}
961 971
962 972 """
963 − def create_scribus(attrs \\ %{}) do
964 − %Scribus{}
973 + def create_scribus({:user, uid}, attrs \\ %{}) do
974 + %Scribus{user_id: uid}
965 975 |> Scribus.changeset(attrs)
966 976 |> Repo.insert()
967 977 end
modified apps/room_sanctum/lib/room_sanctum/configuration/scribus.ex
+8 −0
@@ -2,6 +2,9 @@ defmodule RoomSanctum.Configuration.Scribus do
2 2 use Ecto.Schema
3 3 import Ecto.Changeset
4 4
5 + alias RoomSanctum.Accounts.RabbitUser
6 + alias RoomSanctum.Configuration.{Ownership, Vision}
7 +
5 8 schema "cfg_scribus" do
6 9 field :configuration, {:array, :map}
7 10 field :name, :string
@@ -16,6 +19,8 @@ defmodule RoomSanctum.Configuration.Scribus do
16 19 field :show_name, :boolean
17 20 field :show_time, :boolean
18 21 field :tz, :string
22 + # Set by create_scribus/2 from the signed-in user, never cast.
23 + belongs_to :user, RoomSanctum.Accounts.User
19 24
20 25 timestamps()
21 26 end
@@ -25,5 +30,8 @@ defmodule RoomSanctum.Configuration.Scribus do
25 30 scribus
26 31 |> cast(attrs, [:name, :resolution, :configuration, :enabled, :ankyra, :color, :wait, :buffer, :theme, :vision])
27 32 |> validate_required([:name, :resolution, :vision, :enabled, :color, :wait, :buffer, :theme])
33 + # As on a pythiae: only the owner's own vision, shown on their own Ankyra.
34 + |> Ownership.validate_owned(:vision, Vision)
35 + |> Ownership.validate_owned(:ankyra, RabbitUser)
28 36 end
29 37 end
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_scribus_live/form_component.ex
+1 −1
@@ -118,7 +118,7 @@ defmodule RoomSanctumWeb.ScribusLive.FormComponent do
118 118 end
119 119
120 120 defp save_scribus(socket, :new, scribus_params) do
121 − case Configuration.create_scribus(scribus_params) do
121 + case Configuration.create_scribus({:user, socket.assigns.current_user.id}, scribus_params) do
122 122 {:ok, scribus} ->
123 123 notify_parent({:saved, scribus})
124 124
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_scribus_live/index.ex
+6 −3
@@ -11,7 +11,7 @@ defmodule RoomSanctumWeb.ScribusLive.Index do
11 11
12 12 {:ok,
13 13 socket
14 − |> stream(:cfg_scribus, Configuration.list_cfg_scribus())
14 + |> stream(:cfg_scribus, Configuration.list_cfg_scribus({:user, uid}))
15 15 |> stream(:resolutions, Configuration.list_scribus_resolutions({:user, uid}))
16 16 |> assign(:show_info, false)
17 17 |> assign(:resolution_form, to_form(Configuration.change_scribus_resolution(%ScribusResolution{})))
@@ -26,7 +26,7 @@ defmodule RoomSanctumWeb.ScribusLive.Index do
26 26 defp apply_action(socket, :edit, %{"id" => id}) do
27 27 socket
28 28 |> assign(:page_title, "Edit Scribus")
29 − |> assign(:scribus, Configuration.get_scribus!(id))
29 + |> assign(:scribus, Configuration.get_scribus!(owner(socket), id))
30 30 end
31 31
32 32 defp apply_action(socket, :new, _params) do
@@ -48,7 +48,7 @@ defmodule RoomSanctumWeb.ScribusLive.Index do
48 48
49 49 @impl true
50 50 def handle_event("delete", %{"id" => id}, socket) do
51 − scribus = Configuration.get_scribus!(id)
51 + scribus = Configuration.get_scribus!(owner(socket), id)
52 52 {:ok, _} = Configuration.delete_scribus(scribus)
53 53
54 54 {:noreply, stream_delete(socket, :cfg_scribus, scribus)}
@@ -82,4 +82,7 @@ defmodule RoomSanctumWeb.ScribusLive.Index do
82 82 {:noreply, socket}
83 83 end
84 84 end
85 +
86 + # Ids from the URL or the client are looked up among the user's own.
87 + defp owner(socket), do: {:user, socket.assigns.current_user.id}
85 88 end
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_scribus_live/show.ex
+3 −1
@@ -10,12 +10,14 @@ defmodule RoomSanctumWeb.ScribusLive.Show do
10 10
11 11 @impl true
12 12 def handle_params(%{"id" => id}, _, socket) do
13 + # Looked up among the user's own before anything is subscribed to.
14 + scribus = Configuration.get_scribus!({:user, socket.assigns.current_user.id}, id)
13 15 Phoenix.PubSub.subscribe(RoomSanctum.PubSub, "scribus:#{id}")
14 16 {:noreply,
15 17 socket
16 18 |> assign(:page_title, page_title(socket.assigns.live_action))
17 19 |> assign(:preview, nil)
18 − |> assign(:scribus, Configuration.get_scribus!(id))
20 + |> assign(:scribus, scribus)
19 21 |> assign(:scribus_id, id)}
20 22 end
21 23
added apps/room_sanctum/priv/repo/migrations/20261104224340_add_user_id_to_cfg_scribus.exs
+46 −0
@@ -0,0 +1,46 @@
1 +defmodule RoomSanctum.Repo.Migrations.AddUserIdToCfgScribus do
2 + use Ecto.Migration
3 +
4 + @moduledoc """
5 + A scribus had no owner, so every signed-in user could list, open, edit and
6 + delete every scribus. It gets one, as the other cfg tables have: a reference
7 + to users, required.
8 +
9 + Nobody can say who made the scribus rows already there, so they all go to
10 + the first user -- the lowest users.id, which on an installation with one
11 + person on it is that person. Plain SQL, so it does not depend on the schema
12 + modules as they happen to be when it runs. Rows with no user to give them
13 + to (scribus rows, but no users at all) make the NOT NULL fail, which is the
14 + right outcome: there is nobody to hand them to.
15 + """
16 +
17 + @doc false
18 + # Exposed so the test can run it against rows it planted.
19 + def backfill_sql do
20 + """
21 + UPDATE cfg_scribus
22 + SET user_id = (SELECT id FROM users ORDER BY id LIMIT 1)
23 + WHERE user_id IS NULL
24 + """
25 + end
26 +
27 + def up do
28 + alter table(:cfg_scribus) do
29 + add :user_id, references(:users, on_delete: :nilify_all)
30 + end
31 +
32 + create index(:cfg_scribus, [:user_id])
33 +
34 + execute(backfill_sql())
35 +
36 + alter table(:cfg_scribus) do
37 + modify :user_id, :bigint, null: false
38 + end
39 + end
40 +
41 + def down do
42 + alter table(:cfg_scribus) do
43 + remove :user_id
44 + end
45 + end
46 +end
modified apps/room_sanctum/test/room_sanctum/configuration_test.exs
+4 −2
@@ -311,6 +311,8 @@ defmodule RoomSanctum.ConfigurationTest do
311 311
312 312 @invalid_attrs %{configuration: nil, name: nil, resolution: nil}
313 313
314 + defp owner, do: {:user, RoomSanctum.OwnershipFixtures.owner().id}
315 +
314 316 test "list_cfg_scribus/0 returns all cfg_scribus" do
315 317 scribus = scribus_fixture()
316 318 assert Configuration.list_cfg_scribus() == [scribus]
@@ -324,14 +326,14 @@ defmodule RoomSanctum.ConfigurationTest do
324 326 test "create_scribus/1 with valid data creates a scribus" do
325 327 valid_attrs = %{configuration: [], name: "some name", resolution: "some resolution"}
326 328
327 − assert {:ok, %Scribus{} = scribus} = Configuration.create_scribus(valid_attrs)
329 + assert {:ok, %Scribus{} = scribus} = Configuration.create_scribus(owner(), valid_attrs)
328 330 assert scribus.configuration == []
329 331 assert scribus.name == "some name"
330 332 assert scribus.resolution == "some resolution"
331 333 end
332 334
333 335 test "create_scribus/1 with invalid data returns error changeset" do
334 − assert {:error, %Ecto.Changeset{}} = Configuration.create_scribus(@invalid_attrs)
336 + assert {:error, %Ecto.Changeset{}} = Configuration.create_scribus(owner(), @invalid_attrs)
335 337 end
336 338
337 339 test "update_scribus/2 with valid data updates the scribus" do
added apps/room_sanctum/test/room_sanctum/scribus_owner_migration_test.exs
+89 −0
@@ -0,0 +1,89 @@
1 +defmodule RoomSanctum.ScribusOwnerMigrationTest do
2 + @moduledoc """
3 + The migration that gives every scribus an owner: the rows already there go
4 + to the first user, and the column ends up required and referencing users
5 + the way the other cfg tables' does.
6 + """
7 + use RoomSanctum.DataCase
8 +
9 + import RoomSanctum.OwnershipFixtures
10 +
11 + alias RoomSanctum.Repo
12 +
13 + Code.require_file(
14 + "../../priv/repo/migrations/20261104224340_add_user_id_to_cfg_scribus.exs",
15 + __DIR__
16 + )
17 +
18 + alias RoomSanctum.Repo.Migrations.AddUserIdToCfgScribus
19 +
20 + defp plant(name, user_id) do
21 + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
22 +
23 + {1, [%{id: id}]} =
24 + Repo.insert_all(
25 + "cfg_scribus",
26 + [
27 + %{
28 + name: name,
29 + resolution: "800x480",
30 + user_id: user_id,
31 + inserted_at: now,
32 + updated_at: now
33 + }
34 + ],
35 + returning: [:id]
36 + )
37 +
38 + id
39 + end
40 +
41 + defp owner_of(id) do
42 + %{rows: [[user_id]]} = Repo.query!("SELECT user_id FROM cfg_scribus WHERE id = $1", [id])
43 + user_id
44 + end
45 +
46 + test "rows with no owner go to the first user, and owned rows are left alone" do
47 + # The rows as they were before the migration: the column there, nothing
48 + # in it. Undone with the rest of the test's transaction.
49 + Repo.query!("ALTER TABLE cfg_scribus ALTER COLUMN user_id DROP NOT NULL")
50 +
51 + first = owner()
52 + second = owner()
53 + %{rows: [[lowest]]} = Repo.query!("SELECT min(id) FROM users")
54 + assert lowest <= first.id
55 +
56 + kitchen = plant("Kitchen", nil)
57 + hall = plant("Hall", nil)
58 + theirs = plant("Theirs", second.id)
59 +
60 + Repo.query!(AddUserIdToCfgScribus.backfill_sql())
61 +
62 + assert owner_of(kitchen) == lowest
63 + assert owner_of(hall) == lowest
64 + assert owner_of(theirs) == second.id
65 + end
66 +
67 + test "the column is required and references users as the other cfg tables' does" do
68 + %{rows: [[nullable]]} =
69 + Repo.query!("""
70 + SELECT is_nullable FROM information_schema.columns
71 + WHERE table_name = 'cfg_scribus' AND column_name = 'user_id'
72 + """)
73 +
74 + assert nullable == "NO"
75 +
76 + # 'n' is ON DELETE SET NULL, Ecto's :nilify_all.
77 + %{rows: rows} =
78 + Repo.query!("""
79 + SELECT c.conrelid::regclass::text, c.confdeltype
80 + FROM pg_constraint c
81 + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey)
82 + WHERE c.contype = 'f' AND c.confrelid = 'users'::regclass AND a.attname = 'user_id'
83 + AND c.conrelid::regclass::text IN ('cfg_scribus', 'cfg_pythiae', 'cfg_queries')
84 + """)
85 +
86 + assert {"cfg_scribus", "n"} in Enum.map(rows, &List.to_tuple/1)
87 + assert rows |> Enum.map(&List.last/1) |> Enum.uniq() == ["n"]
88 + end
89 +end
modified apps/room_sanctum/test/room_sanctum_web/live/ownership_test.exs
+101 −0
@@ -497,6 +497,107 @@ defmodule RoomSanctumWeb.OwnershipTest do
497 497 end
498 498 end
499 499
500 + defp scribus_for(user) do
501 + {:ok, scribus} =
502 + Configuration.create_scribus({:user, user.id}, %{
503 + name: "Kitchen",
504 + resolution: "800x480",
505 + vision: vision_for(user, "Board").id,
506 + enabled: true,
507 + color: :untouched,
508 + theme: :inky
509 + })
510 +
511 + scribus
512 + end
513 +
514 + describe "the scribus pages" do
515 + test "list only the user's own scribus", ctx do
516 + mine = scribus_for(ctx.me)
517 + theirs = scribus_for(ctx.them)
518 + {:ok, live, _html} = live(ctx.conn, Routes.scribus_index_path(ctx.conn, :index))
519 +
520 + assert has_element?(live, "#cfg_scribus-#{mine.id}")
521 + refute has_element?(live, "#cfg_scribus-#{theirs.id}")
522 + end
523 +
524 + test "show and edit the user's own scribus", ctx do
525 + mine = scribus_for(ctx.me)
526 +
527 + assert {:ok, _live, _html} = live(ctx.conn, Routes.scribus_show_path(ctx.conn, :show, mine))
528 +
529 + assert {:ok, _live, _html} =
530 + live(ctx.conn, Routes.scribus_index_path(ctx.conn, :edit, mine))
531 + end
532 +
533 + test "will not show another user's scribus", ctx do
534 + theirs = scribus_for(ctx.them)
535 +
536 + assert_raise Ecto.NoResultsError, fn ->
537 + live(ctx.conn, Routes.scribus_show_path(ctx.conn, :show, theirs))
538 + end
539 + end
540 +
541 + test "will not open another user's scribus for editing", ctx do
542 + theirs = scribus_for(ctx.them)
543 +
544 + for path <- [
545 + Routes.scribus_index_path(ctx.conn, :edit, theirs),
546 + Routes.scribus_show_path(ctx.conn, :edit, theirs)
547 + ] do
548 + assert_raise Ecto.NoResultsError, fn -> live(ctx.conn, path) end
549 + end
550 + end
551 +
552 + test "will not delete another user's scribus", ctx do
553 + theirs = scribus_for(ctx.them)
554 + {:ok, live, _html} = live(ctx.conn, Routes.scribus_index_path(ctx.conn, :index))
555 +
556 + forge(live, "delete", %{"id" => to_string(theirs.id)})
557 +
558 + assert Repo.reload(theirs)
559 + end
560 +
561 + test "deletes the user's own scribus", ctx do
562 + mine = scribus_for(ctx.me)
563 + {:ok, live, _html} = live(ctx.conn, Routes.scribus_index_path(ctx.conn, :index))
564 +
565 + render_click(live, "delete", %{"id" => to_string(mine.id)})
566 +
567 + refute Repo.reload(mine)
568 + end
569 +
570 + test "a new scribus belongs to whoever made it, whatever the form says", ctx do
571 + vision = vision_for(ctx.me, "Board")
572 + {:ok, live, _html} = live(ctx.conn, Routes.scribus_index_path(ctx.conn, :new))
573 +
574 + live
575 + |> form("#scribus-form")
576 + |> render_submit(%{
577 + "scribus" => %{
578 + "name" => "Hall",
579 + "resolution" => "800x480",
580 + "vision" => to_string(vision.id),
581 + "enabled" => "true",
582 + "color" => "untouched",
583 + "theme" => "inky",
584 + "user_id" => to_string(ctx.them.id)
585 + }
586 + })
587 +
588 + assert [%{user_id: owner}] = Repo.all(Configuration.Scribus)
589 + assert owner == ctx.me.id
590 + end
591 +
592 + test "a scribus cannot show another user's vision", ctx do
593 + mine = scribus_for(ctx.me)
594 + their_vision = vision_for(ctx.them, "Their board")
595 +
596 + assert {:error, _} = Configuration.update_scribus(mine, %{vision: their_vision.id})
597 + assert Repo.reload!(mine).vision != their_vision.id
598 + end
599 + end
600 +
500 601 describe "the keryx pages" do
501 602 setup ctx do
502 603 {:ok, theirs} =
modified apps/room_sanctum/test/support/fixtures/configuration_fixtures.ex
+13 −7
@@ -87,14 +87,20 @@ defmodule RoomSanctum.ConfigurationFixtures do
87 87 Generate a scribus.
88 88 """
89 89 def scribus_fixture(attrs \\ %{}) do
90 − {:ok, scribus} =
90 + {user_id, attrs} =
91 91 attrs
92 − |> Enum.into(%{
93 − configuration: [],
94 − name: "some name",
95 − resolution: "some resolution"
96 − })
97 − |> RoomSanctum.Configuration.create_scribus()
92 + |> Map.new()
93 + |> Map.pop_lazy(:user_id, fn -> RoomSanctum.OwnershipFixtures.owner().id end)
94 +
95 + {:ok, scribus} =
96 + RoomSanctum.Configuration.create_scribus(
97 + {:user, user_id},
98 + Enum.into(attrs, %{
99 + configuration: [],
100 + name: "some name",
101 + resolution: "some resolution"
102 + })
103 + )
98 104
99 105 scribus
100 106 end

Parents: 503bcb9