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/xamxam.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/xamxam.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, ordered by sort_order then upload time. * Covers the new sort_order column added in migration 007. */ public function getThesisFiles($thesisId) { $sql = 'SELECT * FROM thesis_files WHERE thesis_id = :thesis_id ORDER BY sort_order ASC, uploaded_at ASC'; $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 — single-valued FK: use full intersection (including own filter). // Clicking one year should fade years that have zero theses in the current result. $matchedYearsIds = array_column($exec("SELECT DISTINCT t.year $baseJoins WHERE $wAll", $bAll), '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, $matchedYearsIds, true)], $allYears); // AP programs — single-valued FK: use full intersection. $matchedApIds = array_column($exec("SELECT DISTINCT ap.name $baseJoins WHERE $wAll AND ap.name IS NOT NULL", $bAll), '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, $matchedApIds, true)], $allAp); // Orientations — single-valued FK: use full intersection. $matchedOrIds = array_column($exec("SELECT DISTINCT o.name $baseJoins WHERE $wAll AND o.name IS NOT NULL", $bAll), 'name'); $allOr = array_column($exec('SELECT name FROM orientations ORDER BY name', []), 'name'); $orOut = array_map(fn ($n) => ['value' => $n, 'matched' => in_array($n, $matchedOrIds, true)], $allOr); // Finality types — single-valued FK: use full intersection. $matchedFiIds = array_column($exec("SELECT DISTINCT ft.name $baseJoins WHERE $wAll AND ft.name IS NOT NULL", $bAll), '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, $matchedFiIds, 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 ORDER BY a.name ASC) 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(); } /** * Check whether an existing thesis is a likely duplicate of the data being * submitted. * * Matching logic (all three conditions must be satisfied): * - Same year * - Same author name (case-insensitive, trimmed) * - Normalised title similarity: after lowercasing and stripping all * non-alphanumeric characters, one title must start with the other or * the Levenshtein distance must be ≤ 10 % of the longer string's length. * * Returns an associative array with keys * [id, identifier, title, author, year] * or null when no duplicate is found. * * @param string $title Proposed title. * @param string $authorName Proposed author name. * @param int $year Proposed year. * @return array{id:int,identifier:string,title:string,author:string,year:int}|null */ /** * @param string $title Proposed title. * @param string[] $authorNames Proposed author names (already trimmed, non-empty). * @param int $year Proposed year. * @return array{id:int,identifier:string,title:string,author:string,year:int}|null */ public function findDuplicateThesis(string $title, array $authorNames, int $year): ?array { if (empty($authorNames)) { return null; } // Fetch all theses for the same year that share any author with the submission. $numAuthors = count($authorNames); $ph = implode(',', array_fill(0, $numAuthors, 'LOWER(TRIM(?))')); $params = array_merge([$year], $authorNames); $stmt = $this->pdo->prepare( "SELECT DISTINCT t.id, t.identifier, t.title, t.year, GROUP_CONCAT(a2.name ORDER BY ta2.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 JOIN thesis_authors ta2 ON ta2.thesis_id = t.id JOIN authors a2 ON a2.id = ta2.author_id WHERE t.year = ? AND LOWER(TRIM(a.name)) IN ({$ph}) GROUP BY t.id" ); $stmt->execute($params); $candidates = $stmt->fetchAll(); if (empty($candidates)) { return null; } $normalise = static function (string $s): string { return preg_replace('/[^a-z0-9]/u', '', mb_strtolower($s)); }; $normNew = $normalise($title); foreach ($candidates as $row) { $normExisting = $normalise($row['title']); // Exact match after normalisation. if ($normNew === $normExisting) { return [ 'id' => (int)$row['id'], 'identifier' => $row['identifier'], 'title' => $row['title'], 'author' => $row['authors'], 'year' => (int)$row['year'], ]; } // Prefix match: one starts with the other (handles subtitle variations). $maxLen = max(mb_strlen($normNew), mb_strlen($normExisting)); if ($maxLen === 0) { continue; } $minLen = min(mb_strlen($normNew), mb_strlen($normExisting)); if ($minLen >= 5) { // avoid matching very short fragments if (str_starts_with($normExisting, $normNew) || str_starts_with($normNew, $normExisting)) { return [ 'id' => (int)$row['id'], 'identifier' => $row['identifier'], 'title' => $row['title'], 'author' => $row['authors'], 'year' => (int)$row['year'], ]; } } // Levenshtein distance ≤ 10 % of the longer string. // levenshtein() is limited to 255 chars; use substrings for safety. $a = mb_substr($normNew, 0, 255); $b = mb_substr($normExisting, 0, 255); $dist = levenshtein($a, $b); $threshold = (int)ceil($maxLen * 0.10); if ($dist <= $threshold) { return [ 'id' => (int)$row['id'], 'identifier' => $row['identifier'], 'title' => $row['title'], 'author' => $row['authors'], 'year' => (int)$row['year'], ]; } } return null; } /** * 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.is_ulb, 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, is_ulb, 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; $isUlb = isset($member['is_ulb']) ? (int)$member['is_ulb'] : 0; $stmt->execute([$thesisId, $supervisorId, $role, $isExternal, $isUlb, (int)$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, license_custom, access_type_id, context_note, remarks, jury_points, exemplaire_baiu, exemplaire_erg, cc4r 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 = ?, license_custom = ?, access_type_id = ?, is_published = ?, remarks = ?, jury_points = ?, exemplaire_baiu = ?, exemplaire_erg = ?, cc4r = ?, 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, $data['license_id'] ?? null, !empty($data['license_custom']) ? $data['license_custom'] : null, $data['access_type_id'] ?? null, $data['is_published'] ? 1 : 0, !empty($data['remarks']) ? $data['remarks'] : null, isset($data['jury_points']) && $data['jury_points'] !== '' ? (float)$data['jury_points'] : null, !empty($data['exemplaire_baiu']) ? 1 : 0, !empty($data['exemplaire_erg']) ? 1 : 0, !empty($data['cc4r']) ? 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, (int)$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, license_custom, 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, $data['license_id'] ?? null, !empty($data['license_custom']) ? $data['license_custom'] : null, isset($data['access_type_id']) ? (int)$data['access_type_id'] : 2, // default: Interne $objet, ]); return (int)$this->pdo->lastInsertId(); } /** * 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. * sort_order defaults to (max existing sort_order + 1) for the thesis. */ public function insertThesisFile($thesisId, $fileType, $filePath, $fileName, $fileSize, $mimeType, ?string $displayLabel = null, ?int $sortOrder = null) { if ($sortOrder === null) { $maxStmt = $this->pdo->prepare( 'SELECT COALESCE(MAX(sort_order), 0) FROM thesis_files WHERE thesis_id = ?' ); $maxStmt->execute([$thesisId]); $sortOrder = (int)$maxStmt->fetchColumn() + 1; } $stmt = $this->pdo->prepare(' INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type, display_label, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?) '); $stmt->execute([$thesisId, $fileType, $filePath, $fileName, $fileSize, $mimeType, $displayLabel, $sortOrder]); return $this->pdo->lastInsertId(); } /** * Persist a new sort order for thesis files. * $order is an array of file IDs in the desired order. * Only files belonging to $thesisId are updated (safety guard). */ public function reorderThesisFiles(int $thesisId, array $order): void { $stmt = $this->pdo->prepare( 'UPDATE thesis_files SET sort_order = ? WHERE id = ? AND thesis_id = ?' ); foreach ($order as $i => $fileId) { $stmt->execute([(int)$i + 1, (int)$fileId, $thesisId]); } } /** * Update the display_label for a thesis file. */ public function updateThesisFileLabel(int $fileId, int $thesisId, ?string $label): void { $this->pdo->prepare( 'UPDATE thesis_files SET display_label = ? WHERE id = ? AND thesis_id = ?' )->execute([$label ?: null, $fileId, $thesisId]); } /** * 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(); } /** * All thesis files for the file-export ZIP. * Includes every thesis_files column + the thesis identifier for manifest * construction. */ public function getAllThesisFilesForExport(): array { return $this->pdo->query(' SELECT tf.*, t.identifier FROM thesis_files tf JOIN theses t ON t.id = tf.thesis_id ORDER BY t.year DESC, t.title ASC, tf.sort_order ASC ')->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; } }