267 lines
8.9 KiB
PHP
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);
|