API
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
<?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);
|
||||
Reference in New Issue
Block a user