#!/usr/bin/env python3 """ BinaryPHP API multipart uploader — Python 3.8+, requests only. pip install requests API_KEY=bphp_live_xxx python upload.py path/to/big.zip --domain app.example.com Files ≤100 MB use the single POST /upload/{key} endpoint. Files >100 MB are split into 50 MB parts and walked through the multipart flow (init → PUT each part → complete). """ from __future__ import annotations import argparse import os import sys from pathlib import Path import requests # pip install requests BASE = os.environ.get("BPHP_BASE", "https://webhook.binaryphp.com") THRESHOLD = 100 * 1024 * 1024 # Cloudflare edge body cap def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("file") ap.add_argument("--domain", default="*", help="comma-separated, e.g. 'a.com,*.b.com'") ap.add_argument("--mac", default="", help="comma-separated MACs") ap.add_argument("--expire", default="", help="YYYY-MM-DD") ap.add_argument("--plugin", default="") ap.add_argument("--webhook", default="", help="optional URL — receives encode.completed") ap.add_argument("--api-key", default=os.environ.get("API_KEY", "")) args = ap.parse_args() if not args.api_key: sys.exit("set API_KEY=bphp_live_... or pass --api-key") path = Path(args.file) if not path.is_file(): sys.exit(f"not a file: {path}") size = path.stat().st_size if size <= THRESHOLD: print(f"→ single PUT ({size} bytes)", file=sys.stderr) return single_upload(args, path, size) print(f"→ multipart upload ({size} bytes)", file=sys.stderr) return multipart_upload(args, path, size) def common_headers(args) -> dict: h = {"X-BPHP-Domain": args.domain} if args.mac: h["X-BPHP-Mac"] = args.mac if args.expire: h["X-BPHP-Expire"] = args.expire if args.plugin: h["X-BPHP-Plugin"] = args.plugin if args.webhook: h["X-BPHP-Webhook"] = args.webhook return h def single_upload(args, path: Path, size: int) -> int: h = {**common_headers(args), "X-BPHP-Filename": path.name} with path.open("rb") as f: r = requests.post(f"{BASE}/upload/{args.api_key}", data=f, headers=h, timeout=120) print(r.text) return 0 if r.ok else 1 def multipart_upload(args, path: Path, size: int) -> int: s = requests.Session() # 1. Init — server tells us part count + presigned URLs. init = s.post( f"{BASE}/upload/{args.api_key}/init", headers={"X-BPHP-Filename": path.name, "X-BPHP-Total-Size": str(size)}, timeout=30, ) if not init.ok: sys.exit(f"init failed: {init.status_code} {init.text}") init_data = init.json() upload_id = init_data["upload_id"] r2_key = init_data["r2_key"] part_size = init_data["part_size"] parts_meta = init_data["parts"] print(f" upload_id={upload_id}, {len(parts_meta)} × {part_size} bytes", file=sys.stderr) # 2. Upload each part — capture ETag from response. etags = [] try: with path.open("rb") as f: for p in parts_meta: f.seek((p["part_no"] - 1) * part_size) chunk = f.read(part_size) pr = s.put(p["upload_url"], data=chunk, timeout=300) if not pr.ok: raise RuntimeError(f"part {p['part_no']}: {pr.status_code} {pr.text[:200]}") etag = pr.headers.get("ETag") if not etag: raise RuntimeError(f"part {p['part_no']}: no ETag in response") print(f" part {p['part_no']}/{len(parts_meta)} etag={etag}", file=sys.stderr) etags.append({"part_no": p["part_no"], "etag": etag}) except Exception as e: # Best-effort cleanup so we don't leave orphan parts billing in R2. s.post( f"{BASE}/upload/{args.api_key}/abort", json={"upload_id": upload_id, "r2_key": r2_key}, timeout=30, ) sys.exit(str(e)) # 3. Complete — server stitches parts, runs hybrid encoder, returns URL. body = { "upload_id": upload_id, "r2_key": r2_key, "filename": path.name, "parts": etags, "domain": args.domain, "mac": args.mac, "expire": args.expire, "plugin": args.plugin, "webhook": args.webhook, } cr = s.post(f"{BASE}/upload/{args.api_key}/complete", json=body, timeout=120) print(cr.text) return 0 if cr.ok else 1 if __name__ == "__main__": raise SystemExit(main())