fix(logos): support logo downloads for custom soccer leagues (#262)

* fix(logos): support logo downloads for custom soccer leagues

LogoDownloader.fetch_teams_data() and fetch_single_team() only had
hardcoded API endpoints for predefined soccer leagues. Custom leagues
(e.g., por.1, mex.1) would silently fail when the ESPN game data
didn't include a direct logo URL. Now dynamically constructs the ESPN
teams API URL for any soccer_* league not in the predefined map.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(logos): address PR review — directory, bulk download, and dedup

- get_logo_directory: custom soccer leagues now resolve to shared
  assets/sports/soccer_logos/ instead of creating per-league dirs
- download_all_missing_logos: use _resolve_api_url so custom soccer
  leagues are no longer silently skipped
- Extract _resolve_api_url helper to deduplicate dynamic URL
  construction between fetch_teams_data and fetch_single_team

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web): preserve array item properties in _set_nested_value

When saving config with array-of-objects fields (e.g., custom_leagues),
_set_nested_value would replace existing list objects with dicts when
navigating dot-notation paths like "custom_leagues.0.name". This
destroyed any properties on array items that weren't submitted in the
form (e.g., display_modes, game_limits, filtering).

Now properly indexes into existing lists when encountering numeric path
segments, preserving all non-submitted properties on array items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): address PR #262 code review security findings

- logo_downloader: validate league name against allowlist before
  constructing filesystem paths in get_logo_directory to prevent
  path traversal (reject anything not matching ^[a-z0-9_-]+$)
- logo_downloader: validate league_code against allowlist before
  interpolating into ESPN API URL in _resolve_api_url to prevent
  URL path injection; return None on invalid input
- api_v3: add MAX_LIST_EXPANSION=1000 cap to _set_nested_value list
  expansion; raise ValueError for out-of-bounds indices; replace
  silent break fallback with TypeError for unexpected traversal types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-02-24 19:18:29 -05:00
committed by GitHub
parent 38a9c1ed1b
commit 275fed402e
2 changed files with 25 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ with special support for FCS teams and other NCAA divisions.
"""
import os
import re
import time
import logging
import requests
@@ -146,6 +147,9 @@ class LogoDownloader:
return variations
# Allowlist for league names used in filesystem paths: alphanumerics, underscores, dashes only
_SAFE_LEAGUE_RE = re.compile(r'^[a-z0-9_-]+$')
def get_logo_directory(self, league: str) -> str:
"""Get the logo directory for a given league."""
directory = LogoDownloader.LOGO_DIRECTORIES.get(league)
@@ -154,6 +158,10 @@ class LogoDownloader:
if league.startswith('soccer_'):
directory = 'assets/sports/soccer_logos'
else:
# Validate league before using it in a filesystem path
if not self._SAFE_LEAGUE_RE.match(league):
logger.warning(f"Rejecting unsafe league name for directory construction: {league!r}")
raise ValueError(f"Unsafe league name: {league!r}")
directory = f'assets/sports/{league}_logos'
path = Path(directory)
if not path.is_absolute():
@@ -244,11 +252,17 @@ class LogoDownloader:
logger.error(f"Unexpected error downloading logo for {team_abbreviation}: {e}")
return False
# Allowlist for the league_code segment interpolated into ESPN API URLs
_SAFE_LEAGUE_CODE_RE = re.compile(r'^[a-z0-9_-]+$')
def _resolve_api_url(self, league: str) -> Optional[str]:
"""Resolve the ESPN API teams URL for a league, with dynamic fallback for custom soccer leagues."""
api_url = self.API_ENDPOINTS.get(league)
if not api_url and league.startswith('soccer_'):
league_code = league[len('soccer_'):]
if not self._SAFE_LEAGUE_CODE_RE.match(league_code):
logger.warning(f"Rejecting unsafe league_code for ESPN URL construction: {league_code!r}")
return None
api_url = f'https://site.api.espn.com/apis/site/v2/sports/soccer/{league_code}/teams'
logger.info(f"Using dynamic ESPN endpoint for custom soccer league: {league}")
return api_url