API
This commit is contained in:
+63
-2
@@ -43,6 +43,30 @@ return [
|
||||
];
|
||||
```
|
||||
|
||||
### Akademia Wodociągowca (harmonogram w sekcji 04)
|
||||
|
||||
Tabela terminów na stronie głównej i na `szkolenia.html` pobiera webinary
|
||||
i kursy z publicznego API Akademii. Dopisz klucz wygenerowany w panelu
|
||||
Akademii (Ustawienia -> Klucze API - pokazywany tylko raz):
|
||||
|
||||
```php
|
||||
// wariant ze stałymi
|
||||
define('SOFTSPM_AKADEMIA_API_KEY', 'tutaj_klucz_z_panelu_akademii');
|
||||
|
||||
// wariant z tablicą
|
||||
'softspm_akademia_api_key' => 'tutaj_klucz_z_panelu_akademii',
|
||||
```
|
||||
|
||||
Adres API domyślnie to `https://akademiawodociagowca.pl/api/public/v1`.
|
||||
Zmieniasz go tylko wtedy, gdy instancja stoi pod innym adresem:
|
||||
|
||||
```php
|
||||
define('SOFTSPM_AKADEMIA_API_URL', 'https://inna-domena/api/public/v1');
|
||||
```
|
||||
|
||||
Bez tego klucza nic się nie psuje: `api/schedule.php` zwraca
|
||||
`source: "error"`, a w tabeli zostają statyczne wiersze wpisane w HTML.
|
||||
|
||||
### Wpisy opcjonalne
|
||||
|
||||
```php
|
||||
@@ -145,7 +169,44 @@ szablon endpointu nie zamienia `\n` na `<br>`. `send_email_api.php` jest wasz,
|
||||
więc najczystszą poprawką jest tam jedno `nl2br()` na sanityzowanej treści -
|
||||
nic po stronie tej strony nie wymaga wtedy zmian.
|
||||
|
||||
## 5. Zabezpieczenia przed botami
|
||||
## 5. Harmonogram z Akademii Wodociągowca
|
||||
|
||||
[schedule.php](schedule.php) pobiera `GET /webinars` i `GET /courses` z API
|
||||
Akademii (dokumentacja: `akademiawodociagowca/docs/api_public_v1.html`), scala
|
||||
je w jedną listę i oddaje stronie jako JSON. Klucz `X-API-Key` zostaje na
|
||||
serwerze - punkt 9 dokumentacji Akademii wprost tego wymaga.
|
||||
|
||||
- **Cache:** odpowiedź leży w `api/storage/cache/schedule.json` przez
|
||||
`akademia_cache_ttl` (domyślnie 300 s, zgodnie z zaleceniem 1-5 minut).
|
||||
Pusty wynik i błąd trzymane są krócej, maksymalnie minutę.
|
||||
- **Kolejność:** webinary z terminem rosnąco, potem kursy na żądanie.
|
||||
- **Linki:** akceptujemy wyłącznie adresy prowadzące do domeny Akademii -
|
||||
gdyby API zwróciło cokolwiek innego, link jest pomijany.
|
||||
- **Limit:** `?limit=` (1-40). Strona główna prosi o 8 pozycji,
|
||||
`szkolenia.html` o 12.
|
||||
|
||||
Odpowiedź ma pole `source`:
|
||||
|
||||
| `source` | Znaczenie | Co widzi użytkownik |
|
||||
|---|---|---|
|
||||
| `api` | Są pozycje | Tabela, nagłówki i notatka podmienione na dane z Akademii; kolumna wraca z „Termin" na „Data" |
|
||||
| `empty` | API odpowiedziało pustą tablicą | Statyczne wiersze z HTML-a zostają nietknięte |
|
||||
| `error` | Brak klucza albo API nie odpowiada | Jak wyżej |
|
||||
|
||||
Statyczne wiersze to pełnoprawny plan awaryjny: działają bez JavaScriptu,
|
||||
bez klucza i przy niedostępnym API. Strona nigdy nie pokazuje pustej tabeli.
|
||||
Są w `index.html` (8 pozycji) i `szkolenia.html` (11 pozycji) - jeśli chcesz
|
||||
zmienić te tematy zastępcze, edytujesz je wprost w HTML.
|
||||
|
||||
Test:
|
||||
|
||||
```bash
|
||||
curl -s "https://softspm.com/api/schedule.php?limit=5" | head -c 400
|
||||
```
|
||||
|
||||
Oczekiwane: `{"ok":true,"source":"api",...}` z tablicą `items`.
|
||||
|
||||
## 6. Zabezpieczenia przed botami
|
||||
|
||||
Suwak sam z siebie niczego nie gwarantuje - jest elementem interfejsu, a nie
|
||||
kryptografią. Blokady, które faktycznie odsiewają automaty, działają na
|
||||
@@ -166,7 +227,7 @@ Jeśli mimo to zacznie przechodzić spam, następnym krokiem jest Cloudflare
|
||||
Turnstile albo hCaptcha - obie działają na tej samej zasadzie co obecny token,
|
||||
więc podmiana dotknie tylko `send.php` i `assets/forms.js`.
|
||||
|
||||
## 6. Test po wdrożeniu
|
||||
## 7. Test po wdrożeniu
|
||||
|
||||
```bash
|
||||
# 1. Token - powinien zwrócić {"ok":true,"token":"t.szkolenia...."}
|
||||
|
||||
+25
-2
@@ -26,6 +26,14 @@ $config = [
|
||||
// Dowolny losowy ciąg, min. 32 znaki. Zmiana unieważnia otwarte formularze.
|
||||
'token_secret' => '',
|
||||
|
||||
// --- Akademia Wodociągowca: publiczne API v1 (webinary i kursy) --------
|
||||
// Dokumentacja: akademiawodociagowca/docs/api_public_v1.html
|
||||
// Klucz z panelu Akademii: Ustawienia -> Klucze API. Pokazywany raz.
|
||||
'akademia_api_url' => 'https://akademiawodociagowca.pl/api/public/v1',
|
||||
'akademia_api_key' => '',
|
||||
// Bufor odpowiedzi w sekundach. Dokumentacja Akademii zaleca 1-5 minut.
|
||||
'akademia_cache_ttl' => 300,
|
||||
|
||||
// --- Adresat zgłoszeń --------------------------------------------------
|
||||
// Ustawiany wyłącznie po stronie serwera. Nigdy nie pochodzi z formularza -
|
||||
// inaczej endpoint stałby się otwartym przekaźnikiem spamu.
|
||||
@@ -68,8 +76,10 @@ $config = [
|
||||
|
||||
// --- 1. Zmienne środowiskowe ------------------------------------------------
|
||||
$fromEnv = [
|
||||
'api_key' => getenv('SOFTSPM_MAIL_API_KEY'),
|
||||
'token_secret' => getenv('SOFTSPM_FORM_SECRET'),
|
||||
'api_key' => getenv('SOFTSPM_MAIL_API_KEY'),
|
||||
'token_secret' => getenv('SOFTSPM_FORM_SECRET'),
|
||||
'akademia_api_key' => getenv('SOFTSPM_AKADEMIA_API_KEY'),
|
||||
'akademia_api_url' => getenv('SOFTSPM_AKADEMIA_API_URL'),
|
||||
];
|
||||
foreach ($fromEnv as $key => $value) {
|
||||
if (is_string($value) && $value !== '') {
|
||||
@@ -133,6 +143,19 @@ if (is_file($sharedPath) && is_readable($sharedPath)) {
|
||||
}
|
||||
}
|
||||
|
||||
// Klucz do publicznego API Akademii Wodociągowca.
|
||||
if ($config['akademia_api_key'] === '') {
|
||||
$value = $pick('SOFTSPM_AKADEMIA_API_KEY', 'softspm_akademia_api_key');
|
||||
if (is_string($value)) {
|
||||
$config['akademia_api_key'] = $value;
|
||||
}
|
||||
}
|
||||
// Adres API Akademii - tylko gdy instancja stoi pod innym adresem.
|
||||
$value = $pick('SOFTSPM_AKADEMIA_API_URL', 'softspm_akademia_api_url');
|
||||
if (is_string($value)) {
|
||||
$config['akademia_api_url'] = $value;
|
||||
}
|
||||
|
||||
// Adresat zgłoszeń - jeden dla wszystkich formularzy.
|
||||
$value = $pick('SOFTSPM_FORM_RECIPIENT', 'softspm_form_recipient');
|
||||
if (is_string($value)) {
|
||||
|
||||
@@ -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