fix(logos): one hardened logo download path; shared, real HTTP headers (#612)

* fix(logos): harden the plugin logo download and share core HTTP headers

download_missing_logo / LogoDownloader.download_logo, the path the
scoreboard plugins use, read response.content with no size cap and wrote
straight to the final path, so a failed or corrupt download could be left
in place and cached as the logo. It now goes through fetch_logo: streamed
with a 10 MB cap, image/* only, decoded by Pillow, converted to RGBA once,
and moved into place atomically. A failure leaves no partial or temp file
and keeps any logo already on disk. LogoHelper._download_logo delegates to
the same code. Public signatures and return values are unchanged; saved
files are pixel-identical to before (RGBA, palette+tRNS, L+tRNS, LA, JPEG).

download_missing_logo reuses one downloader per thread instead of a new
Session per logo. Per thread rather than behind a lock: Session is not
documented thread-safe, and a lock would serialise every plugin's
downloads behind the slowest one.

Placeholders are written atomically, without the test_write.tmp probe.

The logo downloader and background data service now send the real
ChuckBuilds User-Agent from src.common.api_helper (USER_AGENT,
DEFAULT_HTTP_HEADERS) instead of a yourusername/contact@example.com
placeholder, and no longer hand-set Accept-Encoding: br (brotli is not
installed).

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

* fix(http): drop APIHelper's hand-set brotli encoding; LogoHelper sends the real UA

APIHelper advertised `br` though brotli isn't installed, so a server that
honoured it would send a body requests can't decode. LogoHelper sent a bare
`LEDMatrix-Common/1.0`, the kind of User-Agent ESPN has been rejecting.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-23 12:58:11 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f813ea2117
commit 3f8edf5113
8 changed files with 622 additions and 153 deletions
+80
View File
@@ -0,0 +1,80 @@
"""One set of HTTP headers for core's ESPN/data requests.
The logo downloader and the background data service each carried their own
header dict with a placeholder User-Agent (``yourusername/LEDMatrix;
contact@example.com``) -- the kind of nonconforming token ESPN began 403ing
around 2026-08-04 -- and a hand-set ``Accept-Encoding: ... br`` although brotli
is not installed, so a ``br`` body could not have been decoded.
"""
from unittest.mock import MagicMock
import pytest
from src.common.api_helper import DEFAULT_HTTP_HEADERS, USER_AGENT, APIHelper
def _lower_keys(headers):
return {k.lower(): v for k, v in headers.items()}
class TestSharedHeaders:
def test_user_agent_names_the_project(self):
assert USER_AGENT == 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)'
assert DEFAULT_HTTP_HEADERS['User-Agent'] == USER_AGENT
def test_no_hand_set_accept_encoding(self):
assert 'accept-encoding' not in _lower_keys(DEFAULT_HTTP_HEADERS)
def test_is_read_only(self):
with pytest.raises(TypeError):
DEFAULT_HTTP_HEADERS['User-Agent'] = 'x' # type: ignore[index]
def test_api_helper_sends_the_same_user_agent(self):
assert APIHelper().session.headers['User-Agent'] == USER_AGENT
def test_api_helper_does_not_hand_set_brotli(self):
assert 'br' not in APIHelper().session.headers.get('Accept-Encoding', '')
def test_logo_helper_sends_the_same_user_agent(self):
from src.common.logo_helper import LogoHelper
assert LogoHelper(display_width=64, display_height=32).session.headers['User-Agent'] == USER_AGENT
class TestLogoDownloaderHeaders:
def test_uses_the_shared_headers(self):
from src.logo_downloader import LogoDownloader
headers = LogoDownloader().headers
assert headers['User-Agent'] == USER_AGENT
assert 'accept-encoding' not in _lower_keys(headers)
assert 'yourusername' not in str(headers)
def test_instance_headers_are_a_private_copy(self):
from src.logo_downloader import LogoDownloader
downloader = LogoDownloader()
downloader.headers['X-Test'] = '1'
assert 'X-Test' not in DEFAULT_HTTP_HEADERS
assert 'X-Test' not in LogoDownloader().headers
def test_logo_request_sends_the_user_agent_and_asks_for_an_image(self, tmp_path):
from src.logo_downloader import LogoDownloader
downloader = LogoDownloader()
downloader.session.get = MagicMock(side_effect=RuntimeError("stop"))
downloader.download_logo("http://x/a.png", tmp_path / "A.png", "A")
sent = _lower_keys(downloader.session.get.call_args.kwargs['headers'])
assert sent['user-agent'] == USER_AGENT
assert sent['accept'].startswith('image/')
assert 'accept-encoding' not in sent
class TestBackgroundDataServiceHeaders:
def test_uses_the_shared_headers(self):
from src.background_data_service import BackgroundDataService
service = BackgroundDataService(MagicMock(), max_workers=1)
try:
headers = service.default_headers
assert headers['User-Agent'] == USER_AGENT
assert 'accept-encoding' not in _lower_keys(headers)
assert 'yourusername' not in str(headers)
finally:
service.shutdown(wait=False)
+267
View File
@@ -7,16 +7,20 @@ ensure_logo_directory, and the download_missing_logo function path
(with HTTP mocked).
"""
import io
import os
import threading
import time
import pytest
import requests
from pathlib import Path
from unittest.mock import patch, Mock, MagicMock
from PIL import Image
from PIL.PngImagePlugin import PngInfo
import src.logo_downloader as logo_downloader_module
from src.logo_downloader import (
PLACEHOLDER_BG,
PLACEHOLDER_MARKER,
@@ -351,3 +355,266 @@ class TestRefreshPlaceholderTimestamp:
def test_missing_file_is_not_an_error(self, tmp_path):
assert refresh_placeholder_timestamp(tmp_path / "nope.png") is False
# ---------------------------------------------------------------------------
# download_logo: the download the scoreboard plugins actually use
#
# It used to read response.content with no size cap, write straight to the
# final path (so a failed or corrupt download could be left there and then be
# cached as the logo), and build a fresh Session for every logo. It now goes
# through fetch_logo, the hardened download LogoHelper also uses.
# ---------------------------------------------------------------------------
def _png(img: Image.Image) -> bytes:
buf = io.BytesIO()
img.save(buf, "PNG")
return buf.getvalue()
def _stream(body: bytes, content_type: str = "image/png", chunk: int = 1024,
die_after: int | None = None):
"""A streamed requests.Response stand-in.
``die_after`` makes the transfer fail after that many bytes, the way a
reset connection does mid-body.
"""
response = MagicMock()
response.__enter__.return_value = response
response.__exit__.return_value = False
response.raise_for_status = MagicMock()
response.headers = {"content-type": content_type}
def _iter_content(*_args, **_kwargs):
sent = 0
for i in range(0, len(body), chunk):
if die_after is not None and sent >= die_after:
raise requests.exceptions.ChunkedEncodingError("connection reset")
piece = body[i:i + chunk]
sent += len(piece)
yield piece
response.iter_content = _iter_content
return response
@pytest.fixture
def fresh_thread_state(monkeypatch):
"""Each test gets its own per-thread downloader cache."""
monkeypatch.setattr(logo_downloader_module, "_thread_state", threading.local())
@pytest.fixture
def downloader():
return LogoDownloader()
@pytest.fixture
def old_logo(tmp_path):
"""A logo already on disk that a failed re-download must not damage."""
path = tmp_path / "PHI.png"
Image.new("RGBA", (30, 30), (1, 2, 3, 255)).save(path)
return path, path.read_bytes()
def _leftovers(directory: Path, keep: str | None = None):
return sorted(p.name for p in directory.iterdir() if p.name != keep)
class TestDownloadLogoHardening:
def test_valid_logo_is_saved_as_rgba_png(self, downloader, tmp_path):
target = tmp_path / "PHI.png"
downloader.session.get = MagicMock(
return_value=_stream(_png(Image.new("RGB", (20, 10), (9, 8, 7)))))
assert downloader.download_logo("http://x/phi.png", target, "PHI") is True
with Image.open(target) as img:
assert img.format == "PNG" and img.mode == "RGBA"
assert img.getpixel((0, 0)) == (9, 8, 7, 255)
assert _leftovers(tmp_path, keep="PHI.png") == []
# Streamed, so the size cap applies before the body is buffered.
assert downloader.session.get.call_args.kwargs["stream"] is True
def test_oversized_response_is_rejected_and_leaves_no_file(
self, downloader, tmp_path, monkeypatch):
monkeypatch.setattr(logo_downloader_module, "MAX_LOGO_BYTES", 4096)
target = tmp_path / "BIG.png"
body = _png(Image.new("RGB", (8, 8))) + b"\x00" * 8192
downloader.session.get = MagicMock(return_value=_stream(body))
assert downloader.download_logo("http://x/big.png", target, "BIG") is False
assert list(tmp_path.iterdir()) == []
def test_oversized_response_does_not_replace_an_existing_logo(
self, downloader, old_logo, monkeypatch):
path, before = old_logo
monkeypatch.setattr(logo_downloader_module, "MAX_LOGO_BYTES", 4096)
downloader.session.get = MagicMock(
return_value=_stream(b"\x89PNG" + b"\x00" * 8192))
assert downloader.download_logo("http://x/big.png", path, "PHI") is False
assert path.read_bytes() == before
assert _leftovers(path.parent, keep=path.name) == []
def test_mid_download_failure_leaves_no_partial_file(self, downloader, tmp_path):
target = tmp_path / "CUT.png"
body = _png(Image.new("RGB", (200, 200), (5, 5, 5))) + b"\x00" * 4096
downloader.session.get = MagicMock(
return_value=_stream(body, chunk=256, die_after=512))
assert downloader.download_logo("http://x/cut.png", target, "CUT") is False
assert list(tmp_path.iterdir()) == []
def test_mid_download_failure_keeps_the_previous_logo(self, downloader, old_logo):
# The old code opened the final path for writing before the body
# arrived, so a dropped connection truncated the logo it was replacing.
path, before = old_logo
body = _png(Image.new("RGB", (200, 200), (5, 5, 5))) + b"\x00" * 4096
downloader.session.get = MagicMock(
return_value=_stream(body, chunk=256, die_after=512))
assert downloader.download_logo("http://x/cut.png", path, "PHI") is False
assert path.read_bytes() == before
assert _leftovers(path.parent, keep=path.name) == []
def test_non_image_content_type_is_rejected(self, downloader, old_logo):
# Rejected on the label alone, before the body is trusted: these bytes
# would decode, so only the content-type check stops them.
path, before = old_logo
body = _png(Image.new("RGB", (8, 8), (250, 0, 0)))
downloader.session.get = MagicMock(
return_value=_stream(body, content_type="text/html"))
assert downloader.download_logo("http://x/404", path, "PHI") is False
assert path.read_bytes() == before
assert _leftovers(path.parent, keep=path.name) == []
def test_bytes_that_do_not_decode_are_rejected(self, downloader, old_logo):
# A server that labels an error page image/png must not get it cached.
path, before = old_logo
downloader.session.get = MagicMock(
return_value=_stream(b"<html>oops</html>", content_type="image/png"))
assert downloader.download_logo("http://x/lie.png", path, "PHI") is False
assert path.read_bytes() == before
assert _leftovers(path.parent, keep=path.name) == []
def test_http_error_keeps_the_previous_logo(self, downloader, old_logo):
path, before = old_logo
response = _stream(b"")
response.raise_for_status.side_effect = requests.exceptions.HTTPError("503")
downloader.session.get = MagicMock(return_value=response)
assert downloader.download_logo("http://x/phi.png", path, "PHI") is False
assert path.read_bytes() == before
assert _leftovers(path.parent, keep=path.name) == []
def test_unwritable_directory_returns_false(self, downloader, tmp_path):
downloader.session.get = MagicMock()
with patch("src.logo_downloader.tempfile.mkstemp",
side_effect=PermissionError("read-only")):
assert downloader.download_logo(
"http://x/phi.png", tmp_path / "PHI.png", "PHI") is False
downloader.session.get.assert_not_called()
class TestDownloadLogoTransparency:
"""Plugins paste logos with the image as its own mask; alpha must survive."""
def _download(self, downloader, tmp_path, body, content_type="image/png"):
target = tmp_path / "LOGO.png"
downloader.session.get = MagicMock(return_value=_stream(body, content_type))
assert downloader.download_logo("http://x/logo", target, "LOGO") is True
with Image.open(target) as img:
img.load()
return img.copy(), img.format
def test_rgba_alpha_is_kept_exactly(self, downloader, tmp_path):
src = Image.new("RGBA", (16, 16), (0, 0, 0, 0))
for x in range(16):
src.putpixel((x, 3), (200, 100, 50, x * 16))
out, _ = self._download(downloader, tmp_path, _png(src))
assert out.mode == "RGBA"
assert list(out.getdata()) == list(src.getdata())
def test_palette_transparency_becomes_alpha(self, downloader, tmp_path):
src = Image.new("P", (8, 8), 0)
src.putpalette([0, 0, 0, 255, 0, 0] + [0] * (254 * 3))
src.putpixel((4, 4), 1)
buf = io.BytesIO()
src.save(buf, "PNG", transparency=0)
out, _ = self._download(downloader, tmp_path, buf.getvalue())
assert out.mode == "RGBA"
assert out.getpixel((0, 0))[3] == 0
assert out.getpixel((4, 4)) == (255, 0, 0, 255)
def test_greyscale_transparency_becomes_alpha(self, downloader, tmp_path):
src = Image.new("L", (8, 8), 0)
src.putpixel((2, 2), 255)
buf = io.BytesIO()
src.save(buf, "PNG", transparency=0)
out, _ = self._download(downloader, tmp_path, buf.getvalue())
assert out.getpixel((0, 0))[3] == 0
assert out.getpixel((2, 2)) == (255, 255, 255, 255)
def test_jpeg_is_stored_as_opaque_rgba_png(self, downloader, tmp_path):
buf = io.BytesIO()
Image.new("RGB", (8, 8), (10, 200, 30)).save(buf, "JPEG", quality=95)
out, fmt = self._download(downloader, tmp_path, buf.getvalue(), "image/jpeg")
assert fmt == "PNG" and out.mode == "RGBA"
assert out.getchannel("A").getextrema() == (255, 255)
class TestSharedDownloader:
def test_download_missing_logo_reuses_one_session(self, tmp_path, fresh_thread_state):
sessions = []
real_session = requests.Session
def counting_session(*args, **kwargs):
s = real_session(*args, **kwargs)
sessions.append(s)
return s
with patch("src.logo_downloader.requests.Session", side_effect=counting_session):
with patch.object(LogoDownloader, "download_logo", return_value=True) as dl:
for abbr in ("AAA", "BBB", "CCC"):
assert download_missing_logo(
"nfl", "1", abbr, tmp_path / f"{abbr}.png",
logo_url=f"http://x/{abbr}.png",
create_placeholder=False) is True
assert dl.call_count == 3
assert len(sessions) == 1
def test_each_thread_gets_its_own_downloader(self, fresh_thread_state):
here = logo_downloader_module.shared_downloader()
assert logo_downloader_module.shared_downloader() is here
seen = []
t = threading.Thread(target=lambda: seen.append(
logo_downloader_module.shared_downloader()))
t.start()
t.join()
assert seen and seen[0] is not here
assert seen[0].session is not here.session
class TestPlaceholderWrite:
def test_no_write_probe_file_is_created(self, tmp_path):
created = []
real_touch = Path.touch
def spy_touch(self, *args, **kwargs):
created.append(self.name)
return real_touch(self, *args, **kwargs)
with patch.object(Path, "touch", spy_touch):
assert LogoDownloader().create_placeholder_logo("COLL", str(tmp_path)) is True
assert "test_write.tmp" not in created
assert sorted(p.name for p in tmp_path.iterdir()) == ["COLL.png"]
def test_unwritable_directory_returns_false(self, tmp_path):
with patch.object(LogoDownloader, "ensure_logo_directory", return_value=True), \
patch("src.logo_downloader.tempfile.mkstemp",
side_effect=PermissionError("read-only")):
assert LogoDownloader().create_placeholder_logo("COLL", str(tmp_path)) is False
assert list(tmp_path.iterdir()) == []
def test_failed_save_keeps_the_previous_file(self, tmp_path):
path = tmp_path / "COLL.png"
Image.new("RGBA", (30, 30), (1, 2, 3, 255)).save(path)
before = path.read_bytes()
with patch("src.logo_downloader.os.replace", side_effect=OSError("disk full")):
assert LogoDownloader().create_placeholder_logo("COLL", str(tmp_path)) is False
assert path.read_bytes() == before
assert sorted(p.name for p in tmp_path.iterdir()) == ["COLL.png"]
+12 -7
View File
@@ -33,7 +33,7 @@ def _no_real_chmod(monkeypatch):
# Keep the permission helpers out of the way: their own env detection
# is not what these tests are about.
monkeypatch.setattr("src.common.logo_helper.ensure_directory_permissions", MagicMock())
monkeypatch.setattr("src.common.logo_helper.ensure_file_permissions", MagicMock())
monkeypatch.setattr("src.logo_downloader.ensure_file_permissions", MagicMock())
@pytest.fixture
@@ -48,7 +48,8 @@ def write_logo(path: Path, size=(20, 20), color=(255, 0, 0), fmt="PNG") -> Path:
return path
def fake_response(content: bytes, chunk_size: int = 64 * 1024):
def fake_response(content: bytes, chunk_size: int = 64 * 1024,
content_type: str = "image/png"):
"""Stand-in for a streamed requests.Response.
_download_logo opens `with session.get(..., stream=True)` and reads
@@ -61,6 +62,7 @@ def fake_response(content: bytes, chunk_size: int = 64 * 1024):
response.__enter__.return_value = response
response.__exit__.return_value = False
response.raise_for_status = MagicMock()
response.headers = {"content-type": content_type}
def _iter_content(*_args, **_kwargs):
for i in range(0, len(content), chunk_size):
@@ -80,6 +82,7 @@ def endless_response(chunk: bytes = b"\x00" * 65536):
response.__enter__.return_value = response
response.__exit__.return_value = False
response.raise_for_status = MagicMock()
response.headers = {"content-type": "image/png"}
def _iter_content(*_args, **_kwargs):
while True:
@@ -208,7 +211,7 @@ class TestLoadLogoWithDownload:
# stream=True is load-bearing: it is what lets the size cap apply
# before the body is buffered.
helper.session.get.assert_called_once_with(
"http://x/logo.png", timeout=30, stream=True)
"http://x/logo.png", headers=None, timeout=30, stream=True)
def test_download_failure_falls_back_to_placeholder(self, helper, tmp_path):
helper.session.get = MagicMock(
@@ -241,7 +244,7 @@ class TestDownloadLogo:
path.parent.mkdir()
helper.session.get = MagicMock(return_value=fake_response(png_bytes()))
with patch("src.common.logo_helper.ensure_directory_permissions") as dirs, \
patch("src.common.logo_helper.ensure_file_permissions") as files:
patch("src.logo_downloader.ensure_file_permissions") as files:
helper._download_logo("http://x/logo.png", path)
assert path.exists()
dirs.assert_called_once()
@@ -284,6 +287,7 @@ class TestDownloadLogo:
response.__enter__.return_value = response
response.__exit__.return_value = False
response.raise_for_status = MagicMock()
response.headers = {"content-type": "image/png"}
response.iter_content = _dies_midway
helper.session.get = MagicMock(return_value=response)
@@ -305,7 +309,7 @@ class TestDownloadLogo:
seen.append(name)
return fd, name
with patch("src.common.logo_helper.tempfile.mkstemp", side_effect=record):
with patch("src.logo_downloader.tempfile.mkstemp", side_effect=record):
helper.session.get = MagicMock(return_value=fake_response(png_bytes()))
helper._download_logo("http://x/logo.png", path)
helper.session.get = MagicMock(return_value=fake_response(png_bytes()))
@@ -351,7 +355,7 @@ class TestDownloadLogo:
def load(self):
raise Image.DecompressionBombError("too many pixels")
monkeypatch.setattr("src.common.logo_helper.Image.open", lambda *a, **kw: Bomb())
monkeypatch.setattr("src.logo_downloader.Image.open", lambda *a, **kw: Bomb())
with pytest.raises(Image.DecompressionBombError):
helper._download_logo("http://x/bomb.png", path)
assert not path.exists()
@@ -420,7 +424,8 @@ class TestPlaceholderLogo:
class TestSessionConfiguration:
def test_user_agent_and_accept_headers(self, helper):
assert helper.session.headers["User-Agent"] == "LEDMatrix-Common/1.0"
from src.common.api_helper import USER_AGENT
assert helper.session.headers["User-Agent"] == USER_AGENT
assert helper.session.headers["Accept"] == "image/*"