Reorganise docs: move historical files to archive/, merge overlapping docs

- Move 12 historical/superseded docs + 1 session log + 1 PDF + 1 HTML plan to archive/
- Merge 4 VM-crash docs into archive/vm-crash-incident.md
- Merge LDAP plan + spec into ldap.md
- Merge FilePond race investigation into filepond-crash-analysis.md
- Merge SPECS.md client notes into spec-sheet.md appendix
- Update README index and security.md cross-reference
This commit is contained in:
Pontoporeia
2026-08-24 11:36:02 +02:00
parent e4b48867aa
commit 55c714a35d
27 changed files with 501 additions and 1602 deletions
+91
View File
@@ -0,0 +1,91 @@
# Current Issues — XAMXAM (2026-05-10)
## 1. FK constraint violation on thesis save (create + edit)
**Symptom:** `⚠ SQLSTATE[23000]: Integrity constraint violation: 19 FOREIGN KEY constraint failed`
**Triggers:**
- Editing an imported CSV thesis, changing access type to Interdit, save
- Opening any imported thesis edit form and saving *without changes*
- Saving the add form with empty fields — form below "Cadre académique" disappears (PHP dies mid-render)
**Root cause found so far:** `Database::createThesis()` at line ~1860 was doing `(int)$data['orientation_id']` which converts SQL null → PHP `null` → `(int)null` = `0`. Since no row with ID 0 exists in `orientations`, this triggers FK violation. Fixed in commit `55088c94` by using `$v ? (int)$v : null` pattern.
**Still happening after fix** — suggests another code path has same issue, or the fix wasn't complete. The `updateThesis` path was already safe (uses `?: null`), but the error persists.
**Debugging added:**
- `[DB:updateThesis]` log line with all FK values before query (commit `55088c94`)
- `[ThesisEdit] Step 1-6 OK` step-level logging (commit `8734d964`)
- `ErrorHandler::log()` with full trace on catch (commit `03ad73f3`)
**Dev server output:** No error_log visible in dev mode — PHP built-in server sends errors to stderr which may not be captured.
**Next steps:**
- Enable `display_errors=1` in dev mode so FK errors render in browser
- Check if `setThesisFormats`, `setThesisLanguages`, `setThesisTags` paths also have `(int)null` → `0` issues
- Check `formulaire.php` action file — does it also use `createThesis`?
---
## 2. Dev server debugging output
**Symptom:** No error output visible in browser when PHP crashes.
**Current config** (`bootstrap.php`):
- Dev mode (cli-server): `display_errors=1`, `error_reporting=E_ALL`
- Production: `display_errors=0`, `log_errors=1`
**But:** the admin action files override this:
- `formulaire.php` line 5-7: `ini_set('display_errors', 0); ini_set('log_errors', 1);`
- `edit.php` action: no override (uses bootstrap defaults)
**Action needed:** Don't suppress display_errors in dev mode. Check `php_sapi_name()` before overriding.
---
## 3. Console warnings
```
Layout was forced before the page was fully loaded. node.js:416:1
[file-upload-queue] XamxamInitFileUploads called (twice)
```
- `file-upload-queue.js` called twice — the script might be included twice (check `add.php` template + the `form.php` partial)
---
## 4. Tags: lowercase + dedup + CSV import
**Status:** Implemented across all paths:
- Frontend JS: `normalizeTag()` with `replace(/\s+/g, ' ')`, lowercase
- Server fragment: `preg_replace('/\s+/', ' ', strtolower(...))`
- Both controllers: `fn(string $t) => strtolower(trim(preg_replace('/\s+/', ' ', $t)))`
- CSV import: same normalization (commit `8734d964`)
- Minimum 3 tags enforced (commit `8734d964`)
## 5. ErrorHandler coverage
**Status:** Applied to 12 admin action files + 6 public controllers + 2 form controllers + partage entry point (commit `03ad73f3`). 77 unit test assertions.
## Relevant commits (most recent first)
```
55088c94 Fix FK violation: (int)null → 0 in createThesis
27378b42 ErrorHandler tests: 77 assertions
4d9296fd ErrorHandler: precise FK field extraction from SQLite
03ad73f3 ErrorHandler: shared logging across all actions/controllers
6b6c62d1 Error logging: step-by-step transaction tracing
8734d964 Mots-clés: collapse spaces, minimum 3 keywords
dfe1186b Mots-clés: lowercase, dedup, keyboard nav, absolute dropdown
8d04d4ba Mots-clés: lowercase enforcement, deduplication, absolute dropdown
7fe53f8c Mots-clés: interactive HTMX tag search
dd110cc5 Admin mobile block: fix inline style beating media query
```
## Key files to review
- `app/src/Database.php` — `createThesis()` line ~1830, `updateThesis()` line ~1751, `setThesisFormats/Languages/Tags`
- `app/src/Controllers/ThesisCreateController.php` — `submit()` line ~146, `validateAndSanitise()` line ~312
- `app/src/Controllers/ThesisEditController.php` — `save()` line ~158
- `app/public/admin/actions/formulaire.php` — calls `$ctrl->submit()` (create path)
- `app/public/admin/actions/edit.php` — calls `$ctrl->save()` (edit path)
- `app/public/admin/index.php` — CSV import at line ~220
+403
View File
@@ -0,0 +1,403 @@
# Livraisons par mois — xamxam
## Janvier 2026 (8 commits)
### Formulaire & soumission · 5
- Schéma SQLite : tables theses, auteurs, formats, langues, tags, fichiers
- Système de gestion des thèses avec migrations
- Formulaire admin : création + édition TFE
- Formulaire étudiant via liens de partage (share links)
### Structure & architecture · 3
- Monorepo : organisation en dossiers (formulaire, templates, src, admin)
- Justfile, .gitignore, READMEs
- Nginx config + déploiement fonctionnel
---
## Février 2026 (29 commits)
### Répertoire & recherche · 9
- Moteur de recherche (par nom, année, orientation, AP, mot-clé)
- Cards en grille sur la page d'accueil (3×4 responsive)
- Footer avec années horizontales, scrollable
- Filtres combinables, header de recherche compact
### Admin & gestion des données · 7
- Export / import CSV des TFE
- Index admin : liste, pagination, stats
- Navigation admin dynamique
- CSS admin : composants réutilisables, styles standardisés
### Formulaire & soumission · 5
- Uploads de fichiers (stockage, affichage)
- Structure formulaire : champs obligatoires, validation
- Extraction buildSearchConditions partagé admin/public
### Sécurité · 3
- Auth guard PHP pour le panneau admin (CRITICAL)
- Correction failles HIGH et LOW (TODO.SECURITY.md)
### Autre · 5
- Renommage memoire.php → tfe.php
- CSS fixup, serveur dev avec live-reload
- Analyse de dépendances (TODO.md)
---
## Mars 2026 (74 commits)
### Accessibilité & sémantique HTML · 15
- Refactor sémantique complet de toutes les pages publiques et admin
- ARIA : nav aria-label, search role=search, aria-current, skip links
- Status badges non dépendants de la couleur (WCAG 1.4.1)
- Autofocus premier champ invalide (WCAG 3.3.1)
- Sous-titres WebVTT pour vidéos (WCAG 4.1.2)
- Cibles tactiles 44×44px (WCAG 2.5.5)
- Open Graph / Twitter Card meta tags, meta description
- Head partagé public, favicon
### Architecture code · 16
- ThesisCreateController, ThesisEditController : extraction depuis add.php/edit.php (530 lignes → contrôleurs dédiés)
- TfeController, SearchController, HomeController : extraction depuis les pages publiques
- SystemController : unifie status + logs en onglet système
- App.php : classe fondation + flash messages unifiés
- Partials de formulaire réutilisables (jury-fieldset, etc.)
- Nettoyage code mort, suppression alias Database
### Répertoire & recherche · 15
- Répertoire en 6 colonnes : années, orientations, AP, mots-clés, noms, compte
- Filtres HTMX sans rechargement, URL partageable
- Tri matched-first dans les colonnes de filtre
- Popover étudiant·e (pré-rendu serveur, lazy load)
- Page TFE publique : layout auteur > titre > meta+synopsis 2-col > fichiers > jury
- Liens hypertextes vers le répertoire sur les métadonnées
### Admin & gestion des données · 8
- Pagination serveur (25/page)
- Tri des colonnes dans l'index
- Merge status + logs → system.php avec onglets fetch()
- Paramètres admin : maintenance + compte
- Dialogue d'import CSV inline
### Formulaire & soumission · 6
- Jury : interne/externe, président·e, promoteurices, lecteurices
- Mode étudiant simulé dans l'admin (?mode=student)
- Aide contextuelle Markdown dans le formulaire
### Email / SMTP · 7
- SmtpRelay : client SMTP natif (socket), AUTH PLAIN, TLS
- Test SMTP, vérification des credentials à la sauvegarde
- Champs from_name, notify_email
- Email de notification admin après soumission
### Base de données · 5
- Migrations : superviseurs multi-rôles, tags renommés depuis keywords
- Vues SQL (v_theses_full, v_smtp_active)
- WAL mode pour SQLite
- Index composites (is_published, year)
- Auto-migration au démarrage du serveur
### CSS & design · 3
- Suppression de tous les assets externes (self-hosting)
- Polices : Combined → Ductus (titres), BBBDMSans (corps)
- Variables de couleur standardisées, dark mode
### Ops · 5
- Déploiement : scripts unifiés, justfile
- Renommage Posterg → XAMXAM (code, nginx, docs)
- Rate limiting sur les endpoints partage
### Autre · 4
- Analyse architecture PHP vs Flask, recommandations refactoring
- flake.nix pour shell de dev Nix
- Instructions DEV.md pour macOS
---
## Avril 2026 (207 commits)
### Répertoire & recherche · 28
- Répertoire 6 colonnes : fixed headers, colonnes différenciées, scroll contenu
- Alignement baseline, padding minimal, police conforme maquette
- AP entre crochets (ex: Design et Politique du Multiple [DPM])
- Résultats : cartes sans couverture → gradient placeholder
- Popover étudiant·e : lazy via HTMX, Cache-Control
- Split search.php / repertoire.php (index seul)
- HTMX filter colonnes : contrôle scroll, swap sans perte d'état
### Admin & gestion des données · 34
- Tags admin : search, rename inline, merge bulk, delete
- Langues admin : search, rename, merge, delete, dédoublonnage
- Sidebar TOC (IntersectionObserver), lazy load HTMX
- Bulk actions : merge, delete, export (CSV + fichiers ZIP)
- Colonnes triables (Auteur, Accès)
- Dialogue <dialog> remplace alert/confirm navigateur
- Toasts repositionnés, durée plus longue
- Export DB → paramètres, bouton Mots-clés retiré
- Checkboxes auto-save admin en HTMX
- Import CSV : AP aliases, OUI/NON sanitization
- Index : sticky thead, colspan stats, barre de sélection bulk
### Formulaire & soumission · 45
- Partials de champs unifiés : text-field, select-field, checkbox-list, file-field
- Jury : promoteurices multiples avec ajout/suppression dynamique
- Jury ULB conditionnel (finalité Approfondi)
- Annexes : checkbox + fichier conditionnel dans Fichiers
- Contact visible / contact interne découplés
- Formulaire partage : rate limiting, flash messages, StudentEmail
- CC4r → CC2r renommé
- Fragments HTMX partagés admin / partage : formats+fichiers, licence, langue-autre
- Fragments conditionnels selon formats cochés
- Aide contextuelle Markdown via HTMX
- Identifiant TFE régénéré automatiquement au changement d'année
- Récapitulatif exhaustif admin + étudiant (tous les champs)
- Mode étudiant simulé dans l'admin
### Uploads & fichiers · 25
- **FilePond** remplace les queues custom (495 lignes de JS supprimées)
- Architecture upload asynchrone : process / revert / load / remove
- Pool unique TFE (tous formats : PDF, vidéo, audio)
- Barre de progression d'upload
- Validation inline des fichiers (MIME, taille) via HTMX
- Contraintes : 100 MB PDF, 500 MB autres, bentopdf.com
- Bannières mergées dans covers (suppression banner_path)
- FilePond dans partage (endpoints, auth session)
- Prévisualisation des fichiers existants dans FilePond
### PeerTube · 8
- Intégration API PeerTube : upload vidéo/audio, OAuth2
- Feature flag (désactivé par défaut, en attente de quota)
- Embed iframe dans tfe.php
- Gestion des fichiers vidéo/audio → PeerTube OU upload direct
### Pages statiques / CMS · 10
- À propos, Charte, Licence : Markdown → HTML, édition admin
- TOC sticky dans la sidebar
- Contenus-edit : remplacement EasyMDE → OverType (333→118KB)
- Aide formulaire : blocs d'aide éditables en admin
### CSS & design · 21
- Architecture CSS en couches : reset → colors → typography → base → components
- Suppression de toutes les classes BEM → sélecteurs sémantiques
- Tokens de design unifiés (couleurs, typographie, espacement)
- Échelle fluide utopia pour typographie et espacement
- Suppression du dark mode, unification des thèmes
- Extraction de tous les styles inline vers CSS
- Polices : Ductus (display), BBBDMSans (body), @font-face corrigé
- Header plus haut, texte plus grand, text-shadow violet
- Cache-busting par filemtime
### Accessibilité · 10
- ARIA complet sur le formulaire : aria-errormessage, aria-invalid, aria-describedby
- WCAG 2.5.5 : cibles tactiles 44×44px
- Formulaire responsive mobile (colonnes empilées à 600px)
- Sémantique HTML admin : dl stats, section cards, th scope, landmarks
### Sécurité · 9
- Obfuscation des emails en entités HTML (EmailObfuscator)
- Content-Security-Policy (admin + public), HSTS, SameSite cookies
- COOP / CORP headers
- Rate limiting, flash messages partage
- Chiffrement credentials PeerTube + SMTP (AES-256-GCM)
### Architecture code · 18
- Extraction des contrôleurs (create, edit, search, TFE, system)
- Partials de formulaire réutilisables, fragments HTMX partagés
- Dispatcher + front controller (index.php)
- Configuration externalisée (config/apropos.php → SQLite)
- Nettoyage code mort, suppression Parsedown, déduplication
- Tests unitaires + intégration (Phase 0–4 PHPUnit)
- Linting : PHPStan, PHP-CS-Fixer, Biome
### Email / SMTP · 5
- SmtpRelay : TLS peer verification, envelope injection fix, dot-stuffing
- SMTP test button, credentials probe on save (connect+auth+quit)
- notify_email, email de notification remanié
### Ops & déploiement · 15
- Déploiement automatisé (justfile) : code, dépendances, migrations
- Vérification des permissions post-déploiement
- Backups SQLite cron (horaire + quotidien)
- Renommage Posterg → XAMXAM complet
- Exclusion DB/fichiers/backups/logs du rsync
- Maintenance.flag, rate limit cache, .env permissions
- Script de scan passif de sécurité (pentest)
- Scripts de migration, merge schema.sql
### Logs · 5
- AdminLogger : JSON-lines
- Logging structuré pour les soumissions admin/partage
- Log viewer dans system.php avec tail
- Exclusion des logs du git et du déploiement
---
## Mai 2026 (156 commits)
### FilePond & uploads · 30
- Correction crash « can't access property main, n.status is undefined » (triple root cause)
- Tailles en bytes (plus de confusion toInt('1GB') = 1)
- Fichiers temporaires survivent au rechargement (session-track)
- Guard no-JS : filepond_mode désactivé par défaut, fallback serveur
- Relink fichiers orphelins : file browser + intégration FilePond
- Trash policy : suppression → tmp/_trash avec traçabilité DB
- Storage prefix : theses/ → documents/
- Nettoyage modal : fichiers orphelins listés, suppression
- Recap admin : suppressions figures, fichiers en table
- FilePond CSV import (storeAsFile, no async)
- Tous les inputs fichiers → FilePond standardisés
- Prévisualisation existante dans FilePond en mode édition
### Formulaire & soumission · 42
- Brouillon automatique formulaire partage (HTMX POST/GET session)
- Auto-save admin checkboxes avec toasts de feedback
- Nettoyage périodique des brouillons orphelins (cron)
- Autosave contenus-edit avec debounce, toolbar OverType
- Thesis status column (two-phase commit lifecycle)
- FormBootstrap : DRY entre add/edit
- Extraction partage chrome → form-page.php partagé
- Split form.css → form-base.css + form-admin.css
- Fragments admin/partage séparés (auth + shared templates)
- Licence : Libre→CC2r+licence, Interne→opt-in, Interdit→none
- Corrections formulaire : identifiant/année, contact, fichiers optionnels
- Author name casing (COLLATE NOCASE, idHint lookup)
- Contact visible / interne découplage complet (v2)
- is_published reset fix, note contextuelle label
- Promoteurice array repopulation, migration 028 fix
- Export fichiers ZIP avec LINK.txt (URLs PeerTube)
- Jury fieldset : old() avec signature corrigée pour partage
### Tests & QA · 15
- PHPUnit setup : 228 tests, couverture 21,27%
- Tests unitaires : Crypto, EmailObfuscator, SystemController, StudentEmail, TfeController
- Tests intégration : Database, ShareLink, RateLimit
- Tests validation : ThesisCreate, ThesisEdit, AutofocusField
- PHPStan + PHP-CS-Fixer + Biome pour le linting
- Lint-php recipe unifié
- Correction test failures + duplicate detection bug
### Dépendances & modernisation · 8
- Composer.json avec autoloader
- Parsedown → league/commonmark
- Client HTTP custom → Guzzle
- SMTP socket → PHPMailer
- Code coverage configuration (phpunit.xml)
### Logging · 6
- **Monolog** : 4 canaux PSR-3 (app, admin, error, audit)
- Rotation 30 jours, format JSON préservé
- Remplace 4 systèmes de log distincts (AdminLogger, AppLogger, ErrorHandler, Audit)
- LOG_LEVEL par variable d'environnement
- Log viewer : JSON parsé en one-liners lisibles, fallback tail PHP
### CSS — refactor architecture · 10
- Split en couches nommées (reset, colors, typography, base, components, utilities)
- Un fichier par composant (links, focus, forms, tables, dialog, media, buttons, badges, toasts, pagination, header, search)
- Compatibilité backward (variables.css, common.css wrappers)
- Corrections vs refactoring initial : modern-normalize base, !important search, toast feedback
- Unnest header.css, suppression doublons status-badge/toast
### Search & filtres · 6
- Recherche publique : un seul grand input, icône loupe positionnée
- Filtres finalité + format, boutons plus compacts, Réinitialiser neutre
- Filtres actifs dans l'index admin (htmx triggers input/change)
### Admin · 12
- Contenus : Mots-clés + Langues fieldset, sticky thead, max-height scroll
- Export : bulk CSV + fichiers, DB export → Paramètres
- Dialogue import : bouton Terminé, succès permanent, padding
- Sortable columns Auteur/Accès
- Sticky thead sur index, langues, mots-clés tables
- Rename Liens étudiant·e → ajout nom + dialogue d'édition
- Recapitulatif admin : sections → fieldsets, pas de thumbnails
### Divers · 27
- Déploiement : split deploy-code/deploy-deps/deploy-migrate, lockfile checksum
- Migration runner : .php migrations, idempotent (no such column)
- Backups SQLite cron, trigger-backup, test-restore
- Production fixes : client_max_body_size 256M, bars.svg 404, nginx rate limit 300r/m
- Merge SMTP fields → single fieldset "Emails"
- CSV import : AP aliases, boolean fix, jury roles
- Migration 033 deduplicate format_types, 025 fix lowercase collision
- PeerTube : embed audio player height, fix curl_close, fix upload (multipart POST)
- Standardisation : .btn, multi-auteur, sticky save/cancel
- Avant/après unload dialog fix, bookmark.md draggable
- Sécurité : pentest script PEP 723, CSP headers, session cookie params
---
## Juin 2026 (82 commits)
### Icônes SVG · 8
- Migration complète <img> → SVG inline via icon() helper PHP
- 26 templates modifiés, 12 icônes corrigées (fill→currentColor)
- Icône recherche remplacée (Phosphor fill-based)
- Nettoyage modal : SVG icons, padding/margin, BBBDMSans
### Pages statiques & TOC · 7
- Refonte À propos / Charte / Licence : layout partagé en grille .page-content
- TOC sticky → details/summary responsive (collapsible sur mobile)
- Ancres de titres stables (CommonMark HeadingPermalinkExtension)
- Styles liens standardisés, fix heading anchor links
- Apropos : contacts flexibles, sidebar éditables, grille contacts admin
- Content-page.css remplace apropos.css (partagé 3 pages)
### Répertoire · 14
- Accordéon mobile pour les colonnes de filtre (≤640px)
- Toggle, chevron, badge compteur de filtres actifs
- Tri des colonnes : matched first, puis unmatched, alphabetique
- Mots-clés : résultats viables en haut
- Suppression de la chip bar mobile
- Correction re-init accordéon après swap HTMX (bug DOM détaché)
- Scroll colonnes : min-height: 0, overflow-y: auto
- Scroll-position memory sur swaps HTMX
- Transition opacité fluide sur #repertoire-index
- CSS répertoire : colonnes proportionnelles, scrollbars discrètes, fontes maquette
- Fix word-break dans les en-têtes de colonnes
### Formulaire · 22
- **Accessibilité** : aria-errormessage, aria-invalid, aria-describedby sur tout le formulaire
- Layout mobile responsive (600px) : champs empilés, touch targets 44×44px
- Autosave brouillon partage (fragment endpoint POST/GET, session persistence)
- Guard no-JS uploads (filepond_mode disabled par défaut, fallback serveur)
- Preserve fichiers temporaires FilePond après redirection validation
- FormBootstrap extraction
- Thesis status column (two-phase commit)
- FormAdmin CSS séparé
### Durée TFE · 4
- Colonnes DB (duration_hours, duration_minutes, duration_seconds)
- Champs formulaire : h:m:s au lieu de minutes/sec/heures
- Affichage public : format XhYm
- Migration + contrôleurs + vues
### TFE — corrections métadonnées · 8
- Finalité master affichée sur la page publique
- Identifiant régénéré si mismatch année (préfixe 4 chiffres)
- Author name casing corrigé (COLLATE NOCASE, idHint lookup)
- Suppression vidéos PeerTube lors de la suppression d'un TFE
- CC2r + licence affichés sur tfe.php, formatage contact court
- Meta+synopsis en grille 2 colonnes, underline retiré, inclusive writing
### Build & performance · 12
- **Pipeline build** : biome (lint/format) + rolldown (JS bundles) + lightningcss (CSS bundles/minify)
- Gzip activé dans nginx
- ~730 lignes de JS inline → 15 fichiers externes
- Dev-watch avec chokidar-cli (reconstruction ~200ms)
- dist/ pour les assets minifiés
- .gitignore vendor/ path, storage/tmp/filepond/
### Sécurité · 8
- Open redirect fix : rejet des URLs protocol-relative (//evil.com)
- CSRF sur retry-email POST, gate partage fragments sur share_active session
- Suppression App::verifyCsrf() mort
- PHP upload limits → 8GB pour vidéos (8192M)
- Biome lint fixes (duplicate CSS, arrow functions)
### Cleanup & divers · 7
- Nettoyage dialog : suppression doublons, hx-confirm sur delete
- Correctif logs capturés, debug console.log retiré
- Type hint adminOld corrigé (string → string|array)
- Index SQLite pour requêtes langues/tags (contenus page)
- TODO à jour, tasks sécurité complétées
Binary file not shown.
+134
View File
@@ -0,0 +1,134 @@
# Post-ERG Setup Guide
Complete setup guide for development and production deployment.
## Requirements
- PHP 8.4
- SQLite3 (`php8.4-sqlite3`)
- nginx (production)
## Development Setup
### 1. Initial Setup
```bash
just setup
```
### 2. Start Development Server
```bash
just serve
```
Access at: http://localhost:8000
### 3. Run Tests
```bash
just test
```
## Production Deployment
### First-Time Server Setup
```bash
ssh posterg
sudo mkdir -p /var/www/posterg
sudo chown www-data:posterg /var/www/posterg
sudo chmod 775 /var/www/posterg
exit
```
### Deploy Application
```bash
just deploy
just deploy-nginx
```
### Set Admin Password
```bash
just manage-admin-users
ssh posterg "sudo bash /tmp/manage-admin-users.sh"
```
### Verify Deployment
```bash
# Test public site
curl -I https://posterg.erg.be/
# Test admin protection
curl -I https://posterg.erg.be/admin/
# Test file protection
curl -I https://posterg.erg.be/storage/test.db
```
## Nginx Configuration
See `nginx/SETUP.md` and `nginx/docs/PRODUCTION_DEPLOYMENT.md` for detailed nginx setup.
## Admin Panel
The admin panel is protected by:
1. nginx HTTP Basic Authentication (htpasswd)
2. PHP session authentication
Manage users with:
```bash
just manage-admin-users
```
## Database
### Initialize Test Database
```bash
just init-db
```
### Reset Database
```bash
just reset-db
```
### Deploy Test Database to Server
```bash
just deploy-db
```
## Common Operations
### View Logs
```bash
just logs
```
### Stop Development Server
```bash
just stop
```
### Run Migrations
```bash
just migrate
```
## Security
- Admin panel: HTTP Basic Auth + PHP session
- File uploads: Stored outside webroot, served via `media.php`
- Rate limiting: 30 req/min general, 10 req/min admin
- Security headers: X-Frame-Options, CSP, HSTS, etc.
See `nginx/docs/SECURITY_HEADERS.md` for details.
+139
View File
@@ -0,0 +1,139 @@
# SMTP 550 — Recipient Address Rejected (`erg.school`)
**Date:** 2026-04-30
**Symptom:** Access-link emails to `@erg.school` addresses fail with:
```
550 5.1.1 <user@erg.school>: Recipient address rejected: User unknown in virtual mailbox table
```
---
## What is happening
The SMTP relay that XAMXAM uses to send outbound email is a Postfix instance
that is **also configured as the authoritative mail server for `erg.school`**.
When XAMXAM sends `RCPT TO:<user@erg.school>`, Postfix looks up the address in
its local `virtual_mailbox_maps` table. Because the individual mailbox does not
exist in that table, Postfix rejects the message permanently with 550 instead
of forwarding it outward.
This affects **all** outbound email to `@erg.school` sent through this relay,
regardless of whether the address is real — Postfix never tries to route the
message anywhere else.
---
## Why it happens
Postfix owns a domain in one of two ways:
| Setting | Effect |
|---|---|
| `mydestination` | Postfix delivers locally via Unix accounts |
| `virtual_mailbox_domains` | Postfix delivers locally via `virtual_mailbox_maps` |
If `erg.school` (or a wildcard matching it) appears in either of these on the
outbound relay, Postfix will **never relay** mail to that domain — it will
always attempt local delivery and reject unknown recipients.
To confirm, run on the relay server:
```bash
postconf mydestination
postconf virtual_mailbox_domains
postconf relay_domains
```
Check whether `erg.school` appears (directly or via a lookup table).
---
## Fix options
### Option A — Preferred: use a different relay for outbound mail
Configure XAMXAM to send via an SMTP relay that does **not** host `erg.school`
(e.g. a dedicated outbound relay, a transactional mail provider, or the
outbound smarthost if one exists).
Change the SMTP settings in the XAMXAM admin panel (`/admin/parametres.php`)
to point to that relay.
---
### Option B — Remove `erg.school` from local delivery on the relay
If the relay should not be the final destination for `erg.school` mail, remove
it from the relevant Postfix maps.
**If it is in `mydestination`:**
```ini
# /etc/postfix/main.cf
mydestination = localhost, localhost.localdomain
# remove erg.school (and any wildcard covering it)
```
**If it is in `virtual_mailbox_domains`:**
```ini
# /etc/postfix/main.cf
virtual_mailbox_domains = ...
# remove erg.school from the list (or from the referenced lookup table)
```
After editing `main.cf`:
```bash
postfix check
systemctl reload postfix
```
---
### Option C — Add a `transport_maps` override for the domain
If `erg.school` must remain in `virtual_mailbox_domains` for inbound delivery
but outbound mail from XAMXAM should still be relayed, add a transport override
so that mail *to* `erg.school` sent by XAMXAM is forwarded to the real MX
rather than delivered locally.
```ini
# /etc/postfix/main.cf
transport_maps = hash:/etc/postfix/transport
```
```
# /etc/postfix/transport
erg.school smtp:[mail.erg.school]:25
```
```bash
postmap /etc/postfix/transport
systemctl reload postfix
```
> **Note:** This approach is fragile — if XAMXAM is on the same server as the
> MX, you risk a delivery loop. Option A or B is cleaner.
---
## Verification
After applying the fix, test with XAMXAM's built-in SMTP probe
(`/admin/parametres.php` → Test SMTP). Then submit a real access request with
an `@erg.school` address and confirm the email arrives.
You can also test directly from the server:
```bash
swaks --to test.user@erg.school \
--from xamxam@erg.be \
--server <smtp_host> --port 587 \
--tls --auth-user <username> --auth-password <password>
```
A successful relay returns `250 2.0.0 Ok: queued as …`.
A 550 response confirms the domain is still being caught locally.
File diff suppressed because it is too large Load Diff
+269
View File
@@ -0,0 +1,269 @@
# CSS Cleanup - Post-ERG
Complete CSS rewrite removing Bulma dependency and creating a minimalistic, readable design.
## 🎯 What Changed
### Removed
- ❌ Bulma CSS framework (~200KB)
- ❌ External CDN dependency
- ❌ Unused CSS bloat
### Added
- ✅ Custom minimalistic CSS (~9KB)
- ✅ Clean, modern design
- ✅ Fully responsive layout
- ✅ Maintained all functionality
## 📊 Before vs After
| Metric | Before | After | Improvement |
|--------|--------|-------|-------------|
| **CSS Size** | ~200KB | ~9KB | **95% smaller** |
| **External Deps** | 1 (Bulma CDN) | 0 | **No external deps** |
| **Load Time** | ~500ms | ~50ms | **90% faster** |
| **Maintainability** | Hard | Easy | **Full control** |
## 🎨 Design System
### Color Palette
```css
--color-primary: #c104fc /* Purple - main accent */
--color-secondary: #4da870 /* Green - secondary accent */
--color-text: #333 /* Dark gray - main text */
--color-text-light: #666 /* Light gray - secondary text */
--color-border: #ddd /* Light border */
--color-bg: #fff /* White background */
--color-bg-light: #f9f9f9 /* Light gray background */
```
### Typography
- **System fonts** for speed and readability
- **Combined font** for headings (custom font preserved)
- **Base size**: 16px (1rem)
- **Line height**: 1.6 for readability
### Spacing
- **Base spacing**: 1rem (16px)
- **Large spacing**: 2rem (32px)
- **Consistent rhythm** throughout
## 🧩 Components
All Bulma classes kept working with custom implementations:
### Layout
- `.section` - Page sections with padding
- `.container` - Max-width centered container
- `.columns` - CSS Grid responsive layout
- `.column` - Grid items with responsive sizing
### Components
- `.navbar` - Sticky header with gradient
- `.card` - Content cards with hover effects
- `.button` - Action buttons
- `.notification` - Alert messages
- `.box` - Content containers
- `.tag` - Labels and badges
### Form Elements
- `.input` - Text inputs
- `.textarea` - Multi-line inputs
- `.label` - Form labels
- `.field` - Form field containers
## 📱 Responsive Design
### Breakpoints
- **Desktop**: > 768px (multi-column grid)
- **Tablet**: 480-768px (2-column grid)
- **Mobile**: < 480px (single column)
### Features
- ✅ Responsive navigation
- ✅ Flexible grid layout
- ✅ Adaptive card sizes
- ✅ Touch-friendly targets
- ✅ Readable text sizes
## 🎯 Key Features
### Performance
- **No external dependencies** - all CSS self-hosted
- **Minimal file size** - only 9KB
- **Critical CSS only** - no unused styles
- **Fast parsing** - simple selectors
### Accessibility
- **High contrast** text
- **Focus states** on interactive elements
- **Semantic HTML** preserved
- **Keyboard navigation** supported
### Maintainability
- **CSS variables** for easy theming
- **Clear sections** and comments
- **Consistent naming** conventions
- **No preprocessor needed**
## 🔧 Customization
### Change Colors
Edit CSS variables at the top of `posterg.css`:
```css
:root {
--color-primary: #c104fc; /* Your brand color */
--color-secondary: #4da870; /* Secondary color */
/* ... */
}
```
### Change Spacing
```css
:root {
--spacing: 1rem; /* Base spacing */
--spacing-lg: 2rem; /* Large spacing */
}
```
### Change Layout Width
```css
:root {
--max-width: 1200px; /* Maximum content width */
}
```
## 📂 Files Modified
### Updated
- `apps/public/inc/header.php` - Removed Bulma link
- `apps/public/assets/posterg.css` - Complete rewrite
### Preserved
- `apps/public/assets/normalize.css` - CSS reset (kept)
- `apps/public/assets/fonts/` - Custom fonts (kept)
## ✅ Testing Checklist
After deployment, verify:
- [ ] Homepage loads and looks good
- [ ] Card grid is responsive
- [ ] Navigation works
- [ ] Hover effects work on cards
- [ ] Search page works
- [ ] Individual thesis pages work
- [ ] Forms display correctly (admin)
- [ ] Mobile layout works
- [ ] Tablet layout works
- [ ] Desktop layout works
## 🚀 Deployment
The CSS was deployed automatically with:
```bash
just deploy-public
```
This updates:
1. `assets/posterg.css` - New minimalistic CSS
2. `inc/header.php` - Removed Bulma dependency
## 🎨 Visual Changes
### Navigation
- ✅ Kept gradient background
- ✅ Sticky positioning
- ✅ Hover effects
- ✅ Custom font preserved
### Cards
- ✅ Clean borders
- ✅ Subtle hover effects
- ✅ Responsive grid
- ✅ Better spacing
### Typography
- ✅ More readable sizes
- ✅ Better line heights
- ✅ Consistent hierarchy
## 🔮 Future Improvements
### Easy Wins
- Add dark mode toggle
- Add custom color themes
- Add print stylesheet
- Add animation transitions
### Advanced
- Lazy load images
- Add skeleton loaders
- Progressive enhancement
- Service worker caching
## 📊 Browser Support
Tested and working on:
- ✅ Chrome/Edge (modern)
- ✅ Firefox (modern)
- ✅ Safari (modern)
- ✅ Mobile browsers
Uses modern CSS features:
- CSS Grid (2017+)
- CSS Variables (2016+)
- Flexbox (2015+)
All with excellent browser support (>95%).
## 🎓 Technical Details
### CSS Architecture
- **Mobile-first** approach
- **CSS Grid** for layout
- **Flexbox** for components
- **CSS Variables** for theming
- **BEM-like** naming (kept Bulma classes)
### No Build Process
- Pure CSS (no SCSS/LESS/PostCSS needed)
- No JavaScript required
- Direct deployment
- Easy to debug
## 💡 Benefits
### For Users
- ⚡ **Faster load times** - 95% less CSS
- 📱 **Better mobile experience** - optimized responsive
- 🎯 **Cleaner design** - less visual noise
- 🌐 **No CDN dependency** - works offline
### For Developers
- 🔧 **Easy to maintain** - simple, clear CSS
- 🎨 **Easy to customize** - CSS variables
- 🐛 **Easy to debug** - no framework magic
- 📚 **Easy to understand** - well-commented code
### For Performance
- 📉 **95% smaller CSS** - 200KB → 9KB
- ⚡ **No external requests** - self-hosted
- 🚀 **Faster parsing** - simpler selectors
- 💾 **Better caching** - static file
---
## 📞 Support
The new CSS maintains full compatibility with the existing HTML structure. All Bulma classes still work, but are now implemented with custom, lightweight CSS.
To revert to Bulma (not recommended):
```html
<!-- In apps/public/inc/header.php -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
```
But the custom CSS is faster, smaller, and fully customizable! 🎉
+154
View File
@@ -0,0 +1,154 @@
# Migration History
Consolidated record of structural migrations performed on posterg-website.
---
## Migration 1: YAML → SQLite (2026-01)
### What Changed
Replaced flat YAML file storage with a normalized SQLite database.
**Before:** Form data saved as individual YAML files in `data/yaml/`, with file uploads in `data/content/` and `data/cover/`.
**After:** All thesis data stored in a relational SQLite database with 19 tables (11 core, 5 junction, 3 reference), 2 views, proper normalization (3NF), auto timestamps, cascade deletes.
### Key Changes
- `Database.php` — PDO wrapper with transaction management, find-or-create methods, lookup helpers
- `index.php` — Dynamically loads form options from DB; added subtitle, synopsis, finality, languages, formats
- `formulaire.php` — Complete rewrite: transaction-based processing, prepared statements, random cryptographic filenames
- `thanks.php` — Reads from DB using thesis ID, displays data from `v_theses_full` view
### YAML → Database Mapping
| YAML Field | Database Location |
|------------|-------------------|
| `auteurice` | `authors.name` |
| `email` | `authors.email` |
| `année` | `theses.year` |
| `titre` | `theses.title` |
| `description` | `theses.synopsis` |
| `orientation` | `theses.orientation_id` |
| `ap` | `theses.ap_program_id` |
| `promoteurice` | `supervisors.name` + `thesis_supervisors` |
| `tag` | `keywords.keyword` + `thesis_keywords` |
| `files` | `thesis_files` table |
### Data Migration Path
For importing existing YAML data:
1. Parse YAML files with `Symfony\Yaml\Yaml::parseFile()`
2. Insert into DB within transactions
3. Verify with `SELECT COUNT(*) FROM theses; SELECT * FROM v_theses_full LIMIT 5;`
---
## Migration 2: Repository Restructure (2026-02)
### What Changed
Restructured from `apps/public/` + `apps/admin/` + `shared/` layout to idiomatic PHP website layout.
**Before:**
```
posterg-website/
├── apps/public/ # Public website
├── apps/admin/ # Admin panel
├── shared/ # Shared PHP libraries
└── database/
```
**After (intermediate):**
```
posterg-website/
├── index.php # Public root
├── admin/ # Admin panel
├── lib/ # Shared libraries (was shared/)
├── inc/ # Templates (header/footer)
├── assets/ # Static files
├── database/
└── vendor/
```
### Key Changes
- Moved `apps/public/*` to root
- Moved `apps/admin/` to `admin/`
- Renamed `shared/` to `lib/`
- Updated all `require` paths
- Added php-live-reload to `vendor/`
---
## Migration 3: Public Directory Structure (2026-02)
### What Changed
Moved web-accessible files into `public/` subdirectory so only `public/` is the DocumentRoot.
**Before:** All files (including config, DB, source) in DocumentRoot — security relied on nginx deny rules.
**After:**
```
posterg-website/
├── public/ # DocumentRoot — only this exposed
│ ├── index.php
│ ├── search.php
│ ├── admin/
│ └── assets/
├── config/ # Private
├── includes/ # Private (was inc/)
├── src/ # Private (was lib/)
├── storage/ # Private (DB + uploads)
└── var/ # Private (cache, logs)
```
### Key Changes
- `config/bootstrap.php` — Central path management with constants (APP_ROOT, PUBLIC_ROOT, etc.)
- All public PHP files updated to use bootstrap and relative paths
- Dev server: `php -S 127.0.0.1:8000 -t public/`
- Deployment: rsync to `/var/www/posterg/` (not `/var/www/html/`)
- Nginx DocumentRoot: `/var/www/posterg/public`
### Security Impact
| Resource | Before | After |
|----------|--------|-------|
| Database | Accessible if nginx misconfigured | Physically outside web root |
| Config | One deny rule away | Physically private |
| Source code | Exposed | Physically private |
| Dev server | Exposed everything | Matches production security |
---
## Migration 4: Simplification (2026-02)
### What Changed
Removed package-oriented complexity from the `public/` migration.
- Removed `var/` directory (completely unused for this website)
- Renamed `resources/views/` → `includes/` (simpler, clearer)
- Simplified `config/bootstrap.php` from 66 → 33 lines
- Removed 10+ unused constants (VAR_ROOT, CACHE_ROOT, LOGS_ROOT, etc.)
- Replaced `view()` helper with standard PHP `include`
### Result
Only essential constants remain: `APP_ROOT`, `STORAGE_ROOT`, plus environment detection. Security unchanged (still uses `public/` as DocumentRoot).
---
## Rollback Notes
All migrations are tracked in jj. To view history:
```bash
jj log
jj edit <previous-change-id>
```
The YAML-based code was kept as a fallback during the SQLite migration. The old `/var/www/html/` structure was kept as a backup during the directory structure migration.
+105
View File
@@ -0,0 +1,105 @@
# ORM Assessment — posterg
> **Date:** 2026-03-28
> **Scope:** Full codebase review — `src/Database.php`, all `public/` PHP files, `storage/schema.sql`, migrations, tests.
---
## Verdict: No ORM needed
An ORM would add complexity and dependency weight without solving any real pain points in this project. The recommendation is to **keep raw PDO** and instead make one targeted refactor to the `edit.php` file.
---
## What the project actually is
- **PHP 8.4 / SQLite3**, single-file database (`posterg.db`)
- ~1 130-line `Database.php` service class wrapping a single `PDO` instance
- ~10 public-facing PHP files + ~15 admin PHP files
- One developer, non-commercial, low-to-medium traffic school project
- No Composer, no framework, no autoloader — intentionally minimal
---
## Where SQL lives today
| Location | SQL type | How parameterised |
|---|---|---|
| `src/Database.php` | All major queries (reads, writes, views, search) | Named/positional PDO bindings throughout |
| `public/admin/actions/formulaire.php` | INSERT thesis, link authors/jury (create path) | PDO positional params |
| `public/admin/actions/publish.php` | Bulk/single `UPDATE is_published` | PDO positional params |
| `public/admin/edit.php` | Large `UPDATE theses SET …`, DELETE+INSERT junction tables | Raw PDO via `$pdo = $db->getPDO()` — **partially bypasses the service class** |
| `public/admin/import.php` | Multi-row INSERT during CSV import | Raw PDO via `$pdo = $db->getPDO()` |
The pattern is largely consistent: `Database.php` is the canonical data-access layer. Two admin files (`edit.php`, `import.php`) poke through it via `getPDO()` for operations the service class doesn't expose as methods. That's the main rough edge, not something that calls for an ORM.
---
## Arguments for an ORM (examined and rejected)
### "The schema is relational with many junctions"
The schema is normalised (14 tables, 6 junction tables, 2 views), but every relationship is **read by the database views** (`v_theses_full`, `v_theses_public`) which already aggregate `GROUP_CONCAT` columns. The PHP code barely touches junction tables directly — it calls `setThesisTags()`, `setThesisJury()`, `setThesisLanguages()` etc., which are already encapsulated service methods.
An ORM would re-implement these aggregations less efficiently and lose the view layer's performance benefits.
### "There is duplicated SQL in edit.php / formulaire.php"
True, but this is a **missing method on `Database.php`**, not an ORM problem. The `updateThesis()` method simply doesn't exist yet; `edit.php` compensates by calling `$db->getPDO()` and writing the UPDATE inline. The fix is a 30-line method addition, not adopting a 50 MB ORM.
### "Migrations are hand-written SQL files"
There are 6 migrations in `storage/migrations/`, all trivial (`ALTER TABLE ADD COLUMN`, `CREATE INDEX`, `INSERT OR IGNORE`). This is an appropriate level of complexity for a SQLite project with one schema target (no multi-tenant, no multi-DB sharding). An ORM's migration runner would add overhead without benefit.
### "Type safety — PHP arrays aren't typed"
The codebase already returns typed arrays from PDO with `FETCH_ASSOC` and documents return shapes in docblocks (`@return array{license_id:int|null,…}`). PHP 8.4 property types on a couple of DTOs could improve IDE ergonomics if desired, but that's independent of ORM adoption.
---
## Arguments against an ORM (decisive)
### 1. The search query cannot be replicated by an ORM query builder
`Database::searchTheses()` builds a WHERE clause over `v_theses_public` with up to 9 optional filters including an `EXISTS` subquery over `thesis_tags → tags`. This is a deliberate, LIKE-escaped, parameterised query that maps onto a database view. Every ORM would either:
- Force re-implementation as raw SQL passed to the ORM's `raw()` escape hatch (no benefit), or
- Generate a massively suboptimal N+1 or multi-JOIN query that duplicates what the view already does.
### 2. The schema uses database views as the primary read layer
`v_theses_full` and `v_theses_public` join 15 tables and aggregate via `GROUP_CONCAT`. ORMs treat views as read-only proxies at best; the GROUP_CONCAT columns (`authors`, `keywords`, `languages`, etc.) aren't mappable to ORM collection properties without a custom hydrator. The view approach was a deliberate performance trade-off — an ORM would fight it.
### 3. SQLite is a poor fit for most PHP ORMs
Doctrine ORM and Eloquent have first-class SQLite support but with caveats: no `ALTER TABLE ADD COLUMN` rollback, no partial indexes, different date handling, no stored procedures. The current migration approach (plain `.sql` files applied manually/via script) is actually more reliable for SQLite than an ORM migration runner.
### 4. The project has zero dependencies by design
`composer.json` does not exist. There is no autoloader. The bootstrap is `require_once __DIR__ . '/../../src/Database.php'`. Introducing an ORM means either Doctrine (~4 MB, 15+ packages) or Eloquent standalone (~2 MB, plus Capsule bootstrapping). For a school project with one SQLite file, this dependency overhead is not justified.
### 5. The one "leaky abstraction" has a trivial fix
`edit.php` and `import.php` call `$db->getPDO()` because `Database.php` is missing two methods:
- `updateThesis(int $id, array $fields): void`
- `importThesisRow(array $data): int`
Adding these two methods completes the service-class encapsulation. After that, **no file outside `src/Database.php` would need raw SQL**.
---
## What should actually be done instead
| Priority | Action |
|---|---|
| Low | Add `Database::updateThesis()` method to encapsulate the `UPDATE theses SET …` in `edit.php`. |
| Low | Add `Database::importThesisRow()` method to encapsulate the CSV import INSERT chain in `import.php`. |
| Optional | Add `Database::relinkThesisAuthors()` to replace the delete+reinsert block in `edit.php` (already done for jury/languages/formats/tags). |
| Never | Adopt an ORM. |
---
## Summary
The current architecture — a single handwritten PDO service class backed by a SQLite database with view-based reads — is **the right tool for this project's scale and context**. The SQL is well-parameterised, the views handle join complexity efficiently, and the one rough edge (two admin pages calling `getPDO()` directly) is a 60-line refactor, not an architectural crisis.
An ORM would increase complexity, add a large dependency, and provide no benefit that isn't already delivered by `Database.php`.
+176
View File
@@ -0,0 +1,176 @@
# Posterg: PHP vs Flask Analysis
## Current Architecture Summary
- **Stack**: Vanilla PHP (no framework), SQLite, nginx + php-fpm
- **Codebase**: ~9,100 lines across 48 PHP files
- **Structure**: File-based routing (`public/` = webroot), shared templates via `include`, singleton `Database` class (1,294 lines), custom auth, rate limiting, media proxy
- **Pages**: 8 public pages, 17 admin pages (11 views + 7 action handlers)
- **Templating**: Raw PHP includes with variable scoping (`$isAdmin`, `$bodyClass`, `$extraCss`, etc.)
- **Database**: SQLite via PDO, WAL mode, 13 tables, 2 views, 6 junction tables
---
## Templating
### Current PHP Pain Points
1. **No template inheritance.** Every page manually sets variables (`$pageTitle`, `$bodyClass`, `$extraCss`, `$ogTags`, `$isAdmin`) then `include`s `head.php`, `header.php`, and `footer.php` in sequence. The head template uses conditionals to branch between admin/public modes — functional but brittle.
2. **Variable scoping is implicit.** Templates read variables from the caller's scope. There's no contract — if `$availableYears` isn't set before `footer.php` is included, it silently renders nothing. Flask's Jinja2 would make this explicit via `render_template('page.html', years=years)`.
3. **No block/slot system.** The admin footer injects `$extraJs` / `$extraJsInline` via loose conventions. In Jinja2, `{% block scripts %}` handles this cleanly with override semantics.
4. **Repeated boilerplate.** Every page repeats the same 5-line preamble: require bootstrap, require Database, set template vars, include head, include header. A Flask `@app.route` + `render_template` collapses this to ~3 lines.
5. **HTML mixed with logic.** Files like `search.php` (220 lines) interleave DB queries, input validation, pagination math, OG tag construction, and HTML rendering in a single file. Flask naturally separates route handlers from templates.
### What Flask/Jinja2 Would Improve
- **Template inheritance**: One `base.html` with `{% block content %}`, `{% block head_extra %}`, `{% block scripts %}`. Admin extends `admin_base.html` which extends `base.html`.
- **Macros**: The card rendering loop, pagination nav, and filter dropdowns are all repeated patterns that become `{% macro card(item) %}`.
- **Auto-escaping**: Jinja2 escapes by default. The current code manually calls `htmlspecialchars()` ~150 times across the project. One missed call = XSS.
- **Explicit context**: `render_template('tfe.html', thesis=data, files=files)` is self-documenting. The current `$data` / `$thesis` / `$item` naming is inconsistent across pages.
### What Flask Would NOT Improve
- The templates themselves would be roughly the same size — HTML is HTML.
- The OG tag logic in `head.php` is already centralized; Jinja2 wouldn't simplify the conditional logic, just change its syntax.
---
## Routing & Code Organization
### Current State
File-based routing via nginx → `public/*.php`. Each file is a standalone entry point:
```
public/index.php → home page
public/search.php → search/repertoire
public/tfe.php → thesis detail
public/admin/edit.php → edit form (GET)
public/admin/actions/edit.php → edit handler (POST)
```
This is simple and transparent — the URL *is* the file path. But it means:
- No centralized middleware (auth, CSRF, rate limiting are manually required per-file)
- No URL generation (hardcoded `href="/admin/edit.php?id=..."` everywhere)
- POST handlers are separate files that redirect back, duplicating auth/CSRF boilerplate
### Flask Equivalent
```python
@app.route('/admin/edit/<int:id>', methods=['GET', 'POST'])
@login_required
def admin_edit(id):
if request.method == 'POST':
...
return redirect(url_for('admin_edit', id=id))
return render_template('admin/edit.html', thesis=thesis)
```
- `@login_required` replaces 7 identical `AdminAuth::requireLogin()` calls + 7 identical CSRF checks
- `url_for()` replaces ~50 hardcoded URL strings
- GET/POST in one function eliminates the `actions/` directory pattern
---
## Performance
### Where PHP Already Wins
1. **Process-per-request model with OPcache.** PHP-FPM with OPcache compiles PHP to bytecode once, then serves each request from shared memory. There is no framework initialization overhead because *there is no framework*. Each request loads only the files it needs.
2. **SQLite + WAL mode.** The database is local, on-disk, zero-network-hop. The `PRAGMA` settings (WAL, 8MB cache, synchronous=NORMAL) are well-tuned. This is identical regardless of language.
3. **Low memory footprint.** Each PHP-FPM worker uses ~10-20MB. A Flask process (gunicorn worker) with SQLAlchemy loaded uses ~30-50MB.
4. **No ORM overhead.** Raw PDO queries with manual bindings are as fast as it gets for SQLite. Flask would likely introduce SQLAlchemy, adding per-query overhead (object hydration, identity map, unit of work tracking).
5. **Static file serving by nginx.** CSS/JS/fonts are served directly by nginx, never touching PHP. This is identical with Flask behind nginx.
### Where Flask Would Be Comparable
| Aspect | PHP (current) | Flask |
|--------|--------------|-------|
| Cold start | ~5ms (OPcache hit) | ~50-100ms (Python import) |
| Warm request | ~2-5ms | ~3-8ms |
| SQLite query | Same PDO overhead | Same sqlite3/aiosqlite |
| Template render | PHP native | Jinja2 compiled (comparable) |
| Concurrency | php-fpm pool (sync) | gunicorn workers (sync) |
### Where Flask Would Be Worse
1. **Python is slower for raw computation.** Not relevant here — the bottleneck is SQLite I/O, not CPU.
2. **No equivalent to OPcache.** Python caches `.pyc` bytecode files, but Jinja2 templates must be compiled at startup or on first access. PHP OPcache stores compiled opcodes in shared memory — inherently faster for the "compile once, serve many" pattern.
3. **GIL.** Python's GIL limits true parallelism per process. PHP-FPM workers are independent processes with no shared lock. For a database-bound app this is irrelevant, but under high concurrency PHP-FPM scales more linearly.
4. **Memory per worker.** Flask + dependencies (Werkzeug, Jinja2, click, itsdangerous, markupsafe, plus any ORM) consumes more baseline memory than a PHP-FPM worker running vanilla PHP.
### Where Flask Would Be Better (Performance)
1. **Application-level caching.** Flask can hold objects in memory across requests (e.g., cached orientation lists, available years). PHP re-queries these on every request because it shares nothing between requests by default. However, PHP can use APCu for this — it's just not implemented here.
2. **Connection pooling.** Flask can maintain a persistent SQLite connection per worker. PHP opens a new PDO connection per request (the singleton is per-request, not per-process). For SQLite this overhead is minimal (~0.1ms), but it exists.
### Verdict: Performance
**PHP wins marginally for this specific workload.** The app is a low-traffic academic catalogue with SQLite. The differences are in the single-digit millisecond range and completely irrelevant at the expected scale (likely <100 requests/minute). Neither choice would ever be the bottleneck — the network round-trip to the user dwarfs everything.
---
## Developer Experience & Maintainability
### Where Flask Would Be Clearly Better
1. **Dependency management.** `pip install flask` + `requirements.txt` (or `pyproject.toml`). Currently there are zero external PHP dependencies — which sounds like a feature until you realize the project vendors `Parsedown.php` (2,000 lines of Markdown parsing) instead of using Composer.
2. **Form handling.** Flask-WTF provides declarative form classes with validation, CSRF built-in, and type coercion. The current code manually validates ~15 fields per form with inconsistent approaches (`filter_var`, `intval`, `trim`, `sanitize_string`).
3. **Testing.** Flask has a built-in test client (`app.test_client()`) that can simulate full request/response cycles. The current test suite uses a custom `run-tests.php` harness — functional but non-standard.
4. **Error handling.** Flask has `@app.errorhandler(404)`, `@app.errorhandler(500)`. The current code uses scattered `die()` calls and inconsistent error responses.
5. **Session management.** Flask-Login provides remember-me, session expiry, next-URL redirect after login. `AdminAuth.php` reimplements a subset of this in 121 lines.
### Where PHP Is Adequate or Better for This Project
1. **Zero build step.** Edit a `.php` file, refresh browser. No `flask run`, no virtual environment, no `pip install`. The `php -S localhost:8000` dev server with live-reload is already configured.
2. **Deployment simplicity.** rsync files to server, done. No virtualenv, no systemd unit for gunicorn, no WSGI/ASGI configuration. PHP-FPM is already running on the server.
3. **Hosting availability.** Any shared host runs PHP. Flask requires a VPS or PaaS with Python support. However, this project already uses a dedicated server with nginx, so this is moot.
4. **Team knowledge.** If the maintainers know PHP, rewriting in Python is a net negative regardless of technical merit.
---
## Migration Effort
Rewriting this project in Flask would require:
| Component | Effort |
|-----------|--------|
| Database layer (`Database.php` → SQLAlchemy or raw sqlite3) | 2-3 days |
| 8 public routes + templates | 2 days |
| 17 admin routes + templates | 3-4 days |
| Auth system (Flask-Login) | 0.5 days |
| File upload/media serving | 1 day |
| Rate limiting (Flask-Limiter) | 0.5 days |
| nginx config adaptation | 0.5 days |
| Testing | 1-2 days |
| **Total** | **~10-14 days** |
---
## Conclusion
**Flask would have been a better starting point** for this project — primarily for templating (Jinja2 inheritance eliminates the fragile variable-scoping pattern), routing (decorators + middleware replace per-file boilerplate), and developer ergonomics (form validation, auto-escaping, test client).
**Flask would not deliver better performance.** The current vanilla PHP stack is actually *slightly faster* for this workload due to OPcache efficiency and the absence of framework overhead. The difference is immaterial at this scale.
**A rewrite is not justified.** The project works, the architecture is coherent (if verbose), and the codebase is small enough (~9K lines) to maintain. The practical improvements Flask would bring (cleaner templates, less boilerplate, better form handling) don't outweigh the cost of a full rewrite plus the operational change from PHP-FPM to gunicorn.
**If starting fresh today:** Flask (or even Litestar/FastAPI with Jinja2) would be the stronger choice for a SQLite-backed catalogue app of this size. The Jinja2 templating alone would save ~20% of the current codebase.
File diff suppressed because one or more lines are too long
+127
View File
@@ -0,0 +1,127 @@
# System Setup — PHP Extensions
## Required extensions (in `composer.json`)
| Extension | Used for | Enabled? |
|-----------|----------|----------|
| `pdo` | Database abstraction | ✅ |
| `pdo_sqlite` | SQLite driver | ✅ |
| `sqlite3` | Direct SQLite (migrations) | ✅ |
| `openssl` | AES-256-GCM encryption, TLS, CSRNG | ✅ |
| `json` | API responses, config, logging | ✅ |
| `ctype` | Character type checks (Composer/vendor) | ✅ (polyfill) |
| `filter` | Input validation (`filter_var`) | ✅ |
| `hash` | Password hashing, checksums | ✅ |
| `mbstring` | Multibyte string handling | ✅ (polyfill) |
| `iconv` | Character encoding conversion (vendor) | ⚠️ (polyfill) |
| `tokenizer` | PHP-CS-Fixer, PHPStan | ✅ |
| `fileinfo` | MIME type detection (FilePond uploads) | ✅ |
| `curl` | HTTP requests (PeerTube, external APIs) | ✅ |
| `zip` | ZIP export (ExportController), vendor packages | ✅ |
| `dom` | HTML/XML parsing (vendor, Parsedown→CommonMark) | ✅ |
| `libxml` | XML parsing | ✅ |
| `session` | Admin auth, CSRF, flash messages | ✅ |
| `zlib` | Compression, vendor packages | ✅ |
## Recommended extensions (not required, but useful)
| Extension | Purpose | Add? |
|-----------|---------|------|
| `gd` | Image resizing/thumbnails (cover images) | ⬜ Future |
| `exif` | Image metadata extraction | ⬜ Future |
| `sodium` | Modern crypto primitives (alternative to openssl) | ⬜ Optional |
| `intl` | Unicode collation, date formatting (locale-aware) | ⬜ Optional |
## Production server (nginx + PHP-FPM)
The production server runs PHP 8.4 FPM. The nginx config references:
```
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
```
### Production extension list
Same as above, plus:
- `php8.4-fpm` (the FPM SAPI itself)
### Enabling an extension on Arch Linux
Extensions are compiled as shared objects (`.so` files) and live in
`/usr/lib/php/modules/`. To enable one:
```bash
# 1. Verify the .so file exists
ls /usr/lib/php/modules/iconv.so
# 2. Add to /etc/php/php.ini
echo "extension=iconv" >> /etc/php/php.ini
# 3. Verify it loaded
php -m | grep iconv
# 4. Restart PHP-FPM (production)
sudo systemctl restart php8.4-fpm
```
### Checking the production server
```bash
# SSH into the server
ssh xamxam
# List loaded extensions
php -m
# List available (compiled but not loaded) extensions
ls /usr/lib/php/modules/
# Check PHP-FPM status
sudo systemctl status php8.4-fpm
```
## Required shared objects on current dev machine
The following `.so` files exist in `/usr/lib/php/modules/` but are **not loaded**:
| Extension | `.so` present? | Currently loaded? | Needed? |
|-----------|---------------|-------------------|---------|
| `iconv` | ✅ `iconv.so` | ❌ (polyfill) | For production — enable native to avoid polyfill overhead |
| `gd` | ❌ | ❌ | Not yet, but useful for cover image thumbnails |
| `exif` | ✅ `exif.so` | ❌ | Not yet |
| `intl` | ✅ `intl.so` | ❌ | Not yet |
| `sodium` | ❌ | ❌ | Not yet |
| `bcmath` | ✅ `bcmath.so` | ❌ | No |
| `bz2` | ✅ `bz2.so` | ❌ | No |
| `gmp` | ✅ `gmp.so` | ❌ | No |
| `ldap` | ✅ `ldap.so` | ❌ | No — future LDAP auth planned |
## Quick enable for production parity
To match what composer expects natively (no polyfills needed on server):
```bash
# On the server
echo "extension=iconv" | sudo tee -a /etc/php/php.ini
echo "extension=mbstring" | sudo tee -a /etc/php/php.ini
sudo systemctl restart php8.4-fpm
```
`iconv` and `mbstring` are currently satisfied by Symfony polyfills on the dev
machine. Enabling them natively on the server is a free performance improvement
and avoids a class of polyfill edge cases.
## composer.json platform requirements
```json
"require": {
"php": ">=8.4",
"ext-json": "*",
"ext-pdo": "*",
"ext-openssl": "*"
}
```
These three are declared explicitly because the application cannot function
without them. All other extensions (curl, zip, dom, etc.) are required
transitively by vendor packages and will cause a `composer install` failure
if missing.
+468
View File
@@ -0,0 +1,468 @@
# PHP Testing Best Practices
## Standard PHP Testing Structure
### Industry Standard: PHPUnit
The de facto standard for PHP testing is **PHPUnit**. Here's how professional PHP projects handle testing:
## Proper Directory Structure
```
front-backend/
├── src/ # Application code (or keep in root for small projects)
│ ├── Database.php
│ ├── RateLimit.php
│ └── ...
├── tests/ # All tests go here
│ ├── Unit/ # Unit tests (test individual methods)
│ │ ├── DatabaseTest.php
│ │ └── RateLimitTest.php
│ ├── Integration/ # Integration tests (test multiple components)
│ │ └── SearchTest.php
│ └── Security/ # Security-specific tests
│ └── SecurityTest.php
├── public/ # Public-facing files (or web root)
│ ├── index.php
│ ├── search.php
│ └── assets/
├── vendor/ # Dependencies (git-ignored, not deployed)
├── cache/ # Runtime cache (not deployed)
├── composer.json # Dependency management
├── phpunit.xml # PHPUnit configuration
└── .gitignore # Excludes tests, vendor, cache from git
```
## What We Currently Have (Non-Standard)
```
front-backend/
├── test_search.php ❌ Tests in root
├── test_security.php ❌ No framework
├── test_rate_limit.php ❌ Would deploy to production
├── create_test_db.php ❌ Test fixture in root
└── Database.php ✓ OK
```
## How Professional Projects Work
### 1. Composer Configuration
**composer.json** - Proper setup:
```json
{
"require": {
"php": "^7.4|^8.0"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"symfony/var-dumper": "^6.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit",
"test:coverage": "phpunit --coverage-html coverage"
}
}
```
**Key points:**
- `require`: Production dependencies
- `require-dev`: Development/testing dependencies (not deployed)
- `autoload-dev`: Test autoloading (not in production)
- `scripts`: Convenient test commands
### 2. PHPUnit Configuration
**phpunit.xml** - Test configuration:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
colors="true"
verbose="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory>tests/Integration</directory>
</testsuite>
<testsuite name="Security">
<directory>tests/Security</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">src</directory>
</include>
<exclude>
<directory>vendor</directory>
<directory>tests</directory>
</exclude>
</coverage>
</phpunit>
```
### 3. Example PHPUnit Test
**tests/Unit/DatabaseTest.php**:
```php
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use Database;
class DatabaseTest extends TestCase
{
private $db;
protected function setUp(): void
{
$this->db = Database::getInstance();
}
public function testGetPublishedTheses()
{
$results = $this->db->getPublishedTheses(10, 0);
$this->assertIsArray($results);
$this->assertLessThanOrEqual(10, count($results));
}
public function testSearchThesesWithWildcard()
{
$results = $this->db->searchTheses(['query' => '%'], 10, 0);
// Should return 0 results (wildcards are escaped)
$this->assertCount(0, $results);
}
public function testSearchThesesRejectsLongInput()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Search query too long');
$longQuery = str_repeat('a', 201);
$this->db->searchTheses(['query' => $longQuery]);
}
public function testSearchThesesRejectsInvalidYear()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid year');
$this->db->searchTheses(['year' => 999999]);
}
}
```
### 4. Running Tests
```bash
# Install dependencies (including dev dependencies)
composer install
# Run all tests
composer test
# or
./vendor/bin/phpunit
# Run specific test suite
./vendor/bin/phpunit --testsuite Unit
# Run specific test file
./vendor/bin/phpunit tests/Unit/DatabaseTest.php
# Run with coverage report
composer test:coverage
```
### 5. .gitignore Configuration
**.gitignore**:
```
# Dependencies
/vendor/
# Test artifacts
/coverage/
/.phpunit.cache/
/phpunit.xml.local
# Cache
/cache/
# Environment
.env
.env.local
# IDE
/.idea/
/.vscode/
*.swp
# OS
.DS_Store
Thumbs.db
# Logs
*.log
error.log
```
**Important:** Tests themselves ARE committed to git, but:
- `vendor/` is excluded (regenerated via `composer install`)
- Test coverage reports are excluded
- Cache is excluded
## Production Deployment
### What Gets Deployed
```bash
# Option 1: composer install without dev dependencies
composer install --no-dev --optimize-autoloader
# This installs ONLY 'require' packages, NOT 'require-dev'
# Result: No PHPUnit, no test dependencies
```
**Deployed:**
- Application code (`src/` or root PHP files)
- Production dependencies (`vendor/` - only `require`)
- Public assets (`public/`, `assets/`)
**NOT Deployed:**
- `tests/` directory (excluded via deployment config)
- Dev dependencies (PHPUnit, etc.)
- `cache/` directory
- `.git/` directory
### Deployment Configurations
**Option 1: .deployignore** (custom deploy scripts):
```
/tests/
/coverage/
/.git/
/.github/
/cache/
phpunit.xml
phpunit.xml.dist
.env.example
README*.md
*.md
```
**Option 2: rsync with excludes** (like your justfile):
```bash
rsync -avz \
--exclude 'tests/' \
--exclude 'coverage/' \
--exclude 'cache/' \
--exclude '.git/' \
--exclude 'phpunit.xml' \
--exclude '*.md' \
./ server:/var/www/html/
```
**Option 3: Build artifact** (best for large projects):
```bash
# Build step
composer install --no-dev --optimize-autoloader
# Creates clean vendor/ with only production deps
# Then deploy only necessary files
```
## Continuous Integration (CI/CD)
Professional projects run tests automatically:
**GitHub Actions** (.github/workflows/tests.yml):
```yaml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run tests
run: composer test
- name: Check security
run: ./vendor/bin/phpunit --testsuite Security
```
## Test Types
### Unit Tests
Test individual methods in isolation:
```php
public function testEscapeLikeString()
{
$db = new Database();
$reflection = new ReflectionClass($db);
$method = $reflection->getMethod('escapeLikeString');
$method->setAccessible(true);
$result = $method->invoke($db, 'test%value_here');
$this->assertEquals('test\%value\_here', $result);
}
```
### Integration Tests
Test multiple components together:
```php
public function testSearchWithMultipleFilters()
{
$db = Database::getInstance();
$results = $db->searchTheses([
'query' => 'urbain',
'year' => 2024,
'orientation' => 'Arts Numériques'
]);
$this->assertNotEmpty($results);
foreach ($results as $result) {
$this->assertEquals(2024, $result['year']);
}
}
```
### Security Tests
Test security measures:
```php
public function testSqlInjectionPrevention()
{
$db = Database::getInstance();
// These should not cause errors or expose data
$malicious = ["' OR 1=1--", "'; DROP TABLE theses;--"];
foreach ($malicious as $injection) {
$results = $db->searchTheses(['query' => $injection]);
// Treated as literal strings, returns valid results or empty
$this->assertIsArray($results);
}
}
```
## Comparison: Current vs. Standard
| Aspect | Current Approach | Standard Approach |
|--------|------------------|-------------------|
| **Location** | Root directory | `tests/` directory |
| **Framework** | Raw PHP scripts | PHPUnit |
| **Naming** | `test_*.php` | `*Test.php` |
| **Running** | `php test_file.php` | `composer test` |
| **CI/CD** | Manual | Automated |
| **Production** | Must manually exclude | Auto-excluded |
| **Coverage** | None | Built-in reporting |
| **Assertions** | Manual echoing | PHPUnit assertions |
## Migration Path for Your Project
### Minimal Changes (Keep it Simple)
If you want to keep the current simple approach but make it safer:
1. **Move tests to `tests/` directory:**
```bash
mkdir tests
mv test_*.php tests/
mv create_test_db.php tests/fixtures/
```
2. **Update justfile to exclude tests:**
```just
deploy:
rsync -vur --progress \
--exclude 'tests/' \
--exclude 'cache/' \
--exclude '*.db' \
./front-backend/ server:/var/www/html/
```
3. **Add .gitignore:**
```
/cache/
/vendor/
*.log
test.db
```
### Recommended Approach (Industry Standard)
For a more professional setup:
1. **Install PHPUnit:**
```bash
composer require --dev phpunit/phpunit
```
2. **Convert tests to PHPUnit** (I can help with this)
3. **Add phpunit.xml configuration**
4. **Update deployment to use `composer install --no-dev`**
## Benefits of Standard Approach
1. **Automatic Exclusion**: Tests never deployed by accident
2. **Better Assertions**: PHPUnit provides rich assertion library
3. **Coverage Reports**: See which code is tested
4. **CI/CD Integration**: Automated testing on every commit
5. **IDE Support**: Better integration with PHPStorm, VSCode
6. **Mocking**: Easy to mock dependencies
7. **Data Providers**: Test same logic with multiple inputs
8. **Professional**: Expected by other developers
## Quick Decision Guide
**Keep Simple Approach If:**
- ✓ Small project (< 10 files)
- ✓ Solo developer
- ✓ No CI/CD pipeline
- ✓ You manually test before deploy
**Use PHPUnit If:**
- ✓ Team project
- ✓ Growing codebase
- ✓ Want automated testing
- ✓ Need coverage reports
- ✓ Planning CI/CD
## Recommendation for Your Project
Given your project size, I'd suggest a **hybrid approach**:
1. **Move tests to `tests/` directory** (immediate)
2. **Update deployment to exclude `tests/`** (immediate)
3. **Keep simple PHP test scripts for now** (works fine)
4. **Migrate to PHPUnit later** (when project grows)
Would you like me to help with any of these approaches?
+161
View File
@@ -0,0 +1,161 @@
# VM Crash Root Cause Analysis (posterg.erg.be)
**Date:** 2026-03-26
**Server:** posterg.erg.be
**Status:** ✅ ROOT CAUSE IDENTIFIED — **NOT the application's fault**
> Merged from `VM_Crash_Analysis_FINAL.md`, `VM_Crash_Reports.md`,
> `EVIDENCE_SUMMARY.md`, and `IMMEDIATE_FIX.md` (single incident).
---
## 🔥 ROOT CAUSE: Serial Console (serial-getty) Crash Loop
The VM did **not** crash due to the nginx/posterg application. The crash was
caused by a **systemd `serial-getty@ttyS0` service crash loop** that ran
continuously for ~50 days, eventually exhausting system memory.
### The smoking gun
- **1,264,488 serial-getty crashes** recorded in the journal
- **Restart counter reached 421,491** by the time of the OOM event
- **Crashed every 10 seconds** for the entire uptime
- Error: `agetty[PID]: could not get terminal name: -22` / `failed to get terminal attributes: Input/output error`
### Timeline reconstruction
| Date | Event | Details |
|------|-------|---------|
| Jan 13, 2026 | System boot | Clean boot, services started normally |
| Jan 13 – Mar 4 | Serial getty crash loop | ~421,491 restarts over 48.7 days (6 restarts/min) |
| Mar 4, 10:45 | MariaDB memory pressure | InnoDB reports memory pressure event |
| Mar 4, 10:50 | OOM Killer triggered | Systemd invokes OOM killer due to memory exhaustion |
| Mar 4, 10:51 | Journal stops | System likely became unresponsive |
| Mar 4 – Mar 24 | Unknown state | 20-day gap in logs |
| Mar 24, 12:56 | Hard reboot | Technicians forced reboot |
| Mar 24, 12:57 | System back online | New boot, clean state |
### Why this happened
**QEMU/KVM virtual machine configuration issue.** The error
`could not get terminal name: -22` (EINVAL) indicates the VM's serial console
(ttyS0) is misconfigured or not properly connected at the hypervisor level.
Common causes: serial console enabled in VM config but not attached to host,
QEMU `-serial` parameter misconfigured, VirtIO console driver issue, or
host-side serial device permissions.
### Resource impact
Each `agetty` spawn creates a process, opens file descriptors, and logs to the
journal (~200 bytes per entry). Over 50 days at 6 crashes/minute:
- ~421,000 failed process spawns
- ~1.2 million journal entries (~240MB journal bloat)
- Gradual memory exhaustion → OOM killer
---
## 🔍 The application is NOT at fault
Evidence the posterg application is innocent:
1. **No PHP-FPM crashes** — clean operation, 11.1–11.2M peak memory
2. **No nginx errors before OOM** — the 234KB error log is from *after* the
reboot (Mar 26), mostly blocked security-scanner attempts
3. **Normal traffic** — only internal IP 192.168.6.11 accessing the site
4. **No DB issues before crash** — SQLite working fine
### Post-reboot issues (unrelated to crash)
After the Mar 24 reboot there were schema errors (`no such table: tags`,
`no such column: ts.role`) caused by code updates (Mar 24 14:49) without a
matching migration — **not** the crash cause.
### Post-reboot security events (Mar 26)
955 blocked requests from 192.168.6.11 (`.env`, `.git/config`, WordPress/
Next.js/Nuxt.js probes) — all properly blocked by nginx (working as designed).
---
## 🛠️ The fix
Disable the broken serial console service:
```bash
sudo systemctl stop serial-getty@ttyS0.service
sudo systemctl disable serial-getty@ttyS0.service
sudo systemctl mask serial-getty@ttyS0.service
# Verify
sudo systemctl status serial-getty@ttyS0.service # → "Loaded: masked"
```
**Also fix the post-reboot DB schema errors:**
```bash
cd /var/www/posterg
ls -la storage/migrations/
sqlite3 storage/posterg.db "SELECT name FROM sqlite_master WHERE type='table';"
```
### Optional: fix the serial console properly (hypervisor)
If serial console access is needed for emergency recovery, configure it on the
QEMU/KVM host via `virsh edit posterg` (add/verify `<serial type='pty'>` +
`<console ...>`), restart the VM in a maintenance window, then unmask/re-enable
`serial-getty@ttyS0`.
---
## 📊 Post-reboot system health
✅ All systems healthy — memory 6% used, disk 12% used, swap unused, load idle.
nginx 4 workers, PHP-FPM 2 workers, MariaDB 155MB RSS (all normal).
---
## 🎯 Recommendations
1. **CRITICAL:** disable `serial-getty@ttyS0` (see fix above)
2. **Fix DB schema** for post-reboot errors
3. **Improve monitoring** — `prometheus-node-exporter` or systemd unit monitoring
would have surfaced the serial-getty loop earlier
4. **Journal maintenance:**
```bash
sudo journalctl --disk-usage
sudo journalctl --vacuum-size=500M
sudo journalctl --vacuum-time=30d
# /etc/systemd/journald.conf: SystemMaxUse=500M, SystemKeepFree=1G, MaxRetentionSec=30day
```
5. **Optional:** tighten `limit_req` rates and add `fail2ban` for repeated 403s
---
## 📎 Appendix: technical details
### OOM event
```
Mar 04 10:50:23 posterg kernel: systemd invoked oom-killer
gfp_mask=0x140cca(GFP_HIGHUSER_MOVABLE|__GFP_COMP), order=0
```
### Serial getty error code
`agetty[PID]: could not get terminal name: -22` — EINVAL, terminal
initialization on a misconfigured ttyS0 device.
### Journal statistics
- Total journal entries: ~193 MB
- Serial-getty crashes: 1,264,488 (~65% of journal)
- Uptime at OOM: ~50 days (Jan 13 – Mar 4)
- Crash frequency: every 10s; total restarts 421,491
---
**Report prepared by:** Automated analysis + human review
**Confidence:** 🟢 HIGH (definitively identified from kernel/journal/service logs)
**Risk:** before fix 🟠 HIGH (will recur ~50 days) · after fix 🟢 LOW