mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
Restructure repository and implement secure search feature
Phase 1: Consolidate shared infrastructure - Create shared/ directory for common code - Consolidate Database.php from front-backend and formulaire into unified shared/Database.php - Smart path detection for test.db vs posterg.db - Secure search with wildcard escaping and input validation - Support both singleton and direct instantiation patterns - Full CRUD methods for admin functionality - Move RateLimit.php to shared/ (30 requests/min) - Update all require paths across apps to use shared/ Phase 2: Reorganize directory structure - Rename front-backend/ → apps/public/ - Rename formulaire/ → apps/admin/ - Rename db/ → database/ - Update all file paths for new structure - Create root .gitignore excluding databases, cache, logs Implement secure search feature - Add apps/public/search.php with full-text search across theses - Search filters: query, year, orientation, AP program, keywords - Security features: - SQL injection prevention (prepared statements) - Wildcard injection prevention (escape % and _) - Input validation (max 200 chars, year range 1900-2100) - Rate limiting (30 req/min per IP) - Pagination limited to 100 results/page - XSS protection (htmlspecialchars on output) Add comprehensive test suite - Create apps/public/tests/ with proper structure - tests/Integration/SearchTest.php - 12 search scenarios - tests/Security/SecurityTest.php - vulnerability testing - tests/Unit/RateLimitTest.php - rate limit behavior - Create database/fixtures/CreateTestDatabase.php - Add apps/public/run-tests.php test runner - All tests passing (4/4 suites) Update deployment configuration - Rename justfile 'sync' recipe to 'deploy' - Create deploy group with separate deploy-public and deploy-admin - Add test-deploy recipe for test database - Exclude *.db, tests/, cache/, *.md from production deploy - Deploy shared/ to both public and admin locations Stats: +4482 insertions, -654 deletions across 72 files
This commit is contained in:
419
apps/public/search.php
Normal file
419
apps/public/search.php
Normal file
@@ -0,0 +1,419 @@
|
||||
<?php
|
||||
ini_set('display_errors', 0);
|
||||
ini_set('log_errors', 1);
|
||||
ini_set('error_log', 'error.log');
|
||||
|
||||
require_once __DIR__ . '/../../shared/Database.php';
|
||||
require_once __DIR__ . '/../../shared/RateLimit.php';
|
||||
|
||||
// Rate limiting: 30 requests per minute
|
||||
$rateLimit = new RateLimit(30, 60);
|
||||
|
||||
// Check rate limit
|
||||
if (!$rateLimit->check()) {
|
||||
// Send rate limit headers
|
||||
http_response_code(429);
|
||||
header('Retry-After: ' . $rateLimit->getResetTime());
|
||||
$rateLimit->sendHeaders();
|
||||
|
||||
// Display error page
|
||||
include 'inc/header.php';
|
||||
echo '<section class="section">';
|
||||
echo ' <div class="container">';
|
||||
echo ' <div class="notification is-danger">';
|
||||
echo ' <strong>Trop de requêtes</strong><br>';
|
||||
echo ' Vous avez dépassé la limite de ' . 30 . ' recherches par minute.';
|
||||
echo ' <br>Veuillez réessayer dans ' . $rateLimit->getResetTime() . ' secondes.';
|
||||
echo ' </div>';
|
||||
echo ' </div>';
|
||||
echo '</section>';
|
||||
include 'inc/footer.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Send rate limit headers for successful requests
|
||||
$rateLimit->sendHeaders();
|
||||
|
||||
// Periodic cleanup (1% chance)
|
||||
if (rand(1, 100) === 1) {
|
||||
$rateLimit->cleanup();
|
||||
}
|
||||
|
||||
// Pagination (max 100 per page)
|
||||
$page = isset($_GET['page']) ? intval($_GET['page']) : 1;
|
||||
$itemsPerPage = min(100, isset($_GET['per_page']) ? intval($_GET['per_page']) : 20);
|
||||
|
||||
// Collect search parameters
|
||||
$searchParams = [];
|
||||
if (!empty($_GET['query'])) {
|
||||
$searchParams['query'] = trim($_GET['query']);
|
||||
}
|
||||
if (!empty($_GET['year'])) {
|
||||
$searchParams['year'] = intval($_GET['year']);
|
||||
}
|
||||
if (!empty($_GET['orientation'])) {
|
||||
$searchParams['orientation'] = $_GET['orientation'];
|
||||
}
|
||||
if (!empty($_GET['ap_program'])) {
|
||||
$searchParams['ap_program'] = $_GET['ap_program'];
|
||||
}
|
||||
if (!empty($_GET['finality'])) {
|
||||
$searchParams['finality'] = $_GET['finality'];
|
||||
}
|
||||
if (!empty($_GET['keyword'])) {
|
||||
$searchParams['keyword'] = $_GET['keyword'];
|
||||
}
|
||||
if (!empty($_GET['format'])) {
|
||||
$searchParams['format'] = $_GET['format'];
|
||||
}
|
||||
if (!empty($_GET['language'])) {
|
||||
$searchParams['language'] = $_GET['language'];
|
||||
}
|
||||
if (isset($_GET['is_doctoral'])) {
|
||||
$searchParams['is_doctoral'] = $_GET['is_doctoral'] === '1';
|
||||
}
|
||||
|
||||
$validationError = null;
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Get search results
|
||||
$offset = ($page - 1) * $itemsPerPage;
|
||||
$results = $db->searchTheses($searchParams, $itemsPerPage, $offset);
|
||||
$totalItems = $db->countSearchResults($searchParams);
|
||||
$totalPages = ceil($totalItems / $itemsPerPage);
|
||||
|
||||
// Get filter options
|
||||
$years = $db->getAvailableYears();
|
||||
$orientations = $db->getOrientations();
|
||||
$apPrograms = $db->getApPrograms();
|
||||
$finalityTypes = $db->getFinalityTypes();
|
||||
$keywords = $db->getUsedKeywords();
|
||||
$formats = $db->getFormatTypes();
|
||||
$languages = $db->getLanguages();
|
||||
|
||||
} catch (InvalidArgumentException $e) {
|
||||
// Input validation error
|
||||
error_log("Search validation error: " . $e->getMessage());
|
||||
$validationError = $e->getMessage();
|
||||
$results = [];
|
||||
$totalPages = 0;
|
||||
$totalItems = 0;
|
||||
$years = [];
|
||||
$orientations = [];
|
||||
$apPrograms = [];
|
||||
$finalityTypes = [];
|
||||
$keywords = [];
|
||||
$formats = [];
|
||||
$languages = [];
|
||||
} catch (Exception $e) {
|
||||
// Database or other error
|
||||
error_log("Error in search: " . $e->getMessage());
|
||||
$validationError = "Une erreur est survenue lors de la recherche.";
|
||||
$results = [];
|
||||
$totalPages = 0;
|
||||
$totalItems = 0;
|
||||
$years = [];
|
||||
$orientations = [];
|
||||
$apPrograms = [];
|
||||
$finalityTypes = [];
|
||||
$keywords = [];
|
||||
$formats = [];
|
||||
$languages = [];
|
||||
}
|
||||
|
||||
include 'inc/header.php'; ?>
|
||||
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h1 class="title">Rechercher un mémoire</h1>
|
||||
|
||||
<!-- Display validation errors -->
|
||||
<?php if ($validationError): ?>
|
||||
<div class="notification is-danger">
|
||||
<strong>Erreur de validation :</strong> <?= htmlspecialchars($validationError); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Search Form -->
|
||||
<form method="GET" action="search.php">
|
||||
<div class="box">
|
||||
<!-- Main search query -->
|
||||
<div class="field">
|
||||
<label class="label">Recherche libre</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="query"
|
||||
placeholder="Titre, auteur, mots-clés, synopsis..."
|
||||
value="<?= htmlspecialchars($_GET['query'] ?? ''); ?>">
|
||||
</div>
|
||||
<p class="help">Recherche dans le titre, sous-titre, synopsis, auteurs, promoteurs et mots-clés</p>
|
||||
</div>
|
||||
|
||||
<!-- Advanced filters in columns -->
|
||||
<div class="columns is-multiline">
|
||||
<!-- Year filter -->
|
||||
<div class="column is-half">
|
||||
<div class="field">
|
||||
<label class="label">Année</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="year">
|
||||
<option value="">Toutes les années</option>
|
||||
<?php foreach ($years as $year): ?>
|
||||
<option value="<?= $year; ?>" <?= (isset($_GET['year']) && $_GET['year'] == $year) ? 'selected' : ''; ?>>
|
||||
<?= $year; ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orientation filter -->
|
||||
<div class="column is-half">
|
||||
<div class="field">
|
||||
<label class="label">Orientation</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="orientation">
|
||||
<option value="">Toutes les orientations</option>
|
||||
<?php foreach ($orientations as $orientation): ?>
|
||||
<option value="<?= htmlspecialchars($orientation['name']); ?>"
|
||||
<?= (isset($_GET['orientation']) && $_GET['orientation'] == $orientation['name']) ? 'selected' : ''; ?>>
|
||||
<?= htmlspecialchars($orientation['name']); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AP Program filter -->
|
||||
<div class="column is-half">
|
||||
<div class="field">
|
||||
<label class="label">Atelier Pratique (AP)</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="ap_program">
|
||||
<option value="">Tous les AP</option>
|
||||
<?php foreach ($apPrograms as $ap): ?>
|
||||
<option value="<?= htmlspecialchars($ap['name']); ?>"
|
||||
<?= (isset($_GET['ap_program']) && $_GET['ap_program'] == $ap['name']) ? 'selected' : ''; ?>>
|
||||
<?= htmlspecialchars($ap['name']); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Finality filter -->
|
||||
<div class="column is-half">
|
||||
<div class="field">
|
||||
<label class="label">Finalité</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="finality">
|
||||
<option value="">Toutes les finalités</option>
|
||||
<?php foreach ($finalityTypes as $finality): ?>
|
||||
<option value="<?= htmlspecialchars($finality['name']); ?>"
|
||||
<?= (isset($_GET['finality']) && $_GET['finality'] == $finality['name']) ? 'selected' : ''; ?>>
|
||||
<?= htmlspecialchars($finality['name']); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Format filter -->
|
||||
<div class="column is-half">
|
||||
<div class="field">
|
||||
<label class="label">Format</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="format">
|
||||
<option value="">Tous les formats</option>
|
||||
<?php foreach ($formats as $format): ?>
|
||||
<option value="<?= htmlspecialchars($format['name']); ?>"
|
||||
<?= (isset($_GET['format']) && $_GET['format'] == $format['name']) ? 'selected' : ''; ?>>
|
||||
<?= htmlspecialchars($format['name']); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Language filter -->
|
||||
<div class="column is-half">
|
||||
<div class="field">
|
||||
<label class="label">Langue</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="language">
|
||||
<option value="">Toutes les langues</option>
|
||||
<?php foreach ($languages as $language): ?>
|
||||
<option value="<?= htmlspecialchars($language['name']); ?>"
|
||||
<?= (isset($_GET['language']) && $_GET['language'] == $language['name']) ? 'selected' : ''; ?>>
|
||||
<?= htmlspecialchars($language['name']); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keyword filter -->
|
||||
<div class="column is-full">
|
||||
<div class="field">
|
||||
<label class="label">Mot-clé</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="keyword">
|
||||
<option value="">Tous les mots-clés</option>
|
||||
<?php foreach ($keywords as $keyword): ?>
|
||||
<option value="<?= htmlspecialchars($keyword['keyword']); ?>"
|
||||
<?= (isset($_GET['keyword']) && $_GET['keyword'] == $keyword['keyword']) ? 'selected' : ''; ?>>
|
||||
<?= htmlspecialchars($keyword['keyword']); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Thesis type filter -->
|
||||
<div class="column is-full">
|
||||
<div class="field">
|
||||
<label class="label">Type</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="is_doctoral">
|
||||
<option value="">TFE et Thèses doctorales</option>
|
||||
<option value="0" <?= (isset($_GET['is_doctoral']) && $_GET['is_doctoral'] == '0') ? 'selected' : ''; ?>>
|
||||
TFE uniquement
|
||||
</option>
|
||||
<option value="1" <?= (isset($_GET['is_doctoral']) && $_GET['is_doctoral'] == '1') ? 'selected' : ''; ?>>
|
||||
Thèses doctorales uniquement
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-link">Rechercher</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<a href="search.php" class="button is-light">Réinitialiser</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Search results -->
|
||||
<?php if (!empty($searchParams)): ?>
|
||||
<div class="notification is-info is-light">
|
||||
<strong><?= $totalItems; ?></strong> résultat<?= $totalItems > 1 ? 's' : ''; ?> trouvé<?= $totalItems > 1 ? 's' : ''; ?>
|
||||
</div>
|
||||
|
||||
<?php if (count($results) > 0): ?>
|
||||
<div class="columns is-multiline">
|
||||
<?php foreach ($results as $item): ?>
|
||||
<div class="column is-one-fifth">
|
||||
<a href="memoire.php?id=<?= $item['id']; ?>" class="card-link">
|
||||
<div class="card">
|
||||
<?php
|
||||
// Get cover image from thesis_files if available
|
||||
$coverImage = null;
|
||||
if (!empty($item['id'])) {
|
||||
$files = $db->getThesisFiles($item['id']);
|
||||
foreach ($files as $file) {
|
||||
$ext = strtolower(pathinfo($file['file_path'], PATHINFO_EXTENSION));
|
||||
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif']) && $file['file_type'] === 'main') {
|
||||
$coverImage = $file['file_path'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
<?php if ($coverImage): ?>
|
||||
<div class="card-image">
|
||||
<figure class="image">
|
||||
<img src="<?= htmlspecialchars($coverImage); ?>" alt="Image preview">
|
||||
</figure>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="card-content">
|
||||
<h4 class="title is-4">
|
||||
<?= htmlspecialchars($item['title']); ?>
|
||||
</h4>
|
||||
<h2 class="subtitle">
|
||||
<?= htmlspecialchars($item['authors'] ?? 'Auteur inconnu'); ?>
|
||||
</h2>
|
||||
<h3 class="tag title is-6 is-link is-light">
|
||||
<?= htmlspecialchars($item['year']); ?>
|
||||
</h3>
|
||||
<p class="block content">
|
||||
<?php
|
||||
$excerpt_length = 150;
|
||||
$synopsis = $item['synopsis'] ?? '';
|
||||
$description_excerpt = strlen($synopsis) > $excerpt_length
|
||||
? substr($synopsis, 0, $excerpt_length) . '...'
|
||||
: $synopsis;
|
||||
?>
|
||||
<?= htmlspecialchars($description_excerpt); ?>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<?php if ($totalPages > 1): ?>
|
||||
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
|
||||
<?php if ($page > 1): ?>
|
||||
<a href="?<?= http_build_query(array_merge($_GET, ['page' => $page - 1])); ?>" class="pagination-previous">Précédent</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($page < $totalPages): ?>
|
||||
<a href="?<?= http_build_query(array_merge($_GET, ['page' => $page + 1])); ?>" class="pagination-next">Suivant</a>
|
||||
<?php endif; ?>
|
||||
<ul class="pagination-list">
|
||||
<?php for ($i = 1; $i <= $totalPages; $i++): ?>
|
||||
<li>
|
||||
<a href="?<?= http_build_query(array_merge($_GET, ['page' => $i])); ?>"
|
||||
class="pagination-link <?= $i === $page ? 'is-current' : ''; ?>">
|
||||
<?= $i; ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endfor; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php else: ?>
|
||||
<div class="notification">
|
||||
Utilisez le formulaire ci-dessus pour rechercher des mémoires.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php include 'inc/footer.php'; ?>
|
||||
Reference in New Issue
Block a user