Files
xamxam/app/src/Database.php
Pontoporeia 43702542eb feat(admin): sortable form-help blocks with two-panel UI
- Migration 005: add sort_order column to form_help_blocks
- Database: getAllFormHelpBlocks orders by sort_order; new reorderFormHelpBlocks()
- actions/form-help-reorder.php: HTMX POST handler, CSRF-validated, 204 response
- templates/admin/contenus.php: replace flat table with two-panel layout
  - Left: SortableJS 1.15.2 + htmx drag-and-drop ordered block cards
  - Right: static form structure reference showing fieldsets and their inputs
- admin.css: .fhb-* styles for layout, cards, ghost/chosen/drag states, anchors
- schema.sql: updated form_help_blocks DDL with sort_order column
2026-04-29 21:45:55 +02:00

2420 lines
88 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Unified Database connection class for Post-ERG thesis database
* Combines functionality from both front-backend and formulaire
* Supports both singleton (front-backend) and direct instantiation (formulaire)
*/
class Database {
private static $instance = null;
private $pdo;
private $dbPath;
/**
* Constructor - public to support both singleton and direct instantiation
* @param string $dbPath Optional database path override
*/
public function __construct($dbPath = null) {
$this->dbPath = $this->determineDatabasePath($dbPath);
try {
$this->pdo = new PDO('sqlite:' . $this->dbPath);
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
// Enable foreign key constraints + 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<string,mixed>} [$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<string, 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<int,string>
*/
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;
}
}