mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
Reintroduce TFE duration metadata: DB columns, form fields, controllers, views, and migration
Add 'unsafe-eval' to CSP script-src directives (htmx requires Function())
This commit is contained in:
@@ -3,9 +3,7 @@
|
||||
/**
|
||||
* Minimal PHP session guard for the admin panel.
|
||||
*
|
||||
* This is a defence-in-depth layer that sits behind nginx Basic Auth.
|
||||
* It protects against proxy misconfiguration, bypass, and local-dev
|
||||
* scenarios where the reverse proxy may be absent.
|
||||
* Password-only authentication via an HTML login form.
|
||||
*
|
||||
* The admin password hash is stored in the site_settings table
|
||||
* (key = 'admin_password_hash').
|
||||
@@ -17,6 +15,10 @@ class AdminAuth
|
||||
private const SESSION_KEY = 'admin_authenticated';
|
||||
private const LOGIN_URL = '/admin/login.php';
|
||||
|
||||
// Throttle: max 5 attempts before mandatory delay, cooldown 15 min.
|
||||
private const MAX_ATTEMPTS = 5;
|
||||
private const COOLDOWN_MINUTES = 15;
|
||||
|
||||
/**
|
||||
* Start the PHP session with hardened cookie parameters.
|
||||
* Idempotent — safe to call even if session is already active.
|
||||
@@ -61,10 +63,7 @@ class AdminAuth
|
||||
* Authentication order:
|
||||
* 1. No password hash configured → dev mode, pass through.
|
||||
* 2. Session already authenticated → pass through.
|
||||
* 3. nginx Basic Auth password present in $_SERVER['PHP_AUTH_PW']
|
||||
* → validate it with password_verify; on success create session
|
||||
* (seamless: user only sees the browser Basic Auth dialog).
|
||||
* 4. Neither → redirect to the PHP login form.
|
||||
* 3. Neither → redirect to the PHP login form.
|
||||
*/
|
||||
public static function requireLogin(): void
|
||||
{
|
||||
@@ -76,11 +75,6 @@ class AdminAuth
|
||||
if (!empty($_SESSION[self::SESSION_KEY])) {
|
||||
return; // Already authenticated via session.
|
||||
}
|
||||
// Try to auto-authenticate from the nginx Basic Auth credentials.
|
||||
if (isset($_SERVER['PHP_AUTH_PW']) && self::verifyHash($_SERVER['PHP_AUTH_PW'], $storedHash)) {
|
||||
$_SESSION[self::SESSION_KEY] = true;
|
||||
return;
|
||||
}
|
||||
header('Location: ' . self::LOGIN_URL);
|
||||
exit;
|
||||
}
|
||||
@@ -89,15 +83,54 @@ class AdminAuth
|
||||
* Validate a plaintext password against the stored hash.
|
||||
* On success: regenerates the session ID and marks the session authenticated.
|
||||
*
|
||||
* Throttling: after MAX_ATTEMPTS consecutive failures, a mandatory delay is
|
||||
* enforced (incremental: 1s, 2s, 4s, … up to 60s). Returns the same `false`
|
||||
* result as a wrong password so the attacker cannot distinguish the reason.
|
||||
*
|
||||
* @return bool true on success, false on wrong password / no hash stored.
|
||||
*/
|
||||
public static function login(string $password): bool
|
||||
{
|
||||
$storedHash = self::getStoredHash();
|
||||
if ($storedHash === null || !self::verifyHash($password, $storedHash)) {
|
||||
if ($storedHash === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self::startSession();
|
||||
$alreadyAuthed = !empty($_SESSION[self::SESSION_KEY]);
|
||||
|
||||
// ── Throttle: only on unauthenticated login attempts ────────────────
|
||||
if (!$alreadyAuthed) {
|
||||
$attempts = (int) ($_SESSION['auth_attempts'] ?? 0);
|
||||
$firstAt = (int) ($_SESSION['auth_first_attempt'] ?? 0);
|
||||
$now = time();
|
||||
|
||||
// Cooldown window — reset after COOLDOWN_MINUTES
|
||||
if ($attempts > 0 && ($now - $firstAt) > self::COOLDOWN_MINUTES * 60) {
|
||||
$attempts = 0;
|
||||
$firstAt = 0;
|
||||
unset($_SESSION['auth_attempts'], $_SESSION['auth_first_attempt']);
|
||||
}
|
||||
|
||||
if ($attempts >= self::MAX_ATTEMPTS) {
|
||||
$extra = $attempts - self::MAX_ATTEMPTS;
|
||||
$delay = min(1 << min($extra, 6), 60); // 1s → 2s → 4s … → 60s cap
|
||||
sleep($delay);
|
||||
}
|
||||
}
|
||||
|
||||
if (!self::verifyHash($password, $storedHash)) {
|
||||
if (!$alreadyAuthed) {
|
||||
if ($attempts === 0) {
|
||||
$_SESSION['auth_first_attempt'] = $now;
|
||||
}
|
||||
$_SESSION['auth_attempts'] = $attempts + 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Success: clear throttling, create/refresh session ──────────────
|
||||
unset($_SESSION['auth_attempts'], $_SESSION['auth_first_attempt']);
|
||||
session_regenerate_id(true);
|
||||
$_SESSION[self::SESSION_KEY] = true;
|
||||
$_SESSION['admin_login_at'] = time();
|
||||
@@ -145,12 +178,6 @@ class AdminAuth
|
||||
if (!empty($_SESSION[self::SESSION_KEY])) {
|
||||
return true;
|
||||
}
|
||||
// Also accept nginx Basic Auth credentials directly (e.g. HTMX fragment
|
||||
// requests that arrive before a PHP session has been established).
|
||||
if (isset($_SERVER['PHP_AUTH_PW']) && self::verifyHash($_SERVER['PHP_AUTH_PW'], $storedHash)) {
|
||||
$_SESSION[self::SESSION_KEY] = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
require_once APP_ROOT . '/src/EmailObfuscator.php';
|
||||
require_once APP_ROOT . '/src/MarkdownHelper.php';
|
||||
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
|
||||
@@ -29,9 +30,12 @@ class CharteController
|
||||
$converter = new CommonMarkConverter(['html_input' => 'strip']);
|
||||
$html = EmailObfuscator::obfuscateHtml($converter->convert($content)->getContent());
|
||||
|
||||
$tocItems = MarkdownHelper::extractToc($content);
|
||||
|
||||
return [
|
||||
'content' => $content,
|
||||
'html' => $html,
|
||||
'tocItems' => $tocItems,
|
||||
'pageTitle' => $pageTitle . ' – XAMXAM',
|
||||
'metaDescription' => "Charte d'utilisation de XAMXAM, le répertoire des TFE de l'erg.",
|
||||
'currentNav' => 'charte',
|
||||
@@ -39,4 +43,5 @@ class CharteController
|
||||
'bodyClass' => 'apropos-body',
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
require_once APP_ROOT . '/src/EmailObfuscator.php';
|
||||
require_once APP_ROOT . '/src/MarkdownHelper.php';
|
||||
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
|
||||
@@ -29,9 +30,12 @@ class LicenceController
|
||||
$converter = new CommonMarkConverter(['html_input' => 'strip']);
|
||||
$html = EmailObfuscator::obfuscateHtml($converter->convert($content)->getContent());
|
||||
|
||||
$tocItems = MarkdownHelper::extractToc($content);
|
||||
|
||||
return [
|
||||
'content' => $content,
|
||||
'html' => $html,
|
||||
'tocItems' => $tocItems,
|
||||
'pageTitle' => $pageTitle . ' – XAMXAM',
|
||||
'metaDescription' => "Informations sur les licences d'utilisation des mémoires publiés sur XAMXAM, le répertoire des TFE de l'erg.",
|
||||
'currentNav' => 'licence',
|
||||
@@ -39,4 +43,5 @@ class LicenceController
|
||||
'bodyClass' => 'apropos-body',
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -162,6 +162,8 @@ class ThesisCreateController
|
||||
'exemplaire_baiu' => $data['exemplaireBaiu'],
|
||||
'exemplaire_erg' => $data['exemplaireErg'],
|
||||
'cc2r' => $data['cc2r'],
|
||||
'duration_value' => $data['durationValue'],
|
||||
'duration_unit' => $data['durationUnit'],
|
||||
]);
|
||||
|
||||
$identifier = $this->db->getThesisIdentifier($thesisId);
|
||||
@@ -548,6 +550,22 @@ class ThesisCreateController
|
||||
$exemplaireErg = !empty($post['exemplaire_erg']);
|
||||
$cc2r = !empty($post['cc2r']);
|
||||
|
||||
// Duration: numeric value + unit (optional, admin-validated)
|
||||
$validDurationUnits = ['pages', 'minutes', 'sec', 'heures', 'mo'];
|
||||
$durationValue = $post['duration_value'] ?? null;
|
||||
$durationUnit = $post['duration_unit'] ?? 'pages';
|
||||
if ($durationValue !== null && $durationValue !== '') {
|
||||
$durationValue = filter_var($durationValue, FILTER_VALIDATE_FLOAT);
|
||||
if ($durationValue === false || $durationValue <= 0) {
|
||||
$durationValue = null; // ignore invalid
|
||||
}
|
||||
} else {
|
||||
$durationValue = null;
|
||||
}
|
||||
if (!in_array($durationUnit, $validDurationUnits, true)) {
|
||||
$durationUnit = 'pages';
|
||||
}
|
||||
|
||||
// Annexes are optional — no validation required
|
||||
$hasAnnexes = !empty($post['has_annexes']);
|
||||
|
||||
@@ -577,7 +595,9 @@ class ThesisCreateController
|
||||
'juryPoints',
|
||||
'exemplaireBaiu',
|
||||
'exemplaireErg',
|
||||
'cc2r'
|
||||
'cc2r',
|
||||
'durationValue',
|
||||
'durationUnit'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,8 @@ class ThesisEditController
|
||||
$currentAccessTypeId = $rawRow['access_type_id'] ?? null;
|
||||
$currentContextNote = $rawRow['context_note'] ?? '';
|
||||
$currentContactVisible = $rawRow['contact_visible'] ?? '';
|
||||
$currentDurationValue = $rawRow['duration_value'] ?? null;
|
||||
$currentDurationUnit = $rawRow['duration_unit'] ?? 'pages';
|
||||
|
||||
// Author contact info (from view)
|
||||
$contactInterne = $thesis['contact_interne'] ?? '';
|
||||
@@ -132,6 +134,8 @@ class ThesisEditController
|
||||
'currentAccessTypeId' => $currentAccessTypeId,
|
||||
'currentContextNote' => $currentContextNote,
|
||||
'currentContactVisible' => $currentContactVisible,
|
||||
'currentDurationValue' => $currentDurationValue,
|
||||
'currentDurationUnit' => $currentDurationUnit,
|
||||
'contactInterne' => $contactInterne,
|
||||
'contactPublic' => $contactPublic,
|
||||
'currentRaw' => $rawRow,
|
||||
@@ -219,6 +223,8 @@ class ThesisEditController
|
||||
'exemplaire_erg' => !empty($post['exemplaire_erg']),
|
||||
'cc2r' => !empty($post['cc2r']),
|
||||
'license_custom' => trim($post['license_custom'] ?? ''),
|
||||
'duration_value' => isset($post['duration_value']) && $post['duration_value'] !== '' ? (float)$post['duration_value'] : null,
|
||||
'duration_unit' => !empty($post['duration_unit']) ? $post['duration_unit'] : 'pages',
|
||||
];
|
||||
// Regenerate identifier if year changed or if identifier prefix doesn't match year
|
||||
$oldThesis = $this->db->getThesis($thesisId);
|
||||
|
||||
@@ -41,18 +41,7 @@ class Database
|
||||
*/
|
||||
private function runMigrations(): void
|
||||
{
|
||||
// Add 'name' column to share_links if missing
|
||||
try {
|
||||
$this->pdo->exec('ALTER TABLE share_links ADD COLUMN name TEXT');
|
||||
} catch (\PDOException $e) {
|
||||
// Column already exists — ignore
|
||||
}
|
||||
// Add 'locked_year' column to share_links if missing
|
||||
try {
|
||||
$this->pdo->exec('ALTER TABLE share_links ADD COLUMN locked_year INTEGER');
|
||||
} catch (\PDOException $e) {
|
||||
// Column already exists — ignore
|
||||
}
|
||||
(new DatabaseMigrations($this->pdo))->run();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2037,7 +2026,7 @@ class Database
|
||||
public function getThesisRawFields(int $thesisId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT license_id, license_custom, access_type_id, context_note, contact_visible, remarks, jury_points, exemplaire_baiu, exemplaire_erg, cc2r, is_published FROM theses WHERE id = ? LIMIT 1'
|
||||
'SELECT license_id, license_custom, access_type_id, context_note, contact_visible, remarks, jury_points, exemplaire_baiu, exemplaire_erg, cc2r, duration_value, duration_unit, is_published FROM theses WHERE id = ? LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$thesisId]);
|
||||
$row = $stmt->fetch();
|
||||
@@ -2181,6 +2170,8 @@ class Database
|
||||
exemplaire_baiu = ?,
|
||||
exemplaire_erg = ?,
|
||||
cc2r = ?,
|
||||
duration_value = ?,
|
||||
duration_unit = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
");
|
||||
@@ -2212,6 +2203,8 @@ class Database
|
||||
!empty($data['exemplaire_baiu']) ? 1 : 0,
|
||||
!empty($data['exemplaire_erg']) ? 1 : 0,
|
||||
!empty($data['cc2r']) ? 1 : 0,
|
||||
isset($data['duration_value']) && $data['duration_value'] !== '' ? (float)$data['duration_value'] : null,
|
||||
!empty($data['duration_unit']) ? $data['duration_unit'] : 'pages',
|
||||
$thesisId,
|
||||
]);
|
||||
$stmt->execute($params);
|
||||
@@ -2262,8 +2255,9 @@ class Database
|
||||
remarks, jury_points,
|
||||
exemplaire_baiu, exemplaire_erg,
|
||||
cc2r,
|
||||
duration_value, duration_unit,
|
||||
submitted_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, "draft", ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, "draft", ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
');
|
||||
|
||||
$validObjet = ['tfe', 'thèse', 'frart'];
|
||||
@@ -2296,6 +2290,8 @@ class Database
|
||||
!empty($data['exemplaire_baiu']) ? 1 : 0,
|
||||
!empty($data['exemplaire_erg']) ? 1 : 0,
|
||||
!empty($data['cc2r']) ? 1 : 0,
|
||||
isset($data['duration_value']) && $data['duration_value'] !== '' ? (float)$data['duration_value'] : null,
|
||||
!empty($data['duration_unit']) ? $data['duration_unit'] : 'pages',
|
||||
]);
|
||||
|
||||
$newId = (int)$this->pdo->lastInsertId();
|
||||
|
||||
92
app/src/DatabaseMigrations.php
Normal file
92
app/src/DatabaseMigrations.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* One-off database migrations.
|
||||
*
|
||||
* Applied once on every Database constructor call, via Database::runMigrations().
|
||||
* Each migration is idempotent — safe to run multiple times.
|
||||
*/
|
||||
|
||||
class DatabaseMigrations
|
||||
{
|
||||
private PDO $pdo;
|
||||
|
||||
public function __construct(PDO $pdo)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all pending migrations.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$this->migrateRenameFinalityTypes();
|
||||
$this->migrateShareLinksNameColumn();
|
||||
$this->migrateShareLinksLockedYearColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* 2026-06-11 — Rename finality types + relink theses.
|
||||
*
|
||||
* Spécialisé → Spécialisée
|
||||
* Approfondi → Approfondie
|
||||
* Enseignement → Didactique
|
||||
*/
|
||||
private function migrateRenameFinalityTypes(): void
|
||||
{
|
||||
try {
|
||||
$renames = [
|
||||
'Spécialisé' => 'Spécialisée',
|
||||
'Approfondi' => 'Approfondie',
|
||||
'Enseignement' => 'Didactique',
|
||||
];
|
||||
foreach ($renames as $old => $new) {
|
||||
// Skip if only canonical row already exists
|
||||
$oldCount = $this->pdo->query("SELECT COUNT(*) FROM finality_types WHERE name = '$old'")->fetchColumn();
|
||||
if ($oldCount == 0) {
|
||||
continue;
|
||||
}
|
||||
// Relink theses from old row to canonical row (create if needed)
|
||||
$canonical = $this->pdo->query("SELECT id FROM finality_types WHERE name = '$new'")->fetchColumn();
|
||||
if (!$canonical) {
|
||||
$this->pdo->exec("INSERT INTO finality_types (name) VALUES ('$new')");
|
||||
$canonical = $this->pdo->lastInsertId();
|
||||
}
|
||||
// Relink
|
||||
$this->pdo->exec("
|
||||
UPDATE theses SET finality_id = $canonical
|
||||
WHERE finality_id IN (SELECT id FROM finality_types WHERE name = '$old')
|
||||
");
|
||||
// Delete old row
|
||||
$this->pdo->exec("DELETE FROM finality_types WHERE name = '$old'");
|
||||
}
|
||||
} catch (\PDOException $e) {
|
||||
// Table may not exist yet on fresh install — ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add 'name' column to share_links if missing.
|
||||
*/
|
||||
private function migrateShareLinksNameColumn(): void
|
||||
{
|
||||
try {
|
||||
$this->pdo->exec('ALTER TABLE share_links ADD COLUMN name TEXT');
|
||||
} catch (\PDOException $e) {
|
||||
// Column already exists — ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add 'locked_year' column to share_links if missing.
|
||||
*/
|
||||
private function migrateShareLinksLockedYearColumn(): void
|
||||
{
|
||||
try {
|
||||
$this->pdo->exec('ALTER TABLE share_links ADD COLUMN locked_year INTEGER');
|
||||
} catch (\PDOException $e) {
|
||||
// Column already exists — ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* called from both the admin panel and the student partage form.
|
||||
*
|
||||
* Auth is checked by the caller before invoking these methods:
|
||||
* - Admin endpoints: nginx auth_basic + AdminAuth::requireLogin()
|
||||
* - Admin endpoints: AdminAuth::requireLogin()
|
||||
* - Partagé endpoints: session_start() + verify share_active + CSRF
|
||||
*
|
||||
* All paths in this file assume the session is already started and CSRF is
|
||||
|
||||
@@ -32,6 +32,7 @@ class FormBootstrap
|
||||
'/assets/js/app/beforeunload-guard.js',
|
||||
'/assets/js/app/pill-search.js',
|
||||
'/assets/js/app/jury-autocomplete.js',
|
||||
'/assets/js/app/autosave-handler.js',
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -117,13 +118,47 @@ class FormBootstrap
|
||||
$generalitiesHtml = $helpFn('fieldset_generalites');
|
||||
$defaultAccessTypeId = $options['defaultAccessTypeId'] ?? 2;
|
||||
|
||||
// ── Autosave draft wiring (add / edit only) ─────────────────────┐
|
||||
$autosaveUrl = '/admin/actions/draft.php';
|
||||
$formExtraAttrs = '';
|
||||
$showAutosaveStatus = false;
|
||||
$extraHidden = '';
|
||||
if ($mode === 'add') {
|
||||
// Reuse draft token from session so drafts survive page reloads
|
||||
if (empty($_SESSION['admin_draft_add_token'])) {
|
||||
$_SESSION['admin_draft_add_token'] = bin2hex(random_bytes(8));
|
||||
}
|
||||
$draftToken = $_SESSION['admin_draft_add_token'];
|
||||
$draftKey = 'admin_draft_' . $draftToken;
|
||||
$extraHidden = '<input type="hidden" name="draft_token" value="' . $draftToken . '">';
|
||||
// Hydrate from any previous session (survives accidental navigations)
|
||||
$draft = $_SESSION[$draftKey] ?? [];
|
||||
$formData = array_merge($draft, $formData);
|
||||
$showAutosaveStatus = true;
|
||||
} elseif ($mode === 'edit') {
|
||||
$thesisId = (int)($options['thesisId'] ?? 0);
|
||||
if ($thesisId > 0) {
|
||||
$draftKey = 'admin_draft_edit_' . $thesisId;
|
||||
$extraHidden = '<input type="hidden" name="thesis_id" value="' . $thesisId . '">';
|
||||
$draft = $_SESSION[$draftKey] ?? [];
|
||||
$formData = array_merge($draft, $formData);
|
||||
$showAutosaveStatus = true;
|
||||
}
|
||||
}
|
||||
if ($showAutosaveStatus) {
|
||||
$formExtraAttrs = 'hx-post="' . htmlspecialchars($autosaveUrl) . '"';
|
||||
}
|
||||
|
||||
return array_merge([
|
||||
// Base
|
||||
'mode' => $mode,
|
||||
'formAction' => $formAction,
|
||||
'hiddenFields' => $hiddenFields,
|
||||
'hiddenFields' => $hiddenFields . $extraHidden,
|
||||
'errorFieldName' => $autofocusField,
|
||||
'synopsisExtra' => $options['synopsisExtra'] ?? '',
|
||||
'formExtraAttrs' => $formExtraAttrs,
|
||||
'showAutosaveStatus' => $showAutosaveStatus,
|
||||
'autosaveUrl' => $autosaveUrl,
|
||||
|
||||
// Helpers
|
||||
'helpFn' => $helpFn,
|
||||
@@ -174,6 +209,8 @@ class FormBootstrap
|
||||
'contactPublic' => false,
|
||||
'currentContextNote' => null,
|
||||
'currentContactVisible' => null,
|
||||
'currentDurationValue' => null,
|
||||
'currentDurationUnit' => 'pages',
|
||||
|
||||
// Files (edit mode)
|
||||
'currentCover' => null,
|
||||
|
||||
34
app/src/MarkdownHelper.php
Normal file
34
app/src/MarkdownHelper.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Shared markdown utilities.
|
||||
*/
|
||||
class MarkdownHelper
|
||||
{
|
||||
/**
|
||||
* Extract h1–h3 headings from raw markdown content as TOC items.
|
||||
*
|
||||
* Each heading gets an anchor id matching CommonMark's default slugification
|
||||
* (lowercase, spaces → hyphens, punctuation stripped).
|
||||
*
|
||||
* @return array<int, array{label: string, href: string}>
|
||||
*/
|
||||
public static function extractToc(string $content): array
|
||||
{
|
||||
$items = [];
|
||||
$lines = explode("\n", $content);
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^#{1,3}\s+(.+)$/', $line, $m)) {
|
||||
$label = trim($m[1]);
|
||||
// Replicate CommonMark's default heading ID generation:
|
||||
// lowercase, strip non-word chars (except hyphens/spaces), spaces→hyphens
|
||||
$id = strtolower($label);
|
||||
$id = preg_replace('/[^\w\s-]/u', '', $id);
|
||||
$id = preg_replace('/\s+/', '-', $id);
|
||||
$id = trim($id, '-');
|
||||
$items[] = ['label' => $label, 'href' => '#' . $id];
|
||||
}
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
}
|
||||
@@ -228,6 +228,16 @@ class ShareLink
|
||||
)->execute([$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete an archived share link.
|
||||
*/
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->db->getConnection()->prepare(
|
||||
'DELETE FROM share_links WHERE id = ? AND is_archived = 1'
|
||||
)->execute([$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the usage count for a share link.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user