<?php
// Front controller — routing pro frontend (šablona nad DB).
// Lokálně: php -S 127.0.0.1:8100 -t public public/index.php   (?site=kod pro výběr mutace)

// statické soubory (assets) nechá servírovat vestavěný server
if (php_sapi_name() === 'cli-server') {
  $f = __DIR__ . parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
  if (is_file($f)) return false;
}

// Poslední záchrana: neodchycená výjimka (typicky nedostupná db1) končila bílou
// stránkou — server má display_errors=Off, takže čtenář neviděl vůbec nic a nginx
// neměl důvod sáhnout po staré kopii z mikrocache. Teď 503 + Retry-After + krátká
// omluva, která si na vykreslení nesahá na databázi. (odolnost 29. 7.)
set_exception_handler(function (Throwable $e) {
  error_log('drbna-cms: neodchycená výjimka: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
  if (!headers_sent()) {
    http_response_code(503);
    header('Retry-After: 30');
    header('Cache-Control: no-store');
    header('Content-Type: text/html; charset=utf-8');
  }
  $f = __DIR__ . '/../app/error-503.html';
  echo is_file($f) ? file_get_contents($f) : '<h1>Omlouváme se</h1><p>Web je krátkodobě nedostupný. Zkuste to prosím za chvíli.</p>';
});

require __DIR__ . '/../app/render.php';

// Měření pomalých požadavků. Stojí hned za načtením aplikace, aby se do
// času vešlo úplně všechno; čas startu se stejně bere z REQUEST_TIME_FLOAT.
mereniZapni();

sendSecurityHeaders();   // bezpečnostní hlavičky (SEO audit + OWASP) na všechny odpovědi

$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?: '/';

// ADMIN — vlastní dispatcher (přihlášení, editor článků, audit)
// Cesta je konfigurovatelná (cfg admin_path). Uvnitř dispatcher pracuje s '/admin'.
$adminBase = '/' . trim((string)(cfg()['admin_path'] ?? 'admin'), '/');
if ($path === $adminBase || strpos($path, $adminBase . '/') === 0) {
  require __DIR__ . '/../app/admin.php';
  adminDispatch('/admin' . substr($path, strlen($adminBase)));
  return;
}

// /health — veřejný JSON stav serveru (pro monitory a vzájemné hlídání serverů). Bez tajemství.
// on-the-fly zmenšeniny fotek (public/t/... pak servíruje nginx staticky)
if (preg_match('~^/t/([a-z0-9]+)/(.+)$~', $path, $m)) {
  require_once __DIR__ . '/../app/thumbs.php';
  thumbServe($m[1], $m[2]); return;
}
if ($path === '/health') {
  require __DIR__ . '/../app/health.php';
  // Token se ověřuje PŘED spuštěním kontrol, ne až u výpisu. Plná sada je 27
  // kontrol včetně síťových volání a desítek dotazů — kdokoli z internetu ji
  // takhle mohl opakovaně spouštět zadarmo. Anonymní požadavek proto dostane
  // levný stav (snímek cronu + ťuknutí do DB), plnou diagnostiku jen ten,
  // kdo předloží `health_token`. (nález FT SUN, 11. 8. 2026)
  $tok = (string)(cfg()['health_token'] ?? '');
  $given = (string)($_SERVER['HTTP_X_HEALTH_TOKEN'] ?? ($_GET['token'] ?? ''));
  $full = $tok !== '' && hash_equals($tok, $given);
  $h = $full ? runHealthChecks() : healthStatusLevne();
  header('Content-Type: application/json; charset=utf-8');
  header('Cache-Control: no-store');
  // HTTP 503 jen při selhání INFRASTRUKTURY (HEALTH_INFRA v app/health.php) —
  // provozní `fail` (stížnosti na newsletter apod.) zůstává v těle a v
  // Diagnostice, ale vnější monitor kvůli němu nemá vidět „web leží". Kvůli
  // míře stížností stálo /health 24 h na 503, ačkoli weby jely. (15. 8. 2026)
  http_response_code(!empty($h['infra_fail']) ? 503 : 200);
  // Veřejně JEN stav — detaily (volné GB, počet CPU, load, RAM, uptime db1) jsou
  // průzkum pro útočníka. Dead-man's switch protějšku čte jen 'status'.
  echo json_encode($full ? $h : ['status' => $h['status'], 'time' => $h['time']], JSON_UNESCAPED_UNICODE);
  return;
}

/* ---------- API pro ostatní naše systémy ----------
   VERZE V ADRESE: `/api/v1/…` je od 17. 8. 2026 kanonický tvar, `/api/…` bez
   verze zůstává funkční jako alias. Důvod je prostý: klientů začne přibývat
   (dnes Informační systém a mobilní aplikace, k tomu přijde CRM) a bez verze
   v adrese znamená každá změna tvaru odpovědi vyjednávání se všemi naráz.
   S verzí se dá `/api/v2/…` postavit vedle a klienti přejdou, až se jim to hodí.

   Alias se NERUŠÍ dokud na něm někdo visí — Informační systém je živý klient
   a přepnout ho patří do jeho vlastního nasazení, ne do tohohle. Odpověď na
   staré adrese nese hlavičku `X-Api-Zastarale`, takže se pozná, kdo ještě
   nepřešel, aniž by mu cokoli přestalo fungovat. */
$apiCesta = str_starts_with($path, '/api/v1/') ? '/api/' . substr($path, 8) : $path;
$apiStara = $apiCesta !== $path ? false : str_starts_with($path, '/api/');

/** Hlavičky o verzi API. Musí jít ven dřív, než endpoint začne psát tělo. */
function apiVerzeHlavicky(bool $stara): void {
  if (headers_sent()) return;
  header('X-Api-Verze: 1');
  if ($stara) header('X-Api-Zastarale: adresa bez verze, pouzij /api/v1/ (stara zustava funkcni)');
}

// /api/v1/is/… — čtecí API pro Informační systém (is.drbna.cz).
//
// Stojí NAD currentSite() schválně: odpovídá za CELOU síť, ne za jednu mutaci,
// takže je jedno, na které doméně se na něj někdo zeptá. Klíč se čte z hlavičky,
// takže se nemá jak dostat do access logu nginxu.
if ($apiCesta === '/api/is/clanky' || $apiCesta === '/api/is/weby' || $apiCesta === '/api/is/fb-posty') {
  require __DIR__ . '/../app/api-is.php';
  apiVerzeHlavicky($apiStara);
  if ($apiCesta === '/api/is/clanky')      isApiClanky();
  elseif ($apiCesta === '/api/is/weby')    isApiWeby();
  else                                     isApiFbPosty();
  return;
}

// /api/v1/crm/koncept — CRM zakládá koncept článku z podkladů obchodu.
//
// Taky NAD currentSite(): web si říká v těle požadavku, ne doménou, na kterou
// se CRM trefí. Vlastní konzument (`crm_api_key`), takže se dá odebrat bez
// dopadu na Informační systém — a naopak klíč IS sem nesmí (kontrola uvnitř).
if ($apiCesta === '/api/crm/koncept') {
  require __DIR__ . '/../app/api-crm.php';
  apiVerzeHlavicky($apiStara);
  crmApiKoncept();
  return;
}

// /api/v1/diagnostika — poslední snímek kontrol pro mobilní aplikaci.
//
// Taky NAD currentSite(): stav hlásí server, ne jedna mutace. Vlastní klíč
// (`diag_api_key`), aby odebrání přístupu telefonu neshodilo Informační systém.
if ($apiCesta === '/api/diagnostika' || $apiCesta === '/api/diagnostika/odber') {
  require __DIR__ . '/../app/api-diag.php';
  apiVerzeHlavicky($apiStara);
  if ($apiCesta === '/api/diagnostika') diagApiStav();
  else                                  diagApiOdber();
  return;
}

/* ---------- Vlastní obsah do neprodaných reklamních pozic (house ads) ----------
   Stojí NAD currentSite(): volá se z reklamní kreativy, která běží na cizí
   doméně Googlu, a web se pozná z parametru `host`, ne z hlavičky. Kdyby to
   šlo přes currentSite(), skončil by požadavek na „Neznámá doména“.
   Odpověď nenese cookie, takže ji mikrocache normálně uloží. */
if ($path === '/promo' || $path === '/promo.json') {
  require_once __DIR__ . '/../app/promo.php';
  $ws = promoWeb((string)($_GET['host'] ?? '') ?: (string)($_SERVER['HTTP_REFERER'] ?? ''));
  // `poradi` = kolikátá house pozice na TÉŽE STRÁNCE se ptá. Číslo si kreativa
  // zvedne v sessionStorage ještě před dotazem, takže dvě pozice načtené naráz
  // dostanou různá čísla. Server je bez stavu a odpověď leží minutu v mikrocache,
  // takže by souběh sám nepoznal — a na stránce by svítilo dvakrát totéž.
  // `s`/`v` = rozměr pozice v CSS pixelech, který si kreativa změřila sama.
  // Podle něj se rozhodne, kolik dlaždic se do pozice vejde. Když nepřijde
  // (starší kreativa v Ad Manageru), vyjde jedna — tedy stav před touhle
  // změnou, takže nic z toho, co běží, se nerozbije.
  $rozvrzeni = promoRozvrzeni((int)($_GET['s'] ?? 0), (int)($_GET['v'] ?? 0));
  $polozky = promoObsahVice($ws, (int)($_GET['poradi'] ?? 0), $rozvrzeni['pocet']);
  $o = $polozky[0];
  // Kreativa běží na cizím původu, takže si ji musí smět stáhnout: veřejná data,
  // žádné cookie, žádný uživatel. A `frame-ancestors 'self'` ze
  // sendSecurityHeaders() by variantu v iframu zablokoval — tady se přepisuje.
  header('Access-Control-Allow-Origin: *');
  header('Content-Security-Policy: frame-ancestors *');
  header_remove('X-Frame-Options');   // vložení se povolí tím, že se hlavička NEpošle (žádná „povolující“ hodnota neexistuje)
  header('Cache-Control: public, max-age=60');
  if ($path === '/promo.json') {
    header('Content-Type: application/json; charset=utf-8');
    // Kořen odpovědi zůstává první dlaždicí (`titulek`, `odkaz`, …), ať starší
    // kreativa, která si z JSON bere jen fragment, nepozná rozdíl. Celý seznam
    // je navíc v `dlazdice` pro případ, že by si ho někdo chtěl vykreslit sám.
    echo json_encode($o + [
      'dlazdice' => $polozky,
      'pocet'    => count($polozky),
      'fragment' => promoFragment($ws, $polozky, $rozvrzeni),
      'html'     => promoHtml($ws, $polozky, $rozvrzeni),
    ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  } else {
    header('Content-Type: text/html; charset=utf-8');
    echo promoHtml($ws, $polozky, $rozvrzeni);
  }
  return;
}

$site = currentSite();

// Úklid cookies po doběhlých anketách — vrací čtenáře do mikrocache.
//
// `poll_<id>` se zakládá s path=/ a vhost na prefix `poll_` obchází mikrocache
// (deploy/nginx-app1.conf), takže jediný hlas vyřadí čtenáře z cache na CELÉ síti
// a dosud to trvalo půl roku. Dokud anketa běží, je to nutná daň za správnost:
// podle té cookie se vykresluje buď formulář, nebo výsledky, a bez obcházení by
// cache podstrčila jednu variantu druhému čtenáři (past z 5. 8. 2026). Jakmile je
// anketa po termínu, cookie nemění nic — pollEnded() ukáže výsledky všem a
// pollVote() další hlas odmítne — takže je správné ji zahodit.
//
// Dotaz běží JEN požadavkům, které tou cookií stejně jedou plný render, a je to
// jedno vyhledání přes primární klíč. Odpověď se Set-Cookie nginx neukládá (žádné
// `fastcgi_ignore_headers`), takže se úklid nemůže podstrčit jinému čtenáři.
// (audit 15. 8. 2026; na produkci leželo 1,88 milionu hlasů)
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET' && !headers_sent()) {
  $ankety = [];
  foreach (array_keys($_COOKIE) as $jm) {
    if (strncmp((string)$jm, 'poll_', 5) === 0 && ctype_digit(substr((string)$jm, 5))) $ankety[] = (int)substr((string)$jm, 5);
  }
  // Strop na počet hlaviček Set-Cookie v jedné odpovědi; zbytek se uklidí příště.
  $ankety = array_slice($ankety, 0, 25);
  if ($ankety) {
    $zive = db()->query('SELECT id FROM polls WHERE id IN (' . implode(',', $ankety) . ')
                         AND (vote_until IS NULL OR vote_until >= NOW())')->fetchAll(PDO::FETCH_COLUMN);
    foreach (array_diff($ankety, array_map('intval', $zive)) as $mrtva) {
      setcookie('poll_' . $mrtva, '', ['expires' => time() - 86400, 'path' => '/', 'samesite' => 'Lax']);
    }
  }
}

// 301/302 přesměrování (migrace starých URL). Web-specifické má přednost před globálním.
$rd = db()->prepare("SELECT id,to_url,code FROM redirects WHERE from_path=? AND (site_id=? OR site_id IS NULL) ORDER BY site_id IS NULL LIMIT 1");
$rd->execute([$path, $site['id']]);
// Systémové cesty přesměrovat nejde — viz redirectPathOk(). Kontrola je i při
// zadávání v administraci, ale tahle je ta rozhodující: pravidla přišla importem
// a v databázi můžou ležet dál.
if (($r = $rd->fetch()) && redirectPathOk($path)) {
  db()->prepare("UPDATE redirects SET hits=hits+1 WHERE id=?")->execute([$r['id']]);
  $cil = (string)$r['to_url'];
  // Na hřišti musí přesměrování nést web s sebou. Všech dvanáct Drben tam běží
  // na jedné doméně a přepínají se ?site= — bez toho skončí skok na výchozím
  // webu, ten cílovou stránku nemá a vrátí 404. Na ostrém webu nese web doména,
  // takže se nepřipojuje nic. Absolutních adres se to netýká. (6. 8. 2026)
  if (!empty(cfg()['staging']) && $cil !== '' && $cil[0] === '/' && !str_contains($cil, 'site=')) {
    $cil .= (str_contains($cil, '?') ? '&' : '?') . 'site=' . rawurlencode((string)$site['code']);
  }
  header('Location: ' . $cil, true, (int)$r['code'] === 302 ? 302 : 301);
  return;
}

// hlasování v anketě (public) — cookie brání běžnému opakování
if ($path === '/poll/vote' && $_SERVER['REQUEST_METHOD'] === 'POST') {
  $pollId = (int)($_POST['poll'] ?? 0); $opt = (int)($_POST['option'] ?? 0);
  if ($pollId && $opt && !pollHasVoted($pollId) && pollVote($pollId, $opt)) {
    // Cookie nesmí přežít anketu, které se týká. Jde s path=/ na KAŽDÝ požadavek
    // a vhost na prefix `poll_` obchází mikrocache, takže jeden hlas znamenal
    // plný render všeho, co čtenář na síti otevře, dalšího půl roku (naměřeno
    // 15. 8. 2026 na app1: 5 ms z cache proti 20 ms plným renderem). Po termínu
    // `vote_until` přitom neřídí NIC: renderPoll() ukazuje přes pollEnded()
    // výsledky všem a pollVote() další hlas stejně odmítne. Anketa bez termínu
    // běží dál, tam musí zůstat původního půl roku.
    $vu = db()->prepare('SELECT vote_until FROM polls WHERE id=?');
    $vu->execute([$pollId]);
    $konec = strtotime((string)($vu->fetchColumn() ?: '')) ?: 0;
    $plati = $konec > time() ? min($konec + 2 * 86400, time() + 180 * 86400) : time() + 180 * 86400;
    setcookie('poll_' . $pollId, '1', ['expires' => $plati, 'path' => '/', 'samesite' => 'Lax']);
  }
  $back = safeBack($_POST['back'] ?? '/');
  header('Location: ' . $back); return;
}

// A/B počítadla (beacon z článku): zobrazení, proklik do galerie, čas na stránce.
// Bez identity — jen tři čísla na variantu a den. Rate-limit je tu proti tomu,
// aby jeden prohlížeč (nebo skript) neposunul výsledek testu.
if ($path === '/ab/udalost' && $_SERVER['REQUEST_METHOD'] === 'POST') {
  if (!rateOk('ab:' . ($_SERVER['REMOTE_ADDR'] ?? '?'), 240, 60)) { http_response_code(429); return; }
  abUdalostEndpoint(); return;
}

// hodnocení článku (AJAX) — vrátí počty; jeden hlas na otisk + cookie proti opakování
if ($path === '/clanek/hodnoceni' && $_SERVER['REQUEST_METHOD'] === 'POST') {
  header('Content-Type: application/json; charset=utf-8');
  $aid = (int)($_POST['a'] ?? 0); $ch = (int)($_POST['c'] ?? -1);
  if (!$aid || $ch < 0 || $ch > 5 || !rateOk('rate:' . ($_SERVER['REMOTE_ADDR'] ?? '?'), 40, 60)) { echo '{"ok":false}'; return; }
  $counts = articleRate($aid, $ch);
  if ($counts === null) { echo '{"ok":false}'; return; }
  echo json_encode(['ok' => true, 'counts' => $counts, 'total' => array_sum($counts)]);
  return;
}

// odeslání formuláře (public) — honeypot + uložení, pak zpět s poděkováním
if ($path === '/form/submit' && $_SERVER['REQUEST_METHOD'] === 'POST') {
  $formId = (int)($_POST['form'] ?? 0);
  $ok = $formId && rateOk('frm:' . ($_SERVER['REMOTE_ADDR'] ?? '?'), 15, 60) ? formSubmit($formId, $_POST) : false;
  $back = safeBack($_POST['back'] ?? '/');
  if ($ok) { $sep = strpos($back, '?') !== false ? '&' : '?'; $back .= $sep . 'form_sent=' . $formId . '#form-' . $formId; }
  header('Location: ' . $back); return;
}

// proklik banneru — započítá klik a přesměruje na cíl
if (preg_match('~^/b/(\d+)(?:-(\d+))?$~', $path, $m)) {
  // Počítadlo prokliků se dalo nafouknout opakovaným voláním adresy — a čísla
  // z něj vidí inzerent. Přesměrování proběhne vždycky (odkaz musí fungovat
  // i z e-mailu nebo z cizí stránky), počítá se ale jen proklik z našeho webu
  // a nejvýš pár za minutu z jedné adresy. (audit 5. 8. 2026)
  $puvodH = (string)(parse_url((string)($_SERVER['HTTP_REFERER'] ?? ''), PHP_URL_HOST) ?: '');
  $zNaseho = $puvodH !== '' && $puvodH === (string)($_SERVER['HTTP_HOST'] ?? '');
  // Číslo za pomlčkou je kreativa: jedna kampaň jich smí mít víc a každá může
  // vést jinam. Chybí-li, použije se cíl kampaně jako dřív.
  $to = bannerClick((int)$m[1], $zNaseho && rateOk('bklik:' . (int)$m[1] . ':' . ($_SERVER['REMOTE_ADDR'] ?? '?'), 5, 1), (int)($m[2] ?? 0));
  header('Location: ' . ($to ?: '/')); return;
}

// newsletter — přihlášení (double opt-in), potvrzení, odhlášení
if ($path === '/newsletter/signup' && $_SERVER['REQUEST_METHOD'] === 'POST') {
  if (!empty($_POST['company'])) { header('Location: /?site=' . $site['code']); return; }   // honeypot
  if (!rateOk('nl:' . ($_SERVER['REMOTE_ADDR'] ?? '?'), 5, 60)) {
    $r = ['ok' => false, 'msg' => 'Příliš mnoho pokusů — zkus to prosím později.'];
    if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'fetch') { header('Content-Type: application/json'); echo json_encode($r, JSON_UNESCAPED_UNICODE); return; }
    renderMessagePage($site, 'Nepovedlo se', $r['msg']); return;
  }
  $sid = (int)($_POST['site'] ?? $site['id']);
  $r = ($_POST['list'] ?? '') === 'all'
    ? nlSignupAll($sid, (string)($_POST['email'] ?? ''), $_POST['name'] ?? null, false)
    : nlSignup($sid, (string)($_POST['email'] ?? ''), $_POST['name'] ?? null, (string)($_POST['list'] ?? 'weekly'), false);
  // Potvrzovací e-mail odchází až po odpovědi — do JSONu se uzávěra nesmí dostat.
  $potvrzeni = $r['potvrzeni'] ?? null; unset($r['potvrzeni']);
  // AJAX z lišty → JSON (potvrzení bez přesměrování); klasický POST → stránka (fallback bez JS)
  if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'fetch') {
    header('Content-Type: application/json'); echo json_encode($r, JSON_UNESCAPED_UNICODE);
    if ($potvrzeni) poOdpovedi($potvrzeni);
    return;
  }
  renderMessagePage($site, $r['ok'] ? 'Přihlášení k odběru' : 'Nepovedlo se', $r['msg']);
  if ($potvrzeni) poOdpovedi($potvrzeni);
  return;
}
// sekce Aktuálně (všechny články chronologicky) a Výběr šéfredaktora
if ($path === '/aktualne' || $path === '/aktualne.html') {
  renderListing($site, 'Aktuálně', "a.content_type<>'seo'", '/aktualne.html'); return;   // SEO články se ve výpisu neukazují
}
if ($path === '/vyber-sefredaktora' || $path === '/vyber-sefredaktora.html') {
  renderListing($site, 'Výběr šéfredaktora', 'a.is_editor_pick=1', '/vyber-sefredaktora.html'); return;
}
if ($path === '/komercni-clanky' || $path === '/komercni-clanky.html') {
  renderListing($site, 'Komerční obsah', "a.content_type='pr'", '/komercni-clanky.html'); return;
}
// potvrzení odběru — stránka s tlačítkem (GET) + provedení (POST).
// GET schválně nic nemění: skenery odkazů (antivirus, Outlook Safe Links,
// firemní brány) proklikávají maily za adresáta, takže by souhlas vznikal
// lidem, kteří o něm nevědí. Stejný postup jako u odhlášení níž.
if ($path === '/newsletter/confirm') {
  $token = (string)($_POST['t'] ?? $_GET['token'] ?? $_GET['t'] ?? '');
  if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!empty($_POST['znovu'])) {
      $ok = nlConfirmZnovu($token);
      renderMessagePage($site, $ok ? 'Nový e-mail je na cestě' : 'Odkaz neplatí',
        $ok ? 'Poslali jsme nový potvrzovací e-mail — mrkni do schránky.' : 'Odkaz je neplatný nebo už byl použit.'); return;
    }
    $ok = nlConfirm($token);
    renderMessagePage($site, $ok ? 'Odběr potvrzen' : 'Odkaz neplatí', $ok ? 'Díky! Od teď ti budou chodit novinky.' : 'Potvrzovací odkaz je neplatný, prošlý, nebo už byl použit.'); return;
  }
  nlConfirmPage($site, $token); return;
}
// odhlášení — potvrzení (GET) + provedení (POST); /nl/u i URL 1:1 se starým webem.
// GET jen ukáže potvrzení (skenery odkazů tak omylem neodhlásí), POST odhlásí.
if ($path === '/nl/u' || $path === '/newsletter/unsubscribe' || $path === '/odhlaseni-z-newsletteru' || $path === '/odhlaseni-z-denniho-prehledu-clanku'
    || $path === '/odhlaseni-z-newsletteru.html' || $path === '/odhlaseni-z-denniho-prehledu-clanku.html') {
  $token = (string)($_POST['t'] ?? $_GET['t'] ?? $_GET['token'] ?? $_GET['u'] ?? '');
  if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $done = nlUnsub($token) !== null;
    if (empty($_POST['web'])) { http_response_code(200); header('Content-Type: text/plain; charset=utf-8'); echo 'OK'; return; }   // jednoklikové odhlášení z klienta (List-Unsubscribe-Post)
    renderMessagePage($site, 'Odhlášení z odběru', $done ? 'Byl(a) jsi odhlášen(a). Kdyby to byl omyl, přihlásit zpět se dá kdykoli na webu.' : 'Odkaz je neplatný nebo už byl použit.'); return;
  }
  nlUnsubConfirm($site, $token); return;
}

// newsletter — měřicí pixel (zobrazení)
if (preg_match('~^/nl/o/(\d+)\.gif$~', $path, $m)) {
  nlTrackOpen((int)$m[1], (int)($_GET['s'] ?? 0), (string)($_GET['h'] ?? ''));
  header('Content-Type: image/gif'); header('Cache-Control: no-store');
  echo base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'); return;   // 1×1 transparentní GIF
}
// newsletter — proklik (měření kliků + přesměrování)
if (preg_match('~^/nl/c/(\d+)$~', $path, $m)) {
  $to = nlTrackClick((int)$m[1], (string)($_GET['u'] ?? ''), (int)($_GET['s'] ?? 0), (string)($_GET['h'] ?? ''));
  header('Location: ' . ($to ?: ('https://' . $site['host'] . '/'))); return;
}
// newsletter — webová verze („zobrazit v prohlížeči")
if (preg_match('~^/nl/web/(\d+)$~', $path, $m)) {
  $html = nlWebVersion((int)$m[1], (string)($_GET['sig'] ?? ''));
  if ($html === null) { http_response_code(403); echo 'Neplatný odkaz.'; return; }
  header('Content-Type: text/html; charset=utf-8'); echo $html; return;
}
// Po go-live je ?site= zbytečný a nechceme, aby se tatáž stránka indexovala pod
// dvěma adresami. Parametr se odstraní a adresa přesměruje natrvalo; ostatní
// parametry (stránkování, hledání) zůstávají. Na stagingu se nepřesměrovává,
// tam je ?site= jediný způsob, jak se mezi weby přepnout. (audit 30. 7. 2026)
if (empty(cfg()['staging']) && isset($_GET['site']) && ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET') {
  $zbytek = $_GET; unset($zbytek['site']);
  header('Location: ' . $path . ($zbytek ? '?' . http_build_query($zbytek) : ''), true, 301);
  return;
}
// Započítání zobrazení článku. Volá se beaconem z prohlížeče, protože stránku
// samotnou po dobu mikrocache vrací nginx bez PHP a počítadlo by se nezvyšovalo.
// POST schválně: mikrocache propouští jen GET, takže tudy nic neprojde z cache.
// Rate limit drží, aby si nikdo nemohl nastřílet vlastní článek do „Nejčtenější".
if (preg_match('~^/v/(\d+)$~', $path, $mv) && $_SERVER['REQUEST_METHOD'] === 'POST') {
  http_response_code(204);
  // Původ místo rate-limitu. rateOk() zapisuje řádek při KAŽDÉM volání, což by
  // u počítadla zobrazení znamenalo miliony zápisů denně do rate_limits — a ta
  // tabulka slouží i k omezení přihlašování, takže by se to potkalo tam, kde
  // nemá. Kontrola hlavičky Origin/Referer nestojí nic a zarazí to, o co jde:
  // volání ve smyčce mimo prohlížeč. Kdo si počítadlo chce nafouknout přes
  // vlastní stránku, ten to udělá tak jako tak a o bezpečnostní věc nejde.
  // (prověrka dopadů 31. 7. 2026)
  $puvod = (string)($_SERVER['HTTP_ORIGIN'] ?? '');
  if ($puvod === '') $puvod = (string)($_SERVER['HTTP_REFERER'] ?? '');
  $puvodHost = (string)(parse_url($puvod, PHP_URL_HOST) ?: '');
  if ($puvodHost !== '' && $puvodHost === (string)($_SERVER['HTTP_HOST'] ?? '')) {
    // POČÍTADLO NESMÍ POLOŽIT WEB. 21. 8. 2026 běžela nad `articles` migrace,
    // která tabulku přestavovala a držela metadata lock — a právě tyhle zápisy
    // se za něj zařadily, obsadily všech 32 workerů php-fpm a Cloudflare pak
    // vracel 504 i na stránky, které s článkem nemají nic společného. Půl hodiny
    // výpadku kvůli číslu, na kterém nic nestojí.
    //
    // Čeká se proto nanejvýš dvě vteřiny (`lock_wait_timeout` platí i na
    // metadata lock) a neúspěch se spolkne: ztracené započtení zobrazení je
    // proti výpadku nic. Časový limit se vrací zpátky, protože spojení je
    // sdílené a další dotazy v požadavku mají mít původní chování.
    try {
      db()->exec('SET SESSION lock_wait_timeout=2, innodb_lock_wait_timeout=2');
      db()->prepare("UPDATE articles SET views=views+1 WHERE id=? AND status='publikovano'")->execute([(int)$mv[1]]);
    } catch (Throwable $e) {
      error_log('počítadlo zobrazení: ' . $e->getMessage());
    } finally {
      try { db()->exec('SET SESSION lock_wait_timeout=DEFAULT, innodb_lock_wait_timeout=DEFAULT'); } catch (Throwable $e) { /* nevadí */ }
    }
  }
  return;
}
// Měření blokované reklamy — pípnutí ze stránky (viz síťový skript v Administrace → Scripty).
//
// SCHVÁLNĚ PRVNÍ STRANA A NEUTRÁLNÍ ADRESA. Přes Google Analytics to měřit nejde:
// blokátory (uBlock, AdGuard, Brave) mají googletagmanager i google-analytics na
// stejných seznamech jako reklamu, takže právě ti čtenáři, které chceme spočítat,
// by žádnou událost neodeslali a číslo by vyšlo podstřelené. Vlastní doména jim
// projde. Adresa proto neobsahuje slova jako „ad", „track" ani „beacon" — ta si
// filtry hlídají samy.
//
// Ukládají se jen denní součty (tabulka mereni_reklama), ne řádek za návštěvu.
if ($path === '/mr' && $_SERVER['REQUEST_METHOD'] === 'POST') {
  http_response_code(204);
  // stejná ochrana původu jako u počítadla zobrazení: zarazí volání ve smyčce mimo prohlížeč
  $puvod = (string)($_SERVER['HTTP_ORIGIN'] ?? '') ?: (string)($_SERVER['HTTP_REFERER'] ?? '');
  $puvodHost = (string)(parse_url($puvod, PHP_URL_HOST) ?: '');
  if ($puvodHost === '' || $puvodHost !== (string)($_SERVER['HTTP_HOST'] ?? '')) return;
  $stav = ($_GET['s'] ?? '') === 'blok' ? 'blok' : (($_GET['s'] ?? '') === 'ok' ? 'ok' : '');
  $var  = ($_GET['v'] ?? '') === 'seznam' ? 'seznam' : 'obecne';
  if ($stav === '') return;
  $sid = (int)(currentSite()['id'] ?? 0);
  if (!$sid) return;
  try {
    db()->prepare('INSERT INTO mereni_reklama (den, site_id, stav, varianta, pocet) VALUES (CURDATE(),?,?,?,1)
                   ON DUPLICATE KEY UPDATE pocet=pocet+1')->execute([$sid, $stav, $var]);
  } catch (Throwable $e) { /* měření nesmí shodit stránku */ }
  return;
}
// SES bounce/complaint (SNS webhook) — odhlásí neexistující adresy a stížnosti
if ($path === '/nl/ses' && $_SERVER['REQUEST_METHOD'] === 'POST') {
  $raw = file_get_contents('php://input'); $env = json_decode($raw, true) ?: [];
  if (!snsVerify($env)) { http_response_code(403); echo 'bad signature'; return; }
  if (($env['Type'] ?? '') === 'SubscriptionConfirmation' && !empty($env['SubscribeURL'])) {
    // potvrdit jen skutečný SNS endpoint (žádné SSRF na cizí URL)
    $sp = parse_url((string)$env['SubscribeURL']);
    if (($sp['scheme'] ?? '') === 'https' && preg_match('~^sns\.[a-z0-9-]+\.amazonaws\.com$~', $sp['host'] ?? '')) {
      @file_get_contents($env['SubscribeURL']);
    }
  } elseif (($env['Type'] ?? '') === 'Notification') {
    nlHandleSes(json_decode((string)($env['Message'] ?? '{}'), true) ?: []);
  }
  http_response_code(200); echo 'ok'; return;
}

// Odrazy a stížnosti od SMTP2GO (webhook). Adresa se pozná podle tajemství
// v cestě — služba neumí podepisovat, takže tajná URL je jediná ochrana.
// Bez klíče v configu adresa neexistuje, aby se na produkci nedalo trefit
// slepým hádáním. (13. 8. 2026)
if (preg_match('~^/nl/hook/([A-Za-z0-9_-]{16,64})$~', $path, $mh) && $_SERVER['REQUEST_METHOD'] === 'POST') {
  $tajne = (string)(cfg()['smtp2go_hook'] ?? '');
  if ($tajne === '' || !hash_equals($tajne, $mh[1])) { http_response_code(404); return; }
  require_once dirname(__DIR__) . '/app/newsletter.php';
  nlHandleSmtp2go(json_decode((string)file_get_contents('php://input'), true) ?: []);
  http_response_code(200); echo 'ok'; return;
}

// komentáře + čtenářský účet
// hlasování u komentáře (palec nahoru/dolů) — JSON, toggle
if ($path === '/komentar/hlas' && $_SERVER['REQUEST_METHOD'] === 'POST') {
  require_once __DIR__ . '/../app/comments.php';
  header('Content-Type: application/json; charset=utf-8');
  // Hlasování nemělo žádný limit. Otisk hlasujícího se u anonyma odvozuje jen
  // z cookie, kterou si klient vybírá sám — kdo ji smaže (nebo ji vůbec
  // neposílá), je pokaždé někdo nový a palce se dají posílat bez omezení.
  // Stejná dvojí pojistka jako u hodnocení článku: shoda původu (zarazí volání
  // ve smyčce mimo prohlížeč) a limit na adresu. (audit 5. 8. 2026)
  $puvodH = (string)(parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? $_SERVER['HTTP_REFERER'] ?? ''), PHP_URL_HOST) ?: '');
  if ($puvodH === '' || $puvodH !== (string)($_SERVER['HTTP_HOST'] ?? '')) { echo '{"ok":false}'; return; }
  if (!rateOk('chlas:' . ($_SERVER['REMOTE_ADDR'] ?? '?'), 30, 1)) { echo '{"ok":false}'; return; }
  $in = json_decode((string)file_get_contents('php://input'), true) ?: [];
  $res = commentVote((int)($in['id'] ?? 0), (int)($in['val'] ?? 0));
  echo $res === null ? json_encode(['ok' => false]) : json_encode(['ok' => true, 'up' => $res[0], 'down' => $res[1], 'my' => $res[2]]);
  return;
}
if ($path === '/komentar' && $_SERVER['REQUEST_METHOD'] === 'POST') {
  $back = safeBack($_POST['back'] ?? '/');
  // anti-spam: captcha Turnstile (prázdný secret v configu = přeskočí se — dev)
  if (!turnstileVerify((string)($_POST['cf-turnstile-response'] ?? ''))) {
    $sep = strpos($back, '?') !== false ? '&' : '?';
    header('Location: ' . preg_replace('~#~', $sep . 'komentar=captcha#', $back . (strpos($back, '#') === false ? '#komentare' : ''), 1)); return;
  }
  $status = commentSubmit($site, (int)($_POST['article'] ?? 0), (int)($_POST['parent'] ?? 0) ?: null, (string)($_POST['body'] ?? ''));
  $flag = match ($status) { 'viditelny' => 'ok', 'ke_kontrole' => 'kontrola', 'skryty' => 'skryt', default => '' };
  $sep = strpos($back, '?') !== false ? '&' : '?';
  header('Location: ' . ($flag ? preg_replace('~#~', $sep . 'komentar=' . $flag . '#', $back, 1) : $back)); return;
}
// návratová URL z formulářů: jen lokální cesta ("//host" i "/\\host" = open redirect)
function safeBack($v): string {
  return (is_string($v) && $v !== '' && $v[0] === '/' && !in_array($v[1] ?? '', ['/', '\\'], true)) ? $v : '/';
}
$isPost = $_SERVER['REQUEST_METHOD'] === 'POST';
// ---- Sociální login (OAuth) ----
if ($path === '/auth/google' || $path === '/auth/facebook') {
  $prov = substr($path, 6);
  $back = safeBack($_GET['back'] ?? '/');
  $prov === 'google' ? authGoogleStart($back) : authFacebookStart($back); return;
}
if ($path === '/auth/google/callback') { authGoogleCallback(); return; }
if ($path === '/auth/facebook/callback') { authFacebookCallback(); return; }
if ($path === '/auth/apple') {
  $back = safeBack($_GET['back'] ?? '/');
  authAppleStart($back); return;
}
if ($path === '/auth/apple/callback') { authAppleCallback(); return; }
// ověření domény pro Sign in with Apple (soubor od Applu nahrajeme do var/)
if ($path === '/.well-known/apple-developer-domain-association.txt') {
  $f = dirname(__DIR__) . '/var/apple-developer-domain-association.txt';
  if (is_file($f)) { header('Content-Type: text/plain'); readfile($f); } else { http_response_code(404); echo 'Not found'; }
  return;
}
if ($path === '/ucet/prihlaseni' && $isPost) {
  $back = safeBack($_POST['back'] ?? '/');
  $r = readerLogin((string)($_POST['email'] ?? ''), (string)($_POST['password'] ?? ''));
  if (!$r['ok']) {
    $err = 'ucet_chyba=' . rawurlencode($r['error']);
    if (($h = strpos($back, '#')) !== false) { $base = substr($back, 0, $h); $frag = substr($back, $h); $back = $base . (strpos($base, '?') !== false ? '&' : '?') . $err . $frag; }
    else $back .= (strpos($back, '?') !== false ? '&' : '?') . $err;
  }
  header('Location: ' . $back); return;
}
if ($path === '/ucet/registrace' && $isPost) {
  $pass = (string)($_POST['password'] ?? '');
  if ($pass !== (string)($_POST['password2'] ?? '')) $r = ['ok' => false, 'error' => 'Hesla se neshodují.'];
  elseif (!turnstileVerify((string)($_POST['cf-turnstile-response'] ?? ''))) $r = ['ok' => false, 'error' => 'Ověření captchy selhalo, zkus to prosím znovu.'];
  else $r = readerRegister((string)($_POST['first_name'] ?? ''), (string)($_POST['last_name'] ?? ''), (string)($_POST['email'] ?? ''), $pass, !empty($_POST['gdpr']));
  if ($r['ok']) { header('Location: /ucet'); return; }
  header('Location: /ucet/registrace?' . http_build_query(['chyba' => $r['error'], 'fn' => (string)($_POST['first_name'] ?? ''), 'ln' => (string)($_POST['last_name'] ?? ''), 'em' => (string)($_POST['email'] ?? '')])); return;
}
if ($path === '/ucet/registrace') { renderRegisterPage($site); return; }
if ($path === '/ucet/odhlaseni' && $isPost) {
  readerLogout();
  $back = safeBack($_POST['back'] ?? '/');
  header('Location: ' . $back); return;
}
if ($path === '/ucet/reset' && $isPost) {
  if (rateOk('pwreset:' . ($_SERVER['REMOTE_ADDR'] ?? '?'), 5, 60)) readerResetRequest($site, (string)($_POST['email'] ?? ''));
  header('Location: /ucet/reset?odeslano=1'); return;   // odpověď stejná i při limitu (nic neprozrazovat)
}
if ($path === '/ucet/reset') { renderResetPage($site); return; }
if (preg_match('~^/ucet/reset/([a-f0-9]{32})$~', $path, $mm)) {
  if ($isPost) {
    $pass = (string)($_POST['password'] ?? '');
    if ($pass !== (string)($_POST['password2'] ?? '')) { header('Location: /ucet/reset/' . $mm[1] . '?chyba=' . rawurlencode('Hesla se neshodují.')); return; }
    $r = readerResetConfirm($mm[1], $pass);
    header('Location: ' . ($r['ok'] ? '/ucet?ok=1' : '/ucet/reset/' . $mm[1] . '?chyba=' . rawurlencode($r['error']))); return;
  }
  renderResetConfirmPage($site, $mm[1]); return;
}
if ($path === '/ucet/profil' && $isPost) { if (($rr = readerCurrent()) && readerCsrfOk()) readerProfileSave($rr); else header('Location: /ucet'); return; }
if ($path === '/ucet/foto' && $isPost) { if (($rr = readerCurrent()) && readerCsrfOk()) readerPhotoSave($rr); else header('Location: /ucet'); return; }
if ($path === '/ucet/odhlasit-vse' && $isPost) { if (($rr = readerCurrent()) && readerCsrfOk()) readerLogoutEverywhere($rr); else header('Location: /ucet'); return; }
if ($path === '/ucet/heslo' && $isPost) { if (($rr = readerCurrent()) && readerCsrfOk()) readerPasswordSave($rr); else header('Location: /ucet'); return; }
if ($path === '/ucet/newslettery' && $isPost) { if (($rr = readerCurrent()) && readerCsrfOk()) readerNewslettersSave($rr); else header('Location: /ucet'); return; }
if ($path === '/ucet/smazat' && $isPost) {
  // Bez platného tokenu ani ránu: tohle je nevratná akce (anonymizace účtu).
  if (($r = readerCurrent()) && readerCsrfOk()) {
    if (!empty($_POST['unsub'])) db()->prepare("UPDATE nl_subscribers SET status='unsub' WHERE email=?")->execute([$r['email']]);
    // avatar pryč z disku, pak anonymizace v DB
    require_once __DIR__ . '/../app/avatar.php';
    readerAvatarRemove((int)$r['id']);
    readerDelete((int)$r['id']);
    // notifikace pro přehled (výmaz proběhl automaticky, není co vyřizovat)
    if (($adm = trim((string)(cfg()['account_delete_notify'] ?? 'matej@drbna.cz'))) !== '') {
      require_once __DIR__ . '/../app/mailer.php';
      mailerSend($adm, 'Čtenář si smazal účet', '<p>Čtenář <b>' . e((string)$r['email']) . '</b> si právě smazal účet (anonymizováno automaticky, bez další akce).</p>', ['from_name' => 'Drbna CMS']);
    }
  }
  header('Location: /?site=' . rawurlencode($site['code'])); return;
}
if ($path === '/ucet') {
  $r = readerCurrent();
  if (!$r) { header('Location: /ucet/registrace'); return; }
  renderAccountPage($site, $r); return;
}

// návštěva z distribuce Seznamu: založ/obnov 30min cookie, dokud jdou hlavičky
adSeznamActive();

// náhled konceptu z adminu — podepsaná URL (HMAC z app_secret), platnost 7 dní
if ($path === '/nahled') {
  $pid = (int)($_GET['id'] ?? 0); $exp = (int)($_GET['exp'] ?? 0); $sig = (string)($_GET['sig'] ?? '');
  $ok = $pid && $exp > time() && hash_equals(hash_hmac('sha256', $pid . '.' . $exp, (string)cfg()['app_secret']), $sig);
  if (!$ok) { http_response_code(403); header('Content-Type: text/plain; charset=utf-8'); echo 'Neplatný nebo prošlý odkaz na náhled.'; return; }
  $prow = db()->prepare('SELECT site_id FROM articles WHERE id=?'); $prow->execute([$pid]); $psid = (int)$prow->fetchColumn();
  if (!$psid) { http_response_code(404); echo 'Článek nenalezen.'; return; }
  $pss = db()->prepare('SELECT * FROM sites WHERE id=?'); $pss->execute([$psid]);
  renderArticle($pss->fetch(), $pid, true);
  return;
}

// Tisková podoba článku (A4, bez webu kolem) — z ní si prohlížeč udělá PDF.
// Publikovaný článek stačí číslem, nepublikovaný potřebuje podepsaný odkaz
// stejně jako náhled, aby se rozepsaný text nedostal ven. (Matěj 14. 8. 2026)
if ($path === '/tisk') {
  require_once __DIR__ . '/../app/render-tisk.php';
  $tid = (int)($_GET['id'] ?? 0); $texp = (int)($_GET['exp'] ?? 0); $tsig = (string)($_GET['sig'] ?? '');
  $tr = db()->prepare('SELECT site_id, status, published_at, is_premium FROM articles WHERE id=?'); $tr->execute([$tid]); $trow = $tr->fetch();
  if (!$trow) { http_response_code(404); header('Content-Type: text/plain; charset=utf-8'); echo 'Článek nenalezen.'; return; }
  $tpodpis = $texp > time() && $tsig !== ''
    && hash_equals(hash_hmac('sha256', $tid . '.' . $texp, (string)cfg()['app_secret']), $tsig);
  // Naplánovaný článek má status `publikovano` s budoucím datem — bez kontroly
  // data si embargovaný text mohl přečíst kdokoli, kdo uhodl ID (audit 15. 8.
  // 2026, N07). Redakci se nic nebere: podepsaný odkaz z administrace projde.
  $tembargo = $trow['published_at'] !== null && strtotime((string)$trow['published_at']) > time();
  if (($trow['status'] !== 'publikovano' || $tembargo) && !$tpodpis) {
    http_response_code(403); header('Content-Type: text/plain; charset=utf-8');
    echo 'Nepublikovaný nebo naplánovaný článek jde vytisknout jen přes odkaz z administrace.'; return;
  }
  // Placený článek se netiskne jen tak: detail článku zpoplatněné tělo vůbec
  // nevykreslí (`render-article.php`), ale tisková verze `is_premium`
  // nekontrolovala, takže celý text vydala komukoli, kdo znal ID. Podepsaný
  // odkaz z administrace projde dál — redakce si svůj článek vytisknout musí.
  // (nezávislý audit 20. 8. 2026)
  require_once __DIR__ . '/../app/premium.php';
  if (!empty($trow['is_premium']) && !$tpodpis && !readerPremium()) {
    http_response_code(403); header('Content-Type: text/plain; charset=utf-8');
    echo 'Tenhle článek je pro předplatitele — přihlas se na webu a zkus to znovu.'; return;
  }
  $tss = db()->prepare('SELECT * FROM sites WHERE id=?'); $tss->execute([(int)$trow['site_id']]);
  renderArticleTisk($tss->fetch(), $tid);
  return;
}

// veřejný kalendář akcí
if ($path === '/akce' || $path === '/akce/') { renderEventsPage($site); return; }
if ($path === '/akce/archiv') { renderEventsPage($site, true); return; }
// tip na akci od čtenáře → akce NEAKTIVNÍ + mail redakci se schvalovacím odkazem
if ($path === '/akce/tip' && $_SERVER['REQUEST_METHOD'] === 'POST') {
  $backErr = fn(string $m) => header('Location: /akce?site=' . $site['code'] . '&tipchyba=' . rawurlencode($m));
  if (!empty($_POST['company'])) { header('Location: /akce?site=' . $site['code']); return; }   // honeypot
  if (!turnstileVerify((string)($_POST['cf-turnstile-response'] ?? ''))) { $backErr('Potvrď prosím, že nejsi robot.'); return; }
  if (!rateOk('evtip:' . ($_SERVER['REMOTE_ADDR'] ?? '?'), 3, 60)) { $backErr('Příliš mnoho tipů za sebou — zkus to prosím za hodinu.'); return; }
  if (empty($_POST['agree'])) { $backErr('Potvrď prosím souhlas s podmínkami.'); return; }
  $title = trim((string)($_POST['title'] ?? '')); $email = trim((string)($_POST['email'] ?? ''));
  $odT = strtotime((string)($_POST['od'] ?? '')); $doT = strtotime((string)($_POST['do'] ?? ''));
  $desc = trim((string)($_POST['description'] ?? ''));
  // Konec je NEPOVINNÝ: spousta akcí oficiální konec nemá („párty do noci“)
  // a redakce si musela vymýšlet čas, který neexistuje. (Matěj 13. 8. 2026)
  if ($title === '' || !filter_var($email, FILTER_VALIDATE_EMAIL) || !$odT || $desc === '') { $backErr('Vyplň prosím všechna povinná pole.'); return; }
  if ($doT && $doT < $odT) { $backErr('Konec akce nemůže být před začátkem.'); return; }
  // fotka (nepovinná): uložit do public/uploads/akce
  $imgUrl = null;
  if (!empty($_FILES['photo']['tmp_name']) && is_uploaded_file($_FILES['photo']['tmp_name'])) {
    $mime = (string)@mime_content_type($_FILES['photo']['tmp_name']);
    $ext = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'][$mime] ?? null;
    if ($ext && $_FILES['photo']['size'] <= 8 * 1024 * 1024) {
      $dir = __DIR__ . '/uploads/akce'; @mkdir($dir, 0775, true);
      $name = date('Ymd') . '-' . bin2hex(random_bytes(6)) . '.' . $ext;
      if (move_uploaded_file($_FILES['photo']['tmp_name'], "$dir/$name")) {
        $imgUrl = rtrim((string)(cfg()['auth_base'] ?? ''), '/') . '/uploads/akce/' . $name;
      }
    }
  }
  db()->prepare("INSERT INTO events (site_id, title, description, body_html, image_url, starts_at, ends_at, location, url, active, tip_email, tip_at)
                 VALUES (?,?,?,?,?,?,?,?,?,0,?,NOW())")
    ->execute([(int)$site['id'], $title, $desc, '', $imgUrl, date('Y-m-d H:i:s', $odT), $doT ? date('Y-m-d H:i:s', $doT) : null,
               trim((string)($_POST['location'] ?? '')), trim((string)($_POST['url'] ?? '')), $email]);
  $eid = (int)db()->lastInsertId();
  // mail redakci se schvalovacím tlačítkem
  $em = (json_decode((string)($site['settings'] ?? ''), true) ?: [])['emails'] ?? [];
  $to = $em['tipy'] ?? ($em['redakce'] ?? '');
  if ($to !== '') {
    // Platnost 30 dní: tip na akci se schvaluje v řádu dnů a starý odkaz
    // ležící v poště nemá co publikovat. (21. 8. 2026)
    $exp = time() + 30 * 86400;
    $sig = hash_hmac('sha256', 'evtip' . $eid . '.' . $exp, (string)cfg()['app_secret']);
    $link = rtrim((string)(cfg()['auth_base'] ?? ''), '/') . '/akce/schvalit?id=' . $eid . '&exp=' . $exp . '&sig=' . $sig . '&site=' . rawurlencode($site['code']);
    $html = '<p>Do kalendáře <b>' . e($site['name']) . '</b> přišel tip na akci:</p>'
      . '<p><b>' . e($title) . '</b><br>' . e(date('j. n. Y H:i', $odT)) . ($doT ? ' – ' . e(date('j. n. Y H:i', $doT)) : ' <i>(bez uvedeného konce)</i>')
      . (trim((string)($_POST['location'] ?? '')) !== '' ? '<br>Místo: ' . e($_POST['location']) : '')
      . (trim((string)($_POST['url'] ?? '')) !== '' ? '<br>Odkaz: ' . e($_POST['url']) : '')
      . '<br>Kontakt: ' . e($email) . '</p><p>' . nl2br(e($desc)) . '</p>'
      . ($imgUrl ? '<p><img src="' . e($imgUrl) . '" style="max-width:400px"></p>' : '')
      . '<p><a href="' . $link . '" style="background:#16a34a;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none">✓ Schválit a publikovat</a></p>'
      . '<p style="color:#888;font-size:12px">Upravit nebo smazat můžeš v CMS (Obsah → Kalendář akcí — akce je tam jako skrytá).</p>';
    mailerSend($to, 'Tip na akci: ' . $title, $html, ['from_name' => $site['name']]);
  }
  header('Location: /akce?site=' . $site['code'] . '&tip=ok'); return;
}
// Schválení tipu z e-mailu (podepsaný odkaz; idempotentní).
//
// PUBLIKUJE AŽ POTVRZENÍ, NE OTEVŘENÍ ODKAZU. Do 21. 8. 2026 stačilo odkaz
// načíst (GET) a akce se zveřejnila. Jenže odkazy v e-mailech otevírají i
// stroje: firemní ochrana pošty (Microsoft SafeLinks a spol.) je předběžně
// stahuje, aby zjistila, kam vedou — takže tip mohl vyjít na web dřív, než ho
// kdokoli z redakce vůbec uviděl. Formulář s tlačítkem stroj neodešle.
// (nezávislá revize 20. 8. 2026; totéž jsme už dřív řešili u newsletteru)
if ($path === '/akce/schvalit') {
  $eid = (int)($_GET['id'] ?? $_POST['id'] ?? 0);
  $sig = (string)($_GET['sig'] ?? $_POST['sig'] ?? '');
  $exp = (int)($_GET['exp'] ?? $_POST['exp'] ?? 0);
  $tajne = (string)cfg()['app_secret'];
  // Nové odkazy nesou platnost, starší (rozeslané před touhle změnou) ji nemají
  // — ať redakci nezůstane v poště hromada mrtvých tlačítek. Obojí ale musí
  // projít potvrzením, takže ani starý odkaz sám o sobě nic nezveřejní.
  $sedi = $eid && (
       ($exp > time() && hash_equals(hash_hmac('sha256', 'evtip' . $eid . '.' . $exp, $tajne), $sig))
    || ($exp === 0 && hash_equals(hash_hmac('sha256', 'evtip' . $eid, $tajne), $sig)));
  if (!$sedi) {
    renderMessagePage($site, 'Neplatný odkaz', $exp > 0 && $exp <= time()
      ? 'Schvalovací odkaz už vypršel — akci publikuj v administraci (Obsah → Kalendář akcí).'
      : 'Schvalovací odkaz není platný.');
    return;
  }
  if ($isPost) {
    db()->prepare('UPDATE events SET active=1 WHERE id=?')->execute([$eid]);
    renderMessagePage($site, 'Akce schválena ✓', 'Akce je publikovaná v kalendáři. Upravit ji můžeš v CMS (Obsah → Kalendář akcí).');
    return;
  }
  $ev = db()->prepare('SELECT title, starts_at FROM events WHERE id=?');
  $ev->execute([$eid]);
  $e = $ev->fetch();
  head($site, 'Schválit akci – ' . $site['name']);
  echo '<div class="card" style="max-width:560px;margin:30px auto;background:var(--surf);border:1px solid var(--line);border-radius:12px;padding:26px 28px">'
    . '<h1 style="font:900 24px/1.2 var(--fh);margin:0 0 12px">Publikovat tip na akci?</h1>'
    . ($e ? '<p><b>' . e((string)$e['title']) . '</b><br><span style="color:var(--ink2)">'
            . e($e['starts_at'] ? date('j. n. Y H:i', strtotime((string)$e['starts_at'])) : '') . '</span></p>'
          : '<p style="color:var(--ink2)">Akce už v kalendáři není.</p>')
    . '<form method="post" action="/akce/schvalit' . sq($site) . '" style="margin-top:16px">'
    . '<input type="hidden" name="id" value="' . $eid . '">'
    . '<input type="hidden" name="sig" value="' . e($sig) . '">'
    . '<input type="hidden" name="exp" value="' . $exp . '">'
    . '<button class="btn" type="submit">✓ Publikovat v kalendáři</button></form>'
    . '<p style="margin-top:14px;font-size:13px;color:var(--ink2)">Akce se zveřejní až tímhle tlačítkem — samotné otevření odkazu nic nemění.</p>'
    . '</div>';
  foot($site);
  return;
}

// detail akce
if (preg_match('~^/akce/(\d+)(?:-[a-z0-9-]*)?$~', $path, $m)) { renderEventDetail($site, (int)$m[1]); return; }

// počasí (URL 1:1 se starým webem: /pocasi.html)
if ($path === '/pocasi' || $path === '/pocasi/' || $path === '/pocasi.html') { renderWeatherPage($site); return; }

// všechny ankety — /ankety i menu cesty /ankety.html a /drbna/ankety.html
if (in_array($path, ['/ankety', '/ankety/', '/ankety.html', '/drbna/ankety.html'], true)) { renderPollsPage($site); return; }
// kvízy — krokovaně: otázka a odpověď = samostatné stránky (pageviews)
if (preg_match('~^/kviz/(\d+)$~', $path, $m)) { renderQuizIntro($site, (int)$m[1]); return; }
if (preg_match('~^/kviz/(\d+)/start$~', $path, $m) && $isPost) {
  quizSaveAnswers((int)$m[1], []);                       // smaž rozehrané odpovědi
  header('Location: /kviz/' . (int)$m[1] . '/otazka/1?site=' . $site['code']); return;
}
if (preg_match('~^/kviz/(\d+)/otazka/(\d+)$~', $path, $m)) { renderQuizQuestion($site, (int)$m[1], (int)$m[2]); return; }
if (preg_match('~^/kviz/(\d+)/odpoved$~', $path, $m) && $isPost) {
  $qid = (int)$m[1]; $n = (int)($_POST['q'] ?? 0); $opt = (int)($_POST['opt'] ?? 0);
  $ans = quizAnswers($qid);
  if ($n >= 1 && $opt) { $ans = array_slice($ans, 0, $n - 1); $ans[$n - 1] = $opt; quizSaveAnswers($qid, $ans); }
  header('Location: /kviz/' . $qid . '/odpoved/' . $n . '?site=' . $site['code']); return;
}
if (preg_match('~^/kviz/(\d+)/odpoved/(\d+)$~', $path, $m)) { renderQuizAnswer($site, (int)$m[1], (int)$m[2]); return; }
if (preg_match('~^/kviz/(\d+)/vysledek$~', $path, $m)) { renderQuizResult($site, (int)$m[1]); return; }
if (preg_match('~^/stitek/([a-z0-9-]+)\.html$~', $path, $m)) { renderTagPage($site, $m[1]); return; }
// detail fotogalerie — URL 1:1 se starým webem (/galerie/gallery/{id}-{slug}.html), slug tolerantní
if (preg_match('~^/galerie/gallery/(\d+)(?:-[^/]*)?\.html$~', $path, $m)) { renderGalleryPage($site, (int)$m[1]); return; }
// fotogalerie z přiložených fotek článku (záložka Obrázky) — stejný zážitek jako modul galerie
if (preg_match('~^/galerie/clanek/(\d+)(?:-[^/]*)?\.html$~', $path, $m)) {
  // Stejný podepsaný náhled jako u článku — jinak si redakce galerii konceptu
  // ani článku s budoucím datem neprohlédne (404). (Matěj 12. 8. 2026)
  $gid = (int)$m[1]; $gexp = (int)($_GET['exp'] ?? 0); $gsig = (string)($_GET['sig'] ?? '');
  $gnahled = $gexp > time() && $gsig !== ''
    && hash_equals(hash_hmac('sha256', $gid . '.' . $gexp, (string)cfg()['app_secret']), $gsig);
  $gsite = $site;
  if ($gnahled) {
    // Náhled se otevírá z administrace, a ta jede na kterékoli doméně sítě —
    // redaktor tak často kouká na článek cizí mutace. Galerie proto musí článek
    // hledat pod JEHO webem, ne pod tím, na jehož doméně zrovna je; jinak vrátí
    // 404. Náhled článku to takhle dělá taky. (nahlásil Matěj 12. 8. 2026)
    $gq = db()->prepare('SELECT s.* FROM articles a JOIN sites s ON s.id=a.site_id WHERE a.id=? LIMIT 1');
    $gq->execute([$gid]);
    if ($gr = $gq->fetch()) $gsite = $gr;
  }
  renderArticleGalleryPage($gsite, $gid, $gnahled);
  return;
}
// náhled modulu (anketa/formulář/video) — jen přihlášená redakce (gate uvnitř)
if (preg_match('~^/nahled/(anketa|formular|video)/(\d+)$~', $path, $m)) { renderModulePreview($site, $m[1], (int)$m[2]); return; }
// hry — osmisměrky a tajenky (týdenní levely z článků)
if ($path === '/hry' || $path === '/hry/') { renderGamesList($site); return; }
// Pexeso (prototyp) — mimo hřiště 404, dokud ho redakce neschválí (config pexeso_zapnuto)
if ($path === '/hry/pexeso') { renderPexeso($site); return; }

// Sběr hlášení o porušení CSP (posílá prohlížeč, viz csp_report v configu).
if ($path === '/csp-hlaseni' && $_SERVER['REQUEST_METHOD'] === 'POST') {
  require_once __DIR__ . '/../app/csp.php'; cspPrijmiHlaseni(); return;
}

// Push notifikace. `kliknuto` a `obnova` volá service worker, `prihlasit`
// a `odhlasit` stránka — proto mají první dva bez CSRF (worker token nemá).
if (str_starts_with($path, '/push/') && ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
  require_once __DIR__ . '/../app/push.php';
  if ($path === '/push/prihlasit') { pushEndpointPrihlasit($site); return; }
  if ($path === '/push/odhlasit')  { pushEndpointOdhlasit();       return; }
  if ($path === '/push/kliknuto')  { pushEndpointKliknuto();       return; }
  if ($path === '/push/obnova')    { pushEndpointObnova($site);    return; }
}

// Volby — samostatná stránka pro záložku v menu a jeden odkaz, který se dá
// ve volební noci sdílet. Kreslí tentýž blok jako [volby:ID] v článku.
if ($path === '/volby/data') { require_once __DIR__ . '/../app/render-volby.php'; volbyDataEndpoint($site); return; }
if ($path === '/volby/obce') { require_once __DIR__ . '/../app/render-volby.php'; volbyObceEndpoint(); return; }
if ($path === '/volby' || $path === '/volby/' || preg_match('~^/volby/(\d+)$~', $path, $mv)) {
  require_once __DIR__ . '/../app/render-volby.php';
  renderVolbyPage($site, isset($mv[1]) ? (int)$mv[1] : 0);
  return;
}
if (preg_match('~^/hry/(\d+)/vysledek$~', $path, $m) && ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') { doGameResult($site, (int)$m[1]); return; }
if (preg_match('~^/hry/(\d+)(?:-[^/]*)?$~', $path, $m)) { renderGamePage($site, (int)$m[1]); return; }
if (preg_match('~^/autor/([a-z0-9-]+)$~', $path, $m)) { renderAuthorPage($site, $m[1]); return; }

// komerční články (PR obsah)

// Adresa, na kterou je zvyklá Impression Media. Jejich vlastní PHP skript
// u nás nahraný není — `ads.txt` je záznam v CMS, ne soubor na disku — ale
// šťouchnutí sem udělá totéž: stáhne jejich seznam a vloží ho mezi značky.
// Protože je ads.txt síťový, jedno zavolání aktualizuje všech dvanáct domén.
if ($path === '/imAdsTxtUpdater.php') {
  require_once __DIR__ . '/../app/adstxt.php';
  adstxtEndpoint();
  return;
}

// Vlastní URL z adminu (ads.txt, ad.js, ověřovací soubory…); per-web má přednost před síťovým
// pojistka i tady (nejen v adminu): starý záznam se systémovou cestou se neobslouží
if (customFilePathOk($path)) {
  $cf = db()->prepare("SELECT mime, body FROM custom_files WHERE path=? AND active=1 AND (site_id=? OR site_id IS NULL) ORDER BY site_id IS NULL LIMIT 1");
  $cf->execute([$path, $site['id']]);
  if ($cfr = $cf->fetch()) { header('Content-Type: ' . $cfr['mime']); echo $cfr['body']; return; }
}

// Premium (bez reklam): nákup přes GoPay, návrat/notifikace, promo kódy
if ($path === '/premium') { require_once __DIR__ . '/../app/premium.php'; renderPremiumPage($site); return; }
if ($path === '/premium/koupit' && $isPost) {
  require_once __DIR__ . '/../app/premium.php';
  $r = readerCurrent();
  if (!$r) { header('Location: /premium?site=' . $site['code'] . '&chyba=' . rawurlencode('Nejdřív se přihlas.')); return; }
  if (!readerCsrfOk()) { header('Location: /premium?site=' . $site['code'] . '&chyba=' . rawurlencode('Neplatný požadavek, zkus to prosím znovu ze stránky účtu.')); return; }
  $recurring = ($_POST['platba'] ?? 'opakovane') !== 'jednorazova';
  if (empty($_POST['agree'])) { header('Location: /premium?site=' . $site['code'] . '&chyba=' . rawurlencode('Potvrď prosím souhlas s podmínkami.')); return; }
  if ($recurring && empty($_POST['agree_rec'])) { header('Location: /premium?site=' . $site['code'] . '&chyba=' . rawurlencode('Potvrď prosím souhlas s opakovanou platbou.')); return; }
  [$ok, $out] = gopayCreatePayment($r, $site, $recurring);
  header('Location: ' . ($ok ? $out : '/premium?site=' . $site['code'] . '&chyba=' . rawurlencode($out)));
  return;
}
if ($path === '/premium/navrat') {
  require_once __DIR__ . '/../app/premium.php';
  // Obě routy platební brány jsou nepřihlášené a každé volání drží workera po
  // dobu dvou dotazů na GoPay. Bez stropu je stačí volat ve smyčce a dojdou
  // procesy php-fpm — a to shodí VŠECHNY weby sítě, ne jen premium. Třicet za
  // minutu z adresy pokryje i opakované klikání zákazníka. (audit 30. 7. 2026)
  if (!rateOk('gopay:' . zkracenaIp((string)($_SERVER['REMOTE_ADDR'] ?? '')), 30, 1)) {
    http_response_code(429); header('Retry-After: 60'); echo 'Příliš mnoho požadavků.'; return;
  }
  $state = gopaySettle((string)($_GET['id'] ?? ''));
  $pvs = '';
  if ($state === 'paid') {
    $po = db()->prepare('SELECT vs, amount FROM premium_orders WHERE gopay_id=?');
    $po->execute([(string)($_GET['id'] ?? '')]);
    if ($por = $po->fetch()) $pvs = '&vs=' . rawurlencode($por['vs']) . '&am=' . (int)$por['amount'];
  }
  $msg = $state === 'paid' ? ['ok', 'zaplaceno']
    : ($state === 'created' || $state === 'unknown' ? ['chyba', 'Platba zatím není dokončená. Jakmile dorazí, premium se aktivuje samo.']
    : ['chyba', 'Platba neproběhla (' . $state . '). Zkus to prosím znovu.']);
  header('Location: /premium?site=' . $site['code'] . '&' . $msg[0] . '=' . rawurlencode($msg[1]) . $pvs);
  return;
}
// STARÁ NOTIFIKAČNÍ ADRESA GOPAY (systém FT SUNu). Mandáty založené před
// migrací mají u brány `notification_url` na téhle cestě — a od přepnutí DNS
// 12. 8. 2026 míří k nám. GoPay se sem hodinu co hodinu pokoušela doručit
// ZAPLACENOU obnovu a dostávala 404: zákazník zaplatil a premium se mu
// neprodloužilo (nalezeno 20. 8. 2026 v access logu, platba 9288130320).
// Zpracování je stejné jako u /premium/notifikace: gopaySettle si stav platby
// OVĚŘÍ u brány naším klíčem, aktivuje jen skutečně zaplacené a je idempotentní
// — parametr z adresy se nikdy nebere za bernou minci.
if ($path === '/background/custom/drbna/user-premium-gopay-notification.php'
    || $path === '/premium/notifikace') {
  require_once __DIR__ . '/../app/premium.php';
  // Viz komentář u /premium/navrat. Notifikace chodí od GoPay, tedy z pár adres —
  // strop se legitimního provozu nedotkne.
  if (!rateOk('gopay:' . zkracenaIp((string)($_SERVER['REMOTE_ADDR'] ?? '')), 30, 1)) {
    http_response_code(429); header('Retry-After: 60'); echo 'Příliš mnoho požadavků.'; return;
  }
  gopaySettle((string)($_GET['id'] ?? ''));
  header('Content-Type: text/plain'); echo 'OK'; return;
}
if ($path === '/premium/zrusit-obnoveni' && $isPost) {
  require_once __DIR__ . '/../app/premium.php';
  $r = readerCurrent();
  if (!$r) { header('Location: /premium?site=' . $site['code'] . '&chyba=' . rawurlencode('Nejdřív se přihlas.')); return; }
  if (!readerCsrfOk()) { header('Location: /premium?site=' . $site['code'] . '&chyba=' . rawurlencode('Neplatný požadavek, zkus to prosím znovu ze stránky účtu.')); return; }
  // VŠECHNY živé mandáty, ne jen poslední. Dřív se rušil jeden (LIMIT 1)
  // a čtenáři se napsalo „zrušeno" — kdo si premium koupil dvakrát nebo mu
  // zůstal mandát z migrace, měl u brány dál živé oprávnění inkasovat.
  // (audit 5. 8. 2026)
  $mandaty = premiumVsechnyMandaty((int)$r['id']);
  if (!$mandaty) { $ok = false; $out = 'Nemáš aktivní automatické obnovování.'; }
  else {
    $zruseno = 0; $out = '';
    foreach ($mandaty as $m) {
      [$o, $z] = gopayVoidRecurrence((int)$m['id']);
      if ($o) $zruseno++; else $out = $z;
    }
    $ok = $zruseno === count($mandaty);
    if (!$ok && $zruseno > 0) $out = 'Zrušili jsme ' . $zruseno . ' z ' . count($mandaty) . ' obnovování. Se zbytkem se ozvi na redakce@drbna.cz.';
  }
  header('Location: /premium?site=' . $site['code'] . '&' . ($ok ? 'ok=zruseno' : 'chyba=' . rawurlencode($out)));
  return;
}
if ($path === '/premium/kod' && $isPost) {
  require_once __DIR__ . '/../app/premium.php';
  $r = readerCurrent();
  if (!$r) { header('Location: /premium?site=' . $site['code'] . '&chyba=' . rawurlencode('Nejdřív se přihlas.')); return; }
  if (!readerCsrfOk()) { header('Location: /premium?site=' . $site['code'] . '&chyba=' . rawurlencode('Neplatný požadavek, zkus to prosím znovu ze stránky účtu.')); return; }
  [$ok, $out] = promoRedeem((string)($_POST['kod'] ?? ''), $r, $site);
  header('Location: /premium?site=' . $site['code'] . '&' . ($ok ? 'ok=kod' : 'chyba=' . rawurlencode($out)));
  return;
}

// ads.txt — obsah per web z nastavení (dodá obchod/agentura); prázdné = 404
if ($path === '/ads.txt') {
  $adsTxt = trim((string)(((json_decode((string)($site['settings'] ?? ''), true) ?: [])['ads_txt']) ?? ''));
  if ($adsTxt === '') { http_response_code(404); header('Content-Type: text/plain; charset=utf-8'); echo "not configured\n"; return; }
  header('Content-Type: text/plain; charset=utf-8'); echo $adsTxt . "\n"; return;
}

// feedy / sitemapy / robots — generované z DB, filtrované maticí chování
// (aliasy /export/custom/drbna/*.php a /rss.html = URL starého webu, ať odběratelům nic nespadne)
$feedRoutes = [
  '/robots.txt'      => fn() => robotsTxt($site),
  // Manifest si tahá favicon bot Seznamu — 39 697 requestů DENNĚ, každý 404.
  // Vedle toho z něj prohlížeč bere jméno a ikonu při „přidat na plochu",
  // což se hodí, protože posíláme upozornění do prohlížeče. (18. 8. 2026)
  '/manifest.json'   => fn() => manifestJson($site),
  '/sitemap.xml'     => fn() => sitemapIndex($site),
  '/sitemap-stranky.xml' => fn() => sitemapStranky($site),
  '/feed/google.xml' => fn() => googleNews($site),
  '/feed/msn.xml'    => fn() => msnFeed($site),
  // Číselník kategorií pro odběratele feedu (Toxin, MSN) — ať si je připraví
  // dopředu a obsah jim nespadne do jedné skupiny. Není součástí feedu.
  '/feed/kategorie.json' => fn() => feedKategorie($site),
  '/feed/rss.xml'    => fn() => rss($site, 'in_rss'),
  '/feed/seznam.xml' => fn() => rss($site, 'feed_seznam', ' – Seznam'),
  // Staré adresy musí vracet i STARÝ TVAR, ne jen tutéž množinu článků —
  // odběratel má u nich nastavený parser, který nový tvar nepřečte.
  // (porovnáno se skutečnými feedy FT SUNu 8. 8. 2026)
  '/export/custom/drbna/google-news.php' => fn() => googleNewsRss($site),   // starý = RSS, ne sitemapa
  '/export/custom/drbna/seznam-cz.php'   => fn() => seznamFeed(),           // starý = celá síť, ne jeden web
  '/export/custom/drbna/newton.php'      => fn() => newtonFeed($site),
  '/export/custom/drbna/seznam-cz-dorucovaci-boxy.php' => fn() => seznamBoxesFeed(),
  '/export/custom/drbna/msn.php'         => fn() => msnFeed($site),         // alias chyběl → 404
  '/export/custom/drbna/toxin.php'       => fn() => toxinFeed($site),       // monitorovací partner, jen z jeho IP
  '/rss.html'                            => fn() => rss($site, 'in_rss'),
];
if (isset($feedRoutes[$path])) { require __DIR__ . '/../app/feeds.php'; $feedRoutes[$path](); return; }
// stránkované sitemapy článků (archiv má statisíce URL, do jednoho souboru se nevejdou)
if (preg_match('~^/sitemap-clanky-(\d+)\.xml$~', $path, $m)) {
  require __DIR__ . '/../app/feeds.php'; sitemapClanky($site, (int)$m[1]); return;
}

// fragment sekce pro přepínání štítků na homepage (bez layoutu)
if ($path === '/_sekce') {
  $ids = array_filter(array_map('intval', explode(',', (string)($_GET['r'] ?? ''))));
  echo rubricSectionArts($site, $ids, 1);
  return;
}

// vyhledávání
if ($path === '/hledat' || $path === '/hledat/') {
  $q = trim((string)($_GET['q'] ?? ''));
  // Hledání je nejdražší veřejný dotaz na webu a jde spustit bez přihlášení.
  // Třicet dotazů za minutu z jedné adresy pokryje i netrpělivého čtenáře;
  // crawler ve smyčce narazí. (audit 30. 7. 2026)
  if ($q !== '' && !rateOk('hledat:' . zkracenaIp((string)($_SERVER['REMOTE_ADDR'] ?? '')), 30, 1)) {
    http_response_code(429);
    header('Retry-After: 60');
    echo 'Příliš mnoho dotazů, zkuste to za chvíli.';
    return;
  }
  if (mb_strlen($q) === 1) $q = '';        // jednopísmenný dotaz nic smysluplného nenajde
  if ($q === '') { renderListing($site, 'Hledání', "a.content_type<>'seo'", '/aktualne.html'); }
  else {
    // Filtr nad výsledky. Chip „Podle štítku" se ukáže jen tehdy, když dotaz
    // opravdu sedí na název štítku a ten má na tomhle webu články — jinak by
    // vedl do prázdna. „Vše" je výchozí a chová se jako dosud.
    $kde = in_array($_GET['kde'] ?? '', ['text', 'stitek'], true) ? $_GET['kde'] : 'vse';
    $maStitek = searchStitekMa((int)$site['id'], $q);
    if (!$maStitek && $kde === 'stitek') $kde = 'vse';
    $chips = '';
    if ($maStitek) {
      $odkaz = fn(string $k) => '/hledat' . sq($site, 'q=' . rawurlencode($q) . ($k !== 'vse' ? '&kde=' . $k : ''));
      $chips = '<div class="chips">';
      foreach (['vse' => 'Vše', 'text' => 'V textu článku', 'stitek' => 'Podle štítku'] as $k => $popis)
        $chips .= '<a class="chip' . ($kde === $k ? ' on' : '') . '" href="' . e($odkaz($k)) . '">' . e($popis) . '</a>';
      $chips .= '</div>';
    }
    [$fc, $fp, $fo] = searchCond($q, $kde, $maStitek);
    renderListing($site, 'Hledání: „' . $q . '"', $fc, '/hledat', $fp, $chips, '', $fo);
  }
}
// článek: /{rubrika}/{legacy_id}-{slug}.html  (nebo /{legacy_id}-{slug}.html)
elseif (preg_match('~(?:^|/)(\d+)-[^/]+\.html$~', $path, $m)) {
  renderArticle($site, (int)$m[1]);
}
// homepage (/aktualne.html = URL 1:1 s ostrým webem)
elseif ($path === '/' || $path === '' || $path === '/aktualne.html') {
  renderHome($site);
}
// podrubrika: /zpravy/doprava.html (1:1 s ostrým webem)
elseif (preg_match('~^/([a-z0-9-]+)/([a-z0-9-]+)\.html$~', $path, $m)) {
  renderRubric($site, $m[2], $m[1]);
}
// rubrika: /zpravy.html (1:1) i staré /{slug}/
elseif (preg_match('~^/([a-z0-9-]+)\.html$~', $path, $m) || preg_match('~^/([a-z0-9-]+)/?$~', $path, $m)) {
  renderRubric($site, $m[1]);
}
else {
  // stará URL bez čísla (~19 tis. článků z let ≤2019, hlavně blogy): /…/slug.html
  // → 301 na kanonický číselný tvar (SEO váha se přenese)
  if (preg_match('~/([a-z0-9-]+)\.html$~', $path, $m) && articleSlugRedirect($site, $m[1])) return;
  renderNotFound($site);
}
