100 MB → /upload/{key}/init → PUT each part → /upload/{key}/complete * * Output (stdout): the JSON result returned by the API. On non-2xx the script * exits non-zero and dumps the error JSON. */ const BPHP_BASE = 'https://webhook.binaryphp.com'; const THRESHOLD = 100 * 1024 * 1024; // Cloudflare edge body cap const MIN_PART = 5 * 1024 * 1024; // R2 multipart minimum (except last) if (PHP_SAPI !== 'cli') { fwrite(STDERR, "CLI only.\n"); exit(1); } [$file, $opts] = parse_args($argv); $apiKey = $opts['api-key'] ?? getenv('API_KEY') ?: ''; if (!$apiKey) { fwrite(STDERR, "set API_KEY=bphp_live_... or pass --api-key=...\n"); exit(1); } if (!is_file($file)) { fwrite(STDERR, "not a file: $file\n"); exit(1); } $size = filesize($file) ?: 0; $base = rtrim(getenv('BPHP_BASE') ?: BPHP_BASE, '/'); if ($size <= THRESHOLD) { fwrite(STDERR, "→ single PUT ($size bytes)\n"); exit(single_upload($base, $apiKey, $file, $opts)); } fwrite(STDERR, "→ multipart upload ($size bytes)\n"); exit(multipart_upload($base, $apiKey, $file, $size, $opts)); // ─── Implementation ─────────────────────────────────────────────── function parse_args(array $argv): array { array_shift($argv); // script name $opts = []; $file = null; foreach ($argv as $a) { if (str_starts_with($a, '--')) { $a = substr($a, 2); $eq = strpos($a, '='); $k = $eq === false ? $a : substr($a, 0, $eq); $v = $eq === false ? '1' : substr($a, $eq + 1); $opts[$k] = $v; } elseif ($file === null) { $file = $a; } } if (!$file) { fwrite(STDERR, "usage: php upload.php [--domain=...] [--mac=...] [--expire=YYYY-MM-DD] [--plugin=...] [--webhook=...]\n"); exit(1); } return [$file, $opts]; } /** Build the X-BPHP-* header set common to single + multipart. */ function common_headers(array $opts): array { $h = []; if (!empty($opts['domain'])) $h[] = 'X-BPHP-Domain: ' . $opts['domain']; if (!empty($opts['mac'])) $h[] = 'X-BPHP-Mac: ' . $opts['mac']; if (!empty($opts['expire'])) $h[] = 'X-BPHP-Expire: ' . $opts['expire']; if (!empty($opts['plugin'])) $h[] = 'X-BPHP-Plugin: ' . $opts['plugin']; if (!empty($opts['webhook'])) $h[] = 'X-BPHP-Webhook: ' . $opts['webhook']; return $h; } function single_upload(string $base, string $apiKey, string $file, array $opts): int { $fp = fopen($file, 'rb'); if (!$fp) { fwrite(STDERR, "cannot open $file\n"); return 1; } $size = filesize($file); $headers = array_merge(common_headers($opts), [ 'X-BPHP-Filename: ' . basename($file), 'Content-Type: application/octet-stream', ]); $ch = curl_init("$base/upload/$apiKey"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_PUT => false, CURLOPT_HTTPHEADER => $headers, CURLOPT_INFILE => $fp, CURLOPT_INFILESIZE => $size, CURLOPT_UPLOAD => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 300, ]); $resp = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); fclose($fp); if ($resp === false) { fwrite(STDERR, "curl: $err\n"); return 1; } fwrite(STDOUT, $resp . "\n"); return ($status >= 200 && $status < 300) ? 0 : 1; } function multipart_upload(string $base, string $apiKey, string $file, int $size, array $opts): int { // ─── 1. Init: server tells us how to slice + presigned PUT URLs ───── $init = http_json("$base/upload/$apiKey/init", 'POST', null, [ 'X-BPHP-Filename: ' . basename($file), 'X-BPHP-Total-Size: ' . $size, ]); if (!$init['ok']) { fwrite(STDOUT, $init['raw'] . "\n"); return 1; } $data = $init['json']; $uploadId = $data['upload_id'] ?? ''; $r2Key = $data['r2_key'] ?? ''; $partSize = (int) ($data['part_size'] ?? 0); $partCount = (int) ($data['part_count'] ?? 0); $parts = $data['parts'] ?? []; if ($partSize < MIN_PART || !$uploadId || !$r2Key || !$parts) { fwrite(STDERR, "init: malformed response\n" . $init['raw'] . "\n"); return 1; } fwrite(STDERR, " upload_id=$uploadId, $partCount × $partSize bytes\n"); // ─── 2. PUT each part directly to R2 (no Worker bandwidth) ────────── $fp = fopen($file, 'rb'); if (!$fp) { fwrite(STDERR, "cannot open $file\n"); return 1; } $etags = []; try { foreach ($parts as $p) { $partNo = (int) ($p['part_no'] ?? 0); $url = (string) ($p['upload_url'] ?? ''); if (!$partNo || !$url) throw new RuntimeException("malformed part info"); fseek($fp, ($partNo - 1) * $partSize); $chunk = stream_get_contents($fp, $partSize); if ($chunk === false) throw new RuntimeException("read failed at part $partNo"); $etag = put_part($url, $chunk); fwrite(STDERR, " part $partNo/$partCount etag=$etag\n"); $etags[] = ['part_no' => $partNo, 'etag' => $etag]; } } catch (Throwable $e) { fclose($fp); // Best-effort cleanup so R2 doesn't keep billing us for orphan parts. @http_json("$base/upload/$apiKey/abort", 'POST', ['upload_id' => $uploadId, 'r2_key' => $r2Key]); fwrite(STDERR, "aborted: " . $e->getMessage() . "\n"); return 1; } fclose($fp); // ─── 3. Complete: server stitches parts, runs hybrid encoder ──────── $body = [ 'upload_id' => $uploadId, 'r2_key' => $r2Key, 'filename' => basename($file), 'parts' => $etags, 'domain' => $opts['domain'] ?? '', 'mac' => $opts['mac'] ?? '', 'expire' => $opts['expire'] ?? '', 'plugin' => $opts['plugin'] ?? '', 'webhook' => $opts['webhook'] ?? '', ]; $cr = http_json("$base/upload/$apiKey/complete", 'POST', $body); fwrite(STDOUT, $cr['raw'] . "\n"); return $cr['ok'] ? 0 : 1; } /** PUT one part to a presigned R2 URL; return the ETag header (with quotes). */ function put_part(string $url, string $chunk): string { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_POSTFIELDS => $chunk, CURLOPT_HEADER => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 600, ]); $resp = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $hsize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); $err = curl_error($ch); curl_close($ch); if ($resp === false || $status < 200 || $status >= 300) { $body = $resp ? substr($resp, $hsize, 300) : $err; throw new RuntimeException("R2 PUT $status: $body"); } $headers = substr($resp, 0, $hsize); if (preg_match('/^etag:\s*("[^"]+")/im', $headers, $m)) { return $m[1]; } throw new RuntimeException("no ETag header in R2 response"); } /** * POST application/json to {url} with our JSON body, return parsed result. * Returns ['ok'=>bool, 'status'=>int, 'json'=>array, 'raw'=>string]. */ function http_json(string $url, string $method, ?array $body, array $extraHeaders = []): array { $headers = array_merge(['Content-Type: application/json'], $extraHeaders); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_POSTFIELDS => $body !== null ? json_encode($body, JSON_UNESCAPED_SLASHES) : '', CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 120, ]); $resp = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $raw = $resp === false ? '{"error":"curl failed"}' : (string) $resp; $json = json_decode($raw, true) ?: []; return [ 'ok' => ($status >= 200 && $status < 300), 'status' => $status, 'json' => $json, 'raw' => $raw, ]; }