docs: keep only reference documentation, archive one-offs

This commit is contained in:
Pontoporeia
2026-09-18 16:26:36 +02:00
parent 8accb88452
commit d5c1dfb23d
27 changed files with 108 additions and 78 deletions
@@ -0,0 +1,276 @@
# Analysis: Inline JS/CSS, Minification & Compression
**Date:** 2026-06-24
**Scope:** Entire project (excluding `/vendor`, `/.jj`, `/.git`, `/coverage`, `/storage`)
---
## 1. Inline JavaScript
### 1.1 Summary
**~797 lines of inline JavaScript** spread across **17 PHP template files**. Every admin page loads multiple inline blocks. The public-facing pages also contain inline JS.
### 1.2 Detailed Inventory
| File | Lines | Can Be Externalized? | Notes |
|---|---|---|---|
| `app/templates/admin/contenus.php` | 232 | ✅ Yes | Bulk lang/tag operations: delete, merge, rename, inline rename via HTMX, dialog handlers. Two large independent blocks (langues + mots-clés) that mirror each other almost identically. |
| `app/templates/public/repertoire.php` | 101 | ✅ Yes | Student popover: positioning logic, HTML fetch/prefill. Self-contained component. |
| `app/templates/admin/tags.php` | 76 | ✅ Yes | Bulk merge, inline rename, delete confirm. Mirrors the mots-clés block in `contenus.php` — same logic duplicated. |
| `app/templates/admin/apropos-groups-form.php` | 68 | ✅ Yes | Dynamic form fields: add/remove contact groups and entries, reindex. Reads template from `<template>` element. Could be a reusable module. |
| `app/templates/admin/acces.php` | 59 | ⚠️ Mostly | Some JS is straightforward (dialogs, clipboard). But 4 lines pass PHP vars into JS globals (`_newLinkPassword`, `_newLinkSlug`) → needs a pattern like `<meta>` tags or `data-*` attributes or a tiny inline bootstrap. |
| `app/templates/partials/form/jury-fieldset.php` | 42 | ✅ Yes | Dynamic jury member rows: add/remove, autocomplete init, load state restore. |
| `app/templates/partials/form/form.php` | 41 | ✅ Yes | Duration unit toggle (pages/mo/durée → time fields), flash warning scroll. |
| `app/templates/admin/contenus-edit.php` | 36 | ✅ Yes | Sidebar link add/remove rows, reindex. Simple list manipulation. |
| `app/templates/admin/partials/admin-toc.php` | 35 | ✅ Yes | IntersectionObserver for sticky TOC highlighting. Pure JS, no PHP dependency. |
| `app/templates/admin/acces-etudiante.php` | 29 | ✅ Yes | Dialog openers, clipboard copy, password dialog. Standard UI helpers. |
| `app/templates/admin/footer.php` | 27 | ⚠️ Mixed | HTMX global event listeners (sendError, beforeSend, afterSettle) + MD cheatsheet dialog handling. The HTMX debug logging is dev-only and should probably be conditional or removed in production. |
| `app/templates/partials/form/language-search.php` | 17 | ✅ Yes | Language pill input: search, select, remove. Self-contained interactive widget. |
| `app/templates/admin/index.php` | 10 | ✅ Yes | Bulk selection toggle, updateBulk, confirmBulk. Already inlined but tiny. |
| `app/templates/admin/parametres.php` | 10 | ✅ Yes | SMTP error field focus on load + sys-status collapse toggle. |
| `app/templates/admin/file-access.php` | 8 | ✅ Yes | Dialog openers for approve/reject. Trivial. |
| `app/templates/admin/index-table.php` | 1 | ✅ Yes | One-liner re-attaching change listeners after HTMX swap. |
| `app/templates/head.php` | 5 | ❌ Must stay inline | Live-reload poller (dev only). Already gated behind `php_sapi_name() === 'cli-server'`. |
### 1.3 Key Observations
1. **Duplication**: The mots-clés bulk logic in `contenus.php` (~130 lines) is a near-identical copy of the tags bulk logic in `tags.php` (~76 lines) and the langues bulk logic in the same `contenus.php` (~130 lines). Three copies of the same pattern.
2. **PHP-in-JS coupling**: `acces.php` injects PHP values (`$baseUrl`, `$newLinkPassword`, `$newLinkSlug`) directly into JS globals. This is fragile. Alternatives: `<meta>` tags, `data-*` attributes, or a JSON blob in a `<script type="application/json">`.
3. **No build step**: There is no bundler, no minification, no tree-shaking. The `biome.json` config only handles formatting/linting of JS (not CSS).
4. **Dev-only code in production**: `footer.php` has `console.log` calls in HTMX event handlers, and `head.php` has the live-reload poller (gated, but still present in templates served in production). The `footer.php` console.log calls are unconditional.
---
## 2. Inline CSS
### 2.1 Summary
**4 locations**, all in standalone error/maintenance pages that are served without the main CSS pipeline:
| File | Line count | Purpose |
|---|---|---|
| `app/public/maintenance.php` | ~20 lines (inline in `<style>`) | 503 Maintenance page — dark minimal style |
| `app/public/validate-access.php` | ~16 lines (2 blocks) | Access token validation page + error page |
| `app/src/Controllers/SearchController.php` | ~15 lines (in PHP heredoc) | Rate-limit error page (429) |
### 2.2 Assessment
These are **acceptable** as inline styles:
- Each page is a standalone error/status page that must render correctly **without** the main CSS pipeline (no `style.css`, no external deps).
- Each is ~15-20 lines, fully self-contained, and has zero overlap with the main design system.
- Moving them to external files would add an extra HTTP request for pages almost nobody sees, without benefit.
**Recommendation**: Keep as-is. These are the correct use case for inline CSS.
### 2.3 Edge Case: `SearchController.php`
The rate-limit 429 page is rendered as a PHP heredoc inside a controller method. This could be extracted to a template file (`app/templates/error/rate-limit.php`) for consistency, but functionally it's fine.
---
## 3. CSS Architecture & Minification
### 3.1 Current Setup
```
style.css (27 lines, @import-only)
├── reset.css, colors.css, typography.css, base.css
├── components/{links,focus,forms,tables,dialog,details,media,buttons,badges,toast,pagination,header,search}.css
└── utilities.css
+ admin.css (loaded in admin via $extraCss)
+ form-base.css (loaded on form pages)
+ form-admin.css
+ public.css, repertoire.css, tfe.css, content-page.css, system.css, file-access.css
+ filepond.min.css + plugin (vendor, already minified)
+ modern-normalize.min.css (vendor, already minified)
```
**Total CSS (excluding vendor minified): ~6,200 lines across 18 files**, served as 2-4 requests per page (style.css via `@import` + page-specific files via `<link>`).
### 3.2 Problems
1. **`@import` chains block rendering**: `style.css` uses 17 `@import` statements. Browsers download `style.css`, discover the imports, then fetch each imported file sequentially. This is the worst way to load CSS for performance — it creates a waterfall. `@import` is essentially deprecated for production use.
2. **No minification**: Custom CSS files are served uncompressed. The `@import`-based structure makes them hard to bundle or minify automatically.
3. **No cache-busting on CSS**: The `App::assetV()` helper adds version query strings for JS files, but the same mechanism handles CSS. Need to verify it's consistently applied (appears to be, via `$extraCss` pattern).
### 3.3 Recommendation: Bundle + Minify
Create a build step that:
1. Concatenates all CSS files into a single bundle (one for public, one for admin, one for forms).
2. Minifies the result (CSSNano, LightningCSS, or even a simple PHP script).
3. Replaces all `@import` with actual concatenation.
4. The `@import` approach was a good dev ergonomics choice but should be resolved at build time, not at request time.
---
## 4. JavaScript Architecture & Minification
### 4.1 Current Setup
**Custom JS: ~1,763 lines across 9 files** (all in `app/public/assets/js/app/`):
| File | Lines | Purpose |
|---|---|---|
| `file-upload-filepond.js` | 1,057 | FilePond init/config for admin + partage |
| `pill-search.js` | 197 | Language/tag pill input widget |
| `jury-autocomplete.js` | 152 | Jury member autocomplete |
| `access-request.js` | 101 | File access request flow |
| `autosave-handler.js` | 79 | OverType autosave |
| `admin-logs.js` | 70 | Admin log viewer |
| `acces-password.js` | 36 | Share link password prompt |
| `beforeunload-guard.js` | 32 | Unsaved changes warning |
| `clipboard.js` | 39 | Copy-to-clipboard utility |
**Vendor JS (already minified):** htmx, FilePond + 4 plugins, OverType.
### 4.2 Problems
1. **No minification on custom JS**: All 9 app JS files are served uncompressed. Combined they're ~1,763 lines (~45 KB unminified).
2. **No bundling**: 9 separate HTTP requests for app JS on form pages (plus 6 vendor scripts = 15 total on the partage form page). Each is a round-trip.
3. **Vendor scripts already minified**: Good. No action needed there.
### 4.3 Recommendation: Minify + Optionally Bundle
At minimum: minify each app JS file individually. This is the lowest-risk change and yields most of the benefit (~40-60% size reduction on custom JS).
Optionally: bundle all app JS into one file. But this is lower priority — the 9 individual files are small and HTTP/2 multiplexing handles them fine. The bigger win is just minification.
---
## 5. Gzip / Compression
### 5.1 Current State: **NOT ENABLED**
The nginx configuration (`nginx/xamxam.conf`) has **no `gzip` directives whatsoever**. Neither does the reference config. There is no `gzip on;`, no `gzip_types`, nothing.
### 5.2 Impact
This means every CSS file (~6,200 lines uncompressed), every JS file (~1,763 lines uncompressed), every HTML page, and every API response is served **without compression**. For a text-heavy PHP application, this is the single biggest performance miss.
Typical compression ratios for text assets:
- HTML: 70-80% reduction
- CSS: 75-85% reduction
- JS: 70-80% reduction
- JSON/XML: 80-90% reduction
### 5.3 Recommendation
Add gzip to the nginx config. Brotli would be even better but requires `ngx_brotli` module (not always available). Gzip is universally supported and the default nginx module is always available.
**Recommended nginx gzip config:**
```nginx
# Compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
text/xml
image/svg+xml
application/x-font-ttf
font/opentype;
```
This should be added in the `server` block (or the `http` block of the main nginx.conf).
---
## 6. Priority Summary
| Priority | Item | Effort | Impact |
|---|---|---|---|
| 🔴 **P0** | Add gzip to nginx config | 5 min (3 lines of config) | **High** — 70-80% bandwidth reduction on all text assets |
| 🟠 **P1** | Minify custom JS files | 1-2 hours (add a build step) | **Medium** — ~40-60% JS size reduction |
| 🟠 **P1** | Bundle CSS (eliminate @import) | 2-3 hours | **Medium** — eliminates render-blocking waterfall |
| 🟡 **P2** | Extract inline JS to external files | 4-6 hours | **Low-Medium** — enables caching, CSP tightening |
| 🟡 **P2** | Remove dev-only console.log from footer.php | 5 min | **Low** — code quality |
| 🟢 **P3** | Bundle JS files (single file) | 2-3 hours | **Low** — HTTP/2 handles multiple small files fine |
| 🟢 **P3** | Deduplicate mots-clés/langues/tags JS | 2-3 hours | **Low** — maintenance benefit |
---
## 7. Detailed Extraction Plan (for P2)
If inline JS externalization is pursued, here's the recommended mapping:
### New files to create:
| New File | Content From |
|---|---|
| `assets/js/app/admin-bulk-actions.js` | `index.php` bulk selection + `index-table.php` reattach |
| `assets/js/app/admin-contenus-langues.js` | `contenus.php` langues block (delete, rename, merge, inline rename) |
| `assets/js/app/admin-contenus-motscles.js` | `contenus.php` mots-clés block (mirrors above) |
| `assets/js/app/admin-tags.js` | `tags.php` tags bulk + inline rename |
| `assets/js/app/admin-contacts-form.js` | `apropos-groups-form.php` dynamic group/entry management |
| `assets/js/app/admin-acces-sharelink.js` | `acces.php` + `acces-etudiante.php` (clipboard, dialogs, edit, archive, password) |
| `assets/js/app/admin-toc.js` | `admin-toc.php` IntersectionObserver |
| `assets/js/app/admin-file-access.js` | `file-access.php` approve/reject dialogs |
| `assets/js/app/repertoire-popover.js` | `repertoire.php` student popover |
| `assets/js/app/form-duration-toggle.js` | `form.php` duration unit toggle |
| `assets/js/app/form-jury-fields.js` | `jury-fieldset.php` dynamic jury rows |
| `assets/js/app/form-language-search.js` | `language-search.php` language pill widget |
| `assets/js/app/sidebar-links-editor.js` | `contenus-edit.php` sidebar links add/remove |
| `assets/js/app/htmx-global-setup.js` | `footer.php` HTMX event listeners |
| `assets/js/app/smtp-error-focus.js` | `parametres.php` SMTP field focus |
| `assets/js/app/sys-status-toggle.js` | `parametres.php` collapsible toggle |
### PHP-to-JS data passing
For `acces.php` which injects `_newLinkPassword` and `_newLinkSlug` into JS globals, replace with:
```html
<meta name="new-link-password" content="<?= htmlspecialchars($newLinkPassword ?? '') ?>">
<meta name="new-link-slug" content="<?= htmlspecialchars($newLinkSlug ?? '') ?>">
```
Then read from `document.querySelector('meta[name="new-link-slug"]').content` in the external JS.
---
## 8. Build Step Proposal
A minimal build step using existing infrastructure (no npm/node required):
### Option A: PHP build script (zero new dependencies)
```php
// scripts/build-assets.php
// 1. Minify JS files using a simple regex-based minifier (strip comments, whitespace)
// 2. Concatenate CSS files, replacing @import
// 3. Write to app/public/assets/dist/
```
### Option B: Justfile commands using CLI tools
```makefile
# Requires: uglifyjs or terser (npm), lightningcss (npm/cargo)
build-js:
uglifyjs app/public/assets/js/app/*.js -o app/public/assets/dist/app.min.js -c -m
build-css:
lightningcss --minify --bundle app/public/assets/css/style.css -o app/public/assets/dist/style.min.css
```
### Option C: `justfile` + Python (available on any server)
Python's `html.parser` and `re` can handle CSS concatenation + JS minification without any additional packages.
---
## 9. Security Note: CSP Implications
The current CSP allows `'unsafe-inline'` for scripts and styles:
```
script-src 'self' 'unsafe-inline' 'unsafe-eval'
style-src 'self' 'unsafe-inline'
```
This is **required** as long as inline scripts and styles exist. Externalizing inline JS/CSS would allow tightening the CSP to remove `'unsafe-inline'` (using nonces or hashes), which is a meaningful security improvement against XSS. However, HTMX's `hx-on:*` attributes also require `'unsafe-inline'` or a nonce-based approach, so full removal isn't trivial.
+58
View File
@@ -0,0 +1,58 @@
# Analysis: Proposed PHP Structure Reorganization
## Summary
The proposed structure is a standard Laravel/Symfony-inspired architecture, but **overengineered for this project's scale and conflicts with existing architectural decisions**.
## Key Issues
### 1. Redundant Separation
The project already has good separation of concerns:
- Controllers in `src/` (14 focused classes)
- Templates in `templates/`
- Data in `storage/` (outside web root)
- Organized tests
### 2. Conflicts with Existing Decisions
- **`docs/orm-assessment.md`** explicitly decided against Doctrine/ORM → Proposed `src/Model/` entities contradict this
- **No Composer** in current project → Proposed structure requires PSR-4 autoloading
- **`App.php` already does the job** → Adding `Core/AppKernel.php` is redundant
### 3. Overengineering
- Repository + Service + Model layers for a ~15 page SQLite app creates 3x the code
- `ThesisCreationService`, `AuthService`, `ExportService` split logic that's currently cohesive
- `Config/Settings.php` replaces working `config/bootstrap.php` for no clear benefit
## What Actually Makes Sense
### High Value, Low Cost:
1. **`.env` for secrets** → Replace hardcoded credentials pattern
2. **Single entry point** → Move to `public/index.php` routing (requires nginx config changes)
3. **Keep logic out of `public/`** → Already partially achieved
### Medium Value:
4. **Consolidate `src/` classes** → Group into subdirectories without full MVC overhaul
5. **Move `test.db` from root** → Into `storage/` where it belongs
### Low Value (Skip):
- Entity classes (conflicts with ORM assessment)
- Repository pattern (SQLite direct access is fine)
- Service layer over-splitting
- Composer integration (unless you need PHP packages)
## Recommendation
**Do NOT adopt the full proposed structure.**
Instead, make incremental improvements:
1. Add `.env` support
2. Consolidate to single entry point
3. Organize `src/` into logical subdirectories
4. Move stray files to proper locations
This achieves 80% of the benefits at 20% of the cost.
## Questions to Consider
1. Are you planning to add external PHP dependencies? (If yes, Composer makes sense)
2. Do you expect multiple developers to work on this simultaneously? (Justifies stricter structure)
3. Is the current codebase difficult to maintain? (If no, restructure is premature optimization)
+62
View File
@@ -0,0 +1,62 @@
# TODO — Docs Deduplication & Standardization
## Analysis
- 36 markdown files, many heavily duplicated across topic clusters
- Filenames inconsistent (SCREAMING_CASE, Title-Case, lowercase)
- Historical migration docs scattered across 10+ files
- Security docs split across 5 files with overlapping content
- Database docs duplicated across 4 files (~72KB → ~20KB)
## Plan
### Consolidate into authoritative docs
- [x] `database.md` — merge DATABASE_SPECIFICATION + QUICK_SCHEMA_REFERENCE + DATABASE_CONFIG + SETUP.md (schema & config sections)
- [x] `deployment.md` — merge SERVER_SETUP + COMPLETE_DEPLOYMENT_GUIDE + DEPLOYMENT_STEPS
- [x] `security.md` — merge SECURITY_ANALYSIS + TODO.SECURITY
- [x] `development.md` — merge DEVELOPMENT_GUIDE + LIVE_RELOAD_SETUP + TEST_CENTRALIZATION
- [x] `migration-history.md` — consolidate all past migration docs into one reference
### Rename to lowercase kebab-case (standard convention)
- [x] SEARCH_FEATURE.md → search.md
- [x] IMPORT.md → import.md
- [x] ORM_ASSESSMENT.md → orm-assessment.md
- [x] REFACTORING_RECOMMENDATIONS.md → refactoring.md
- [x] CSS_CLEANUP.md → css.md
- [x] posterg_fiche-technique.md → spec-sheet.md
- [x] TESTING_BEST_PRACTICES.md → testing.md
- [x] ANALYSIS_PHP_VS_FLASK.md → php-vs-flask.md
### Remove (superseded / not docs)
- [x] Context.md (58KB research notes)
- [x] chat-export-2026-04-02.md (chat log)
- [x] SECURITY.md (pre-SQLite, superseded by security.md)
- [x] SECURITY_IMPLEMENTATION.md (search security → covered by search.md + security.md)
- [x] README_SECURE_SEARCH.md (duplicate of SECURITY_IMPLEMENTATION)
- [x] SETUP.md (35KB, 90% duplicated → merged into database.md)
- [x] DATABASE_CONFIG.md (merged into database.md)
- [x] DATABASE_SPECIFICATION.md (merged into database.md)
- [x] QUICK_SCHEMA_REFERENCE.md (merged into database.md)
- [x] SERVER_SETUP.md (merged into deployment.md)
- [x] COMPLETE_DEPLOYMENT_GUIDE.md (merged into deployment.md)
- [x] DEPLOYMENT_STEPS.md (merged into deployment.md)
- [x] DEVELOPMENT_GUIDE.md (merged into development.md)
- [x] LIVE_RELOAD_SETUP.md (merged into development.md)
- [x] TEST_CENTRALIZATION.md (merged into development.md)
- [x] SECURITY_ANALYSIS.md (merged into security.md)
- [x] TODO.SECURITY.md (merged into security.md)
- [x] MIGRATION.md (merged into migration-history.md)
- [x] MIGRATION_GUIDE.md (merged into migration-history.md)
- [x] MIGRATION_CHECKLIST.md (merged into migration-history.md)
- [x] MIGRATION_COMPLETE.md (merged into migration-history.md)
- [x] DEPLOYMENT_MIGRATION.md (merged into migration-history.md)
- [x] RESTRUCTURE_PLAN.md (merged into migration-history.md)
- [x] DIRECTORY_STRUCTURE.md (merged into migration-history.md)
- [x] SIMPLIFICATION.md (merged into migration-history.md)
- [x] REPOSITORY_STRUCTURE_ANALYSIS.md (merged into migration-history.md)
- [x] Analysis.md (merged into migration-history.md)
- [x] assessments.md (merged into migration-history.md)
### Final verification
- [x] Cross-references updated (search.md self-reference fixed)
- [x] No unique content lost (all information preserved in consolidated files)
- [ ] jj commit
+478
View File
@@ -0,0 +1,478 @@
# Autosave System — Architecture & HTMX Migration Assessment
## Overview
The admin panel has a custom JavaScript autosave system (`autosave.js`) that
auto-submits forms after a 1.5s debounce. It is used on pages where content
is edited with the OverType Markdown editor (static pages, form help blocks)
and on structured-data forms (contacts, sidebar links).
Three server-side action handlers implement the same CSRF-rotation + JSON
response contract that `autosave.js` consumes.
## Architecture
```
┌──────────────────────────────────┐
│ autosave.js │
│ ───────── │
│ Watches <form data-autosave> │
│ Listens: input / change (bubble)│
│ Debounce: 1500ms │
│ POST via fetch() │
│ Accept: application/json │
│ On 2xx: shows "Enregistré ✓" │
│ On err: shows "Erreur !", retry │
│ Updates CSRF token from response│
└──────────┬───────────────────────┘
│ POST (JSON)
▼
┌──────────────────────────────────┐
│ Backend handler (page.php, │
│ apropos.php, form-help.php) │
│ ────────────────────────────────│
│ 1. Check CSRF token │
│ 2. Validate payload │
│ 3. Save to DB │
│ 4. Regenerate CSRF token │
│ 5. Return JSON: │
│ {success:true, csrf_token:X} │
└──────────────────────────────────┘
```
## Forms Using `data-autosave`
### 1. Static Pages (about, licenses, charte)
| Attribute | Value |
|---|---|
| **Template** | `app/templates/admin/contenus-edit.php` (branches `about_page` and `page`) |
| **Form action** | `/admin/actions/page.php` |
| **Editor** | OverType (custom `contenteditable`-based Markdown WYSIWYG) |
| **Fields** | `csrf_token` (hidden), `slug` (hidden), `content` (hidden, synced by OverType `onChange`) |
| **How events reach autosave** | User types in OverType's `contenteditable` div → native `input` events bubble up to `<form>` → autosave.js detects them |
**Key interaction**: OverType's `onChange(value) { hiddenInput.value = value }` sets
the hidden `#content` input's value programmatically. This does NOT fire native DOM
events. The autosave trigger comes from `input` events on the contenteditable editor
div, not from the hidden input.
### 2. About Contacts (apropos groups)
| Attribute | Value |
|---|---|
| **Template** | `app/templates/admin/apropos-groups-form.php` |
| **Form action** | `/admin/actions/apropos.php` |
| **Editor** | Native text/email/url inputs |
| **Fields** | `csrf_token`, `apropos_key`, `groups[][role]`, `groups[][entries][][text/email/url]` |
Has custom JS for adding/removing contact groups and entries (inline `<script>`
in the template). The reindex logic updates `name` attributes after add/remove.
### 3. About Sidebar Links
| Attribute | Value |
|---|---|
| **Template** | Inline in `app/templates/admin/contenus-edit.php` (the `about_page` branch) |
| **Form action** | `/admin/actions/apropos.php` |
| **Fields** | `csrf_token`, `apropos_key=sidebar_links`, `links[][label]`, `links[][url]` |
Has custom JS for add/remove/reindex (inline `<script>` in the template).
### 4. Form Help Blocks
| Attribute | Value |
|---|---|
| **Template** | `app/templates/admin/contenus-edit.php` (branch `form_help`) |
| **Form action** | `/admin/actions/form-help.php` |
| **Editor** | OverType (same as static pages) |
| **Fields** | `csrf_token`, `form_help_key`, `content` |
Same OverType + autosave integration as static pages.
## Backend Handlers
### `page.php` — `/admin/actions/page.php`
```
CSRF check → slug validation (about|licenses|charte) → savePage(slug, content)
→ AdminLogger::logPageEdit() → regenerate CSRF → return JSON
```
On AJAX: returns `{success: true, csrf_token: "<new_token>"}`.
On non-AJAX (regular form POST): flashes message, redirects to `/admin/contenus.php`.
### `apropos.php` — `/admin/actions/apropos.php`
```
CSRF check → key validation → dispatch by key type:
URL keys (erg_site_url, source_code_url) → saveAproposContent(key, url)
Link lists (sidebar_links) → validate URLs, save structured array
Group-based (contacts) → validate groups/entries, save structured array
→ AdminLogger::logAproposEdit() → regenerate CSRF → return JSON
```
### `form-help.php` — `/admin/actions/form-help.php`
```
CSRF check → key validation (FORM_HELP_KEYS) → setFormHelpBlock(key, content)
→ AdminLogger::logFormStructureEdit() → regenerate CSRF → return JSON
```
### CSRF Rotation Pattern (shared by all three)
```
1. Verify: hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])
2. Save to DB
3. Rotate: $_SESSION['csrf_token'] = bin2hex(random_bytes(32))
4. Return: {success: true, csrf_token: <NEW>}
5. Client: autosave.js reads data.csrf_token, updates hidden input
```
## Other Autosave-like UI (Non-autosave.js)
These use HTMX directly, NOT `autosave.js`:
| Location | Trigger | Target | Handler |
|---|---|---|---|
| Settings checkboxes (Accès, Types) | `change` via `hx-post` | `#acces-response` / `#types-response` | `/admin/actions/settings.php` |
| TFE messages textareas | `change` via `hx-post` | inline toast | `/admin/actions/settings.php` |
| File restrictions toggle | `change` via `hx-post` | inline toast | `/admin/actions/settings.php` |
| Form help inline editor | Manual `Enregistrer` via `hx-post` | collapsed chip | `/admin/form-help-inline-fragment.php` |
| Form help toggle (dot button) | Click via `hx-post` | collapsed chip | `/admin/form-help-inline-fragment.php` |
| Language inline renames | Manual submit via `hx-post` | `#langues-table-wrap` | `/admin/actions/language.php` |
## autosave.js — Detailed Behavior
```js
// Watches: form[data-autosave]
// Debounce: 1500ms after last input/change event
// Status display: sibling element matching [data-autosave-status]
const DEBOUNCE_MS = 1500;
let timer = null;
let dirty = false;
// schedule() — called on every input/change event
// Sets dirty=true, clears previous timer, starts new 1500ms timer
schedule() → clearTimeout(timer); timer = setTimeout(doSave, 1500);
// doSave() — fires after 1500ms of inactivity
doSave() → if (!dirty) return; // guard: skip if not dirty
dirty = false; // mark clean (re-set to true on error)
setStatus('Enregistrement…');
const fd = new FormData(form); // snapshots ALL current form values
fetch(form.action, {
method: 'POST',
body: fd,
headers: { 'Accept': 'application/json' }
})
.then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status); // 403/500/etc → catch
setStatus('Enregistré ✓');
r.json().then(data => {
if (data.csrf_token) {
// Update both form input and meta tag
form.querySelector('input[name="csrf_token"]').value = data.csrf_token;
document.querySelector('meta[name="csrf-token"]').content = data.csrf_token;
}
}).catch(() => {}); // silently ignore JSON parse errors
})
.catch(() => {
setStatus('Erreur !');
dirty = true; // re-arm: will retry on next input
});
```
**Important**: The CSRF token update happens asynchronously inside `.json().then()`,
after the status is already set to "Enregistré ✓". In practice this is fine
because the 1.5s debounce provides ample time for the microtask to complete
before the next save cycle.
If the JSON response is malformed (PHP warnings/notices in output), the
`.catch(() => {})` silently discards the parse error, the CSRF token stays
stale, and all subsequent saves fail with 403.
## Known Issues
### 403 Forbidden on Autosave
Observed on charte page (and possibly about/licenses). The response is:
```
HTTP/1.1 403 Forbidden
{"error": "Erreur de sécurité : token invalide."}
```
The 403 comes from the CSRF check in `page.php` (line ~18):
```php
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
```
Possible causes:
1. **Stale CSRF after error**: First save succeeds and rotates the token, but
`autosave.js` fails to update the form (e.g., JSON parse error from PHP
warning in output). All subsequent saves fail with 403.
2. **Session not initialized**: If `$_SESSION['csrf_token']` is not set when
`page.php` runs. In admin context, it should be set by:
- `contenus.php` (list page): `if (empty($_SESSION['csrf_token'])) { ... }`
- `contenus-edit.php` (edit page): `if (empty($_SESSION['csrf_token'])) { ... }`
- `AdminAuth::requireLogin()` calls `session_start()`
3. **Session collision**: PHP's default file-based session locking serializes
concurrent requests. Unlikely with 1.5s debounce.
4. **OverType → hidden input sync**: OverType's `onChange` updates the hidden
input, but if the OverType script fails to load, the hidden input stays
empty, and the form submission has no content (but CSRF token should still
be present).
### Non-event-emitting value updates
`autosave.js` relies on `input`/`change` events bubbling. The OverType editor
updates `hidden.value` programmatically. If someone adds custom JS that updates
form values without firing events, autosave won't detect those changes.
## HTMX v2 Migration Plan
### The Core Pattern
HTMX v2 replaces the entire `autosave.js` fetch/debounce/CSRF-update loop. The key pieces:
- `hx-trigger` handles debouncing
- `hx-on::after-request` handles CSRF token rotation
- `hx-swap="none"` since you only need the JSON response side-effect
- A response header (`HX-Trigger`) can drive the status indicator
### 1. Native Input Forms (Contacts & Sidebar Links) — Full Replacement
These are straightforward since all inputs fire native DOM events.
```html
<form
hx-post="/admin/actions/apropos.php"
hx-trigger="change delay:1500ms, input delay:1500ms"
hx-swap="none"
hx-on::after-request="handleAutosaveResponse(event)"
>
<input type="hidden" name="csrf_token" value="...">
<input type="hidden" name="apropos_key" value="contacts">
<!-- your inputs -->
<span data-autosave-status></span>
</form>
```
One subtlety: HTMX v2 fires the trigger on the **element with `hx-trigger`**, which
here is the `<form>` — so `change` and `input` events bubbling up from child inputs
will correctly trigger it.
### 2. OverType Forms (Static Pages & Form Help) — Partial Replacement
OverType updates the hidden input programmatically without firing DOM events, so you
need to dispatch a custom event from its `onChange` hook:
```js
// In your OverType init
onChange: function(value) {
hiddenInput.value = value;
// Dispatch a custom event that HTMX can listen for
hiddenInput.dispatchEvent(
new CustomEvent('overtype:change', { bubbles: true })
);
}
```
Then on the form:
```html
<form
hx-post="/admin/actions/page.php"
hx-trigger="overtype:change delay:1500ms"
hx-swap="none"
hx-on::after-request="handleAutosaveResponse(event)"
>
<input type="hidden" name="csrf_token" value="...">
<input type="hidden" name="slug" value="charte">
<input type="hidden" id="content" name="content" value="">
<span data-autosave-status></span>
</form>
```
### 3. CSRF Rotation + Status Indicator
Replace `autosave.js`'s `.json().then()` pattern with a single shared handler. The
silent-parse-error risk disappears because you're explicitly handling the response:
```js
function handleAutosaveResponse(event) {
const status = event.target.closest('form')
.querySelector('[data-autosave-status]');
if (!event.detail.successful) {
if (status) status.textContent = 'Erreur !';
return;
}
try {
const data = JSON.parse(event.detail.xhr.responseText);
// Rotate CSRF token in both the form and the meta tag
if (data.csrf_token) {
event.target.closest('form')
.querySelector('input[name="csrf_token"]').value = data.csrf_token;
const meta = document.querySelector('meta[name="csrf-token"]');
if (meta) meta.content = data.csrf_token;
}
if (status) status.textContent = data.success ? 'Enregistré ✓' : 'Erreur !';
} catch {
// JSON parse failed (e.g. PHP warning in output) — surface it rather than silently swallowing
if (status) status.textContent = 'Erreur !';
console.warn('Autosave: could not parse response', event.detail.xhr.responseText);
}
}
```
This is a direct improvement over the current `autosave.js` `.catch(() => {})`
silent swallow — you now see the PHP warning in the console instead of just
getting a mystery 403 on the next save.
### 4. "Loading" State During Save
If you want a saving indicator (the current `"Enregistrement…"` state), use
`htmx:beforeRequest` on the form — or use HTMX's built-in `htmx-request` class
which is added to the element automatically while a request is in flight:
```css
/* Target the class HTMX adds */
form.htmx-request [data-autosave-status]::after {
content: 'Enregistrement…';
}
```
Or explicitly with an event listener:
```js
document.body.addEventListener('htmx:beforeRequest', e => {
const status = e.target.querySelector('[data-autosave-status]');
if (status) status.textContent = 'Enregistrement…';
});
```
### 5. The Add/Remove Group JS Still Works Unchanged
The reindex logic (updating `name` attributes after add/remove) is inline
`<script>` independent of `autosave.js`. This continues to work — HTMX reads
`FormData` at request time, so newly added/reindexed inputs are automatically
included in the next triggered save.
### Migration Checklist
| Form | Change required |
|---|---|
| `contenus-edit.php` (pages) | Add `hx-*` attrs, add `overtype:change` dispatch in OverType `onChange` |
| `contenus-edit.php` (form_help) | Same as above |
| `apropos-groups-form.php` (contacts) | Add `hx-*` attrs only |
| `contenus-edit.php` (sidebar_links) | Add `hx-*` attrs only |
| `autosave.js` | **Delete** once all four forms are migrated |
| Backend handlers | **No changes needed** — they already return `{success, csrf_token}` |
The backend is untouched throughout, which is the cleanest part of this migration.
## HTMX Migration Feasibility
The settings toggles already use HTMX successfully for similar patterns
(checkbox → toggle → server → toast). Here's an assessment for each form:
### 1. Static Pages (page.php) — **Challenging**
The OverType editor is a heavy custom JS component (contenteditable-based
Markdown WYSIWYG). HTMX could handle the *submission* side (replacing
`fetch()` with `hx-post`), but the editor itself would remain custom JS.
An HTMX approach would:
- Add `hx-post="/admin/actions/page.php"` to the form
- Add `hx-trigger="every 3s"` or a custom event dispatched by OverType's `onChange`
- Use `hx-swap="none"` or a status indicator swap
- Need a custom event from OverType like `overtype:changed` to trigger saves
at the right time (not polling-based like `every 3s`)
**Verdict**: Partial replacement possible. HTMX for submission, keep OverType
for editing. Could eliminate ~40 lines of autosave.js logic for this form.
### 2. About Contacts (apropos.php) — **Easy**
Simple text inputs. The add/remove group/entry logic is separate JS. Autosave
just watches `input` events.
HTMX approach:
- `hx-post` with `hx-trigger="change delay:1500ms"` on each input
- `hx-swap="none"` + custom status indicator via `hx-on::after-request`
**Verdict**: Straightforward replacement. Eliminates autosave.js dependency.
### 3. About Sidebar Links (apropos.php) — **Easy**
Same as contacts — simple text/URL inputs. The add/remove JS is independent
of autosave.
**Verdict**: Same as contacts.
### 4. Form Help Blocks (form-help.php) — **Challenging**
Same OverType integration as static pages. Same considerations apply.
**Verdict**: Same as static pages.
### 5. The autosave.js Itself — **Can be Replaced**
If every form with `data-autosave` is converted to HTMX, `autosave.js` can be
removed entirely. The script is ~60 lines.
### Summary Table
| Form | Editor | HTMX Feasibility | Effort |
|---|---|---|---|
| Static pages (about/licenses/charte) | OverType | Partial (keep OverType) | Medium |
| Form help blocks | OverType | Partial (keep OverType) | Medium |
| About contacts | Native inputs | Full | Low |
| About sidebar links | Native inputs | Full | Low |
### Recommended HTMX Strategy
For static pages and form help blocks:
```html
<form hx-post="/admin/actions/page.php"
hx-trigger="overtype:changed delay:1500ms"
hx-swap="none"
hx-on::after-request="
var data = JSON.parse(event.detail.xhr.responseText);
if (data.csrf_token) {
document.querySelector('input[name=csrf_token]').value = data.csrf_token;
}
"
data-autosave>
```
And dispatch `overtype:changed` from OverType's `onChange`:
```js
onChange: function(value) {
hidden.value = value;
editorElement.dispatchEvent(new CustomEvent('overtype:changed', { bubbles: true }));
}
```
For contacts/sidebar links — use per-input `hx-trigger="change delay:1500ms"`
or a form-level trigger on any input change.
The key advantage: HTMX handles the CSRF token rotation natively via
`hx-on::after-request`, eliminating the async `.json().then()` pattern
and the associated silent-parse-error risk.
+188
View File
@@ -0,0 +1,188 @@
# SQLite Backup & Data Integrity Plan
## Status Legend
- `[ ]` To do
- `[x]` Done
- `[~]` Partial / needs review
---
## Phase 1 — WAL Mode
**Goal:** Ensure SQLite uses Write-Ahead Logging for safe concurrent reads and hot backups.
- [ ] Connect to the DB and verify WAL is active:
```bash
sqlite3 /path/to/your.db "PRAGMA journal_mode;"
# Expected output: wal
```
- [ ] If not `wal`, enable it (run once, persists):
```bash
sqlite3 /path/to/your.db "PRAGMA journal_mode=WAL;"
```
- [ ] Confirm the `-wal` and `-shm` sidecar files exist next to the `.db` file after a write
- [ ] Make sure nginx/PHP has write access to those sidecar files (same owner as the `.db`)
---
## Phase 2 — Audit Log
**Goal:** Record every INSERT, UPDATE, and DELETE with the actor, timestamp, and a before/after snapshot.
### 2.1 — Create the table
- [ ] Add the `audit_log` table to the DB:
```sql
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
actor TEXT NOT NULL,
action TEXT NOT NULL CHECK(action IN ('INSERT','UPDATE','DELETE')),
table_name TEXT NOT NULL,
record_id INTEGER,
old_data TEXT,
new_data TEXT
);
```
### 2.2 — Instrument PHP mutations
- [ ] Create a reusable `audit()` helper in PHP that accepts `$db, $actor, $action, $table, $id, $old, $new`
- [ ] Wrap every **DELETE** in the admin dashboard with `audit()`, capturing the row before deletion
- [ ] Wrap every **UPDATE** (form submissions + admin edits) with `audit()`, capturing before/after
- [ ] Wrap **INSERTs** for completeness (new_data only)
- [ ] Verify by triggering a test delete and querying `SELECT * FROM audit_log ORDER BY id DESC LIMIT 5;`
### 2.3 — Protect the audit log
- [ ] No UI should expose a "clear audit log" button
- [ ] The PHP DB user should not have `DELETE` permission on `audit_log` (use a restricted PDO connection for app queries if possible)
---
## Phase 3 — Soft Deletes
**Goal:** Prevent hard DELETEs on critical tables so data is always recoverable instantly. htmx elements that query languages/keywords must continue to work transparently.
### 3.1 — Schema changes
- [ ] Identify all tables that htmx elements query (e.g. `languages`, `keywords`, any lookup/reference tables)
- [ ] Add `deleted_at` to each:
```sql
ALTER TABLE languages ADD COLUMN deleted_at TEXT DEFAULT NULL;
ALTER TABLE keywords ADD COLUMN deleted_at TEXT DEFAULT NULL;
-- repeat for other affected tables
```
### 3.2 — Replace DELETE queries
- [ ] Search the codebase for `DELETE FROM languages`, `DELETE FROM keywords`, etc.
- [ ] Replace each hard DELETE with a soft delete:
```php
// Before
$db->prepare("DELETE FROM languages WHERE id = ?")->execute([$id]);
// After
$db->prepare("UPDATE languages SET deleted_at = datetime('now') WHERE id = ?")
->execute([$id]);
```
- [ ] Do the same in any admin dashboard bulk-delete operations
### 3.3 — Filter deleted rows everywhere
- [ ] Add `WHERE deleted_at IS NULL` to **every** SELECT that feeds an htmx endpoint:
```sql
-- Example
SELECT * FROM languages WHERE deleted_at IS NULL ORDER BY name;
SELECT * FROM keywords WHERE deleted_at IS NULL ORDER BY name;
```
- [ ] Search for raw `SELECT * FROM languages` and `SELECT * FROM keywords` across all PHP files and patch each one
- [ ] Test each htmx-driven element (dropdowns, tag lists, autocompletes) to confirm deleted entries no longer appear
### 3.4 — Admin: show soft-deleted entries
- [ ] Add an admin view that lists soft-deleted rows (`WHERE deleted_at IS NOT NULL`) with a **Restore** button
- [ ] The restore action sets `deleted_at = NULL`
---
## Phase 4 — Hourly Snapshots via Cronjob
**Goal:** Automatically save compressed, timestamped copies of the DB locally, retained for 30 days.
### 4.1 — Create the backup script
- [ ] Create `/usr/local/bin/backup-sqlite.sh`:
```bash
#!/bin/bash
DB_PATH="/var/www/myapp/database.db"
BACKUP_DIR="/var/backups/myapp"
RETENTION_DAYS="${RETENTION_DAYS:-30}"
TIMESTAMP=$(date +"%Y-%m-%dT%H-%M-%S")
BACKUP_FILE="$BACKUP_DIR/db-$TIMESTAMP.db.gz"
mkdir -p "$BACKUP_DIR"
# Safe hot backup using SQLite's online backup API
sqlite3 "$DB_PATH" ".backup /tmp/myapp-snapshot.db"
gzip -c /tmp/myapp-snapshot.db > "$BACKUP_FILE"
rm /tmp/myapp-snapshot.db
# Prune old backups
find "$BACKUP_DIR" -name "*.db.gz" -mtime +$RETENTION_DAYS -delete
echo "[$(date)] Backup written: $BACKUP_FILE"
```
- [ ] Make it executable:
```bash
chmod +x /usr/local/bin/backup-sqlite.sh
```
- [ ] Run it manually once and verify a `.db.gz` file appears in `/var/backups/myapp/`
- [ ] Test restore by decompressing and opening the snapshot:
```bash
gunzip -c /var/backups/myapp/db-<timestamp>.db.gz > /tmp/test-restore.db
sqlite3 /tmp/test-restore.db ".tables"
```
### 4.2 — Schedule with cron
- [ ] Open the crontab:
```bash
crontab -e
```
- [ ] Add hourly and daily jobs:
```cron
# Hourly snapshot — kept 30 days
0 * * * * /usr/local/bin/backup-sqlite.sh >> /var/log/xamxam-backup-$(date +%Y-%m-%d).log 2>&1
# Daily snapshot at 2am — kept 90 days
0 2 * * * RETENTION_DAYS=90 /usr/local/bin/backup-sqlite.sh >> /var/log/xamxam-backup-$(date +%Y-%m-%d).log 2>&1
```
- [ ] Verify the log after the next hour: `tail -f /var/log/xamxam-backup-$(date +%Y-%m-%d).log`
---
## Phase 5 — Remote Sync *(implemented — see [nextcloud-sync.md](nextcloud-sync.md))*
**Goal:** Push backups off the VM to a remote destination so a disk failure or VM loss doesn't take your history with it.
Implemented via a PHP WebDAV sync to Nextcloud (`cloud.erg.school`), reusing the SMTP credentials — see [nextcloud-sync.md](nextcloud-sync.md) for the full reference. The checklist below is superseded by that doc.
- [x] Choose a remote destination — **Nextcloud WebDAV** (`/XAMXAM-BCK`)
- [x] Transport — **PHP `curl`**, not rclone (reuses SMTP credentials, no extra binary)
- [x] Add remote sync separate from the backup script — `scripts/nextcloud-sync.php` (daily cron, 20 min after the 02:00 snapshot)
- [x] Remote retention — keep last 7 snapshots (`REMOTE_KEEP`)
- [x] Test a full restore from remote — restore procedure documented in [nextcloud-sync.md](nextcloud-sync.md)
- [x] Monitoring — `backup-watchdog.php` alerts on stale/missing remote copy
---
## Quick Reference — Recovery Scenarios
| Scenario | Solution |
|---|---|
| Admin accidentally deleted a row | Set `deleted_at = NULL` in the relevant table |
| User submitted bad data via a form | Query `audit_log` for the `old_data` JSON, restore manually |
| Bulk accidental delete | Restore from the last hourly snapshot (< 1h data loss max) |
| VM or disk failure | Pull latest snapshot from remote (Phase 5) |
| "Who deleted this and when?" | `SELECT * FROM audit_log WHERE table_name='x' AND action='DELETE'` |
+107
View File
@@ -0,0 +1,107 @@
# Bookmarklet — auto-fill test form
A drag-to-bookmarks helper that pre-fills an XAMXAM thesis form with dummy data
so a submit can be tested quickly.
> The old `?mode=student` flag and the standalone student form no longer exist.
> Student submissions now use **share links** at `/partage/<slug>` (see the
> `share_links` table). The same form template (`app/templates/partials/form/`)
> drives admin add/edit and the partage form, so the field names below mostly
> match. The bookmarklet below targets the **admin** add form
> (`/admin/add.php`).
## Bookmarklet
Open `/admin/add.php` (logged in), then click the bookmarklet:
```
javascript:(function(){var set=function(n,v){var e=document.querySelector('[name="'+n+'"]');if(e){e.value=v;e.dispatchEvent(new Event('change',{bubbles:true}));}};var setFirst=function(n,v){var e=document.querySelector('[name="'+n+'"][type="text"]');if(e){e.value=v;e.dispatchEvent(new Event('change',{bubbles:true}));}};var checkCB=function(n,v){var e=document.querySelector('[name="'+n+'[]"][value="'+v+'"]');if(e){e.checked=true;e.dispatchEvent(new Event('change',{bubbles:true}));}};var check=function(n){var e=document.querySelector('[name="'+n+'"]');if(e){e.checked=true;e.dispatchEvent(new Event('change',{bubbles:true}));}};set('titre','TFE Test — Impact des réseaux sociaux');set('subtitle','Étude de cas auprès des étudiant·es de l\'ERG');set('auteurice','Marie-France Dupont');set('contact_visible','public');setFirst('jury_promoteur[]','Dr. Aline Vandenberghe');setFirst('jury_lecteur_interne[]','Prof. Jean-Luc Moreau');setFirst('jury_lecteur_externe[]','Prof. Kim Sølv');setFirst('jury_promoteur_ulb_name[]','Prof. Université');set('synopsis','Ce travail explore l\'impact des plateformes numériques sur la pratique artistique contemporaine.');set('orientation','4');set('ap','3');set('finality','1');checkCB('formats','1');checkCB('formats','3');set('license_id','8');set('duration_pages','96');check('cc2r');set('website_url','https://example.com/tfe-test');set('access_type_id','2');set('contact_interne','mfd@testmail.be');set('jury_points','16.5');})()
```
## Readable source
```js
(function () {
var set = function (n, v) {
var e = document.querySelector('[name="' + n + '"]');
if (e) { e.value = v; e.dispatchEvent(new Event('change', { bubbles: true })); }
};
var setFirst = function (n, v) { // first hidden/text field of a jury array
var e = document.querySelector('[name="' + n + '"][type="text"]');
if (e) { e.value = v; e.dispatchEvent(new Event('change', { bubbles: true })); }
};
var checkCB = function (n, v) { // checkbox-list (formats[]) value
var e = document.querySelector('[name="' + n + '[]"][value="' + v + '"]');
if (e) { e.checked = true; e.dispatchEvent(new Event('change', { bubbles: true })); }
};
var check = function (n) {
var e = document.querySelector('[name="' + n + '"]');
if (e) { e.checked = true; e.dispatchEvent(new Event('change', { bubbles: true })); }
};
// ── Informations du TFE ──
set('titre', 'TFE Test — Impact des réseaux sociaux');
set('subtitle', 'Étude de cas auprès des étudiant·es de l\'ERG');
set('auteurice', 'Marie-France Dupont'); // comma-separated
set('contact_visible', 'public'); // admin mode; partage uses 'mail'
// ── Jury (array fields, one row each) ──
setFirst('jury_promoteur[]', 'Dr. Aline Vandenberghe');
setFirst('jury_lecteur_interne[]', 'Prof. Jean-Luc Moreau');
setFirst('jury_lecteur_externe[]', 'Prof. Kim Sølv');
setFirst('jury_promoteur_ulb_name[]', 'Prof. Université');
// ── Cadre académique ──
set('synopsis', 'Ce travail explore l\'impact des plateformes numériques sur la pratique artistique contemporaine.');
set('orientation', '4'); // Installation-Performance
set('ap', '3'); // Atelier Pratiques Situées
set('finality', '1'); // Approfondie
checkCB('formats', '1'); // Site web
checkCB('formats', '3'); // Vidéo
// ── Métadonnées ──
set('license_id', '8'); // Tous droits réservés
set('duration_pages', '96');
check('cc2r'); // CC2r licence
set('website_url', 'https://example.com/tfe-test');
set('access_type_id', '2'); // Interne
set('contact_interne', 'mfd@testmail.be');
set('jury_points', '16.5');
})();
```
## Notes & current field names (verified against the form partials)
| Field | Notes |
|-------|-------|
| `titre`, `subtitle`, `auteurice` | `auteurice` is comma-separated |
| `contact_visible` | admin add/edit; the partage form uses `mail` |
| `jury_promoteur[]`, `jury_lecteur_interne[]`, `jury_lecteur_externe[]`, `jury_promoteur_ulb_name[]` | one `input[type=text]` per row (`setFirst` targets the first) |
| `orientation`, `ap`, `finality` | `<select>`; values are DB ids |
| `formats[]` | checkbox-list |
| `languages[]`, `tags[]` | **pill-search** (type-ahead with hidden `name=…[]` pills) — not pre-filled by this simple helper; add pills through the search UI or dispatch the pill-create event |
| `license_id` | `<select>` of `license_types` |
| `cc2r` | checkbox (renamed from `cc4r`) |
| `want_license` | hidden `0`; set to `1` to show the licence explanation block |
| `duration_pages` | combined duration: pages int; time uses `duration_h` + `duration_m` (values in minutes) |
| `website_url`, `website_label` | the "lien" (site web) fields |
| `access_type_id` | radio: `""`, `1`=Libre, `2`=Interne, `3`=Interdit |
| `contact_interne`, `exemplaire_baiu`, `exemplaire_erg`, `is_published`, `context_note`, `jury_points`, `remarks` | other form fields |
### Removed / renamed
- `cc4r` → `cc2r`
- `duration_info` → split into `duration_pages` + `duration_h`/`duration_m`
- `jury_president`, single `jury_promoteur` → jury arrays above (`jury_promoteur[]` etc.)
- `lien` → `website_url`; `mail` in admin mode → `contact_visible`
- `contact_public` checkbox → handled via `authors.show_contact` / `contact_visible`
- `?mode=student` → student flows go through `/partage/<slug>`
### Lookup ids
`orientations` 4=`Installation-Performance`; `ap_programs` 3=`Atelier Pratiques
Situées`; `finality_types` 1=`Approfondie`; `format_types` 1=`Site web`,
3=`Vidéo`; `license_types` 8=`Tous droits réservés`; `access_types` 2=`Interne`.
> IDs may shift after DB migrations that re-number reference rows — verify
> against the current `app/storage/schema.sql` seed data if a lookup fails.
+162
View File
@@ -0,0 +1,162 @@
# CSS split / unusedSymbols — Template → Page-type inventory
Status: analysis (gates `define-per-page-type-css-bundle` and the unusedSymbols content corpus)
## How CSS is loaded today (head.php)
`App::render()` (app/src/App.php) always `include`s `app/templates/head.php` for a
full page render. head.php unconditionally emits:
```html
<link rel="stylesheet" href="/assets/dist/base.min.css">
```
`base.min.css` = the whole `style.css` `@import` chain:
reset → colors → typography → base → all `components/*` → utilities. So **every
full page ships the entire component+utility set**, even pages that barely use it
(the cross-page waste the split targets).
On top of it, head.php renders `$extraCss` array entries (admin prepends
`admin.min.css` via `$extraCssAdmin`). Page-specific `.min.css` bundles load as
`$extraCss`.
Fragments / HTMX partials (`app/templates/partials/*`, `app/public/**/fragments`)
are included **directly**, not via `App::render()`, so they do NOT load head.php.
## Page-type map (entry → templates → extra css → bodyClass)
### Public (non-admin)
| Page type | Template(s) | extraCss (on top of base) | bodyClass | Notes |
|----------------------|---------------------------------|--------------------------------|--------------|-------|
| home | `public/home.php` | `public.min.css` | `home-body` | HomeController |
| tfe | `public/tfe.php` | `tfe.min.css` | `tfe-body` | TfeController |
| repertoire/search | `public/repertoire.php`, `public/search.php` | `repertoire.min.css` | `search-body` | SearchController |
| content-page (about / licence / charte) | `public/about.php`, `public/licence.php`, `public/charte.php` | `content-page.min.css` | `apropos-body` | AboutController, LicenceController, CharteController |
| not-found | `public/not-found.php` | `not-found.min.css` | `page-not-found` | Dispatcher |
| partage (student form / recap) | `partage/form-page.php` (templates/), `partage/recapitulatif.php`, `partage/retry-email.php`, `partage/index.php` | `form.min.css` (+ `filepond`) | — | FormBootstrap injects `extraCss = [form.min.css]`; partage/index sets `$filepondBase` |
### Admin
head.php prepends `admin.min.css` (`$extraCssAdmin`) whenever `$isAdmin` is set.
| Page type | Entry (app/public/admin/*.php) | extra extraCss / extraCssAdmin | bodyClass |
|-----------------|------------------------------------|--------------------------------|-----------|
| login / reset | `login.php`, `password-reset.php`, `request-reset.php` | — | `admin-body` |
| index | `index.php` | `filepond*.css` (extraCssAdmin) | `admin-body` |
| recapitulatif | `recapitulatif.php` | — | `admin-body student-body` (student mode) |
| add / edit | `add.php`, `edit.php`, `contenus-edit.php` | form deps | `admin-body` |
| contenu mgmt | `contenus.php`, `tags.php`, `acces*.php`, `cleanup.php`, `account.php`, etc. | — | `admin-body` |
| parametres | `parametres.php` | `system.min.css` (extraCssAdmin) | `admin-body` |
## Dist bundles present (app/public/assets/dist/)
From `scripts/build-css.mjs`: `base.min.css`, `admin.min.css`, `form.min.css`,
`public.min.css`, `tfe.min.css`, `repertoire.min.css`, `content-page.min.css`,
`not-found.min.css`, `system.min.css`, `file-access.min.css`, `common.min.css`.
## Shared core (potential overlap between bundles)
`base.min.css` already contains everything: reset, colors, typography, base,
components/{links,focus,forms,tables,dialog,details,media,buttons,badges,toast,
pagination,header,search,toc}, utilities.
The split must decide which of those move OUT of the global base into page-type
bundles; the components used on every page (reset, colors, typography, base,
header, footer, search? — see component usage below) stay in a slim base.
## Next step
Component-by-component usage audit: which templates actually reference each
`components/*` class / utility. That drives `define-per-page-type-css-bundle`
and feeds the unusedSymbols content corpus.
## Content corpus (for unusedSymbols report)
`scripts/css-content-sources.mjs` builds the content corpus scanned against the
CSS symbols:
- **Sources**: app/templates/*.php, app/public/*.php, app/src/*.php
(controllers + icon.php helper), app/public/assets/js/app/*.js (first-party).
- **Vendor JS excluded** (htmx/filepond/pdf): their class names are internal to
their own bundled CSS, and including them would only *under*-report project
usage — the safe direction.
- **Dynamic-class handling**: `buildCorpus()` returns a safelist of 22 exact
runtime class names (status-ok/warn/err/unknown, log-*, input-error, active,
disabled, btn--*, fhb-*, admin-icon-btn--*, status-published/pending/badge)
plus 5 prefix patterns (status-access--*, toc-level-*, admin-import-log__item--*,
admin-body, student-body) whose suffix is DB/state-derived.
Run: `node scripts/css-content-sources.mjs` (prints inventory).
The report script (scripts/css-unused-report.mjs, task 12) imports `buildCorpus()`.
## unusedSymbols report — findings (diagnostic, task 12/14 go-no-go)
`just css-report` rebuilds CSS then runs `scripts/css-unused-report.mjs`:
for each dist/*.min.css it extracts class/id symbols, checks them against the
content corpus + dynamic/vendor safelists, and measures bytes lightningcss would
reclaim (no stripping applied).
**Result (216,383 B total): ~6.2 KB (2.9%) reclaimable.** Per bundle:
| bundle | orig B | reclaim B | candidate-unused |
|---|---|---|---|
| admin.min.css | 55016 | 3060 | admin-import-results*, n-grid, n-section, param-*, admin-toggle*, admin-dialog--sheet, admin-maintenance-* |
| base.min.css | 21375 | 484 | btn--success, btn--blue, btn--yellow |
| form.min.css | 41855 | 731 | mode-toggle, licence-generalites, file-preview-list |
| partage-form.min.css | 37229 | 731 | (same) |
| form-base.min.css | 19110 | 751 | (same) |
| public.min.css | 4047 | 228 | card__media--placeholder |
| system.min.css | 7408 | 185 | sys-status-section |
| content-page.min.css | 3683 | 32 | heading-permalink |
**Vendor classes excluded** (would over-report + reclaim): filepond--*, htmx-*.
They are assembled by vendor JS at runtime, so they never appear as literals.
See TODO task 14 for the go/no-go evaluation of this data.
## Decision (task 14): per-page split and CSS pruning
Data: ~6.2 KB (2.9%) reclaimable across 216 KB total. base.min.css itself only
484 B (2.3%) reclaimable. Waste is concentrated in admin/form/partage-form
(shared FilePond + form CSS).
**Diagnosis:** base.css is already well-used; the per-page SPLIT would shave
minimal real weight (base.min.css is ~all-referenced). The unused-symbol PRUNING
opportunity is small and concentrated in bundles that are already page-type-
specific (form/admin).
### Split — NO-GO
Splitting base.min.css into per-page bundles was premised on cross-page waste of
component+utility CSS. The report shows that premise is weak: nearly all of
base.min.css's classes are referenced somewhere, and shaving a redundant handful
is a pruning task, not a split. A split adds build/template complexity for ~2%
of one bundle. → Do not split. Park tasks u-w-x-y as deferred.
### Pruning — CONDITIONAL-GO (narrow, safe subset only)
The only pruning worth doing is hand-verifiable dead selectors, removed from
SOURCE css files (never from dist) so the next build drops them:
- base.min.css: btn--success, btn--blue, btn--yellow (3, 484 B)
- public.min.css: card__media--placeholder (228 B)
- system.min.css: sys-status-section (185 B)
- content-page.min.css: heading-permalink (32 B)
- form*.min.css: mode-toggle, mode-toggle--back, licence-generalites,
file-preview-list (731 B, duplicated across form-base/form/partage-form)
- admin.min.css: admin-import-results*, n-grid, n-section, param-*,
admin-toggle*, admin-dialog--sheet, admin-maintenance-* (3060 B)
Safety rules:
1. Delete from app/public/assets/css/** sources, never dist.
2. Rebuild + re-run `just css-report` after each edit; the class must vanish
from the candidate list (not just move to needs-review).
3. Never remove a `needs-review` (dynamic emitter) or vendor-prefix class
(filepond--*, htmx-*).
4. btn--success/blue/yellow are safe (no live $-built variants); confirm they are
not emitted by a helper before deleting.
5. Green path: `just build-css` + smoke admin/login/form/public pages after.
**Verdict:** park the split (NO-GO); optionally prune the ~6.2 KB dead set as a
follow-up, lowest-risk slice first (base/public/system/content-page).
+87
View File
@@ -0,0 +1,87 @@
# De-librairisation Plan
## Why
XAMXAM currently contains ~3,300 lines of custom code implementing
common infrastructural concerns that well-maintained ecosystem libraries
have already solved — correctly, securely, and with years of security
audits and edge-case hardening behind them. By replacing these bespoke
implementations with off-the-shelf packages we:
- **Eliminate attack surface** — we stop maintaining our own SMTP
client, HTTP client, and Markdown parser.
- **Reduce maintenance burden** — each line of infrastructure code we
own is a line we must understand, debug, and keep secure. Off-the-shelf
libs shift that to dedicated maintenance teams.
- **Gain features for free** — DKIM signing, TLS 1.3, connection pooling,
async I/O, proper content security — all things we would never build
ourselves.
- **Make the codebase smaller and more readable** — the remaining code
is *actual application logic*, not protocol plumbing.
## What we replace
| # | Component | Current size | Replaced by | Why |
|---|-----------|-------------|--------------|-----|
| 1 | Markdown parser | ~1770 lines | `league/commonmark` 2.x | Parsedown 1.8.0 is unmaintained since 2019. It has known XSS vulnerabilities fixed in later versions that never shipped. `league/commonmark` is the de-facto standard, actively maintained, security-audited, and supports GFM extensions (tables, strikethrough, autolinks). |
| 2 | SMTP client | ~680 lines | `phpmailer/phpmailer` 6.x | Raw socket SMTP with manual STARTTLS negotiation is one of the hardest things to get right in application code. PHPMailer handles TLS 1.3, DKIM, MIME encoding, character sets, connection pooling — all things our custom code does not. |
| 3 | HTTP client | ~200 lines | `guzzlehttp/guzzle` 7.x | `PeerTubeService::httpRequest()` uses `file_get_contents()` with `stream_context_create()`. Manual JSON parsing, no retry logic, no connection reuse, fragile error handling. Guzzle is the PHP HTTP standard. |
| 4 | Encryption | ~86 lines | `defuse/php-encryption` 2.x | Our AES-256-GCM implementation is actually correct, but home-rolled crypto is never recommended. `defuse/php-encryption` is the recommended library by the PHP security community. **Requires migration of existing encrypted data.** |
## What we keep (and why)
| Component | Why keep |
|-----------|----------|
| `password_hash()` / `password_verify()` | Already the correct approach — PHP's built-in bcrypt. No library needed. |
| CSRF (`App.php`) | Implementation is correct: 256-bit random token, `hash_equals()` verification, rotated after mutations. A Symfony CSRF component would be an upgrade but not urgent. |
| Rate limiter (`RateLimit.php`) | Adequate for current scale. A concurrent-safe Symfony rate-limiter would be better but the file race condition is low-risk at our traffic levels. |
| PHP templates | Plain PHP `include` with `extract()` is fast, simple, and well-understood. Auto-escaping (Twig) would be a security upgrade but the migration cost is high and content is mostly admin-controlled. |
| Logging | `error_log()` with JSON-lines is sufficient. Monolog would be cleaner but adds no security benefit. |
## Composer setup
The project currently has no `composer.json`. PHP CS Fixer and PHPStan
were installed manually into `vendor/bin/`. We will:
1. Create `composer.json` with the four packages above.
2. Run `composer install` to populate `vendor/`.
3. Keep existing PHP CS Fixer and PHPStan configs — they already work.
## Migration order (by risk)
### Phase 1: Markdown parser (low risk, high payoff)
- **Surface**: 4 files import Parsedown
- **API similarity**: `$pd->text($input)` → `$converter->convert($input)`
- **SafeMode equivalent**: `league/commonmark` is safe by default (no raw HTML in safe mode)
- **No data migration needed**: input is Markdown strings, output is HTML — both are ephemeral and regenerated on each page load
- **Files to change**:
- `app/src/Controllers/AboutController.php`
- `app/src/Controllers/LicenceController.php`
- `app/templates/partials/form/form-help-block.php`
- `app/public/admin/form-help-inline-fragment.php`
### Phase 2: HTTP client (low risk, PeerTube-specific)
- **Surface**: `app/src/PeerTubeService.php` only
- **No data migration needed**: purely a transport layer replacement
- **Can be done independently of Phase 1**
### Phase 3: SMTP client (medium risk, needs testing)
- **Surface**: `app/src/SmtpRelay.php`
- **API change**: `SmtpRelay::send($db, $to, $subject, $htmlBody)` signature stays, but internals replaced with PHPMailer
- **No data migration needed**: SMTP settings in DB are read identically
- **Must test**: actual email sending to a real SMTP server before deploying
### Phase 4: Encryption (highest risk, requires migration)
- **Surface**: `app/src/Crypto.php`, `app/src/ShareLink.php`, `app/src/AdminAuth.php`
- **Affects**: encrypted passwords in `share_links.encrypted_password`, SMTP password in `site_settings`
- **Migration required**: decrypt all values with old Crypto, re-encrypt with defuse/php-encryption, write migration script
- **Do last**, and only if the risk/reward is worth it (our current implementation is actually correct)
## Target state
After all phases, the codebase loses ~2,700 lines of infrastructure code and gains
four well-maintained dependencies with known security postures and upgrade paths.
+377
View File
@@ -0,0 +1,377 @@
# FilePond crash analysis — TFE upload forms
Status: **unresolved** — analysis complete, root cause identified in vendor code.
Hand this doc (and the whole repo) to another agent for implementing the fix.
---
## Errors observed (Firefox, dev server 127.0.0.1:8000)
Trigger: adding an image to "Image de couverture" (cover queue) or any TFE file upload form.
```
InstallTrigger is deprecated and will be removed in the future. content.js:1
Failed to execute 'postMessage' on 'DOMWindow': target origin mismatch 2 20260609-...
htmx:targetError htmx.min.js:1
Uncaught TypeError: can't access property "main", n.status is undefined filepond.min.js:9
Wt filepond.min.js:9
A filepond.min.js:9
A filepond.min.js:9
_write filepond.min.js:9 (×16)
<anonymous> filepond.min.js:9
<anonymous> filepond.min.js:9
e filepond.min.js:9
e filepond.min.js:9
u filepond.min.js:9 (× many — retry loop)
e filepond.min.js:9
u ...
[filepond:event] error Object { pond: {…}, error: null, file: {…} } file-upload-filepond.js:587
```
Rows 1–3 are noise: `InstallTrigger`/`postMessage` are Firefox internals; `htmx:targetError` is an unrelated HTMX issue. The real crash is rows 4+.
---
## Root cause
### The crash
At `filepond.min.js:9:60852`, FilePond 4.32.12 crashes inside its view system's `_write` method. Two view writers dereference `action.status.main`:
**FilePond unminified (file-status view), line 7847:**
```js
var error = function error(_ref8) {
var root = _ref8.root,
action = _ref8.action;
text(root.ref.main, action.status.main); // ← crashes if action.status is undefined
text(root.ref.sub, action.status.sub);
};
```
**FilePond unminified (assistant view), line 10735:**
```js
var itemError = function itemError(_ref6) {
var root = _ref6.root,
action = _ref6.action;
var item = root.query('GET_ITEM', action.id);
var filename = item.filename;
assist(root, action.status.main + ' ' + filename + ' ' + action.status.sub);
};
```
Neither function guards against `action.status === undefined`.
### How `action.status` becomes undefined
FilePond's internal response objects use the property name **`code`**, not `status`:
```js
// line 4700
var createResponse = function createResponse(type, code, body, headers) {
return {
type: type,
code: code, // ← "code", not "status"
body: body,
headers: headers,
};
};
```
But the `load-file-error` event handler accesses `error.status`:
```js
// line 6777-6784
item.on('load-file-error', function(error) {
dispatch('DID_THROW_ITEM_INVALID', {
id: id,
error: error.status, // ← .status is undefined on createResponse objects!
status: error.status, // ← dispatches undefined as the status
});
failure({ error: error.status, file: createItemAPI(item) });
});
```
Because the error object has `.code` (not `.status`), both `error: error.status` and `status: error.status` are `undefined`. When the dispatched action reaches the view writer, `action.status` is `undefined` → crash.
### When does `load-file-error` fire?
The `load-file-error` event is emitted in the item `_load` method when the `LOAD_FILE` filter chain **rejects**:
```js
// line 5855-5863
loader.on('load', function(file) {
var error = function error(result) {
state.file = file;
fire('load-meta');
setStatus(ItemStatus.LOAD_ERROR);
fire('load-file-error', result); // ← fires when filter chain rejects
};
if (state.serverFileReference) {
success(file); // ← existing files take this safe path
return;
}
onload(file, success, error); // ← new files take this path
});
```
For existing DB files (edit mode), `state.serverFileReference` is set → `success(file)` is called directly → `load-file-error` never fires.
For **newly added files** (no serverId yet), `onload(file, success, error)` runs the `LOAD_FILE` filter chain. The FilePond **FileValidateType** plugin (v1.2.8) registers a `LOAD_FILE` filter:
```js
// plugin line 132
addFilter('LOAD_FILE', function(file, _ref3) {
// ...
var handleRejection = function handleRejection() {
reject({
status: { main: '...', sub: '...' } // ← plugin rejects with proper status object
});
};
// ...
});
```
The plugin rejects with `{ status: { main, sub } }`. This is CORRECT. The rejected value flows into the `error(result)` callback → `result.status` IS `{ main, sub }`. So when `load-file-error` fires from a plugin rejection, `error.status` is actually a proper object, NOT undefined. **This particular path is safe.**
However, `load-file-error` can also fire from the `DID_LOAD_ITEM` filter chain `catch` handler at line 6878:
```js
.catch(function(e) {
if (!e || !e.error || !e.status) return handleAdd(false);
dispatch('DID_THROW_ITEM_INVALID', {
id: id,
error: e.error,
status: e.status, // ← e.status could be anything
});
});
```
This dispatches directly to `DID_THROW_ITEM_INVALID` (bypassing `load-file-error`), but it still copies `e.status` into the action. If `e.status` is undefined or not an object with `main`/`sub`, same crash.
### How raw createResponse objects reach `load-file-error`
There IS one path where a raw `createResponse` object (with `.code`, no `.status`) reaches `load-file-error`:
When the server returns an HTTP error for a **load** request but the XHR onload handler treats it as success:
```js
// line 4652
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
api.onload(xhr); // → blob processed
} else {
api.onerror(xhr); // → error callback
}
};
```
If `xhr.status` is 0 (aborted XHR), it goes to `api.onerror`. But if the XHR is in some intermediate state, or if there's a race, it might not reach either path cleanly. Firefox's behavior with aborted XHRs and `responseType: 'blob'` can produce edge cases where `xhr.response` is a malformed blob and FilePond's blob processing triggers internal errors that propagate differently.
### Summary of the bug
| Component | Issue |
|-----------|-------|
| `createResponse()` (line 4700) | Uses `.code`, not `.status` |
| `load-file-error` handler (line 6777) | Reads `.status` on a createResponse object → `undefined` |
| Error view writer (line 7847) | No guard: crashes on `undefined.status.main` |
| Assistant view writer (line 10735) | Same: crashes on `undefined.status.main` |
The bug is in FilePond 4.32.12 vendor code. We cannot modify `filepond.min.js`.
---
## Proposed fix
### Option A: Patch the minified JS (risky but direct)
Find the `load-file-error` → `DID_THROW_ITEM_INVALID` dispatch in `filepond.min.js` and add a guard. Difficult because the code is minified and version-pinned via cache-busting query params.
### Option B: Replace server.load with a custom function (cleanest)
In `file-upload-filepond.js`, replace the `server.load` URL string with a **custom function** that:
1. Makes its own `fetch`/XHR to load.php
2. On success: calls `load(blob)` — safe because `serverFileReference` is set for existing files
3. On error: calls `error('message')` — safe because this goes through `load-request-error` (NOT `load-file-error`) which properly creates `{ status: { main, sub } }`
4. Never lets FilePond's internal `createFetchFunction` create a createResponse object with `.code`
This completely bypasses the buggy code path.
```js
load: function(source, load, error, progress, abort, headers) {
var xhr = new XMLHttpRequest();
var url = base + '/load.php?id=' + encodeURIComponent(source);
xhr.open('GET', url);
xhr.responseType = 'blob';
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
load(xhr.response);
} else {
error('Fichier introuvable (HTTP ' + xhr.status + ')');
}
};
xhr.onerror = function() {
error('Erreur réseau');
};
xhr.onabort = abort;
xhr.onprogress = function(e) {
if (e.lengthComputable) progress(e.lengthComputable, e.loaded, e.total);
};
xhr.send();
return { abort: function() { xhr.abort(); } };
},
```
### Option C: Abort in-flight loads before destroying (defense in depth)
The `destroyFilePondsIn()` function in `file-upload-filepond.js` should abort in-flight loads/processing before calling `pond.destroy()`. Already partially attempted in commit `znunoqpw` but needs clean implementation.
---
## Files involved
| File | Role |
|------|------|
| `app/public/assets/js/vendor/filepond.min.js` | FilePond 4.32.12 — **contains the bug** (unmodifiable) |
| `app/public/assets/js/app/file-upload-filepond.js` | Our FilePond wrapper — **where the fix goes** |
| `app/src/FilepondHandler.php` | Server-side FilePond endpoints (process, load, revert, remove) |
| `app/public/admin/actions/filepond/load.php` | Admin load endpoint |
| `app/public/admin/actions/filepond/process.php` | Admin process endpoint |
| `app/public/partage/actions/filepond/load.php` | Partage load endpoint |
| `app/public/partage/actions/filepond/process.php` | Partage process endpoint |
---
## Reproduction
1. `just dev` (PHP dev server on 127.0.0.1:8000)
2. Open Firefox (Firefox triggers this more readily than Chromium due to different XHR abort behavior)
3. Go to `/admin/edit.php?id=<any>` or `/admin/add.php`
4. Click "Parcourir" on the "Image de couverture" FilePond input
5. Select an image file → crash in console
6. Or: drag a file to the "TFE" FilePond input → same crash if the load fails or races with HTMX swaps
---
## What commit `znunoqpw` already did (insufficient)
- Added `Content-Type: text/plain` headers to all FilepondHandler error responses
- Fixed `server.process.onerror` to not access `.status` on a string
- Converted `server.load` from a URL string to an object with onload/onerror
- Added pre-destroy abort in `destroyFilePondsIn()`
These changes address server response format and cleanup ordering, but **do not bypass the buggy `load-file-error` → `action.status` path inside FilePond's internal code**. The crash still reproduces.
---
# HTMX/destroy race investigation (merged from filepond-race-investigation.md)
This section narrows the crash's trigger. It was formerly a separate doc
(`filepond-race-investigation.md`).
## HTMX destroy triggers
The only code path that destroys FilePond instances is `destroyFilePondsIn(el)`, called by the `htmx:beforeSwap` listener:
```js
window.htmx.on("htmx:beforeSwap", onHtmxBeforeSwap);
// → onHtmxBeforeSwap(evt) { destroyFilePondsIn(evt.detail.target); }
```
On the **edit page** (`/admin/edit.php`), the HTMX targets on page load are:
| Element | Trigger | Target selector | Scope |
|---------|---------|-----------------|-------|
| `#toast-region` | `load` | `#toast-region` | Footer `<aside>` |
| `.licence-license-choice` (hidden input) | `load` | `.licence-license-choice` | Licence fieldset |
| Language checkboxes | `change` | `#languages-required-asterisk` | A `<span>` |
| File browser buttons | `click` | `#relink-modal-body` | Modal body |
| Jury autocomplete | `change` | small targets | Form field |
| Tag search input | `input` | pill list container | Form field |
| Licence radio buttons | `change` | `.licence-license-choice` | Licence fieldset |
**None of these targets are ancestors of the `#format-fichiers-block` div**
(which contains all FilePond inputs including the cover queue). Therefore **no
HTMX swap on the edit page can trigger `destroyFilePondsIn` on the FilePond
container during normal operation.**
The `htmx:targetError` in the crash log is confirmed noise: `targetError` does
**not** fire `htmx:beforeSwap`, so no DOM swap occurs.
**Verdict: HTMX does NOT swap the FilePond container. The race hypothesis as stated is refuted.**
## In-flight state at file-pick time
No HTMX request is in flight when the crash occurs: the toast-region's
`hx-get` completes quickly (sub-second, 204 or small fragment) long before a
human clicks "Parcourir" and selects a file. Other triggers require explicit
user interaction; the native file picker is modal and blocks the main thread.
## `znunoqpw` abort analysis
Commit `znunoqpw` added a pre-destroy abort in `destroyFilePondsIn`:
```js
for (var i = 0; i < files.length; i++) {
var f = files[i];
if (f.status === 4 || f.status === 2 || f.status === 3) {
try { pond.removeFile(f); } catch (_abort) {}
}
}
pond.destroy();
```
**The status check is incorrect.** FilePond 4.32.12 internal status constants
are `INIT:1`, `IDLE:2`, `PROCESSING:3`, `PROCESSING_COMPLETE:5`, `LOADING:7`,
`LOAD_ERROR:8`, `PROCESSING_QUEUED:9`. The check catches `2` (IDLE, no-op),
`3` (PROCESSING), and `4` (does not exist). Status `7` (LOADING) is **not
caught**, so a file in the LOAD_FILE filter chain is never removed.
However, `pond.destroy()` → `ABORT_ALL` freezes items and calls `abortLoad()`.
Since `activeLoader` is null during the LOAD_FILE chain, the else branch sets
status INIT + fires `load-abort`; the chain Promise still runs but the freeze
gate (`i.frozen`) suppresses event dispatch. **So the abort mechanism prevents
the crash after destroy, but only when `destroyFilePondsIn` is actually called
— which it never is in the standard repro (HTMX never swaps the container).**
## Line 6878 catch reachability
Two paths dispatch `DID_THROW_ITEM_INVALID` to the `file-status` view writer:
```js
Wt = function(e) {
var t = e.root, n = e.action;
Nt(t.ref.main, n.status.main); // ← crashes if n.status undefined
Nt(t.ref.sub, n.status.sub);
};
```
- **Path A — `load-request-error` → SAFE.** Both branches wrap rejection in
`{ status: { main, sub } }`. No crash.
- **Path B — `load-file-error` → VULNERABLE.** Passes `t.status` through
unguarded. For local files, LOAD_FILE plugins (`FileValidateType`/`FileValidateSize`)
reject with a proper `{ status: { main, sub } }`. For server-loaded files with
error responses, `createResponse` has `.code` not `.status`, but the FileValidateType
filter still wraps its rejection correctly → still safe.
- **`.catch` handler (line 6878)** has an explicit `!t.status` guard → cannot crash.
## Verdict
- **HTMX race hypothesis: REFUTED** (no swap targets the FilePond container;
freeze gate prevents post-destroy dispatch).
- **Actual crash cause: INDETERMINATE (but narrowed).** The only vulnerable path
is `load-file-error` → `DID_THROW_ITEM_INVALID` with `status: undefined`. For
local file selection, the exact path to `undefined` status isn't identified.
The most likely trigger is a **Firefox-specific XHR abort edge case** in the
existing cover file's `server.load`, racing with adding a new local file.
## Recommended next step
Add `console.log` instrumentation to `server.load`'s onload/onerror and a global
`FilePond:error` / `window.error` trap, then reproduce in Firefox. If
`server.load onload` fires immediately before the crash, the race is confirmed
and the fix is **Option B** (custom `fetch`-based `server.load` that never
routes server responses through the LOAD_FILE filter chain).
+166
View File
@@ -0,0 +1,166 @@
# LDAP Authentication for XAMXAM Admin
> Merged from `LDAP_AUTH_PLAN.md` and `LDAP_SPEC.md`.
> **Status: not implemented.** Network access to the LDAP server is an unresolved blocker.
---
## Current state
Two-layer authentication guards the `/admin/` area:
| Layer | Mechanism | Where |
|-------|-----------|-------|
| 1 (nginx) | `auth_basic` against `/etc/nginx/.htpasswd-xamxam` | `nginx/xamxam.conf` |
| 2 (PHP) | `AdminAuth` — bcrypt hash in `site_settings.admin_password_hash` | `app/src/AdminAuth.php` + login/account handlers |
Layer 1 controls the browser's Basic Auth dialog; layer 2 provides a PHP session
gate. When both share the same password the user is authenticated transparently
(nginx passes `PHP_AUTH_PW`, `AdminAuth` verifies it against the DB hash).
## Goal
Replace both layers with LDAP so staff use their existing org credentials and
account lifecycle is handled centrally.
---
## Required information from IT
| # | Item | Example / format |
|---|------|------------------|
| 1 | LDAP server URL | `ldaps://ldap.erg.be:636` or `ldap://ldap.erg.be:389` |
| 2 | Base DN | `dc=erg,dc=be` |
| 3 | Bind DN (read-only service/search account) | `cn=xamxam-svc,ou=services,dc=erg,dc=be` |
| 4 | Bind password | (secret — read-only is sufficient) |
| 5 | User search filter | `(&(uid=%s)(memberOf=cn=xamxam-admins,ou=groups,dc=erg,dc=be))` |
| 6 | Group membership mechanism | `memberOf` (AD) **or** `member`/`uniqueMember` (OpenLDAP) |
| 7 | Username attribute | `uid` (OpenLDAP) or `sAMAccountName` (AD) |
| 8 | TLS certificate | self-signed? provide CA PEM. Otherwise confirm publicly-trusted cert |
| 9 | Admin group DN/CN | `cn=xamxam-admins,ou=groups,dc=erg,dc=be` (or decide on a name) |
## Network prerequisite (blocker)
XAMXAM's VM may not have direct TCP access to the LDAP server. Confirm before
writing any code:
```bash
nc -zv <ldap-host> 636 # LDAPS (preferred)
nc -zv <ldap-host> 389 # plain LDAP (fallback, trusted LAN only)
```
---
## Chosen approach: PHP-only LDAP (no nginx layer)
**Decision:** replace both layers with a single LDAP-backed PHP login. No
nginx module compilation; the existing `AdminAuth` session architecture stays
intact — only the credential-verification back-end changes.
This is "Option C" below (the earlier "Option A" nginx-ldap-auth daemon was
evaluated but not chosen).
---
## Architecture options (evaluated)
### Option A — `nginx-ldap-auth` daemon
- Drop-in `auth_basic` replacement using nginx `auth_request`; a Python daemon
at `127.0.0.1:8888` binds to LDAP and returns 200/403.
- Keeps the nginx-level gate; needs `python3-ldap` + the daemon.
### Option B — `ngx_http_auth_ldap_module`
- Native nginx module, requires recompiling nginx. Simpler config, less flexible.
### Option C — PHP-only LDAP (chosen)
- Remove nginx auth entirely; `AdminAuth::requireLogin()` does `ldap_bind()` +
group check directly in PHP. Simpler nginx config, no separate daemon.
---
## Implementation plan (Option C)
### Phase 1 — Server preparation
- [ ] Confirm network access (see blocker).
- [ ] `sudo apt install php8.4-ldap` + `sudo systemctl restart php8.4-fpm`; verify `php -m | grep ldap`.
- [ ] Store connection params in `site_settings` or a server-side env file (**never in repo**):
`ldap_host`, `ldap_port`, `ldap_bind_dn`, `ldap_bind_password`,
`ldap_base_dn`, `ldap_user_attr`, `ldap_group_dn` (optional).
### Phase 2 — New `LdapAuth` class
`app/src/LdapAuth.php` → `LdapAuth::verify(string $username, string $password): bool`:
1. Load params from `Database::getSetting()`.
2. `ldap_connect($host, $port)`; set `LDAP_OPT_PROTOCOL_VERSION=3`, `LDAP_OPT_REFERRALS=0`, `LDAP_OPT_NETWORK_TIMEOUT=3` (fail fast).
3. Service-account bind `ldap_bind($conn, $bind_dn, $bind_password)`.
4. Search user: `ldap_search($conn, $base_dn, "($attr=$username)", ['dn'])`, extract user DN.
5. Optional group check: verify membership against `ldap_group_dn`.
6. User bind `ldap_bind($conn, $user_dn, $password)` — the actual credential check.
7. `ldap_unbind($conn)`; return `true`/`false`.
Error handling: catch `ldap_error()`/`ldap_errno()` on each step; log failures
(never expose LDAP error strings to the browser); **fail closed** when the LDAP
server is unreachable.
### Phase 3 — Modify `AdminAuth`
| Location | Change |
|---|---|
| `AdminAuth::login()` | Replace `password_verify()` with `LdapAuth::verify($username, $password)` |
| `requireLogin()` nginx passthrough (`$_SERVER['PHP_AUTH_PW']`) | Remove (nginx `auth_basic` gone) |
| `getStoredHash()` / `setPasswordHash()` / `removePasswordHash()` | Retire |
Session logic (`SESSION_KEY`, `session_regenerate_id`, cookie hardening,
`logout()`) is unchanged — auth-method-agnostic. The login form gains a
`username` field; the password-change page is retired.
### Phase 4 — Modify the login form
Add `<input name="username">` before password; remove "change password" link;
POST handler calls `AdminAuth::login($username, $password)`.
### Phase 5 — Remove nginx `auth_basic`
In `nginx/xamxam.conf` (inside `location ^~ /admin/`), remove `auth_basic` and
`auth_basic_user_file`. Keep the `limit_req zone=admin` rate limit. Update
`scripts/deploy-server.sh` / `manage-admin-users.sh`; `sudo rm /etc/nginx/.htpasswd-xamxam`.
### Phase 6 — Retire password-management UI
Remove/repurpose `account.php` handlers + templates; remove the "Compte" nav link;
optionally clear `admin_password_hash` from `site_settings`.
### Phase 7 — Testing
- [ ] LDAP reachable from VM (Phase 1 smoke test)
- [ ] Valid staff credentials → session created, redirected to `/admin/`
- [ ] Invalid password / unknown username → denied (same error message — no enum)
- [ ] LDAP unreachable → "service unavailable", not a PHP fatal
- [ ] Group check: non-member staff → denied
- [ ] Session expiry/logout → redirected to login
- [ ] 20+ rapid logins → nginx rate limit (429)
- [ ] `/etc/nginx/.htpasswd-xamxam` removed on server
---
## What does NOT change
- PHP session layer (`AdminAuth::startSession`, `isAuthenticated`, `logout`, cookie params)
- CSRF protection on all action handlers
- nginx rate-limiting zone for `/admin/` and all other nginx security rules
- `AdminAuth` remains for **session management** (persistence, logout, CSRF, audit identity);
LDAP handles **authentication** only
## Security notes
- **Use LDAPS (port 636) exclusively** — plain LDAP transmits passwords in cleartext.
- **Service account must be read-only.**
- **Never store the service-account password in the repo.** Use `Database::setSetting()` or a server env var.
- **Never log user/service passwords.**
- **Fail closed** on connect/bind failure.
- **Escape the username** per RFC 4515 (`ldap_escape(..., LDAP_ESCAPE_FILTER)`) to prevent LDAP injection.
+141
View File
@@ -0,0 +1,141 @@
# XAMXAM — Monolog Integration Plan
## Goal
Replace the three separate logging systems (`AppLogger`, `AdminLogger`, `ErrorHandler`, `Audit`) with a single
Monolog-based logger, PSR-3 compliant, without changing any call sites in the first pass.
---
## Prerequisites
```bash
composer require monolog/monolog
```
---
## Step 1 — Understand the current landscape
Four logging systems exist. Map them before touching anything:
| Class | What it logs | Output | Call sites |
|---|---|---|---|
| `AppLogger` | App-level errors, warnings | File (JSON lines) | Scattered across controllers |
| `AdminLogger` | Admin actions, audit trail | File + DB | Admin controllers |
| `ErrorHandler` | PHP errors, exceptions | File (JSON lines) | Registered globally in boot |
| `Audit` | Data mutations (create/edit/delete) | DB table | DB layer, controllers |
Before writing any code, grep the codebase for every call site of each class and note the method signatures.
The goal is to know exactly what the new unified interface must support before designing it.
---
## Step 2 — Create a central `Logger` factory
Create `app/Logger.php` — a single factory/registry that holds named Monolog channel instances.
Do not replace any existing class yet. Just build the foundation.
```php
// Channels to create:
// - 'app' → replaces AppLogger
// - 'admin' → replaces AdminLogger
// - 'error' → replaces ErrorHandler logging
// - 'audit' → replaces Audit (DB writes stay, but structured through Monolog)
```
Each channel gets:
- A `RotatingFileHandler` writing to `xamxam-{channel}.log` (production: `/var/log/xamxam/`; dev: `storage/logs/`), keeping 30 days
- A `JsonFormatter` so log lines stay JSON (preserving the existing format contract)
- Log level set from an environment variable (`LOG_LEVEL`, defaulting to `WARNING` in production, `DEBUG` in dev)
The factory must be a simple static registry (`Logger::get('app')`) so existing call sites can be migrated
one file at a time without passing instances around.
---
## Step 3 — Replace `AppLogger`
- Rewrite `AppLogger` as a thin wrapper that delegates to `Logger::get('app')`
- Keep the existing public method signatures identical — no call sites change in this step
- Run the app, verify log output appears in `xamxam-app.log` (see the log dir for the active SAPI)
- Delete the old file-writing implementation inside `AppLogger`, keep the class as a facade for now
---
## Step 4 — Replace `ErrorHandler` logging
- In `ErrorHandler`, replace the internal `log()` method to delegate to `Logger::get('error')`
- Monolog's `ErrorHandler` integration can optionally replace the manual `set_error_handler` /
`set_exception_handler` registration — evaluate whether to adopt that or keep the custom handler
and just swap the write path
- Verify that fatal errors and uncaught exceptions still produce log entries
---
## Step 5 — Replace `AdminLogger`
This is the most complex because `AdminLogger` writes to both a file and the DB.
- File path → delegate to `Logger::get('admin')` with a `RotatingFileHandler`
- DB writes → keep as-is for now inside `AdminLogger`, or add a custom Monolog `Handler` that
writes to the DB table. A custom handler is cleaner but optional in this pass.
- Keep public method signatures identical
---
## Step 6 — Replace `Audit`
`Audit` is DB-only (no file output). Two options:
- **Option A (simple):** Keep `Audit` as-is, add a Monolog `Logger::get('audit')` that shadows
writes to a file for debuggability, call both from `Audit` methods
- **Option B (clean):** Write a custom Monolog `AuditHandler` that writes to the DB table,
replace `Audit` entirely
Option A is lower risk for this pass. Option B is the right long-term shape.
Recommend Option A now, Option B as a follow-up.
---
## Step 7 — Collapse the facades
Once all four classes delegate to Monolog internally, the facades (`AppLogger`, `AdminLogger`, etc.)
are just indirection. This step is optional in this pass but sets up the cleanup:
- Identify call sites that use `AppLogger::warning(...)` style static calls
- Decide whether to keep the facades permanently (low churn, acceptable) or migrate call sites
to `Logger::get('app')->warning(...)` directly (cleaner, more churn)
- A middle path: have the facades implement `Psr\Log\LoggerInterface` explicitly, which makes
them swappable in tests
---
## Step 8 — Add context standardisation
One of the main wins of Monolog over the current setup is structured context. Once the plumbing works,
add processors to inject consistent fields into every log entry:
- `WebProcessor` — adds URL, IP, HTTP method to every request log automatically
- A custom processor for `request_id` — generate a UUID per request in `App::boot()` and attach
it to all channels so log entries from one request can be correlated across channels
---
## What NOT to do in this pass
- Do not change any call site outside the four logger classes
- Do not change log file paths or formats yet (other tooling may depend on them)
- Do not add Slack/email handlers yet — get the foundation right first
- Do not touch `Audit`'s DB schema
---
## Definition of done
- `composer require monolog/monolog` is the only `composer.json` change
- All four logging systems write through Monolog internally
- Existing log file locations and JSON format are preserved
- No call site outside the four logger classes has changed
- `AppLogger`, `AdminLogger`, `ErrorHandler`, `Audit` still exist and work as before from the outside
- A single `LOG_LEVEL` environment variable controls verbosity across all channels
+342
View File
@@ -0,0 +1,342 @@
# PeerTube `invalid_grant` incident — diagnosis & ownership
**Date:** 2026-08-18
**Symptom reported:** uploads to PeerTube fail with
`✗ PeerTube auth failed (400): … "code":"invalid_grant"`, after the SSO admins
"updated the SSO for services such as PeerTube and the email server."
**Key premise (from the developer):** the credential set in use
(`xamxam@erg.be` + password) was provided from the organisation's **LDAP**, and
was previously the single credential that worked for both the mail server and
PeerTube — because both services were anchored to that same LDAP directory.
---
## 1. What actually changed (verified)
| # | Fact | Evidence | Verified |
|---|------|----------|----------|
| 1 | The stored credentials are **unchanged**. The SMTP username is still `xamxam@erg.be` on `mail.erg.school:587`. | `SELECT host, port, username FROM smtp_settings` | ✅ |
| 2 | SMTP **still authenticates** with those stored credentials, right now. | `creds-probe.php` → `smtp.ok = true` | ✅ |
| 3 | PeerTube **rejects the same stored credentials** at `POST /api/v1/users/token` with OAuth2 `password` grant → `invalid_grant`. | live `creds-probe.php` → `peertube.ok = false`, code `invalid_grant` | ✅ |
| 4 | Nothing is intercepting/redirecting the request at the HTTP layer. `videos.erg.be` serves PeerTube directly (`nginx`, `x-powered-by: PeerTube`), the token endpoint answers `400` with **0 redirects**. | `curl -w` trace of `/api/v1/users/token` and `GET /` | ✅ |
| 5 | The **long-lived app-token** path (`client_credentials` grant) is **rejected** for the built-in `local` OAuth client (`unsupported_grant_type`). It requires an admin-created application client. | `app-token.sh` live run | ✅ |
| 6 | The manual browser login reportedly now goes through **`portail.erg.be`** (SSO portal). | user observation (login page is a client-rendered SPA; no server-side SSO link in raw HTML) | ⚠️ observed, not server-verified |
**Conclusion of the investigation:** the credential was provided from LDAP and
worked everywhere *before* because both the mail server and PeerTube ultimately
verified against that same LDAP directory. The admins moved **both** onto
`portail.erg.school` SSO — but differently: the mail server kept its `PLAIN`/`LOGIN`
password fallback (so SMTP still works), while PeerTube *removed* its `password`
grant entirely (so the app's `password` grant now returns `invalid_grant` even
though the password is correct).
Nothing in this repo, and nothing about the credential, changed.
---
## 2. Root cause
The app authenticates to PeerTube with an **OAuth2 `password` grant**:
```
POST https://videos.erg.be/api/v1/users/token
grant_type=password
username=xamxam@erg.be
password=<the SMTP password>
```
This grant type relies on PeerTube being able to verify the `xamxam@erg.be`
password **itself** (either a local PeerTube password or a direct bind against
the account's identity directory — historically LDAP).
**Important context:** the single credential set (`xamxam@erg.be` + password)
was provided from the organisation's **LDAP**. Previously *both* the mail server
and PeerTube were anchored to that same LDAP directory, which is why one
credential worked everywhere and why the app was built to reuse it.
The admins moved PeerTube's authentication onto an **SSO / OpenID Connect
provider** (`portail.erg.be`). When PeerTube delegates login to an IdP instead of
the LDAP directory directly:
- the `password` grant can no longer bind to LDAP / verify a local password —
PeerTube now only accepts an SSO-issued identity;
- `POST /api/v1/users/token?grant_type=password` therefore returns
`invalid_grant` ("user credentials are invalid") — even though the very same
username/password still works for the mail server (which is also on SSO but
kept its `PLAIN`/`LOGIN` password fallback) and still works when you sign in
through the SSO web flow.
In other words: **the credential was never wrong, and still isn't.** What was
removed is the *path* the app used — the direct LDAP/password grant — in favour
of the SSO IdP. The app kept using the retired path.
---
## 3. Ownership
**This failure is admin-side, not a defect in this application — and not
fixable with the credential you were given.**
- The app's configuration (instance URL, channel, credentials) is unchanged.
- The stored password is still valid (proven by SMTP).
- The application made no code change that could cause this.
- You were handed a single LDAP credential that *previously* was sufficient
exactly because both services were LDAP-backed. The admins retired the
LDAP-direct path on PeerTube; they did not change your credential.
- Therefore there is no credential value you can type that will make the
`password` grant succeed — the missing piece is a *method* (local/LDAP
password grant), not a *password*.
**One honest caveat (not blame, but worth stating):** the app's *design* chose to
reuse the SMTP password as the PeerTube login and to use the `password` grant.
That coupling means an authentication change on the PeerTube/SSO side will always
surface here. That is a robustness gap on our side, but it is **not the trigger** —
the trigger was the admin-side SSO/LDAP change.
**What we need from the admins (either/or):**
1. **Re-enable the API `password` grant** for an account, or provision a
**local PeerTube account** (separate from SSO) whose password the app can use, **or**
2. **Create an application OAuth client** (PeerTube `create-client`) and hand us
`client_id`/`client_secret`, so the app can switch to the long-lived
`client_credentials` grant, **or**
3. **Expose the SSO IdP's OIDC endpoints**, so the app can authenticate against
`portail.erg.be` directly instead of PeerTube's local password grant.
---
## 3.5 Service topology (DNS + IdP discovery, verified)
| Host | IP | Reverse DNS | Identity |
|------|-----|-------------|----------|
| `videos.erg.be` | `194.78.61.186` | Belgacom static ADSL (on-prem PeerTube) | PeerTube app (`x-powered-by: PeerTube`) |
| `mail.erg.school` | `79.99.201.114` | `mail.erg.school` | **Mailcow** (`MCSESSID` cookie) |
| `portail.erg.school` | `79.99.201.119` | none | **LemonLDAP::NG SSO** (`trspan="authPortal"`, CAS + OIDC) |
Note: the developer referred to it as `portail.erg.be`, but the real host is
**`portail.erg.school`** (`portail.erg.be` does not resolve).
The SSO portal is a **LemonLDAP::NG** instance and doubles as an **OIDC provider**,
confirmed by its `.well-known/openid-configuration`:
```
issuer: https://portail.erg.school/
authorization_endpoint: https://portail.erg.school/oauth2/authorize
token_endpoint: https://portail.erg.school/oauth2/token
userinfo_endpoint: https://portail.erg.school/oauth2/userinfo
response_types_supported: ["code"]
grant_types_supported: ["authorization_code", "refresh_token"]
token_endpoint_auth_methods: ["client_secret_post", "client_secret_basic"]
```
**The decisive fact:** the IdP supports **only `authorization_code` + `refresh_token`**
and **only `response_type=code`**.
- ❌ No `password` grant (cannot exchange username/password headlessly).
- ❌ No `client_credentials` grant (so the "long-lived app token" idea is
**unsupported by this IdP** — PeerTube's own `local` client already rejected it).
- ❌ No dynamic client registration (`/oauth2/register` serves HTML, not an API).
- ❌ No device-authorization flow.
`authorization_code` is an **interactive browser** workflow (redirect to the
portal, human login, redirect back with a `code`, then exchange). A headless
backend upload job cannot complete it by itself.
### Why SMTP still works but PeerTube does not (both are SSO now)
`mail.erg.school` **is also on the new SSO** — verified from its post-STARTTLS
SMTP capabilities:
```
AUTH PLAIN LOGIN XOAUTH2 OAUTHBEARER PLAIN LOGIN XOAUTH2 OAUTHBEARER
```
The presence of `XOAUTH2` / `OAUTHBEARER` proves the mail server was wired up
for SSO/OAuth2 SMTP auth. **But it kept `PLAIN` and `LOGIN` alongside**; the app
authenticates with `AuthType = 'PLAIN'` (`SmtpRelay.php:229`), which still works
against the remaining LDAP/legacy passdb.
The two migrations were different in kind:
| Service | SSO added | Legacy password path | App's method | Outcome |
|---------|-----------|----------------------|--------------|---------|
| `mail.erg.school` | ✅ `XOAUTH2`/`OAUTHBEARER` | ✅ **kept** `PLAIN`/`LOGIN` | `PLAIN` | works |
| `videos.erg.be` | ✅ OIDC `authorization_code` | ❌ **removed** `password` grant | `password` grant | broken |
The mail migration was **additive** (SSO *alongside* password login); the PeerTube
migration was a **hard cutover** (SSO *replaced* the password grant). That one
difference is why the same credential works for mail and fails for PeerTube.
---
## 4. How to solve it
**Honest headline: there is no way to fix this with just the LDAP credential you
already hold, because the IdP offers no non-interactive grant type** (no
`password`, no `client_credentials`, no device flow). Every viable fix requires
an admin action first.
### Short-term — unblock (admin action, no code)
Have the admins restore a **local/LDAP-direct `password` grant** on PeerTube for
the `xamxam@erg.be` account (i.e. make PeerTube verify the password itself again), **or**
provision a dedicated local PeerTube account whose password the app can use.
This is the only option that needs *no* app code change.
### Mid-term — OIDC `authorization_code` + `refresh_token` (the SSO-first fix)
This is the **correct** path now that the IdP is known to be `portail.erg.school`
( LemonLDAP::NG OIDC, only `authorization_code`/`refresh_token`). It is **not**
headless-able in one shot — it needs a one-time human login in the browser to get
the first `refresh_token`, after which the app can keep refreshing indefinitely
without a human.
Concretely the admins must do **two small things**:
1. **Register an OIDC client** for this app on `portail.erg.school` (there is no
self-service `/oauth2/register`, so an admin creates it) and give us a
`client_id` + `client_secret`.
2. Give us a **one-time authorization** (the login on the portal) so we can
exchange the `code` for an `access_token` + a **`refresh_token`**.
Then we store the `refresh_token` (encrypted, like the SMTP password already is)
and change `PeerTubeService::obtainToken()` to:
- refresh via `POST https://portail.erg.school/oauth2/token` (`grant_type=refresh_token`), and
- use the resulting token the way it uses the current one.
This is genuinely SSO-aware and survives password rotations, but it still needs
an admin to register the client and one interactive login to seed the refresh
token.
### The earlier "long-lived app token" idea is now ruled out
`client_credentials` is **not supported** by this IdP (verified via discovery),
and PeerTube's own `local` client already rejected it. Drop it as an option; the
`scripts/app-token.sh` probe is kept only as a diagnostic for non-SSO PeerTube
deployments.
### How to demonstrate this to the admins (copy-paste commands)
Every claim in this report is reproducible with just `curl` — no credentials
leaked, no app code involved. Run these and paste the outputs.
**1. The IdP is the SSO (LemonLDAP OIDC), and it does NOT offer a `password` or
`client_credentials` grant.**
```bash
curl -sS https://portail.erg.school/.well-known/openid-configuration | jq '{issuer, grant_types_supported, response_types_supported, token_endpoint, authorization_endpoint}'
# → "grant_types_supported": ["authorization_code", "refresh_token"]
# (no "password", no "client_credentials")
```
**2. PeerTube itself rejects the old password grant** (this is the `invalid_grant`
the app sees — note: the credentials are *not* shown, only the server's reply).
```bash
# fetch the PeerTube local OAuth client (public endpoint)
curl -sS https://videos.erg.be/api/v1/oauth-clients/local | jq .
# ask PeerTube for a token via the password grant (it refuses)
curl -sS -X POST https://videos.erg.be/api/v1/users/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=password&response_type=code&client_id=<client_id>&client_secret=<client_secret>&username=xamxam@erg.be&password=<PASSWORD>' \
| jq .
# → 400 { "code": "invalid_grant", "detail": "Invalid grant: user credentials are invalid" }
```
**3. The mail server is *also* on SSO, but kept the legacy `PLAIN`/`LOGIN`
password path — which is why SMTP still works.** Show its advertised AUTH
mechanisms after STARTTLS:
```bash
python3 - <<'EOF'
import smtplib, ssl
s = smtplib.SMTP("mail.erg.school", 587, timeout=20)
s.ehlo(); s.starttls(context=ssl.create_default_context())
code, _ = s.ehlo()
print("AUTH mechanisms:", s.esmtp_features.get("auth", "NONE"))
s.quit()
EOF
# → "AUTH ... PLAIN LOGIN XOAUTH2 OAUTHBEARER ..."
# XOAUTH2/OAUTHBEARER = SSO added; PLAIN/LOGIN = legacy kept
```
**4. The one-sentence version to put in an email to the admins:**
> PeerTube now authenticates only through `portail.erg.school` (OIDC), and its
> old `password` grant has been removed, so backend uploads fail with
> `invalid_grant`. The mail server kept its `PLAIN`/`LOGIN` password fallback,
> which is why SMTP still works. To restore uploads, either re-enable a
> password/LDAP grant for the `xamxam@erg.be` account on PeerTube, **or** register
> an OIDC client for this app and let us use `authorization_code` + `refresh_token`.
### The open question to put to the admins (the "❓")
Every fact above is **verifiable from the outside** with `sso-diagnose.sh`. The
one thing it cannot establish remotely is **what authentication information
LemonLDAP actually forwards to PeerTube after it authenticates a user**. That is
infrastructure-side configuration, and it is the one remaining unknown.
Three architectures are possible after the migration; the admins must decide
which one is the *intended* contract for an external app hitting the PeerTube API:
1. **Browser/user SSO** (PeerTube is an OIDC client of LemonLDAP) — the app
would use the IdP's `authorization_code` + `refresh_token` (interactive, needs
a one-time human login + a registered OIDC client).
2. **Machine-to-machine** (direct PeerTube API) — needs a non-interactive
mechanism (a PeerTube service account / API token), because the IdP does **not**
advertise `client_credentials`.
3. **Reverse-proxy SSO** (LemonLDAP authenticates, forwards identity headers,
PeerTube trusts them) — needs LemonLDAP to inject the identity (e.g.
`Auth-User`/`X-Remote-User`) **and** PeerTube configured to consume it.
`request-side identity header probe → SKIPPED` in the diagnostic means we have
**not yet confirmed** that (3) is even wired up — i.e. that PeerTube receives
*who* LemonLDAP authenticated. That is the next thing to pin down.
Copy-paste ask for the admins:
> `videos.erg.be` is now behind LemonLDAP::NG; its OIDC discovery advertises only
> `authorization_code` and `refresh_token` (no `password`, no
> `client_credentials`). Could you confirm what mechanism external applications
> are now supposed to use to authenticate to the PeerTube API — (1) an OIDC
> authorization-code flow through `portail.erg.school`, (2) a forwarded
> authenticated-user identity (and if so, which header), or (3) a separate
> non-interactive service account / API token? And can you verify that the
> LemonLDAP-protected `videos.erg.be` vhost forwards the authenticated identity
> to the PeerTube backend as intended?
### Responsibility boundary (no zero-blame claim)
The *trigger* is unambiguously admin/infrastructure-side: the IdP removed the
`password` grant the app used, while the credential itself stayed valid (SMTP
still authenticates). But the app is **not** blameless in design: it coupled
PeerTube auth to the SMTP password and the `password` grant, so an IdP-side change
surfaces here. And **if** the app must keep speaking to PeerTube, it *will* need
code changes — to implement whichever supported grant the admins specify. Those
changes cannot be written until the admins settle the new authentication contract,
which is the blocking unknown above.
---
## 5. Tooling produced during this investigation
| Tool | Purpose |
|------|---------|
| `scripts/creds-probe.php` | Reads stored creds, probes SMTP AUTH + PeerTube password grant, prints JSON |
| `scripts/creds-test.sh` | gum UI wrapper; logs results (never the password); `just creds-test` |
| `scripts/app-token.sh` | gum probe for the long-lived `client_credentials` app-token path; `just app-token` |
| `scripts/sso-diagnose.sh` | Verifies every claim above (DNS/rDNS, IdP discovery, PeerTube OAuth + password grant, SMTP AUTH, identity-header propagation) into `sso-diagnose.log`; `just sso-diagnose` |
| `scripts/echo-headers.php` | Request-side echo endpoint to reveal the identity header LemonLDAP injects; pair with `--echo <url>` |
| `PeerTubeService::probeAuth()` | Public helper isolating token issuance from channel resolution |
All live runs are reproducible:
```bash
just creds-test # proves SMTP ok, PeerTube invalid_grant
just app-token # proves client_credentials rejected for the local client
just sso-diagnose # full reproducible report → sso-diagnose.log (no secrets)
# the one remaining unknown (who LemonLDAP forwards to PeerTube) needs
# scripts/echo-headers.php hosted behind the SAME LemonLDAP vhost:
# bash scripts/sso-diagnose.sh --echo 'https://videos.erg.be/path/to/echo-headers.php'
```
+615
View File
@@ -0,0 +1,615 @@
# Posterg: Refactoring Recommendations
Concrete improvements to the separation between templating, routing, and backend logic — staying in PHP, no framework required.
---
## 1. Extract a Micro-Router + Middleware Pipeline
### Problem
Every file repeats the same preamble. The 7 action handlers and 17 page controllers all independently:
```php
require_once __DIR__ . '/../../config/bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
require_once __DIR__ . '/../../src/Database.php';
```
This is ~6-8 identical lines per file × 24 files = ~170 lines of pure duplication. When the CSRF check pattern changes, every action handler must be updated in lockstep.
### Solution
Create `src/App.php` — a thin request dispatcher with middleware hooks:
```php
// src/App.php
class App {
private static bool $booted = false;
/** Boot once per request: load Database, ensure CSRF token exists. */
public static function boot(): Database {
if (!self::$booted) {
require_once APP_ROOT . '/src/Database.php';
self::$booted = true;
}
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return Database::getInstance();
}
/** Gate for admin pages: auth + CSRF token. */
public static function adminGuard(): Database {
require_once APP_ROOT . '/src/AdminAuth.php';
AdminAuth::requireLogin();
return self::boot();
}
/** Validate CSRF on POST. Call at the top of every action handler. */
public static function verifyCsrf(): void {
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|| !isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
exit('CSRF token invalide.');
}
}
/** Regenerate CSRF after a successful mutation. */
public static function rotateCsrf(): void {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
/** Flash a message into the session and redirect. */
public static function redirect(string $url, ?string $success = null, ?string $error = null): never {
if ($success) $_SESSION['success'] = $success;
if ($error) $_SESSION['error'] = $error;
header('Location: ' . $url);
exit;
}
}
```
Every admin page becomes:
```php
<?php
require_once __DIR__ . '/../../config/bootstrap.php';
$db = App::adminGuard();
// ... page-specific logic
```
Every action handler becomes:
```php
<?php
require_once __DIR__ . '/../../../config/bootstrap.php';
$db = App::adminGuard();
App::verifyCsrf();
// ... mutation logic
App::rotateCsrf();
App::redirect('/admin/', success: 'Done.');
```
**Impact**: Eliminates ~170 lines of duplication. Centralises the CSRF lifecycle (generate, verify, rotate) in one place. Any change to the auth or CSRF pattern is a single-file edit.
---
## 2. Separate Controllers from Templates
### Problem
Every page file (e.g. `public/index.php`, `public/search.php`, `public/admin/edit.php`) is a single file that mixes three concerns:
1. **Data fetching** (DB queries, input validation, pagination math)
2. **View variable preparation** (`$pageTitle`, `$ogTags`, `$extraCss`, `$bodyClass`)
3. **HTML rendering** (the entire template, inline)
`system.php` is the extreme case: 400+ lines of PHP logic (systemd checks, curl pings, disk stats, log parsing, nginx syntax highlighting) followed by 200+ lines of inline `<style>`, then 200+ lines of HTML, then 30+ lines of inline `<script>`.
`search.php` is another: the répertoire index view and the search results view are two entirely different pages sharing a single file because they share a URL.
### Solution
Introduce a `controllers/` directory. Each controller is a function that does the data work and returns an associative array. The page file becomes a thin bridge.
**Directory structure:**
```
src/
App.php ← new: middleware/helpers
controllers/
HomeController.php
SearchController.php
TfeController.php
admin/
ThesisListController.php
ThesisEditController.php
ThesisAddController.php
TagController.php
PageController.php
SystemController.php
AccountController.php
...existing files...
templates/
public/
home.php
search-results.php
search-index.php
tfe.php
apropos.php
licence.php
admin/
thesis-list.php
thesis-edit.php
thesis-add.php
tags.php
pages-list.php
pages-edit.php
system.php
account.php
...existing shared templates (head.php, header.php, footer.php)...
```
**Example — `search.php` split:**
```php
// src/controllers/SearchController.php
class SearchController {
public static function index(Database $db): array {
// Collect params, run queries, compute pagination
// ...all current logic from the top of search.php...
return [
'hasSearch' => $hasSearch,
'results' => $results,
'totalItems' => $totalItems,
'totalPages' => $totalPages,
'years' => $years,
'orientations'=> $orientations,
'apPrograms' => $apPrograms,
'keywords' => $keywords,
'students' => $students,
'authorMap' => $authorMap,
'page' => $page,
'error' => $validationError,
// Template config
'pageTitle' => 'Répertoire – Posterg',
'bodyClass' => 'search-body',
'extraCss' => ['/assets/css/search.css'],
'currentNav' => 'repertoire',
];
}
}
```
```php
// public/search.php (entire file)
<?php
require_once __DIR__ . '/../config/bootstrap.php';
require_once APP_ROOT . '/src/RateLimit.php';
// Rate limiting (stays here — it's a routing concern)
$rateLimit = new RateLimit(30, 60);
if (!$rateLimit->check()) { /* ...429 response... */ }
$rateLimit->sendHeaders();
$db = App::boot();
require_once APP_ROOT . '/src/controllers/SearchController.php';
$data = SearchController::index($db);
extract($data); // populates $hasSearch, $results, $pageTitle, etc.
include APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
if ($hasSearch) {
include APP_ROOT . '/templates/public/search-results.php';
} else {
include APP_ROOT . '/templates/public/search-index.php';
}
include APP_ROOT . '/templates/footer.php';
```
```
// templates/public/search-results.php
// Pure HTML + minimal <?= ?> for output. No DB queries. No input processing.
```
**Impact**: Templates become auditable for XSS — they only do output. Controllers are testable — they return arrays, no output buffering needed. The `extract()` bridge keeps the familiar variable-name convention without changing every template.
---
## 3. Introduce a `render()` Helper to Replace the 5-Line Include Chain
### Problem
Every page ends with the same sequence:
```php
include APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
// ... main content ...
include APP_ROOT . '/templates/footer.php'; // or admin/footer.php
```
The admin variant also requires `$isAdmin = true; $bodyClass = 'admin-body';` to be set before the head include. Forgetting any variable or include breaks the page silently.
### Solution
Add a `render()` function to `App`:
```php
// In src/App.php
public static function render(string $template, array $vars = []): void {
extract($vars);
include APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
include APP_ROOT . '/templates/' . $template;
// Choose footer based on admin flag
if (!empty($isAdmin)) {
include APP_ROOT . '/templates/admin/footer.php';
} else {
include APP_ROOT . '/templates/footer.php';
}
}
```
Every page becomes:
```php
// public/licence.php
<?php
require_once __DIR__ . '/../config/bootstrap.php';
$db = App::boot();
require_once APP_ROOT . '/src/controllers/LicenceController.php';
App::render('public/licence.php', LicenceController::index($db));
```
Every admin page becomes:
```php
// public/admin/tags.php
<?php
require_once __DIR__ . '/../../config/bootstrap.php';
$db = App::adminGuard();
require_once APP_ROOT . '/src/controllers/admin/TagController.php';
App::render('admin/tags.php', TagController::index($db));
```
**Impact**: No more forgotten includes. The head→header→content→footer pipeline is enforced. Admin footer selection is automatic. Template variables are explicit (passed as array keys, not ambient scope).
---
## 4. Consolidate Action Handlers into Controller Methods
### Problem
The `public/admin/actions/` directory contains 7 POST-only files that each:
1. Require bootstrap + auth
2. Verify CSRF
3. Extract + validate `$_POST` data
4. Call `Database` methods
5. Rotate CSRF
6. Flash a message
7. Redirect
Steps 1-2 and 5-7 are identical in every file. The actual business logic (step 4) is usually 5-15 lines.
`actions/publish.php` (95 lines) does exactly one thing: flip `is_published` on 1-N theses. The other 80 lines are auth, CSRF, validation boilerplate.
### Solution
Merge each action into its controller as a `handlePost()` or action-specific static method:
```php
// src/controllers/admin/ThesisListController.php
class ThesisListController {
public static function index(Database $db): array {
// ... current admin/index.php data logic ...
}
public static function publish(Database $db): never {
App::verifyCsrf();
$action = $_POST['action'] ?? '';
$isBulk = !empty($_POST['bulk']);
// ... 15 lines of actual logic ...
App::rotateCsrf();
App::redirect('/admin/', success: "$count TFE(s) publié(s).");
}
public static function toggleMaintenance(Database $db): never {
App::verifyCsrf();
// ... 6 lines ...
App::rotateCsrf();
App::redirect('/admin/', success: 'Maintenance toggled.');
}
}
```
The page file handles dispatch:
```php
// public/admin/index.php
<?php
require_once __DIR__ . '/../../config/bootstrap.php';
$db = App::adminGuard();
require_once APP_ROOT . '/src/controllers/admin/ThesisListController.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['_action'] ?? '';
match($action) {
'publish' => ThesisListController::publish($db),
'maintenance' => ThesisListController::toggleMaintenance($db),
default => App::redirect('/admin/', error: 'Action inconnue.'),
};
}
App::render('admin/thesis-list.php', ThesisListController::index($db));
```
**Or** keep the existing `actions/*.php` files but reduce each to 3 lines:
```php
// public/admin/actions/publish.php (entire file)
<?php
require_once __DIR__ . '/../../../config/bootstrap.php';
$db = App::adminGuard();
App::verifyCsrf();
require_once APP_ROOT . '/src/controllers/admin/ThesisListController.php';
ThesisListController::publish($db);
```
**Impact**: The `actions/` directory goes from 7 files × 50-100 lines each ≈ 500 lines, to 7 files × 5 lines each ≈ 35 lines. The business logic moves into testable controller methods.
---
## 5. Extract Inline CSS and JS from `system.php`
### Problem
`system.php` contains:
- 180 lines of `<style>` embedded in the page
- 40 lines of `<script>` embedded in the page
- 12 PHP helper functions defined inline (`safeExec`, `systemdStatus`, `localHttpCheck`, `humanBytes`, `statusLabel`, `statusClass`, `readLogTail`, `logLineClass`, `nginxLineClass`)
This makes it the largest file in the project (500+ lines) and impossible to cache the CSS/JS independently.
### Solution
1. Move the `<style>` block → `public/assets/css/system.css`, reference it via `$extraCss`
2. Move the `<script>` block → `public/assets/js/system.js`, reference it via `$extraJs`
3. Move the 12 helper functions → `src/controllers/admin/SystemController.php`
4. Move the data-gathering logic (checks array, PHP info, disk stats, log reading) into `SystemController::index()`
The template becomes pure HTML with `<?= ?>` interpolation.
**Impact**: `system.php` goes from ~500 lines to ~5 lines (boot + controller + render). The CSS/JS becomes cacheable by the browser (nginx `expires 30d` rule already exists for `.css`/`.js`). The helpers become unit-testable.
---
## 6. Introduce Template Partials for Repeated UI Patterns
### Problem
Several HTML patterns are copy-pasted across templates:
**Flash messages** — Identical block in `admin/index.php`, `admin/edit.php`, `admin/tags.php`, `admin/pages.php`, `admin/account.php`:
```php
<?php if (isset($_SESSION['error'])): ?>
<div class="admin-alert admin-alert--error">⚠ <?= htmlspecialchars($_SESSION['error']); unset($_SESSION['error']); ?></div>
<?php endif; ?>
<?php if (isset($_SESSION['success'])): ?>
<div class="admin-alert admin-alert--success">✓ <?= htmlspecialchars($_SESSION['success']); unset($_SESSION['success']); ?></div>
<?php endif; ?>
```
**Pagination** — Near-identical block in `index.php` and `search.php` (30 lines each, slightly different URL building).
**Select dropdowns with "selected" logic** — Repeated in `admin/add.php` and `admin/edit.php` for orientation, AP, finality, license, access type.
**Jury fieldset + JS** — Duplicated between `admin/add.php` and `admin/edit.php` (50+ lines of identical HTML + 20 lines of identical JS).
### Solution
Create small partial templates:
```
templates/
partials/
flash-messages.php ← reads $_SESSION['error'] / $_SESSION['success']
pagination.php ← receives $page, $totalPages, $baseUrl
admin/
select-field.php ← receives $name, $label, $options, $selected
checkbox-list.php ← receives $name, $label, $options, $checked
jury-fieldset.php ← receives $jury (array), outputs fieldset + JS
```
**Example — `flash-messages.php`:**
```php
<?php
// templates/partials/flash-messages.php
$_flashError = $_SESSION['error'] ?? $_SESSION['admin_error'] ?? $_SESSION['edit_error'] ?? null;
$_flashSuccess = $_SESSION['success'] ?? $_SESSION['admin_success'] ?? $_SESSION['edit_success'] ?? null;
unset($_SESSION['error'], $_SESSION['success'], $_SESSION['admin_error'],
$_SESSION['admin_success'], $_SESSION['edit_error'], $_SESSION['edit_success']);
?>
<?php if ($_flashError): ?>
<div class="admin-alert admin-alert--error">⚠ <?= htmlspecialchars($_flashError) ?></div>
<?php endif; ?>
<?php if ($_flashSuccess): ?>
<div class="admin-alert admin-alert--success">✓ <?= htmlspecialchars($_flashSuccess) ?></div>
<?php endif; ?>
```
This also fixes a latent bug: the project uses 6 different session keys for flash messages (`error`, `success`, `admin_error`, `admin_success`, `edit_error`, `edit_success`, `form_error`). Centralising flash handling would unify these into two keys.
**Impact**: Eliminates ~200 lines of duplicated HTML. The jury fieldset (duplicated between add and edit) becomes a single 50-line partial. Form field partials make admin pages shorter and more consistent.
---
## 7. Unify Flash Message Keys
### Problem
The project uses 7 different session keys for flash messages across different pages:
| Key | Used by |
|-----|---------|
| `$_SESSION['error']` | `admin/index.php`, `visibility.php`, `account.php` |
| `$_SESSION['success']` | `admin/index.php`, `visibility.php`, `pages.php`, `account.php` |
| `$_SESSION['admin_error']` | `tags.php` |
| `$_SESSION['admin_success']` | `tags.php` |
| `$_SESSION['edit_error']` | `admin/edit.php` |
| `$_SESSION['edit_success']` | `admin/edit.php` |
| `$_SESSION['form_error']` | `admin/add.php` |
| `$_SESSION['form_data']` | `admin/add.php` (re-population) |
This means flash messages can silently persist across pages if a redirect sends the user somewhere that doesn't read the matching key.
### Solution
Standardise on two keys: `$_SESSION['_flash_error']` and `$_SESSION['_flash_success']`. Consume them in the shared `flash-messages.php` partial. Add `App::flash(string $type, string $message)` helper.
---
## 8. Move OG Tag Construction into Controller Logic
### Problem
Every public page constructs `$ogTags` inline before the template, with ~15 lines of boilerplate. `tfe.php` has 25 lines of OG logic including image resolution (banner → cover → none) and description truncation.
### Solution
Move OG tag logic into each controller's return array. Create a helper:
```php
// In src/App.php or a dedicated helpers file
public static function ogTags(array $overrides = []): array {
return array_merge([
'type' => 'website',
'site_name' => 'Posterg – ERG',
'title' => 'Posterg',
'description' => '',
'url' => '',
'image' => '',
], $overrides);
}
```
The TFE controller builds the OG image resolution as part of its data preparation. The template never touches it.
---
## Summary: Proposed File Layout
```
src/
App.php ← NEW: boot, adminGuard, verifyCsrf, render, redirect, flash
Database.php ← unchanged
AdminAuth.php ← unchanged
RateLimit.php ← unchanged
Parsedown.php ← unchanged
config.php ← unchanged
controllers/
HomeController.php ← extracted from public/index.php
SearchController.php ← extracted from public/search.php
TfeController.php ← extracted from public/tfe.php
AproposController.php ← extracted from public/apropos.php
LicenceController.php ← extracted from public/licence.php
admin/
ThesisListController.php ← extracted from admin/index.php + actions/publish.php + actions/maintenance.php
ThesisEditController.php ← extracted from admin/edit.php + actions/edit.php
ThesisAddController.php ← extracted from admin/add.php + actions/formulaire.php
TagController.php ← extracted from admin/tags.php + actions/tag.php
PageController.php ← extracted from admin/pages.php + pages-edit.php + actions/page.php
SystemController.php ← extracted from admin/system.php (400 lines of logic)
AccountController.php ← extracted from admin/account.php + actions/account.php
ImportController.php ← extracted from admin/import.php
templates/
head.php ← unchanged
header.php ← unchanged
footer.php ← unchanged
search-bar.php ← unchanged
admin/
footer.php ← unchanged
partials/
flash-messages.php ← NEW
pagination.php ← NEW
admin/
jury-fieldset.php ← NEW (deduplicated from add.php + edit.php)
select-field.php ← NEW
checkbox-list.php ← NEW
public/
home.php ← extracted HTML from index.php
search-results.php ← extracted HTML from search.php (results view)
search-index.php ← extracted HTML from search.php (répertoire view)
tfe.php ← extracted HTML from tfe.php
apropos.php ← extracted HTML from apropos.php
licence.php ← extracted HTML from licence.php
admin/
thesis-list.php ← extracted HTML from admin/index.php
thesis-edit.php ← extracted HTML from admin/edit.php
thesis-add.php ← extracted HTML from admin/add.php
tags.php ← extracted HTML from admin/tags.php
pages-list.php ← extracted HTML from admin/pages.php
pages-edit.php ← extracted HTML from admin/pages-edit.php
system.php ← extracted HTML from admin/system.php
account.php ← extracted HTML from admin/account.php
public/
index.php ← 5-8 lines: boot, controller, render
search.php ← 8-10 lines: boot, rate limit, controller, render
tfe.php ← 5-8 lines
apropos.php ← 5-8 lines
licence.php ← 5-8 lines
media.php ← unchanged (already clean)
maintenance.php ← unchanged
live-reload.php ← unchanged
admin/
index.php ← 8-10 lines: boot, dispatch POST or render
edit.php ← 8-10 lines
add.php ← 8-10 lines
tags.php ← 6-8 lines
pages.php ← 5-8 lines
pages-edit.php ← 5-8 lines
system.php ← 5-8 lines
account.php ← 8-10 lines
import.php ← 5-8 lines
login.php ← unchanged
logout.php ← unchanged
actions/ ← can be REMOVED (merged into controllers)
OR reduced to 3-5 line stubs
```
## Estimated Impact
| Metric | Before | After |
|--------|--------|-------|
| Lines in `public/` page files | ~4,200 | ~200 |
| Lines in `actions/` handlers | ~500 | 0 (merged) |
| Duplicated boilerplate | ~300 lines | ~0 |
| Duplicated HTML (jury, flash, pagination) | ~200 lines | ~0 |
| `system.php` | 530 lines | ~8 lines (page) + ~180 (controller) + ~100 (CSS) + ~30 (JS) |
| Testable controller methods | 0 | ~15 |
| Flash session key variants | 7 | 2 |
Total net reduction: ~800–1,000 lines eliminated through deduplication, with cleaner separation making the remaining code auditable and testable.
## Execution Order
This can be done incrementally, one page at a time, with zero disruption:
1. **Create `src/App.php`** with `boot()`, `adminGuard()`, `verifyCsrf()`, `rotateCsrf()`, `redirect()`, `render()`, `flash()`
2. **Create `templates/partials/flash-messages.php`** and adopt it in one admin page
3. **Extract `SystemController`** — the biggest single-file win (500 → 8 lines)
4. **Extract `SearchController`** — the most complex public page
5. **Extract `ThesisEditController`** — merges `edit.php` + `actions/edit.php`, deduplicates jury fieldset
6. **Do remaining controllers one by one**, smallest first
7. **Unify flash keys** project-wide as the last step
@@ -0,0 +1,111 @@
# Répertoire — Mobile Responsive Propositions
> Date: 2026-06-22
> Context: Adapting the interactive 6-column filter model to mobile viewports
## Current Architecture
- `repertoire.php` — page wrapper, loads HTMX + popover JS
- `repertoire-index.php` — 6-column filter index (also served as HTMX partial on filter toggles)
- `repertoire.css` — all styling, 3 breakpoints: 1024px → 3 cols, 600px → 1 col
**Desktop interactive model:**
1. **6-column filter grid** — years, AP, orientations, finality, students, keywords. Each entry is a `<button>` with `hx-get` swapping `#repertoire-index` via HTMX.
2. **Hover-to-preview** on student names — `hx-get` on `mouseenter`, renders `<div id="student-popover">` positioned via JS.
3. **Pure CSS hamburger menu** in global header, separate from this page.
## Problems on Mobile
1. **Column overload** — Six vertically-stacked scrollable lists, each with independent `overflow-y: auto`, create deeply nested scrolling. Hostile UX — scroll hijacking, content below hard to discover.
2. **Hover-based preview impossible on touch** — `mouseenter`/`mouseleave` has no equivalent on touch devices. Student links navigate to `/tfe?id=…` on tap, but preview popover never fires.
3. **HTMX navigation cost** — Each filter tap triggers full HTTP round-trip + grid re-render. On mobile networks, latency is perceptible. pushState doubles the fetch on back-navigation.
4. **Touch targets** — Filter entries use `padding: var(--space-3xs) 0` (~5px vertical). Tapping "Design et Politique du Multiple" without mis-hitting neighbors is error-prone.
5. **No filter state visibility** — Once scrolled past the first 2-3 sections, selected filter states are invisible (buried in scroll regions).
6. **Header search bar competition** — `header-search-wrap` renders above main, competing for vertical space with the full-height column layout.
---
## Proposition A — Accordion + Active Filters Bar (minimal JS)
Each of the 6 filter columns becomes a collapsible accordion section. A persistent "active filters" bar shows selected filters as removable chips. Students column becomes a tap-to-open drawer (replaces hover).
**Implementation:**
- `<details>` + `<summary>` on each section heading for no-JS baseline. Enhanced with a tiny JS toggle for smooth animation if desired.
- On mobile, only one accordion section open at a time. On desktop, all remain open (unchanged).
- Extract selected filters into a horizontal chip bar above the accordion — each chip fires the same HTMX toggle URL to de-select.
- Replace hover popover with a click-to-open bottom sheet: tap a student name → slide-up panel shows thesis cards.
- Years column optionally becomes a horizontal pill bar (scannable horizontally).
**Pros:** Progressive enhancement, works without JS (details/summary), low structural change to PHP partial.
**Cons:** Opening/closing accordions to scan filters is slower than the all-visible desktop model.
---
## Proposition B — Tab Bar with Filter View Switcher
The 6 filter dimensions become horizontal tabs (scrollable on small screens). Tapping a tab shows only that dimension's list full-width. A "Résultats" badge in the header shows match count.
**Implementation:**
- `<nav role="tablist">` across the top, horizontally scrollable (CSS `scroll-snap`).
- Each tab panel renders one `<section>` at full width. Single filter column, no nested scroll — the list scrolls with the page.
- Students tab replaces hover popover with inline cards: tap a name → inline expand to show thesis previews.
- Selected filter count shown as a badge on each tab.
- Active filter chips in a persistent bar.
**Pros:** Single scroll context, excellent for scanning one dimension at a time, familiar mobile pattern.
**Cons:** Significant PHP restructuring — each tab panel excludes the other 5 columns. Needs JS for tab switching (or HTMX per tab). Separates filters from each other visually.
---
## Proposition C — Drawer/Panel Pattern (most native-feeling)
Results-first view with a filter drawer. A "Filtres" button opens a side panel / bottom sheet containing all filter columns. Applying filters updates results and closes the drawer.
**Implementation:**
- Mobile: fixed "Filtres (N)" button at the top. Tap → sliding bottom sheet with all 6 filter columns in a single scrollable panel.
- Tapping an entry applies the filter via HTMX, optionally closes the sheet (or stays open for multi-filtering).
- Count badge shows active filter count.
- Desktop: unchanged — 6-column grid remains.
- Students: scrollable list in main content area below filter button, tap-to-expand cards.
- Popover JS deleted entirely.
**Pros:** Extensively tested pattern (maps apps, e-commerce). Closest to native mobile filter UX. Desktop unchanged. Single scroll container — no nested scrolling.
**Cons:** Needs new drawer/sheet component. Results-first approach changes the page's purpose — currently the page *is* the filters, not a results view.
---
## Proposition D — Hybrid: Filter Bar + Scrollable Carousel Sections
Keep the 6-column structure but transform into a horizontally scrollable carousel on mobile. Sections sit in a horizontal scroll container with snap points.
**Implementation:**
- `@media (max-width: 640px)`: `.repertoire-index` grid becomes flex row with `overflow-x: auto; scroll-snap-type: x mandatory`.
- Each `.repertoire-col` becomes `scroll-snap-align: start; min-width: 85vw`.
- Heading acts as sticky within each snap panel.
- Students column moves to full-width below the carousel.
- Navigation dots or scroll hint at the bottom.
- Popover becomes long-press or tap-and-hold on students.
**Pros:** Preserves "browse everything" mental model. Low structural change — just CSS. Sections independently scannable.
**Cons:** Horizontal carousels have discoverability issues. Popover still needs touch replacement. Horizontal+vertical scroll nesting creates gesture conflicts.
---
## Recommended Approach
**Proposition A (Accordion + Chip Bar)** is the most pragmatic starting point:
1. Least structural PHP changes
2. Eliminates nested scrolling (the #1 problem)
3. Provides clear filter-state visibility via chips
4. Degrades gracefully (details/summary works without JS)
5. Same HTMX endpoints work — only container shape changes
### Phased Implementation
1. **Phase 1:** Accordion sections + active filter chip bar (mobile only via CSS media query). Desktop unchanged.
2. **Phase 2:** Replace student hover popover with tap-to-open bottom sheet (touch-aware JS, keep hover for desktop).
3. **Phase 3:** Polish — touch target sizing (min 44px), filtered count badges, scroll-position memory on HTMX swap.
+201
View File
@@ -0,0 +1,201 @@
# Fiche technique
## Différentes catégories / métadonnées
**• Titre du TFE**
**• Sous-titre (si applicable)**
**• Auteur·ice(s)**
**• Contact (optionnel) [mail/site/insta/etc.]**
**• Promoteur·ice(s)**
**• Année**
**• Orientation [liste prédéfinie]**
* Arts Numériques / Dessin / Cinéma d'animation / Installation-Performance / Peinture / Photographie / Sculpture / Vidéographie / Graphisme / Typographie / Design Numérique / Illustration / Bande-Dessinés / Sérigraphie / Gravure
**• AP [liste prédéfinie]**
* Narration Spéculative / Design et Politique du Multiple [DPM] / Atelier Pratiques Situées [APS] / Lieux, Interdisciplinarités, Écologie, Nécessité, Systèmes [L.I.E.N.S.]
**• Finalité du master [liste prédéfinie]**
* Approfondi / Enseignement /Spécialisé
**• Langue du TFE [liste prédéfinie + option de créer des nouvelles langues]**
* Français / Anglais / autre : [imput] —> possible d'en sélectionner plusieurs en même temps
**• Format [liste pré-définie + une case autre "fourre-tout"]**
* Site web / Audio / Vidéo / Performance / Objet éditorial / Installation / Etc. / Autre
* —> possible d'en sélectionner plusieurs en même temps
**• Mots-clés (max 10) [liste avec les mots clés déjà existants + option d'en créer des nouveaux]**
* spéculation / narration / urbanisme / patrimoine / intime / collectivité / film / cinéma / sociologie / anthropologie / éphémérité / queer / écriture / poésie / écologies affectives / technologies / autre : [imput]
**• Synopsis (environ 200 mots ; pas nécessairement de max – à voir si c'est nécessaire côté technique)**
**• Durée du TFE (si applicable) [faire en choix entre minutes/pages : [imput]]**
**• J'autorise l'erg à archiver mon TFE de la manière suivante ;**
[]x Libre ;[] mon TFE est en libre accès à tout le monde sur la plateforme des TFE ainsi que dans la bibliothèque de l'erg.
[]x Interne ;[] mon TFE n'est accessible que sur place en physique. Une note descriptive est disponible sur le site.
[]x Interdit ;[] mon TFE n'est pas disponible en physique ni sur le site. Une note descriptive est disponible sur le site.
L'étudiant·e peut, à tout moment, décider de restreindre son propre choix. Iel ne peut par contre pas l'ouvrir.
**• Licence du TFE ; dropdown avec plusieurs choix pré-établis + ouverture pour en donner d'autres ?**
* Les options précises sont encore au travail.
**• Upload du TFE**
**• Upload des annexes éventuelles**
**• Upload de la partie écrite**
**• Système pour que læ président·e du jury puisse rajouter une note de max 150 mots qui contextualiserait le TFE.**
**• Points du jury**
[]/!\ Quand l'étudiant·e dépose le TFE, celui-ci ne doit pas immédiatement être publié. Il faut attendre que la soutenance ait eu lieu et que læ président·e puisse éventuellement y ajouter un texte ainsi que les points. []
[]—> trouver un système pour rendre ça le plus fluide possible pour læ présidant·e ainsi que l'étudiant·e.[]
## Design
• Prévoir un « onglet » Charte / à propos (texte à venir – doit être facile à adapter sans avoir à coder).
• Prévoir un « onglet » licences (texte à venir – doit être facile à adapter sans avoir à coder).
• Prévoir un « onglet » contact (texte à venir – doit être facile à adapter sans avoir à coder).
• Il faut prévoir un espace ou quelque chose pour différencier les thèses (doctorats) des TFE.
## Points importants
• Important que le "back-office" soit accessible / pas trop complexe pour qu'on puisse adapter, supprimer, ajouter, corriger les données des TFE (relativement) facilement.
• Important que le texte des différents onglets soit éditable (relativement) facilement.
• Important que le statut de monstration "libre", "interne", "interdit" soit facilement changeable.
## Création Base de Données
• Engagement début décembre
* Min 5h/semaine à horaire libre
* On espère qu'un premier draft de base donnée arrivera mi-décembre pour pouvoir expérimenter avec. On vous enverra un fichier csv dès qu'on a une base solide.
• Collecte et assemblage des différentes années (au moins 2 ans – idéalement tout [lol])
* —> il faudra demander aux ancien·nes étudiant·es s'iels sont d'accord que leurs données soient publiées. Un mail sera envoyé après la récolte.
* —> voir avec Karim ce qu'on a le droit de montrer s'il n'y a pas de réponse (fiche descriptive, TFE en physique ?)
• Établir une liste de mots clés prédéfinis / voir s'il y a des lacunes et/ou problèmes quelque part
• On est au travail pour la partie doctorats. On vous tient au courant dès qu'il y a plus d'informations à ce sujet.
## Technique
• Hébergement et intégration avec les outils existants à voir avec Joan.
## Retroplanning
• Mi-décembre ; envoi d'un semblant de base de donnée pour permettre à l'équipe posterg d'expérimenter
• Journées pédagogiques du 15 \& 16 janvier ; travail sur les TFE, la place du jury dans sa publication etc.
• Mi-février ; envoi d'un mail aux ancien·nes étudiant·es et aux profs en vue de la publication digitale des TFE
• Mi-février ; finalisation de la maquette du site Post-ERG
• Mi-mars ; base de donnée des ancien·nes étudiant·es finalisée (fichier .cvs)
• Mi-avril ; date de remise du projet – site finalisé
• Début mai ; mise en ligne du site
• Mi-mai ; dépôt des TFE de 1e session (les TFE ne sont pas publiés publiquement à ce moment)
• Mi-juin ; publication publique des TFE (après éventuelle note du jury)
---
# Annexes : questions & remarques client·e (issues de SPECS.md)
## Accueil
- Ordre des TFE sur la page d'accueil : dérouler par année (plus récents en haut), ordre de chaque année aléatoire.
- Disposer des PDF et notes d'intention des dernières années ; nomenclature particulière demandée ?
- Export de la maquette du site (jpg) pour produire les textes en adéquation.
## admin
- Décision : ne pas rendre les TFE visibles vers l'extérieur pour l'instant.
- Option « interne » comme défaut sur une majorité des TFE.
- Pas disponible : PDF + note d'intention uniquement accessibles physiquement à l'erg (par IP) ou via login.
## Formulaire de dépôt
- Les étudiant·es préparent une image au bon format (taille à préciser).
- Sur la page du formulaire : ajouter l'explication et le contexte des choix.
- Un TFE déposé n'est pas publié directement : il arrive en base, quelqu'un clique « publier » dans le backoffice après la défense (et selon le jury).
- L'option « libre » n'existe pas encore cette année ; système de toggle pour activer les options du formulaire (seulement « interdit » + « interne » pour l'instant, « libre » l'an prochain).
- Case « contact » accompagnée d'une case à cocher « Je veux que mon contact soit accessible à toustes » ; selon la réponse, le contact apparaît ou non sur la page du TFE.
## Base de données
- Ajouter une catégorie « objet » pour différencier TFE / FRART / thèses (pour l'instant simple tag en back-office).
- Quelle(s) fonte(s) utilisée(s) sur le site ?
- Envoi d'un export de la maquette (jpg) pour rafraîchir la mémoire et produire les textes.
+180
View File
@@ -0,0 +1,180 @@
# XAMXAM — Test Coverage Plan
## Prerequisites (do these first, in order)
1. **Add Composer** — create `composer.json` if not present, ensure `vendor/` is gitignored except for committed tools
2. **Install PHPUnit** — `composer require --dev phpunit/phpunit ^11`
3. **Create `phpunit.xml`** at project root:
```xml
<?xml version="1.0"?>
<phpunit bootstrap="tests/bootstrap.php" colors="true">
<testsuites>
<testsuite name="XAMXAM">
<directory>tests/phpunit</directory>
</testsuite>
</testsuites>
</phpunit>
```
4. **Create `tests/bootstrap.php`** — autoload classes under test, define any constants the app needs (DB credentials from env, app root path, etc.)
5. **Create `tests/phpunit/`** directory — all new tests go here. The existing `run-tests.php` and its 8 test files are left untouched for now.
---
## Phase 1 — Pure Logic (no DB, no filesystem, no network)
**Goal:** Cover all stateless classes and helper methods. These have zero external dependencies and should take 2–3 hours total. Every test here runs in milliseconds and needs no fixtures.
### 1.1 `CryptoTest.php` — HIGH PRIORITY
Cover:
- Encrypt → decrypt round-trip returns original plaintext
- Different plaintexts produce different ciphertexts (IV randomness)
- `isEncrypted()` correctly identifies encrypted vs plain strings
- Legacy fallback path decrypts values encrypted with the old scheme
- Empty string handling (encrypt/decrypt empty string without error)
- Invalid base64 input to decrypt throws or returns false gracefully
- Wrong key produces failure, not silent garbage
### 1.2 `EmailObfuscatorTest.php` — MEDIUM PRIORITY
Cover:
- `encode()` produces output with no literal `@` character
- `email()` renders a working obfuscated mailto link
- `mailto()` builds correct href structure
- `emailText()` replaces inline email addresses in a block of text
- `mailtoInText()` wraps addresses in mailto links
- `obfuscateHtml()` reconstructs anchor tags correctly
- Edge cases: empty string, string with no emails, already-obfuscated content, multiple emails in one string
### 1.3 `SystemControllerHelpersTest.php` — HIGH VALUE, LOW EFFORT (~15 min)
Cover the static pure functions (no HTTP context needed):
- `humanBytes()` — 0, 1023, 1024, 1MB, 1GB boundaries
- `diskColor()` — thresholds (below warning, warning, critical)
- `logLineClass()` — maps log level strings to CSS class names
- `nginxLineClass()` — maps nginx status codes to classes
- `statusLabel()` / `statusClass()` — all defined statuses
### 1.4 `StudentEmailTest.php` — LOW EFFORT
Cover `buildHtml()`:
- Returns a non-empty string containing key thesis fields (title, author)
- HTML-escapes special characters in thesis data
- Handles a thesis row with missing/null optional fields without error
### 1.5 `TfeControllerOgTest.php`
Cover `buildOgTags()`:
- Returns array with all required OG keys (`og:title`, `og:description`, `og:image`, etc.)
- Falls back correctly when image is absent
- Long descriptions are truncated to meta limits
---
## Phase 2 — Integration (requires test database)
**Goal:** Cover DB-layer methods not yet touched. Requires a dedicated test database seeded with fixtures, wiped between test runs.
### Setup required before Phase 2
- Create a `tests/fixtures/` directory with SQL seed files (one per test class or shared base)
- Add a `TestDatabase` helper class that boots a PDO connection to the test DB, runs migrations, seeds, and truncates on teardown
- Store test DB credentials in `.env.test` (never committed), read in `bootstrap.php`
### 2.1 `DatabaseExtendedTest.php`
High-value targets (cover these first):
- `escapeLikeString()` — percent signs, underscores, backslashes in input
- `buildSearchConditions()` — various combinations of filters produce correct WHERE clauses (check SQL structure, not just that it runs)
- `findDuplicateThesis()` — detects exact duplicate, misses near-duplicate, handles empty table
- `generateThesisIdentifier()` — format is correct, increments correctly, no collision on concurrent inserts
- `getCoverPathsForTheses()` — returns correct paths for known IDs, returns empty for unknown IDs
- `findOrCreateAuthor()` — idempotent (calling twice with same name returns same ID)
- `deduplicateLanguages()` / `renameLanguage()` / `mergeLanguage()` — data integrity after merge
- `renameTag()` / `mergeTag()` — same pattern
### 2.2 `ShareLinkExtendedTest.php`
Extend existing ShareLinkTest with:
- `listActive()` — only returns active links
- `listArchived()` — only returns archived
- `findBySlug()` — hit and miss cases
- `setPassword()` + `getDecryptedPassword()` round-trip
- `update()` — fields change, others don't
### 2.3 `RateLimitExtendedTest.php`
Extend existing RateLimitTest with:
- `checkKey()` — counts per key, not globally
- `getRemaining()` — decrements correctly
- `getClientIdentifier()` — produces consistent output for same input, ignores X-Forwarded-For
---
## Phase 3 — Validation & Controller Logic
**Goal:** Cover the validation and sanitisation logic that lives inside controllers, tested by driving through the public interface rather than calling private methods directly.
### 3.1 `ThesisCreateValidationTest.php`
Drive `ThesisCreateController` through its `handle()` method with a mock HTTP POST:
- Valid submission creates a record
- Missing required fields (title, author, year) returns error, nothing written to DB
- Invalid year format (letters, future year beyond threshold, year 0) rejected
- Malformed URL in website field rejected
- Tag list with duplicates deduplicated before save
- XSS payload in title stored escaped, never executed
### 3.2 `ThesisEditValidationTest.php`
Same pattern for `ThesisEditController`:
- `load()` returns correct data for known ID, 404 for unknown
- `collectJuryMembers()` handles empty list, single member, duplicates
- `handleWebsiteUrl()` normalises http/https, rejects non-URLs
### 3.3 `autofocusFieldForErrorTest.php`
`ThesisEditController` has its own copy of this helper with different field names from `CreateController`. Verify:
- Returns correct field name for each known error key
- Returns null/default for unknown error key
- Does not leak `CreateController` field names
---
## Phase 4 — Cleanup (no new tests, housekeeping)
**Goal:** Consolidate the two test systems.
- Migrate the 8 existing custom-runner tests to PHPUnit equivalents in `tests/phpunit/`
- Validate they all pass under `vendor/bin/phpunit`
- Remove `run-tests.php` and the old test files
- Add `vendor/bin/phpunit` to CI pipeline (or a `Makefile` target: `make test`)
- Generate a baseline coverage report: `vendor/bin/phpunit --coverage-html coverage/`
- Commit the `coverage/` baseline so regressions are visible in future reports
---
## What is explicitly out of scope
These classes are noted as hard to test and are **not part of this plan**. Do not attempt to test them without first adding dependency injection or a proper HTTP testing layer:
- `App` — session/header-heavy
- `Dispatcher` — requires full HTTP context
- `FilepondHandler` — requires `$_FILES` injection
- `SmtpRelay` — socket-level, needs mock SMTP server
- `PeerTubeService` — OAuth + HTTP, needs VCR-style mocking
- `Parsedown` — third-party, tested upstream
---
## Summary
| Phase | Scope | Effort | Requires |
|---|---|---|---|
| 0 — Prerequisites | PHPUnit setup | ~1h | Composer |
| 1 — Pure logic | Crypto, Obfuscator, SystemController helpers, StudentEmail, OG tags | ~2–3h | Nothing |
| 2 — Integration | DB extended, ShareLink extended, RateLimit extended | ~3–4h | Test DB |
| 3 — Controller validation | Create/Edit validation paths | ~2–3h | Test DB + HTTP mock |
| 4 — Cleanup | Migrate old tests, CI, coverage report | ~2h | Phase 1–3 done |
**Start with Phase 0 + Phase 1.** They are fully unblocked and deliver the highest security-relevant coverage (Crypto, EmailObfuscator) with the least setup friction.