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 5782fd56..5953c27d 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,21 @@ 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 +765,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..08525d2f 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,66 @@ 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_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