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

View File

@@ -9,8 +9,6 @@ ini_set('error_log', 'error.log');
AdminAuth::requireLogin();
$studentMode = isset($_POST['student_mode']) && $_POST['student_mode'] === '1';
// Verify CSRF token
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
@@ -28,10 +26,6 @@ try {
unset($_SESSION['csrf_token']);
$redirect = '../thanks.php?id=' . urlencode($thesisId);
if ($studentMode) {
$redirect .= '&mode=student';
}
header('Location: ' . $redirect);
exit();
@@ -42,9 +36,6 @@ try {
$_SESSION['form_data'] = $_POST;
$redirect = '../add.php';
if ($studentMode) {
$redirect .= '?mode=student';
}
$autofocusField = ThesisCreateController::autofocusFieldForError($e->getMessage());
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";
$studentMode = isset($_GET['mode']) && $_GET['mode'] === 'student';
require_once __DIR__ . '/../../src/ThesisCreateController.php';
@@ -48,33 +47,20 @@ function wasSelected($key, $value) {
?>
<?php
$isAdmin = true;
if ($studentMode) {
$bodyClass = 'admin-body student-body';
require_once APP_ROOT . '/templates/head.php';
} else {
$bodyClass = 'admin-body';
require_once APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
}
$bodyClass = 'admin-body';
require_once APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
?>
<main id="main-content">
<div class="thesis-add-header">
<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>
<?php include APP_ROOT . '/templates/partials/flash-messages.php'; ?>
<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"]) ?>">
<?php if ($studentMode): ?>
<input type="hidden" name="student_mode" value="1">
<?php endif; ?>
<!-- ═══════════════════ Informations du TFE ═══════════════════ -->
<fieldset>
@@ -165,55 +151,6 @@ if ($studentMode) {
?>
</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">
<button type="submit" name="go">Soumettre</button>
</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>