mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 08:09:18 +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:
File diff suppressed because one or more lines are too long
10
README.md
10
README.md
@@ -65,17 +65,9 @@ just deploy
|
||||
just deploy-nginx
|
||||
```
|
||||
|
||||
### Admin users (htpasswd)
|
||||
|
||||
```bash
|
||||
just manage-admin-users
|
||||
# Then on server:
|
||||
ssh xamxam "sudo bash /tmp/manage-admin-users.sh"
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- Admin panel protected by nginx `auth_basic` + PHP session (`AdminAuth`)
|
||||
- Admin panel protected by PHP session (`AdminAuth`) — password-only, no username
|
||||
- Uploads stored outside webroot, served via controlled `media.php`
|
||||
- Rate limiting on public search (`src/RateLimit.php`)
|
||||
- See `nginx/docs/SECURITY_HEADERS.md` for security headers reference
|
||||
|
||||
14
TODO.md
14
TODO.md
@@ -1,15 +1,26 @@
|
||||
# TODO
|
||||
|
||||
> Last updated: 2026-06-11
|
||||
> Context: Form Accessibility & Resilience improvements for XAMXAM thesis submission platform
|
||||
> Context: Removed overtype autosave (403 CSRF bug), replaced with explicit Save button above full-page editor
|
||||
|
||||
## Pending
|
||||
|
||||
- [ ] #apropos-toc-confirm Visually confirm charte + licence TOC layout renders correctly in browser
|
||||
- [ ] #aria-test-manual Test WCAG changes with VoiceOver and NVDA on full add/edit/partage form flows
|
||||
- [ ] #nojs-upload-test Test end-to-end: submit partage form with JS disabled, verify files arrive via `$_FILES`
|
||||
- [ ] #csp-media-iframe-deploy Deploy nginx config fix to server, test PDF iframe on /tfe?id=221
|
||||
|
||||
## Completed
|
||||
|
||||
- [x] #csp-media-iframe-fix Fix CSP `frame-ancestors 'none'` blocking PDF iframes — replaced `try_files` redirect with direct `fastcgi_pass` in `location = /media` so `add_header` CSP override survives internal nginx redirect `(nginx/xamxam.conf)` ✓
|
||||
|
||||
- [x] #duration-migration Add migration to reintroduce `duration_value` and `duration_unit` columns + update views `(migrations/applied/040_duration_fields.sql)` ✓
|
||||
- [x] #duration-database Update `createThesis`, `updateThesis`, `getThesisRawFields` in Database `(Database.php)` ✓
|
||||
- [x] #duration-controllers Handle duration in `ThesisCreateController` and `ThesisEditController` `(ThesisCreateController.php, ThesisEditController.php)` ✓
|
||||
- [x] #duration-form Add duration fieldset (value + unit dropdown) to form template `(templates/partials/form/form.php)` ✓
|
||||
- [x] #duration-display Show duration on public TFE detail page `(templates/public/tfe.php)` ✓
|
||||
- [x] #duration-view Include duration in v_theses_full and v_theses_public `(migrations/applied/040_duration_fields.sql, schema.sql, schema.sql.new)` ✓
|
||||
- [x] #duration-bootstrapWire Wire duration variables through FormBootstrap adminFormVariables `(FormBootstrap.php)` ✓
|
||||
- [x] #cleanup-drafts Add periodic cleanup job for orphaned drafts (`Database.php`, `justfile`, `deploy/xamxam-cleanup.cron`, `scripts/cleanup-drafts.php`) ✓
|
||||
- [x] #form-setup-helper Add `FormBootstrap` helper class to reduce bootstrap duplication across add/edit/partage `(admin/add.php)` `(admin/edit.php)` ✓
|
||||
- [x] #two-phase-commit Add two-phase commit: INSERT thesis `status='draft'`, COMMIT, move files, UPDATE to `active` `(ThesisCreateController.php)` ✓
|
||||
@@ -20,6 +31,7 @@
|
||||
- [x] #aria-errormessage WCAG AA: field-level `aria-errormessage`, `aria-invalid`, `aria-describedby` on all form fields ✓
|
||||
- [x] #nojs-upload-fix No-JS file uploads: `filepond_mode` default to `0 disabled`, server-side `$_FILES` fallback ✓
|
||||
- [x] #autosave-partage Autosave text fields on partage form: session draft endpoint (`fragments/draft.php`), HTMX autosave on change/input, page-load hydration, "Brouillon enregistré" indicator, draft cleared on submit ✓
|
||||
- [x] #autosave-partage-wire Wire `formExtraAttrs`, `showAutosaveStatus`, draft hydration, `autosave-handler.js`, draft cleanup into partage form (`partage/index.php`, `partage/form-page.php`) ✓
|
||||
- [x] #mobile-responsive Mobile-responsive form layout: `@media (max-width: 600px)` breakpoint, 44×44px touch targets ✓
|
||||
- [x] #aria-fieldset-fix Remove invalid `required` attribute from `<fieldset>`, keep `aria-required="true"`, add `role="group"` ✓
|
||||
- [x] #split-form-css Split `form.css` into `form-base.css` and `form-admin.css` ✓
|
||||
|
||||
80
app/migrations/applied/040_duration_fields.sql
Normal file
80
app/migrations/applied/040_duration_fields.sql
Normal file
@@ -0,0 +1,80 @@
|
||||
-- 040_duration_fields.sql
|
||||
-- Reintroduce duration metadata (value + unit) for TFE records.
|
||||
-- These columns were removed in 024_remove_duration_size_fields.sql but are now
|
||||
-- re-added with a cleaner design: a numeric value and a unit selector.
|
||||
|
||||
-- Add new columns to theses table
|
||||
ALTER TABLE theses ADD COLUMN duration_value REAL;
|
||||
ALTER TABLE theses ADD COLUMN duration_unit TEXT DEFAULT 'pages';
|
||||
|
||||
-- Drop and recreate views to include the new columns
|
||||
DROP VIEW IF EXISTS v_theses_full;
|
||||
DROP VIEW IF EXISTS v_theses_public;
|
||||
|
||||
CREATE VIEW IF NOT EXISTS v_theses_full AS
|
||||
SELECT
|
||||
t.id,
|
||||
t.identifier,
|
||||
t.title,
|
||||
t.subtitle,
|
||||
t.year,
|
||||
t.is_doctoral,
|
||||
t.objet,
|
||||
o.name as orientation,
|
||||
ap.name as ap_program,
|
||||
ft.name as finality_type,
|
||||
t.synopsis,
|
||||
t.context_note,
|
||||
t.duration_value,
|
||||
t.duration_unit,
|
||||
at.name as access_type,
|
||||
lt.name as license_type,
|
||||
t.license_id,
|
||||
t.license_custom,
|
||||
t.access_type_id,
|
||||
t.jury_points,
|
||||
t.submitted_at,
|
||||
t.defense_date,
|
||||
t.published_at,
|
||||
t.is_published,
|
||||
t.baiu_link,
|
||||
t.exemplaire_baiu,
|
||||
t.exemplaire_erg,
|
||||
t.cc2r,
|
||||
t.remarks,
|
||||
t.jury_note_added,
|
||||
t.contact_visible,
|
||||
GROUP_CONCAT(DISTINCT a.name ORDER BY a.name ASC) as authors,
|
||||
GROUP_CONCAT(DISTINCT s.name) as supervisors,
|
||||
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'president' THEN s.name END) as jury_president,
|
||||
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' AND ts.is_ulb = 0 THEN s.name END) as jury_promoteurs,
|
||||
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' AND ts.is_ulb = 1 THEN s.name END) as jury_promoteurs_ulb,
|
||||
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 0 THEN s.name END) as jury_lecteurs_internes,
|
||||
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 1 THEN s.name END) as jury_lecteurs_externes,
|
||||
GROUP_CONCAT(DISTINCT l.name) as languages,
|
||||
GROUP_CONCAT(DISTINCT fmt.name) as formats,
|
||||
GROUP_CONCAT(DISTINCT tg.name) as keywords,
|
||||
-- First author's email and contact-visibility flag
|
||||
(SELECT a2.email FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as author_email,
|
||||
(SELECT a2.show_contact FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as author_show_contact
|
||||
FROM theses t
|
||||
LEFT JOIN orientations o ON t.orientation_id = o.id
|
||||
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
|
||||
LEFT JOIN finality_types ft ON t.finality_id = ft.id
|
||||
LEFT JOIN access_types at ON t.access_type_id = at.id
|
||||
LEFT JOIN license_types lt ON t.license_id = lt.id
|
||||
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
|
||||
LEFT JOIN authors a ON ta.author_id = a.id
|
||||
LEFT JOIN thesis_supervisors ts ON t.id = ts.thesis_id
|
||||
LEFT JOIN supervisors s ON ts.supervisor_id = s.id
|
||||
LEFT JOIN thesis_languages tl ON t.id = tl.thesis_id
|
||||
LEFT JOIN languages l ON tl.language_id = l.id
|
||||
LEFT JOIN thesis_formats tf ON t.id = tf.thesis_id
|
||||
LEFT JOIN format_types fmt ON tf.format_id = fmt.id
|
||||
LEFT JOIN thesis_tags tt ON t.id = tt.thesis_id
|
||||
LEFT JOIN tags tg ON tt.tag_id = tg.id
|
||||
GROUP BY t.id;
|
||||
|
||||
CREATE VIEW IF NOT EXISTS v_theses_public AS
|
||||
SELECT * FROM v_theses_full
|
||||
WHERE is_published = 1;
|
||||
@@ -7,8 +7,6 @@ require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
require_once __DIR__ . '/../../../src/ShareLink.php';
|
||||
require_once __DIR__ . '/../../../src/AdminLogger.php';
|
||||
|
||||
error_log('[acces-etudiante.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
|
||||
|
||||
App::adminGuard();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|
||||
@@ -90,6 +88,15 @@ switch ($action) {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
if ($id > 0) {
|
||||
$shareLink->delete($id);
|
||||
App::redirect('/admin/acces.php', success: 'Lien supprimé définitivement.');
|
||||
} else {
|
||||
App::redirect('/admin/acces.php', error: 'Lien introuvable.');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
if ($id > 0) {
|
||||
$name = isset($_POST['name']) ? trim($_POST['name']) : null;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
error_log('[access-request.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | request_id=' . ($_POST['request_id'] ?? 0) . ' | post_keys=' . implode(',', array_keys($_POST)));
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||
|
||||
@@ -11,8 +11,6 @@ require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
require_once __DIR__ . '/../../../src/Database.php';
|
||||
|
||||
error_log('[account.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'change_password') . ' | post_keys=' . implode(',', array_keys($_POST)));
|
||||
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
// ── CSRF ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
*/
|
||||
require_once __DIR__ . "/../../../bootstrap.php";
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
error_log('[apropos.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | key=' . ($_POST['apropos_key'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
$isAjax = (!empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json'))
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
error_log('[corbeille.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none'));
|
||||
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
error_log('[delete.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | is_bulk=' . (!empty($_POST['bulk']) ? '1' : '0') . ' | post_keys=' . implode(',', array_keys($_POST)));
|
||||
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
require_once __DIR__ . '/../../../src/Database.php';
|
||||
|
||||
93
app/public/admin/actions/draft.php
Normal file
93
app/public/admin/actions/draft.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin autosave draft endpoint.
|
||||
*
|
||||
* POST — receive all form fields and persist them to the session draft store.
|
||||
*
|
||||
* Drafts are scoped per mode:
|
||||
* - add: keyed by a generated token (stored in form)
|
||||
* - edit: keyed by thesis_id
|
||||
*
|
||||
* Excluded field patterns (not persisted as drafts):
|
||||
* - csrf_token
|
||||
* - FilePond metadata (filepond_mode, queue_file, filepond_*)
|
||||
* - Files-related fields
|
||||
* - Empty values
|
||||
*/
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once APP_ROOT . '/src/AdminAuth.php';
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// ── CSRF check ──────────────────────────────────────────────────────────
|
||||
if ($method !== 'POST') {
|
||||
http_response_code(405);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'Méthode non autorisée.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (
|
||||
!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])
|
||||
) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'Token de sécurité invalide.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Determine draft key ─────────────────────────────────────────────────
|
||||
$draftToken = $_POST['draft_token'] ?? '';
|
||||
$thesisId = (int)($_POST['thesis_id'] ?? 0);
|
||||
|
||||
if ($draftToken !== '' && preg_match('/^[a-f0-9]{16}$/', $draftToken)) {
|
||||
$draftKey = 'admin_draft_' . $draftToken;
|
||||
} elseif ($thesisId > 0) {
|
||||
$draftKey = 'admin_draft_edit_' . $thesisId;
|
||||
} else {
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'Paramètres invalides.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Save all form fields ────────────────────────────────────────────────
|
||||
$excludePrefixes = [
|
||||
'csrf_token', 'share_link_token',
|
||||
'filepond_mode', 'queue_file', 'filepond_',
|
||||
];
|
||||
$excludeExact = ['draft_token', 'thesis_id', 'slug',
|
||||
'couverture', 'note_intention', 'files', 'annexes',
|
||||
'peertube_video', 'peertube_audio', 'cover_remove',
|
||||
'go', 'MAX_FILE_SIZE'];
|
||||
|
||||
$draft = [];
|
||||
foreach ($_POST as $key => $value) {
|
||||
if (in_array($key, $excludeExact, true)) continue;
|
||||
$skip = false;
|
||||
foreach ($excludePrefixes as $prefix) {
|
||||
if (str_starts_with($key, $prefix)) { $skip = true; break; }
|
||||
}
|
||||
if ($skip) continue;
|
||||
|
||||
if ($value === '' || $value === null || (is_array($value) && count($value) === 0)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$draft[$key] = $value;
|
||||
}
|
||||
|
||||
$_SESSION[$draftKey] = $draft;
|
||||
|
||||
// Rotate CSRF after mutation
|
||||
$newToken = bin2hex(random_bytes(32));
|
||||
$_SESSION['csrf_token'] = $newToken;
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'csrf_token' => $newToken,
|
||||
]);
|
||||
exit;
|
||||
@@ -3,8 +3,6 @@
|
||||
require_once __DIR__ . "/../../../bootstrap.php";
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
|
||||
error_log('[edit.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | content_type=' . ($_SERVER['CONTENT_TYPE'] ?? 'none') . ' | content_length=' . ($_SERVER['CONTENT_LENGTH'] ?? '0') . ' | post_keys=' . implode(',', array_keys($_POST)) . ' | files_count=' . count($_FILES) . ' | files_keys=' . implode(',', array_keys($_FILES)) . ' | queue_tfe=' . (isset($_FILES['queue_file']['name']['tfe']) ? count(array_filter($_FILES['queue_file']['name']['tfe'])) : 0));
|
||||
|
||||
// PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
@@ -37,6 +35,10 @@ try {
|
||||
// Regenerate CSRF token after successful save
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
|
||||
// Clear autosave draft + FilePond temp files
|
||||
unset($_SESSION['admin_draft_edit_' . $thesisId]);
|
||||
unset($_SESSION['filepond_tmp']);
|
||||
|
||||
AdminLogger::make()->logEdit($thesisId, $_POST['titre'] ?? $_POST['title'] ?? '');
|
||||
|
||||
App::flash('success', "TFE mis à jour avec succès!");
|
||||
|
||||
@@ -24,7 +24,6 @@ function relinkError(int $code, string $message): never {
|
||||
|
||||
// CSRF via header
|
||||
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
error_log('[relink] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | csrf=' . (isset($_SESSION['csrf_token']) ? 'set' : 'missing') . ' | header=' . (strlen($csrfHeader) > 0 ? substr($csrfHeader, 0, 8) . '...' : 'empty') . ' | body_len=' . strlen(file_get_contents('php://input')));
|
||||
if (!isset($_SESSION['csrf_token'])
|
||||
|| !hash_equals($_SESSION['csrf_token'], $csrfHeader)) {
|
||||
relinkError(403, 'Token CSRF invalide.');
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
error_log('[form-help.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | key=' . ($_POST['form_help_key'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
$isAjax = (!empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json'))
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
|
||||
error_log('[formulaire.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | content_type=' . ($_SERVER['CONTENT_TYPE'] ?? 'none') . ' | content_length=' . ($_SERVER['CONTENT_LENGTH'] ?? '0') . ' | post_keys=' . implode(',', array_keys($_POST)) . ' | files_count=' . count($_FILES) . ' | files_keys=' . implode(',', array_keys($_FILES)) . ' | queue_tfe=' . (isset($_FILES['queue_file']['name']['tfe']) ? count(array_filter($_FILES['queue_file']['name']['tfe'])) : 0));
|
||||
|
||||
// Only suppress display_errors in production (cli-server = dev mode).
|
||||
if (php_sapi_name() !== 'cli-server') {
|
||||
ini_set('display_errors', 0);
|
||||
@@ -25,8 +23,6 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||
die('Erreur de sécurité : token invalide. Veuillez recharger le formulaire.');
|
||||
}
|
||||
|
||||
error_log('[formulaire.php] full FILES array: ' . print_r($_FILES, true));
|
||||
|
||||
require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
|
||||
require_once APP_ROOT . '/src/AppLogger.php';
|
||||
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||
@@ -45,6 +41,10 @@ try {
|
||||
$logger->logSubmission('admin', $thesisId, $identifier, $authorName);
|
||||
$adminLogger->logAdd($thesisId, $identifier, $authorName);
|
||||
|
||||
// Clear autosave draft + FilePond temp files
|
||||
unset($_SESSION['admin_draft_' . ($_POST['draft_token'] ?? '')]);
|
||||
unset($_SESSION['admin_draft_add_token']);
|
||||
unset($_SESSION['filepond_tmp']);
|
||||
unset($_SESSION['csrf_token']);
|
||||
|
||||
$redirect = '../recapitulatif.php?id=' . $thesisId;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
error_log('[language.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
error_log('[maintenance.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
error_log('[page.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | slug=' . ($_POST['slug'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
$isAjax = (!empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json'))
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
error_log('[publish.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | is_bulk=' . (!empty($_POST['bulk']) ? '1' : '0') . ' | post_keys=' . implode(',', array_keys($_POST)));
|
||||
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
require_once __DIR__ . '/../../../src/Database.php';
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
error_log('[settings.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | section=' . ($_POST['section'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
|
||||
error_log('[settings.php] CSRF FAIL | session_token=' . ($_SESSION['csrf_token'] ?? 'none') . ' | post_token=' . ($_POST['csrf_token'] ?? 'none'));
|
||||
App::flash('error', "Erreur de sécurité : token invalide.");
|
||||
header('Location: /admin/parametres.php');
|
||||
exit;
|
||||
@@ -21,7 +19,6 @@ $logger = AdminLogger::make();
|
||||
|
||||
$isHxRequest = (isset($_SERVER['HTTP_HX_REQUEST']) && $_SERVER['HTTP_HX_REQUEST'] === 'true');
|
||||
$section = $_POST['section'] ?? '';
|
||||
error_log('[settings.php] PROCESS | section=' . $section . ' | post_keys=' . implode(',', array_keys($_POST)));
|
||||
|
||||
/**
|
||||
* Return an HTML toast fragment for HTMX responses and exit.
|
||||
@@ -58,9 +55,7 @@ function hxToastError(string $message): never {
|
||||
if ($section === 'formulaire_restrictions') {
|
||||
// HTMX may not send unchecked checkboxes even with hidden 0-value inputs;
|
||||
// missing key means unchecked → treat as '0'.
|
||||
$rawPost = $_POST['restricted_files_enabled'] ?? '(missing)';
|
||||
$newValue = empty($_POST['restricted_files_enabled']) ? '0' : '1';
|
||||
error_log('[settings.php] SAVE formulaire_restrictions | restricted_files_enabled raw=' . var_export($rawPost, true) . ' | resolved=' . $newValue);
|
||||
$db->setSetting('restricted_files_enabled', $newValue);
|
||||
$logger->logFormSettingsUpdate(['restricted_files_enabled' => $newValue]);
|
||||
if ($isHxRequest) {
|
||||
@@ -76,9 +71,7 @@ if ($section === 'formulaire_restrictions') {
|
||||
];
|
||||
$newValues = [];
|
||||
foreach ($allowed as $key) {
|
||||
$raw = $_POST[$key] ?? '(missing)';
|
||||
$value = empty($_POST[$key]) ? '0' : '1';
|
||||
error_log('[settings.php] SAVE formulaire_acces | ' . $key . ' raw=' . var_export($raw, true) . ' | resolved=' . $value);
|
||||
$db->setSetting($key, $value);
|
||||
$newValues[$key] = $value;
|
||||
}
|
||||
@@ -89,14 +82,10 @@ if ($section === 'formulaire_restrictions') {
|
||||
App::flash('success', "Degrés d'ouverture mis à jour.");
|
||||
}
|
||||
} elseif ($section === 'objet_types') {
|
||||
$rawThese = $_POST['objet_these_enabled'] ?? '(missing)';
|
||||
$rawFrart = $_POST['objet_frart_enabled'] ?? '(missing)';
|
||||
$newValues = [
|
||||
'objet_these_enabled' => empty($_POST['objet_these_enabled']) ? '0' : '1',
|
||||
'objet_frart_enabled' => empty($_POST['objet_frart_enabled']) ? '0' : '1',
|
||||
];
|
||||
error_log('[settings.php] SAVE objet_types | objet_these_enabled raw=' . var_export($rawThese, true) . ' | resolved=' . $newValues['objet_these_enabled']);
|
||||
error_log('[settings.php] SAVE objet_types | objet_frart_enabled raw=' . var_export($rawFrart, true) . ' | resolved=' . $newValues['objet_frart_enabled']);
|
||||
$db->setSetting('objet_these_enabled', $newValues['objet_these_enabled']);
|
||||
$db->setSetting('objet_frart_enabled', $newValues['objet_frart_enabled']);
|
||||
$logger->logObjetTypesUpdate($newValues);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . "/../../../bootstrap.php";
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
error_log('[smtp-test.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | test_email=' . ($_POST['test_email'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
error_log('[tag.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
error_log('[visibility.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | is_bulk=' . (!empty($_POST['bulk']) ? '1' : '0') . ' | post_keys=' . implode(',', array_keys($_POST)));
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||
|
||||
@@ -83,35 +83,33 @@ $extraJsInline = '';
|
||||
|
||||
if ($editType === 'page' || $editType === 'about_page') {
|
||||
$initialContent = $page["content"] ?? "";
|
||||
$extraJs = ["/assets/js/vendor/overtype.min.js", "/assets/js/app/autosave-handler.js"];
|
||||
$extraJs = ["/assets/js/vendor/overtype.min.js"];
|
||||
$extraJsInline = <<<'JS'
|
||||
var OT = window.OverType.default || window.OverType;
|
||||
var hidden = document.getElementById('content');
|
||||
var editor = new OT(document.getElementById('editor'), {
|
||||
value: hidden.value,
|
||||
minHeight: '400px',
|
||||
minHeight: '100%',
|
||||
spellcheck: false,
|
||||
toolbar: true,
|
||||
onChange: function(value) {
|
||||
hidden.value = value;
|
||||
hidden.dispatchEvent(new CustomEvent('overtype:change', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
JS;
|
||||
} elseif ($editType === 'form_help') {
|
||||
$initialContent = $formHelpContent;
|
||||
$extraJs = ["/assets/js/vendor/overtype.min.js", "/assets/js/app/autosave-handler.js"];
|
||||
$extraJs = ["/assets/js/vendor/overtype.min.js"];
|
||||
$extraJsInline = <<<'JS'
|
||||
var OT = window.OverType.default || window.OverType;
|
||||
var hidden = document.getElementById('content');
|
||||
var editor = new OT(document.getElementById('editor'), {
|
||||
value: hidden.value,
|
||||
minHeight: '400px',
|
||||
minHeight: '100%',
|
||||
spellcheck: false,
|
||||
toolbar: true,
|
||||
onChange: function(value) {
|
||||
hidden.value = value;
|
||||
hidden.dispatchEvent(new CustomEvent('overtype:change', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
JS;
|
||||
|
||||
@@ -123,12 +123,12 @@ extract(FormBootstrap::adminFormVariables(
|
||||
mode: 'edit',
|
||||
formAction: '/admin/actions/edit.php',
|
||||
hiddenFields:
|
||||
'<input type="hidden" name="csrf_token" value="' . htmlspecialchars($_SESSION['csrf_token']) . '">'
|
||||
. '<input type="hidden" name="thesis_id" value="' . $thesisId . '">',
|
||||
'<input type="hidden" name="csrf_token" value="' . htmlspecialchars($_SESSION['csrf_token']) . '">',
|
||||
formData: $formData,
|
||||
siteSettings: $siteSettings,
|
||||
helpBlocks: $helpBlocks,
|
||||
options: [
|
||||
'thesisId' => $thesisId,
|
||||
'filesMode' => 'edit',
|
||||
'existingWebsiteUrl' => $existingWebsiteUrl,
|
||||
'existingWebsiteLabel' => $existingWebsiteLabel,
|
||||
@@ -144,6 +144,8 @@ extract(FormBootstrap::adminFormVariables(
|
||||
'currentFiles' => $currentFiles ?? [],
|
||||
'currentContextNote' => $currentContextNote ?? null,
|
||||
'currentContactVisible' => $currentContactVisible ?? null,
|
||||
'currentDurationValue' => $currentDurationValue ?? null,
|
||||
'currentDurationUnit' => $currentDurationUnit ?? 'pages',
|
||||
'contactInterne' => $contactInterne ?? null,
|
||||
'contactPublic' => $contactPublic ?? false,
|
||||
'showCoverPreview' => true,
|
||||
@@ -158,6 +160,10 @@ $formData['license_id'] = $currentLicenseId;
|
||||
$formData['license_custom'] = $currentRaw['license_custom'] ?? '';
|
||||
$formData['cc2r'] = $currentRaw['cc2r'] ?? false;
|
||||
|
||||
// Duration variables for the form template
|
||||
$durationValue = $currentDurationValue ?? null;
|
||||
$durationUnit = $currentDurationUnit ?? 'pages';
|
||||
|
||||
// Asset arrays and page chrome
|
||||
$isAdmin = true;
|
||||
$bodyClass = 'admin-body';
|
||||
|
||||
@@ -13,8 +13,6 @@ AdminAuth::requireLogin();
|
||||
|
||||
$storageRoot = STORAGE_ROOT;
|
||||
|
||||
error_log('[file-browser] ENTRY | dir=' . ($_GET['dir'] ?? '(root)') . ' | storageRoot=' . $storageRoot);
|
||||
|
||||
// Determine which directory to browse
|
||||
$relDir = trim($_GET['dir'] ?? '', '/');
|
||||
if ($relDir !== '' && !preg_match('#^(tfe|these|frart|documents|theses)(/|$)#', $relDir)) {
|
||||
|
||||
@@ -17,7 +17,6 @@ $importResults = [];
|
||||
$importDone = false;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
error_log('[admin/index.php] IMPORT ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | content_type=' . ($_SERVER['CONTENT_TYPE'] ?? 'none') . ' | csv_file_error=' . ($_FILES['csv_file']['error'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
|
||||
if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
|
||||
$importErrors[] = "Erreur de sécurité : token invalide.";
|
||||
} else {
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
require_once __DIR__ . '/../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../src/AdminAuth.php';
|
||||
|
||||
error_log('[admin/login.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | is_auth=' . (AdminAuth::isAuthenticated() ? '1' : '0') . ' | has_password=' . (AdminAuth::hasPassword() ? '1' : '0'));
|
||||
|
||||
if (!AdminAuth::hasPassword()) {
|
||||
header('Location: /admin/');
|
||||
exit;
|
||||
@@ -24,8 +22,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
|
||||
$pageTitle = 'Connexion';
|
||||
$isAdmin = true; $bodyClass = 'admin-body';
|
||||
$isAdmin = true; $isLogin = true; $bodyClass = 'admin-body';
|
||||
require_once APP_ROOT . '/templates/head.php';
|
||||
include APP_ROOT . '/templates/header.php';
|
||||
include APP_ROOT . '/templates/admin/login.php';
|
||||
require_once APP_ROOT . '/templates/admin/footer.php';
|
||||
// Login page does not render the admin footer (no toast-region poll, no HTMX extras).
|
||||
// It closes <html> directly so there is no dangling HTMX polling the toast endpoint
|
||||
// while unauthenticated.
|
||||
echo "\n</body>\n</html>";
|
||||
|
||||
@@ -24,7 +24,10 @@ if (isset($_GET['id'])) {
|
||||
if ($thesisId !== false && $thesisId > 0) {
|
||||
try {
|
||||
$db = new Database();
|
||||
$thesis = $db->getThesis($thesisId);
|
||||
// Student-mode preview: only show published theses.
|
||||
$thesis = $studentMode
|
||||
? $db->getThesisById($thesisId)
|
||||
: $db->getThesis($thesisId);
|
||||
|
||||
if (!$thesis) {
|
||||
$error = "TFE non trouvé.";
|
||||
|
||||
@@ -9,7 +9,13 @@
|
||||
require_once __DIR__ . '/../../bootstrap.php';
|
||||
require_once __DIR__ . '/../../src/AdminAuth.php';
|
||||
|
||||
AdminAuth::requireLogin();
|
||||
// Don't redirect unauthenticated requests — just return empty (defense-in-depth).
|
||||
// The toast-region poll fires on <hx-trigger="load">; if the user is on the
|
||||
// login page they are not authenticated yet.
|
||||
if (!AdminAuth::isAuthenticated()) {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
$flash = App::consumeFlash();
|
||||
|
||||
|
||||
@@ -541,7 +541,7 @@ th.admin-ap-col {
|
||||
|
||||
.admin-body main > section[aria-labelledby^="settings-"] fieldset legend {
|
||||
padding: 0;
|
||||
font-weight: 600;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
@@ -1303,7 +1303,7 @@ th.admin-ap-col {
|
||||
flex: 1 1 260px;
|
||||
}
|
||||
.param-smtp-test-row label {
|
||||
font-weight: 600;
|
||||
font-weight: 400;
|
||||
margin-bottom: var(--space-2xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -1909,7 +1909,7 @@ th.admin-ap-col {
|
||||
.fhb-edit-label {
|
||||
display: block;
|
||||
font-size: var(--step--2);
|
||||
font-weight: 500;
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-3xs);
|
||||
}
|
||||
|
||||
@@ -13,23 +13,32 @@ details > summary::-webkit-details-marker {
|
||||
}
|
||||
|
||||
details {
|
||||
padding: var(--space-s);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius, 6px);
|
||||
background: var(--bg-secondary, var(--surface));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
details[open] {
|
||||
padding-bottom: var(--space-s);
|
||||
}
|
||||
|
||||
details > :not(summary) {
|
||||
margin-left: var(--space-s);
|
||||
margin-right: var(--space-s);
|
||||
}
|
||||
|
||||
summary {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
color: var(--accent-secondary);
|
||||
transition: color 0.15s;
|
||||
|
||||
svg {
|
||||
fill: var(--accent-secondary);
|
||||
vertical-align: text-bottom;
|
||||
height: 1.4em;
|
||||
}
|
||||
color: var(--text-primary);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
padding: var(--space-s);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
summary:hover {
|
||||
color: var(--accent-primary);
|
||||
background: var(--hover-bg, rgba(0, 0, 0, 0.03));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-3xs);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── Text inputs, selects, textareas ────────────────────────────────── */
|
||||
@@ -95,7 +96,7 @@ fieldset > *:not(:last-child) { margin-bottom: var(--space-xs); }
|
||||
|
||||
legend {
|
||||
font-size: var(--step--1);
|
||||
font-weight: 600;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -242,6 +242,6 @@
|
||||
|
||||
.admin-dialog label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-weight: 400;
|
||||
margin-bottom: var(--space-3xs);
|
||||
}
|
||||
|
||||
@@ -232,3 +232,68 @@
|
||||
.file-browser-file .file-browser-select-btn:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* ── Full-page editor (contenus-edit overtype) ─────────────────────── */
|
||||
|
||||
/* Make the main content area a flex column so the form fills remaining height */
|
||||
.admin-body .full-editor-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-form--full-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.admin-form--full-editor #editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-form--full-editor #editor .n-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-form--full-editor #editor .n-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-form--full-editor #editor .n-input {
|
||||
height: 100% !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.admin-form--full-editor #editor .n-preview {
|
||||
height: 100% !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.full-editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-s);
|
||||
padding-bottom: var(--space-s);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
margin-bottom: var(--space-s);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.full-editor-label {
|
||||
font-size: var(--step--1);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.full-editor-toolbar .btn--primary {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
> label,
|
||||
.admin-form div:has(select:required) > label,
|
||||
.admin-form div:has(textarea:required) > label {
|
||||
font-weight: 600;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Asterisk on required field labels */
|
||||
@@ -161,6 +161,19 @@
|
||||
gap: var(--space-3xs);
|
||||
}
|
||||
|
||||
.admin-form-group--inline {
|
||||
flex-direction: row;
|
||||
align-items: end;
|
||||
gap: var(--space-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-form-group--inline > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3xs);
|
||||
}
|
||||
|
||||
/* ── Jury fieldset ──────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Website-URL inline fieldset (shown/hidden via HTMX) ────────────────── */
|
||||
@@ -178,7 +191,7 @@
|
||||
.admin-body fieldset fieldset.admin-jury-lecteurs > legend,
|
||||
.student-body fieldset fieldset.admin-jury-lecteurs > legend {
|
||||
font-size: var(--step--2);
|
||||
font-weight: 600;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
@@ -845,7 +858,7 @@
|
||||
|
||||
.retry-email-form label {
|
||||
font-size: var(--step--1);
|
||||
font-weight: 600;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.retry-email-form input[type="email"] {
|
||||
@@ -1207,7 +1220,7 @@ legend {
|
||||
}
|
||||
|
||||
.licence-options-fieldset legend {
|
||||
font-weight: 600;
|
||||
font-weight: 400;
|
||||
font-size: var(--step--1);
|
||||
color: var(--text-secondary);
|
||||
padding: 0 var(--space-2xs);
|
||||
@@ -1227,7 +1240,7 @@ legend {
|
||||
.admin-form > div:not(.admin-form-footer) > label,
|
||||
.admin-form > div:not(.admin-form-footer) > span.admin-row-label {
|
||||
padding-top: 0;
|
||||
font-weight: 500;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Labels inside fieldsets (checkbox/radio groups) */
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
}
|
||||
|
||||
/* ---- 6-column index layout ---- */
|
||||
/* Column fractions: years=0.4 ap=1 or=1.2 fi=0.7 students=1 kw=1 */
|
||||
/* Equal-width columns except Années (years) = narrower */
|
||||
.repertoire-index {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(3rem, 0.4fr)
|
||||
minmax(12rem, 1.4fr)
|
||||
minmax(9rem, 0.8fr)
|
||||
minmax(7rem, 0.8fr)
|
||||
minmax(8rem, 0.7fr)
|
||||
minmax(7rem, 1fr);
|
||||
minmax(3rem, 0.45fr)
|
||||
minmax(12rem, 1fr)
|
||||
minmax(9rem, 1fr)
|
||||
minmax(7rem, 1fr)
|
||||
minmax(8rem, 1fr)
|
||||
minmax(min-content, 1fr);
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: var(--space-s);
|
||||
justify-content: space-between;
|
||||
@@ -78,14 +78,14 @@
|
||||
.repertoire-col > h2 {
|
||||
grid-row: 1;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-0);
|
||||
font-size: var(--step--1);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-primary);
|
||||
font-weight: 398;
|
||||
margin: 0;
|
||||
padding: var(--space-xs) var(--space-2xs) var(--space-3xs);
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
padding: var(--space-xs) 0 var(--space-3xs) 0;
|
||||
border-bottom: 1px solid var(--text-primary);
|
||||
align-self: end;
|
||||
hyphens: manual;
|
||||
word-break: normal;
|
||||
@@ -96,7 +96,7 @@
|
||||
grid-row: 2;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--space-2xs) var(--space-2xs) var(--space-l);
|
||||
padding: var(--space-2xs) 0 var(--space-l) 0;
|
||||
}
|
||||
|
||||
/* Strip list chrome inside repertoire columns */
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
|
||||
.tfe-access-request-form label {
|
||||
font-size: var(--step--1);
|
||||
font-weight: 600;
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,14 @@
|
||||
* parse errors instead of silently swallowing them (unlike the
|
||||
* old autosave.js .catch(() => {}) pattern).
|
||||
*/
|
||||
function _handleAutosaveResponse(event) {
|
||||
function handleAutosaveResponse(event) {
|
||||
// Only handle responses from autosave endpoints (draft.php).
|
||||
// The htmx:afterRequest event bubbles, so child elements'
|
||||
// HTMX requests (e.g. licence fragment, pill-search) also
|
||||
// reach this handler. We filter by URL to avoid mixing them.
|
||||
const url = event.detail.requestConfig?.path || "";
|
||||
if (!url.includes("draft.php")) return;
|
||||
|
||||
const form = event.target.closest("form");
|
||||
const status = form ? form.querySelector("[data-autosave-status]") : null;
|
||||
|
||||
@@ -56,11 +63,17 @@ function _handleAutosaveResponse(event) {
|
||||
|
||||
// Show saving indicator while request is in flight
|
||||
document.body.addEventListener("htmx:beforeRequest", (e) => {
|
||||
const url = e.detail.requestConfig?.path || "";
|
||||
if (!url.includes("draft.php")) return;
|
||||
// The autosave request comes from the hidden probe div, so find
|
||||
// the status indicator by searching the closest form.
|
||||
const el = e.target;
|
||||
if (!el) return;
|
||||
const status = el.querySelector("[data-autosave-status]");
|
||||
const form = el?.closest?.("form");
|
||||
const status = form?.querySelector?.("[data-autosave-status]");
|
||||
if (status) {
|
||||
status.textContent = "Enregistrement…";
|
||||
status.className = "autosave-status autosave-status--saving";
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -277,7 +277,11 @@ function renderShareLinkForm(string $slug, array $link): void
|
||||
// Filter out PACS from AP programs for student forms (spec: admin-only AP)
|
||||
$apPrograms = array_values(array_filter($apPrograms, fn($ap) => ($ap['code'] ?? '') !== 'PACS'));
|
||||
|
||||
$formData = $_SESSION['form_data_share_' . $slug] ?? [];
|
||||
// Hydrate form data from session draft (autosave). Flash repopulation
|
||||
// (from validation redirects) takes priority over stale draft entries.
|
||||
$draftKey = 'partage_draft_' . $slug;
|
||||
$draftData = $_SESSION[$draftKey] ?? [];
|
||||
$formData = array_merge($draftData, $_SESSION['form_data_share_' . $slug] ?? []);
|
||||
unset($_SESSION['form_data_share_' . $slug]);
|
||||
|
||||
// Determine allowed objet values for this link
|
||||
@@ -324,7 +328,8 @@ function renderShareLinkForm(string $slug, array $link): void
|
||||
// ── Shared form variables ──────────────────────────────────────────────
|
||||
$mode = 'partage';
|
||||
$formAction = '/partage/' . urlencode($slug) . '/submit';
|
||||
$hiddenFields = '<input type="hidden" name="share_link_token" value="' . htmlspecialchars($shareCsrfToken) . '">';
|
||||
$hiddenFields = '<input type="hidden" name="csrf_token" value="' . htmlspecialchars($csrfToken) . '">'
|
||||
. '<input type="hidden" name="share_link_token" value="' . htmlspecialchars($shareCsrfToken) . '">';
|
||||
|
||||
$oldFn = $shareOldFn;
|
||||
$withAutofocusFn = $shareWithAutofocusFn;
|
||||
@@ -399,6 +404,11 @@ function renderShareLinkForm(string $slug, array $link): void
|
||||
$currentContextNote = null;
|
||||
$currentContactVisible = null;
|
||||
|
||||
// ── Autosave wiring ─────────────────────────────────────────────────┐
|
||||
$autosaveUrl = '/partage/fragments/draft.php?slug=' . urlencode($slug);
|
||||
$formExtraAttrs = '';
|
||||
$showAutosaveStatus = true;
|
||||
|
||||
include APP_ROOT . '/templates/partage/form-page.php';
|
||||
?>
|
||||
<main id="main-content">
|
||||
@@ -537,6 +547,8 @@ function handleShareLinkSubmission(string $slug): void
|
||||
unset($_SESSION['share_verified_' . $slug]);
|
||||
unset($_SESSION['share_active']);
|
||||
unset($_SESSION['share_primed_files_' . $slug]);
|
||||
// Clear autosave draft — submission succeeded
|
||||
unset($_SESSION['partage_draft_' . $slug]);
|
||||
// Clear FilePond temp file tracking — files have been moved to permanent storage
|
||||
unset($_SESSION['filepond_tmp']);
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ if ($thesisId <= 0) {
|
||||
}
|
||||
|
||||
$db = Database::getInstance();
|
||||
$thesis = $db->getThesis($thesisId);
|
||||
// Only published theses are visible via public recap (no slug-auth here).
|
||||
$thesis = $db->getThesisById($thesisId);
|
||||
if (!$thesis) {
|
||||
http_response_code(404);
|
||||
die('TFE introuvable.');
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -89,6 +89,8 @@ CREATE TABLE IF NOT EXISTS theses (
|
||||
context_note TEXT,
|
||||
contact_visible TEXT DEFAULT NULL,
|
||||
remarks TEXT,
|
||||
duration_value REAL,
|
||||
duration_unit TEXT DEFAULT 'pages',
|
||||
access_type_id INTEGER,
|
||||
license_id INTEGER,
|
||||
jury_points DECIMAL(4,2),
|
||||
@@ -410,6 +412,8 @@ SELECT
|
||||
t.synopsis,
|
||||
t.context_note,
|
||||
t.contact_visible,
|
||||
t.duration_value,
|
||||
t.duration_unit,
|
||||
at.name as access_type,
|
||||
lt.name as license_type,
|
||||
t.license_id,
|
||||
@@ -526,9 +530,9 @@ INSERT OR IGNORE INTO ap_programs (name, code) VALUES ('Atelier Pratiques Situé
|
||||
INSERT OR IGNORE INTO ap_programs (name, code) VALUES ('Lieux, Interdisciplinarités, Écologie, Nécessité, Systèmes', 'LIENS');
|
||||
INSERT OR IGNORE INTO ap_programs (name, code) VALUES ('Pratique de l''art - outils critiques, arts et contexte simultanés', 'PACS');
|
||||
|
||||
INSERT OR IGNORE INTO finality_types (name) VALUES ('Approfondi');
|
||||
INSERT OR IGNORE INTO finality_types (name) VALUES ('Enseignement');
|
||||
INSERT OR IGNORE INTO finality_types (name) VALUES ('Spécialisé');
|
||||
INSERT OR IGNORE INTO finality_types (name) VALUES ('Approfondie');
|
||||
INSERT OR IGNORE INTO finality_types (name) VALUES ('Didactique');
|
||||
INSERT OR IGNORE INTO finality_types (name) VALUES ('Spécialisée');
|
||||
|
||||
INSERT OR IGNORE INTO languages (name) VALUES ('français');
|
||||
INSERT OR IGNORE INTO languages (name) VALUES ('anglais');
|
||||
@@ -563,6 +567,7 @@ INSERT OR IGNORE INTO site_settings (key, value) VALUES ('objet_frart_enabled',
|
||||
INSERT OR IGNORE INTO site_settings (key, value) VALUES ('objet_these_enabled', '0');
|
||||
INSERT OR IGNORE INTO site_settings (key, value) VALUES ('peertube_upload_enabled', '1');
|
||||
INSERT OR IGNORE INTO site_settings (key, value) VALUES ('restricted_files_enabled', '1');
|
||||
INSERT OR IGNORE INTO site_settings (key, value) VALUES ('admin_password_hash', '');
|
||||
|
||||
INSERT OR IGNORE INTO pages (slug, title, content, is_published) VALUES ('about', 'À propos', 'Contenu à venir', 1);
|
||||
INSERT OR IGNORE INTO pages (slug, title, content, is_published) VALUES ('charte', 'Charte', 'Contenu à venir', 1);
|
||||
|
||||
@@ -1318,6 +1318,8 @@ CREATE TABLE IF NOT EXISTS theses (
|
||||
remarks TEXT, -- Internal remarks
|
||||
|
||||
-- Duration/size
|
||||
duration_value REAL,
|
||||
duration_unit TEXT DEFAULT 'pages',
|
||||
access_type_id INTEGER,
|
||||
license_id INTEGER,
|
||||
|
||||
@@ -4347,6 +4349,8 @@ SELECT
|
||||
ft.name as finality_type,
|
||||
t.synopsis,
|
||||
t.context_note,
|
||||
t.duration_value,
|
||||
t.duration_unit,
|
||||
at.name as access_type,
|
||||
lt.name as license_type,
|
||||
t.license_id,
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
<th scope="col">Utilisations</th>
|
||||
<th scope="col">Expiration</th>
|
||||
<th scope="col">Créé le</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -175,6 +176,12 @@
|
||||
<td style="text-align:center;"><?= intval($link['usage_count']) ?></td>
|
||||
<td><?= $expires ?></td>
|
||||
<td><?= $created ?></td>
|
||||
<td class="admin-actions-col">
|
||||
<button type="button" class="admin-icon-btn admin-icon-btn--delete" title="Supprimer"
|
||||
onclick="openDeleteArchivedLinkDialog(<?= $link['id'] ?>)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"></path></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
@@ -641,6 +648,10 @@ function _executeArchiveLink() {
|
||||
const form = document.getElementById('archive-link-form-' + _pendingArchiveLinkId);
|
||||
if (form) form.submit();
|
||||
}
|
||||
function openDeleteArchivedLinkDialog(id) {
|
||||
document.getElementById('delete-archived-link-id').value = id;
|
||||
document.getElementById('delete-archived-link-dialog').showModal();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Archive link confirm -->
|
||||
@@ -658,3 +669,24 @@ function _executeArchiveLink() {
|
||||
<button type="button" class="btn btn--secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- ═══════════════════════ DELETE ARCHIVED LINK DIALOG ═══════════════════════ -->
|
||||
<dialog id="delete-archived-link-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="delete-archived-link-title">
|
||||
<div class="admin-dialog__header">
|
||||
<h2 id="delete-archived-link-title">Supprimer le lien</h2>
|
||||
<button type="button" class="admin-dialog__close" aria-label="Fermer"
|
||||
onclick="this.closest('dialog').close()">✕</button>
|
||||
</div>
|
||||
<div class="admin-dialog__alert">
|
||||
<p>Supprimer définitivement ce lien archivé ? Cette action est irréversible.</p>
|
||||
</div>
|
||||
<div class="admin-dialog__footer">
|
||||
<form method="post" action="actions/acces-etudiante.php">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" id="delete-archived-link-id" value="">
|
||||
<button type="submit" class="btn btn--danger">Supprimer</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn--secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -1,30 +1,28 @@
|
||||
<main id="main-content">
|
||||
<main id="main-content" class="full-editor-page">
|
||||
<h1><a href="/admin/contenus.php" class="admin-back-btn" title="Retour"><svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm48-88a8,8,0,0,1-8,8H107.31l18.35,18.34a8,8,0,0,1-11.32,11.32l-32-32a8,8,0,0,1,0-11.32l32-32a8,8,0,0,1,11.32,11.32L107.31,120H168A8,8,0,0,1,176,128Z"></path></svg></a> Éditer : <?= htmlspecialchars($editTitle) ?></h1>
|
||||
|
||||
<?php if ($editType === 'about_page'): ?>
|
||||
|
||||
<!-- ── Markdown content ──────────────────────────────────────────────── -->
|
||||
<h2>Contenu de la page</h2>
|
||||
<form action="/admin/actions/page.php" method="post" class="admin-form"
|
||||
hx-post="/admin/actions/page.php"
|
||||
hx-trigger="overtype:change delay:1500ms"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="handleAutosaveResponse(event)">
|
||||
<form action="/admin/actions/page.php" method="post" class="admin-form admin-form--full-editor">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">
|
||||
<input type="hidden" name="slug" value="about">
|
||||
|
||||
<label for="editor">Contenu (Markdown) :</label>
|
||||
<button type="button" class="btn btn--sm"
|
||||
hx-get="/admin/markdown-cheatsheet-fragment.php"
|
||||
hx-target="#md-cheatsheet-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()">
|
||||
Aide Markdown
|
||||
</button>
|
||||
<div class="full-editor-toolbar">
|
||||
<span class="full-editor-label">Contenu (Markdown) :</span>
|
||||
<button type="button" class="btn btn--sm"
|
||||
hx-get="/admin/markdown-cheatsheet-fragment.php"
|
||||
hx-target="#md-cheatsheet-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()">
|
||||
Aide Markdown
|
||||
</button>
|
||||
<button type="submit" class="btn btn--primary btn--sm">Enregistrer</button>
|
||||
</div>
|
||||
<input type="hidden" id="content" name="content"
|
||||
value="<?= htmlspecialchars($initialContent) ?>">
|
||||
<div id="editor"></div>
|
||||
<div class="autosave-status" data-autosave-status></div>
|
||||
</form>
|
||||
|
||||
<!-- ── Contacts ──────────────────────────────────────────────────────── -->
|
||||
@@ -137,50 +135,46 @@
|
||||
</script>
|
||||
|
||||
<?php elseif ($editType === 'page' && $pageSlug !== 'about'): ?>
|
||||
<form action="/admin/actions/page.php" method="post" class="admin-form"
|
||||
hx-post="/admin/actions/page.php"
|
||||
hx-trigger="overtype:change delay:1500ms"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="handleAutosaveResponse(event)">
|
||||
<form action="/admin/actions/page.php" method="post" class="admin-form admin-form--full-editor">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">
|
||||
<input type="hidden" name="slug" value="<?= htmlspecialchars($pageSlug) ?>">
|
||||
|
||||
<label for="editor">Contenu (Markdown) :</label>
|
||||
<button type="button" class="btn btn--sm"
|
||||
hx-get="/admin/markdown-cheatsheet-fragment.php"
|
||||
hx-target="#md-cheatsheet-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()">
|
||||
Aide Markdown
|
||||
</button>
|
||||
<div class="full-editor-toolbar">
|
||||
<span class="full-editor-label">Contenu (Markdown) :</span>
|
||||
<button type="button" class="btn btn--sm"
|
||||
hx-get="/admin/markdown-cheatsheet-fragment.php"
|
||||
hx-target="#md-cheatsheet-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()">
|
||||
Aide Markdown
|
||||
</button>
|
||||
<button type="submit" class="btn btn--primary btn--sm">Enregistrer</button>
|
||||
</div>
|
||||
<input type="hidden" id="content" name="content"
|
||||
value="<?= htmlspecialchars($initialContent) ?>">
|
||||
<div id="editor"></div>
|
||||
<div class="autosave-status" data-autosave-status></div>
|
||||
</form>
|
||||
|
||||
<?php elseif ($editType === 'form_help'): ?>
|
||||
<p class="param-note">Ce texte est affiché dans le formulaire de soumission des étudiant·es (lien de partage). Supporte le Markdown.</p>
|
||||
<form action="/admin/actions/form-help.php" method="post" class="admin-form"
|
||||
hx-post="/admin/actions/form-help.php"
|
||||
hx-trigger="overtype:change delay:1500ms"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="handleAutosaveResponse(event)">
|
||||
<form action="/admin/actions/form-help.php" method="post" class="admin-form admin-form--full-editor">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||
<input type="hidden" name="form_help_key" value="<?= htmlspecialchars($formHelpKey) ?>">
|
||||
|
||||
<label for="editor">Contenu (Markdown) :</label>
|
||||
<button type="button" class="btn btn--sm"
|
||||
hx-get="/admin/markdown-cheatsheet-fragment.php"
|
||||
hx-target="#md-cheatsheet-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()">
|
||||
Aide Markdown
|
||||
</button>
|
||||
<div class="full-editor-toolbar">
|
||||
<span class="full-editor-label">Contenu (Markdown) :</span>
|
||||
<button type="button" class="btn btn--sm"
|
||||
hx-get="/admin/markdown-cheatsheet-fragment.php"
|
||||
hx-target="#md-cheatsheet-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()">
|
||||
Aide Markdown
|
||||
</button>
|
||||
<button type="submit" class="btn btn--primary btn--sm">Enregistrer</button>
|
||||
</div>
|
||||
<input type="hidden" id="content" name="content"
|
||||
value="<?= htmlspecialchars($initialContent) ?>">
|
||||
<div id="editor"></div>
|
||||
<div class="autosave-status" data-autosave-status></div>
|
||||
</form>
|
||||
|
||||
<?php else: ?>
|
||||
|
||||
@@ -69,14 +69,6 @@ document.addEventListener('htmx:afterSwap',()=>{document.querySelectorAll('input
|
||||
<option value="<?= $y ?>" <?= $yearFilter == $y ? 'selected' : '' ?>><?= $y ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<select name="orientation">
|
||||
<option value="">Orientation</option>
|
||||
<?php foreach ($orientations as $o): ?>
|
||||
<option value="<?= $o['id'] ?>" <?= $orientationFilter == $o['id'] ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($o['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<select name="ap">
|
||||
<option value="">AP</option>
|
||||
<?php foreach ($apPrograms as $ap): ?>
|
||||
@@ -85,6 +77,14 @@ document.addEventListener('htmx:afterSwap',()=>{document.querySelectorAll('input
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<select name="orientation">
|
||||
<option value="">Orientation</option>
|
||||
<?php foreach ($orientations as $o): ?>
|
||||
<option value="<?= $o['id'] ?>" <?= $orientationFilter == $o['id'] ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($o['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if ($searchQuery || $yearFilter || $orientationFilter || $apFilter): ?>
|
||||
<a href="/admin/" class="btn btn--secondary btn--sm admin-filters-reset">✕ Réinitialiser</a>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -360,14 +360,14 @@
|
||||
<!-- Danger zone: remove credentials -->
|
||||
<?php if ($hasPassword): ?>
|
||||
<fieldset class="param-danger-zone">
|
||||
<legend>Supprimer la configuration du mot de passe PHP</legend>
|
||||
<legend>Supprimer la configuration du mot de passe</legend>
|
||||
<p>
|
||||
Supprime le hash de la base de données. L'accès admin
|
||||
dépendra uniquement de l'authentification nginx Basic Auth si elle est configurée.
|
||||
ne sera plus protégé (mode développement).
|
||||
</p>
|
||||
<?php /* TODO: replace this browser confirm() with a proper <dialog> modal like the other confirmations */ ?>
|
||||
<form method="post" action="/admin/actions/account.php"
|
||||
onsubmit="return confirm('Supprimer le mot de passe PHP ? L\'accès admin ne sera protégé que par nginx Basic Auth.')">
|
||||
onsubmit="return confirm('Supprimer le mot de passe ? L\'accès admin ne sera plus protégé.')">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||
<input type="hidden" name="action" value="remove_credentials">
|
||||
<input type="hidden" name="redirect" value="/admin/parametres.php">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// header.php — unified site header for public and admin sections.
|
||||
// Reads: $isAdmin (bool), $currentNav (string, public only)
|
||||
$_isAdmin = !empty($isAdmin);
|
||||
$_isLogin = !empty($isLogin);
|
||||
$_navCurrent = $currentNav ?? '';
|
||||
$_currentPage = basename($_SERVER['PHP_SELF']);
|
||||
$_thesisId = $_GET['id'] ?? null;
|
||||
@@ -9,7 +10,7 @@ $_thesisId = $_GET['id'] ?? null;
|
||||
<header>
|
||||
<a href="#main-content" class="skip-link">Aller au contenu principal</a>
|
||||
|
||||
<?php if ($_isAdmin): ?>
|
||||
<?php if ($_isAdmin && !$_isLogin): ?>
|
||||
|
||||
<nav aria-label="Navigation admin">
|
||||
<ul class="nav-left-links">
|
||||
@@ -87,7 +88,7 @@ $_thesisId = $_GET['id'] ?? null;
|
||||
|
||||
</header>
|
||||
|
||||
<?php if ($_isAdmin): ?>
|
||||
<?php if ($_isAdmin && !$_isLogin): ?>
|
||||
<div class="admin-mobile-block">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><rect width="256" height="256" fill="none"/><rect x="24" y="56" width="208" height="144" rx="16" fill="none" stroke="currentColor" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/><line x1="24" y1="168" x2="104" y2="88" fill="none" stroke="currentColor" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/><line x1="152" y1="168" x2="232" y2="88" fill="none" stroke="currentColor" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<h2>Section administrateur</h2>
|
||||
@@ -95,7 +96,7 @@ $_thesisId = $_GET['id'] ?? null;
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$_isAdmin): ?>
|
||||
<?php if (!$_isAdmin && !$_isLogin): ?>
|
||||
<?php
|
||||
// Search bar — public section only (rendered below header for equal height)
|
||||
$searchBarValue = $searchBarValue ?? $_GET['query'] ?? '';
|
||||
|
||||
@@ -58,6 +58,7 @@ $filepondBase = $filepondBase ?? null;
|
||||
<?php if ($includeFilePond): ?>
|
||||
<script src="<?= App::assetV('/assets/js/app/pill-search.js') ?>" defer></script>
|
||||
<script src="<?= App::assetV('/assets/js/app/jury-autocomplete.js') ?>" defer></script>
|
||||
<script src="<?= App::assetV('/assets/js/app/autosave-handler.js') ?>" defer></script>
|
||||
<script src="<?= App::assetV('/assets/js/vendor/htmx.min.js') ?>" defer></script>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($extraJs as $js): ?>
|
||||
|
||||
@@ -64,4 +64,6 @@ $ariaDescribedBy = ($errorFieldName === $name) ? ' aria-describedby="flash-error
|
||||
</fieldset>
|
||||
</div>
|
||||
<?php
|
||||
unset($checked, $hxPost, $hxTarget, $hxSwap, $hxInclude, $ariaInvalid, $ariaDescribedBy, $errorFieldName);
|
||||
unset($checked, $hxPost, $hxTarget, $hxSwap, $hxInclude, $ariaInvalid, $ariaDescribedBy);
|
||||
// NOTE: $errorFieldName is intentionally NOT unset — it is a shared variable
|
||||
// consumed by downstream partials (e.g. fichiers-fragment.php).
|
||||
|
||||
@@ -89,7 +89,7 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
|
||||
data-queue-type="cover"
|
||||
data-existing-files='<?= htmlspecialchars(json_encode($existingFilesJsonForCover ?? []), ENT_QUOTES) ?>'
|
||||
aria-describedby="couverture-hint">
|
||||
<small id="couverture-hint">JPG, PNG ou WEBP. Format 4:3 recommandé. Max 20 MB.</small>
|
||||
<small id="couverture-hint">JPG, PNG ou WEBP. Format 4:3 recommandé (ex. 1200 × 900 px). Max 20 MB.</small>
|
||||
</div>
|
||||
<?php if ($editMode): ?>
|
||||
<button type="button" class="btn btn--sm btn--ghost file-browser-trigger"
|
||||
|
||||
@@ -20,7 +20,7 @@ $adminMode = $adminMode ?? false;
|
||||
$name = 'couverture';
|
||||
$label = 'Image de couverture (optionnel) :';
|
||||
$accept = 'image/jpeg,image/png,image/webp';
|
||||
$hint = 'JPG, PNG ou WEBP. Format 4:3 recommandé. Max 20 MB.';
|
||||
$hint = 'JPG, PNG ou WEBP. Format 4:3 recommandé (ex. 1200 × 900 px). Max 20 MB.';
|
||||
include APP_ROOT . '/templates/partials/form/file-field.php';
|
||||
?>
|
||||
|
||||
|
||||
@@ -102,6 +102,10 @@ $existingWebsiteUrl = $existingWebsiteUrl ?? '';
|
||||
$existingWebsiteLabel = $existingWebsiteLabel ?? '';
|
||||
$checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
|
||||
|
||||
// Duration (value + unit)
|
||||
$durationValue = $durationValue ?? null;
|
||||
$durationUnit = $durationUnit ?? 'pages';
|
||||
|
||||
// WCAG 3.3.1: which field has a validation error (set by caller from App::consumeAutofocus())
|
||||
$errorFieldName = $errorFieldName ?? null;
|
||||
?>
|
||||
@@ -413,8 +417,38 @@ if ($filesMode === 'add'): ?>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- ═══════════════════ Métadonnées complémentaires ═══════════════════
|
||||
(Durée/Nombre de pages supprimés — redondants avec les fichiers attachés) -->
|
||||
<!-- ═══════════════════ Durée ═══════════════════ -->
|
||||
<fieldset>
|
||||
<legend>Durée</legend>
|
||||
<div class="admin-form-group admin-form-group--inline">
|
||||
<div>
|
||||
<label for="duration_unit">Unité :</label>
|
||||
<select id="duration_unit" name="duration_unit">
|
||||
<?php
|
||||
$_currentUnit = $durationUnit ?? ($formData['duration_unit'] ?? 'pages');
|
||||
$_units = [
|
||||
'pages' => 'pages',
|
||||
'minutes' => 'minutes',
|
||||
'sec' => 'secondes',
|
||||
'heures' => 'heures',
|
||||
'mo' => 'Mo',
|
||||
];
|
||||
foreach ($_units as $_val => $_label): ?>
|
||||
<option value="<?= $_val ?>" <?= $_currentUnit === $_val ? 'selected' : '' ?>><?= htmlspecialchars($_label) ?></option>
|
||||
<?php endforeach; unset($_units, $_currentUnit, $_val, $_label); ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="duration_value">Valeur :</label>
|
||||
<input type="number" id="duration_value" name="duration_value"
|
||||
value="<?= htmlspecialchars((string)($durationValue ?? ($formData['duration_value'] ?? ''))) ?>"
|
||||
step="0.1" min="0" placeholder="0"
|
||||
style="width: 8ch;">
|
||||
</div>
|
||||
</div>
|
||||
<small>Optionnel. Exemples : 88 pages, 32 minutes, 1.5 heures, 120 Mo.</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- ═══════════════════ Degrés d'ouverture et licences ═══════════════════ -->
|
||||
<?php
|
||||
@@ -552,6 +586,16 @@ if ($filesMode === 'add'): ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($showAutosaveStatus): ?>
|
||||
<!-- Hidden autosave element: polls the form every 3s and POSTs to draft.php.
|
||||
Uses hx-include to serialize the entire form. hx-swap="none" so response
|
||||
doesn't alter the DOM. -->
|
||||
<div hx-post="<?= htmlspecialchars($autosaveUrl ?? '') ?>"
|
||||
hx-trigger="every 3s"
|
||||
hx-include="closest form"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="handleAutosaveResponse(event)"
|
||||
data-autosave-probe
|
||||
aria-hidden="true" style="display:none"></div>
|
||||
<div class="autosave-status" data-autosave-status></div>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
@@ -73,4 +73,6 @@ foreach ($attrs as $k => $v) {
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
unset($required, $placeholder, $id, $hint, $attrs, $selectAttrStr, $k, $v, $hintId, $describedBy, $ariaDescribedBy, $ariaInvalid, $errorFieldName);
|
||||
unset($required, $placeholder, $id, $hint, $attrs, $selectAttrStr, $k, $v, $hintId, $describedBy, $ariaDescribedBy, $ariaInvalid);
|
||||
// NOTE: $errorFieldName is intentionally NOT unset — it is a shared variable
|
||||
// consumed by downstream partials (e.g. fichiers-fragment.php).
|
||||
|
||||
@@ -74,4 +74,6 @@ $ariaInvalid = ($errorFieldName === $name) ? ' aria-invalid="true" aria-errormes
|
||||
</div>
|
||||
<?php
|
||||
// Reset consumed variables so includes in a loop don't bleed state.
|
||||
unset($type, $required, $placeholder, $hint, $id, $attrs, $attrStr, $k, $v, $hintId, $describedBy, $ariaDescribedBy, $ariaInvalid, $errorFieldName);
|
||||
unset($type, $required, $placeholder, $hint, $id, $attrs, $attrStr, $k, $v, $hintId, $describedBy, $ariaDescribedBy, $ariaInvalid);
|
||||
// NOTE: $errorFieldName is intentionally NOT unset — it is a shared variable
|
||||
// consumed by the parent partial (e.g. fieldset-tfe-info.php for the synopsis textarea).
|
||||
|
||||
@@ -95,7 +95,7 @@ $filterColumns = [
|
||||
['dataKey' => 'years', 'dim' => 'years', 'heading' => 'Années'],
|
||||
['dataKey' => 'ap_programs', 'dim' => 'ap', 'heading' => 'Ateliers Pluridisciplinaires'],
|
||||
['dataKey' => 'orientations', 'dim' => 'or', 'heading' => 'Orientations'],
|
||||
['dataKey' => 'finality_types', 'dim' => 'fi', 'heading' => 'Finalité du Master'],
|
||||
['dataKey' => 'finality_types', 'dim' => 'fi', 'heading' => 'Finalité du Master'],
|
||||
['dataKey' => 'keywords', 'dim' => 'kw', 'heading' => 'Mots-clés'],
|
||||
];
|
||||
|
||||
@@ -118,7 +118,7 @@ foreach ($renderOrder as $colKey):
|
||||
if ($colKey === 'students'): ?>
|
||||
<!-- ÉTUDIANTES -->
|
||||
<section class="repertoire-col" data-col="students">
|
||||
<h2>Étudiantes</h2>
|
||||
<h2>Étudiant·es</h2>
|
||||
<ul>
|
||||
<?php if (empty($studentWorks)): ?>
|
||||
<li class="rep-empty">—</li>
|
||||
@@ -147,7 +147,7 @@ foreach ($renderOrder as $colKey):
|
||||
<?php else:
|
||||
$col = array_values(array_filter($filterColumns, fn($c) => $c['dim'] === $colKey))[0]; ?>
|
||||
<section class="repertoire-col" data-col="<?= $col['dim'] ?>">
|
||||
<h2><?= htmlspecialchars($col['heading']) ?></h2>
|
||||
<h2><?= $col['heading'] ?></h2>
|
||||
<ul>
|
||||
<?php foreach ($repData[$col['dataKey']] as $item):
|
||||
repFilterEntry($item, $col['dim'], $activeSets, $anyActive, $colHasMatches[$col['dim']], $hx);
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
<main class="apropos-main" id="main-content">
|
||||
<div class="prose apropos-single">
|
||||
<?php if (!empty(trim($content))): ?>
|
||||
<?= $html ?>
|
||||
<?php else: ?>
|
||||
<p>Contenu à venir.</p>
|
||||
<div class="apropos-layout">
|
||||
|
||||
<!-- LEFT: sticky table of contents -->
|
||||
<?php if (!empty($tocItems)): ?>
|
||||
<nav class="apropos-toc" aria-label="Sections de la page">
|
||||
<p class="apropos-toc-label">Parties</p>
|
||||
<ul>
|
||||
<?php foreach ($tocItems as $item): ?>
|
||||
<li><a href="<?= htmlspecialchars($item['href']) ?>"><?= htmlspecialchars($item['label']) ?></a></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- MIDDLE: main prose -->
|
||||
<div class="apropos-content">
|
||||
<section class="apropos-section">
|
||||
<div class="prose">
|
||||
<?php if (!empty(trim($content))): ?>
|
||||
<?= $html ?>
|
||||
<?php else: ?>
|
||||
<p>Contenu à venir.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
<main class="apropos-main" id="main-content">
|
||||
<div class="prose apropos-single">
|
||||
<?php if (!empty(trim($content))): ?>
|
||||
<?= $html ?>
|
||||
<?php else: ?>
|
||||
<p>Contenu à venir.</p>
|
||||
<div class="apropos-layout">
|
||||
|
||||
<!-- LEFT: sticky table of contents -->
|
||||
<?php if (!empty($tocItems)): ?>
|
||||
<nav class="apropos-toc" aria-label="Sections de la page">
|
||||
<p class="apropos-toc-label">Parties</p>
|
||||
<ul>
|
||||
<?php foreach ($tocItems as $item): ?>
|
||||
<li><a href="<?= htmlspecialchars($item['href']) ?>"><?= htmlspecialchars($item['label']) ?></a></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- MIDDLE: main prose -->
|
||||
<div class="apropos-content">
|
||||
<section class="apropos-section">
|
||||
<div class="prose">
|
||||
<?php if (!empty(trim($content))): ?>
|
||||
<?= $html ?>
|
||||
<?php else: ?>
|
||||
<p>Contenu à venir.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -17,18 +17,6 @@
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="search-filter-label" for="filter-orientation">Orientation
|
||||
<select class="search-filter-select" name="orientation" id="filter-orientation">
|
||||
<option value="">Toutes</option>
|
||||
<?php foreach ($orientations as $o): ?>
|
||||
<option value="<?= htmlspecialchars($o['name']) ?>"
|
||||
<?= (isset($_GET['orientation']) && $_GET['orientation'] == $o['name']) ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($o['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="search-filter-label" for="filter-ap">AP
|
||||
<select class="search-filter-select" name="ap_program" id="filter-ap">
|
||||
<option value="">Tous</option>
|
||||
@@ -41,6 +29,18 @@
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="search-filter-label" for="filter-orientation">Orientation
|
||||
<select class="search-filter-select" name="orientation" id="filter-orientation">
|
||||
<option value="">Toutes</option>
|
||||
<?php foreach ($orientations as $o): ?>
|
||||
<option value="<?= htmlspecialchars($o['name']) ?>"
|
||||
<?= (isset($_GET['orientation']) && $_GET['orientation'] == $o['name']) ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($o['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="search-filter-label" for="filter-finality">Finalité
|
||||
<select class="search-filter-select" name="finality" id="filter-finality">
|
||||
<option value="">Toutes</option>
|
||||
|
||||
@@ -46,6 +46,27 @@
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($data["duration_value"]) && !empty($data["duration_unit"])): ?>
|
||||
<?php
|
||||
$_dVal = (float)$data["duration_value"];
|
||||
$_dUnit = $data["duration_unit"];
|
||||
$_unitLabels = [
|
||||
'pages' => 'pages',
|
||||
'minutes' => 'minutes',
|
||||
'sec' => 'secondes',
|
||||
'heures' => 'heures',
|
||||
'mo' => 'Mo',
|
||||
];
|
||||
$_label = $_unitLabels[$_dUnit] ?? $_dUnit;
|
||||
// if float, show 0.1 or .0 as needed
|
||||
$_display = ($_dVal == (int)$_dVal) ? (int)$_dVal : $_dVal;
|
||||
?>
|
||||
<p class="tfe-meta-item">
|
||||
<span class="tfe-meta-label">Durée :</span>
|
||||
<?= $_display ?> <?= htmlspecialchars($_label) ?>
|
||||
</p>
|
||||
<?php unset($_unitLabels, $_dVal, $_dUnit, $_label, $_display); endif; ?>
|
||||
|
||||
<?php if (!empty($data["languages"])): ?>
|
||||
<p class="tfe-meta-item">
|
||||
<span class="tfe-meta-label">Langue :</span>
|
||||
|
||||
3
justfile
3
justfile
@@ -97,7 +97,8 @@ deploy-deps:
|
||||
composer install --no-dev --no-interaction --optimize-autoloader && \
|
||||
sha256sum composer.lock | cut -d" " -f1 > vendor/.composer-lock-checksum; \
|
||||
else \
|
||||
echo "→ composer.lock unchanged, skipping composer install"; \
|
||||
echo "→ composer.lock unchanged, dumping autoloader (new classes may exist)…"; \
|
||||
composer dump-autoload --optimize --no-interaction; \
|
||||
fi'
|
||||
|
||||
[group('deploy')]
|
||||
|
||||
@@ -8,7 +8,6 @@ This directory contains nginx configuration and documentation for the Post-ERG t
|
||||
- **`docs/`** - Documentation
|
||||
- `PRODUCTION_DEPLOYMENT.md` - Deployment guide
|
||||
- `QUICK_REFERENCE.md` - Command reference
|
||||
- `ADMIN_USERS.md` - User management
|
||||
- `SECURITY_HEADERS.md` - Security headers reference
|
||||
- `PHP_AUTH_LAYER.md` - Authentication layer documentation
|
||||
- `HTACCESS_TO_NGINX.md` - Apache to nginx migration notes
|
||||
@@ -33,19 +32,16 @@ The deployment script will:
|
||||
- ✅ Test and reload nginx
|
||||
- ✅ Verify PHP-FPM is running
|
||||
|
||||
### Manage admin users
|
||||
### Manage admin password
|
||||
|
||||
```bash
|
||||
just manage-admin-users
|
||||
ssh xamxam "sudo bash /tmp/manage-admin-users.sh"
|
||||
```
|
||||
The admin password is managed via the admin panel at `/admin/parametres` → Account tab.
|
||||
|
||||
## 🔒 Security Features
|
||||
|
||||
### Admin Panel Protection
|
||||
- **Password required** for `/admin/`
|
||||
- HTTP Basic Authentication
|
||||
- Rate limited: 10 requests/minute
|
||||
- **Password required** for `/admin/` (password-only, no username)
|
||||
- PHP session-based authentication (`AdminAuth`)
|
||||
- Rate limited: 300 req/min, burst=30
|
||||
|
||||
### File Access Protection
|
||||
- Database files (`.db`) - **BLOCKED**
|
||||
@@ -59,7 +55,7 @@ ssh xamxam "sudo bash /tmp/manage-admin-users.sh"
|
||||
### Rate Limiting
|
||||
- General requests: 30/minute
|
||||
- Search endpoint: 30/minute
|
||||
- Admin panel: 10/minute
|
||||
- Admin panel: 300 req/min (burst=30)
|
||||
|
||||
### Security Headers
|
||||
- ✅ X-Frame-Options (clickjacking protection)
|
||||
@@ -72,7 +68,6 @@ ssh xamxam "sudo bash /tmp/manage-admin-users.sh"
|
||||
|
||||
- **[docs/PRODUCTION_DEPLOYMENT.md](docs/PRODUCTION_DEPLOYMENT.md)** - Complete deployment guide
|
||||
- **[docs/QUICK_REFERENCE.md](docs/QUICK_REFERENCE.md)** - Command reference and troubleshooting
|
||||
- **[docs/ADMIN_USERS.md](docs/ADMIN_USERS.md)** - Admin user management
|
||||
- **[docs/SECURITY_HEADERS.md](docs/SECURITY_HEADERS.md)** - Security headers reference
|
||||
|
||||
## 🧪 Testing
|
||||
@@ -90,11 +85,6 @@ curl -I https://xamxam.erg.be/ | grep -E "X-|Strict-Transport"
|
||||
|
||||
## 🆘 Quick Help
|
||||
|
||||
### Admin can't log in
|
||||
```bash
|
||||
sudo htpasswd /etc/nginx/.htpasswd-xamxam admin
|
||||
```
|
||||
|
||||
### 502 Bad Gateway
|
||||
```bash
|
||||
sudo systemctl status php8.4-fpm
|
||||
|
||||
@@ -26,10 +26,7 @@ sudo bash /tmp/deploy-server.sh
|
||||
|
||||
### 3. Set admin password (first time only)
|
||||
|
||||
```bash
|
||||
just manage-admin-users
|
||||
ssh xamxam "sudo bash /tmp/manage-admin-users.sh"
|
||||
```
|
||||
Visit `/admin/parametres` → Account tab and set the admin password there.
|
||||
|
||||
## Manual Setup Steps
|
||||
|
||||
@@ -37,15 +34,16 @@ ssh xamxam "sudo bash /tmp/manage-admin-users.sh"
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install nginx apache2-utils php8.4-fpm
|
||||
sudo apt install nginx php8.4-fpm php8.4-curl php8.4-sqlite3
|
||||
```
|
||||
|
||||
### 2. Create Admin Password
|
||||
### 2. Set admin password
|
||||
|
||||
Visit `/admin/parametres` → Account tab in the admin panel to set the password.
|
||||
|
||||
Or generate a hash and insert it directly:
|
||||
```bash
|
||||
just manage-admin-users
|
||||
# Then on the server:
|
||||
ssh xamxam "sudo bash /tmp/manage-admin-users.sh"
|
||||
php -r "echo password_hash('your-secret-password', PASSWORD_BCRYPT);"
|
||||
```
|
||||
|
||||
### 3. Copy Nginx Configuration
|
||||
@@ -69,11 +67,8 @@ sudo systemctl status nginx
|
||||
### Test Admin Authentication
|
||||
|
||||
```bash
|
||||
# Should return 401
|
||||
# Should redirect to login page (302)
|
||||
curl -I https://xamxam.erg.be/admin/
|
||||
|
||||
# With credentials
|
||||
curl -u admin:password https://xamxam.erg.be/admin/
|
||||
```
|
||||
|
||||
### Test File Protection
|
||||
@@ -92,12 +87,6 @@ curl -I https://xamxam.erg.be/ | grep -E "X-|Strict-Transport"
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 403 Forbidden on admin
|
||||
```bash
|
||||
sudo ls -l /etc/nginx/.htpasswd-xamxam
|
||||
sudo chmod 644 /etc/nginx/.htpasswd-xamxam
|
||||
```
|
||||
|
||||
### 502 Bad Gateway
|
||||
```bash
|
||||
sudo systemctl status php8.4-fpm
|
||||
@@ -112,8 +101,10 @@ sudo nginx -t
|
||||
## Maintenance
|
||||
|
||||
### Change Admin Password
|
||||
|
||||
Visit `/admin/parametres` → Account tab or generate a new hash:
|
||||
```bash
|
||||
sudo htpasswd /etc/nginx/.htpasswd-xamxam admin
|
||||
php -r "echo password_hash('new-password', PASSWORD_BCRYPT);"
|
||||
```
|
||||
|
||||
### Reload Configuration
|
||||
@@ -125,4 +116,4 @@ sudo nginx -t && sudo systemctl reload nginx
|
||||
|
||||
- **[docs/PRODUCTION_DEPLOYMENT.md](docs/PRODUCTION_DEPLOYMENT.md)** - Detailed deployment
|
||||
- **[docs/QUICK_REFERENCE.md](docs/QUICK_REFERENCE.md)** - Command reference
|
||||
- **[docs/ADMIN_USERS.md](docs/ADMIN_USERS.md)** - User management
|
||||
- **[docs/PHP_AUTH_LAYER.md](docs/PHP_AUTH_LAYER.md)** - Auth layer documentation
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
# Managing Admin Users - Post-ERG
|
||||
|
||||
Quick guide to manage admin users for the Post-ERG admin panel.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quick Commands
|
||||
|
||||
### Interactive Menu (Recommended)
|
||||
|
||||
```bash
|
||||
# From your local machine
|
||||
just manage-admin-users
|
||||
|
||||
# Then on the server
|
||||
ssh xamxam
|
||||
sudo bash /tmp/manage-admin-users.sh
|
||||
```
|
||||
|
||||
This gives you an interactive menu to:
|
||||
1. List all users
|
||||
2. Add new user
|
||||
3. Change user password
|
||||
4. Delete user
|
||||
5. Reset all (start fresh)
|
||||
|
||||
---
|
||||
|
||||
## 📝 Manual Commands
|
||||
|
||||
### List Current Users
|
||||
|
||||
```bash
|
||||
ssh xamxam
|
||||
sudo cut -d: -f1 /etc/nginx/.htpasswd-xamxam
|
||||
```
|
||||
|
||||
### Change Password for Existing User
|
||||
|
||||
```bash
|
||||
ssh xamxam
|
||||
sudo htpasswd /etc/nginx/.htpasswd-xamxam username_here
|
||||
```
|
||||
|
||||
You'll be prompted to enter the new password twice.
|
||||
|
||||
### Add New User
|
||||
|
||||
```bash
|
||||
ssh xamxam
|
||||
sudo htpasswd /etc/nginx/.htpasswd-xamxam new_username
|
||||
```
|
||||
|
||||
### Delete User
|
||||
|
||||
```bash
|
||||
ssh xamxam
|
||||
sudo htpasswd -D /etc/nginx/.htpasswd-xamxam username_to_delete
|
||||
```
|
||||
|
||||
### Reset Everything (Start Fresh)
|
||||
|
||||
```bash
|
||||
ssh xamxam
|
||||
sudo htpasswd -c /etc/nginx/.htpasswd-xamxam new_username
|
||||
```
|
||||
|
||||
⚠️ **Warning:** The `-c` flag creates a new file, deleting all existing users!
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deploy Management Script
|
||||
|
||||
To upload the interactive management script to the server:
|
||||
|
||||
```bash
|
||||
# From your local machine
|
||||
just manage-admin-users
|
||||
|
||||
# Or manually:
|
||||
rsync -v scripts/manage-admin-users.sh xamxam:/tmp/manage-admin-users.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Current Setup
|
||||
|
||||
After deployment, your admin panel has:
|
||||
- **URL:** https://xamxam.erg.be/admin/
|
||||
- **Current user:** `test_posterg_22@`
|
||||
- **Password:** Set during initial deployment
|
||||
|
||||
---
|
||||
|
||||
## 💡 Common Scenarios
|
||||
|
||||
### Scenario 1: Change Current Password
|
||||
|
||||
```bash
|
||||
ssh xamxam
|
||||
sudo htpasswd /etc/nginx/.htpasswd-xamxam test_posterg_22@
|
||||
# Enter new password when prompted
|
||||
```
|
||||
|
||||
### Scenario 2: Change Username
|
||||
|
||||
Since you can't rename users, you need to:
|
||||
|
||||
```bash
|
||||
ssh xamxam
|
||||
# Add new user
|
||||
sudo htpasswd /etc/nginx/.htpasswd-xamxam new_username
|
||||
# Delete old user
|
||||
sudo htpasswd -D /etc/nginx/.htpasswd-xamxam test_posterg_22@
|
||||
```
|
||||
|
||||
### Scenario 3: Forgot Username
|
||||
|
||||
```bash
|
||||
ssh xamxam
|
||||
sudo cut -d: -f1 /etc/nginx/.htpasswd-xamxam
|
||||
```
|
||||
|
||||
### Scenario 4: Multiple Admins
|
||||
|
||||
```bash
|
||||
ssh xamxam
|
||||
# Add second admin
|
||||
sudo htpasswd /etc/nginx/.htpasswd-xamxam admin2
|
||||
# Add third admin
|
||||
sudo htpasswd /etc/nginx/.htpasswd-xamxam admin3
|
||||
```
|
||||
|
||||
All users can log into `/admin/` with their own credentials.
|
||||
|
||||
### Scenario 5: Start Over with New Username
|
||||
|
||||
```bash
|
||||
ssh xamxam
|
||||
# This will DELETE ALL existing users and create a new one
|
||||
sudo htpasswd -c /etc/nginx/.htpasswd-xamxam new_admin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
After changing users/passwords:
|
||||
|
||||
```bash
|
||||
# Test that password is required
|
||||
curl -I https://xamxam.erg.be/admin/
|
||||
# Should return: 401 Unauthorized
|
||||
|
||||
# Test with credentials
|
||||
curl -u username:password https://xamxam.erg.be/admin/
|
||||
# Should return: 200 OK
|
||||
```
|
||||
|
||||
No nginx reload needed - changes take effect immediately!
|
||||
|
||||
---
|
||||
|
||||
## 📊 Password File Details
|
||||
|
||||
**Location:** `/etc/nginx/.htpasswd-xamxam`
|
||||
|
||||
**Format:** Standard Apache htpasswd format
|
||||
```
|
||||
username:$apr1$encrypted_password_hash
|
||||
```
|
||||
|
||||
**Permissions:**
|
||||
```bash
|
||||
-rw-r--r-- root root /etc/nginx/.htpasswd-xamxam
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Tips
|
||||
|
||||
1. **Use Strong Passwords**
|
||||
```bash
|
||||
# Generate a strong password
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
2. **Avoid Common Usernames**
|
||||
- ❌ Bad: `admin`, `administrator`, `root`
|
||||
- ✅ Good: `xamxam_admin`, `erg_webmaster`
|
||||
|
||||
3. **Regular Password Changes**
|
||||
- Change passwords every 3-6 months
|
||||
- Change immediately if compromised
|
||||
|
||||
4. **Monitor Access**
|
||||
```bash
|
||||
# Check who's accessing the admin panel
|
||||
ssh xamxam
|
||||
sudo grep "admin" /var/log/nginx/xamxam_access.log
|
||||
```
|
||||
|
||||
5. **Backup Password File**
|
||||
```bash
|
||||
ssh xamxam
|
||||
sudo cp /etc/nginx/.htpasswd-xamxam /etc/nginx/.htpasswd-xamxam.backup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Troubleshooting
|
||||
|
||||
### "401 Unauthorized" even with correct password
|
||||
|
||||
**Check file exists:**
|
||||
```bash
|
||||
ssh xamxam
|
||||
ls -la /etc/nginx/.htpasswd-xamxam
|
||||
```
|
||||
|
||||
**Verify user exists:**
|
||||
```bash
|
||||
sudo cat /etc/nginx/.htpasswd-xamxam
|
||||
```
|
||||
|
||||
**Check nginx config:**
|
||||
```bash
|
||||
sudo grep -A 5 "auth_basic" /etc/nginx/sites-available/xamxam
|
||||
```
|
||||
|
||||
### Can't change password - "command not found"
|
||||
|
||||
**Install apache2-utils:**
|
||||
```bash
|
||||
ssh xamxam
|
||||
sudo apt update
|
||||
sudo apt install apache2-utils
|
||||
```
|
||||
|
||||
### Password file got deleted
|
||||
|
||||
**Recreate it:**
|
||||
```bash
|
||||
ssh xamxam
|
||||
sudo htpasswd -c /etc/nginx/.htpasswd-xamxam new_admin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 Quick Reference
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| **Interactive menu** | `sudo bash /tmp/manage-admin-users.sh` |
|
||||
| **List users** | `sudo cut -d: -f1 /etc/nginx/.htpasswd-xamxam` |
|
||||
| **Change password** | `sudo htpasswd /etc/nginx/.htpasswd-xamxam username` |
|
||||
| **Add user** | `sudo htpasswd /etc/nginx/.htpasswd-xamxam newuser` |
|
||||
| **Delete user** | `sudo htpasswd -D /etc/nginx/.htpasswd-xamxam username` |
|
||||
| **Reset all** | `sudo htpasswd -c /etc/nginx/.htpasswd-xamxam newuser` |
|
||||
| **Generate password** | `openssl rand -base64 32` |
|
||||
|
||||
---
|
||||
|
||||
## ✅ After Making Changes
|
||||
|
||||
No action needed! Changes to the password file take effect immediately.
|
||||
|
||||
You can verify with:
|
||||
```bash
|
||||
curl -u username:password https://xamxam.erg.be/admin/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Remember:** Store passwords securely using a password manager! 🔐
|
||||
@@ -6,53 +6,32 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The admin panel uses **two independent authentication layers** with a single UX prompt:
|
||||
The admin panel uses a single **PHP session-based authentication** layer.
|
||||
Authentication is password-only (no username required).
|
||||
|
||||
| Layer | Mechanism | Configured by |
|
||||
|-------|-----------|---------------|
|
||||
| **1st** | nginx HTTP Basic Auth | `/etc/nginx/.htpasswd-xamxam` (see `ADMIN_USERS.md`) |
|
||||
| **2nd** | PHP session guard (`src/AdminAuth.php`) | `config/admin_credentials.php` |
|
||||
| **PHP** | Session guard (`src/AdminAuth.php`) | `site_settings.admin_password_hash` in DB |
|
||||
|
||||
The user only sees **one prompt** (the browser Basic Auth dialog). PHP reads the
|
||||
same password from `$_SERVER['PHP_AUTH_PW']` and validates it independently with
|
||||
`password_verify`. On success it creates a session so subsequent requests skip
|
||||
the `password_verify` call.
|
||||
|
||||
---
|
||||
|
||||
## Why two layers?
|
||||
|
||||
nginx Basic Auth alone is a **single point of failure**:
|
||||
|
||||
- Reverse-proxy misconfiguration could expose admin routes directly.
|
||||
- Local development without the proxy leaves admin unprotected.
|
||||
- A misconfigured `auth_basic off` block (e.g., in a nested location) could bypass it.
|
||||
|
||||
The PHP session guard (`AdminAuth::requireLogin()`) is ~100 lines of PHP stdlib
|
||||
(`password_verify` + `session_regenerate_id`) with negligible attack surface.
|
||||
The user sees an HTML login form at `/admin/login.php` that asks only for a
|
||||
password. On successful login, a PHP session is created and all admin pages
|
||||
use `AdminAuth::requireLogin()` to enforce the guard.
|
||||
|
||||
## Authentication flow
|
||||
|
||||
```
|
||||
Browser → nginx Basic Auth dialog (username + password)
|
||||
Browser → /admin/login.php (HTML password-only form)
|
||||
│
|
||||
▼
|
||||
nginx validates against .htpasswd ──✗──▶ 401
|
||||
│ ✓
|
||||
▼
|
||||
PHP: AdminAuth::requireLogin()
|
||||
├─ session already live? ──✓──▶ proceed
|
||||
├─ $_SERVER['PHP_AUTH_PW'] set?
|
||||
│ └─ password_verify(PHP_AUTH_PW, ADMIN_PASSWORD_HASH)
|
||||
│ ├─ ✓ → create session → proceed (normal path)
|
||||
│ └─ ✗ → redirect to login form
|
||||
└─ neither → redirect to login form (proxy bypass)
|
||||
POST password → AdminAuth::login()
|
||||
├─ password_verify(password, stored_hash)
|
||||
│ ├─ ✓ → create session → redirect to /admin/
|
||||
│ └─ ✗ → show error, stay on login form
|
||||
└─
|
||||
```
|
||||
|
||||
The login form (`/admin/login.php`) is a **fallback** for when the reverse proxy
|
||||
is absent. In normal production use the user never sees it.
|
||||
|
||||
---
|
||||
If no password hash is stored in the DB (dev / cli-server), `AdminAuth`
|
||||
is a no-op — all admin pages are open.
|
||||
|
||||
## PHP auth setup (production)
|
||||
|
||||
@@ -61,19 +40,13 @@ is absent. In normal production use the user never sees it.
|
||||
php -r "echo password_hash('your-secret-password', PASSWORD_DEFAULT);"
|
||||
```
|
||||
|
||||
2. Create `config/admin_credentials.php` (outside the webroot, never committed):
|
||||
```php
|
||||
<?php
|
||||
define('ADMIN_PASSWORD_HASH', '$2y$12$<paste-hash-here>');
|
||||
2. Store it in the DB via the admin panel at `/admin/parametres` (Account tab)
|
||||
or by inserting directly:
|
||||
```sql
|
||||
INSERT INTO site_settings (key, value) VALUES ('admin_password_hash', '$2y$12$...')
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
|
||||
```
|
||||
|
||||
3. The `bootstrap.php` auto-loads this file if it exists.
|
||||
|
||||
If `ADMIN_PASSWORD_HASH` is not defined (development / cli-server), the PHP
|
||||
auth layer is a **no-op** — nginx Basic Auth remains the sole guard.
|
||||
|
||||
---
|
||||
|
||||
## Session cookie hardening (TODO item #8)
|
||||
|
||||
`AdminAuth::startSession()` calls `session_set_cookie_params()` before
|
||||
@@ -87,28 +60,15 @@ auth layer is a **no-op** — nginx Basic Auth remains the sole guard.
|
||||
| `Path` | `/admin` |
|
||||
| `Lifetime` | `0` (session cookie, expires on browser close) |
|
||||
|
||||
This replaces all direct `session_start()` calls in admin PHP files.
|
||||
|
||||
---
|
||||
|
||||
## Logout
|
||||
|
||||
A **Déconnexion** button is shown in the admin nav when `ADMIN_PASSWORD_HASH`
|
||||
is defined. It hits `/admin/logout.php` which destroys the PHP session.
|
||||
nginx Basic Auth invalidation requires closing the browser tab / window.
|
||||
A **Déconnexion** button is shown in the admin nav when a password hash is
|
||||
configured. It hits `/admin/logout.php` which destroys the PHP session.
|
||||
|
||||
---
|
||||
## Files
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/AdminAuth.php` | New — auth guard class |
|
||||
| `config/admin_credentials.php` | New — credential store (gitignored) |
|
||||
| `config/admin_credentials.example.php` | New — example / template |
|
||||
| `config/bootstrap.php` | Load credentials on startup |
|
||||
| `public/admin/*.php` | Replace `session_start()` with `AdminAuth::requireLogin()` |
|
||||
| `public/admin/actions/*.php` | Same |
|
||||
| `public/admin/login.php` | New — login form |
|
||||
| `public/admin/logout.php` | New — logout handler |
|
||||
| `public/admin/inc/head.php` | Logout button in nav |
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/AdminAuth.php` | Auth guard class |
|
||||
| `public/admin/login.php` | Login form (password-only) |
|
||||
| `public/admin/logout.php` | Logout handler |
|
||||
|
||||
@@ -37,8 +37,8 @@ server {
|
||||
|
||||
# Security headers
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload;" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
@@ -115,12 +115,8 @@ server {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Admin panel - password protected
|
||||
# Admin panel - password protected at the PHP layer (AdminAuth)
|
||||
location ^~ /admin/ {
|
||||
# HTTP Basic Authentication (first layer)
|
||||
auth_basic "Admin Access - XAMXAM";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd-xamxam;
|
||||
|
||||
# Rate limiting for admin
|
||||
# 300r/m rate + burst=30 allows all concurrent HTMX fragments (up to ~12
|
||||
# on contenus.php) while still capping brute-force at 5 req/s sustained.
|
||||
@@ -129,12 +125,13 @@ server {
|
||||
# Content-Security-Policy - Admin policy
|
||||
# script-src needs 'unsafe-inline' for the OverType editor init block
|
||||
# and the live-reload poller (dev only). Admin is already auth-gated.
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';" always;
|
||||
# 'unsafe-eval' is required by htmx (uses Function() internally).
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';" always;
|
||||
|
||||
# Disable directory listing
|
||||
autoindex off;
|
||||
|
||||
# PHP handling for admin (AdminAuth provides second layer)
|
||||
# PHP handling for admin (AdminAuth provides auth layer)
|
||||
location ~ \.php$ {
|
||||
include snippets/fastcgi-php.conf;
|
||||
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
|
||||
@@ -168,8 +165,24 @@ server {
|
||||
}
|
||||
|
||||
# /media — served by front controller (MediaController validates + streams)
|
||||
# Override frame-ancestors to 'self' so Firefox's built-in PDF viewer
|
||||
# can display PDFs inline (Firefox uses an internal iframe for PDF.js).
|
||||
# Direct fastcgi_pass to /index.php (no try_files) so add_header survives —
|
||||
# try_files triggers an internal redirect to location = /index.php, which
|
||||
# loses this CSP override and inherits the server-block frame-ancestors 'none'.
|
||||
location = /media {
|
||||
try_files $uri /index.php$is_args$args;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'self';" always;
|
||||
# Direct fastcgi_pass to /index.php (no try_files) so add_header survives.
|
||||
# We can't use snippets/fastcgi-php.conf because its try_files
|
||||
# $fastcgi_script_name =404 would fail — $fastcgi_script_name is /media
|
||||
# (no .php extension). Include fastcgi.conf directly instead.
|
||||
include fastcgi.conf;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
|
||||
fastcgi_param SCRIPT_NAME /index.php;
|
||||
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
|
||||
fastcgi_param PHP_VALUE "upload_max_filesize=8192M \n post_max_size=8192M";
|
||||
fastcgi_read_timeout 600;
|
||||
fastcgi_send_timeout 600;
|
||||
}
|
||||
|
||||
# /live-reload — served by front controller
|
||||
|
||||
@@ -113,12 +113,8 @@ server {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Admin panel - password protected
|
||||
# Admin panel - password protected at the PHP layer (AdminAuth)
|
||||
location ^~ /admin/ {
|
||||
# HTTP Basic Authentication (first layer)
|
||||
auth_basic "Admin Access - Post-ERG";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd-xamxam;
|
||||
|
||||
# Rate limiting for admin
|
||||
limit_req zone=admin burst=5 nodelay;
|
||||
|
||||
|
||||
@@ -83,21 +83,6 @@ chown -R www-data:xamxam /var/www/xamxam/storage/cache
|
||||
chmod -R 2775 /var/www/xamxam/storage/cache
|
||||
ok "Cache dirs: created and owned by www-data:xamxam"
|
||||
|
||||
# ── Step 1b: htpasswd file ──────────────────────────────────────────────────────
|
||||
printf "\n📋 Step 1b: Checking htpasswd file...\n"
|
||||
echo "--------------------------------------"
|
||||
|
||||
if [ -f "/etc/nginx/.htpasswd-xamxam" ]; then
|
||||
ok "htpasswd file exists: /etc/nginx/.htpasswd-xamxam"
|
||||
elif [ -f "/etc/nginx/.htpasswd-posterg" ]; then
|
||||
cp /etc/nginx/.htpasswd-posterg /etc/nginx/.htpasswd-xamxam
|
||||
chmod 644 /etc/nginx/.htpasswd-xamxam
|
||||
ok "Migrated .htpasswd-posterg → .htpasswd-xamxam"
|
||||
else
|
||||
warn "No htpasswd file found — admin panel will return 403 until one is created"
|
||||
warn "Run: sudo htpasswd -c /etc/nginx/.htpasswd-xamxam <username>"
|
||||
fi
|
||||
|
||||
# ── Step 2: Nginx config ──────────────────────────────────────────────────────
|
||||
printf "\n📋 Step 2: Deploying nginx configuration...\n"
|
||||
echo "--------------------------------------------"
|
||||
|
||||
95
scripts/fix-banner-path-view.php
Executable file
95
scripts/fix-banner-path-view.php
Executable file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
/**
|
||||
* One-shot repair: fix v_theses_full / v_theses_public after migration 040
|
||||
* accidentally included the already-dropped banner_path column.
|
||||
*
|
||||
* Run from project root: php scripts/fix-banner-path-view.php
|
||||
*
|
||||
* This re-executes the view portion of migration 040 (with the fix applied)
|
||||
* so the views no longer reference t.banner_path.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../bootstrap.php';
|
||||
|
||||
$db = \App\Database::getInstance();
|
||||
$pdo = $db->getPdo();
|
||||
|
||||
echo "Dropping broken views...\n";
|
||||
$pdo->exec('DROP VIEW IF EXISTS v_theses_public');
|
||||
$pdo->exec('DROP VIEW IF EXISTS v_theses_full');
|
||||
|
||||
echo "Recreating v_theses_full (without banner_path)...\n";
|
||||
$pdo->exec("
|
||||
CREATE VIEW IF NOT EXISTS v_theses_full AS
|
||||
SELECT
|
||||
t.id,
|
||||
t.identifier,
|
||||
t.title,
|
||||
t.subtitle,
|
||||
t.year,
|
||||
t.is_doctoral,
|
||||
t.objet,
|
||||
o.name as orientation,
|
||||
ap.name as ap_program,
|
||||
ft.name as finality_type,
|
||||
t.synopsis,
|
||||
t.context_note,
|
||||
t.duration_value,
|
||||
t.duration_unit,
|
||||
at.name as access_type,
|
||||
lt.name as license_type,
|
||||
t.license_id,
|
||||
t.license_custom,
|
||||
t.access_type_id,
|
||||
t.jury_points,
|
||||
t.submitted_at,
|
||||
t.defense_date,
|
||||
t.published_at,
|
||||
t.is_published,
|
||||
t.baiu_link,
|
||||
t.exemplaire_baiu,
|
||||
t.exemplaire_erg,
|
||||
t.cc2r,
|
||||
t.remarks,
|
||||
t.jury_note_added,
|
||||
t.contact_visible,
|
||||
GROUP_CONCAT(DISTINCT a.name ORDER BY a.name ASC) as authors,
|
||||
GROUP_CONCAT(DISTINCT s.name) as supervisors,
|
||||
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'president' THEN s.name END) as jury_president,
|
||||
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' AND ts.is_ulb = 0 THEN s.name END) as jury_promoteurs,
|
||||
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' AND ts.is_ulb = 1 THEN s.name END) as jury_promoteurs_ulb,
|
||||
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 0 THEN s.name END) as jury_lecteurs_internes,
|
||||
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 1 THEN s.name END) as jury_lecteurs_externes,
|
||||
GROUP_CONCAT(DISTINCT l.name) as languages,
|
||||
GROUP_CONCAT(DISTINCT fmt.name) as formats,
|
||||
GROUP_CONCAT(DISTINCT tg.name) as keywords,
|
||||
-- First author's email and contact-visibility flag
|
||||
(SELECT a2.email FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as author_email,
|
||||
(SELECT a2.show_contact FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as author_show_contact
|
||||
FROM theses t
|
||||
LEFT JOIN orientations o ON t.orientation_id = o.id
|
||||
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
|
||||
LEFT JOIN finality_types ft ON t.finality_id = ft.id
|
||||
LEFT JOIN access_types at ON t.access_type_id = at.id
|
||||
LEFT JOIN license_types lt ON t.license_id = lt.id
|
||||
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
|
||||
LEFT JOIN authors a ON ta.author_id = a.id
|
||||
LEFT JOIN thesis_supervisors ts ON t.id = ts.thesis_id
|
||||
LEFT JOIN supervisors s ON ts.supervisor_id = s.id
|
||||
LEFT JOIN thesis_languages tl ON t.id = tl.thesis_id
|
||||
LEFT JOIN languages l ON tl.language_id = l.id
|
||||
LEFT JOIN thesis_formats tf ON t.id = tf.thesis_id
|
||||
LEFT JOIN format_types fmt ON tf.format_id = fmt.id
|
||||
LEFT JOIN thesis_tags tt ON t.id = tt.thesis_id
|
||||
LEFT JOIN tags tg ON tt.tag_id = tg.id
|
||||
GROUP BY t.id;
|
||||
");
|
||||
|
||||
echo "Recreating v_theses_public...\n";
|
||||
$pdo->exec("
|
||||
CREATE VIEW IF NOT EXISTS v_theses_public AS
|
||||
SELECT * FROM v_theses_full
|
||||
WHERE is_published = 1
|
||||
");
|
||||
|
||||
echo "Done. Views recreated without banner_path.\n";
|
||||
@@ -1,199 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Manage admin users for XAMXAM nginx basic authentication
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
PASSWORD_FILE="/etc/nginx/.htpasswd-xamxam"
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Error: This script must be run as root (use sudo)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if htpasswd is available
|
||||
if ! command -v htpasswd &> /dev/null; then
|
||||
echo -e "${YELLOW}Installing apache2-utils...${NC}"
|
||||
apt-get update -qq
|
||||
apt-get install -y apache2-utils
|
||||
fi
|
||||
|
||||
show_menu() {
|
||||
echo ""
|
||||
echo -e "${BLUE}════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE} XAMXAM Admin User Management${NC}"
|
||||
echo -e "${BLUE}════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo "1. List all users"
|
||||
echo "2. Add new user"
|
||||
echo "3. Change user password"
|
||||
echo "4. Delete user"
|
||||
echo "5. Reset all (create new password file)"
|
||||
echo "6. Exit"
|
||||
echo ""
|
||||
echo -n "Choose an option [1-6]: "
|
||||
}
|
||||
|
||||
list_users() {
|
||||
echo ""
|
||||
if [ ! -f "$PASSWORD_FILE" ]; then
|
||||
echo -e "${YELLOW}No password file found.${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Current admin users:${NC}"
|
||||
echo "────────────────────────"
|
||||
cut -d: -f1 "$PASSWORD_FILE" | nl
|
||||
echo ""
|
||||
}
|
||||
|
||||
add_user() {
|
||||
echo ""
|
||||
echo -n "Enter new username: "
|
||||
read -r USERNAME
|
||||
|
||||
if [ -z "$USERNAME" ]; then
|
||||
echo -e "${RED}Username cannot be empty${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
# Check if user already exists
|
||||
if [ -f "$PASSWORD_FILE" ] && grep -q "^${USERNAME}:" "$PASSWORD_FILE"; then
|
||||
echo -e "${YELLOW}User '$USERNAME' already exists. Use option 3 to change password.${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
# Add user (use -c only if file doesn't exist)
|
||||
if [ ! -f "$PASSWORD_FILE" ]; then
|
||||
htpasswd -c "$PASSWORD_FILE" "$USERNAME"
|
||||
else
|
||||
htpasswd "$PASSWORD_FILE" "$USERNAME"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ User '$USERNAME' added successfully${NC}"
|
||||
}
|
||||
|
||||
change_password() {
|
||||
list_users
|
||||
echo -n "Enter username to change password: "
|
||||
read -r USERNAME
|
||||
|
||||
if [ -z "$USERNAME" ]; then
|
||||
echo -e "${RED}Username cannot be empty${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ ! -f "$PASSWORD_FILE" ]; then
|
||||
echo -e "${RED}Password file not found${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
if ! grep -q "^${USERNAME}:" "$PASSWORD_FILE"; then
|
||||
echo -e "${RED}User '$USERNAME' not found${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
htpasswd "$PASSWORD_FILE" "$USERNAME"
|
||||
echo -e "${GREEN}✓ Password changed for user '$USERNAME'${NC}"
|
||||
}
|
||||
|
||||
delete_user() {
|
||||
list_users
|
||||
echo -n "Enter username to delete: "
|
||||
read -r USERNAME
|
||||
|
||||
if [ -z "$USERNAME" ]; then
|
||||
echo -e "${RED}Username cannot be empty${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ ! -f "$PASSWORD_FILE" ]; then
|
||||
echo -e "${RED}Password file not found${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
if ! grep -q "^${USERNAME}:" "$PASSWORD_FILE"; then
|
||||
echo -e "${RED}User '$USERNAME' not found${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
echo -n "Are you sure you want to delete user '$USERNAME'? [y/N] "
|
||||
read -r CONFIRM
|
||||
|
||||
if [ "$CONFIRM" = "y" ] || [ "$CONFIRM" = "Y" ]; then
|
||||
htpasswd -D "$PASSWORD_FILE" "$USERNAME"
|
||||
echo -e "${GREEN}✓ User '$USERNAME' deleted${NC}"
|
||||
else
|
||||
echo "Cancelled"
|
||||
fi
|
||||
}
|
||||
|
||||
reset_all() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}WARNING: This will delete ALL existing users!${NC}"
|
||||
echo -n "Are you sure? [y/N] "
|
||||
read -r CONFIRM
|
||||
|
||||
if [ "$CONFIRM" != "y" ] && [ "$CONFIRM" != "Y" ]; then
|
||||
echo "Cancelled"
|
||||
return
|
||||
fi
|
||||
|
||||
# Backup existing file
|
||||
if [ -f "$PASSWORD_FILE" ]; then
|
||||
BACKUP="${PASSWORD_FILE}.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
cp "$PASSWORD_FILE" "$BACKUP"
|
||||
echo -e "${GREEN}✓ Backed up to: $BACKUP${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -n "Enter new username: "
|
||||
read -r USERNAME
|
||||
|
||||
if [ -z "$USERNAME" ]; then
|
||||
echo -e "${RED}Username cannot be empty${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
htpasswd -c "$PASSWORD_FILE" "$USERNAME"
|
||||
echo -e "${GREEN}✓ Password file reset with user '$USERNAME'${NC}"
|
||||
}
|
||||
|
||||
# Main loop
|
||||
while true; do
|
||||
show_menu
|
||||
read -r CHOICE
|
||||
|
||||
case $CHOICE in
|
||||
1)
|
||||
list_users
|
||||
;;
|
||||
2)
|
||||
add_user
|
||||
;;
|
||||
3)
|
||||
change_password
|
||||
;;
|
||||
4)
|
||||
delete_user
|
||||
;;
|
||||
5)
|
||||
reset_all
|
||||
;;
|
||||
6)
|
||||
echo ""
|
||||
echo "Goodbye!"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Invalid option${NC}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
Reference in New Issue
Block a user