Files
xamxam/src/SearchController.php
Pontoporeia 9a58b97cb8 Extract SearchController from public/search.php
Move all data-fetching and request logic out of the 285-line search page
into src/SearchController.php:

- SearchController::create() — static factory; builds RateLimit + Database
  dependencies, sends HTTP 429 (and exits) if rate limit is exceeded,
  runs probabilistic cleanup, returns ready instance
- SearchController::handle() — sanitises GET params (query/year/orientation/
  ap_program/keyword), runs all DB queries (searchTheses, countSearchResults,
  getAvailableYears, getAllOrientations, getAllAPPrograms, getUsedTags,
  getPublishedAuthors), builds alphabetical author→id map, assembles
  OG/meta tags, returns a flat array of view variables
- Rate-limit 429 HTML response moved into private sendRateLimitResponse()

public/search.php is now a 6-line dispatcher:
  require SearchController; extract(SearchController::create()->handle());
followed by the unchanged view template (162 lines total, was 285).

The view template is byte-for-byte equivalent: same HTML, same variable
names, same pagination partial include.
2026-04-06 15:33:08 +02:00

258 lines
9.2 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 / répertoire page.
* The entry point (public/search.php) delegates to this class and receives
* a plain array of view variables ready for template inclusion.
*
* 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
*
* The class has NO output side-effects; all template rendering stays in
* public/search.php so the view layer remains easy to inspect and modify.
*/
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);
}
// ── Main entry point ─────────────────────────────────────────────────────
/**
* Process the current request and return all variables needed by the view.
*
* @return array<string, mixed>
*/
public function handle(): array
{
$searchParams = $this->collectSearchParams();
$hasSearch = !empty($searchParams);
$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 = [];
$keywords = [];
$students = [];
try {
if ($hasSearch) {
$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();
$keywords = $this->db->getUsedTags();
// Fetch id+authors only — lean query bypassing the fat v_theses_public view
$students = $this->db->getPublishedAuthors();
} catch (InvalidArgumentException $e) {
$validationError = $e->getMessage();
} catch (Exception $e) {
error_log('SearchController: ' . $e->getMessage());
$validationError = 'Une erreur est survenue.';
}
// Build the author index map (répertoire index view)
$authorMap = $this->buildAuthorMap($students);
// Preserve all active search/filter params, strip 'page' (pagination partial adds it)
$baseParams = array_diff_key($_GET, ['page' => '']);
return [
// Search state
'searchParams' => $searchParams,
'hasSearch' => $hasSearch,
'page' => $page,
'totalItems' => $totalItems,
'totalPages' => $totalPages,
'results' => $results,
'validationError' => $validationError,
'baseParams' => $baseParams,
// Filter / index data
'years' => $years,
'orientations' => $orientations,
'apPrograms' => $apPrograms,
'keywords' => $keywords,
'authorMap' => $authorMap,
// Page meta
'searchBarValue' => $_GET['query'] ?? '',
'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. Recherche par année, orientation, atelier et mots-clés.',
'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. Recherche par année, orientation, atelier et mots-clés.',
'url' => 'https://posterg.erg.be/search.php',
'site_name' => 'Posterg ERG',
],
'currentNav' => 'repertoire',
'extraCss' => ['/assets/css/search.css'],
'bodyClass' => 'search-body',
];
}
// ── Private helpers ───────────────────────────────────────────────────────
/**
* Sanitise and collect valid 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;
}
/**
* Build an alphabetically-sorted author → thesis-id map from the
* published-authors list. Each author name maps to their first thesis id.
*
* @param array<int, array{id: int, authors: string}> $students
* @return array<string, int>
*/
private function buildAuthorMap(array $students): array
{
$map = [];
foreach ($students as $s) {
if (empty($s['authors'])) {
continue;
}
foreach (explode(',', $s['authors']) as $name) {
$name = trim($name);
if ($name !== '' && !isset($map[$name])) {
$map[$name] = (int) $s['id'];
}
}
}
ksort($map);
return $map;
}
// ── 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 Posterg</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">POSTERG</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;
}
}