diff --git a/backend/app/routes/media.py b/backend/app/routes/media.py index 245bffb..7c7b8cb 100644 --- a/backend/app/routes/media.py +++ b/backend/app/routes/media.py @@ -54,6 +54,26 @@ def resolve_media_scope( return session.external_team_id, session.external_player_id +def resolve_media_read_scope( + session: UserSession, + *, + requested_team_id: str | None = None, + requested_player_id: str | None = None, +) -> tuple[str, str | None]: + if session.is_admin: + team_id = requested_team_id or session.external_team_id + player_id = requested_player_id if requested_player_id is not None else session.external_player_id + if not team_id: + raise HTTPException(status_code=422, detail="Select a team before viewing media") + return team_id, player_id + + if not session.external_team_id: + raise HTTPException(status_code=422, detail="Select a team before viewing media") + if requested_team_id and requested_team_id != session.external_team_id: + raise HTTPException(status_code=403, detail="This team does not match your selected session") + return session.external_team_id, requested_player_id + + def clip_to_response(clip: AudioClip) -> AudioClipResponse: normalized_url = f"/media/files/{clip.normalized_path}" if clip.normalized_path else None waveform = storage.load_or_generate_waveform(clip.asset.storage_path) @@ -452,7 +472,7 @@ def list_clips( session: UserSession = Depends(require_session), db: Session = Depends(get_db), ) -> list[AudioClipResponse]: - external_team_id, owner_external_player_id = resolve_media_scope( + external_team_id, owner_external_player_id = resolve_media_read_scope( session, requested_team_id=external_team_id, requested_player_id=owner_external_player_id, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 26143a0..e245b34 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -690,6 +690,58 @@ def test_hidden_clips_are_removed_from_gameday_views_but_remain_pinnable() -> No assert [item["clip_id"] for item in pins.json()] == [clip.id] +def test_same_team_player_can_read_another_players_clips() -> None: + db = SessionLocal() + owner_session = UserSession( + session_token="owner-library-session", + provider="teamsnap", + external_team_id="team-share", + external_player_id="player-owner", + ) + viewer_session = UserSession( + session_token="viewer-library-session", + provider="teamsnap", + external_team_id="team-share", + external_player_id="player-viewer", + ) + db.add_all([owner_session, viewer_session]) + db.flush() + + asset = AudioAsset( + external_team_id="team-share", + owner_external_player_id="player-owner", + uploaded_by_session_id=owner_session.id, + title="Shared song", + original_filename="shared-song.mp3", + mime_type="audio/mpeg", + size_bytes=123, + storage_path="uploads/shared-song.mp3", + ) + db.add(asset) + db.flush() + clip = AudioClip( + asset_id=asset.id, + label="Shared clip", + start_ms=0, + end_ms=10000, + normalization_status="ready", + normalized_path="normalized/shared-clip.mp3", + ) + db.add(clip) + db.commit() + db.close() + + client.cookies.set(settings.session_cookie_name, "viewer-library-session") + response = client.get( + "/media/clips", + params={"external_team_id": "team-share", "owner_external_player_id": "player-owner"}, + ) + + assert response.status_code == 200 + assert [item["id"] for item in response.json()] == [clip.id] + assert response.json()[0]["owner_external_player_id"] == "player-owner" + + def test_clip_updates_can_use_player_scoped_authorization() -> None: uploader_session = UserSession(session_token="uploader-session", provider="teamsnap") editor_session = UserSession(