Files
xamxam/TODO.md
Pontoporeia 7d836c165c docs: frontend & template audit — add sections D–H to TODO.md
Analysed all public pages, CSS files, and template partials. Found:

Template structure (D):
- <head> boilerplate duplicated across 5 public pages (no shared partial exists)
- Live-reload snippet copy-pasted into 6 files
- templates/header.php and templates/head.php are dead/orphaned files
- public/assets/icons.svg is a dead TrumboWYG sprite (never referenced, ~15 KB)
- admin_favicon.svg used as public favicon (misleading naming)

CSS (E):
- html/body reset block repeated in 4 page stylesheets; belongs in common.css
- @font-face missing font-display:swap (FOIT risk)
- Search pagination is fully inline-styled; home page already has .pagination-btn classes
- Multiple one-off inline styles across tfe.php, edit.php, index.php
- .site-nav__right is a CSS duplicate of .site-nav__link
- .site-nav__link--active applied in PHP but has no CSS rule (invisible active state)

Template logic (F):
- 429 rate-limit response is bare unstyled HTML
- apropos.php contacts/credits hardcoded (require code deploy to change)
- licence.php wastes half the viewport with an always-empty right column

Accessibility (G):
- <nav> has no aria-label; search <form> has no accessible name
- No <meta name=description> on any public page
- No Open Graph tags anywhere (blank previews when sharing thesis links)

Minor (H):
- thanks.php duplicates getThesisFiles() with a raw query
- admin/index.php stats broken when filters are active (PHP array_filter on subset)
2026-03-26 22:51:16 +01:00

565 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TODO
## Styling Redesign (matching design images)
- [x] Redesign shared nav bar (purple gradient top, flat, POSTERG / RÉPERTOIRE / À PROPOS)
- [x] Redesign shared search bar (full-width, icon, bottom border only, white bg)
- [x] Rewrite `common.css` (nav + search bar components)
- [x] Rewrite `main.css` (home page — white bg, media card grid, label below)
- [x] Rewrite `search.css` (répertoire index — 4-col ANNÉES/CATÉGORIES/ÉTUDIANTES/MOTS-CLÉS)
- [x] Rewrite `tfe.css` (TFE page — 2-col, large author/title left, media right)
- [x] Add `apropos.css` (À Propos — 2-col, large monospace text)
- [x] Rewrite `admin.css` (dark bg, purple gradient nav, bottom-border-only form inputs)
- [x] Update `templates/nav.php` (new shared nav partial)
- [x] Update `templates/search-bar.php` (new shared search bar partial)
- [x] Rewrite `public/index.php` (home page with new layout)
- [x] Rewrite `public/search.php` (répertoire index view + search results view)
- [x] Rewrite `public/tfe.php` (individual TFE page)
- [x] Create `public/apropos.php` (À Propos page)
- [x] Rewrite `templates/admin/head.php` (admin nav)
- [x] Rewrite `templates/admin/footer.php` (clean close)
- [x] Rewrite `public/admin/add.php` (form with row layout)
- [x] Rewrite `public/admin/index.php` (dark table)
- [x] Rewrite `public/admin/edit.php` (form with row layout)
- [x] Rewrite `public/admin/login.php` (centered dark login box)
- [x] Rewrite `public/admin/thanks.php` (dark info cards)
- [x] Rewrite `public/admin/import.php` (clean dark form)
## Justfile / Ops
- [x] Simplify `serve` and `deploy` to one recipe each
- [x] Remove sysadmin recipes (server-logs, server-status, deploy-nginx, deploy-admin-tools)
- [x] Extract server scripts to `scripts/` (deploy-server.sh, manage-admin-users.sh)
- [x] Guard `deploy-db` against overwriting existing remote database
- [x] Update README.md and docs/SERVER_SETUP.md to reflect current structure
---
## NEW FEATURES
### 1 — License page (public)
Create a public-facing `/licence.php` page, styled consistently with `apropos.php`.
- [x] **`public/licence.php`** — new public page; fetches content from `pages` table
(slug `'licenses'`); renders with Parsedown Markdown; uses `apropos.css` layout
- [x] **`templates/nav.php`** — add "Licence" link between "Répertoire" and "À Propos";
apply `site-nav__link--active` when `$currentNav === 'licence'`
- [x] The `pages` table row for slug `'licenses'` verified in live DB
---
### 2 — Admin: WYSIWYG/Markdown editors for static pages
Allow admins to edit the content of the "À propos" and "Licence" pages from the admin
panel, stored in the existing `pages` table.
#### 2a — `src/Database.php`
- [x] `getPage(string $slug): array|null``SELECT * FROM pages WHERE slug = ?`
- [x] `savePage(string $slug, string $content): void` — throws if slug not found
- [x] `getAllPages(): array` — for listing in admin
#### 2b — Admin pages editor UI
- [x] **`public/admin/pages.php`** — list all editable pages; links to edit each one
- [x] **`public/admin/pages-edit.php`** — EasyMDE WYSIWYG Markdown editor via CDN
#### 2c — `public/admin/actions/page.php`
- [x] Auth guard + CSRF check + slug validation + length validation + savePage + redirect
#### 2d — Public pages render Markdown
- [x] **`public/apropos.php`** — renders `$db->getPage('about')` via Parsedown (bundled `src/Parsedown.php`)
- [x] **`public/licence.php`** — renders `$db->getPage('licenses')` via Parsedown
- [x] Parsedown bundled as `src/Parsedown.php` (zero-dependency, MIT)
#### 2e — Nav links in admin
- [x] **`templates/admin/head.php`** — "Pages statiques" nav item added
---
### 3 — License field on TFE forms
Add a "Licence" dropdown to the Add and Edit TFE forms. The `license_types` table
already exists in the schema with an `id`, `name`, `description` structure but has
no seed data yet.
#### 3a — Schema / DB
- [x] **`storage/schema.sql`** — seed `INSERT OR IGNORE` for 8 CC licence types added
- [x] **`storage/migrations/003_seed_license_types.sql`** — migration created + applied
- [x] Verified live DB has `license_types` with 8 rows
#### 3b — `src/Database.php`
- [x] `getLicenseTypes(): array`
- [x] `getAllLicenseTypes(): array` — alias
#### 3c — Add form (`public/admin/add.php`)
- [x] Loads `$licenseTypes`; "Licence" `<select name="license_id">` added before duration
#### 3d — Add action (`public/admin/actions/formulaire.php`)
- [x] `$licenseId` parsed + included in INSERT
#### 3e — Edit form (`public/admin/edit.php`)
- [x] Loads `$licenseTypes`; raw `license_id` FK fetched directly; select pre-populated
- [x] POST handler: `license_id` included in UPDATE
#### 3f — View update
- [x] **`storage/schema.sql`** — `v_theses_full` now exposes `t.license_id` raw FK (done as part of 004/005 view rebuild)
#### 3g — TFE public page
- [x] **`public/tfe.php`** — "Licence :" meta row added, shown when non-null
---
### 4 — Jury composition section in Add/Edit forms
Replace the current flat "promoteur interne / externe" fields with a structured
**Composition du jury** section: président·e, promoteur·ice, lecteur·ices (mixed
internal/external).
#### 4a — Schema / DB
Current state: `supervisors` table + `thesis_supervisors` junction with a bare
`supervisor_order` integer. No role distinction.
- [x] **`storage/migrations/004_jury_roles.sql`**:
- `ALTER TABLE thesis_supervisors ADD COLUMN role TEXT NOT NULL DEFAULT 'promoteur'`
— role values: `'president'`, `'promoteur'`, `'lecteur'`
- `ALTER TABLE thesis_supervisors ADD COLUMN is_external INTEGER NOT NULL DEFAULT 0`
— 1 = external, 0 = internal (replaces the old "promoteur externe" free-text field)
- No data loss: existing rows get `role = 'promoteur'`, `is_external = 0`
- [x] **`storage/schema.sql`** — add the two new columns to `thesis_supervisors`
definition; update `v_theses_full` to expose jury members grouped by role:
- `GROUP_CONCAT(DISTINCT CASE WHEN ts.role='president' THEN s.name END) as jury_president`
- `GROUP_CONCAT(DISTINCT CASE WHEN ts.role='promoteur' THEN s.name END) as jury_promoteurs`
- `GROUP_CONCAT(DISTINCT CASE WHEN ts.role='lecteur' THEN s.name END) as jury_lecteurs`
- Keep the existing `supervisors` column (all names) for backwards compat
- Migration SQL must DROP + CREATE the view
#### 4b — `src/Database.php`
- [x] `getThesisJury(int $thesisId): array` — fetch all supervisors for a thesis with
their role and is_external flag
- [x] `setThesisJury(int $thesisId, array $juryMembers): void` — delete + re-insert
#### 4c — Add form (`public/admin/add.php`)
- [x] Remove `promoteurice` and `promoteurice_externe` fields
- [x] Add **"Composition du jury"** fieldset: président·e, promoteur·ice + externe checkbox, dynamic lecteur·ices list with JS add/remove
#### 4d — Add action (`public/admin/actions/formulaire.php`)
- [x] Removed old promoteurice parsing; parse jury fields; call `$db->setThesisJury()`
#### 4e — Edit form (`public/admin/edit.php`)
- [x] Load `$jury = $db->getThesisJury($thesisId)`; jury fieldset pre-populated from DB
- [x] POST handler calls `$db->setThesisJury()`
#### 4f — TFE public page (`public/tfe.php`)
- [x] Three conditional jury rows: Président·e, Promoteur·ice, Lecteur·ices
---
### 5 — Banner image upload for home page cards
Each TFE can have an optional banner image used as its home page card thumbnail.
This is distinct from the existing "couverture" file concept (which goes into
`thesis_files` as type `'cover'`) — the banner is specifically optimised for the
home grid (wider, shorter aspect ratio).
#### 5a — Schema / DB
- [x] **`storage/migrations/005_add_banner.sql`** — `ALTER TABLE theses ADD COLUMN banner_path TEXT`
- [x] **`storage/schema.sql`** — `banner_path TEXT` in `theses`; `t.banner_path` in view
#### 5b — Add form (`public/admin/add.php`)
- [x] "Image bannière" file input added after couverture row
#### 5c — Add action (`public/admin/actions/formulaire.php`)
- [x] Banner upload: MIME check, 5 MB cap, save to `banners/`, call `setBannerPath()`
#### 5d — Edit form (`public/admin/edit.php`)
- [x] Banner preview img shown; remove_banner checkbox; new banner upload input
- [x] POST handler: unlinks old file on remove; processes new upload via `setBannerPath()`
#### 5e — `src/Database.php`
- [x] `banner_path` exposed via view — verified; `getThesisById()` / `getPublishedTheses()` pick it up automatically
- [x] `setBannerPath(int $thesisId, ?string $path): void` added
---
### 6 — Home page: gradient placeholder cards & random ordering
#### 6a — Gradient placeholder for cards without a banner
- [x] **`public/index.php`** — gradient placeholder using HSL hue from thesis ID
- [x] **`public/assets/main.css`** — `.card__media--gradient`, `.card__gradient-author`,
`.card__gradient-title` styles added
#### 6b — Banner image as card thumbnail
- [x] **`public/index.php`** — checks `banner_path` first, falls through to gradient
#### 6c — Random ordering from the latest year
- [x] **`src/Database.php`** — `getLatestYearTheses(int $limit = 24)` + `getLatestPublishedYear()`
- [x] **`public/index.php`** — default home view uses random latest-year selection;
paginated view for `?year=X` and `?page=N`; info label shown
---
## Refactor: M2M tags via `tags` + `thesis_tags` junction table
The current schema stores keywords in a `keywords` table joined via `thesis_keywords`.
The field column is named `keyword` (not `name`), breaking the naming convention used by
every other lookup table (`orientations.name`, `format_types.name`, etc.).
More critically, `buildSearchConditions` and the view `v_theses_full` filter keywords
through `GROUP_CONCAT` strings with `LIKE`, bypassing the junction table entirely.
Goal: rename the tables and column to the canonical M2M pattern (`tags`, `thesis_tags`,
`tags.name`), add the missing index, and rewrite all tag queries to use a proper JOIN.
### 1 — Schema migration (`storage/schema.sql` + live DB)
- [x] Rename table `keywords``tags`; column `keyword``name`
- [x] Rename junction `thesis_keywords``thesis_tags`; FK `keyword_id``tag_id`
- [x] PK on `thesis_tags(tag_id, thesis_id)`; `idx_tags_name`; updated index names
- [x] Views `v_theses_full`/`v_theses_public` use `thesis_tags`/`tags.name`
- [x] Migration `storage/migrations/001_rename_keywords_to_tags.sql` written and applied
### 2 — `src/Database.php`
- [x] `findOrCreateTag()` added; `findOrCreateKeyword()` is a backwards-compat alias
- [x] `getUsedTags()` rewritten with proper M2M JOIN; `getUsedKeywords()` alias kept
- [x] `buildSearchConditions`: keyword/query use `EXISTS` subquery on `thesis_tags`/`tags`
- [x] All conditions prefixed with `vp.` to match view alias; `vp` alias added to search queries
### 3 — Admin write paths
- [x] `public/admin/actions/formulaire.php`: uses `findOrCreateTag` + `thesis_tags`
- [x] `public/admin/edit.php`: `DELETE FROM thesis_tags` + `findOrCreateTag` + `thesis_tags`
### 4 — Public read paths
- [x] `public/search.php`: fixed `$kw['keyword']``$kw['name']` (tag column rename)
- [x] `getUsedKeywords()` alias delegates to `getUsedTags()` — no functional change needed
- [x] `public/tfe.php`: `$data['keywords']` still works — view column name unchanged
- [x] `templates/search-bar.php`: no keyword param refs — verified
### 5 — Admin tag management UI (`/admin/tags.php`)
#### 5a — `src/Database.php`
- [x] `getAllTagsWithCount()`, `renameTag()`, `mergeTag()`, `deleteTag()`
#### 5b — `public/admin/tags.php`
- [x] Auth guard, CSRF, table with rename/merge/delete per row, inline forms
#### 5c — `public/admin/actions/tag.php`
- [x] Routes on `$_POST['action']`: rename, merge, delete
#### 5d — Nav & routing
- [x] `templates/admin/head.php`: "Mots-clés" nav link added
#### 5e — Propagation safety
- [x] mergeTag() uses INSERT OR IGNORE to avoid PK conflicts; deleteTag() cascades via FK
### 6 — Tests
- [x] `tests/Unit/DatabaseTest.php`: tests 57 cover findOrCreateTag, getUsedTags, alias
- [x] `tests/Integration/SearchTest.php`: tests 46 cover tag-filter subquery, full-text query, count consistency
### 6 — Fixtures / seed data
- [x] `storage/fixtures/CreateTestDatabase.php`: updated to `tags`/`thesis_tags`/`findOrCreateTag()`
---
## Feature: Mode Maintenance
- [x] Storage flag file `storage/maintenance.flag` (created on demand)
- [x] Public gate in `config/bootstrap.php` — blocks non-admin routes when flag exists
- [x] `public/maintenance.php` (503 page, minimal dark UI)
- [x] `public/admin/actions/maintenance.php` (POST: enable/disable)
- [x] Admin UI toggle in `public/admin/index.php` (bar with status + action button)
---
## Feature: TFE Visibility States (publique / interne / interdit)
- [x] DB migration `002_add_visibility.sql` — seeds access_types rows (already existed)
- [x] `src/Database.php``setVisibility()`, `bulkSetVisibility()`, `getAccessTypes()`
- [x] `public/media.php` — blocks thesis files when access_type_id = 3 (Interdit)
- [x] `public/tfe.php` — shows access type, context_note, hides files for Interdit
- [x] `public/admin/edit.php` — access_type_id select + context_note textarea
- [x] `public/admin/index.php` — three-state access badge per row
- [x] `public/admin/actions/visibility.php` — single + bulk visibility update
---
## Fixes
- [x] Fix `tests/Security/SecurityTest.php`: update SQL injection test to call `searchTheses(['query' => $string])` instead of bare string — `searchTheses()` signature was updated to `array $params` but the test was never updated, causing a fatal `TypeError` that prevented the security suite from running at all
## Pending
- [x] Add flake.nix for Nix-based PHP dev environment
- [x] Add favicon (`<link rel="icon">` → admin_favicon.svg) to all pages; nginx 204 for /favicon.ico
- [x] Remove 100-item cap from répertoire student index: `getAllPublishedTheses()` fetches all published theses; search results remain paginated at 30/page
- [x] Cover image fallback for home grid cards: batch-load `thesis_files` covers for theses without `banner_path`; resolution order: banner → cover → gradient
## Admin / Server
- [x] Create `scripts/setup-server.sh` (one-time server setup: group, ownership, setgid 2775 on dirs)
- [x] Add `just setup-server` recipe (rsync + run setup-server.sh on remote)
- [x] Exclude `.claude` and `.pi` from rsync deploy
- [x] Update `docs/SERVER_SETUP.md` with correct permissions rationale and troubleshooting
- [x] Add server status view in admin panel (nginx + php-fpm health, site HTTP check)
- [x] Add server log viewer in admin panel (tail nginx error/access logs via SSH or log endpoint)
- [x] Add nginx config viewer to admin panel: "nginx — config" tab in `system.php` reads `/etc/nginx/sites-available/posterg` (live, with badge) or falls back to local `nginx/posterg.conf`; line-numbered, syntax-coloured, copy-to-clipboard
- [x] Add admin user management UI — password change/set for PHP auth layer (`public/admin/account.php` + `actions/account.php`; "Compte" nav link; account CSS)
- [x] Merge `status.php` and `logs.php` into a single `system.php` page; remove "Statut" and "Journaux" nav links, add single "Système" link; preserve all existing content in their respective sections
- [x] Rework logs UI: replace the select-then-click-Afficher flow with instant tabs (nginx access, nginx error, php-fpm); switching tabs loads the selected log immediately without a form submit; add a copy-to-clipboard button per log view
---
## Refactor & Maintenance (backend audit 2026-03-26)
### A — SQLite / Query performance
- [ ] **WAL mode** — set `PRAGMA journal_mode = WAL` and `PRAGMA synchronous = NORMAL` in `Database::__construct()` after `foreign_keys = ON`.
Eliminates full-database read-locks on every write; makes concurrent PHP-FPM workers safe.
Also add `PRAGMA cache_size = -8000` (≈8 MB page cache) while there.
- [ ] **Composite index `(is_published, year DESC)`** on `theses` — every public query filters on both.
Currently `idx_theses_published` and `idx_theses_year` are separate; the query planner picks one
and sorts the other with a temp B-tree. A single covering index eliminates the sort:
`CREATE INDEX IF NOT EXISTS idx_theses_pub_year ON theses(is_published, year DESC);`
Add to `schema.sql` + a migration.
- [ ] **`v_theses_full` is always fully materialised** — every query against `v_theses_public` forces
SQLite to expand the entire view CTE (all 15 JOINs + 8 GROUP_CONCAT temp B-trees) before
applying the `WHERE`/`LIMIT`. The view cannot be used with an index. Fix: **replace `v_theses_public`
with a plain table query in `searchTheses` and `getPublishedTheses`** — build the minimal JOIN set
per query and let indexes filter `theses` first. Keep the views for reporting/admin use only.
- [ ] **`getAllPublishedTheses()` in `search.php`** — fetches every published thesis (all columns,
all JOINs) just to build the student-name index on the Répertoire page. This is a full table
scan through the fat view. Replace with a dedicated lean query:
`SELECT id, authors FROM v_theses_public ORDER BY authors ASC` — or add
`Database::getPublishedAuthors(): array` that queries `thesis_authors JOIN authors` directly,
avoiding the view entirely.
- [ ] **`migration 005` view is stale in the file** — `005_add_banner.sql` recreates the view still
referencing `thesis_keywords` / `keywords.keyword` (the old pre-migration-001 names).
The file is already applied to the live DB (correctly, since 001 ran first), but the migration
file itself is wrong and misleading. Fix: rewrite it to reference `thesis_tags` / `tags.name`,
or drop the view recreation from it (schema.sql is the canonical source).
### B — PHP / Database.php
- [ ] **Dead CRUD helpers**`getOrientationId()`, `getAPProgramId()`, `getFinalityId()`,
`getLanguageId()`, `getFormatId()` are defined in `Database.php` but **never called** anywhere
(forms now pass IDs directly from selects). Remove them to reduce surface area.
- [ ] **Alias proliferation**`getOrientations()`/`getAllOrientations()`, `getApPrograms()`/`getAllAPPrograms()`,
`getFinalityTypes()`/`getAllFinalityTypes()`, `getLanguages()`/`getAllLanguages()`,
`getFormatTypes()`/`getAllFormatTypes()`, `getLicenseTypes()`/`getAllLicenseTypes()`,
`findOrCreateKeyword()`, `getUsedKeywords()`**13 alias methods** pointing at 6 real ones.
Pick the canonical name for each pair, update all call-sites (there are few), and delete aliases.
Reduces Database.php from ~945 lines significantly.
- [ ] **`getPDO()` / `getConnection()` leaking to callers** — `edit.php`, `formulaire.php`,
`thanks.php`, `import.php`, `tfe.php`, `index.php`, `media.php`, `system.php` all call
`$db->getPDO()` or `$db->getConnection()` to run raw queries that belong in `Database.php`.
Each is a missed encapsulation:
- `tfe.php`: raw `SELECT access_type_id FROM theses WHERE id = ?` → add `getThesisAccessTypeId(int $id): ?int`
- `index.php`: raw `SELECT thesis_id, file_path FROM thesis_files WHERE … IN (…)` → add `getCoverPathsForTheses(array $ids): array`
- `media.php`: raw visibility join → move into `Database::getFileVisibility(string $path): ?int`
- `edit.php` (line 155): unparameterised `"… WHERE id = $thesisId"` **SQL injection risk** — fix immediately; also move to a DB method
- `edit.php`: raw `SELECT license_id, access_type_id, context_note FROM theses WHERE id = ?` → expose these via `getThesis()` (already returns `v_theses_full` which has `license_id`)
- `formulaire.php`: raw identifier-generation query + all junction-table INSERTs → encapsulate in `Database::createThesis(array $data): int`
- [ ] **`sanitize_string()` in `formulaire.php` applies `htmlspecialchars` at write time** —
HTML-escaping belongs at render time (in the template), not at storage time. Storing
`&amp;` or `&lt;` in the DB means search, export, and any non-HTML consumer sees corrupt data.
Remove `htmlspecialchars` from `sanitize_string()`; keep only `trim()`. The templates already
call `htmlspecialchars()` on output.
- [ ] **Dead variable `$problematique`**`formulaire.php` line 84 reads `$_POST["problématique"]`
into `$problematique` but the value is **never used** (no matching column, no INSERT reference).
Delete it.
- [ ] **`setThesisJury()` not wrapped in a transaction** — the method does a DELETE then multiple
INSERTs with no transaction guard of its own. If called from outside a transaction (e.g. a
future API endpoint) a partial failure leaves orphaned rows. Wrap the body in
`BEGIN … COMMIT / ROLLBACK` (check `$this->pdo->inTransaction()` to avoid nesting).
- [ ] **DB config auto-detection is fragile**`src/config.php` switches to `test.db` whenever the
file exists locally, which means a developer who ran tests and forgot to delete `test.db` will
silently hit test data on a local production-mirror. Make the default `prod`; require explicit
`DB_ENV=test` to use the test database.
### C — Code organisation / maintainability
- [ ] **`edit.php` does too much** — 530 lines combining form display, POST handling, file upload,
and all reference-data loading in one file. Extract the POST handler to
`public/admin/actions/edit.php` (matching the pattern already used by `formulaire.php`,
`tag.php`, `page.php`, etc.).
- [ ] **`formulaire.php` duplicates banner-upload logic verbatim from `edit.php`** — the MIME check,
size cap, `random_bytes` name, `chmod`, and `setBannerPath()` call are copy-pasted.
Extract a private `Database`-level helper or a standalone `uploadBanner(array $file): ?string`
utility function (returns the relative path or null) shared by both action files.
- [ ] **Junction-table INSERTs are open-coded in every action**`formulaire.php` and `edit.php`
both manually loop `INSERT INTO thesis_languages`, `thesis_formats`, `thesis_tags`.
Add `Database::setThesisLanguages(int $id, array $ids)`, `setThesisFormats(int $id, array $ids)`,
`setThesisTags(int $id, array $names)` — following the same delete-then-reinsert pattern
already used by `setThesisJury()`.
- [ ] **`RateLimit` uses per-file JSON on disk** — reads, writes, and `glob()`s the filesystem on
every public request. For a low-traffic art-school site this is fine, but it creates a
write-on-every-hit pattern. Consider switching to APCu (if available) or SQLite (single INSERT)
to avoid filesystem churn. At minimum, move the cache dir to `/tmp` or a dedicated
`storage/cache/` path that is excluded from deploy rsync.
- [ ] **`__wakeup()` singleton guard throws from a public method** — PHP 8.x deprecates
throwing exceptions from `__wakeup`. Change to `trigger_error(…, E_USER_ERROR)` or implement
`__serialize()`/`__unserialize()` that always throw.
---
## Refactor & Maintenance — Templates & Frontend (audit 2026-03-26)
### D — Template structure / boilerplate duplication
- [ ] **Every public page duplicates its own `<head>`**`index.php`, `search.php`, `tfe.php`,
`apropos.php`, `licence.php` each contain an identical block: `<!DOCTYPE html>`,
`<html lang="fr">`, `<meta charset>`, `<meta viewport>`, `<link rel="icon">`,
`<link modern-normalize>`, `<link common.css>`, live-reload script. Only `<title>` and one
extra CSS `<link>` differ. Extract a `templates/public/head.php` partial accepting
`$pageTitle` and `$extraCss` — mirrors the pattern `templates/admin/head.php` already uses.
- [ ] **Live-reload snippet copy-pasted into 6 files**`index.php`, `search.php`, `tfe.php`,
`apropos.php`, `licence.php`, `templates/admin/head.php` all contain the same 6-line
`(function poll(){…})()` block. Consolidate into the shared head partials.
- [ ] **`templates/header.php` and `templates/head.php` are dead files** — neither is `include`d
anywhere in the codebase. Both contain outdated markup from a previous design iteration
(`lang="en"`, empty author meta, a broken nav with double-quoted `href` attributes inside
`href`). Delete both to remove confusion.
- [ ] **`public/assets/icons.svg` is dead** — it is the full TrumboWYG editor icon sprite (40+
symbols) referenced **nowhere** in the codebase. The only WYSIWYG editor in use (EasyMDE
in `pages-edit.php`) loads from CDN. Delete `icons.svg` (~15 KB of noise).
- [ ] **`admin_favicon.svg` used as the public-facing favicon** — every public page links
`/assets/admin_favicon.svg`. Rename or create a distinct `favicon.svg` so admin and public
can diverge without naming confusion.
### E — CSS architecture
- [ ] **`html, body { margin:0; padding:0; height:100% }` repeated in 4 page stylesheets** —
`main.css`, `search.css`, `tfe.css`, `apropos.css` all open with this identical block.
Move it to `common.css` once; delete from the four files. Same for the body-level flex-column
shell (`display:flex; flex-direction:column; background:var(--white)`) which only differs in
the BEM class name applied to `<body>`.
- [ ] **No `font-display` on the `Combinedd.otf` custom font**`common.css` declares `@font-face`
with no `font-display` property; the browser blocks text rendering until the font loads (FOIT).
Add `font-display: swap`. Also add a `<link rel="preload">` for the font file in the shared
head partial once it exists.
- [ ] **Search results pagination is fully inline-styled**`search.php` lines 159164 apply
`style="padding:.25rem .7rem;border:1px solid #ddd;…"` and hardcoded `#ddd`/`#666`. The home
page (`index.php`) already has `.pagination-btn` / `.pagination-info` in `main.css`. Reuse
those classes in `search.php` and remove the inline styles.
- [ ] **Scattered inline styles in templates** — notable instances that should become named classes:
- `tfe.php` line 146: `style="align-items:start;"``.tfe-meta-item--top` in `tfe.css`
- `tfe.php` lines 148, 170172, 193: `font-style:italic`, `margin-top:1.5rem`,
`font-size:.88rem;color:#666`, `color:#999;font-style:italic``.tfe-note-value`,
`.tfe-back-link`, `.tfe-restricted` in `tfe.css`
- `admin/edit.php`: multiple `style=` on `.admin-form-row` and banner preview → modifier
classes in `admin.css`
- `index.php` line 146: `style="padding:2rem;color:#666;"``.cards-empty` in `main.css`
- [ ] **`.site-nav__right` is a duplicate of `.site-nav__link`** — `common.css` defines both with
identical declarations (font-size, letter-spacing, text-transform, color, opacity, transition).
The only difference is DOM position. Merge `.site-nav__right` into `.site-nav__link`; let the
flex layout position it via `margin-left:auto` or DOM order.
- [ ] **`.site-nav__link--active` is applied in `nav.php` but never defined in CSS** — the class
is set conditionally but has no corresponding rule in `common.css`, so the active state is
invisible. Add a visible style (e.g. `opacity:1; border-bottom:1px solid rgba(255,255,255,.6)`)
or remove the conditional.
### F — Template logic / PHP in templates
- [ ] **Rate-limit 429 response in `search.php` emits unstyled bare HTML** — the early-exit block
outputs `<!DOCTYPE html><html><body><h1>Trop de requêtes</h1>…` with no stylesheet, no lang,
no viewport meta. Style it inline-minimally or redirect to a consistent `429.php` page (like
`maintenance.php`).
- [ ] **`apropos.php` contacts and credits are hardcoded in the template** — names, roles, emails
(Laurent Leprince, Xavier Gorgol, Brigitte Ledune) and credits text live in PHP/HTML and
require a code deploy to change. Either move them into the `about` page Markdown (admin-
editable) or extract to a config array so they are in one place.
- [ ] **`licence.php` wastes half the viewport with an always-empty right column** — the page
reuses the two-column `.apropos-layout` but `<div class="apropos-right"></div>` is always
empty. Add a `.apropos-layout--single` variant (or just `grid-template-columns:1fr` when
the right child is empty) to use the full width for content.
### G — Accessibility & semantics
- [ ] **`<nav>` in `nav.php` has no `aria-label`** — pages have multiple landmark regions (main
nav, search `<form>`, pagination). Add `aria-label="Navigation principale"` to the `<nav>`
and `aria-label="Pagination"` to pagination wrappers so screen readers can distinguish them.
- [ ] **Search bar `<form>` has no accessible name**`search-bar.php` has no `aria-label` on the
`<form>` and no `<label>` for the input (only a placeholder). Add `aria-label="Recherche"` to
the `<form>` element.
- [ ] **No `<meta name="description">` on any public page** — all public pages omit the description
meta tag (the dead `templates/head.php` had `content=""`). Add per-page descriptions:
site blurb for `index.php`, synopsis excerpt for `tfe.php`, page content intro for
`apropos.php`/`licence.php`. Necessary for search indexing and link preview cards.
- [ ] **No Open Graph tags**`tfe.php` is the ideal candidate for `og:title`, `og:description`
(synopsis), `og:image` (banner or cover path through `/media.php`), `og:type=article`.
Without them, sharing a thesis link on social media or messaging apps shows a blank preview.
### H — Minor / low-hanging fruit
- [ ] **`admin/thanks.php` duplicates `getThesisFiles()` with a raw PDO query** — lines 3440
manually prepare `SELECT … FROM thesis_files WHERE thesis_id = ?` instead of calling
`$db->getThesisFiles($thesisId)` which already exists. Replace with the DB method.
- [ ] **`admin/index.php` stats computed via PHP `array_filter` on full result set** — "total",
"publiés", "en attente" counts are derived by filtering the already-fetched `$theses` array
in PHP. When a filter is active the stats reflect only filtered rows, which is misleading.
Add `Database::getThesesStats(): array` returning three counts from SQL
(`COUNT(*)`, `SUM(is_published)`, `SUM(1-is_published)`) so they always reflect the full DB.