From eddf4bb36cc33fe254d9704079d0f3fd304fe70d Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Mon, 19 Oct 2026 20:28:13 +0100 Subject: [PATCH 1/2] Gate LIVE_SHOW_JUMP_TO_PAGE on show EXECUTE access and validate page (#1430) Co-Authored-By: Claude Sonnet 5.5 --- server/controllers/ws_controller.py | 48 +++++++++++++++---- server/test/controllers/test_ws_controller.py | 47 ++++++++++++++++++ 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/server/controllers/ws_controller.py b/server/controllers/ws_controller.py index 5782fd56..35f4bdd4 100644 --- a/server/controllers/ws_controller.py +++ b/server/controllers/ws_controller.py @@ -544,7 +544,21 @@ async def _handle_session_op(self, ws_op: str, message: dict) -> None: f"{self.current_user_id}): not the live-show leader" ) elif ws_op == "LIVE_SHOW_JUMP_TO_PAGE": - await self._jump_to_page(session, show, message["DATA"]) + show_session = ( + session.get(ShowSession, show.current_session_id) + if show and show.current_session_id + else None + ) + if show_session and self._user_has_show_execute_access(session, show): + await self._op_jump_to_page( + session, show_session, entry, message["DATA"] + ) + else: + get_logger().debug( + f"Ignored {ws_op} from client " + f"{self.__getattribute__('internal_id')} (user " + f"{self.current_user_id}): no show execute access" + ) else: get_logger().warning( f"Unknown OP {ws_op} received from " @@ -660,18 +674,20 @@ async def _op_reload_clients(self, session, show_session, entry, data) -> None: """Leader op ``RELOAD_CLIENTS``: tell every client to reload.""" await self.application.ws_send_to_all("RELOAD_CLIENT", "NOOP", {}) - async def _jump_to_page(self, session, show: Optional[Show], data) -> None: + async def _op_jump_to_page(self, session, show_session, entry, data) -> None: """Handle ``LIVE_SHOW_JUMP_TO_PAGE``: move the live position and reload all. - Not leader-gated (unchanged from before this PR). + Gated on show EXECUTE access (or admin), not leadership. The page must be a positive integer; anything else is ignored. """ - if not (show and show.current_session_id): + page = data.get("page") if isinstance(data, dict) else None + if isinstance(page, bool) or not isinstance(page, int) or page < 1: + get_logger().warning( + f"Ignored LIVE_SHOW_JUMP_TO_PAGE: invalid page {page!r}" + ) return - show_session = session.get(ShowSession, show.current_session_id) - if show_session: - show_session.latest_line_ref = f"page_{data['page']}_line_0" - session.commit() - await self.application.ws_send_to_all("RELOAD_CLIENT", "NOOP", {}) + show_session.latest_line_ref = f"page_{page}_line_0" + session.commit() + await self.application.ws_send_to_all("RELOAD_CLIENT", "NOOP", {}) async def _claim_vacant_leadership(self, session, show: Optional[Show]) -> None: """Handle ``NEW_CLIENT``: take live-show leadership if nobody holds it. @@ -748,6 +764,20 @@ def _user_has_script_write_access(self, session, show: Optional[Show]) -> bool: show and self.application.rbac.has_role(user, show, Role.WRITE) ) + def _user_has_show_execute_access(self, session, show: Optional[Show]) -> bool: + """Return True if the connected user is an admin or has EXECUTE on *show*. + + :param session: Active SQLAlchemy session. + :param show: The current Show, or None. + :returns: True if the user may run live-show controls such as jump to page. + """ + user = session.get(User, self.current_user_id) if self.current_user_id else None + if not user: + return False + return user.is_admin or bool( + show and self.application.rbac.has_role(user, show, Role.EXECUTE) + ) + async def _reject_script_room_op(self, action: str, key: str, reason: str) -> None: """Send a script-room-op failure message. diff --git a/server/test/controllers/test_ws_controller.py b/server/test/controllers/test_ws_controller.py index 7f4780cb..d33a4667 100644 --- a/server/test/controllers/test_ws_controller.py +++ b/server/test/controllers/test_ws_controller.py @@ -26,6 +26,7 @@ from models.session import Interval, Session, ShowSession from models.show import Act, Show from models.user import User +from rbac.role import Role from services.password_service import PasswordService from test.conftest import DigiScriptTestCase from test.helpers.script_fixtures import create_show_script_revision @@ -2746,6 +2747,52 @@ async def test_adopter_of_uuid_later_elected_leader_cannot_drive(self): ws_f.close() ws_x.close() + @gen_test + async def test_jump_to_page_requires_show_execute_and_valid_page(self): + """Jumping needs show EXECUTE (or admin), not leadership. Viewers, + unauthenticated sockets and invalid pages are refused without moving the + live position or reloading anyone. + """ + ws_l, uuid_l = await self._connect_and_auth(self.admin_id) + ws_a2, _ = await self._connect_and_auth(self.admin2_id) # non-leader admin + viewer, _ = await self._connect_and_auth(self.viewer_id) + self._start_live_session(uuid_l, self.admin_id) + ws_x, _ = await self._refresh_only(uuid_l) # unauthenticated adopter + await self._drain(viewer) + + def jump(page): + return json.dumps({"OP": "LIVE_SHOW_JUMP_TO_PAGE", "DATA": {"page": page}}) + + await ws_x.write_message(jump(9)) + await viewer.write_message(jump(9)) + for bad in (0, -1, "abc", 1.5, True, None): + await ws_a2.write_message(jump(bad)) + await asyncio.sleep(0.1) + + msgs = await self._drain(viewer) + self.assertNotIn("RELOAD_CLIENT", [m.get("OP") for m in msgs]) + self.assertNotEqual("page_9_line_0", self._latest_line_ref()) + + # A non-leader admin may jump. + await ws_a2.write_message(jump(4)) + await self._read_until(viewer, op="RELOAD_CLIENT") + self.assertEqual("page_4_line_0", self._latest_line_ref()) + + # A non-admin granted EXECUTE on the show may jump too. + with self._app.get_db().sessionmaker() as session: + self._app.rbac.give_role( + session.get(User, self.viewer_id), + session.get(Show, self.show_id), + Role.EXECUTE, + ) + session.commit() + await viewer.write_message(jump(7)) + await self._read_until(viewer, op="RELOAD_CLIENT") + self.assertEqual("page_7_line_0", self._latest_line_ref()) + + for ws in (ws_l, ws_a2, viewer, ws_x): + ws.close() + @gen_test async def test_other_user_adopter_of_uuid_later_made_editor_cannot_write(self): """A socket authenticated as a *different* user that adopted a tab's uuid From 6e167b25234ac9c2507fef13428b7969434cbb55 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Mon, 19 Oct 2026 20:34:04 +0100 Subject: [PATCH 2/2] Address review: send numeric page from legacy client, docs, no-session test Co-Authored-By: Claude Sonnet 5.5 --- client/src/App.vue | 2 +- docs/pages/live_show.md | 4 ++++ server/controllers/ws_controller.py | 3 ++- server/test/controllers/test_ws_controller.py | 14 ++++++++++++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/client/src/App.vue b/client/src/App.vue index ba6df192..d2a67408 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -370,7 +370,7 @@ export default defineComponent({ (this as any).$socket.sendObj({ OP: 'LIVE_SHOW_JUMP_TO_PAGE', DATA: { - page: this.pageInputFormState.pageNo, + page: Number(this.pageInputFormState.pageNo), }, }); }, diff --git a/docs/pages/live_show.md b/docs/pages/live_show.md index cd40725d..45f378e2 100644 --- a/docs/pages/live_show.md +++ b/docs/pages/live_show.md @@ -59,6 +59,10 @@ A "Current Cues" footer bar at the bottom of the script area shows the last cue The footer is shown by default, but it can be turned off independently by each user from the **Settings** tab of the User Settings page (**Show current cue footer in Live view**). The preference is saved to your account and persists across sessions and devices. +### Jumping to a Page + +Users with the show **Execute** role (and admins) can move the live show to a specific page: click **Live Config** in the navigation bar, select **Jump To Page** and enter a page number. Every connected client reloads at the new page. You do not need to be the leader to do this, but the page must be a whole number of 1 or more, and requests from users without the Execute role are ignored. + ### Act Intervals If your show has intervals configured between acts, an interval screen will automatically appear between the acts during the live show. The interval will only display if there is script content in both acts surrounding the interval. diff --git a/server/controllers/ws_controller.py b/server/controllers/ws_controller.py index 35f4bdd4..5953c27d 100644 --- a/server/controllers/ws_controller.py +++ b/server/controllers/ws_controller.py @@ -677,7 +677,8 @@ async def _op_reload_clients(self, session, show_session, entry, data) -> None: async def _op_jump_to_page(self, session, show_session, entry, data) -> None: """Handle ``LIVE_SHOW_JUMP_TO_PAGE``: move the live position and reload all. - Gated on show EXECUTE access (or admin), not leadership. The page must be a positive integer; anything else is ignored. + Gated on show EXECUTE access (or admin), not leadership. The page must be + a positive integer; anything else is ignored. """ page = data.get("page") if isinstance(data, dict) else None if isinstance(page, bool) or not isinstance(page, int) or page < 1: diff --git a/server/test/controllers/test_ws_controller.py b/server/test/controllers/test_ws_controller.py index d33a4667..08525d2f 100644 --- a/server/test/controllers/test_ws_controller.py +++ b/server/test/controllers/test_ws_controller.py @@ -2793,6 +2793,20 @@ def jump(page): for ws in (ws_l, ws_a2, viewer, ws_x): ws.close() + @gen_test + async def test_jump_to_page_without_live_session_is_ignored(self): + ws_a, _ = await self._connect_and_auth(self.admin_id) + viewer, _ = await self._connect_and_auth(self.viewer_id) + await self._drain(viewer) + await ws_a.write_message( + json.dumps({"OP": "LIVE_SHOW_JUMP_TO_PAGE", "DATA": {"page": 3}}) + ) + await asyncio.sleep(0.1) + msgs = await self._drain(viewer) + self.assertNotIn("RELOAD_CLIENT", [m.get("OP") for m in msgs]) + ws_a.close() + viewer.close() + @gen_test async def test_other_user_adopter_of_uuid_later_made_editor_cannot_write(self): """A socket authenticated as a *different* user that adopted a tab's uuid