#!/usr/bin/env php $value) { $request_headers[] = $name . ': ' . $value; } if ($payload !== null) { $request_headers[] = 'Content-Length: ' . strlen($payload); } for ($retry_number = 0; ; $retry_number++) { $remaining = $deadline === null ? 120.0 : $deadline - monotonicTime(); if ($remaining <= 0) { throw new PreparationTimeout(); } $options = [ 'method' => $method, 'header' => implode("\r\n", $request_headers), 'ignore_errors' => true, 'follow_location' => 0, 'max_redirects' => 0, 'timeout' => min(120.0, $remaining), 'protocol_version' => 1.1, ]; if ($payload !== null) { $options['content'] = $payload; } $context = stream_context_create([ 'http' => $options, 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true], ]); $http_response_header = []; $response = @file_get_contents($base_url . $path, false, $context); if ($deadline !== null && monotonicTime() >= $deadline) { throw new PreparationTimeout(); } if ($response === false) { if ($retry_number >= 4) { throw new RuntimeException('Network request failed after retries.'); } waitToRetry($retry_number, $deadline); continue; } $status = 0; foreach ($http_response_header as $header) { if (preg_match('/^HTTP\/\S+\s+(\d{3})\b/', $header, $match)) { $status = (int) $match[1]; } } if ($status >= 300 && $status <= 399) { throw new RuntimeException('HTTP ' . $status . ': Redirects are not allowed.'); } if ($retry_number < 4 && in_array($status, [409, 429, 500, 502, 503, 504], true)) { waitToRetry($retry_number, $deadline); continue; } $result = json_decode($response, true, 512, JSON_THROW_ON_ERROR); if (!is_array($result)) { throw new RuntimeException('The API returned a JSON value instead of an object.'); } if ($status >= 200 && $status <= 299) { return $result; } throw new RuntimeException('HTTP ' . $status . ': ' . ($result['error'] ?? 'Request failed.')); } } try { $filename = $argv[1] ?? null; if ($filename === null) { throw new RuntimeException('Usage: php ' . basename($argv[0]) . ' path/to/course.zip'); } $api_key = getenv('SCORMBOX_API_KEY'); $api_secret = getenv('SCORMBOX_API_SECRET'); $idempotency_key = getenv('SCORMBOX_IDEMPOTENCY_KEY'); if ($api_key === false || $api_key === '' || $api_secret === false || $api_secret === '' || $idempotency_key === false || $idempotency_key === '') { throw new RuntimeException('Set SCORMBOX_API_KEY, SCORMBOX_API_SECRET and a stable SCORMBOX_IDEMPOTENCY_KEY for this content version.'); } $base_url = getenv('SCORMBOX_API_BASE_URL'); $base_url = rtrim($base_url === false || $base_url === '' ? 'https://scormbox.com/api/v1' : $base_url, '/'); $service = parse_url($base_url); $scheme = strtolower($service['scheme'] ?? ''); $host = strtolower($service['host'] ?? ''); if ($host === '' || ($scheme !== 'https' && !($scheme === 'http' && $host === 'localhost'))) { throw new RuntimeException('Use HTTPS for server credentials, or HTTP localhost for development.'); } if (isset($service['user']) || isset($service['pass']) || isset($service['query']) || isset($service['fragment'])) { throw new RuntimeException('The API base URL must not include credentials, a query, or a fragment.'); } if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL) || !in_array('https', stream_get_wrappers(), true)) { throw new RuntimeException('Enable allow_url_fopen and HTTPS/OpenSSL support in PHP.'); } $authorization = 'Basic ' . base64_encode($api_key . ':' . $api_secret); $size = filesize($filename); if ($size === false) { throw new RuntimeException('Could not read the package size.'); } $options = ['filename' => basename($filename), 'size' => $size, 'shareable' => getenv('SCORMBOX_SHAREABLE') === 'true']; $title = getenv('SCORMBOX_TITLE'); if ($title !== false && $title !== '') { $options['title'] = $title; } $course = request($base_url, $authorization, '/courses', 'POST', $options, ['Idempotency-Key' => $idempotency_key]); if (in_array($course['status'], ['awaiting_upload', 'uploading'], true)) { $upload = $course['upload']; $upload_path = '/uploads/' . rawurlencode($upload['upload_id']); $part_size = $upload['part_size']; $part_count = $upload['part_count']; if (!is_int($part_size) || $part_size <= 0 || !is_int($part_count) || $part_count !== intdiv($size + $part_size - 1, $part_size)) { throw new RuntimeException('The API returned invalid upload part sizes.'); } $saved_parts = array_fill_keys(array_column($upload['parts'], 'part_number'), true); $file = fopen($filename, 'rb'); if ($file === false) { throw new RuntimeException('Could not open the package.'); } try { for ($part_number = 1; $part_number <= $part_count; $part_number++) { if (isset($saved_parts[$part_number])) { continue; } $offset = ($part_number - 1) * $part_size; $length = min($part_size, $size - $offset); if (fseek($file, $offset) !== 0) { throw new RuntimeException('Could not seek in the package.'); } $bytes = ''; while (strlen($bytes) < $length) { $chunk = fread($file, $length - strlen($bytes)); if ($chunk === false || $chunk === '') { throw new RuntimeException('The package changed while being read.'); } $bytes .= $chunk; unset($chunk); } request($base_url, $authorization, $upload_path . '/parts/' . $part_number, 'PUT', $bytes); unset($bytes); echo 'Uploaded part ' . $part_number . ' of ' . $part_count . PHP_EOL; } } finally { fclose($file); } request($base_url, $authorization, $upload_path . '/complete', 'POST', new stdClass()); } $course_id = $course['course_id']; $deadline = monotonicTime() + 20 * 60; while (true) { $course = request($base_url, $authorization, '/courses/' . rawurlencode($course_id), deadline: $deadline); if ($course['status'] === 'ready') { break; } if ($course['status'] === 'failed') { throw new RuntimeException($course['error'] ?? 'Package preparation failed.'); } $preparation = $course['preparation']; echo 'Course ' . $course['status'] . ': ' . $preparation['completed_files'] . ' of ' . $preparation['total_files'] . ' files' . PHP_EOL; $remaining = $deadline - monotonicTime(); if ($remaining <= 0) { throw new PreparationTimeout(); } usleep((int) (min(2, $remaining) * 1_000_000)); } echo json_encode(['course_id' => $course['course_id'], 'format' => $course['format'], 'management_url' => $course['management_url'], 'learner_url' => $course['learner_url']], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL; } catch (PreparationTimeout $error) { fwrite(STDERR, 'Preparation is still running. Check course_id ' . $course_id . ' before trying again.' . PHP_EOL); exit(1); } catch (Throwable $error) { fwrite(STDERR, $error->getMessage() . PHP_EOL); exit(1); }