This commit is contained in:
2026-09-03 21:57:48 +02:00
parent 496e8bdb27
commit 6d31656ce4
13 changed files with 1090 additions and 51 deletions
+63 -2
View File
@@ -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
View File
@@ -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)) {
+266
View File
@@ -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);