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 . " – XAMXAM" : "Recherche – XAMXAM", "metaDescription" => "Résultats de recherche dans le répertoire des TFE de l'erg.", "ogTags" => [ "type" => "website", "title" => "Recherche – XAMXAM", "description" => "Résultats de recherche dans le répertoire des TFE de l'erg.", "url" => "https://xamxam.erg.be/search", "site_name" => "XAMXAM – ERG", ], "currentNav" => "repertoire", "extraCss" => ["/assets/css/repertoire.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 – XAMXAM", "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 – XAMXAM", "description" => "Parcourez le répertoire des mémoires de fin d'études (TFE) de l'erg – École de Recherches Graphiques de Bruxelles.", "url" => "https://xamxam.erg.be/repertoire", "site_name" => "XAMXAM – ERG", ], "currentNav" => "repertoire", "extraCss" => ["/assets/css/repertoire.css"], "bodyClass" => "search-body", ]; } // ── Private helpers ─────────────────────────────────────────────────────── /** * Render the repertoire index partial and exit (for HTMX swaps). * Never returns. */ /** * HTMX endpoint: returns a popover snippet for a student name. * Renders directly and exits. */ public function handleStudentPreview(): never { $name = trim($_GET['name'] ?? ''); header('Content-Type: text/html; charset=UTF-8'); if ($name === '') { echo ''; exit(); } $theses = $this->db->getThesesByAuthorName($name); if (empty($theses)) { echo ''; exit(); } header('Cache-Control: public, max-age=300'); include APP_ROOT . '/templates/partials/student-preview.php'; exit(); } 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 – XAMXAM

Trop de requêtes

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

HTML; exit(); } }