Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/star-history.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ env:
RELEASE_TAG: star-history/latest
OUTPUT_DIR: dist/star-history
SCALE: '2'
PYNACL_VERSION: '1.6.2'

jobs:
publish:
Expand All @@ -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 }}
Expand Down
11 changes: 10 additions & 1 deletion scripts/star_history_lib/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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")
Expand Down
27 changes: 26 additions & 1 deletion scripts/star_history_lib/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 = ""
Expand All @@ -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:
Expand Down
70 changes: 70 additions & 0 deletions scripts/star_history_lib/seal.py
Original file line number Diff line number Diff line change
@@ -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("=")
Loading