mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
refactor: reorganize to standard PHP structure
- Moved /lib → /src (PHP source code)
- Moved /includes → /public/includes (main site templates)
- Admin section remains self-contained in /public/admin with its own /inc
- Updated all require/include paths across codebase
- Updated config/bootstrap.php, justfile, tests, docs
- All tests passing ✅
Structure now follows PHP best practices:
/config - Configuration files
/database - SQLite database + schema
/docs - Documentation (intact)
/nginx - Server config (intact)
/public - Web-accessible files (entry point)
/admin - Self-contained admin interface
/assets - CSS, fonts, icons
/includes - Main site templates (header/footer)
/scripts - Deployment scripts (intact)
/src - PHP source classes (Database, AdminAuth, RateLimit)
/tests - Test suites
This commit is contained in:
121
src/AdminAuth.php
Normal file
121
src/AdminAuth.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
/**
|
||||
* Minimal PHP session guard for the admin panel.
|
||||
*
|
||||
* This is a defence-in-depth layer that sits behind nginx Basic Auth.
|
||||
* It protects against proxy misconfiguration, bypass, and local-dev
|
||||
* scenarios where the reverse proxy may be absent.
|
||||
*
|
||||
* Usage (top of every admin page):
|
||||
* require_once __DIR__ . '/../../lib/AdminAuth.php';
|
||||
* AdminAuth::requireLogin();
|
||||
*
|
||||
* Credential setup (production):
|
||||
* php -r "echo password_hash('your-password', PASSWORD_DEFAULT);"
|
||||
* # Paste result into config/admin_credentials.php as ADMIN_PASSWORD_HASH
|
||||
*
|
||||
* If ADMIN_PASSWORD_HASH is not defined the guard is a no-op (dev / cli-server).
|
||||
*/
|
||||
class AdminAuth
|
||||
{
|
||||
private const SESSION_KEY = 'admin_authenticated';
|
||||
private const LOGIN_URL = '/admin/login.php';
|
||||
|
||||
/**
|
||||
* Start the PHP session with hardened cookie parameters.
|
||||
* Idempotent — safe to call even if session is already active.
|
||||
*/
|
||||
private static function startSession(): void
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_NONE) {
|
||||
return;
|
||||
}
|
||||
// Harden session cookie (item #8)
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/admin',
|
||||
'secure' => (php_sapi_name() !== 'cli-server'),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate every admin page.
|
||||
*
|
||||
* Authentication order:
|
||||
* 1. Session already authenticated → pass through.
|
||||
* 2. nginx Basic Auth password present in $_SERVER['PHP_AUTH_PW']
|
||||
* → validate it with password_verify; on success create session
|
||||
* (seamless: user only sees the browser Basic Auth dialog).
|
||||
* 3. Neither → redirect to the PHP login form (fallback for when
|
||||
* the reverse proxy is absent / misconfigured).
|
||||
*
|
||||
* No-op if ADMIN_PASSWORD_HASH is not defined (development / cli-server).
|
||||
*/
|
||||
public static function requireLogin(): void
|
||||
{
|
||||
self::startSession();
|
||||
if (!defined('ADMIN_PASSWORD_HASH')) {
|
||||
// No password configured → development / cli-server mode, skip PHP auth.
|
||||
return;
|
||||
}
|
||||
if (!empty($_SESSION[self::SESSION_KEY])) {
|
||||
return; // already authenticated via session
|
||||
}
|
||||
// Try to auto-authenticate from the nginx Basic Auth credentials.
|
||||
// If nginx Basic Auth is bypassed, PHP_AUTH_PW won't be set and this
|
||||
// branch is skipped — the fallback login form is shown instead.
|
||||
if (isset($_SERVER['PHP_AUTH_PW']) && self::login($_SERVER['PHP_AUTH_PW'])) {
|
||||
return;
|
||||
}
|
||||
header('Location: ' . self::LOGIN_URL);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a plaintext password against the stored bcrypt hash.
|
||||
* On success: regenerates the session ID and marks the session authenticated.
|
||||
*
|
||||
* @return bool true on success, false on wrong password / no hash configured.
|
||||
*/
|
||||
public static function login(string $password): bool
|
||||
{
|
||||
$hash = defined('ADMIN_PASSWORD_HASH') ? ADMIN_PASSWORD_HASH : null;
|
||||
if ($hash === null || !password_verify($password, $hash)) {
|
||||
return false;
|
||||
}
|
||||
self::startSession();
|
||||
session_regenerate_id(true);
|
||||
$_SESSION[self::SESSION_KEY] = true;
|
||||
$_SESSION['admin_login_at'] = time();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the current request is authenticated (without redirecting).
|
||||
*/
|
||||
public static function isAuthenticated(): bool
|
||||
{
|
||||
self::startSession();
|
||||
return !empty($_SESSION[self::SESSION_KEY]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the session (logout).
|
||||
*/
|
||||
public static function logout(): void
|
||||
{
|
||||
self::startSession();
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$p = session_get_cookie_params();
|
||||
setcookie(
|
||||
session_name(), '', time() - 86400,
|
||||
$p['path'], $p['domain'], $p['secure'], $p['httponly']
|
||||
);
|
||||
}
|
||||
session_destroy();
|
||||
}
|
||||
}
|
||||
652
src/Database.php
Normal file
652
src/Database.php
Normal file
@@ -0,0 +1,652 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
/**
|
||||
* Unified Database connection class for Post-ERG thesis database
|
||||
* Combines functionality from both front-backend and formulaire
|
||||
* Supports both singleton (front-backend) and direct instantiation (formulaire)
|
||||
*/
|
||||
class Database {
|
||||
private static $instance = null;
|
||||
private $pdo;
|
||||
private $dbPath;
|
||||
|
||||
/**
|
||||
* Constructor - public to support both singleton and direct instantiation
|
||||
* @param string $dbPath Optional database path override
|
||||
*/
|
||||
public function __construct($dbPath = null) {
|
||||
$this->dbPath = $this->determineDatabasePath($dbPath);
|
||||
|
||||
try {
|
||||
$this->pdo = new PDO('sqlite:' . $this->dbPath);
|
||||
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
|
||||
// Enable foreign key constraints
|
||||
$this->pdo->exec('PRAGMA foreign_keys = ON');
|
||||
} catch (PDOException $e) {
|
||||
error_log("Database connection failed: " . $e->getMessage());
|
||||
throw new Exception("Impossible de se connecter à la base de données.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine database path
|
||||
* Uses centralized config from config.php
|
||||
* Priority: custom path > config.php settings
|
||||
*/
|
||||
private function determineDatabasePath($customPath = null) {
|
||||
// Allow explicit override
|
||||
if ($customPath !== null && file_exists($customPath)) {
|
||||
return $customPath;
|
||||
}
|
||||
|
||||
// Use centralized configuration
|
||||
return getDatabasePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance (for front-backend)
|
||||
* @return Database
|
||||
*/
|
||||
public static function getInstance() {
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PDO connection
|
||||
* @return PDO
|
||||
*/
|
||||
public function getConnection() {
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PDO instance (alias for formulaire compatibility)
|
||||
* @return PDO
|
||||
*/
|
||||
public function getPDO() {
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TRANSACTION SUPPORT (from formulaire)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Begin a transaction
|
||||
*/
|
||||
public function beginTransaction() {
|
||||
return $this->pdo->beginTransaction();
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a transaction
|
||||
*/
|
||||
public function commit() {
|
||||
return $this->pdo->commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback a transaction
|
||||
*/
|
||||
public function rollback() {
|
||||
return $this->pdo->rollback();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// PUBLIC SITE METHODS (from front-backend)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Get all published theses with pagination
|
||||
*/
|
||||
public function getPublishedTheses($limit = 10, $offset = 0) {
|
||||
$sql = "SELECT * FROM v_theses_public ORDER BY year DESC, title ASC LIMIT :limit OFFSET :offset";
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count all published theses
|
||||
*/
|
||||
public function countPublishedTheses() {
|
||||
$sql = "SELECT COUNT(*) as count FROM theses WHERE is_published = 1";
|
||||
$stmt = $this->pdo->query($sql);
|
||||
$result = $stmt->fetch();
|
||||
return $result['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thesis by ID with all related data (for public site)
|
||||
*/
|
||||
public function getThesisById($id) {
|
||||
$sql = "SELECT * FROM v_theses_full WHERE id = :id AND is_published = 1";
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$thesis = $stmt->fetch();
|
||||
|
||||
if (!$thesis) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get associated files
|
||||
$thesis['files'] = $this->getThesisFiles($id);
|
||||
|
||||
return $thesis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thesis by ID (for admin - includes unpublished)
|
||||
* @param int $id Thesis ID
|
||||
* @return array|null Thesis data or null if not found
|
||||
*/
|
||||
public function getThesis($id) {
|
||||
$stmt = $this->pdo->prepare("SELECT * FROM v_theses_full WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get files associated with a thesis
|
||||
*/
|
||||
public function getThesisFiles($thesisId) {
|
||||
$sql = "SELECT * FROM thesis_files WHERE thesis_id = :thesis_id ORDER BY file_type, uploaded_at";
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->bindValue(':thesis_id', $thesisId, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SEARCH FUNCTIONALITY (from front-backend - secure implementation)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Escape LIKE wildcards in user input to prevent wildcard injection
|
||||
*/
|
||||
private function escapeLikeString($string) {
|
||||
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize search parameters
|
||||
* @throws InvalidArgumentException if validation fails
|
||||
*/
|
||||
private function validateSearchParams($params) {
|
||||
$validated = [];
|
||||
|
||||
if (!empty($params['query'])) {
|
||||
$query = trim($params['query']);
|
||||
if (strlen($query) > 200) {
|
||||
throw new InvalidArgumentException("Search query too long (max 200 characters)");
|
||||
}
|
||||
$validated['query'] = $this->escapeLikeString($query);
|
||||
}
|
||||
|
||||
if (!empty($params['year'])) {
|
||||
$year = intval($params['year']);
|
||||
if ($year < 1900 || $year > 2100) {
|
||||
throw new InvalidArgumentException("Invalid year");
|
||||
}
|
||||
$validated['year'] = $year;
|
||||
}
|
||||
|
||||
if (!empty($params['orientation'])) {
|
||||
$orientation = trim($params['orientation']);
|
||||
if (strlen($orientation) > 100) {
|
||||
throw new InvalidArgumentException("Orientation name too long");
|
||||
}
|
||||
$validated['orientation'] = $this->escapeLikeString($orientation);
|
||||
}
|
||||
|
||||
if (!empty($params['ap_program'])) {
|
||||
$ap = trim($params['ap_program']);
|
||||
if (strlen($ap) > 100) {
|
||||
throw new InvalidArgumentException("AP program name too long");
|
||||
}
|
||||
$validated['ap_program'] = $this->escapeLikeString($ap);
|
||||
}
|
||||
|
||||
if (!empty($params['finality'])) {
|
||||
$finality = trim($params['finality']);
|
||||
if (strlen($finality) > 100) {
|
||||
throw new InvalidArgumentException("Finality name too long");
|
||||
}
|
||||
$validated['finality'] = $this->escapeLikeString($finality);
|
||||
}
|
||||
|
||||
if (!empty($params['keyword'])) {
|
||||
$keyword = trim($params['keyword']);
|
||||
if (strlen($keyword) > 100) {
|
||||
throw new InvalidArgumentException("Keyword too long");
|
||||
}
|
||||
$validated['keyword'] = $this->escapeLikeString($keyword);
|
||||
}
|
||||
|
||||
if (!empty($params['format'])) {
|
||||
$format = trim($params['format']);
|
||||
if (strlen($format) > 100) {
|
||||
throw new InvalidArgumentException("Format name too long");
|
||||
}
|
||||
$validated['format'] = $this->escapeLikeString($format);
|
||||
}
|
||||
|
||||
if (!empty($params['language'])) {
|
||||
$language = trim($params['language']);
|
||||
if (strlen($language) > 50) {
|
||||
throw new InvalidArgumentException("Language name too long");
|
||||
}
|
||||
$validated['language'] = $this->escapeLikeString($language);
|
||||
}
|
||||
|
||||
if (isset($params['is_doctoral'])) {
|
||||
$validated['is_doctoral'] = (bool)$params['is_doctoral'];
|
||||
}
|
||||
|
||||
return $validated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search theses with filters (secure implementation)
|
||||
*/
|
||||
public function searchTheses($params = [], $limit = 20, $offset = 0) {
|
||||
$params = $this->validateSearchParams($params);
|
||||
$limit = max(1, min(100, intval($limit)));
|
||||
$offset = max(0, intval($offset));
|
||||
|
||||
$conditions = ["is_published = 1"];
|
||||
$bindings = [];
|
||||
|
||||
if (!empty($params['query'])) {
|
||||
$conditions[] = "(
|
||||
title LIKE :query ESCAPE '\\' OR
|
||||
subtitle LIKE :query ESCAPE '\\' OR
|
||||
synopsis LIKE :query ESCAPE '\\' OR
|
||||
authors LIKE :query ESCAPE '\\' OR
|
||||
supervisors LIKE :query ESCAPE '\\' OR
|
||||
keywords LIKE :query ESCAPE '\\'
|
||||
)";
|
||||
$bindings[':query'] = '%' . $params['query'] . '%';
|
||||
}
|
||||
|
||||
if (!empty($params['year'])) {
|
||||
$conditions[] = "year = :year";
|
||||
$bindings[':year'] = $params['year'];
|
||||
}
|
||||
|
||||
if (!empty($params['orientation'])) {
|
||||
$conditions[] = "orientation LIKE :orientation ESCAPE '\\'";
|
||||
$bindings[':orientation'] = '%' . $params['orientation'] . '%';
|
||||
}
|
||||
|
||||
if (!empty($params['ap_program'])) {
|
||||
$conditions[] = "ap_program LIKE :ap_program ESCAPE '\\'";
|
||||
$bindings[':ap_program'] = '%' . $params['ap_program'] . '%';
|
||||
}
|
||||
|
||||
if (!empty($params['finality'])) {
|
||||
$conditions[] = "finality_type LIKE :finality ESCAPE '\\'";
|
||||
$bindings[':finality'] = '%' . $params['finality'] . '%';
|
||||
}
|
||||
|
||||
if (!empty($params['keyword'])) {
|
||||
$conditions[] = "keywords LIKE :keyword ESCAPE '\\'";
|
||||
$bindings[':keyword'] = '%' . $params['keyword'] . '%';
|
||||
}
|
||||
|
||||
if (!empty($params['format'])) {
|
||||
$conditions[] = "formats LIKE :format ESCAPE '\\'";
|
||||
$bindings[':format'] = '%' . $params['format'] . '%';
|
||||
}
|
||||
|
||||
if (!empty($params['language'])) {
|
||||
$conditions[] = "languages LIKE :language ESCAPE '\\'";
|
||||
$bindings[':language'] = '%' . $params['language'] . '%';
|
||||
}
|
||||
|
||||
if (isset($params['is_doctoral'])) {
|
||||
$conditions[] = "is_doctoral = :is_doctoral";
|
||||
$bindings[':is_doctoral'] = $params['is_doctoral'] ? 1 : 0;
|
||||
}
|
||||
|
||||
$whereClause = implode(' AND ', $conditions);
|
||||
$sql = "SELECT * FROM v_theses_public WHERE $whereClause ORDER BY year DESC, title ASC LIMIT :limit OFFSET :offset";
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
foreach ($bindings as $key => $value) {
|
||||
$stmt->bindValue($key, $value);
|
||||
}
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
|
||||
$stmt->execute();
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count search results
|
||||
*/
|
||||
public function countSearchResults($params = []) {
|
||||
$params = $this->validateSearchParams($params);
|
||||
$conditions = ["is_published = 1"];
|
||||
$bindings = [];
|
||||
|
||||
if (!empty($params['query'])) {
|
||||
$conditions[] = "(
|
||||
title LIKE :query ESCAPE '\\' OR
|
||||
subtitle LIKE :query ESCAPE '\\' OR
|
||||
synopsis LIKE :query ESCAPE '\\' OR
|
||||
authors LIKE :query ESCAPE '\\' OR
|
||||
supervisors LIKE :query ESCAPE '\\' OR
|
||||
keywords LIKE :query ESCAPE '\\'
|
||||
)";
|
||||
$bindings[':query'] = '%' . $params['query'] . '%';
|
||||
}
|
||||
|
||||
if (!empty($params['year'])) {
|
||||
$conditions[] = "year = :year";
|
||||
$bindings[':year'] = $params['year'];
|
||||
}
|
||||
|
||||
if (!empty($params['orientation'])) {
|
||||
$conditions[] = "orientation LIKE :orientation ESCAPE '\\'";
|
||||
$bindings[':orientation'] = '%' . $params['orientation'] . '%';
|
||||
}
|
||||
|
||||
if (!empty($params['ap_program'])) {
|
||||
$conditions[] = "ap_program LIKE :ap_program ESCAPE '\\'";
|
||||
$bindings[':ap_program'] = '%' . $params['ap_program'] . '%';
|
||||
}
|
||||
|
||||
if (!empty($params['finality'])) {
|
||||
$conditions[] = "finality_type LIKE :finality ESCAPE '\\'";
|
||||
$bindings[':finality'] = '%' . $params['finality'] . '%';
|
||||
}
|
||||
|
||||
if (!empty($params['keyword'])) {
|
||||
$conditions[] = "keywords LIKE :keyword ESCAPE '\\'";
|
||||
$bindings[':keyword'] = '%' . $params['keyword'] . '%';
|
||||
}
|
||||
|
||||
if (!empty($params['format'])) {
|
||||
$conditions[] = "formats LIKE :format ESCAPE '\\'";
|
||||
$bindings[':format'] = '%' . $params['format'] . '%';
|
||||
}
|
||||
|
||||
if (!empty($params['language'])) {
|
||||
$conditions[] = "languages LIKE :language ESCAPE '\\'";
|
||||
$bindings[':language'] = '%' . $params['language'] . '%';
|
||||
}
|
||||
|
||||
if (isset($params['is_doctoral'])) {
|
||||
$conditions[] = "is_doctoral = :is_doctoral";
|
||||
$bindings[':is_doctoral'] = $params['is_doctoral'] ? 1 : 0;
|
||||
}
|
||||
|
||||
$whereClause = implode(' AND ', $conditions);
|
||||
$sql = "SELECT COUNT(*) as count FROM v_theses_public WHERE $whereClause";
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
foreach ($bindings as $key => $value) {
|
||||
$stmt->bindValue($key, $value);
|
||||
}
|
||||
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetch();
|
||||
return $result['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available years from published theses
|
||||
*/
|
||||
public function getAvailableYears() {
|
||||
$sql = "SELECT DISTINCT year FROM theses WHERE is_published = 1 ORDER BY year DESC";
|
||||
$stmt = $this->pdo->query($sql);
|
||||
return $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all orientations
|
||||
*/
|
||||
public function getOrientations() {
|
||||
$sql = "SELECT * FROM orientations ORDER BY name";
|
||||
$stmt = $this->pdo->query($sql);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for formulaire compatibility
|
||||
*/
|
||||
public function getAllOrientations() {
|
||||
return $this->getOrientations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all AP programs
|
||||
*/
|
||||
public function getApPrograms() {
|
||||
$sql = "SELECT * FROM ap_programs ORDER BY name";
|
||||
$stmt = $this->pdo->query($sql);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for formulaire compatibility
|
||||
*/
|
||||
public function getAllAPPrograms() {
|
||||
return $this->getApPrograms();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all finality types
|
||||
*/
|
||||
public function getFinalityTypes() {
|
||||
$sql = "SELECT * FROM finality_types ORDER BY name";
|
||||
$stmt = $this->pdo->query($sql);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for formulaire compatibility
|
||||
*/
|
||||
public function getAllFinalityTypes() {
|
||||
return $this->getFinalityTypes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all keywords used in published theses
|
||||
*/
|
||||
public function getUsedKeywords() {
|
||||
$sql = "SELECT DISTINCT k.* FROM keywords k
|
||||
INNER JOIN thesis_keywords tk ON k.id = tk.keyword_id
|
||||
INNER JOIN theses t ON tk.thesis_id = t.id
|
||||
WHERE t.is_published = 1
|
||||
ORDER BY k.keyword";
|
||||
$stmt = $this->pdo->query($sql);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all format types
|
||||
*/
|
||||
public function getFormatTypes() {
|
||||
$sql = "SELECT * FROM format_types ORDER BY name";
|
||||
$stmt = $this->pdo->query($sql);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for formulaire compatibility
|
||||
*/
|
||||
public function getAllFormatTypes() {
|
||||
return $this->getFormatTypes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all languages
|
||||
*/
|
||||
public function getLanguages() {
|
||||
$sql = "SELECT * FROM languages ORDER BY name";
|
||||
$stmt = $this->pdo->query($sql);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for formulaire compatibility
|
||||
*/
|
||||
public function getAllLanguages() {
|
||||
return $this->getLanguages();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// CRUD METHODS (from formulaire)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Find or create an author
|
||||
*/
|
||||
public function findOrCreateAuthor($name, $email = null) {
|
||||
$stmt = $this->pdo->prepare("SELECT id FROM authors WHERE name = ?");
|
||||
$stmt->execute([$name]);
|
||||
$author = $stmt->fetch();
|
||||
|
||||
if ($author) {
|
||||
if ($email && $email !== '') {
|
||||
$updateStmt = $this->pdo->prepare("UPDATE authors SET email = ? WHERE id = ?");
|
||||
$updateStmt->execute([$email, $author['id']]);
|
||||
}
|
||||
return $author['id'];
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare("INSERT INTO authors (name, email) VALUES (?, ?)");
|
||||
$stmt->execute([$name, $email]);
|
||||
return $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create a supervisor
|
||||
*/
|
||||
public function findOrCreateSupervisor($name) {
|
||||
$stmt = $this->pdo->prepare("SELECT id FROM supervisors WHERE name = ?");
|
||||
$stmt->execute([$name]);
|
||||
$supervisor = $stmt->fetch();
|
||||
|
||||
if ($supervisor) {
|
||||
return $supervisor['id'];
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare("INSERT INTO supervisors (name) VALUES (?)");
|
||||
$stmt->execute([$name]);
|
||||
return $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create a keyword
|
||||
*/
|
||||
public function findOrCreateKeyword($keyword) {
|
||||
$keyword = trim($keyword);
|
||||
if (empty($keyword)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare("SELECT id FROM keywords WHERE keyword = ?");
|
||||
$stmt->execute([$keyword]);
|
||||
$kw = $stmt->fetch();
|
||||
|
||||
if ($kw) {
|
||||
return $kw['id'];
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare("INSERT INTO keywords (keyword) VALUES (?)");
|
||||
$stmt->execute([$keyword]);
|
||||
return $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orientation ID by name
|
||||
*/
|
||||
public function getOrientationId($name) {
|
||||
$stmt = $this->pdo->prepare("SELECT id FROM orientations WHERE name = ?");
|
||||
$stmt->execute([$name]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ? $result['id'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AP program ID by name
|
||||
*/
|
||||
public function getAPProgramId($name) {
|
||||
$stmt = $this->pdo->prepare("SELECT id FROM ap_programs WHERE name = ?");
|
||||
$stmt->execute([$name]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ? $result['id'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get finality type ID by name
|
||||
*/
|
||||
public function getFinalityId($name) {
|
||||
$stmt = $this->pdo->prepare("SELECT id FROM finality_types WHERE name = ?");
|
||||
$stmt->execute([$name]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ? $result['id'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get language ID by name
|
||||
*/
|
||||
public function getLanguageId($name) {
|
||||
$stmt = $this->pdo->prepare("SELECT id FROM languages WHERE name = ?");
|
||||
$stmt->execute([$name]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ? $result['id'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get format type ID by name
|
||||
*/
|
||||
public function getFormatId($name) {
|
||||
$stmt = $this->pdo->prepare("SELECT id FROM format_types WHERE name = ?");
|
||||
$stmt->execute([$name]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ? $result['id'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a thesis file record
|
||||
*/
|
||||
public function insertThesisFile($thesisId, $fileType, $filePath, $fileName, $fileSize, $mimeType) {
|
||||
$stmt = $this->pdo->prepare("
|
||||
INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([$thesisId, $fileType, $filePath, $fileName, $fileSize, $mimeType]);
|
||||
return $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SINGLETON PATTERN ENFORCEMENT
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Prevent cloning
|
||||
*/
|
||||
private function __clone() {}
|
||||
|
||||
/**
|
||||
* Prevent unserialization
|
||||
*/
|
||||
public function __wakeup() {
|
||||
throw new Exception("Cannot unserialize singleton");
|
||||
}
|
||||
}
|
||||
162
src/RateLimit.php
Normal file
162
src/RateLimit.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Simple file-based rate limiter
|
||||
* Prevents abuse by limiting requests per IP address
|
||||
*/
|
||||
class RateLimit {
|
||||
private $cacheDir;
|
||||
private $maxRequests;
|
||||
private $timeWindow;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
* @param int $maxRequests Maximum requests allowed in time window
|
||||
* @param int $timeWindow Time window in seconds
|
||||
* @param string $cacheDir Directory to store rate limit data
|
||||
*/
|
||||
public function __construct($maxRequests = 30, $timeWindow = 60, $cacheDir = null) {
|
||||
$this->maxRequests = $maxRequests;
|
||||
$this->timeWindow = $timeWindow;
|
||||
$this->cacheDir = $cacheDir ?? __DIR__ . '/cache/rate_limit';
|
||||
|
||||
// Create cache directory if it doesn't exist
|
||||
if (!is_dir($this->cacheDir)) {
|
||||
mkdir($this->cacheDir, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client identifier (IP address)
|
||||
* @return string Client identifier
|
||||
*/
|
||||
private function getClientIdentifier(): string {
|
||||
// Use REMOTE_ADDR only — HTTP_X_FORWARDED_FOR and HTTP_CLIENT_IP
|
||||
// are fully attacker-controlled request headers and must never be
|
||||
// trusted for rate-limiting purposes (an attacker can rotate them
|
||||
// freely to bypass the limiter). Nginx-level rate limiting also
|
||||
// uses $binary_remote_addr for the same reason. If this app is
|
||||
// ever placed behind a trusted reverse-proxy, IP extraction should
|
||||
// be handled at the nginx level, not here.
|
||||
return $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache file path for a client
|
||||
* @param string $identifier Client identifier
|
||||
* @return string File path
|
||||
*/
|
||||
private function getCacheFile($identifier) {
|
||||
return $this->cacheDir . '/' . md5($identifier) . '.json';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if client has exceeded rate limit
|
||||
* @return bool True if allowed, false if rate limit exceeded
|
||||
*/
|
||||
public function check() {
|
||||
$identifier = $this->getClientIdentifier();
|
||||
$file = $this->getCacheFile($identifier);
|
||||
|
||||
// Load existing request timestamps
|
||||
$data = [];
|
||||
if (file_exists($file)) {
|
||||
$content = file_get_contents($file);
|
||||
$data = json_decode($content, true) ?? [];
|
||||
}
|
||||
|
||||
// Clean old entries outside time window
|
||||
$now = time();
|
||||
$data = array_filter($data, function($timestamp) use ($now) {
|
||||
return ($now - $timestamp) < $this->timeWindow;
|
||||
});
|
||||
|
||||
// Check if limit exceeded
|
||||
if (count($data) >= $this->maxRequests) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add new request timestamp
|
||||
$data[] = $now;
|
||||
|
||||
// Save updated data
|
||||
file_put_contents($file, json_encode($data));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining requests for current client
|
||||
* @return int Number of requests remaining
|
||||
*/
|
||||
public function getRemaining() {
|
||||
$identifier = $this->getClientIdentifier();
|
||||
$file = $this->getCacheFile($identifier);
|
||||
|
||||
if (!file_exists($file)) {
|
||||
return $this->maxRequests;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file);
|
||||
$data = json_decode($content, true) ?? [];
|
||||
|
||||
// Clean old entries
|
||||
$now = time();
|
||||
$data = array_filter($data, function($timestamp) use ($now) {
|
||||
return ($now - $timestamp) < $this->timeWindow;
|
||||
});
|
||||
|
||||
return max(0, $this->maxRequests - count($data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time until rate limit resets
|
||||
* @return int Seconds until reset
|
||||
*/
|
||||
public function getResetTime() {
|
||||
$identifier = $this->getClientIdentifier();
|
||||
$file = $this->getCacheFile($identifier);
|
||||
|
||||
if (!file_exists($file)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file);
|
||||
$data = json_decode($content, true) ?? [];
|
||||
|
||||
if (empty($data)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Find oldest timestamp
|
||||
$oldest = min($data);
|
||||
$resetTime = $oldest + $this->timeWindow - time();
|
||||
|
||||
return max(0, $resetTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old cache files (run periodically)
|
||||
* Removes files that haven't been modified in 24 hours
|
||||
*/
|
||||
public function cleanup() {
|
||||
$files = glob($this->cacheDir . '/*.json');
|
||||
$cutoff = time() - 86400; // 24 hours
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (filemtime($file) < $cutoff) {
|
||||
unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send rate limit headers
|
||||
* Provides information about rate limits to clients
|
||||
*/
|
||||
public function sendHeaders() {
|
||||
header('X-RateLimit-Limit: ' . $this->maxRequests);
|
||||
header('X-RateLimit-Remaining: ' . $this->getRemaining());
|
||||
header('X-RateLimit-Reset: ' . (time() + $this->getResetTime()));
|
||||
}
|
||||
}
|
||||
1
src/cache/rate_limit/ad921d60486366258809553a3db49a4a.json
vendored
Normal file
1
src/cache/rate_limit/ad921d60486366258809553a3db49a4a.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
[1770894664]
|
||||
55
src/config.php
Normal file
55
src/config.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Configuration for Post-ERG thesis database
|
||||
* Central location for database paths and environment settings
|
||||
*/
|
||||
|
||||
// Database paths relative to repository root
|
||||
define('DB_ROOT', __DIR__ . '/..');
|
||||
|
||||
// Test database (used in development)
|
||||
define('DB_TEST_PATH', DB_ROOT . '/database/test.db');
|
||||
|
||||
// Production database (used on server)
|
||||
define('DB_PROD_PATH', DB_ROOT . '/database/posterg.db');
|
||||
|
||||
/**
|
||||
* Determine which database to use
|
||||
* Checks environment variable DB_ENV, defaults to auto-detection
|
||||
*
|
||||
* Set DB_ENV in your environment:
|
||||
* - export DB_ENV=test # Force test database
|
||||
* - export DB_ENV=prod # Force production database
|
||||
*
|
||||
* Auto-detection logic:
|
||||
* - If test.db exists, use it (development)
|
||||
* - Otherwise use posterg.db (production)
|
||||
*/
|
||||
function getDatabasePath() {
|
||||
// Allow explicit override via environment variable
|
||||
$env = getenv('DB_ENV');
|
||||
|
||||
if ($env === 'test') {
|
||||
return DB_TEST_PATH;
|
||||
}
|
||||
|
||||
if ($env === 'prod') {
|
||||
return DB_PROD_PATH;
|
||||
}
|
||||
|
||||
// Auto-detect: prefer test database if it exists
|
||||
if (file_exists(DB_TEST_PATH)) {
|
||||
return DB_TEST_PATH;
|
||||
}
|
||||
|
||||
// Default to production database
|
||||
return DB_PROD_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in test/development mode
|
||||
*/
|
||||
function isTestMode() {
|
||||
return getDatabasePath() === DB_TEST_PATH;
|
||||
}
|
||||
Reference in New Issue
Block a user