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.
Scribus: a scribus has an owner, and the pages only reach the user's own
feea568 · Gabe Morell · 2026-11-04 22:51
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