Harden media and gameday access control

This commit is contained in:
Codex
2026-04-24 08:09:31 -05:00
parent cc241d4ae7
commit 22d4f8c017
4 changed files with 333 additions and 70 deletions

View File

@@ -29,6 +29,31 @@ router = APIRouter(prefix="/media", tags=["media"])
DEFAULT_CLIP_LENGTH_MS = 30_000
def resolve_media_scope(
session: UserSession,
*,
requested_team_id: str | None = None,
requested_player_id: str | None = None,
require_player: bool = False,
) -> 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 managing media")
if require_player and not player_id:
raise HTTPException(status_code=422, detail="Select a player before managing media")
return team_id, player_id
if not session.external_team_id or not session.external_player_id:
raise HTTPException(status_code=422, detail="Select a team and player before managing 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")
if requested_player_id and requested_player_id != session.external_player_id:
raise HTTPException(status_code=403, detail="This player does not match your selected session")
return session.external_team_id, session.external_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)
@@ -61,10 +86,15 @@ def prepare_conditional_response(
return etag, is_matching_etag(request, etag)
def can_manage_asset(session: UserSession, asset: AudioAsset, owner_external_player_id: str | None = None) -> bool:
if session.is_admin or asset.uploaded_by_session_id == session.id:
def can_manage_asset(session: UserSession, asset: AudioAsset) -> bool:
if session.is_admin:
return True
return owner_external_player_id is not None and asset.owner_external_player_id == owner_external_player_id
return (
session.external_team_id is not None
and session.external_player_id is not None
and asset.external_team_id == session.external_team_id
and asset.owner_external_player_id == session.external_player_id
)
def next_clip_sort_order(db: Session, *, external_team_id: str, owner_external_player_id: str) -> int:
@@ -91,42 +121,52 @@ def create_asset_with_default_clip(
size_bytes: int,
storage_path: str,
) -> AudioAssetResponse:
asset = AudioAsset(
external_team_id=external_team_id,
owner_external_player_id=owner_external_player_id,
uploaded_by_session_id=session.id,
title=title,
original_filename=original_filename,
mime_type=mime_type,
size_bytes=size_bytes,
storage_path=storage_path,
)
db.add(asset)
db.flush()
clip = AudioClip(
asset_id=asset.id,
label=asset.title,
start_ms=0,
end_ms=DEFAULT_CLIP_LENGTH_MS,
sort_order=next_clip_sort_order(
db,
normalized_path: str | None = None
try:
asset = AudioAsset(
external_team_id=external_team_id,
owner_external_player_id=owner_external_player_id,
),
normalization_status="processing",
)
db.add(clip)
db.flush()
uploaded_by_session_id=session.id,
title=title,
original_filename=original_filename,
mime_type=mime_type,
size_bytes=size_bytes,
storage_path=storage_path,
)
db.add(asset)
db.flush()
normalized_name = f"clip-{clip.id}-{secrets.token_hex(6)}{Path(asset.storage_path).suffix or '.bin'}"
clip.normalized_path = storage.normalize_clip(asset.storage_path, normalized_name)
clip.normalization_status = "ready"
storage.generate_waveform(asset.storage_path)
clip = AudioClip(
asset_id=asset.id,
label=asset.title,
start_ms=0,
end_ms=DEFAULT_CLIP_LENGTH_MS,
sort_order=next_clip_sort_order(
db,
external_team_id=external_team_id,
owner_external_player_id=owner_external_player_id,
),
normalization_status="processing",
)
db.add(clip)
db.flush()
db.commit()
db.refresh(asset)
return AudioAssetResponse.model_validate(asset, from_attributes=True)
normalized_name = f"clip-{clip.id}-{secrets.token_hex(6)}{Path(asset.storage_path).suffix or '.bin'}"
normalized_path = str(Path("normalized") / normalized_name)
normalized_path = storage.normalize_clip(asset.storage_path, normalized_name)
clip.normalized_path = normalized_path
clip.normalization_status = "ready"
storage.generate_waveform(asset.storage_path)
db.commit()
db.refresh(asset)
return AudioAssetResponse.model_validate(asset, from_attributes=True)
except Exception:
db.rollback()
if normalized_path:
storage.delete_relative_path(normalized_path)
storage.delete_relative_path(storage_path)
raise
def download_media_to_storage(url: str) -> tuple[str, int, str, str]:
@@ -180,6 +220,12 @@ async def upload_audio(
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> AudioAssetResponse:
external_team_id, owner_external_player_id = resolve_media_scope(
session,
requested_team_id=external_team_id,
requested_player_id=owner_external_player_id,
require_player=True,
)
extension = Path(file.filename or "upload.bin").suffix or ".bin"
storage_name = f"{secrets.token_hex(16)}{extension}"
relative_path, size = storage.save_upload(file, storage_name)
@@ -202,6 +248,12 @@ def import_audio(
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> AudioAssetResponse:
external_team_id, owner_external_player_id = resolve_media_scope(
session,
requested_team_id=payload.external_team_id,
requested_player_id=payload.owner_external_player_id,
require_player=True,
)
relative_path, size_bytes, original_filename, source_title = download_media_to_storage(payload.url)
title = payload.title.strip() if payload.title else ""
if not title:
@@ -226,9 +278,14 @@ def list_assets(
response: Response,
external_team_id: str,
owner_external_player_id: str | None = None,
_: UserSession = Depends(require_session),
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> list[AudioAssetResponse]:
external_team_id, owner_external_player_id = resolve_media_scope(
session,
requested_team_id=external_team_id,
requested_player_id=owner_external_player_id,
)
query = select(AudioAsset).where(AudioAsset.external_team_id == external_team_id)
if owner_external_player_id:
query = query.where(AudioAsset.owner_external_player_id == owner_external_player_id)
@@ -251,7 +308,7 @@ def delete_asset(
asset = db.get(AudioAsset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
if not can_manage_asset(session, asset, owner_external_player_id):
if not can_manage_asset(session, asset):
raise HTTPException(status_code=403, detail="You can only delete your own uploads")
clips = db.scalars(select(AudioClip).where(AudioClip.asset_id == asset.id)).all()
@@ -279,7 +336,7 @@ def update_asset(
asset = db.get(AudioAsset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
if not can_manage_asset(session, asset, owner_external_player_id):
if not can_manage_asset(session, asset):
raise HTTPException(status_code=403, detail="You can only update your own uploads")
title = payload.title.strip()
@@ -295,15 +352,21 @@ def update_asset(
@router.post("/clips", response_model=AudioClipResponse)
def create_clip(
payload: AudioClipCreate,
_: UserSession = Depends(require_session),
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> AudioClipResponse:
external_team_id, owner_external_player_id = resolve_media_scope(
session,
requested_team_id=payload.external_team_id,
requested_player_id=payload.owner_external_player_id,
require_player=True,
)
asset = db.get(AudioAsset, payload.asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
if asset.external_team_id != payload.external_team_id:
if asset.external_team_id != external_team_id:
raise HTTPException(status_code=422, detail="Clip does not belong to this team")
if asset.owner_external_player_id != payload.owner_external_player_id:
if asset.owner_external_player_id != owner_external_player_id:
raise HTTPException(status_code=403, detail="You can only create clips for that player")
if payload.end_ms <= payload.start_ms:
raise HTTPException(status_code=422, detail="Clip end must be greater than start")
@@ -315,8 +378,8 @@ def create_clip(
end_ms=payload.end_ms,
sort_order=next_clip_sort_order(
db,
external_team_id=payload.external_team_id,
owner_external_player_id=payload.owner_external_player_id,
external_team_id=external_team_id,
owner_external_player_id=owner_external_player_id,
),
normalization_status="processing",
)
@@ -342,7 +405,7 @@ def update_clip(
clip = db.get(AudioClip, clip_id)
if clip is None:
raise HTTPException(status_code=404, detail="Clip not found")
if not can_manage_asset(session, clip.asset, owner_external_player_id):
if not can_manage_asset(session, clip.asset):
raise HTTPException(status_code=403, detail="You can only update clips from your own uploads")
if payload.end_ms <= payload.start_ms:
raise HTTPException(status_code=422, detail="Clip end must be greater than start")
@@ -369,7 +432,7 @@ def delete_clip(
clip = db.get(AudioClip, clip_id)
if clip is None:
raise HTTPException(status_code=404, detail="Clip not found")
if not can_manage_asset(session, clip.asset, owner_external_player_id):
if not can_manage_asset(session, clip.asset):
raise HTTPException(status_code=403, detail="You can only delete clips from your own uploads")
db.execute(delete(GameAssignment).where(GameAssignment.clip_id == clip.id))
@@ -386,9 +449,14 @@ def list_clips(
external_team_id: str,
owner_external_player_id: str | None = None,
include_hidden: bool = False,
_: UserSession = Depends(require_session),
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> list[AudioClipResponse]:
external_team_id, owner_external_player_id = resolve_media_scope(
session,
requested_team_id=external_team_id,
requested_player_id=owner_external_player_id,
)
query = (
select(AudioClip)
.join(AudioClip.asset)
@@ -414,15 +482,19 @@ def reorder_clips(
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> None:
if not session.is_admin and session.external_team_id != payload.external_team_id:
raise HTTPException(status_code=403, detail="You can only reorder clips for your selected team")
external_team_id, owner_external_player_id = resolve_media_scope(
session,
requested_team_id=payload.external_team_id,
requested_player_id=payload.owner_external_player_id,
require_player=True,
)
clips = db.scalars(
select(AudioClip)
.join(AudioClip.asset)
.where(
AudioAsset.external_team_id == payload.external_team_id,
AudioAsset.owner_external_player_id == payload.owner_external_player_id,
AudioAsset.external_team_id == external_team_id,
AudioAsset.owner_external_player_id == owner_external_player_id,
)
).all()
clips_by_id = {clip.id: clip for clip in clips}