Files
xamxam/app/src/Controllers/SearchController.php
2026-04-27 19:30:54 +02:00

365 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* SearchController
*
* Handles all data-fetching logic for the public search and répertoire pages.
*
* Entry points:
* - public/search.php calls handleSearch() — text-query results
* - public/repertoire.php calls handleRepertoire() — filter index + HTMX swaps
*
* Responsibilities:
* - Rate-limit enforcement (returns early HTTP 429 response when needed)
* - GET parameter sanitisation and validation
* - Database queries (search + index listings)
* - OG / meta tag assembly
* - HTMX partial response for repertoire filter swaps
*
* The class has NO output side-effects; all template rendering stays in the
* respective public/*.php files so the view layer remains easy to inspect.
* Exception: renderRepertoirePartial() exits early for HTMX requests.
*/
class SearchController
{
private const RATE_LIMIT_MAX = 30;
private const RATE_LIMIT_WINDOW = 60; // seconds
private const ITEMS_PER_PAGE = 30;
private Database $db;
private RateLimit $rateLimit;
public function __construct(Database $db, RateLimit $rateLimit)
{
$this->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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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 <<<HTML
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Trop de requêtes XAMXAM</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0d0d0d;
color: #e0e0e0;
font-family: 'Helvetica Neue', Arial, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.box { max-width: 520px; text-align: center; }
.box__logo {
font-size: 1.1rem; font-weight: 700;
letter-spacing: .12em; text-transform: uppercase;
color: #fff; margin-bottom: 2.5rem;
}
.box__title { font-size: 1.6rem; font-weight: 300; margin-bottom: 1rem; }
.box__text { font-size: .95rem; color: #999; line-height: 1.7; }
</style>
</head>
<body>
<div class="box">
<div class="box__logo">XAMXAM</div>
<h1 class="box__title">Trop de requêtes</h1>
<p class="box__text">Vous avez effectué trop de recherches en peu de temps.<br>
Réessayez dans {$retrySeconds} secondes.</p>
</div>
</body>
</html>
HTML;
exit();
}
}