admin: replace header 'Ajouter un TFE' nav link with toolbar button

This commit is contained in:
Pontoporeia
2026-04-16 11:50:59 +02:00
parent c4705f6265
commit 150099dc3c
12 changed files with 1166 additions and 76 deletions

23
TODO.md
View File

@@ -8,3 +8,26 @@
- [x] Organise all fields into `<fieldset>/<legend>` blocks: Informations du TFE, Composition du jury, Cadre académique, Fichiers, Métadonnées complémentaires - [x] Organise all fields into `<fieldset>/<legend>` blocks: Informations du TFE, Composition du jury, Cadre académique, Fichiers, Métadonnées complémentaires
- [x] Remove double-wrapping of jury-fieldset (it has its own `<fieldset>`) - [x] Remove double-wrapping of jury-fieldset (it has its own `<fieldset>`)
- [x] Add "Degrés d'ouverture et licences" section (Libre / Interne / Interdit + Généralités) wrapped in `if ($studentMode)` — hidden in admin - [x] Add "Degrés d'ouverture et licences" section (Libre / Interne / Interdit + Généralités) wrapped in `if ($studentMode)` — hidden in admin
- [x] Migrate student mode form to shareable links system (/partage/<form-url>)
- [x] Create `share_links` database table (id, slug YYYYMMDD-random, password_hash, is_active, usage_count, created_by, created_at, expires_at nullable)
- [x] Create `ShareLink` model — generate slugs, validate, verify password, CRUD
- [x] Create `public/partage/index.php` — public form page (no auth required, validates link active + password if set)
- [x] Create `public/partage/.htaccess` — RewriteRule to route all partage paths to index.php
- [x] Create `public/partage/thanks.php` — post-submission confirmation page
- [x] Move student-specific licence explanation fieldset to partage form template
- [x] Share-link specific CSRF token (session-scoped `share_csrf_<slug>`) instead of session CSRF
- [x] Create admin page for managing student access links
- [x] Create `public/admin/student-access.php` — "Accès étudiant·e" page
- [x] Link to new page from admin navigation
- [x] Implement list view of all share links with status (active/disabled, password set, usage count, created date)
- [x] Implement create new link modal/form (optional expiration, password)
- [x] Implement toggle active/disabled status per link
- [x] Implement password set/change/clear per link
- [x] Implement delete link action
- [x] Copy-to-clipboard button for full partage URL
- [ ] Security and validation considerations
- [ ] Rate limiting on form submissions per share link
- [ ] Add flash messages / error handling for invalid/disabled/password-protected links

View File

@@ -9,8 +9,6 @@ ini_set('error_log', 'error.log');
AdminAuth::requireLogin(); AdminAuth::requireLogin();
$studentMode = isset($_POST['student_mode']) && $_POST['student_mode'] === '1';
// Verify CSRF token // Verify CSRF token
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token']) if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) { || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
@@ -28,10 +26,6 @@ try {
unset($_SESSION['csrf_token']); unset($_SESSION['csrf_token']);
$redirect = '../thanks.php?id=' . urlencode($thesisId);
if ($studentMode) {
$redirect .= '&mode=student';
}
header('Location: ' . $redirect); header('Location: ' . $redirect);
exit(); exit();
@@ -42,9 +36,6 @@ try {
$_SESSION['form_data'] = $_POST; $_SESSION['form_data'] = $_POST;
$redirect = '../add.php'; $redirect = '../add.php';
if ($studentMode) {
$redirect .= '?mode=student';
}
$autofocusField = ThesisCreateController::autofocusFieldForError($e->getMessage()); $autofocusField = ThesisCreateController::autofocusFieldForError($e->getMessage());
if ($autofocusField !== null) { if ($autofocusField !== null) {

View File

@@ -0,0 +1,69 @@
<?php
/**
* Student-access link actions (create, toggle, set_password, delete).
*/
require_once __DIR__ . '/../../../config/bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
require_once __DIR__ . '/../../../src/ShareLink.php';
App::adminGuard();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|| !isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
exit('CSRF token invalide.');
}
$action = $_POST['action'] ?? '';
$id = isset($_POST['id']) ? intval($_POST['id']) : 0;
$shareLink = ShareLink::make();
switch ($action) {
case 'create':
$password = !empty($_POST['password']) ? trim($_POST['password']) : null;
$expiresRaw = !empty($_POST['expires_at']) ? trim($_POST['expires_at']) : null;
$expiresAt = null;
if ($expiresRaw) {
// datetime-local gives "YYYY-MM-DDTHH:MM"
$expiresAt = date('Y-m-d H:i:s', strtotime($expiresRaw));
if ($expiresAt <= date('Y-m-d H:i:s')) {
App::redirect('/admin/student-access.php', error: "La date d'expiration doit être dans le futur.");
}
}
$shareLink->create(1, $password, $expiresAt);
App::redirect('/admin/student-access.php', success: 'Lien d\'accès créé.');
break;
case 'toggle':
if ($id > 0) {
$shareLink->toggleActive($id);
App::redirect('/admin/student-access.php', success: 'Statut du lien modifié.');
} else {
App::redirect('/admin/student-access.php', error: 'Lien introuvable.');
}
break;
case 'set_password':
if ($id > 0) {
$password = isset($_POST['password']) && $_POST['password'] !== '' ? trim($_POST['password']) : null;
$shareLink->setPassword($id, $password);
App::redirect('/admin/student-access.php', success: 'Mot de passe mis à jour.');
} else {
App::redirect('/admin/student-access.php', error: 'Lien introuvable.');
}
break;
case 'delete':
if ($id > 0) {
$shareLink->delete($id);
App::redirect('/admin/student-access.php', success: 'Lien supprimé.');
} else {
App::redirect('/admin/student-access.php', error: 'Lien introuvable.');
}
break;
default:
App::redirect('/admin/student-access.php', error: 'Action inconnue.');
break;
}

View File

@@ -8,7 +8,6 @@ if (empty($_SESSION["csrf_token"])) {
} }
$pageTitle = "Ajouter un TFE"; $pageTitle = "Ajouter un TFE";
$studentMode = isset($_GET['mode']) && $_GET['mode'] === 'student';
require_once __DIR__ . '/../../src/ThesisCreateController.php'; require_once __DIR__ . '/../../src/ThesisCreateController.php';
@@ -48,33 +47,20 @@ function wasSelected($key, $value) {
?> ?>
<?php <?php
$isAdmin = true; $isAdmin = true;
if ($studentMode) {
$bodyClass = 'admin-body student-body';
require_once APP_ROOT . '/templates/head.php';
} else {
$bodyClass = 'admin-body'; $bodyClass = 'admin-body';
require_once APP_ROOT . '/templates/head.php'; require_once APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php'; include APP_ROOT . '/templates/header.php';
}
?> ?>
<main id="main-content"> <main id="main-content">
<div class="thesis-add-header"> <div class="thesis-add-header">
<h1>Ajouter un TFE</h1> <h1>Ajouter un TFE</h1>
<?php if (!$studentMode): ?>
<a href="?mode=student" class="mode-toggle" target="_blank" rel="noopener">Mode étudiant ↗</a>
<?php else: ?>
<a href="?mode=admin" class="mode-toggle mode-toggle--back">← Retour admin</a>
<?php endif; ?>
</div> </div>
<?php include APP_ROOT . '/templates/partials/flash-messages.php'; ?> <?php include APP_ROOT . '/templates/partials/flash-messages.php'; ?>
<form action="actions/formulaire.php" method="post" enctype="multipart/form-data" class="admin-form"> <form action="actions/formulaire.php" method="post" enctype="multipart/form-data" class="admin-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">
<?php if ($studentMode): ?>
<input type="hidden" name="student_mode" value="1">
<?php endif; ?>
<!-- ═══════════════════ Informations du TFE ═══════════════════ --> <!-- ═══════════════════ Informations du TFE ═══════════════════ -->
<fieldset> <fieldset>
@@ -165,55 +151,6 @@ if ($studentMode) {
?> ?>
</fieldset> </fieldset>
<?php if ($studentMode): ?>
<!-- ═══════════════════ Degrés d'ouverture ═══════════════════ -->
<fieldset class="licence-explanation">
<legend>Degrés d'ouverture et licences</legend>
<div class="licence-info">
<h3>Je veux que mon TFE soit disponible sous les conditions suivantes :</h3>
<div class="licence-degree">
<h4>🔓 Libre</h4>
<p>Mon TFE est en libre accès à tout le monde sur la plateforme des TFE ainsi que dans la bibliothèque de l'erg. Je suis conscient·e des responsabilités et obligations légales qui viennent avec une diffusion externe et acquiesce avoir lu la documentation prévue à cet effet par l'erg, ainsi qu'avoir discuté des enjeux d'une publication avec l'équipe pédagogique. J'accepte de partager mes droits de diffusion avec l'erg, ce uniquement dans le cadre d'une diffusion sur la plateforme xamxam.</p>
<ul>
<li><label><input type="checkbox" name="cc4r" value="1"> J'accepte les conditions collectives de réutilisation (CC4r) <em class="hint">(pas obligatoire)</em></label></li>
<li><label><input type="checkbox" name="specific_license" value="1"> Je souhaite appliquer une licence spécifique à mon travail <em class="hint">(pas obligatoire)</em></label></li>
</ul>
<p class="licence-note"><em>Au moins une des deux cases doit être cochée pour le degré Libre.</em></p>
</div>
<div class="licence-degree">
<h4>🔒 Interne</h4>
<p>Mon TFE et ma note d'intention ne sont accessibles que sur place en physique ainsi que sur la plateforme xamxam par la communauté erg. Une note descriptive est disponible sur le site à toustes. J'autorise une (ré-)utilisation et diffusion dans un contexte académique et didactique au sein de l'erg.</p>
<p class="licence-note"><em>La diffusion limitée est protégée par le cadre académique/didactique, le travail pourrait donc être diffusé en interne et être cité par d'autres étudiant·es sans implications légales pour l'auteur·ice ni pour l'école.</em></p>
<ul>
<li><label><input type="checkbox" name="cc4r" value="1"> J'accepte les conditions collectives de réutilisation (CC4r) <em class="hint">(pas obligatoire)</em></label></li>
<li><label><input type="checkbox" name="specific_license" value="1"> Je souhaite appliquer une licence spécifique à mon travail <em class="hint">(pas obligatoire)</em></label></li>
</ul>
<p class="licence-note"><em>Au moins une des deux cases doit être cochée.</em></p>
</div>
<div class="licence-degree">
<h4>🚫 Interdit</h4>
<p>Mon TFE n'est pas disponible en physique ni sur le site. Une note descriptive est disponible sur le site.</p>
</div>
</div>
<div class="licence-generalites">
<h3>Généralités</h3>
<ul>
<li>L'auteur·ice peut décider entre trois degrés de partage de son travail : <strong>libre</strong>, <strong>interne</strong>, <strong>interdit</strong>.</li>
<li>L'auteur·ice peut, à tout moment, décider de <strong>restreindre</strong> le degré d'accès à son travail. Il ne peut néanmoins pas l'ouvrir davantage.</li>
<li>Le choix effectué dans ce formulaire sera d'application <strong>une semaine après la soutenance orale</strong> de l'auteur·ice. Celui-ci peut donc décider de restreindre ce choix avant sa publication (mais pas l'ouvrir).</li>
<li>L'erg se réserve le droit de restreindre le degré d'ouverture du TFE ce en accord avec le règlement.</li>
<li>Dans tous les cas, l'auteur·ice garde les droits d'auteurs, de diffusion, d'utilisation, etc. de son travail sauf si la licence choisie restreindrait ses droits.</li>
<li>La diffusion « xamxam » est indépendante de la diffusion à la BAIU.</li>
</ul>
</div>
</fieldset>
<?php endif; ?>
<div class="form-footer"> <div class="form-footer">
<button type="submit" name="go">Soumettre</button> <button type="submit" name="go">Soumettre</button>
</div> </div>

View File

@@ -0,0 +1,244 @@
<?php
require_once __DIR__ . '/../../config/bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
require_once __DIR__ . '/../../src/ShareLink.php';
App::adminGuard();
require_once __DIR__ . '/../../src/ShareLink.php';
$shareLink = ShareLink::make();
$links = $shareLink->listAll();
$flash = App::consumeFlash();
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$baseUrl = $protocol . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
$pageTitle = 'Accès étudiant·e';
$isAdmin = true;
$bodyClass = 'admin-body';
?>
<?php require_once APP_ROOT . '/templates/head.php'; ?>
<?php include APP_ROOT . '/templates/header.php'; ?>
<style>
.access-main { padding: 2rem; max-width: 960px; }
.access-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 1rem; margin-bottom: 1.5rem; }
.access-toolbar h1 { margin: 0; font-size: 1.5rem; }
.access-btn { display: inline-flex; align-items: center; gap: .4rem; padding: .5rem 1rem; border: none; border-radius: 6px; cursor: pointer; font-size: .875rem; background: #2563eb; color: #fff; text-decoration: none; }
.access-btn:hover { background: #1d4ed8; }
.access-btn--secondary { background: #f1f5f9; color: #334155; border: 1px solid #cbd5e1; }
.access-btn--secondary:hover { background: #e2e8f0; }
.access-btn--danger { background: #fee2e2; color: #b91c1c; }
.access-btn--danger:hover { background: #fca5a5; }
.access-btn--sm { padding: .3rem .6rem; font-size: .8rem; }
.access-table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
.access-table th, .access-table td { padding: .6rem .8rem; text-align: left; border-bottom: 1px solid #e2e8f0; vertical-align: middle; }
.access-table th { font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; color: #64748b; background: #f8fafc; }
.access-table th:first-child { border-radius: 6px 0 0 0; }
.access-table th:last-child { border-radius: 0 6px 0 0; }
.access-table tbody tr:hover { background: #f8fafc; }
.access-slug { font-family: ui-monospace, SFMono-Regular, monospace; font-size: .85rem; }
.access-url-input { display: none; margin-top: .25rem; padding: .35rem .5rem; font-size: .8rem; border: 1px solid #cbd5e1; border-radius: 4px; width: 100%; max-width: 320px; font-family: ui-monospace, SFMono-Regular, monospace; }
.access-url-input.visible { display: inline-block; }
.access-badge { display: inline-block; padding: .15rem .5rem; border-radius: 9999px; font-size: .75rem; font-weight: 500; background: #dcfce7; color: #166534; }
.access-badge--disabled { background: #fee2e2; color: #b91c1c; }
.access-badge--expired { background: #fef3c7; color: #92400e; }
.access-act { display: flex; gap: .35rem; flex-wrap: wrap; }
/* ── Create dialog ── */
.access-dialog { border: none; border-radius: 12px; padding: 0; width: min(480px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.18); max-height: 80vh; overflow: auto; }
.access-dialog::backdrop { background: rgba(0,0,0,.35); }
.access-dialog__header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; border-bottom: 1px solid #e2e8f0; }
.access-dialog__header h2 { margin: 0; font-size: 1.15rem; }
.access-dialog__close { background: none; border: none; font-size: 1.4rem; cursor: pointer; color: #64748b; line-height: 1; }
.access-dialog__body { padding: 1.5rem; }
.access-dialog__body label { display: block; margin-bottom: 1rem; font-size: .9rem; }
.access-dialog__body label input[type=text],
.access-dialog__body label input[type=password],
.access-dialog__body label input[type=datetime-local] { display: block; width: 100%; margin-top: .3rem; padding: .45rem .6rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: .9rem; box-sizing: border-box; }
.access-dialog__body small { display: block; margin-top: .25rem; color: #64748b; }
.access-dialog__footer { display: flex; justify-content: flex-end; gap: .5rem; padding: 1rem 1.5rem; border-top: 1px solid #e2e8f0; }
.access-empty { text-align: center; padding: 3rem 1rem; color: #94a3b8; }
.access-empty svg { width: 48px; height: 48px; margin-bottom: .75rem; opacity: .5; }
</style>
<main id="main-content" class="access-main">
<?php if ($flash['success']): ?>
<div class="flash flash--success"><?= htmlspecialchars($flash['success']) ?></div>
<?php endif; ?>
<?php if ($flash['error']): ?>
<div class="flash flash--error"><?= htmlspecialchars($flash['error']) ?></div>
<?php endif; ?>
<div class="access-toolbar">
<h1>Accès étudiant·e</h1>
<button type="button" class="access-btn" id="open-create-dialog">
Créer un lien
</button>
</div>
<?php if (empty($links)): ?>
<div class="access-empty">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"/></svg>
<p>Aucun lien d'accès créé.</p>
<p>Cliquez sur « Créer un lien » pour générer un lien partageable.</p>
</div>
<?php else: ?>
<table class="access-table">
<thead>
<tr>
<th scope="col">Lien</th>
<th scope="col">Statut</th>
<th scope="col">Mot de passe</th>
<th scope="col">Utilisations</th>
<th scope="col">Expiration</th>
<th scope="col">Créé le</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($links as $link): ?>
<?php
$isExpired = $link['expires_at'] !== null && strtotime($link['expires_at']) < time();
$isActive = (bool)$link['is_active'] && !$isExpired;
$statusLabel = $isExpired ? 'Expiré' : ($link['is_active'] ? 'Actif' : 'Désactivé');
$statusClass = $isExpired ? 'access-badge--expired' : ($link['is_active'] ? '' : 'access-badge--disabled');
$fullUrl = $baseUrl . '/partage/' . htmlspecialchars($link['slug']);
$created = date('d/m/Y H:i', strtotime($link['created_at']));
$expires = $link['expires_at'] ? date('d/m/Y', strtotime($link['expires_at'])) : '—';
$hasPassword = !empty($link['password_hash']);
?>
<tr>
<td>
<span class="access-slug"><?= htmlspecialchars($link['slug']) ?></span>
<input class="access-url-input" id="url-<?= $link['id'] ?>" value="<?= $fullUrl ?>" readonly>
</td>
<td><span class="access-badge <?= $statusClass ?>"><?= $statusLabel ?></span></td>
<td><?= $hasPassword ? '🔒 Oui' : 'Non' ?></td>
<td><?= intval($link['usage_count']) ?></td>
<td><?= $expires ?></td>
<td><?= $created ?></td>
<td>
<div class="access-act">
<button type="button" class="access-btn access-btn--secondary access-btn--sm"
onclick="copyUrl(<?= $link['id'] ?>)" title="Copier l'URL">
Copier
</button>
<form method="post" action="actions/student-access.php" style="display:inline;">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="toggle">
<input type="hidden" name="id" value="<?= $link['id'] ?>">
<button type="submit" class="access-btn access-btn--secondary access-btn--sm"
title="<?= $link['is_active'] ? 'Désactiver' : 'Activer' ?>">
<?= $link['is_active'] ? '⏸' : '▶' ?>
</button>
</form>
<button type="button" class="access-btn access-btn--secondary access-btn--sm"
onclick="openPasswordDialog(<?= $link['id'] ?>, <?= $hasPassword ? 'true' : 'false' ?>)"
title="Modifier le mot de passe">
🔑
</button>
<form method="post" action="actions/student-access.php" style="display:inline;"
onsubmit="return confirm('Supprimer ce lien ? Les soumissions via ce lien seront bloquées.')">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= $link['id'] ?>">
<button type="submit" class="access-btn access-btn--danger access-btn--sm" title="Supprimer">
🗑
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</main>
<!-- ═══════════════════════ CREATE DIALOG ═══════════════════════ -->
<dialog id="create-dialog" class="access-dialog">
<div class="access-dialog__header">
<h2>Créer un lien d'accès</h2>
<button type="button" class="access-dialog__close" onclick="document.getElementById('create-dialog').close()">&#x2715;</button>
</div>
<form method="post" action="actions/student-access.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="create">
<div class="access-dialog__body">
<label>
Mot de passe (optionnel)
<input type="password" name="password" autocomplete="new-password">
<small>Laissez vide pour un lien sans mot de passe.</small>
</label>
<label>
Expiration (optionnel)
<input type="datetime-local" name="expires_at">
<small>Laissez vide pour qu'il n'expire jamais.</small>
</label>
</div>
<div class="access-dialog__footer">
<button type="button" class="access-btn access-btn--secondary"
onclick="document.getElementById('create-dialog').close()">Annuler</button>
<button type="submit" class="access-btn">Créer le lien</button>
</div>
</form>
</dialog>
<!-- ═══════════════════════ PASSWORD DIALOG ═══════════════════════ -->
<dialog id="password-dialog" class="access-dialog">
<div class="access-dialog__header">
<h2>Mot de passe</h2>
<button type="button" class="access-dialog__close" onclick="document.getElementById('password-dialog').close()">&#x2715;</button>
</div>
<form method="post" action="actions/student-access.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="set_password">
<input type="hidden" name="id" id="password-link-id" value="">
<div class="access-dialog__body">
<label>
Nouveau mot de passe
<input type="password" name="password" autocomplete="new-password">
<small>Laissez vide pour supprimer le mot de passe.</small>
</label>
<p id="password-current-info" style="font-size:.85rem;color:#64748b;margin-top:.5rem;"></p>
</div>
<div class="access-dialog__footer">
<button type="button" class="access-btn access-btn--secondary"
onclick="document.getElementById('password-dialog').close()">Annuler</button>
<button type="submit" class="access-btn">Enregistrer</button>
</div>
</form>
</dialog>
<script>
document.getElementById('open-create-dialog').addEventListener('click', () => {
document.getElementById('create-dialog').showModal();
});
function copyUrl(id) {
const input = document.getElementById('url-' + id);
input.classList.toggle('visible');
if (input.classList.contains('visible')) {
input.select();
navigator.clipboard.writeText(input.value).then(() => {
// Brief visual feedback
input.style.outline = '2px solid #22c55e';
setTimeout(() => { input.style.outline = ''; }, 600);
});
}
}
function openPasswordDialog(id, hasPassword) {
document.getElementById('password-link-id').value = id;
const info = document.getElementById('password-current-info');
info.textContent = hasPassword
? 'Un mot de passe est actuellement configuré. Entrez-en un nouveau ou laissez vide pour le supprimer.'
: 'Aucun mot de passe configuré.';
document.getElementById('password-dialog').showModal();
}
</script>
<?php require_once APP_ROOT . '/templates/admin/footer.php'; ?>

28
public/partage/.htaccess Normal file
View File

@@ -0,0 +1,28 @@
# Route all partage requests to index.php
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L]
</IfModule>
# Security headers
<IfModule mod_headers.c>
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"
</IfModule>
# Prevent directory listing
Options -Indexes
# Protect dotfiles
<FilesMatch "^\.">
Require all denied
</FilesMatch>

500
public/partage/index.php Normal file
View File

@@ -0,0 +1,500 @@
<?php
/**
* Partage — Entry point for shared student submission forms.
*
* Routes:
* /partage/<slug> — Render the share-link form (or password gate)
* /partage/<slug>/submit — POST endpoint for form submissions via share link
* /partage/thanks.php?id=N — Post-submission confirmation page
*/
require_once __DIR__ . '/../../config/bootstrap.php';
// Parse the requested path from REQUEST_URI
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
// Remove query string
$basePath = parse_url($requestUri, PHP_URL_PATH);
// Extract the part after /partage/
$path = trim(str_replace('/partage/', '', $basePath), '/');
// Split into parts: /partage/<slug>/<action>
$parts = explode('/', $path);
$slug = $parts[0] ?? '';
$action = $parts[1] ?? '';
// Validate slug format: YYYYMMDD-XXXXXXXX (17 chars)
if (!preg_match('/^\d{8}-[A-Z2-7]{8}$/', $slug)) {
http_response_code(404);
die('Lien invalide.');
}
// ── POST: form submission ─────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'submit') {
handleShareLinkSubmission($slug);
exit;
}
// ── GET: render form ─────────────────────────────────────────────────────────
App::boot(); // boot database + CSRF
require_once APP_ROOT . '/src/ShareLink.php';
$shareLinkModel = new ShareLink(Database::getInstance());
$validationResult = $shareLinkModel->validateLink($slug);
if (!$validationResult['valid']) {
$reason = $validationResult['reason'];
if ($reason === 'not_found') {
http_response_code(404);
die('Ce lien de partage n\'existe pas ou a été supprimé.');
}
if ($reason === 'disabled') {
http_response_code(403);
die('Ce lien de partage a été désactivé.');
}
if ($reason === 'expired') {
http_response_code(403);
die('Ce lien de partage a expiré.');
}
if ($reason === 'needs_password') {
// Show password gate
$link = $validationResult['link'];
requirePasswordGate($link, $slug);
exit;
}
http_response_code(400);
die('Erreur inattendue.');
}
// Link is valid — render the form
$link = $validationResult['link'];
renderShareLinkForm($slug, $link);
// ── Functions ─────────────────────────────────────────────────────────────────
function requirePasswordGate(array $link, string $slug): void
{
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['share_password'])) {
require_once APP_ROOT . '/src/ShareLink.php';
$shareLinkModel = new ShareLink(Database::getInstance());
if ($shareLinkModel->verifyPassword($link, $_POST['share_password'])) {
// Store verified status in session
$_SESSION['share_verified_' . $slug] = true;
// Redirect to clear POST data
header('Location: /partage/' . $slug);
exit;
} else {
$error = 'Mot de passe incorrect.';
}
}
$pageTitle = 'Accès protégé';
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<link rel="stylesheet" href="<?= App::assetV('/assets/css/system.css') ?>">
<style>
.password-gate {
max-width: 400px;
margin: 4rem auto;
padding: 2rem;
text-align: center;
}
.password-gate h1 { margin-bottom: 1.5rem; }
.password-gate input[type="password"] {
width: 100%;
padding: 0.5rem;
margin: 0.5rem 0 1rem;
font-size: 1rem;
}
.password-gate button {
padding: 0.75rem 2rem;
font-size: 1rem;
cursor: pointer;
}
.password-error {
color: red;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="password-gate">
<h1>🔒 Accès protégé</h1>
<?php if ($error): ?>
<p class="password-error"><?= htmlspecialchars($error) ?></p>
<?php endif; ?>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>">
<label for="share_password">Ce lien est protégé par un mot de passe :</label>
<input type="password" id="share_password" name="share_password" required autofocus>
<br>
<button type="submit">Accéder au formulaire</button>
</form>
</div>
</body>
</html>
<?php
}
function renderShareLinkForm(string $slug, array $link): void
{
require_once APP_ROOT . '/src/ThesisCreateController.php';
try {
$ctrl = ThesisCreateController::make();
extract($ctrl->loadFormData());
} catch (Exception $e) {
error_log('Failed to load form data: ' . $e->getMessage());
die('Erreur lors du chargement du formulaire.');
}
$formData = $_SESSION['form_data_share_' . $slug] ?? [];
unset($_SESSION['form_data_share_' . $slug]);
// Generate a CSRF token specific to this share link (stored in session)
$shareCsrfKey = 'share_csrf_' . $slug;
if (empty($_SESSION[$shareCsrfKey])) {
$_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32));
}
$shareCsrfToken = $_SESSION[$shareCsrfKey];
$pageTitle = 'Soumettre un TFE';
// Determine if previously verified by password
$isVerified = !empty($_SESSION['share_verified_' . $slug]);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<link rel="stylesheet" href="<?= App::assetV('/assets/css/system.css') ?>">
<link rel="stylesheet" href="<?= App::assetV('/assets/css/admin.css') ?>">
<style>
.student-body {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.licence-explanation {
background: #f9f9f9;
border-left: 4px solid #666;
padding: 1.5rem;
margin: 2rem 0;
}
.licence-info h3 { margin-top: 1.5rem; }
.licence-degree { margin: 1.5rem 0; padding: 1rem; background: white; border: 1px solid #ddd; border-radius: 4px; }
.licence-note { color: #666; font-size: 0.9rem; margin-top: 0.5rem; }
.licence-generalites { margin-top: 2rem; }
.form-footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 2px solid #333;
}
.share-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: #e0f0ff;
border: 1px solid #99c;
border-radius: 3px;
font-size: 0.85rem;
margin-bottom: 1rem;
}
h1 { margin-bottom: 0.5rem; }
.mode-toggle { font-size: 0.9rem; }
</style>
</head>
<body class="admin-body student-body">
<main id="main-content">
<div class="thesis-add-header">
<h1>Soumettre un TFE</h1>
<?php if ($isVerified): ?>
<span class="share-badge">🔓 Accès partagé</span>
<?php endif; ?>
</div>
<?php
// Show flash messages from error redirect
$flashError = $_SESSION['_flash_error'] ?? null;
$flashSuccess = $_SESSION['_flash_success'] ?? null;
unset($_SESSION['_flash_error'], $_SESSION['_flash_success']);
?>
<?php if ($flashError): ?>
<div class="flash-error" role="alert"><?= htmlspecialchars($flashError) ?></div>
<?php endif; ?>
<?php if ($flashSuccess): ?>
<div class="flash-success" role="alert"><?= htmlspecialchars($flashSuccess) ?></div>
<?php endif; ?>
<form action="/partage/<?= urlencode($slug) ?>/submit" method="post" enctype="multipart/form-data" class="admin-form">
<input type="hidden" name="share_link_token" value="<?= htmlspecialchars($shareCsrfToken) ?>">
<!-- ═══════════════════ Informations du TFE ═══════════════════ -->
<fieldset>
<legend>Informations du TFE</legend>
<?php $name = 'titre'; $label = 'Titre :'; $value = old($formData, 'titre'); $required = true; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'subtitle'; $label = 'Sous-titre (si applicable) :'; $value = old($formData, 'subtitle'); $required = false; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'auteurice'; $label = 'Auteur·ice(s) :'; $value = old($formData, 'auteurice'); $required = true; $attrs = ['autocomplete' => 'name']; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'mail'; $label = 'Contact(s) (optionnel) [mail/site/insta/etc.] :'; $value = old($formData, 'mail'); $attrs = ['autocomplete' => 'email']; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<div class="admin-form-group">
<label class="admin-checkbox-label">
<input type="checkbox" name="contact_public" value="1"
<?= isset($formData['contact_public']) ? 'checked' : '' ?>>
Je veux que mon contact soit accessible à toustes depuis la plateforme xamxam
</label>
<small>Si cette case est cochée, votre contact apparaîtra sur la page publique de votre TFE.</small>
</div>
<div>
<label for="synopsis">Synopsis :</label>
<textarea id="synopsis" name="synopsis" rows="7" required><?= old($formData, 'synopsis') ?></textarea>
</div>
</fieldset>
<!-- ═══════════════════ Composition du jury ═══════════════════ -->
<fieldset>
<legend>Composition du jury</legend>
<?php $name = 'jury_president'; $label = 'Président·e du jury :'; $value = old($formData, 'jury_president'); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'jury_promoteur'; $label = 'Promoteur·ice :'; $value = old($formData, 'jury_promoteur'); $hintCheckbox = ['name' => 'jury_promoteur_ext', 'label' => 'Externe à l\'erg', 'checked' => !empty($formData['jury_promoteur_ext'])]; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<div class="jury-additional">
<p>Lecteurs·rices (optionnel) :</p>
<?php for ($i = 0; $i < 4; $i++): ?>
<div class="jury-additional-row">
<input type="text"
name="jury_lecteurs[]"
placeholder="<?= $i === 0 ? 'Nom du lecteur·ice' : ''; ?>"
value="<?= old($formData, "jury_lecteurs:$i") ?>">
<label class="admin-checkbox-label">
<input type="checkbox" name="jury_lecteurs_ext[<?= $i ?>]" value="1"
<?= !empty($formData["jury_lecteurs_ext:$i"]) ? 'checked' : '' ?>>
Externe
</label>
</div>
<?php endfor; ?>
</div>
</fieldset>
<!-- ═══════════════════ Cadre académique ═══════════════════ -->
<fieldset>
<legend>Cadre académique</legend>
<?php
$name = 'année'; $label = 'Année :'; $value = old($formData, 'année'); $required = true;
$type = 'number';
$placeholder = date('Y');
$attrs = ['min' => 2000, 'max' => date('Y') + 1];
include APP_ROOT . '/templates/partials/form/text-field.php';
?>
<?php $name = 'orientation'; $label = 'Orientation :'; $options = $orientations; $selected = isset($formData['orientation']) ? $formData['orientation'] : ''; $required = true; $placeholder = ''; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'ap'; $label = 'Atelier pluridisciplinaire :'; $options = $apPrograms; $selected = isset($formData['ap']) ? $formData['ap'] : ''; $required = true; $placeholder = ''; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'finality'; $label = 'Finalité du master :'; $options = $finalityTypes; $selected = isset($formData['finality']) ? $formData['finality'] : ''; $required = true; $placeholder = ''; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'languages'; $label = 'Langue(s) :'; $options = $languages; $checked = $formData['languages'] ?? []; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?>
<?php $name = 'formats'; $label = 'Format(s) :'; $options = $formatTypes; $checked = $formData['formats'] ?? []; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?>
<?php $name = 'tag'; $label = 'Mots-clés :'; $value = old($formData, 'tag'); $placeholder = 'sociologie, anthropologie, ...'; $hint = 'Séparez par des virgules. Max 10 mots-clés.'; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
</fieldset>
<!-- ═══════════════════ Fichiers ═══════════════════ -->
<fieldset>
<legend>Fichiers</legend>
<?php $name = 'couverture'; $label = 'Image de couverture :'; $accept = 'image/jpeg,image/png'; $hint = 'JPG, PNG. Taille max : 10 MB.'; include APP_ROOT . '/templates/partials/form/file-field.php'; ?>
<?php $name = 'banner'; $label = 'Image bannière (accueil) :'; $accept = 'image/jpeg,image/png,image/webp'; $hint = 'JPG, PNG ou WEBP. Format paysage recommandé (4:1). Max 5 MB.'; include APP_ROOT . '/templates/partials/form/file-field.php'; ?>
<?php $name = 'files'; $label = 'Fichiers du TFE :'; $accept = '.pdf,.jpg,.jpeg,.png,.mp4,.zip,.vtt'; $hint = 'PDF, JPG, PNG, MP4, ZIP. Max 50 MB par fichier. Pour les vidéos, un fichier .vtt de sous-titres peut être joint (il sera associé automatiquement à la vidéo correspondante).'; $multiple = true; include APP_ROOT . '/templates/partials/form/file-field.php'; ?>
</fieldset>
<!-- ═══════════════════ Métadonnées complémentaires ═══════════════════ -->
<fieldset>
<legend>Métadonnées complémentaires</legend>
<?php $name = 'license_id'; $label = 'Licence :'; $options = $licenseTypes; $selected = isset($formData['license_id']) ? $formData['license_id'] : ''; $placeholder = '— Inconnue —'; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'duration_info'; $label = 'Durée / Taille :'; $value = old($formData, 'duration_info'); $placeholder = 'Ex : 84 pages'; $hint = 'Durée (minutes) ou nombre de pages.'; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'lien'; $label = 'Lien (site / ressource) :'; $value = old($formData, 'lien'); $type = 'url'; $placeholder = 'https://...'; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php
$accessOptions = array_map(function($at) {
return ['id' => $at['id'], 'name' => $at['name']];
}, $enabledAccessTypes);
$defaultAccessType = 2;
$selectedAccessType = isset($formData['access_type_id'])
? (int)$formData['access_type_id']
: $defaultAccessType;
$name = 'access_type_id';
$label = 'Visibilité / Accès :';
$options = $accessOptions;
$selected = $selectedAccessType;
$placeholder = null;
$required = true;
$attrs = [];
include APP_ROOT . '/templates/partials/form/select-field.php';
?>
</fieldset>
<!-- ═══════════════════ Degrés d'ouverture ═══════════════════ -->
<fieldset class="licence-explanation">
<legend>Degrés d'ouverture et licences</legend>
<div class="licence-info">
<h3>Je veux que mon TFE soit disponible sous les conditions suivantes :</h3>
<div class="licence-degree">
<h4>🔓 Libre</h4>
<p>Mon TFE est en libre accès à tout le monde sur la plateforme des TFE ainsi que dans la bibliothèque de l'erg. Je suis conscient·e des responsabilités et obligations légales qui viennent avec une diffusion externe et acquiesce avoir lu la documentation prévue à cet effet par l'erg, ainsi qu'avoir discuté des enjeux d'une publication avec l'équipe pédagogique. J'accepte de partager mes droits de diffusion avec l'erg, ce uniquement dans le cadre d'une diffusion sur la plateforme xamxam.</p>
<ul>
<li><label><input type="checkbox" name="cc4r" value="1"> J'accepte les conditions collectives de réutilisation (CC4r) <em class="hint">(pas obligatoire)</em></label></li>
<li><label><input type="checkbox" name="specific_license" value="1"> Je souhaite appliquer une licence spécifique à mon travail <em class="hint">(pas obligatoire)</em></label></li>
</ul>
<p class="licence-note"><em>Au moins une des deux cases doit être cochée pour le degré Libre.</em></p>
</div>
<div class="licence-degree">
<h4>🔒 Interne</h4>
<p>Mon TFE et ma note d'intention ne sont accessibles que sur place en physique ainsi que sur la plateforme xamxam par la communauté erg. Une note descriptive est disponible sur le site à toustes. J'autorise une (ré-)utilisation et diffusion dans un contexte académique et didactique au sein de l'erg.</p>
<p class="licence-note"><em>La diffusion limitée est protégée par le cadre académique/didactique, le travail pourrait donc être diffusé en interne et être cité par d'autres étudiant·es sans implications légales pour l'auteur·ice ni pour l'école.</em></p>
<ul>
<li><label><input type="checkbox" name="cc4r" value="1"> J'accepte les conditions collectives de réutilisation (CC4r) <em class="hint">(pas obligatoire)</em></label></li>
<li><label><input type="checkbox" name="specific_license" value="1"> Je souhaite appliquer une licence spécifique à mon travail <em class="hint">(pas obligatoire)</em></label></li>
</ul>
<p class="licence-note"><em>Au moins une des deux cases doit être cochée.</em></p>
</div>
<div class="licence-degree">
<h4>🚫 Interdit</h4>
<p>Mon TFE n'est pas disponible en physique ni sur le site. Une note descriptive est disponible sur le site.</p>
</div>
</div>
<div class="licence-generalites">
<h3>Généralités</h3>
<ul>
<li>L'auteur·ice peut décider entre trois degrés de partage de son travail : <strong>libre</strong>, <strong>interne</strong>, <strong>interdit</strong>.</li>
<li>L'auteur·ice peut, à tout moment, décider de <strong>restreindre</strong> le degré d'accès à son travail. Il ne peut néanmoins pas l'ouvrir davantage.</li>
<li>Le choix effectué dans ce formulaire sera d'application <strong>une semaine après la soutenance orale</strong> de l'auteur·ice. Celui-ci peut donc décider de restreindre ce choix avant sa publication (mais pas l'ouvrir).</li>
<li>L'erg se réserve le droit de restreindre le degré d'ouverture du TFE ce en accord avec le règlement.</li>
<li>Dans tous les cas, l'auteur·ice garde les droits d'auteurs, de diffusion, d'utilisation, etc. de son travail sauf si la licence choisie restreindrait ses droits.</li>
<li>La diffusion « xamxam » est indépendante de la diffusion à la BAIU.</li>
</ul>
</div>
</fieldset>
<div class="form-footer">
<button type="submit" name="go">Soumettre</button>
</div>
</form>
</main>
</body>
</html>
<?php
}
/**
* Handle share link form submission.
*/
function handleShareLinkSubmission(string $slug): void
{
session_start();
require_once APP_ROOT . '/src/ShareLink.php';
$shareLinkModel = new ShareLink(Database::getInstance());
$link = $shareLinkModel->findBySlug($slug);
if ($link === null || !$link['is_active'] || ($link['expires_at'] !== null && strtotime($link['expires_at']) < time())) {
http_response_code(403);
die('Ce lien n\'est plus valide.');
}
// Check password verification if link has a password
if ($link['password_hash'] !== null && empty($_SESSION['share_verified_' . $slug])) {
// Allow password to be submitted along with the form (first attempt or re-verify)
if (isset($_POST['share_password_submit'])) {
if ($shareLinkModel->verifyPassword($link, $_POST['share_password_submit'])) {
$_SESSION['share_verified_' . $slug] = true;
} else {
http_response_code(403);
die('Mot de passe incorrect.');
}
} else {
http_response_code(403);
die('Vous devez entrer le mot de passe avant de soumettre le formulaire.');
}
}
// Validate share-link CSRF token
$shareCsrfKey = 'share_csrf_' . $slug;
if (!isset($_POST['share_link_token'], $_SESSION[$shareCsrfKey])
|| !hash_equals($_SESSION[$shareCsrfKey], $_POST['share_link_token'])) {
error_log('Share link CSRF validation failed for ' . $slug);
http_response_code(403);
die('Token de sécurité invalide.');
}
require_once APP_ROOT . '/src/ThesisCreateController.php';
try {
$ctrl = ThesisCreateController::make();
$thesisId = $ctrl->submit($_POST, $_FILES);
// Mark the link as used
require_once APP_ROOT . '/src/ShareLink.php';
$shareLinkModel = new ShareLink(Database::getInstance());
$shareLinkModel->incrementUsage($link['id']);
// Clean up share-specific session data
unset($_SESSION[$shareCsrfKey]);
unset($_SESSION['share_verified_' . $slug]);
// Redirect to thanks page
header('Location: /partage/thanks.php?id=' . urlencode((string)$thesisId));
exit();
} catch (Exception $e) {
error_log('Share link submission error: ' . $e->getMessage());
$_SESSION['_flash_error'] = $e->getMessage();
$_SESSION['form_data_share_' . $slug] = $_POST;
$_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32)); // Regenerate token
// Redirect back to the form
header('Location: /partage/' . urlencode($slug));
exit();
}
}
/**
* Helper to retrieve old form data (with support for array keys via : delimiter)
*/
function old(array $data, string $key, string $default = ''): string {
// Support nested keys like "jury_lecteurs:0"
$parts = explode(':', $key);
$value = $data;
foreach ($parts as $part) {
if (is_array($value) && isset($value[$part])) {
$value = $value[$part];
} else {
$value = $default;
break;
}
}
return is_array($value) ? htmlspecialchars(json_encode($value)) : htmlspecialchars((string)$value);
}

87
public/partage/thanks.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
/**
* Thanks page for share-link submissions.
* Displays a centered confirmation with a link to create another thesis via the same link.
*/
require_once __DIR__ . '/../../config/bootstrap.php';
App::boot();
$thesisId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($thesisId <= 0) {
http_response_code(400);
die('ID de TFE invalide.');
}
// Verify the thesis exists
$db = Database::getInstance();
$thesis = $db->getThesis($thesisId);
if (!$thesis) {
http_response_code(404);
die('TFE introuvable.');
}
// Get the share link slug from the referer path
$pathParts = explode('/', trim($_SERVER['REQUEST_URI'] ?? '', '/'));
$slug = count($pathParts) >= 2 ? $pathParts[0] : null;
$pageTitle = 'Merci — TFE enregistré';
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<link rel="stylesheet" href="<?= App::assetV('/assets/css/system.css') ?>">
<style>
.thanks-center {
max-width: 600px;
margin: 4rem auto;
padding: 2rem;
text-align: center;
}
.thanks-center h1 {
margin-bottom: 0.5rem;
font-size: 2rem;
}
.thanks-center p {
font-size: 1.1rem;
color: #555;
margin: 1rem 0 2rem;
}
.thanks-center .thesis-title {
font-weight: bold;
font-size: 1.2rem;
color: #333;
margin-bottom: 2rem;
}
.thanks-center .btn-add-another {
display: inline-block;
padding: 0.75rem 2rem;
background: #333;
color: white;
text-decoration: none;
border-radius: 4px;
font-size: 1rem;
transition: background 0.2s;
}
.thanks-center .btn-add-another:hover {
background: #555;
}
</style>
</head>
<body>
<div class="thanks-center">
<h1>✅ Merci !</h1>
<p>Votre TFE a bien été enregistré sur la plateforme.</p>
<?php if ($thesis): ?>
<div class="thesis-title"><?= htmlspecialchars($thesis['title']) ?> — <?= htmlspecialchars($thesis['authors'] ?? '') ?></div>
<?php endif; ?>
<?php if ($slug): ?>
<a href="/partage/<?= urlencode($slug) ?>" class="btn-add-another">+ Soumettre un autre TFE</a>
<?php endif; ?>
</div>
</body>
</html>

197
src/ShareLink.php Normal file
View File

@@ -0,0 +1,197 @@
<?php
/**
* ShareLink — model for student-access share links.
*
* Share links enable students to submit TFEs via unique URLs without
* requiring admin authentication. Each link has a unique slug, optional
* password, activity flag, optional expiration, and usage count.
*/
class ShareLink
{
private Database $db;
public function __construct(Database $db)
{
$this->db = $db;
}
public static function make(): self
{
require_once APP_ROOT . '/src/Database.php';
return new self(new Database());
}
// ── Slug generation ───────────────────────────────────────────────────────
/**
* Generate a unique slug in the format YYYYMMDD-<random>.
* The random portion uses 8 base32 chars (~40 bits of entropy).
*/
public static function generateSlug(): string
{
$date = date('Ymd');
$random = substr(strtoupper(rtrim(base64_encode(random_bytes(7)), '=')), 0, 8);
return $date . '-' . $random;
}
// ── CRUD ──────────────────────────────────────────────────────────────────
/**
* Create a new share link.
*
* @param int $createdBy Admin user ID
* @param string|null $password Plain-text password (will be hashed), null = no password
* @param string|null $expiresAt ISO-8601 expiration date, null = never expires
* @return array The created link row
*/
public function create(int $createdBy, ?string $password = null, ?string $expiresAt = null): array
{
$slug = self::generateSlug();
$passwordHash = $password !== null ? password_hash($password, PASSWORD_BCRYPT) : null;
$stmt = $this->db->getConnection()->prepare(
"INSERT INTO share_links (slug, password_hash, is_active, created_by, expires_at)
VALUES (?, ?, 1, ?, ?)"
);
$stmt->execute([$slug, $passwordHash, $createdBy, $expiresAt]);
return $this->findBySlug($slug);
}
/**
* Find a share link by its slug.
*
* @return array|null
*/
public function findBySlug(string $slug): ?array
{
$stmt = $this->db->getConnection()->prepare(
"SELECT * FROM share_links WHERE slug = ?"
);
$stmt->execute([$slug]);
$row = $stmt->fetch();
return $row ?: null;
}
/**
* Find a share link by its ID.
*
* @return array|null
*/
public function findById(int $id): ?array
{
$stmt = $this->db->getConnection()->prepare(
"SELECT * FROM share_links WHERE id = ?"
);
$stmt->execute([$id]);
$row = $stmt->fetch();
return $row ?: null;
}
/**
* List all share links, ordered by creation date descending.
*
* @return array
*/
public function listAll(): array
{
$stmt = $this->db->getConnection()->query(
"SELECT * FROM share_links ORDER BY created_at DESC"
);
return $stmt->fetchAll();
}
/**
* Toggle the active status of a share link.
*/
public function toggleActive(int $id): void
{
$this->db->getConnection()->prepare(
"UPDATE share_links SET is_active = NOT is_active WHERE id = ?"
)->execute([$id]);
}
/**
* Set or clear the password for a share link.
*
* @param string|null $password Plain-text password, or null to clear
*/
public function setPassword(int $id, ?string $password): void
{
$hash = $password !== null ? password_hash($password, PASSWORD_BCRYPT) : null;
$this->db->getConnection()->prepare(
"UPDATE share_links SET password_hash = ? WHERE id = ?"
)->execute([$hash, $id]);
}
/**
* Delete a share link.
*/
public function delete(int $id): void
{
$this->db->getConnection()->prepare(
"DELETE FROM share_links WHERE id = ?"
)->execute([$id]);
}
/**
* Increment the usage count for a share link.
*/
public function incrementUsage(int $id): void
{
$this->db->getConnection()->prepare(
"UPDATE share_links SET usage_count = usage_count + 1 WHERE id = ?"
)->execute([$id]);
}
// ── Validation ────────────────────────────────────────────────────────────
/**
* Validate whether a share link is usable.
*
* Returns an array:
* ['valid' => true] if the link is active and not expired
* ['valid' => false, 'reason' => 'disabled'] if deactivated
* ['valid' => false, 'reason' => 'expired'] if past expiration
* ['valid' => false, 'reason' => 'not_found'] if slug doesn't exist
* ['valid' => false, 'reason' => 'needs_password', 'link' => array] if password required
*/
public function validateLink(?string $slug): array
{
if ($slug === null || $slug === '') {
return ['valid' => false, 'reason' => 'not_found'];
}
$link = $this->findBySlug($slug);
if ($link === null) {
return ['valid' => false, 'reason' => 'not_found'];
}
if (!$link['is_active']) {
return ['valid' => false, 'reason' => 'disabled', 'link' => $link];
}
if ($link['expires_at'] !== null && strtotime($link['expires_at']) < time()) {
return ['valid' => false, 'reason' => 'expired', 'link' => $link];
}
if ($link['password_hash'] !== null) {
return ['valid' => false, 'reason' => 'needs_password', 'link' => $link];
}
return ['valid' => true, 'link' => $link];
}
/**
* Verify the password against a share link.
*
* @return bool
*/
public function verifyPassword(array $link, string $password): bool
{
if ($link['password_hash'] === null) {
return true; // No password set
}
return password_verify($password, $link['password_hash']);
}
}

View File

@@ -1 +0,0 @@
2026-04-15T11:53:16+00:00

View File

@@ -0,0 +1,14 @@
-- Share links table: enables students to submit TFEs via unique, optional-password-protected URLs
CREATE TABLE IF NOT EXISTS share_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE, -- Format: YYYYMMDD-<random>, e.g. 20260416-a3f9k2
password_hash TEXT, -- bcrypt hash; NULL = no password required
is_active INTEGER NOT NULL DEFAULT 1, -- 1 = active, 0 = disabled
usage_count INTEGER NOT NULL DEFAULT 0, -- Number of successful submissions via this link
created_by INTEGER NOT NULL, -- admin user ID (references admin_sessions or admin_users)
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME -- NULL = never expires
);
CREATE INDEX IF NOT EXISTS idx_share_links_slug ON share_links(slug);
CREATE INDEX IF NOT EXISTS idx_share_links_active ON share_links(is_active);

View File

@@ -21,6 +21,7 @@ $_thesisId = $_GET['id'] ?? null;
<li><a href="/admin/pages.php" <?= in_array($_currentPage, ['pages.php', 'pages-edit.php']) ? 'aria-current="page"' : '' ?>>Pages statiques</a></li> <li><a href="/admin/pages.php" <?= in_array($_currentPage, ['pages.php', 'pages-edit.php']) ? 'aria-current="page"' : '' ?>>Pages statiques</a></li>
<li><a href="/admin/tags.php" <?= $_currentPage === 'tags.php' ? 'aria-current="page"' : '' ?>>Mots-clés</a></li> <li><a href="/admin/tags.php" <?= $_currentPage === 'tags.php' ? 'aria-current="page"' : '' ?>>Mots-clés</a></li>
<li><a href="/admin/system.php" <?= in_array($_currentPage, ['system.php', 'status.php', 'logs.php']) ? 'aria-current="page"' : '' ?>>Système</a></li> <li><a href="/admin/system.php" <?= in_array($_currentPage, ['system.php', 'status.php', 'logs.php']) ? 'aria-current="page"' : '' ?>>Système</a></li>
<li><a href="/admin/student-access.php" <?= $_currentPage === 'student-access.php' ? 'aria-current="page"' : '' ?>>Accès étudiant·e</a></li>
<li><a href="/admin/parametres.php" <?= $_currentPage === 'parametres.php' ? 'aria-current="page"' : '' ?>>Paramètres</a></li> <li><a href="/admin/parametres.php" <?= $_currentPage === 'parametres.php' ? 'aria-current="page"' : '' ?>>Paramètres</a></li>
<?php if ($_thesisId && in_array($_currentPage, ['edit.php', 'thanks.php'])): ?> <?php if ($_thesisId && in_array($_currentPage, ['edit.php', 'thanks.php'])): ?>
<li><a href="/admin/edit.php?id=<?= intval($_thesisId) ?>" <?= $_currentPage === 'edit.php' ? 'aria-current="page"' : '' ?>>Modifier</a></li> <li><a href="/admin/edit.php?id=<?= intval($_thesisId) ?>" <?= $_currentPage === 'edit.php' ? 'aria-current="page"' : '' ?>>Modifier</a></li>