db = $db; $this->rateLimit = $rateLimit; } // ── Factory ─────────────────────────────────────────────────────────────── /** * Convenience factory: builds dependencies, checks rate limit (sends 429 * and exits if exceeded), then returns a ready-to-use controller instance. */ public static function create(): self { require_once APP_ROOT . '/src/Database.php'; require_once APP_ROOT . '/src/RateLimit.php'; $rateLimit = new RateLimit(self::RATE_LIMIT_MAX, self::RATE_LIMIT_WINDOW); if (!$rateLimit->check()) { self::sendRateLimitResponse($rateLimit); } $rateLimit->sendHeaders(); // Probabilistic cleanup (1-in-100 requests) to prune stale entries if (rand(1, 100) === 1) { $rateLimit->cleanup(); } return new self(Database::getInstance(), $rateLimit); } // ── Entry points ────────────────────────────────────────────────────────── /** * Handle the search results page (public/search.php). * Requires a ?query= parameter; always returns search-result view variables. * * @return array */ public function handleSearch(): array { $searchParams = $this->collectSearchParams(); $page = isset($_GET['page']) ? max(1, (int) $_GET['page']) : 1; $offset = ($page - 1) * self::ITEMS_PER_PAGE; $validationError = null; $results = []; $totalItems = 0; $totalPages = 0; $years = []; $orientations = []; $apPrograms = []; try { $results = $this->db->searchTheses($searchParams, self::ITEMS_PER_PAGE, $offset); $totalItems = $this->db->countSearchResults($searchParams); $totalPages = (int) ceil($totalItems / self::ITEMS_PER_PAGE); $years = $this->db->getAvailableYears(); $orientations = $this->db->getAllOrientations(); $apPrograms = $this->db->getAllAPPrograms(); } catch (InvalidArgumentException $e) { $validationError = $e->getMessage(); } catch (Exception $e) { error_log('SearchController: ' . $e->getMessage()); $validationError = 'Une erreur est survenue.'; } // Preserve all active params, strip 'page' (pagination partial adds it) $baseParams = array_diff_key($_GET, ['page' => '']); $query = $_GET['query'] ?? ''; return [ 'searchParams' => $searchParams, 'page' => $page, 'totalItems' => $totalItems, 'totalPages' => $totalPages, 'results' => $results, 'validationError' => $validationError, 'baseParams' => $baseParams, // Filter dropdowns 'years' => $years, 'orientations' => $orientations, 'apPrograms' => $apPrograms, // Page meta 'searchBarValue' => $query, 'pageTitle' => $query !== '' ? 'Recherche : ' . $query . ' – Posterg' : 'Recherche – Posterg', 'metaDescription' => "Résultats de recherche dans le répertoire des TFE de l'erg.", 'ogTags' => [ 'type' => 'website', 'title' => 'Recherche – Posterg', 'description' => "Résultats de recherche dans le répertoire des TFE de l'erg.", 'url' => 'https://posterg.erg.be/search.php', 'site_name' => 'Posterg – ERG', ], 'currentNav' => 'repertoire', 'extraCss' => ['/assets/css/search.css'], 'bodyClass' => 'search-body', ]; } /** * Handle the répertoire index page (public/repertoire.php). * Serves the filter-column index; HTMX partial swaps are handled here too. * * @return array */ public function handleRepertoire(): array { $isHtmx = !empty($_SERVER['HTTP_HX_REQUEST']); $activeFilters = $this->collectFilterParams(); $repData = null; $validationError = null; try { $repData = $this->db->getRepertoireFilterData($activeFilters); } catch (InvalidArgumentException $e) { $validationError = $e->getMessage(); } catch (Exception $e) { error_log('SearchController: ' . $e->getMessage()); $validationError = 'Une erreur est survenue.'; } // HTMX partial: render just the index div and exit if ($isHtmx && $repData !== null) { $this->renderRepertoirePartial($repData, $activeFilters); } return [ 'repData' => $repData, 'activeFilters' => $activeFilters, 'isHtmx' => $isHtmx, 'validationError' => $validationError, // Page meta 'searchBarValue' => '', 'pageTitle' => 'Répertoire – Posterg', 'metaDescription' => "Parcourez le répertoire des mémoires de fin d'études (TFE) de l'erg – École de Recherches Graphiques de Bruxelles.", 'ogTags' => [ 'type' => 'website', 'title' => 'Répertoire – Posterg', 'description' => "Parcourez le répertoire des mémoires de fin d'études (TFE) de l'erg – École de Recherches Graphiques de Bruxelles.", 'url' => 'https://posterg.erg.be/repertoire.php', 'site_name' => 'Posterg – ERG', ], 'currentNav' => 'repertoire', 'extraCss' => ['/assets/css/search.css'], 'bodyClass' => 'search-body', ]; } // ── Private helpers ─────────────────────────────────────────────────────── /** * Render the repertoire index partial and exit (for HTMX swaps). * Never returns. */ private function renderRepertoirePartial(array $repData, array $activeFilters): never { header('Content-Type: text/html; charset=UTF-8'); $isHtmx = true; include APP_ROOT . '/templates/partials/repertoire-index.php'; exit; } /** * Collect and sanitise repertoire filter params from $_GET. * Params: fy[] (years), ap[] (AP programs), or[] (orientations), * fi[] (finality types), kw[] (keywords) * * @return array{years:int[], ap:string[], or:string[], fi:string[], kw:string[]} */ private function collectFilterParams(): array { $sanitiseStrings = function(mixed $raw, int $maxLen = 100): array { if (!is_array($raw)) return []; $out = []; foreach ($raw as $v) { $v = trim((string)$v); if ($v !== '' && strlen($v) <= $maxLen) { $out[] = $v; } } return array_values(array_unique($out)); }; $years = []; if (!empty($_GET['fy']) && is_array($_GET['fy'])) { foreach ($_GET['fy'] as $y) { $y = (int)$y; if ($y >= 1900 && $y <= 2100) $years[] = $y; } $years = array_values(array_unique($years)); } return [ 'years' => $years, 'ap' => $sanitiseStrings($_GET['ap'] ?? []), 'or' => $sanitiseStrings($_GET['or'] ?? []), 'fi' => $sanitiseStrings($_GET['fi'] ?? []), 'kw' => $sanitiseStrings($_GET['kw'] ?? []), ]; } /** * Sanitise and collect valid text search parameters from $_GET. * * @return array */ private function collectSearchParams(): array { $params = []; if (!empty($_GET['query'])) { $params['query'] = trim((string) $_GET['query']); } if (!empty($_GET['year'])) { $params['year'] = (int) $_GET['year']; } if (!empty($_GET['orientation'])) { $params['orientation'] = (string) $_GET['orientation']; } if (!empty($_GET['ap_program'])) { $params['ap_program'] = (string) $_GET['ap_program']; } if (!empty($_GET['keyword'])) { $params['keyword'] = (string) $_GET['keyword']; } return $params; } // ── Rate-limit response ─────────────────────────────────────────────────── /** * Send a 429 response and exit. Never returns. */ private static function sendRateLimitResponse(RateLimit $rateLimit): never { http_response_code(429); header('Retry-After: ' . $rateLimit->getResetTime()); $retrySeconds = (int) $rateLimit->getResetTime(); echo << Trop de requêtes – Posterg

Trop de requêtes

Vous avez effectué trop de recherches en peu de temps.
Réessayez dans {$retrySeconds} secondes.

HTML; exit; } }