mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
docs: keep only reference documentation, archive one-offs
This commit is contained in:
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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'` |
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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'
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user