= 300) { $code = is_array($decoded) ? ($decoded['error']['code'] ?? 'request_failed') : 'request_failed'; throw new RuntimeException("API returned HTTP $status ($code)."); } if (!is_array($decoded) || !array_key_exists('data', $decoded)) { throw new RuntimeException('The API returned an unexpected JSON response.'); } return $decoded; } function jsonRequest(string $url, string $apiKey, ?array $payload = null, ?string $idempotencyKey = null): array { $handle = curl_init($url); if ($handle === false) { throw new RuntimeException('Could not start an HTTP request.'); } $headers = ['Authorization: Bearer ' . $apiKey, 'Accept: application/json']; if ($idempotencyKey !== null) { $headers[] = 'Idempotency-Key: ' . $idempotencyKey; } $options = [ CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_TIMEOUT => 30, ]; if ($payload !== null) { $headers[] = 'Content-Type: application/json'; $options[CURLOPT_HTTPHEADER] = $headers; $options[CURLOPT_POST] = true; $options[CURLOPT_POSTFIELDS] = json_encode($payload, JSON_THROW_ON_ERROR); } try { curl_setopt_array($handle, $options); return readJsonResponse($handle, curl_exec($handle)); } finally { curl_close($handle); } } function writeAll($stream, string $text): void { $offset = 0; while ($offset < strlen($text)) { $written = fwrite($stream, substr($text, $offset)); if ($written === false || $written === 0) { throw new RuntimeException('Could not prepare the multipart upload.'); } $offset += $written; } } function confirmQuote(string $url, string $apiKey, string $key, string $token, array $files): array { // A PHP CURLOPT_POSTFIELDS map cannot express repeated fields named "files". // Build a disk-backed multipart stream to preserve order without loading PDFs into RAM. $boundary = 'softwarefool-' . bin2hex(random_bytes(16)); $body = tmpfile(); if ($body === false) { throw new RuntimeException('Could not create a temporary upload stream.'); } try { writeAll($body, "--$boundary\r\nContent-Disposition: form-data; name=\"quote_token\"\r\n\r\n$token\r\n"); foreach ($files as $index => $file) { $number = $index + 1; writeAll($body, "--$boundary\r\nContent-Disposition: form-data; name=\"files\"; filename=\"input-$number.pdf\"\r\nContent-Type: application/pdf\r\n\r\n"); $source = fopen($file['path'], 'rb'); if ($source === false) { throw new RuntimeException('An input PDF is no longer readable.'); } try { $copied = stream_copy_to_stream($source, $body); } finally { fclose($source); } if ($copied !== $file['bytes']) { throw new RuntimeException('An input PDF changed after quoting. Start again with a new quote.'); } writeAll($body, "\r\n"); } writeAll($body, "--$boundary--\r\n"); $length = ftell($body); if ($length === false || !rewind($body)) { throw new RuntimeException('Could not read the prepared upload.'); } for ($attempt = 1; $attempt <= 3; $attempt++) { rewind($body); $handle = curl_init($url); if ($handle === false) { throw new RuntimeException('Could not start an HTTP request.'); } try { curl_setopt_array($handle, [ CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Idempotency-Key: ' . $key, 'Accept: application/json', 'Content-Type: multipart/form-data; boundary=' . $boundary, ], CURLOPT_UPLOAD => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_INFILESIZE => $length, CURLOPT_READFUNCTION => static function ($handle, $stream, int $count) use ($body): string { $chunk = fread($body, $count); return $chunk === false ? '' : $chunk; }, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_TIMEOUT => 300, ]); $response = curl_exec($handle); $status = curl_getinfo($handle, CURLINFO_RESPONSE_CODE); // A lost response may hide an accepted job. Retry the same body, // quote token, and idempotency key, never a new submission. if ($attempt < 3 && ($response === false || in_array($status, [429, 500, 502, 503, 504], true))) { sleep($attempt); continue; } return readJsonResponse($handle, $response); } finally { curl_close($handle); } } } finally { fclose($body); } } function downloadResult(string $url, string $apiKey, string $outputDir, array $result): string { $name = $result['filename'] ?? null; $expected = $result['bytes'] ?? null; if (!is_string($name) || !preg_match('/\A[A-Za-z0-9][A-Za-z0-9._ -]{0,239}\z/D', $name) || str_contains($name, '..') || !is_int($expected) || $expected <= 0) { throw new RuntimeException('The API returned invalid result metadata.'); } if (!is_dir($outputDir) && !mkdir($outputDir, 0770, true) && !is_dir($outputDir)) { throw new RuntimeException('Could not create the output directory.'); } $destination = rtrim($outputDir, '/\\') . DIRECTORY_SEPARATOR . $name; $output = fopen($destination, 'x+b'); // Never overwrite an earlier result. if ($output === false) { throw new RuntimeException('The result destination already exists or cannot be created.'); } $handle = curl_init($url); if ($handle === false) { fclose($output); unlink($destination); throw new RuntimeException('Could not start a download.'); } $received = 0; try { curl_setopt_array($handle, [ CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_TIMEOUT => 300, CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use ($output, $expected, &$received): int { $size = strlen($chunk); if ($received + $size > $expected) { return 0; } $written = fwrite($output, $chunk); if ($written === false) { return 0; } $received += $written; return $written; }, ]); $response = curl_exec($handle); $status = curl_getinfo($handle, CURLINFO_RESPONSE_CODE); if ($response === false || $status !== 200 || $received !== $expected) { throw new RuntimeException('The result download failed or was incomplete.'); } } catch (Throwable $error) { fclose($output); unlink($destination); throw $error; } finally { curl_close($handle); } fclose($output); return $destination; } function main(array $argv): void { if (count($argv) < 5) { throw new RuntimeException('Usage: php merge.php MAX_CREDITS OUTPUT_DIR FIRST.pdf SECOND.pdf [MORE.pdf ...]'); } if (!extension_loaded('curl')) { throw new RuntimeException('Enable the PHP cURL extension.'); } $apiKey = getenv('SOFTWAREFOOL_API_KEY'); if (!is_string($apiKey) || $apiKey === '' || strpbrk($apiKey, "\r\n") !== false) { throw new RuntimeException('Set SOFTWAREFOOL_API_KEY to a valid bearer key.'); } $budget = filter_var($argv[1], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); if ($budget === false) { throw new RuntimeException('MAX_CREDITS must be a positive whole number.'); } [$base, $origin] = apiBase(); $files = []; foreach (array_slice($argv, 3) as $path) { if (!is_file($path) || !is_readable($path)) { throw new RuntimeException("Cannot read input: $path"); } $bytes = filesize($path); $hash = hash_file('sha256', $path); if (!is_int($bytes) || $bytes <= 0 || !is_string($hash)) { throw new RuntimeException("Cannot inspect input: $path"); } $files[] = ['path' => $path, 'bytes' => $bytes, 'sha256' => $hash]; } $key = bin2hex(random_bytes(16)); $manifest = [ 'files' => array_map(static fn(array $file): array => [ 'bytes' => $file['bytes'], 'sha256' => $file['sha256'], ], $files), 'parameters' => (object) [], ]; $quote = jsonRequest($base . '/tools/merge-pdf/quotes/', $apiKey, $manifest, $key)['data']; $cost = $quote['cost_credits'] ?? null; $token = $quote['token'] ?? null; if (($quote['tool'] ?? null) !== 'merge-pdf' || !is_int($cost) || $cost < 1 || !is_string($token) || $token === '' || strpbrk($token, "\r\n") !== false || !is_bool($quote['can_afford'] ?? null) || ($quote['file_count'] ?? null) !== count($files) || ($quote['total_bytes'] ?? null) !== array_sum(array_column($files, 'bytes'))) { throw new RuntimeException('The quote does not match the selected files.'); } printf("Quote: %d credits; available: %d credits; budget: %d credits.\n", $cost, $quote['wallet']['available_credits'] ?? 0, $budget); if (!$quote['can_afford'] || $cost > $budget) { throw new RuntimeException('The quote is unaffordable or exceeds your budget.'); } echo "Confirm this quoted job? Type yes: "; if (strtolower(trim((string) fgets(STDIN))) !== 'yes') { echo "Cancelled before upload; no credits reserved.\n"; return; } foreach ($files as $file) { if (filesize($file['path']) !== $file['bytes'] || hash_file('sha256', $file['path']) !== $file['sha256']) { throw new RuntimeException('An input PDF changed after quoting. Start again with a new quote.'); } } echo "Idempotency key: $key\n"; $confirmed = confirmQuote($base . '/tools/merge-pdf/quotes/confirm/', $apiKey, $key, $token, $files); $job = $confirmed['data']; $jobId = $job['id'] ?? null; if (!is_string($jobId) || !preg_match('/\A[0-9a-f-]{36}\z/D', $jobId) || ($job['accepted_credits'] ?? null) !== $cost) { throw new RuntimeException('The API returned an unexpected job.'); } echo "Accepted job: $jobId\n"; $jobUrl = apiLink($origin, $job['links']['self'] ?? null); $deadline = time() + 300; $delay = 2; while (true) { $job = jsonRequest($jobUrl, $apiKey)['data']; if (($job['id'] ?? null) !== $jobId) { throw new RuntimeException('The API returned a different job while polling.'); } $status = $job['status'] ?? null; if ($status === 'succeeded') { break; } if ($status === 'failed' || $status === 'cancelled') { throw new RuntimeException("Job $jobId $status; reserved credits were released."); } if (!in_array($status, ['queued', 'running'], true)) { throw new RuntimeException('The API returned an unknown job status.'); } if (time() >= $deadline) { throw new RuntimeException("Job $jobId is still $status. Poll its job URL later."); } sleep(min($delay, max(1, $deadline - time()))); $delay = min($delay * 2, 8); } $results = jsonRequest(apiLink($origin, $job['links']['results'] ?? null), $apiKey)['data']; if (!is_array($results) || $results === []) { throw new RuntimeException('The completed job has no results.'); } $saved = 0; foreach ($results as $result) { if (($result['status'] ?? null) !== 'available') { continue; } $path = downloadResult(apiLink($origin, $result['links']['download'] ?? null), $apiKey, $argv[2], $result); echo "Saved: $path\n"; $saved++; } if ($saved === 0) { throw new RuntimeException('No available result files remain to download.'); } } try { main($argv); } catch (Throwable $error) { fwrite(STDERR, $error->getMessage() . PHP_EOL); exit(1); }