|
#!/usr/bin/env -S uv run --script |
|
# /// script |
|
# requires-python = ">=3.11" |
|
# dependencies = ["requests>=2.32", "cryptography>=42"] |
|
# /// |
|
"""Upload a file to GitHub user-attachments and print URL/markdown. |
|
|
|
Uses the user's normal Chrome session cookies plus GitHub's undocumented web |
|
attachment flow. Keep stdout machine-readable; progress/errors go to stderr. |
|
""" |
|
from __future__ import annotations |
|
|
|
import argparse |
|
import html |
|
import importlib.util |
|
import json |
|
import mimetypes |
|
import re |
|
import subprocess |
|
import sys |
|
from pathlib import Path |
|
from typing import Any |
|
|
|
import requests |
|
|
|
mimetypes.add_type("image/webp", ".webp") |
|
mimetypes.add_type("image/svg+xml", ".svg") |
|
mimetypes.add_type("video/mp4", ".mp4") |
|
mimetypes.add_type("video/quicktime", ".mov") |
|
mimetypes.add_type("video/webm", ".webm") |
|
|
|
GITHUB = "https://github.com" |
|
|
|
|
|
def eprint(*args: object) -> None: |
|
print(*args, file=sys.stderr) |
|
|
|
|
|
def run_gh(args: list[str]) -> str: |
|
proc = subprocess.run(["gh", *args], text=True, capture_output=True, check=False) |
|
if proc.returncode: |
|
raise RuntimeError(f"gh {' '.join(args)} failed: {proc.stderr.strip() or proc.stdout.strip()}") |
|
return proc.stdout.strip() |
|
|
|
|
|
def parse_repo(repo: str | None) -> tuple[str, str]: |
|
if not repo: |
|
repo = run_gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]) |
|
repo = repo.removeprefix("https://github.com/").removeprefix("github.com/").strip("/") |
|
parts = repo.split("/") |
|
if len(parts) != 2 or not all(parts): |
|
raise ValueError("repo must be OWNER/REPO for github.com") |
|
return parts[0], parts[1] |
|
|
|
|
|
def load_cookie_extractor(): |
|
script_path = Path(__file__).resolve() |
|
script_dir = script_path.parent |
|
# Works both as a Pi skill (scripts/upload-github-media.py with a sibling |
|
# chrome-cookies skill) and as this standalone gist (adjacent extractor file). |
|
skill_dir = script_path.parents[1] if len(script_path.parents) > 1 else script_dir |
|
candidates = [ |
|
script_dir / "chrome_cookie_extract.py", |
|
script_dir / "extract.py", |
|
skill_dir.parent / "chrome-cookies" / "extract.py", |
|
Path.home() / ".pi/agent/skills/chrome-cookies/extract.py", |
|
Path.home() / "projects/pi-setup/skills/chrome-cookies/extract.py", |
|
] |
|
for path in candidates: |
|
if path.exists(): |
|
spec = importlib.util.spec_from_file_location("chrome_cookie_extract", path) |
|
if spec and spec.loader: |
|
module = importlib.util.module_from_spec(spec) |
|
spec.loader.exec_module(module) # type: ignore[union-attr] |
|
return module.extract_cookies |
|
raise RuntimeError("Could not find chrome-cookies/extract.py") |
|
|
|
|
|
def chrome_profiles() -> list[str]: |
|
chrome_dir = Path.home() / "Library/Application Support/Google/Chrome" |
|
found = [] |
|
if chrome_dir.exists(): |
|
found = [p.name for p in chrome_dir.iterdir() if (p / "Cookies").exists()] |
|
preferred = ["Default", *[f"Profile {i}" for i in range(1, 30)]] |
|
ordered = [p for p in preferred if p in found] |
|
ordered.extend(sorted(p for p in found if p not in ordered)) |
|
return ordered |
|
|
|
|
|
def choose_cookies(expected_user: str | None) -> dict[str, str]: |
|
extract_cookies = load_cookie_extractor() |
|
candidates: list[tuple[str, str | None, bool, dict[str, str]]] = [] |
|
for profile in chrome_profiles(): |
|
try: |
|
cookies = extract_cookies("github.com", profile=profile, include_subdomains=True) |
|
except Exception: |
|
continue |
|
if not cookies: |
|
continue |
|
user = cookies.get("dotcom_user") |
|
has_session = bool(cookies.get("user_session")) |
|
candidates.append((profile, user, has_session, cookies)) |
|
|
|
if expected_user: |
|
for profile, user, has_session, cookies in candidates: |
|
if has_session and user == expected_user: |
|
eprint(f"Using Chrome profile {profile} for GitHub user {user}") |
|
return normalize_cookies(cookies) |
|
|
|
for profile, user, has_session, cookies in candidates: |
|
if has_session: |
|
eprint(f"Using Chrome profile {profile} for GitHub user {user or 'unknown'}") |
|
return normalize_cookies(cookies) |
|
|
|
summary = ", ".join(f"{p}:{u or '?'}:{'session' if s else 'no-session'}" for p, u, s, _ in candidates) |
|
raise RuntimeError(f"No Chrome github.com user_session cookie found. Candidates: {summary or 'none'}") |
|
|
|
|
|
def normalize_cookies(cookies: dict[str, str]) -> dict[str, str]: |
|
cookies = dict(cookies) |
|
if cookies.get("user_session") and not cookies.get("__Host-user_session_same_site"): |
|
cookies["__Host-user_session_same_site"] = cookies["user_session"] |
|
return cookies |
|
|
|
|
|
def redact(text: str) -> str: |
|
text = re.sub(r'(user_session=)[^;\s]+', r'\1<redacted>', text) |
|
text = re.sub( |
|
r'"(?:authenticity_token|uploadToken|asset_upload_authenticity_token|upload_authenticity_token)"\s*:\s*"[^"]+"', |
|
'"<token>":"<redacted>"', |
|
text, |
|
) |
|
text = re.sub(r"[A-Za-z0-9_+=/-]{48,}", "<redacted>", text) |
|
return text[:1200] |
|
|
|
|
|
def request_upload(owner: str, repo: str, repo_id: str, path: Path, expected_user: str | None) -> tuple[str, str]: |
|
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream" |
|
size = path.stat().st_size |
|
cookies = choose_cookies(expected_user) |
|
|
|
session = requests.Session() |
|
for name, value in cookies.items(): |
|
session.cookies.set(name, value, domain="github.com", path="/") |
|
|
|
user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari" |
|
repo_url = f"{GITHUB}/{owner}/{repo}" |
|
web_headers = {"User-Agent": user_agent, "Accept": "text/html,application/xhtml+xml"} |
|
api_headers = { |
|
"User-Agent": user_agent, |
|
"Accept": "application/json", |
|
"Origin": GITHUB, |
|
"Referer": repo_url, |
|
"X-Requested-With": "XMLHttpRequest", |
|
} |
|
|
|
page = session.get(repo_url, headers=web_headers, timeout=30) |
|
eprint(f"GET repo page: {page.status_code}") |
|
if page.status_code != 200: |
|
raise RuntimeError(f"repo page failed: {page.status_code} {redact(page.text)}") |
|
|
|
token_match = re.search(r'"uploadToken"\s*:\s*"([^"]+)"', page.text) or re.search( |
|
r""uploadToken"\s*:\s*"([^&]+)"", page.text |
|
) |
|
if not token_match: |
|
raise RuntimeError("No uploadToken found; Chrome user may lack write access or GitHub changed the page payload") |
|
upload_token = html.unescape(token_match.group(1)) |
|
|
|
policy_fields = { |
|
"name": (None, path.name), |
|
"size": (None, str(size)), |
|
"content_type": (None, mime), |
|
"authenticity_token": (None, upload_token), |
|
"repository_id": (None, str(repo_id)), |
|
} |
|
policy = session.post(f"{GITHUB}/upload/policies/assets", headers=api_headers, files=policy_fields, timeout=30) |
|
eprint(f"POST upload policy: {policy.status_code}") |
|
if policy.status_code not in (200, 201): |
|
raise RuntimeError(f"policy request failed: {policy.status_code} {redact(policy.text)}") |
|
payload: dict[str, Any] = policy.json() |
|
|
|
multipart = [(key, (None, str(value))) for key, value in payload["form"].items()] |
|
with path.open("rb") as file_handle: |
|
multipart.append(("file", (path.name, file_handle, mime))) |
|
s3 = requests.post( |
|
payload["upload_url"], |
|
headers={"Origin": GITHUB, "User-Agent": user_agent}, |
|
files=multipart, |
|
timeout=90, |
|
) |
|
eprint(f"POST S3 upload: {s3.status_code}") |
|
if s3.status_code not in (200, 201, 204): |
|
raise RuntimeError(f"S3 upload failed: {s3.status_code} {redact(s3.text)}") |
|
|
|
finalize_fields = {"authenticity_token": (None, payload["asset_upload_authenticity_token"])} |
|
finalize = session.put(f"{GITHUB}{payload['asset_upload_url']}", headers=api_headers, files=finalize_fields, timeout=30) |
|
eprint(f"PUT finalize: {finalize.status_code}") |
|
if finalize.status_code not in (200, 201): |
|
raise RuntimeError(f"finalize failed: {finalize.status_code} {redact(finalize.text)}") |
|
|
|
finalized = finalize.json() |
|
href = finalized.get("href") or payload.get("asset", {}).get("href") |
|
if not href: |
|
raise RuntimeError(f"finalize response had no href: {redact(json.dumps(finalized))}") |
|
return href, mime |
|
|
|
|
|
def markdown_for(path: Path, href: str, mime: str, alt: str | None) -> str: |
|
if mime.startswith("image/"): |
|
label = alt or path.stem.replace("-", " ").replace("_", " ") |
|
return f"" |
|
return f"[{path.name}]({href})" |
|
|
|
|
|
def main() -> int: |
|
parser = argparse.ArgumentParser(description="Upload local media/file to GitHub user-attachments") |
|
parser.add_argument("--repo", "-R", help="GitHub repo as OWNER/REPO; defaults to current repo") |
|
parser.add_argument("--file", "-f", required=True, help="Local file to upload") |
|
parser.add_argument("--alt", help="Alt text for image markdown") |
|
parser.add_argument("--github-user", help="Expected github.com Chrome user; defaults to `gh api user` login") |
|
parser.add_argument("--output", choices=["markdown", "url", "json"], default="markdown") |
|
args = parser.parse_args() |
|
|
|
path = Path(args.file).expanduser().resolve() |
|
if not path.is_file(): |
|
raise FileNotFoundError(path) |
|
|
|
owner, repo = parse_repo(args.repo) |
|
expected_user = args.github_user or run_gh(["api", "user", "--jq", ".login"]) |
|
repo_id = run_gh(["api", f"repos/{owner}/{repo}", "--jq", ".id"]) |
|
href, mime = request_upload(owner, repo, repo_id, path, expected_user) |
|
|
|
if args.output == "url": |
|
print(href) |
|
elif args.output == "json": |
|
print(json.dumps({"url": href, "markdown": markdown_for(path, href, mime, args.alt), "mime": mime}, indent=2)) |
|
else: |
|
print(markdown_for(path, href, mime, args.alt)) |
|
return 0 |
|
|
|
|
|
if __name__ == "__main__": |
|
try: |
|
raise SystemExit(main()) |
|
except Exception as exc: |
|
eprint(f"ERROR: {exc}") |
|
raise SystemExit(1) |