feat: admin tag management, maintenance mode, TFE visibility states

Tags admin:
- Database: getAllTagsWithCount(), renameTag(), mergeTag(), deleteTag()
- public/admin/tags.php: table with inline rename/merge/delete forms, CSRF-guarded
- public/admin/actions/tag.php: routes on action=rename|merge|delete
- templates/admin/head.php: 'Mots-clés' nav link
- admin.css: admin-inline-form, admin-btn--sm/warning/danger variants

Maintenance mode:
- config/bootstrap.php: gate on MAINTENANCE_FLAG file; admin/ and maintenance.php exempt
- public/maintenance.php: 503 dark minimal page
- public/admin/actions/maintenance.php: enable/disable toggle
- public/admin/index.php: status bar with toggle button
- admin.css: admin-maintenance-bar styles

TFE Visibility (Libre/Interne/Interdit via existing access_type_id):
- migration 002_add_visibility.sql: seeds access_types if missing
- Database: setVisibility(), bulkSetVisibility(), getAccessTypes()
- public/media.php: blocks thesis files for access_type_id=3
- public/tfe.php: shows access_type, context_note; hides file panel for Interdit
- public/admin/edit.php: access_type_id select + context_note textarea; saves both
- public/admin/index.php: three-state badge (Libre/Interne/Interdit) per row
- public/admin/actions/visibility.php: single + bulk visibility action handler
- admin.css: status-access badge variants
This commit is contained in:
Pontoporeia
2026-03-24 15:35:52 +01:00
parent 0933137540
commit 92e344b757
17 changed files with 661 additions and 35 deletions

49
TODO.md
View File

@@ -264,32 +264,25 @@ Goal: rename the tables and column to the canonical M2M pattern (`tags`, `thesis
### 5 — Admin tag management UI (`/admin/tags.php`)
The goal is a dedicated page for viewing, renaming, merging, and deleting tags, with
full referential-integrity awareness (no orphan `thesis_tags` rows, no broken search
results).
#### 5a — `src/Database.php`
#### 5a — `src/Database.php` — new tag-management methods
- [x] `getAllTagsWithCount()`, `renameTag()`, `mergeTag()`, `deleteTag()`
- [ ] `getAllTagsWithCount(): array` — return all tags with a `thesis_count` column
- [ ] `renameTag(int $id, string $newName): void`
- [ ] `mergeTag(int $sourceId, int $targetId): void`
- [ ] `deleteTag(int $id): void`
#### 5b — `public/admin/tags.php`
#### 5b — `public/admin/tags.php` — list + inline-edit view
- [x] Auth guard, CSRF, table with rename/merge/delete per row, inline forms
- [ ] Auth guard, CSRF, table with rename/merge/delete per row
#### 5c — `public/admin/actions/tag.php`
#### 5c — `public/admin/actions/tag.php` — POST action handler
- [ ] Route on `$_POST['action']`: rename, merge, delete
- [x] Routes on `$_POST['action']`: rename, merge, delete
#### 5d — Nav & routing
- [ ] `templates/admin/head.php`: add nav link to `/admin/tags.php`
- [x] `templates/admin/head.php`: "Mots-clés" nav link added
#### 5e — Propagation safety checklist
#### 5e — Propagation safety
- [ ] Verify all search/display paths remain correct after tag ops
- [x] mergeTag() uses INSERT OR IGNORE to avoid PK conflicts; deleteTag() cascades via FK
### 6 — Tests
@@ -304,23 +297,23 @@ results).
## Feature: Mode Maintenance
- [ ] Storage flag file `storage/maintenance.flag`
- [ ] Public gate in `config/bootstrap.php`
- [ ] `public/maintenance.php` (503 page)
- [ ] `public/admin/actions/maintenance.php` (POST handler)
- [ ] Admin UI toggle in `public/admin/index.php`
- [x] Storage flag file `storage/maintenance.flag` (created on demand)
- [x] Public gate in `config/bootstrap.php` — blocks non-admin routes when flag exists
- [x] `public/maintenance.php` (503 page, minimal dark UI)
- [x] `public/admin/actions/maintenance.php` (POST: enable/disable)
- [x] Admin UI toggle in `public/admin/index.php` (bar with status + action button)
---
## Feature: TFE Visibility States (publique / interne / interdit)
- [ ] DB migration `002_add_visibility.sql`
- [ ] `src/Database.php``setVisibility()`, `bulkSetVisibility()`
- [ ] `public/media.php`visibility gate
- [ ] `public/tfe.php`conditional rendering
- [ ] `public/admin/edit.php`visibility select + context_note textarea
- [ ] `public/admin/index.php` — three-state badge + bulk actions
- [ ] `public/admin/actions/publish.php` or new `visibility.php`
- [x] DB migration `002_add_visibility.sql` — seeds access_types rows (already existed)
- [x] `src/Database.php``setVisibility()`, `bulkSetVisibility()`, `getAccessTypes()`
- [x] `public/media.php`blocks thesis files when access_type_id = 3 (Interdit)
- [x] `public/tfe.php`shows access type, context_note, hides files for Interdit
- [x] `public/admin/edit.php`access_type_id select + context_note textarea
- [x] `public/admin/index.php` — three-state access badge per row
- [x] `public/admin/actions/visibility.php` — single + bulk visibility update
---

View File

@@ -28,3 +28,17 @@ if (php_sapi_name() === 'cli-server') {
if (file_exists(APP_ROOT . '/config/admin_credentials.php')) {
require_once APP_ROOT . '/config/admin_credentials.php';
}
// Maintenance mode gate — block public pages; allow /admin/ through.
// The flag file lives in storage/ (outside webroot) to avoid web exposure.
define('MAINTENANCE_FLAG', APP_ROOT . '/storage/maintenance.flag');
if (file_exists(MAINTENANCE_FLAG)) {
// Allow admin panel through (by path prefix) and the maintenance page itself
$requestPath = $_SERVER['REQUEST_URI'] ?? '';
$isAdmin = str_starts_with($requestPath, '/admin');
$isMaintenance = str_contains($requestPath, 'maintenance.php');
if (!$isAdmin && !$isMaintenance) {
require APP_ROOT . '/public/maintenance.php';
exit();
}
}

View File

@@ -0,0 +1,28 @@
<?php
require_once __DIR__ . '/../../../config/bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|| !isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
die("Accès refusé.");
}
$action = $_POST['action'] ?? '';
if ($action === 'enable_maintenance') {
file_put_contents(MAINTENANCE_FLAG, date('c'));
$_SESSION['success'] = "Mode maintenance activé.";
} elseif ($action === 'disable_maintenance') {
if (file_exists(MAINTENANCE_FLAG)) {
unlink(MAINTENANCE_FLAG);
}
$_SESSION['success'] = "Mode maintenance désactivé.";
} else {
$_SESSION['error'] = "Action inconnue.";
}
header('Location: /admin/');
exit();

View File

@@ -0,0 +1,50 @@
<?php
require_once __DIR__ . '/../../../config/bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|| !isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
die("Accès refusé.");
}
require_once __DIR__ . '/../../../src/Database.php';
try {
$db = new Database();
$action = $_POST['action'] ?? '';
switch ($action) {
case 'rename':
$id = filter_var($_POST['tag_id'] ?? '', FILTER_VALIDATE_INT);
$newName = trim($_POST['new_name'] ?? '');
if (!$id || $newName === '') throw new Exception("Paramètres invalides.");
$db->renameTag($id, $newName);
break;
case 'merge':
$sourceId = filter_var($_POST['source_id'] ?? '', FILTER_VALIDATE_INT);
$targetId = filter_var($_POST['target_id'] ?? '', FILTER_VALIDATE_INT);
if (!$sourceId || !$targetId) throw new Exception("Paramètres invalides.");
$db->mergeTag($sourceId, $targetId);
break;
case 'delete':
$id = filter_var($_POST['tag_id'] ?? '', FILTER_VALIDATE_INT);
if (!$id) throw new Exception("ID invalide.");
$db->deleteTag($id);
break;
default:
throw new Exception("Action inconnue.");
}
$_SESSION['admin_success'] = "Opération effectuée.";
} catch (Exception $e) {
$_SESSION['admin_error'] = $e->getMessage();
}
header('Location: /admin/tags.php');
exit();

View File

@@ -0,0 +1,55 @@
<?php
require_once __DIR__ . '/../../../config/bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
$_SESSION['error'] = "Erreur de sécurité : token invalide.";
header('Location: /admin/');
exit;
}
require_once __DIR__ . '/../../../src/Database.php';
$action = $_POST['action'] ?? ''; // 'set_visibility'
$accessTypeId = filter_var($_POST['access_type_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
$isBulk = !empty($_POST['bulk']);
$validAccess = [null, 1, 2, 3];
if (!in_array($accessTypeId, $validAccess, true)) {
$_SESSION['error'] = "Valeur de visibilité invalide.";
header('Location: /admin/');
exit;
}
try {
$db = new Database();
if ($isBulk) {
$ids = array_filter(array_map('intval', $_POST['selected_theses'] ?? []), fn($id) => $id > 0);
if (empty($ids)) {
$_SESSION['error'] = "Aucun TFE sélectionné.";
header('Location: /admin/');
exit;
}
$db->bulkSetVisibility($ids, $accessTypeId);
$_SESSION['success'] = count($ids) . " TFE(s) mis à jour.";
} else {
$thesisId = filter_var($_POST['thesis_id'] ?? '', FILTER_VALIDATE_INT);
if (!$thesisId) {
$_SESSION['error'] = "ID invalide.";
header('Location: /admin/');
exit;
}
$db->setVisibility($thesisId, $accessTypeId);
$_SESSION['success'] = "Visibilité mise à jour.";
}
} catch (Exception $e) {
error_log("visibility.php error: " . $e->getMessage());
$_SESSION['error'] = "Erreur : " . $e->getMessage();
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('Location: /admin/');
exit;

View File

@@ -36,7 +36,9 @@ try {
$db->beginTransaction();
// Update thesis basic info
$editLicenseId = filter_var($_POST['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
$editLicenseId = filter_var($_POST['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
$editAccessTypeId = filter_var($_POST['access_type_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
$editContextNote = trim($_POST['context_note'] ?? '');
$stmt = $pdo->prepare("
UPDATE theses SET
@@ -47,9 +49,11 @@ try {
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 = ?
@@ -63,9 +67,11 @@ try {
intval($_POST['ap']),
intval($_POST['finality']),
trim($_POST['synopsis']),
!empty($editContextNote) ? $editContextNote : null,
!empty($_POST['duration_info']) ? trim($_POST['duration_info']) : null,
!empty($_POST['lien']) ? trim($_POST['lien']) : null,
$editLicenseId,
$editAccessTypeId,
isset($_POST['is_published']) ? 1 : 0,
$thesisId
]);
@@ -208,11 +214,15 @@ try {
$languages = $db->getAllLanguages();
$formatTypes = $db->getAllFormatTypes();
$licenseTypes = $db->getAllLicenseTypes();
$accessTypes = $db->getAccessTypes();
// Fetch raw license_id FK (view only exposes license_type name string)
$licenseStmt = $pdo->prepare("SELECT license_id FROM theses WHERE id = ?");
$licenseStmt->execute([$thesisId]);
$currentLicenseId = $licenseStmt->fetchColumn();
// Fetch raw FK IDs (view only exposes name strings)
$rawStmt = $pdo->prepare("SELECT license_id, access_type_id, context_note FROM theses WHERE id = ?");
$rawStmt->execute([$thesisId]);
$rawRow = $rawStmt->fetch();
$currentLicenseId = $rawRow['license_id'] ?? null;
$currentAccessTypeId = $rawRow['access_type_id'] ?? null;
$currentContextNote = $rawRow['context_note'] ?? '';
// Set page title for header
$pageTitle = "Éditer TFE - " . htmlspecialchars($thesis['title']);
@@ -380,6 +390,31 @@ try {
}
</script>
<div class="admin-form-row">
<label class="admin-label" for="access_type_id">Visibilité / Accès :</label>
<select class="admin-select" id="access_type_id" name="access_type_id">
<option value="">— Non défini —</option>
<?php foreach ($accessTypes as $at): ?>
<option value="<?= (int)$at['id'] ?>"
<?= ($currentAccessTypeId == $at['id']) ? 'selected' : '' ?>>
<?= htmlspecialchars($at['name']) ?>
<?php if (!empty($at['description'])): ?>
— <?= htmlspecialchars(mb_strimwidth($at['description'], 0, 60, '…')) ?>
<?php endif; ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="admin-form-row" style="align-items:start;">
<label class="admin-label" for="context_note">Note contextuelle :</label>
<div>
<textarea class="admin-textarea" id="context_note" name="context_note"
rows="4" maxlength="1500"><?= htmlspecialchars($currentContextNote ?? '') ?></textarea>
<p class="admin-hint">Visible publiquement pour les TFE Interne ou Interdit. Max 1 500 caractères.</p>
</div>
</div>
<div class="admin-form-row">
<label class="admin-label" for="license_id">Licence :</label>
<select class="admin-select" id="license_id" name="license_id">

View File

@@ -72,6 +72,29 @@ document.addEventListener('DOMContentLoaded', () => {
<div class="admin-alert admin-alert--success">✓ <?= htmlspecialchars($_SESSION['success']); unset($_SESSION['success']); ?></div>
<?php endif; ?>
<!-- Maintenance mode toggle -->
<?php $maintenanceOn = file_exists(APP_ROOT . '/storage/maintenance.flag'); ?>
<div class="admin-maintenance-bar <?= $maintenanceOn ? 'admin-maintenance-bar--active' : '' ?>">
<?php if ($maintenanceOn): ?>
<span>⚠ Mode maintenance <strong>activé</strong> — le site public est inaccessible.</span>
<form method="post" action="actions/maintenance.php" style="display:inline;">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="disable_maintenance">
<button type="submit" class="admin-btn admin-btn--sm">Désactiver la maintenance</button>
</form>
<?php else: ?>
<span>Site public : <strong>en ligne</strong></span>
<form method="post" action="actions/maintenance.php" style="display:inline;">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="enable_maintenance">
<button type="submit" class="admin-btn admin-btn--sm admin-btn--warning"
onclick="return confirm('Mettre le site en maintenance ? Les visiteurs verront une page 503.')">
Activer la maintenance
</button>
</form>
<?php endif; ?>
</div>
<!-- Stats -->
<div class="admin-stats">
<div class="admin-stat">
@@ -167,6 +190,11 @@ document.addEventListener('DOMContentLoaded', () => {
<?php else: ?>
<span class="status-badge status-pending">En attente</span>
<?php endif; ?>
<?php if (!empty($thesis['access_type'])): ?>
<br><span class="status-badge status-access status-access--<?= strtolower(preg_replace('/[^a-z]/i', '', $thesis['access_type'])) ?>">
<?= htmlspecialchars($thesis['access_type']) ?>
</span>
<?php endif; ?>
</td>
<td>
<div class="admin-actions">

97
public/admin/tags.php Normal file
View File

@@ -0,0 +1,97 @@
<?php
require_once __DIR__ . '/../../config/bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
require_once __DIR__ . '/../../src/Database.php';
$pageTitle = "Gestion des mots-clés";
try {
$db = new Database();
$tags = $db->getAllTagsWithCount();
} catch (Exception $e) {
die("Erreur : " . htmlspecialchars($e->getMessage()));
}
$error = $_SESSION['admin_error'] ?? null;
$success = $_SESSION['admin_success'] ?? null;
unset($_SESSION['admin_error'], $_SESSION['admin_success']);
?>
<?php require_once APP_ROOT . '/templates/admin/head.php'; ?>
<main class="admin-main">
<h1 class="admin-page-title">Mots-clés (<?= count($tags) ?>)</h1>
<?php if ($error): ?>
<div class="admin-alert admin-alert--error">⚠ <?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<?php if ($success): ?>
<div class="admin-alert admin-alert--success">✓ <?= htmlspecialchars($success) ?></div>
<?php endif; ?>
<table class="admin-table" style="margin-top:1.5rem;">
<thead>
<tr>
<th style="width:40%;">Nom</th>
<th style="width:12%;text-align:center;">TFE associés</th>
<th style="width:48%;">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($tags as $tag): ?>
<tr>
<td><?= htmlspecialchars($tag['name']) ?></td>
<td style="text-align:center;"><?= (int)$tag['thesis_count'] ?></td>
<td>
<!-- Rename -->
<form method="post" action="actions/tag.php" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="rename">
<input type="hidden" name="tag_id" value="<?= (int)$tag['id'] ?>">
<input class="admin-input admin-input--inline" type="text" name="new_name"
value="<?= htmlspecialchars($tag['name']) ?>" required style="width:160px;">
<button type="submit" class="admin-btn admin-btn--sm">Renommer</button>
</form>
<!-- Merge into another tag -->
<form method="post" action="actions/tag.php" class="admin-inline-form" style="margin-top:.35rem;">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="merge">
<input type="hidden" name="source_id" value="<?= (int)$tag['id'] ?>">
<select name="target_id" class="admin-select admin-select--inline" style="width:160px;" required>
<option value="">— Fusionner dans… —</option>
<?php foreach ($tags as $other): ?>
<?php if ($other['id'] !== $tag['id']): ?>
<option value="<?= (int)$other['id'] ?>"><?= htmlspecialchars($other['name']) ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
<button type="submit" class="admin-btn admin-btn--sm admin-btn--warning"
onclick="return confirm('Fusionner ce tag dans la cible ? Le tag source sera supprimé.')">
Fusionner
</button>
</form>
<!-- Delete -->
<form method="post" action="actions/tag.php" class="admin-inline-form" style="margin-top:.35rem;display:inline;">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="tag_id" value="<?= (int)$tag['id'] ?>">
<button type="submit" class="admin-btn admin-btn--sm admin-btn--danger"
onclick="return confirm('Supprimer le tag « <?= htmlspecialchars(addslashes($tag['name'])) ?> » ? Cette action est irréversible.')">
Supprimer
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</main>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

View File

@@ -641,3 +641,96 @@ html, body {
border-color: #e55;
color: #e55;
}
/* Inline form actions (tags page) */
.admin-inline-form {
display: flex;
align-items: center;
gap: .4rem;
flex-wrap: wrap;
}
.admin-input--inline {
padding: .28rem .5rem;
font-size: .82rem;
}
.admin-select--inline {
padding: .28rem .5rem;
font-size: .82rem;
}
.admin-btn--sm {
padding: .28rem .65rem;
font-size: .8rem;
}
.admin-btn--warning {
background: #7a5400;
border-color: #b87a00;
}
.admin-btn--warning:hover {
background: #9a6a00;
}
.admin-btn--danger {
background: #6b1a1a;
border-color: #a03030;
}
.admin-btn--danger:hover {
background: #8a2020;
}
/* Maintenance mode bar */
.admin-maintenance-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
background: #1a1a2e;
border: 1px solid #333;
border-radius: 4px;
padding: .65rem 1rem;
margin-bottom: 1.5rem;
font-size: .88rem;
color: var(--admin-text-muted);
}
.admin-maintenance-bar--active {
background: #2a1a00;
border-color: #7a5400;
color: #e0a030;
}
/* Visibility / access badges */
.status-access {
display: inline-block;
font-size: .7rem;
padding: .1rem .4rem;
border-radius: 3px;
margin-top: .2rem;
background: #333;
color: #aaa;
letter-spacing: .03em;
}
.status-access--Libre,
.status-access--libre {
background: #0a2a0a;
color: #6fbe6f;
border: 1px solid #2a5a2a;
}
.status-access--Interne,
.status-access--interne {
background: #1a1a2a;
color: #7a8fcc;
border: 1px solid #3a4a8a;
}
.status-access--Interdit,
.status-access--interdit {
background: #2a0a0a;
color: #cc6060;
border: 1px solid #7a2020;
}

59
public/maintenance.php Normal file
View File

@@ -0,0 +1,59 @@
<?php
// This page is served directly by nginx / bootstrap when maintenance mode is active.
// It is also served by the public gate in bootstrap.php.
http_response_code(503);
header('Retry-After: 3600');
?><!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Maintenance Posterg</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0d0d0d;
color: #e0e0e0;
font-family: 'Helvetica Neue', Arial, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.box {
max-width: 520px;
text-align: center;
}
.box__logo {
font-size: 1.1rem;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
color: #fff;
margin-bottom: 2.5rem;
}
.box__title {
font-size: 1.6rem;
font-weight: 300;
letter-spacing: .04em;
margin-bottom: 1rem;
}
.box__text {
font-size: .95rem;
color: #999;
line-height: 1.7;
}
</style>
</head>
<body>
<div class="box">
<div class="box__logo">POSTERG</div>
<h1 class="box__title">Maintenance en cours</h1>
<p class="box__text">
Le site est temporairement indisponible pour des raisons de maintenance.<br>
Merci de réessayer dans quelques instants.
</p>
</div>
</body>
</html>

View File

@@ -46,6 +46,38 @@ if (!is_file($realFull)) {
exit;
}
// --- 2b. Visibility gate: check access_type_id for thesis files ---------------
// Only applies to paths under theses/ (uploaded thesis content).
// Banners and covers are always public (no sensitivity).
if (preg_match('#^theses/#', $requestedPath)) {
require_once __DIR__ . '/../src/Database.php';
try {
$mediaDb = Database::getInstance();
$mediaPdo = $mediaDb->getConnection();
// Find the thesis that owns this file path
$visStmt = $mediaPdo->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
");
$visStmt->execute([$requestedPath]);
$visRow = $visStmt->fetch();
if ($visRow) {
$accessTypeId = (int)($visRow['access_type_id'] ?? 1);
// 3 = Interdit — block entirely
if ($accessTypeId === 3) {
http_response_code(403);
exit;
}
// 2 = Interne — allow (no session auth requirement for now; could add later)
}
} catch (\Throwable $e) {
// On DB error, fail open (don't block legitimate requests)
error_log("media.php visibility check error: " . $e->getMessage());
}
}
// --- 3. Verify MIME type from file content (not extension) --------------------
$finfo = new finfo(FILEINFO_MIME_TYPE);

View File

@@ -128,6 +128,13 @@ $currentNav = '';
</div>
<?php endif; ?>
<?php if (!empty($data['access_type'])): ?>
<div class="tfe-meta-item">
<span class="label">Accès :</span>
<span class="value"><?= htmlspecialchars($data['access_type']) ?></span>
</div>
<?php endif; ?>
<?php if (!empty($data['license_type'])): ?>
<div class="tfe-meta-item">
<span class="label">Licence :</span>
@@ -135,6 +142,13 @@ $currentNav = '';
</div>
<?php endif; ?>
<?php if (!empty($data['context_note'])): ?>
<div class="tfe-meta-item" style="align-items:start;">
<span class="label">Note :</span>
<span class="value" style="font-style:italic;"><?= nl2br(htmlspecialchars($data['context_note'])) ?></span>
</div>
<?php endif; ?>
<?php if (!empty($data['baiu_link'])): ?>
<div class="tfe-meta-item">
<span class="label">Contact :</span>
@@ -162,7 +176,24 @@ $currentNav = '';
<!-- RIGHT: media -->
<div class="tfe-right">
<?php if (!empty($data['files'])): ?>
<?php
// Determine effective access: need raw access_type_id
// The view exposes 'access_type' (name string). Fetch raw id for gate.
$accessTypeId = null;
try {
$accessStmt = $db->getConnection()->prepare(
"SELECT access_type_id FROM theses WHERE id = ?"
);
$accessStmt->execute([$thesisId]);
$accessTypeId = (int)($accessStmt->fetchColumn() ?? 1);
} catch (\Throwable $e) {}
$isInterdit = ($accessTypeId === 3);
?>
<?php if ($isInterdit): ?>
<p class="tfe-no-files" style="color:#999;font-style:italic;">
Ce TFE n'est pas disponible en ligne.
</p>
<?php elseif (!empty($data['files'])): ?>
<?php foreach ($data['files'] as $file): ?>
<?php $ext = strtolower(pathinfo($file['file_path'], PATHINFO_EXTENSION)); ?>
<div class="tfe-media-block">
@@ -184,7 +215,7 @@ $currentNav = '';
<?php endforeach; ?>
<?php else: ?>
<p class="tfe-no-files">Aucun fichier disponible pour ce TFE.</p>
<?php endif; ?>
<?php endif; // end !$isInterdit ?>
</div>
</div>

View File

@@ -640,6 +640,69 @@ class Database {
return $this->findOrCreateTag((string)$keyword);
}
// ========================================================================
// 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
*/
@@ -749,6 +812,44 @@ class Database {
return $this->getLicenseTypes();
}
// ========================================================================
// 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);
}
/**
* Get all access types (visibility options).
*/
public function getAccessTypes(): array {
$stmt = $this->pdo->query("SELECT * FROM access_types ORDER BY id");
return $stmt->fetchAll();
}
// ========================================================================
// JURY METHODS
// ========================================================================

0
storage/.gitkeep Normal file
View File

View File

@@ -0,0 +1,9 @@
-- Migration 002: Wire visibility states to existing access_type_id column.
-- The access_types table already has Libre (1), Interne (2), Interdit (3).
-- No structural changes needed — this migration is a no-op for the schema.
-- It documents the intent and ensures access_types seed rows exist.
INSERT OR IGNORE INTO access_types (id, name, description) VALUES
(1, 'Libre', 'TFE en libre accès à tout le monde sur la plateforme et en bibliothèque'),
(2, 'Interne', 'TFE accessible uniquement sur place en physique. Une note descriptive est disponible sur le site'),
(3, 'Interdit', 'TFE non disponible en physique ni sur le site. Une note descriptive est disponible sur le site');

Binary file not shown.

View File

@@ -28,6 +28,7 @@
<a href="/admin/add.php" class="admin-nav__link <?= $currentPage === 'add.php' ? 'active' : '' ?>">Ajouter un TFE</a>
<a href="/admin/import.php" class="admin-nav__link <?= $currentPage === 'import.php' ? 'active' : '' ?>">Importer une liste de TFE</a>
<a href="/admin/pages.php" class="admin-nav__link <?= in_array($currentPage, ['pages.php','pages-edit.php']) ? 'active' : '' ?>">Pages statiques</a>
<a href="/admin/tags.php" class="admin-nav__link <?= $currentPage === 'tags.php' ? 'active' : '' ?>">Mots-clés</a>
<?php if ($thesisId && in_array($currentPage, ['edit.php', 'thanks.php'])): ?>
<a href="/admin/edit.php?id=<?= intval($thesisId) ?>" class="admin-nav__link <?= $currentPage === 'edit.php' ? 'active' : '' ?>">Modifier</a>
<?php endif; ?>