Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
});
},
Expand Down
4 changes: 4 additions & 0 deletions docs/pages/live_show.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 40 additions & 9 deletions server/controllers/ws_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@
if success and ws_op == "REFRESH_TOKEN":
await self.write_message({"OP": "WS_TOKEN_REFRESH_SUCCESS", "DATA": {}})

async def _handle_session_op(self, ws_op: str, message: dict) -> None:

Check failure on line 515 in server/controllers/ws_controller.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 19 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=dreamteamprod_DigiScript&issues=AaFVo6lXpWWXU25wttdF&open=AaFVo6lXpWWXU25wttdF&pullRequest=1454
"""Handle client-session and live-show operations.

:param ws_op: The operation code.
Expand Down Expand Up @@ -544,7 +544,21 @@
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 "
Expand Down Expand Up @@ -660,18 +674,21 @@
"""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:
Comment thread
Tim020 marked this conversation as resolved.
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.
Expand Down Expand Up @@ -748,6 +765,20 @@
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.

Expand Down
61 changes: 61 additions & 0 deletions server/test/controllers/test_ws_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading