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 + performance pragmas $this->pdo->exec('PRAGMA foreign_keys = ON'); $this->pdo->exec('PRAGMA journal_mode = WAL'); $this->pdo->exec('PRAGMA synchronous = NORMAL'); $this->pdo->exec('PRAGMA cache_size = -8000'); } 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. * Priority: explicit override → APP_ROOT /storage/posterg.db. * APP_ROOT is defined by bootstrap.php before any controller loads Database. */ private function determineDatabasePath($customPath = null): string { if ($customPath !== null && file_exists($customPath)) { return $customPath; } $root = defined('APP_ROOT') ? APP_ROOT : __DIR__ . '/..'; return $root . '/storage/posterg.db'; } /** * 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; } /** * Return the resolved path of the database file in use. */ public function getDatabasePath(): string { return $this->dbPath; } // ======================================================================== // 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(); } /** * Get theses from the latest published year, in random order (per request). * Used for the default home page view. */ public function getLatestYearTheses(int $limit = 24): array { $sql = "SELECT * FROM v_theses_public WHERE year = (SELECT MAX(year) FROM theses WHERE is_published = 1) ORDER BY RANDOM() LIMIT :limit"; $stmt = $this->pdo->prepare($sql); $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(); } /** * Get the latest year that has published theses */ public function getLatestPublishedYear(): ?int { $stmt = $this->pdo->query("SELECT MAX(year) FROM theses WHERE is_published = 1"); $val = $stmt->fetchColumn(); return $val ? (int)$val : null; } /** * 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; } /** * Build WHERE conditions and named bindings from validated search params. * Always includes the `is_published = 1` guard. * * @param array $params Already-validated params (output of validateSearchParams) * @return array{0: string[], 1: array} [$conditions, $bindings] */ private function buildSearchConditions(array $params): array { $conditions = ["vp.is_published = 1"]; $bindings = []; if (!empty($params['query'])) { $conditions[] = "( vp.title LIKE :query ESCAPE '\\' OR vp.subtitle LIKE :query ESCAPE '\\' OR vp.synopsis LIKE :query ESCAPE '\\' OR vp.authors LIKE :query ESCAPE '\\' OR vp.supervisors LIKE :query ESCAPE '\\' OR EXISTS ( SELECT 1 FROM thesis_tags tt2 JOIN tags tg2 ON tg2.id = tt2.tag_id WHERE tt2.thesis_id = vp.id AND tg2.name LIKE :query ESCAPE '\\' ) )"; $bindings[':query'] = '%' . $params['query'] . '%'; } if (!empty($params['year'])) { $conditions[] = "vp.year = :year"; $bindings[':year'] = $params['year']; } if (!empty($params['orientation'])) { $conditions[] = "vp.orientation LIKE :orientation ESCAPE '\\'"; $bindings[':orientation'] = '%' . $params['orientation'] . '%'; } if (!empty($params['ap_program'])) { $conditions[] = "vp.ap_program LIKE :ap_program ESCAPE '\\'"; $bindings[':ap_program'] = '%' . $params['ap_program'] . '%'; } if (!empty($params['finality'])) { $conditions[] = "vp.finality_type LIKE :finality ESCAPE '\\'"; $bindings[':finality'] = '%' . $params['finality'] . '%'; } if (!empty($params['keyword'])) { $conditions[] = "EXISTS ( SELECT 1 FROM thesis_tags tt_kw JOIN tags tg_kw ON tg_kw.id = tt_kw.tag_id WHERE tt_kw.thesis_id = vp.id AND tg_kw.name LIKE :keyword ESCAPE '\\' )"; $bindings[':keyword'] = '%' . $params['keyword'] . '%'; } if (!empty($params['format'])) { $conditions[] = "vp.formats LIKE :format ESCAPE '\\'"; $bindings[':format'] = '%' . $params['format'] . '%'; } if (!empty($params['language'])) { $conditions[] = "vp.languages LIKE :language ESCAPE '\\'"; $bindings[':language'] = '%' . $params['language'] . '%'; } if (isset($params['is_doctoral'])) { $conditions[] = "vp.is_doctoral = :is_doctoral"; $bindings[':is_doctoral'] = $params['is_doctoral'] ? 1 : 0; } return [$conditions, $bindings]; } /** * Search theses with filters (secure implementation) */ public function searchTheses(array $params = [], $limit = 20, $offset = 0) { $params = $this->validateSearchParams($params); $limit = max(1, min(100, intval($limit))); $offset = max(0, intval($offset)); [$conditions, $bindings] = $this->buildSearchConditions($params); $whereClause = implode(' AND ', $conditions); $sql = "SELECT vp.* FROM v_theses_public vp WHERE $whereClause ORDER BY vp.year DESC, vp.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(array $params = []) { $params = $this->validateSearchParams($params); [$conditions, $bindings] = $this->buildSearchConditions($params); $whereClause = implode(' AND ', $conditions); $sql = "SELECT COUNT(*) as count FROM v_theses_public vp 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 */ /** * Return ALL published theses (no cap) — for internal use (student index). * Not exposed to user-controlled input. * * @deprecated Use getPublishedAuthors() for the répertoire student index — * it avoids the expensive v_theses_public view and only fetches * the two columns actually needed (id, authors). */ public function getAllPublishedTheses(): array { $stmt = $this->pdo->query( "SELECT vp.* FROM v_theses_public vp ORDER BY vp.year DESC, vp.title ASC" ); return $stmt->fetchAll(); } /** * Lean query for the répertoire student-name index. * * Avoids the fat v_theses_public view (15 JOINs + 8 GROUP_CONCAT B-trees). * Queries thesis_authors → authors directly, letting the index on * theses(is_published) filter the base table before any JOIN. * * Returns rows of [id => int, authors => "Name1,Name2"]. */ public function getPublishedAuthors(): array { $stmt = $this->pdo->query( "SELECT t.id, GROUP_CONCAT(a.name ORDER BY ta.author_order ASC) AS authors FROM theses t JOIN thesis_authors ta ON ta.thesis_id = t.id JOIN authors a ON a.id = ta.author_id WHERE t.is_published = 1 GROUP BY t.id ORDER BY MIN(a.name) ASC" ); return $stmt->fetchAll(); } /** * Fetch all published theses for a given author name. * Returns rows of [id => int, title => string]. */ public function getThesesByAuthorName(string $name): array { $stmt = $this->pdo->prepare( "SELECT vp.id, vp.title, vp.subtitle, vp.year, vp.synopsis, vp.orientation, vp.finality_type, vp.banner_path, vp.authors FROM v_theses_public vp JOIN thesis_authors ta ON ta.thesis_id = vp.id JOIN authors a ON a.id = ta.author_id WHERE a.name = ? ORDER BY vp.year DESC, vp.title ASC" ); $stmt->execute([$name]); return $stmt->fetchAll(); } /** * Batch variant: fetch preview data for a list of author names in one query. * Returns [ authorName => [ thesis, ... ], ... ] * * @param string[] $names * @return array */ public function getThesesForAuthors(array $names): array { if (empty($names)) return []; $placeholders = implode(',', array_fill(0, count($names), '?')); $stmt = $this->pdo->prepare( "SELECT a.name AS author_name, vp.id, vp.title, vp.subtitle, vp.year, vp.synopsis, vp.orientation, vp.finality_type, vp.banner_path, vp.authors FROM v_theses_public vp JOIN thesis_authors ta ON ta.thesis_id = vp.id JOIN authors a ON a.id = ta.author_id WHERE a.name IN ($placeholders) ORDER BY a.name ASC, vp.year DESC, vp.title ASC" ); $stmt->execute($names); $rows = $stmt->fetchAll(); $grouped = []; foreach ($rows as $row) { $grouped[$row['author_name']][] = $row; } return $grouped; } 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 getAllOrientations(): array { $stmt = $this->pdo->query("SELECT * FROM orientations ORDER BY name"); return $stmt->fetchAll(); } /** * Get all AP programs */ public function getAllAPPrograms(): array { $stmt = $this->pdo->query("SELECT * FROM ap_programs ORDER BY name"); return $stmt->fetchAll(); } /** * Get all finality types */ public function getAllFinalityTypes(): array { $stmt = $this->pdo->query("SELECT * FROM finality_types ORDER BY name"); return $stmt->fetchAll(); } /** * Get all keywords used in published theses */ public function getUsedTags(): array { $sql = "SELECT DISTINCT tg.id, tg.name FROM tags tg JOIN thesis_tags tt ON tg.id = tt.tag_id JOIN theses th ON tt.thesis_id = th.id WHERE th.is_published = 1 ORDER BY tg.name"; $stmt = $this->pdo->query($sql); return $stmt->fetchAll(); } /** * Compute répertoire filter data. * * Given a set of active filters (each an array of values, combined as AND * across filter types, OR within each filter type), returns: * - matched_ids : int[] thesis IDs matching ALL active filters * - years : array all years with matched flag * - ap_programs : array all AP programs with matched flag * - orientations : array all orientations with matched flag * - finality_types: array all finality types with matched flag * - keywords : array all used keywords with matched flag * - students : array [id, authors] rows for matched theses only * * For each column, "matched" means the value appears in at least one thesis * that satisfies all the OTHER active filters (excluding that column's own * filter when computing its own relevance). * * @param array{years:int[], ap:string[], or:string[], fi:string[], kw:string[]} $filters */ public function getRepertoireFilterData(array $filters): array { $baseJoins = " FROM theses t LEFT JOIN orientations o ON t.orientation_id = o.id LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id LEFT JOIN finality_types ft ON t.finality_id = ft.id "; // Build WHERE + bindings excluding one dimension (for that column's own relevance) $buildWhere = function(string $exclude) use ($filters): array { $conditions = ['t.is_published = 1']; $bindings = []; if ($exclude !== 'years' && !empty($filters['years'])) { $ph = implode(',', array_fill(0, count($filters['years']), '?')); $conditions[] = "t.year IN ($ph)"; foreach ($filters['years'] as $v) $bindings[] = (int)$v; } if ($exclude !== 'ap' && !empty($filters['ap'])) { $ph = implode(',', array_fill(0, count($filters['ap']), '?')); $conditions[] = "ap.name IN ($ph)"; foreach ($filters['ap'] as $v) $bindings[] = (string)$v; } if ($exclude !== 'or' && !empty($filters['or'])) { $ph = implode(',', array_fill(0, count($filters['or']), '?')); $conditions[] = "o.name IN ($ph)"; foreach ($filters['or'] as $v) $bindings[] = (string)$v; } if ($exclude !== 'fi' && !empty($filters['fi'])) { $ph = implode(',', array_fill(0, count($filters['fi']), '?')); $conditions[] = "ft.name IN ($ph)"; foreach ($filters['fi'] as $v) $bindings[] = (string)$v; } if ($exclude !== 'kw' && !empty($filters['kw'])) { foreach ($filters['kw'] as $kv) { $conditions[] = 'EXISTS (SELECT 1 FROM thesis_tags tt2 JOIN tags tg2 ON tg2.id=tt2.tag_id WHERE tt2.thesis_id=t.id AND tg2.name=?)'; $bindings[] = (string)$kv; } } return [implode(' AND ', $conditions), $bindings]; }; $exec = function(string $sql, array $b): array { $stmt = $this->pdo->prepare($sql); $stmt->execute($b); return $stmt->fetchAll(); }; // Full intersection — matched thesis IDs [$wAll, $bAll] = $buildWhere('__none__'); $matchedIds = array_column($exec("SELECT t.id $baseJoins WHERE $wAll", $bAll), 'id'); // Years [$w, $b] = $buildWhere('years'); $matchedYears = array_column($exec("SELECT DISTINCT t.year $baseJoins WHERE $w ORDER BY t.year DESC", $b), 'year'); $allYears = array_column($exec("SELECT DISTINCT year FROM theses WHERE is_published=1 ORDER BY year DESC", []), 'year'); $yearsOut = array_map(fn($y) => ['value' => $y, 'matched' => in_array($y, $matchedYears, true)], $allYears); // AP programs [$w, $b] = $buildWhere('ap'); $matchedAp = array_column($exec("SELECT DISTINCT ap.name $baseJoins WHERE $w AND ap.name IS NOT NULL ORDER BY ap.name", $b), 'name'); $allAp = array_column($exec("SELECT name FROM ap_programs ORDER BY name", []), 'name'); $apOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedAp, true)], $allAp); // Orientations [$w, $b] = $buildWhere('or'); $matchedOr = array_column($exec("SELECT DISTINCT o.name $baseJoins WHERE $w AND o.name IS NOT NULL ORDER BY o.name", $b), 'name'); $allOr = array_column($exec("SELECT name FROM orientations ORDER BY name", []), 'name'); $orOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedOr, true)], $allOr); // Finality types [$w, $b] = $buildWhere('fi'); $matchedFi = array_column($exec("SELECT DISTINCT ft.name $baseJoins WHERE $w AND ft.name IS NOT NULL ORDER BY ft.name", $b), 'name'); $allFi = array_column($exec("SELECT name FROM finality_types ORDER BY name", []), 'name'); $fiOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedFi, true)], $allFi); // Keywords [$w, $b] = $buildWhere('kw'); $matchedKw = array_column($exec( "SELECT DISTINCT tg.name $baseJoins JOIN thesis_tags tt ON tt.thesis_id = t.id JOIN tags tg ON tg.id = tt.tag_id WHERE $w ORDER BY tg.name", $b), 'name'); $allKw = array_column($exec( "SELECT DISTINCT tg.name FROM tags tg JOIN thesis_tags tt ON tg.id = tt.tag_id JOIN theses th ON tt.thesis_id = th.id WHERE th.is_published = 1 ORDER BY tg.name", []), 'name'); $kwOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedKw, true)], $allKw); // Students (output only — full intersection) $studentsOut = []; if (!empty($matchedIds)) { $ph = implode(',', array_fill(0, count($matchedIds), '?')); $studentsOut = $exec( "SELECT t.id, GROUP_CONCAT(a.name ORDER BY ta.author_order ASC) AS authors FROM theses t JOIN thesis_authors ta ON ta.thesis_id = t.id JOIN authors a ON a.id = ta.author_id WHERE t.id IN ($ph) GROUP BY t.id ORDER BY MIN(a.name) ASC", $matchedIds ); } return [ 'matched_ids' => $matchedIds, 'years' => $yearsOut, 'ap_programs' => $apOut, 'orientations' => $orOut, 'finality_types' => $fiOut, 'keywords' => $kwOut, 'students' => $studentsOut, ]; } /** * Get all format types */ public function getAllFormatTypes(): array { $stmt = $this->pdo->query("SELECT * FROM format_types ORDER BY name"); return $stmt->fetchAll(); } /** * Get all languages */ public function getAllLanguages(): array { $stmt = $this->pdo->query("SELECT * FROM languages ORDER BY name"); return $stmt->fetchAll(); } // ======================================================================== // ADMIN LIST METHOD // ======================================================================== /** * Return theses for the admin list view, with optional filters. * * Filters (all optional): * 'search' string – matches title, subtitle, or author name (LIKE) * 'year' int – exact year match * 'orientation' int – orientation_id exact match * * @param array $filters * @return array */ /** * Allowed sort columns for the admin list. * Maps query-string `sort` values to safe SQL ORDER BY expressions. */ private const SORT_MAP = [ 'id' => 't.id', 'identifier' => 't.identifier', 'title' => 't.title', 'year' => 't.year', 'orientation' => 'o.name', 'ap_program' => 'ap.name', 'is_published' => 't.is_published', 'submitted_at' => 't.submitted_at', ]; /** * Build the ORDER BY clause from sort/direction parameters. * Returns a safe SQL fragment (never interpolates raw user input). */ private function buildOrderBy(array $filters): string { $sort = $filters['sort'] ?? 'submitted_at'; $dir = isset($filters['dir']) && strtolower($filters['dir']) === 'asc' ? 'ASC' : 'DESC'; $col = self::SORT_MAP[$sort] ?? self::SORT_MAP['submitted_at']; // Secondary sort for stable ordering $secondary = ($sort === 'year') ? ', t.title ASC' : ', t.id DESC'; return "ORDER BY {$col} {$dir}{$secondary}"; } /** * Count theses matching the given admin filters (no LIMIT). * Used alongside getThesesList() to calculate total pages. */ public function getThesesListCount(array $filters = []): int { $sql = "SELECT COUNT(DISTINCT t.id) FROM theses t LEFT JOIN orientations o ON t.orientation_id = o.id LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id LEFT JOIN authors a ON ta.author_id = a.id WHERE 1=1"; $params = []; if (!empty($filters['search'])) { $sql .= " AND (t.title LIKE ? OR t.subtitle LIKE ? OR a.name LIKE ?)"; $searchParam = '%' . $filters['search'] . '%'; $params[] = $searchParam; $params[] = $searchParam; $params[] = $searchParam; } if (!empty($filters['year'])) { $sql .= " AND t.year = ?"; $params[] = intval($filters['year']); } if (!empty($filters['orientation'])) { $sql .= " AND t.orientation_id = ?"; $params[] = intval($filters['orientation']); } if (!empty($filters['ap'])) { $sql .= " AND t.ap_program_id = ?"; $params[] = intval($filters['ap']); } $stmt = $this->pdo->prepare($sql); $stmt->execute($params); return (int) $stmt->fetchColumn(); } public function getThesesList(array $filters = [], int $limit = 0, int $offset = 0): array { $sql = "SELECT t.id, t.identifier, t.title, t.subtitle, t.year, o.name as orientation, ap.name as ap_program, GROUP_CONCAT(DISTINCT a.name) as authors, t.submitted_at, t.is_published, at.name as access_type FROM theses t LEFT JOIN orientations o ON t.orientation_id = o.id LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id LEFT JOIN authors a ON ta.author_id = a.id LEFT JOIN access_types at ON t.access_type_id = at.id WHERE 1=1"; $params = []; if (!empty($filters['search'])) { $sql .= " AND (t.title LIKE ? OR t.subtitle LIKE ? OR a.name LIKE ?)"; $searchParam = '%' . $filters['search'] . '%'; $params[] = $searchParam; $params[] = $searchParam; $params[] = $searchParam; } if (!empty($filters['year'])) { $sql .= " AND t.year = ?"; $params[] = intval($filters['year']); } if (!empty($filters['orientation'])) { $sql .= " AND t.orientation_id = ?"; $params[] = intval($filters['orientation']); } if (!empty($filters['ap'])) { $sql .= " AND t.ap_program_id = ?"; $params[] = intval($filters['ap']); } $orderBy = $this->buildOrderBy($filters); $sql .= " GROUP BY t.id {$orderBy}"; if ($limit > 0) { $sql .= " LIMIT :limit OFFSET :offset"; } $stmt = $this->pdo->prepare($sql); // Bind named params only when LIMIT is used (mix of ? and : is not allowed). if ($limit > 0) { // Re-bind all positional params by index. foreach ($params as $i => $val) { $stmt->bindValue($i + 1, $val); } $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); $stmt->execute(); } else { $stmt->execute($params); } return $stmt->fetchAll(); } /** * Get distinct years present in the theses table (admin, includes unpublished). */ public function getAllYears(): array { $stmt = $this->pdo->query("SELECT DISTINCT year FROM theses ORDER BY year DESC"); return $stmt->fetchAll(PDO::FETCH_COLUMN); } /** * Return whole-database thesis counts, independent of any active filter. * * Always reflects the full theses table so the stats bar in admin/index.php * shows accurate numbers even when a search or year filter is active. * * @return array{total: int, published: int, pending: int} */ public function getThesesStats(): array { $stmt = $this->pdo->query( "SELECT COUNT(*) AS total, SUM(CASE WHEN is_published = 1 THEN 1 ELSE 0 END) AS published, SUM(CASE WHEN is_published = 0 THEN 1 ELSE 0 END) AS pending FROM theses" ); $row = $stmt->fetch(); return [ 'total' => (int) $row['total'], 'published' => (int) $row['published'], 'pending' => (int) $row['pending'], ]; } // ======================================================================== // CRUD METHODS (from formulaire) // ======================================================================== /** * Find or create an author */ public function findOrCreateAuthor($name, $email = null, bool $showContact = false) { $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 = ?, show_contact = ? WHERE id = ?"); $updateStmt->execute([$email, $showContact ? 1 : 0, $author['id']]); } else { $updateStmt = $this->pdo->prepare("UPDATE authors SET show_contact = ? WHERE id = ?"); $updateStmt->execute([$showContact ? 1 : 0, $author['id']]); } return $author['id']; } $stmt = $this->pdo->prepare("INSERT INTO authors (name, email, show_contact) VALUES (?, ?, ?)"); $stmt->execute([$name, $email, $showContact ? 1 : 0]); 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 tag (formerly keyword) */ public function findOrCreateTag(string $name): ?int { $name = trim($name); if ($name === '') { return null; } $stmt = $this->pdo->prepare("SELECT id FROM tags WHERE name = ?"); $stmt->execute([$name]); $row = $stmt->fetch(); if ($row) { return (int)$row['id']; } $stmt = $this->pdo->prepare("INSERT INTO tags (name) VALUES (?)"); $stmt->execute([$name]); return (int)$this->pdo->lastInsertId(); } // ======================================================================== // TAG MANAGEMENT (admin) // ======================================================================== /** * Return all tags with a count of associated (published) theses. */ public function getAllTagsWithCount(): array { $stmt = $this->pdo->query(" SELECT tg.id, tg.name, COUNT(DISTINCT tt.thesis_id) as thesis_count FROM tags tg LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id GROUP BY tg.id ORDER BY tg.name COLLATE NOCASE "); return $stmt->fetchAll(); } /** * Rename a tag. Throws if the new name already exists. */ public function renameTag(int $id, string $newName): void { $newName = trim($newName); if ($newName === '') throw new Exception("Le nom du tag ne peut pas être vide."); // Check uniqueness $stmt = $this->pdo->prepare("SELECT id FROM tags WHERE name = ? AND id != ?"); $stmt->execute([$newName, $id]); if ($stmt->fetch()) throw new Exception("Un tag avec ce nom existe déjà."); $this->pdo->prepare("UPDATE tags SET name = ? WHERE id = ?")->execute([$newName, $id]); } /** * Merge sourceId into targetId: reassign all thesis_tags rows, then delete source. * Uses INSERT OR IGNORE to avoid PK conflicts. */ public function mergeTag(int $sourceId, int $targetId): void { if ($sourceId === $targetId) throw new Exception("Source et destination identiques."); $this->pdo->beginTransaction(); try { // Re-point thesis_tags rows from source → target (skip conflicts) $this->pdo->prepare(" INSERT OR IGNORE INTO thesis_tags (tag_id, thesis_id) SELECT ?, thesis_id FROM thesis_tags WHERE tag_id = ? ")->execute([$targetId, $sourceId]); // Delete the old source rows $this->pdo->prepare("DELETE FROM thesis_tags WHERE tag_id = ?")->execute([$sourceId]); // Delete the source tag itself $this->pdo->prepare("DELETE FROM tags WHERE id = ?")->execute([$sourceId]); $this->pdo->commit(); } catch (\Throwable $e) { $this->pdo->rollBack(); throw $e; } } /** * Delete a tag and all its thesis_tags rows (cascades via FK). */ public function deleteTag(int $id): void { $this->pdo->prepare("DELETE FROM tags WHERE id = ?")->execute([$id]); } /** * Get orientation ID by name */ // ======================================================================== // STATIC PAGES METHODS // ======================================================================== /** * Get a static page by slug * @param string $slug Page slug (e.g. 'about', 'licenses') * @return array|null */ public function getPage(string $slug): ?array { $stmt = $this->pdo->prepare("SELECT * FROM pages WHERE slug = ?"); $stmt->execute([$slug]); $row = $stmt->fetch(); return $row ?: null; } /** * Update content for a static page by slug * @throws Exception if slug not found */ public function savePage(string $slug, string $content): void { $stmt = $this->pdo->prepare("SELECT id FROM pages WHERE slug = ?"); $stmt->execute([$slug]); if (!$stmt->fetch()) { throw new Exception("Page slug not found: $slug"); } $stmt = $this->pdo->prepare( "UPDATE pages SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?" ); $stmt->execute([$content, $slug]); } /** * Get all static pages */ public function getAllPages(): array { $stmt = $this->pdo->query("SELECT * FROM pages ORDER BY slug"); return $stmt->fetchAll(); } // ======================================================================== // LICENSE TYPE METHODS // ======================================================================== /** * Get all license types ordered by name */ public function getAllLicenseTypes(): array { $stmt = $this->pdo->query("SELECT * FROM license_types ORDER BY name"); return $stmt->fetchAll(); } // ======================================================================== // VISIBILITY METHODS // ======================================================================== /** * Set the access_type_id (visibility) for a single thesis. * @param int $thesisId * @param int|null $accessTypeId 1=Libre, 2=Interne, 3=Interdit, null=unset */ public function setVisibility(int $thesisId, ?int $accessTypeId): void { $stmt = $this->pdo->prepare( "UPDATE theses SET access_type_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?" ); $stmt->execute([$accessTypeId, $thesisId]); } /** * Set visibility for multiple theses at once. * @param int[] $thesisIds * @param int|null $accessTypeId */ public function bulkSetVisibility(array $thesisIds, ?int $accessTypeId): void { if (empty($thesisIds)) return; $placeholders = implode(',', array_fill(0, count($thesisIds), '?')); $params = array_merge([$accessTypeId], $thesisIds); $this->pdo->prepare( "UPDATE theses SET access_type_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ($placeholders)" )->execute($params); } /** * Set the published state of a single thesis. */ public function setPublished(int $thesisId, bool $published): void { $this->pdo->prepare( 'UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?' )->execute([$published ? 1 : 0, $thesisId]); } /** * Set the published state for multiple theses at once. * @param int[] $thesisIds */ public function bulkSetPublished(array $thesisIds, bool $published): void { if (empty($thesisIds)) return; $placeholders = implode(',', array_fill(0, count($thesisIds), '?')); $params = array_merge([$published ? 1 : 0], $thesisIds); $this->pdo->prepare( "UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ($placeholders)" )->execute($params); } /** * Get all access types (visibility options). */ public function getAccessTypes(): array { $stmt = $this->pdo->query("SELECT * FROM access_types ORDER BY id"); return $stmt->fetchAll(); } // ======================================================================== // SITE SETTINGS // ======================================================================== /** * Get a single site setting value by key. Returns $default if not found. */ public function getSetting(string $key, string $default = ''): string { $stmt = $this->pdo->prepare("SELECT value FROM site_settings WHERE key = ? LIMIT 1"); $stmt->execute([$key]); $row = $stmt->fetch(); return $row ? (string) $row['value'] : $default; } /** * Get all site settings as an associative array [ key => value ]. */ public function getAllSettings(): array { $stmt = $this->pdo->query("SELECT key, value FROM site_settings"); $rows = $stmt->fetchAll(); $out = []; foreach ($rows as $r) { $out[$r['key']] = $r['value']; } return $out; } /** * Upsert a site setting. */ public function setSetting(string $key, string $value): void { $this->pdo->prepare( "INSERT INTO site_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP" )->execute([$key, $value]); } /** * Return access types that are enabled in the add-thesis form, * filtered by site_settings toggles. * 'Libre' (id=1) is excluded unless access_type_libre_enabled = '1'. * 'Interne' (id=2) is excluded unless access_type_interne_enabled = '1'. * 'Interdit' (id=3) is excluded unless access_type_interdit_enabled = '1'. */ public function getEnabledFormAccessTypes(): array { $settings = $this->getAllSettings(); $all = $this->getAccessTypes(); $map = [ 'Libre' => $settings['access_type_libre_enabled'] ?? '0', 'Interne' => $settings['access_type_interne_enabled'] ?? '1', 'Interdit' => $settings['access_type_interdit_enabled'] ?? '1', ]; return array_values(array_filter($all, fn($at) => ($map[$at['name']] ?? '0') === '1')); } /** * Update the show_contact flag for the first author of a thesis. */ public function setAuthorShowContact(int $thesisId, bool $show): void { $stmt = $this->pdo->prepare( "UPDATE authors SET show_contact = ? WHERE id = ( SELECT author_id FROM thesis_authors WHERE thesis_id = ? ORDER BY author_order LIMIT 1 )" ); $stmt->execute([$show ? 1 : 0, $thesisId]); } // ======================================================================== // JURY METHODS // ======================================================================== /** * Fetch all jury members for a thesis, with role and is_external flag. */ public function getThesisJury(int $thesisId): array { $stmt = $this->pdo->prepare(" SELECT s.id, s.name, ts.role, ts.is_external, ts.supervisor_order FROM thesis_supervisors ts JOIN supervisors s ON s.id = ts.supervisor_id WHERE ts.thesis_id = ? ORDER BY ts.supervisor_order "); $stmt->execute([$thesisId]); return $stmt->fetchAll(); } /** * Replace the entire jury for a thesis in a single transaction. * $juryMembers: array of ['name' => string, 'role' => string, 'is_external' => int] */ public function setThesisJury(int $thesisId, array $juryMembers): void { $alreadyInTransaction = $this->pdo->inTransaction(); if (!$alreadyInTransaction) { $this->pdo->beginTransaction(); } try { $this->pdo->prepare("DELETE FROM thesis_supervisors WHERE thesis_id = ?")->execute([$thesisId]); $stmt = $this->pdo->prepare(" INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, supervisor_order) VALUES (?, ?, ?, ?, ?) "); foreach ($juryMembers as $order => $member) { $name = trim($member['name'] ?? ''); if ($name === '') continue; $supervisorId = $this->findOrCreateSupervisor($name); $role = in_array($member['role'], ['president', 'promoteur', 'lecteur']) ? $member['role'] : 'promoteur'; $isExternal = isset($member['is_external']) ? (int)$member['is_external'] : 0; $stmt->execute([$thesisId, $supervisorId, $role, $isExternal, $order + 1]); } if (!$alreadyInTransaction) { $this->pdo->commit(); } } catch (\Throwable $e) { if (!$alreadyInTransaction && $this->pdo->inTransaction()) { $this->pdo->rollBack(); } throw $e; } } // ======================================================================== // JUNCTION-TABLE HELPERS (delete-then-reinsert pattern) // ======================================================================== /** * Replace all language associations for a thesis. * @param int $thesisId * @param int[] $languageIds IDs from the languages table */ public function setThesisLanguages(int $thesisId, array $languageIds): void { $this->pdo->prepare("DELETE FROM thesis_languages WHERE thesis_id = ?")->execute([$thesisId]); $stmt = $this->pdo->prepare( "INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?, ?)" ); foreach ($languageIds as $langId) { $id = (int)$langId; if ($id > 0) { $stmt->execute([$thesisId, $id]); } } } /** * Replace all format associations for a thesis. * @param int $thesisId * @param int[] $formatIds IDs from the format_types table */ public function setThesisFormats(int $thesisId, array $formatIds): void { $this->pdo->prepare("DELETE FROM thesis_formats WHERE thesis_id = ?")->execute([$thesisId]); $stmt = $this->pdo->prepare( "INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?, ?)" ); foreach ($formatIds as $fmtId) { $id = (int)$fmtId; if ($id > 0) { $stmt->execute([$thesisId, $id]); } } } /** * Return the list of language IDs currently linked to a thesis. * @return int[] */ public function getThesisLanguageIds(int $thesisId): array { $stmt = $this->pdo->prepare( "SELECT language_id FROM thesis_languages WHERE thesis_id = ?" ); $stmt->execute([$thesisId]); return $stmt->fetchAll(PDO::FETCH_COLUMN); } /** * Return the list of format IDs currently linked to a thesis. * @return int[] */ public function getThesisFormatIds(int $thesisId): array { $stmt = $this->pdo->prepare( "SELECT format_id FROM thesis_formats WHERE thesis_id = ?" ); $stmt->execute([$thesisId]); return $stmt->fetchAll(PDO::FETCH_COLUMN); } /** * Replace all tag associations for a thesis. * Tags are identified by name (findOrCreateTag is called for each). * Empty / whitespace-only names are silently skipped. * Maximum 10 tags are accepted; extras are ignored. * * @param int $thesisId * @param string[] $tagNames */ public function setThesisTags(int $thesisId, array $tagNames): void { $this->pdo->prepare("DELETE FROM thesis_tags WHERE thesis_id = ?")->execute([$thesisId]); $stmt = $this->pdo->prepare( "INSERT OR IGNORE INTO thesis_tags (tag_id, thesis_id) VALUES (?, ?)" ); $count = 0; foreach ($tagNames as $name) { if ($count >= 10) break; $tagId = $this->findOrCreateTag($name); // trims, returns null for empty if ($tagId !== null) { $stmt->execute([$tagId, $thesisId]); $count++; } } } // ======================================================================== // BANNER METHODS // ======================================================================== /** * Set (or clear) the banner_path for a thesis. */ public function setBannerPath(int $thesisId, ?string $path): void { $stmt = $this->pdo->prepare( "UPDATE theses SET banner_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?" ); $stmt->execute([$path, $thesisId]); } /** * Process a banner image upload for a thesis. * * Validates MIME type, extension, and file size, then saves the file to the * banners/ directory under STORAGE_ROOT and calls setBannerPath(). * * Returns the relative path (e.g. "banners/abc123.jpg") on success, * or null if the file array is absent, has an error, fails validation, * or cannot be moved. * * @param int $thesisId Target thesis ID * @param array|null $uploadedFile Entry from $_FILES (e.g. $_FILES['banner']) * @return string|null Relative path stored in the DB, or null */ public function handleBannerUpload(int $thesisId, ?array $uploadedFile): ?string { if (!$uploadedFile || ($uploadedFile['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { return null; } $allowedMimes = ['image/jpeg', 'image/png', 'image/webp']; $allowedExts = ['jpg', 'jpeg', 'png', 'webp']; $maxBytes = 5 * 1024 * 1024; // 5 MB $finfo = new finfo(FILEINFO_MIME_TYPE); $mimeType = $finfo->file($uploadedFile['tmp_name']); $ext = strtolower(pathinfo($uploadedFile['name'], PATHINFO_EXTENSION)); if (!in_array($mimeType, $allowedMimes, true) || !in_array($ext, $allowedExts, true) || $uploadedFile['size'] > $maxBytes) { error_log("handleBannerUpload: rejected " . $uploadedFile['name'] . " ($mimeType, {$uploadedFile['size']} bytes)"); return null; } $bannerDir = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/banners/' : null; if (!$bannerDir) { error_log("handleBannerUpload: STORAGE_ROOT not defined"); return null; } if (!file_exists($bannerDir)) { mkdir($bannerDir, 0755, true); } $safeName = bin2hex(random_bytes(16)) . '.' . $ext; $targetPath = $bannerDir . $safeName; if (!move_uploaded_file($uploadedFile['tmp_name'], $targetPath)) { error_log("handleBannerUpload: move_uploaded_file failed for " . $uploadedFile['name']); return null; } chmod($targetPath, 0644); $relativePath = 'banners/' . $safeName; $this->setBannerPath($thesisId, $relativePath); error_log("handleBannerUpload: saved $relativePath"); return $relativePath; } // ======================================================================== // ENCAPSULATED QUERY HELPERS // ======================================================================== /** * Return the raw access_type_id for a thesis (used for visibility gating). * Returns null if the thesis is not found. */ public function getThesisAccessTypeId(int $thesisId): ?int { $stmt = $this->pdo->prepare( "SELECT access_type_id FROM theses WHERE id = ? LIMIT 1" ); $stmt->execute([$thesisId]); $val = $stmt->fetchColumn(); return ($val !== false) ? (int)$val : null; } /** * Return the raw FK fields not exposed through v_theses_full string columns. * Returns ['license_id', 'access_type_id', 'context_note'] or null if not found. * * @return array{license_id:int|null,access_type_id:int|null,context_note:string}|null */ public function getThesisRawFields(int $thesisId): ?array { $stmt = $this->pdo->prepare( "SELECT license_id, access_type_id, context_note FROM theses WHERE id = ? LIMIT 1" ); $stmt->execute([$thesisId]); $row = $stmt->fetch(); return $row !== false ? $row : null; } /** * Return the banner_path for a thesis, or null. * Used when we need just the banner path without the full view expansion. */ public function getThesisBannerPath(int $thesisId): ?string { $stmt = $this->pdo->prepare( "SELECT banner_path FROM theses WHERE id = ? LIMIT 1" ); $stmt->execute([$thesisId]); $val = $stmt->fetchColumn(); return ($val !== false && $val !== null) ? (string)$val : null; } /** * Batch-load cover file paths for a set of thesis IDs. * Returns [thesis_id => file_path] for IDs that have a cover in thesis_files. * * @param int[] $thesisIds * @return array */ public function getCoverPathsForTheses(array $thesisIds): array { if (empty($thesisIds)) { return []; } $placeholders = implode(',', array_fill(0, count($thesisIds), '?')); $stmt = $this->pdo->prepare(" SELECT thesis_id, file_path FROM thesis_files WHERE file_type = 'cover' AND thesis_id IN ($placeholders) "); $stmt->execute($thesisIds); $map = []; foreach ($stmt->fetchAll() as $row) { $map[(int)$row['thesis_id']] = $row['file_path']; } return $map; } /** * Check visibility for a file path under theses/. * Returns the access_type_id of the owning thesis, or null if the file * is not found or the path does not belong to a thesis file. * * Access type 3 = Interdit (forbidden). */ public function getFileVisibility(string $filePath): ?int { $stmt = $this->pdo->prepare(" SELECT t.access_type_id FROM theses t JOIN thesis_files tf ON tf.thesis_id = t.id WHERE tf.file_path = ? LIMIT 1 "); $stmt->execute([$filePath]); $val = $stmt->fetchColumn(); return ($val !== false) ? (int)$val : null; } /** * Return total number of rows in the theses table (for system status display). */ public function getThesisCount(): int { return (int)$this->pdo->query("SELECT COUNT(*) FROM theses")->fetchColumn(); } /** * Generate a unique YYYY-NNN identifier for a new thesis in the given year. * Counts existing theses for that year (published or not) to determine the next sequence * number. Must be called inside the same transaction that performs the INSERT so that * concurrent requests cannot produce duplicate identifiers. */ /** * Generate a unique identifier like "2025-003" for a new thesis. * * Uses the actual maximum sequence number for the given year (from * existing identifiers) rather than the row count, so deletes don't * cause identifier collisions. * * Must be called inside an open transaction. */ public function generateThesisIdentifier(int $year): string { $stmt = $this->pdo->prepare( "SELECT COALESCE(MAX(CAST(SUBSTR(identifier, 6) AS INTEGER)), 0) FROM theses WHERE identifier LIKE ?" ); $stmt->execute([$year . '-%']); $maxSeq = (int)$stmt->fetchColumn(); return sprintf("%d-%03d", $year, $maxSeq + 1); } /** * Insert a new thesis row, link its author, and return the new thesis ID. * * Expected keys in $data: * year (int), orientation_id (int), ap_program_id (int), finality_id (int), * title (string), subtitle (?string), synopsis (string), * file_size_info (?string), baiu_link (?string), license_id (?int), * author_id (int) * * The identifier is generated automatically from $data['year']. * Must be called inside an open transaction. * * @return int The new thesis ID. */ /** * Update core thesis fields. * All columns except identifier, submitted_at, and file-related fields. */ public function updateThesis(int $thesisId, array $data): void { $stmt = $this->pdo->prepare(" UPDATE theses SET title = ?, subtitle = ?, year = ?, orientation_id = ?, ap_program_id = ?, finality_id = ?, synopsis = ?, context_note = ?, file_size_info = ?, baiu_link = ?, license_id = ?, access_type_id = ?, is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? "); $stmt->execute([ $data['title'], !empty($data['subtitle']) ? $data['subtitle'] : null, (int)$data['year'], (int)$data['orientation_id'], (int)$data['ap_program_id'], (int)$data['finality_id'], $data['synopsis'], !empty($data['context_note']) ? $data['context_note'] : null, !empty($data['file_size_info']) ? $data['file_size_info'] : null, !empty($data['baiu_link']) ? $data['baiu_link'] : null, isset($data['license_id']) ? $data['license_id'] : null, isset($data['access_type_id']) ? $data['access_type_id'] : null, $data['is_published'] ? 1 : 0, $thesisId, ]); } /** * Replace all author associations for a thesis (delete-then-reinsert). * $authors is an array of ['name' => string, 'email' => string|null]. * The first entry is considered the primary author (author_order = 1). */ public function setThesisAuthors(int $thesisId, array $authors): void { $this->pdo->prepare("DELETE FROM thesis_authors WHERE thesis_id = ?")->execute([$thesisId]); $stmt = $this->pdo->prepare( "INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?, ?, ?)" ); foreach ($authors as $index => $author) { $name = trim($author['name'] ?? ''); if ($name === '') continue; $showContact = !empty($author['show_contact']); $authorId = $this->findOrCreateAuthor($name, $author['email'] ?? null, $showContact); $stmt->execute([$thesisId, $authorId, $index + 1]); } } public function createThesis(array $data): int { $identifier = $this->generateThesisIdentifier((int)$data['year']); $stmt = $this->pdo->prepare(" INSERT INTO theses ( identifier, title, subtitle, year, orientation_id, ap_program_id, finality_id, synopsis, file_size_info, baiu_link, license_id, access_type_id, objet, is_published, submitted_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP) "); $validObjet = ['tfe', 'thèse', 'frart']; $objet = in_array($data['objet'] ?? '', $validObjet, true) ? $data['objet'] : 'tfe'; $stmt->execute([ $identifier, $data['title'], !empty($data['subtitle']) ? $data['subtitle'] : null, (int)$data['year'], (int)$data['orientation_id'], (int)$data['ap_program_id'], (int)$data['finality_id'], $data['synopsis'], !empty($data['file_size_info']) ? $data['file_size_info'] : null, !empty($data['baiu_link']) ? $data['baiu_link'] : null, isset($data['license_id']) ? $data['license_id'] : null, isset($data['access_type_id']) ? (int)$data['access_type_id'] : 2, // default: Interne $objet, ]); $thesisId = (int)$this->pdo->lastInsertId(); // Link author — always author_order = 1 for single-author submissions. $stmt = $this->pdo->prepare( "INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?, ?, 1)" ); $stmt->execute([$thesisId, (int)$data['author_id']]); return $thesisId; } /** * Delete a single thesis and all its related data (cascade via FK). * Also removes the banner file from disk if present. */ public function deleteThesis(int $thesisId): void { // Clean up banner file $bannerPath = $this->getThesisBannerPath($thesisId); if ($bannerPath !== null) { $fullPath = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/' . $bannerPath : null; if ($fullPath && file_exists($fullPath)) { @unlink($fullPath); } } // Clean up thesis files from disk $files = $this->getThesisFiles($thesisId); foreach ($files as $file) { if (!empty($file['file_path']) && file_exists($file['file_path'])) { @unlink($file['file_path']); } } // DB cascade handles junction tables $this->pdo->prepare("DELETE FROM theses WHERE id = ?")->execute([$thesisId]); } /** * Delete multiple theses at once. * @param int[] $thesisIds */ public function bulkDeleteTheses(array $thesisIds): void { if (empty($thesisIds)) return; // Clean up files for each thesis foreach ($thesisIds as $id) { $bannerPath = $this->getThesisBannerPath($id); if ($bannerPath !== null) { $fullPath = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/' . $bannerPath : null; if ($fullPath && file_exists($fullPath)) { @unlink($fullPath); } } $files = $this->getThesisFiles($id); foreach ($files as $file) { if (!empty($file['file_path']) && file_exists($file['file_path'])) { @unlink($file['file_path']); } } } $placeholders = implode(',', array_fill(0, count($thesisIds), '?')); $this->pdo->prepare("DELETE FROM theses WHERE id IN ($placeholders)")->execute($thesisIds); } /** * Return the stored identifier string (e.g. "2024-003") for an existing thesis. */ public function getThesisIdentifier(int $thesisId): string { $stmt = $this->pdo->prepare("SELECT identifier FROM theses WHERE id = ?"); $stmt->execute([$thesisId]); $row = $stmt->fetch(); if (!$row) { throw new \RuntimeException("Thesis #$thesisId not found"); } return (string)$row['identifier']; } /** * Delete every thesis in the database. */ public function deleteAllTheses(): int { $ids = $this->pdo->query("SELECT id FROM theses")->fetchAll(\PDO::FETCH_COLUMN); if (empty($ids)) return 0; $count = count($ids); $this->bulkDeleteTheses($ids); return $count; } /** * 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(); } /** * Delete a single thesis file record by its ID and optionally remove the * file from disk. Returns the file_path that was deleted (or null if not * found), so the caller can clean up the filesystem. * * @param int $fileId Primary key of thesis_files row. * @param int $thesisId Owning thesis ID (used as a safety guard). * @return string|null The file_path that was stored, or null. */ public function deleteThesisFile(int $fileId, int $thesisId): ?string { $stmt = $this->pdo->prepare( "SELECT file_path FROM thesis_files WHERE id = ? AND thesis_id = ? LIMIT 1" ); $stmt->execute([$fileId, $thesisId]); $row = $stmt->fetch(); if (!$row) { return null; } $this->pdo->prepare("DELETE FROM thesis_files WHERE id = ?")->execute([$fileId]); return $row['file_path']; } /** * Replace the cover image for a thesis: removes any existing cover record * (and its file from disk), then inserts the new one. * * @param int $thesisId * @param array|null $upload Single-file $_FILES entry. * @return string|null Relative path of the new cover, or null. */ public function handleCoverUpload(int $thesisId, ?array $upload): ?string { if (!$upload || ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { return null; } $allowedMimes = ['image/jpeg', 'image/png']; $allowedExts = ['jpg', 'jpeg', 'png']; $maxBytes = 10 * 1024 * 1024; // 10 MB $finfo = new finfo(FILEINFO_MIME_TYPE); $mimeType = $finfo->file($upload['tmp_name']); $ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION)); if (!in_array($mimeType, $allowedMimes, true) || !in_array($ext, $allowedExts, true) || $upload['size'] > $maxBytes) { error_log("handleCoverUpload: rejected {$upload['name']} ($mimeType, {$upload['size']} bytes)"); return null; } // Remove existing cover record + file $existing = $this->pdo->prepare( "SELECT id, file_path FROM thesis_files WHERE thesis_id = ? AND file_type = 'cover' LIMIT 1" ); $existing->execute([$thesisId]); if ($old = $existing->fetch()) { $this->pdo->prepare("DELETE FROM thesis_files WHERE id = ?")->execute([$old['id']]); if (!empty($old['file_path']) && defined('STORAGE_ROOT')) { $abs = STORAGE_ROOT . '/' . $old['file_path']; if (file_exists($abs)) @unlink($abs); } } $coverDir = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/covers/' : null; if (!$coverDir) { error_log('handleCoverUpload: STORAGE_ROOT not defined'); return null; } if (!is_dir($coverDir)) { mkdir($coverDir, 0755, true); } $safeName = bin2hex(random_bytes(16)) . '.' . $ext; $targetPath = $coverDir . $safeName; if (!move_uploaded_file($upload['tmp_name'], $targetPath)) { error_log("handleCoverUpload: move_uploaded_file failed for {$upload['name']}"); return null; } chmod($targetPath, 0644); $relPath = 'covers/' . $safeName; $this->insertThesisFile($thesisId, 'cover', $relPath, basename($upload['name']), $upload['size'], $mimeType); error_log("handleCoverUpload: saved $relPath"); return $relPath; } // ======================================================================== // EXPORT HELPERS — used by ExportController // ======================================================================== /** * Fetch all theses (admin — includes unpublished) with every column * needed for the CSV export. */ public function getAllThesesForExport(): array { return $this->pdo->query(" SELECT t.id, t.identifier, t.title, t.subtitle, t.year, o.name AS orientation, ap.name AS ap_program, ft.name AS finality_type, at.name AS access_type, lt.name AS license_name, t.synopsis, t.context_note, t.remarks, t.file_size_info, t.jury_points, t.baiu_link FROM theses t LEFT JOIN orientations o ON t.orientation_id = o.id LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id LEFT JOIN finality_types ft ON t.finality_id = ft.id LEFT JOIN access_types at ON t.access_type_id = at.id LEFT JOIN license_types lt ON t.license_id = lt.id ORDER BY t.year DESC, t.title ASC ")->fetchAll(); } /** * All thesis→author rows with author name and email. */ public function getAllThesisAuthorsForExport(): array { return $this->pdo->query(" SELECT ta.thesis_id, a.name, a.email FROM thesis_authors ta JOIN authors a ON a.id = ta.author_id ORDER BY ta.thesis_id, ta.author_order ")->fetchAll(); } /** * All thesis→supervisor rows with name. */ public function getAllThesisSupervisorsForExport(): array { return $this->pdo->query(" SELECT ts.thesis_id, s.name FROM thesis_supervisors ts JOIN supervisors s ON s.id = ts.supervisor_id ORDER BY ts.thesis_id, ts.supervisor_order ")->fetchAll(); } /** * All thesis→tag rows with tag name. */ public function getAllThesisTagsForExport(): array { return $this->pdo->query(" SELECT tt.thesis_id, t.name FROM thesis_tags tt JOIN tags t ON t.id = tt.tag_id ORDER BY tt.thesis_id, t.name ")->fetchAll(); } /** * All thesis→language rows with language name. */ public function getAllThesisLanguagesForExport(): array { return $this->pdo->query(" SELECT tl.thesis_id, l.name FROM thesis_languages tl JOIN languages l ON l.id = tl.language_id ORDER BY tl.thesis_id, l.name ")->fetchAll(); } /** * All thesis→format rows with format name. */ public function getAllThesisFormatsForExport(): array { return $this->pdo->query(" SELECT tf.thesis_id, ft.name FROM thesis_formats tf JOIN format_types ft ON ft.id = tf.format_id ORDER BY tf.thesis_id, ft.name ")->fetchAll(); } // ======================================================================== // SINGLETON PATTERN ENFORCEMENT // ======================================================================== // ======================================================================== // APROPOS CONTENTS (structured data formerly in config/apropos.php) // ======================================================================== /** * Get an apropos content value by key. * @param string $key 'contacts', 'credits', or 'erg_url' * @return array|string|null JSON-decoded array for contacts/credits, string for erg_url */ public function getAproposContent(string $key) { $stmt = $this->pdo->prepare("SELECT value FROM apropos_contents WHERE key = ?"); $stmt->execute([$key]); $row = $stmt->fetch(); if (!$row) return null; $value = $row['value']; if ($key === 'erg_url') { return $value; } $decoded = json_decode($value, true); return is_array($decoded) ? $decoded : null; } /** * Save an apropos content value by key. * @param string $key * @param mixed $value array for contacts/credits, string for erg_url */ public function saveAproposContent(string $key, $value): void { $stmt = $this->pdo->prepare("SELECT id FROM apropos_contents WHERE key = ?"); $stmt->execute([$key]); if (!$stmt->fetch()) { throw new Exception("Apropos key not found: $key"); } $storedValue = is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE) : (string)$value; $stmt = $this->pdo->prepare( "UPDATE apropos_contents SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?" ); $stmt->execute([$storedValue, $key]); } /** * Get all apropos contents as [key => value] pairs (raw DB values). */ public function getAllAproposContents(): array { $stmt = $this->pdo->query("SELECT key, value, updated_at FROM apropos_contents ORDER BY key"); return $stmt->fetchAll(); } // ======================================================================== // FORM HELP BLOCKS // ======================================================================== /** * Known form help block keys (mirrors the seeded rows in migration 004). */ public const FORM_HELP_KEYS = [ 'partage_intro', 'fieldset_tfe_info', 'fieldset_synopsis', 'fieldset_jury', 'fieldset_academic', 'fieldset_files', 'fieldset_access', 'fieldset_email', ]; /** * Human-readable labels for each block key (used in the admin UI). */ public const FORM_HELP_LABELS = [ 'partage_intro' => 'Introduction du formulaire', 'fieldset_tfe_info' => 'Informations du TFE — note d\'introduction', 'fieldset_synopsis' => 'Synopsis — explication', 'fieldset_jury' => 'Composition du jury — note', 'fieldset_academic' => 'Cadre académique — note', 'fieldset_files' => 'Fichiers — note', 'fieldset_access' => 'Visibilité / Accès — explication', 'fieldset_email' => 'E-mail de confirmation — note', ]; /** * Get a single form help block by key. Returns '' when missing. */ public function getFormHelpBlock(string $key): string { $stmt = $this->pdo->prepare( "SELECT content FROM form_help_blocks WHERE key = ? LIMIT 1" ); $stmt->execute([$key]); $val = $stmt->fetchColumn(); return ($val !== false) ? (string)$val : ''; } /** * Upsert a form help block. */ public function setFormHelpBlock(string $key, string $content): void { if (!in_array($key, self::FORM_HELP_KEYS, true)) { throw new Exception("Unknown form help block key: $key"); } $this->pdo->prepare( "INSERT INTO form_help_blocks (key, content, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET content = excluded.content, updated_at = CURRENT_TIMESTAMP" )->execute([$key, $content]); } /** * Return all form help blocks ordered by sort_order, as [ key => ['content' => ..., 'updated_at' => ..., 'sort_order' => ...] ]. */ public function getAllFormHelpBlocks(): array { $stmt = $this->pdo->query( "SELECT key, content, updated_at, sort_order FROM form_help_blocks ORDER BY sort_order, key" ); $rows = $stmt->fetchAll(); $out = []; foreach ($rows as $r) { $out[$r['key']] = [ 'content' => $r['content'], 'updated_at' => $r['updated_at'], 'sort_order' => (int)$r['sort_order'], ]; } return $out; } /** * Persist a new sort order for all form help blocks. * $keys must be an ordered array of known block keys. */ public function reorderFormHelpBlocks(array $keys): void { $stmt = $this->pdo->prepare( "UPDATE form_help_blocks SET sort_order = ? WHERE key = ?" ); foreach ($keys as $i => $key) { if (in_array($key, self::FORM_HELP_KEYS, true)) { $stmt->execute([$i, $key]); } } } // ======================================================================== // SINGLETON PATTERN ENFORCEMENT // ======================================================================== /** * Prevent cloning */ private function __clone() {} /** * Prevent unserialization. * PHP 8.x deprecates throwing from __wakeup(); use trigger_error instead. */ public function __wakeup(): void { // phpcs:ignore trigger_error('Cannot unserialize singleton ' . static::class, E_USER_ERROR); } // ======================================================================== // FILE ACCESS RESTRICTION METHODS // ======================================================================== /** * Check if restricted files feature is enabled. */ public function isRestrictedFilesEnabled(): bool { return $this->getSetting('restricted_files_enabled', '0') === '1'; } /** * Create a new file access request. * * @param int $thesisId * @param string $email * @param string $justification Optional justification for non-ERG emails * @return int New request ID */ public function createFileAccessRequest(int $thesisId, string $email, ?string $justification = null): int { $stmt = $this->pdo->prepare( "INSERT INTO file_access_requests (thesis_id, email, justification, status) VALUES (?, ?, ?, 'pending')" ); $stmt->execute([$thesisId, $email, $justification]); return (int)$this->pdo->lastInsertId(); } /** * Generate and store an access token for a request. * * @param int $requestId * @param int $expiryDays Number of days until token expires (default: 30) * @return string The generated token */ /** * Generate and store a short-lived one-time email access token. * Default: 24 hours. Token is invalidated after first redemption. * * @param int $requestId * @param int $expiryHours Hours until token expires (default: 24) * @return string The generated token (256-bit hex) */ public function generateAccessToken(int $requestId, int $expiryHours = 24): string { $token = bin2hex(random_bytes(32)); $expiresAt = date('Y-m-d H:i:s', time() + $expiryHours * 3600); $stmt = $this->pdo->prepare( "INSERT INTO file_access_tokens (request_id, token, expires_at) VALUES (?, ?, ?)" ); $stmt->execute([$requestId, $token, $expiresAt]); return $token; } /** * Validate a one-time email token and mark it as used (one-time use). * Returns the thesis_id on success, null on failure. * Logs the redemption attempt in file_access_audit. * * @param string $token * @param string $ip Client IP address for audit log * @param string $ua Client User-Agent for audit log * @return int|null Thesis ID on success, null on invalid/expired/used */ /** * Validate and redeem a one-time email access token. * * Returns ['thesis_id' => int, 'request_id' => int] on success. * Returns null if the token is invalid, expired, or already used. * Logs the redemption attempt in file_access_audit. * * @param string $token * @param string $ip Client IP for audit log * @param string $ua Client User-Agent for audit log * @return array{thesis_id:int,request_id:int}|null */ public function redeemAccessToken(string $token, string $ip = '', string $ua = ''): ?array { // Look up the token — only valid if unused, unexpired, and approved $stmt = $this->pdo->prepare( "SELECT fat.id AS token_id, fat.request_id, fr.thesis_id FROM file_access_tokens fat JOIN file_access_requests fr ON fat.request_id = fr.id WHERE fat.token = ? AND fat.is_valid = 1 AND fat.used_at IS NULL AND fat.expires_at > CURRENT_TIMESTAMP AND fr.status = 'approved'" ); $stmt->execute([$token]); $row = $stmt->fetch(); if (!$row) { // Log failed attempt if we can find the token at all $check = $this->pdo->prepare( "SELECT fat.request_id FROM file_access_tokens fat WHERE fat.token = ? LIMIT 1" ); $check->execute([$token]); $bad = $check->fetch(); if ($bad) { $this->logAccessAudit((int)$bad['request_id'], 'invalid_or_expired', $ip, $ua); } return null; } // Mark token as used (one-time) $this->pdo->prepare( "UPDATE file_access_tokens SET used_at = CURRENT_TIMESTAMP, is_valid = 0 WHERE id = ?" )->execute([(int)$row['token_id']]); // Audit log $this->logAccessAudit((int)$row['request_id'], 'redeemed', $ip, $ua); return [ 'thesis_id' => (int)$row['thesis_id'], 'request_id' => (int)$row['request_id'], ]; } /** * Create a long-lived browser session token after a successful link redemption. * Stored in file_access_sessions (separate from one-time email tokens). * * @param int $requestId * @param int $expiryDays Days until session expires (default: 30) * @return string Session token (256-bit hex) */ public function createAccessSession(int $requestId, int $expiryDays = 30): string { $sessionToken = bin2hex(random_bytes(32)); $expiresAt = date('Y-m-d H:i:s', time() + $expiryDays * 86400); $this->pdo->prepare( "INSERT INTO file_access_sessions (request_id, session_token, expires_at) VALUES (?, ?, ?)" )->execute([$requestId, $sessionToken, $expiresAt]); return $sessionToken; } /** * Check if a browser session cookie grants valid access to a thesis. * * @param string $sessionToken Value from the HttpOnly cookie * @param int $thesisId * @return bool True if access is granted */ public function hasValidCookieAccess(string $sessionToken, int $thesisId): bool { $stmt = $this->pdo->prepare( "SELECT COUNT(*) as count FROM file_access_sessions fas JOIN file_access_requests fr ON fas.request_id = fr.id WHERE fas.session_token = ? AND fas.is_valid = 1 AND fas.expires_at > CURRENT_TIMESTAMP AND fr.status = 'approved' AND fr.thesis_id = ?" ); $stmt->execute([$sessionToken, $thesisId]); $result = $stmt->fetch(); return $result && (int)$result['count'] > 0; } /** * Write an entry to the access audit log. * * @param int $requestId * @param string $event 'redeemed' | 'invalid_or_expired' * @param string $ip * @param string $ua */ public function logAccessAudit(int $requestId, string $event, string $ip, string $ua): void { $this->pdo->prepare( "INSERT INTO file_access_audit (request_id, event, ip, user_agent) VALUES (?, ?, ?, ?)" )->execute([$requestId, $event, $ip, $ua]); } /** * Get pending file access requests for admin review. * * @param int $limit Maximum number of requests to return * @param int $offset Pagination offset * @return array List of pending requests with thesis info */ public function getPendingAccessRequests(int $limit = 50, int $offset = 0): array { $sql = " SELECT far.id, far.email, far.justification, far.created_at, t.id as thesis_id, t.title, t.subtitle, a.name as authors, t.year FROM file_access_requests far JOIN theses t ON far.thesis_id = t.id LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id LEFT JOIN authors a ON ta.author_id = a.id AND ta.author_order = 1 WHERE far.status = 'pending' ORDER BY far.created_at DESC LIMIT ? OFFSET ? "; $stmt = $this->pdo->prepare($sql); $stmt->execute([$limit, $offset]); return $stmt->fetchAll(); } /** * Approve a file access request and generate a token. * * @param int $requestId * @param int|null $adminId Admin user ID (can be null if admin auth not tracked) * @param int $expiryDays Token expiry in days * @return string The generated access token */ /** * Approve a file access request and generate a short-lived one-time email token. * * @param int $requestId * @param int|null $adminId Admin user ID for audit trail * @param int $expiryHours Hours until email link expires (default: 24) * @return string The generated one-time access token */ public function approveAccessRequest(int $requestId, ?int $adminId = null, int $expiryHours = 24): string { $this->pdo->beginTransaction(); try { // Update request status $stmt = $this->pdo->prepare( "UPDATE file_access_requests SET status = 'approved', approved_at = CURRENT_TIMESTAMP, approved_by_admin_id = ? WHERE id = ?" ); $stmt->execute([$adminId, $requestId]); // Generate short-lived one-time email token $token = $this->generateAccessToken($requestId, $expiryHours); $this->pdo->commit(); return $token; } catch (\Throwable $e) { $this->pdo->rollBack(); throw $e; } } /** * Reject a file access request. * * @param int $requestId * @param string $adminNotes Optional rejection notes */ public function rejectAccessRequest(int $requestId, ?string $adminNotes = null): void { $stmt = $this->pdo->prepare( "UPDATE file_access_requests SET status = 'rejected', approved_at = CURRENT_TIMESTAMP, admin_notes = ? WHERE id = ?" ); $stmt->execute([$adminNotes, $requestId]); } /** * Get access request by ID with thesis details. * * @param int $requestId * @return array|null Request data or null if not found */ public function getAccessRequestById(int $requestId): ?array { $stmt = $this->pdo->prepare(" SELECT far.*, t.id as thesis_id, t.title, t.subtitle, t.year, a.name as authors FROM file_access_requests far JOIN theses t ON far.thesis_id = t.id LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id LEFT JOIN authors a ON ta.author_id = a.id AND ta.author_order = 1 WHERE far.id = ? "); $stmt->execute([$requestId]); $result = $stmt->fetch(); return $result ?: null; } /** * Count pending access requests. */ public function countPendingAccessRequests(): int { $stmt = $this->pdo->query( "SELECT COUNT(*) as count FROM file_access_requests WHERE status = 'pending'" ); $result = $stmt->fetch(); return (int)$result['count']; } /** * Check if an access request already exists for this email and thesis. * * @param int $thesisId * @param string $email * @return array|null Existing request or null */ public function getExistingAccessRequest(int $thesisId, string $email): ?array { $stmt = $this->pdo->prepare( "SELECT id, status, created_at FROM file_access_requests WHERE thesis_id = ? AND email = ? ORDER BY created_at DESC LIMIT 1" ); $stmt->execute([$thesisId, $email]); $result = $stmt->fetch(); return $result ?: null; } }