{"ok":true,"source":"api|empty|error","items":[...]} * * source = "api" - są pozycje, strona podmienia tabelę * source = "empty" - API odpowiedziało pustą tablicą * source = "error" - API niedostępne albo brak klucza * W obu ostatnich przypadkach strona zostawia statyczne wiersze z HTML-a. */ $config = require __DIR__ . '/config.php'; header('Content-Type: application/json; charset=utf-8'); header('X-Content-Type-Options: nosniff'); function sched_respond(int $status, array $payload): void { http_response_code($status); echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } function sched_storage(array $config): string { $root = rtrim((string) $config['storage_dir'], '/\\'); $dir = $root . '/cache'; if (!is_dir($dir)) { @mkdir($dir, 0770, true); } $guard = $root . '/.htaccess'; if (!is_file($guard)) { @file_put_contents($guard, "Require all denied\n\nDeny from all\n\n"); } return $dir; } /** @return array{status:int, body:string} */ function sched_get(string $url, string $key, int $timeout): array { $headers = ['Accept: application/json', 'X-API-Key: ' . $key]; if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 6, CURLOPT_TIMEOUT => $timeout, ]); $body = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); return ['status' => $status, 'body' => is_string($body) ? $body : '']; } $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'header' => implode("\r\n", $headers), 'timeout' => $timeout, 'ignore_errors' => true, ], ]); $body = @file_get_contents($url, false, $context); $status = 0; foreach ($http_response_header ?? [] as $line) { if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $m) === 1) { $status = (int) $m[1]; } } return ['status' => $status, 'body' => is_string($body) ? $body : '']; } function sched_text($value, int $max = 300): string { if (!is_string($value)) { return ''; } $value = preg_replace('/[\x00-\x1F\x7F]/u', ' ', $value) ?? ''; $value = trim(preg_replace('/\s{2,}/u', ' ', $value) ?? ''); return mb_substr($value, 0, $max); } /** Link trafia na stronę, więc musi prowadzić do Akademii, nie gdziekolwiek. */ function sched_url($value, array $allowedHosts): ?string { if (!is_string($value) || $value === '') { return null; } $parts = parse_url($value); if (!is_array($parts) || !isset($parts['scheme'], $parts['host'])) { return null; } if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) { return null; } $host = strtolower($parts['host']); foreach ($allowedHosts as $allowed) { $allowed = strtolower((string) $allowed); if ($allowed === '') { continue; } if ($host === $allowed || substr($host, -strlen('.' . $allowed)) === '.' . $allowed) { return $value; } } return null; } // --------------------------------------------------------------------------- if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') { sched_respond(405, ['ok' => false, 'source' => 'error', 'error' => 'method_not_allowed', 'items' => []]); } $limit = isset($_GET['limit']) ? (int) $_GET['limit'] : 12; $limit = max(1, min(40, $limit)); $base = rtrim((string) $config['akademia_api_url'], '/'); $key = (string) $config['akademia_api_key']; if ($base === '' || $key === '') { sched_respond(200, ['ok' => true, 'source' => 'error', 'error' => 'not_configured', 'items' => []]); } $linkHosts = []; $hostFromBase = parse_url($base, PHP_URL_HOST); if (is_string($hostFromBase) && $hostFromBase !== '') { $linkHosts[] = preg_replace('/^www\./', '', $hostFromBase); } // --- Cache ------------------------------------------------------------------ // Dokumentacja Akademii zaleca buforowanie 1-5 minut; dane zmieniają się rzadko. $ttl = (int) $config['akademia_cache_ttl']; $dir = sched_storage($config); $cache = $dir . '/schedule.json'; if ($ttl > 0 && is_file($cache) && (time() - (int) @filemtime($cache)) < $ttl) { $raw = @file_get_contents($cache); if (is_string($raw) && $raw !== '') { $cached = json_decode($raw, true); if (is_array($cached) && isset($cached['items']) && is_array($cached['items'])) { $cached['items'] = array_slice($cached['items'], 0, $limit); $cached['cached'] = true; sched_respond(200, $cached); } } } // --- Pobranie danych -------------------------------------------------------- $items = []; $failed = 0; $webinars = sched_get($base . '/webinars', $key, (int) $config['timeout']); if ($webinars['status'] === 200) { $decoded = json_decode($webinars['body'], true); if (is_array($decoded)) { foreach ($decoded as $row) { if (!is_array($row)) { continue; } $title = sched_text($row['title'] ?? '', 160); $start = sched_text($row['startsAt'] ?? '', 40); if ($title === '' || $start === '') { continue; } $capacity = isset($row['capacity']) && is_numeric($row['capacity']) ? (int) $row['capacity'] : null; $registered = isset($row['registeredCount']) && is_numeric($row['registeredCount']) ? (int) $row['registeredCount'] : 0; $items[] = [ 'kind' => 'webinar', 'title' => $title, 'startsAt' => $start, 'endsAt' => sched_text($row['endsAt'] ?? '', 40), 'timezone' => sched_text($row['timezone'] ?? '', 64), 'url' => sched_url($row['registrationUrl'] ?? null, $linkHosts), 'open' => !isset($row['registrationOpen']) || (bool) $row['registrationOpen'], 'soldOut' => $capacity !== null && $registered >= $capacity, 'seatsLeft' => $capacity !== null ? max(0, $capacity - $registered) : null, ]; } } } else { $failed++; } $courses = sched_get($base . '/courses', $key, (int) $config['timeout']); if ($courses['status'] === 200) { $decoded = json_decode($courses['body'], true); if (is_array($decoded)) { foreach ($decoded as $row) { if (!is_array($row)) { continue; } $title = sched_text($row['title'] ?? '', 160); if ($title === '') { continue; } $level = strtoupper(sched_text($row['level'] ?? '', 20)); $items[] = [ 'kind' => 'course', 'title' => $title, 'subtitle' => sched_text($row['subtitle'] ?? '', 160), 'category' => sched_text($row['category'] ?? '', 80), 'level' => in_array($level, ['BASIC', 'INTERMEDIATE', 'ADVANCED'], true) ? $level : null, 'minutes' => isset($row['durationMinutes']) && is_numeric($row['durationMinutes']) ? (int) $row['durationMinutes'] : null, 'free' => !empty($row['free']), 'url' => sched_url($row['courseUrl'] ?? null, $linkHosts), ]; } } } else { $failed++; } // Webinary z terminem idą pierwsze i rosnąco, kursy na żądanie na końcu. usort($items, static function (array $a, array $b): int { $aDated = $a['kind'] === 'webinar'; $bDated = $b['kind'] === 'webinar'; if ($aDated !== $bDated) { return $aDated ? -1 : 1; } if ($aDated) { return strcmp((string) $a['startsAt'], (string) $b['startsAt']); } return 0; }); $source = 'api'; if ($items === []) { $source = $failed > 0 ? 'error' : 'empty'; } $payload = [ 'ok' => true, 'source' => $source, 'fetchedAt' => gmdate('c'), 'items' => $items, ]; if ($ttl > 0) { @file_put_contents($cache, json_encode($payload, JSON_UNESCAPED_UNICODE), LOCK_EX); // Pusty wynik i błąd trzymamy krócej, żeby świeże pozycje pojawiły się szybko. if ($source !== 'api') { $short = min($ttl, 60); @touch($cache, time() - ($ttl - $short)); } } $payload['items'] = array_slice($payload['items'], 0, $limit); sched_respond(200, $payload);