diff --git a/.github/workflows/star-history.yaml b/.github/workflows/star-history.yaml index a07e4995e..6a2e4a6a5 100644 --- a/.github/workflows/star-history.yaml +++ b/.github/workflows/star-history.yaml @@ -27,6 +27,7 @@ env: RELEASE_TAG: star-history/latest OUTPUT_DIR: dist/star-history SCALE: '2' + PYNACL_VERSION: '1.6.2' jobs: publish: @@ -38,6 +39,17 @@ jobs: with: persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install chart dependencies + run: | + set -euo pipefail + + python3 -m pip install --disable-pip-version-check --quiet \ + "pynacl==$PYNACL_VERSION" + - name: Mirror upstream charts env: STAR_HISTORY_TOKEN: ${{ secrets.STAR_HISTORY_TOKEN }} diff --git a/scripts/star_history_lib/cli.py b/scripts/star_history_lib/cli.py index f97c7a4fc..d711dbe2a 100644 --- a/scripts/star_history_lib/cli.py +++ b/scripts/star_history_lib/cli.py @@ -6,6 +6,7 @@ from . import ChartError from .client import CHART_ENDPOINT, chart_url, fetch_svg from .raster import asset_paths, rasterize, write_raster_page +from .seal import seal_token from .svg import background_of, sanitize_svg, viewport_of @@ -52,9 +53,17 @@ def build_manifest(args: argparse.Namespace) -> dict: charts = [] assets = [] + sealed = seal_token(args.token) if args.token else "" + if not sealed: + print( + "::warning::No STAR_HISTORY_TOKEN set; falling back to the shared " + "star-history.com token pool, which is often rate-limited", + file=sys.stderr, + ) + for theme in THEMES: url = chart_url( - args.repository, theme, args.token, args.chart_type, args.legend + args.repository, theme, sealed, args.chart_type, args.legend ) svg = fetch_sanitized_svg(url) svg_path = os.path.join(args.output_dir, f"{args.basename}-{theme}.svg") diff --git a/scripts/star_history_lib/client.py b/scripts/star_history_lib/client.py index 384ff87fa..e6ade0d2d 100644 --- a/scripts/star_history_lib/client.py +++ b/scripts/star_history_lib/client.py @@ -14,6 +14,18 @@ RETRIES = 3 RETRY_BACKOFF_SECONDS = 5 MIN_CHART_BYTES = 2048 +ERROR_BODY_LIMIT = 200 + +TERMINAL_STATUS_HINTS = { + 400: ( + "the sealed token was rejected before it was decrypted; star-history.com " + "may have rotated the key in star_history_lib.seal.SEALING_PUBLIC_KEY" + ), + 401: ( + "the GitHub token was decrypted but refused; refresh the " + "STAR_HISTORY_TOKEN secret with a valid personal access token" + ), +} def chart_url( @@ -31,6 +43,16 @@ def redact(url: str) -> str: return re.sub(r"(sealed_token=)[^&]*", r"\1***", url) +def describe_http_error(error: urllib.error.HTTPError) -> str: + try: + body = error.read().decode("utf-8", "replace").strip() + except OSError: + body = "" + if not body: + return f"HTTP {error.code}" + return f"HTTP {error.code}: {redact(body[:ERROR_BODY_LIMIT])}" + + def fetch_svg(url: str) -> str: request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) last_error = "" @@ -41,7 +63,10 @@ def fetch_svg(url: str) -> str: content_type = response.headers.get("Content-Type", "") body = response.read().decode("utf-8") except urllib.error.HTTPError as error: - last_error = f"HTTP {error.code}" + last_error = describe_http_error(error) + hint = TERMINAL_STATUS_HINTS.get(error.code) + if hint: + raise ChartError(f"{redact(url)} failed: {last_error} — {hint}") except (urllib.error.URLError, TimeoutError) as error: last_error = str(error) except UnicodeDecodeError as error: diff --git a/scripts/star_history_lib/seal.py b/scripts/star_history_lib/seal.py new file mode 100644 index 000000000..77bc7b082 --- /dev/null +++ b/scripts/star_history_lib/seal.py @@ -0,0 +1,70 @@ +import base64 +import binascii +import hashlib + +from . import ChartError + + +SEALING_PUBLIC_KEY = "vP9uLIF/19VVvLUEZMms8LXJo/RCjRys/Z9S543eG10=" + +NONCE_BYTES = 24 +PUBLIC_KEY_BYTES = 32 + +GITHUB_TOKEN_PREFIXES = ( + "ghp_", + "gho_", + "ghu_", + "ghs_", + "ghr_", + "github_pat_", +) + + +def require_nacl(): + try: + from nacl.public import Box, PrivateKey, PublicKey + except ImportError as error: + raise ChartError( + "sealing a token requires PyNaCl; install it with 'pip install pynacl'" + ) from error + return Box, PrivateKey, PublicKey + + +def validate_token(token: str) -> None: + if token != token.strip(): + raise ChartError("the GitHub token has leading or trailing whitespace") + if not token.startswith(GITHUB_TOKEN_PREFIXES): + raise ChartError( + "the GitHub token must be a personal access token starting with one " + f"of {', '.join(GITHUB_TOKEN_PREFIXES)}. The workflow seals the token " + "itself, so store the bare token rather than a pre-sealed one." + ) + + +def decode_public_key(public_key: str) -> bytes: + try: + key = base64.b64decode(public_key, validate=True) + except (binascii.Error, ValueError) as error: + raise ChartError(f"the sealing public key is not valid base64: {error}") from error + if len(key) != PUBLIC_KEY_BYTES: + raise ChartError( + f"the sealing public key must be {PUBLIC_KEY_BYTES} bytes, got {len(key)}" + ) + return key + + +def seal_token(token: str, public_key: str = SEALING_PUBLIC_KEY) -> str: + validate_token(token) + Box, PrivateKey, PublicKey = require_nacl() + + recipient_key = decode_public_key(public_key) + recipient = PublicKey(recipient_key) + ephemeral = PrivateKey.generate() + ephemeral_key = bytes(ephemeral.public_key) + + nonce = hashlib.sha512(ephemeral_key + recipient_key).digest()[:NONCE_BYTES] + ciphertext = Box(ephemeral, recipient).encrypt( + token.encode("utf-8"), nonce + ).ciphertext + + return base64.urlsafe_b64encode(ephemeral_key + ciphertext).decode("ascii").rstrip("=")