Files
2026-09-03 21:57:48 +02:00

267 lines
8.9 KiB
PHP

<?php
declare(strict_types=1);
/**
* SoftSPM - harmonogram szkoleń i webinariów.
*
* Pośrednik do publicznego API Akademii Wodociągowca
* (dokumentacja: akademiawodociagowca/docs/api_public_v1.html).
* Klucz X-API-Key zostaje na serwerze - dokumentacja Akademii wprost tego
* wymaga (punkt 9: "Trzymaj klucz po stronie serwera").
*
* GET api/schedule.php[?limit=8]
* -> {"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<IfModule !mod_authz_core.c>\nDeny from all\n</IfModule>\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);