diff --git a/TODO.md b/TODO.md index 3e5cb94..6b096a7 100644 --- a/TODO.md +++ b/TODO.md @@ -8,3 +8,26 @@ - [x] Organise all fields into `
/` 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 `
`) - [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/) + - [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_`) 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 diff --git a/public/admin/actions/formulaire.php b/public/admin/actions/formulaire.php index 5309a3e..9b77247 100644 --- a/public/admin/actions/formulaire.php +++ b/public/admin/actions/formulaire.php @@ -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) { diff --git a/public/admin/actions/student-access.php b/public/admin/actions/student-access.php new file mode 100644 index 0000000..a6095e4 --- /dev/null +++ b/public/admin/actions/student-access.php @@ -0,0 +1,69 @@ +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; +} diff --git a/public/admin/add.php b/public/admin/add.php index d8d9390..0004964 100644 --- a/public/admin/add.php +++ b/public/admin/add.php @@ -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) { ?>

Ajouter un TFE

- - Mode étudiant ↗ - - ← Retour admin -
"> - - -
@@ -165,55 +151,6 @@ if ($studentMode) { ?>
- - -
- Degrés d'ouverture et licences - -
-

Je veux que mon TFE soit disponible sous les conditions suivantes :

- -
-

🔓 Libre

-

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.

-
    -
  • -
  • -
-

Au moins une des deux cases doit être cochée pour le degré Libre.

-
- -
-

🔒 Interne

-

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.

-

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.

-
    -
  • -
  • -
-

Au moins une des deux cases doit être cochée.

-
- -
-

🚫 Interdit

-

Mon TFE n'est pas disponible en physique ni sur le site. Une note descriptive est disponible sur le site.

-
-
- -
-

Généralités

-
    -
  • L'auteur·ice peut décider entre trois degrés de partage de son travail : libre, interne, interdit.
  • -
  • L'auteur·ice peut, à tout moment, décider de restreindre le degré d'accès à son travail. Il ne peut néanmoins pas l'ouvrir davantage.
  • -
  • Le choix effectué dans ce formulaire sera d'application une semaine après la soutenance orale de l'auteur·ice. Celui-ci peut donc décider de restreindre ce choix avant sa publication (mais pas l'ouvrir).
  • -
  • L'erg se réserve le droit de restreindre le degré d'ouverture du TFE – ce en accord avec le règlement.
  • -
  • 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.
  • -
  • La diffusion « xamxam » est indépendante de la diffusion à la BAIU.
  • -
-
-
- - diff --git a/public/admin/student-access.php b/public/admin/student-access.php new file mode 100644 index 0000000..8196418 --- /dev/null +++ b/public/admin/student-access.php @@ -0,0 +1,244 @@ +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'; +?> + + + + + +
+ +
+ + +
+ + +
+

Accès étudiant·e

+ +
+ + +
+ +

Aucun lien d'accès créé.

+

Cliquez sur « Créer un lien » pour générer un lien partageable.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
LienStatutMot de passeUtilisationsExpirationCréé leActions
+ + + +
+ + + + + + + + +
+ + + + +
+
+
+ +
+ + + +
+

Créer un lien d'accès

+ +
+
+ + +
+ + +
+ +
+
+ + + +
+

Mot de passe

+ +
+
+ + + +
+ +

+
+ +
+
+ + + + diff --git a/public/partage/.htaccess b/public/partage/.htaccess new file mode 100644 index 0000000..28f4adb --- /dev/null +++ b/public/partage/.htaccess @@ -0,0 +1,28 @@ +# Route all partage requests to index.php + + RewriteEngine On + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^(.*)$ index.php [L] + + +# Security headers + + 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:;" + + +# Prevent directory listing +Options -Indexes + +# Protect dotfiles + + Require all denied + diff --git a/public/partage/index.php b/public/partage/index.php new file mode 100644 index 0000000..217a105 --- /dev/null +++ b/public/partage/index.php @@ -0,0 +1,500 @@ + — Render the share-link form (or password gate) + * /partage//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// +$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é'; + ?> + + + + + + <?= htmlspecialchars($pageTitle) ?> + + + + +
+

🔒 Accès protégé

+ +

+ +
+ + + +
+ +
+
+ + + 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]); + ?> + + + + + + <?= htmlspecialchars($pageTitle) ?> + + + + + +
+
+

Soumettre un TFE

+ + + +
+ + + + + + + + + +
+ + + +
+ Informations du TFE + + + + 'name']; include APP_ROOT . '/templates/partials/form/text-field.php'; ?> + 'email']; include APP_ROOT . '/templates/partials/form/text-field.php'; ?> + +
+ + Si cette case est cochée, votre contact apparaîtra sur la page publique de votre TFE. +
+ +
+ + +
+
+ + +
+ Composition du jury + + + 'jury_promoteur_ext', 'label' => 'Externe à l\'erg', 'checked' => !empty($formData['jury_promoteur_ext'])]; include APP_ROOT . '/templates/partials/form/text-field.php'; ?> + +
+

Lecteurs·rices (optionnel) :

+ +
+ "> + +
+ +
+
+ + +
+ Cadre académique + + 2000, 'max' => date('Y') + 1]; + include APP_ROOT . '/templates/partials/form/text-field.php'; + ?> + + + + + + + + + +
+ + +
+ Fichiers + + + + +
+ + +
+ Métadonnées complémentaires + + + + + + + + $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'; + ?> +
+ + +
+ Degrés d'ouverture et licences + +
+

Je veux que mon TFE soit disponible sous les conditions suivantes :

+ +
+

🔓 Libre

+

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.

+
    +
  • +
  • +
+

Au moins une des deux cases doit être cochée pour le degré Libre.

+
+ +
+

🔒 Interne

+

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.

+

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.

+
    +
  • +
  • +
+

Au moins une des deux cases doit être cochée.

+
+ +
+

🚫 Interdit

+

Mon TFE n'est pas disponible en physique ni sur le site. Une note descriptive est disponible sur le site.

+
+
+ +
+

Généralités

+
    +
  • L'auteur·ice peut décider entre trois degrés de partage de son travail : libre, interne, interdit.
  • +
  • L'auteur·ice peut, à tout moment, décider de restreindre le degré d'accès à son travail. Il ne peut néanmoins pas l'ouvrir davantage.
  • +
  • Le choix effectué dans ce formulaire sera d'application une semaine après la soutenance orale de l'auteur·ice. Celui-ci peut donc décider de restreindre ce choix avant sa publication (mais pas l'ouvrir).
  • +
  • L'erg se réserve le droit de restreindre le degré d'ouverture du TFE – ce en accord avec le règlement.
  • +
  • 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.
  • +
  • La diffusion « xamxam » est indépendante de la diffusion à la BAIU.
  • +
+
+
+ + +
+
+ + + 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); +} diff --git a/public/partage/thanks.php b/public/partage/thanks.php new file mode 100644 index 0000000..df14015 --- /dev/null +++ b/public/partage/thanks.php @@ -0,0 +1,87 @@ +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é'; +?> + + + + + + <?= htmlspecialchars($pageTitle) ?> + + + + +
+

✅ Merci !

+

Votre TFE a bien été enregistré sur la plateforme.

+ +
+ + + + Soumettre un autre TFE + +
+ + diff --git a/src/ShareLink.php b/src/ShareLink.php new file mode 100644 index 0000000..ad41abe --- /dev/null +++ b/src/ShareLink.php @@ -0,0 +1,197 @@ +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-. + * 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']); + } +} diff --git a/storage/maintenance.flag b/storage/maintenance.flag deleted file mode 100644 index 8eef93a..0000000 --- a/storage/maintenance.flag +++ /dev/null @@ -1 +0,0 @@ -2026-04-15T11:53:16+00:00 \ No newline at end of file diff --git a/storage/migrations/009_share_links.sql b/storage/migrations/009_share_links.sql new file mode 100644 index 0000000..74cbbc4 --- /dev/null +++ b/storage/migrations/009_share_links.sql @@ -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-, 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); diff --git a/templates/header.php b/templates/header.php index 1a738ca..2694ae9 100644 --- a/templates/header.php +++ b/templates/header.php @@ -21,6 +21,7 @@ $_thesisId = $_GET['id'] ?? null;
  • >Pages statiques
  • >Mots-clés
  • >Système
  • +
  • >Accès étudiant·e
  • >Paramètres
  • >Modifier