script-src 'self' 'unsafe-inline' added to admin Content-Security-Policy.
default-src 'self' was blocking OverType editor init block and
the dev live-reload poller. Admin section is auth-gated so
unsafe-inline is acceptable.
Silence mkdir() with @ operator; guard file_put_contents with
is_writable() check. When storage/cache/rate_limit is not writable
by php-fpm, requests are allowed through instead of throwing
warnings that flood the nginx error log.
The SVG icon in the admin nav's public-site link had two inline styles:
style="vertical-align:middle;margin-right:0.4em"
Moved to a new CSS rule:
.admin-body header nav > a svg { vertical-align: middle; margin-right: 0.4em; }
templates/header.php now contains zero style= attributes.
The only remaining inline styles project-wide are:
- dynamic gradient (hsl computed from $item['id']) in public/index.php — legitimately dynamic
- --disk-pct/--disk-color custom properties in system.php — carry PHP runtime values
admin/thanks.php:
- <div style="margin-top:1.5rem;display:flex;gap:.75rem;flex-wrap:wrap;"> → class="admin-action-bar"
- <p style="color:var(--text-secondary);"> → class="admin-muted"
admin/pages.php:
- Éditer button style="font-size:.8rem;padding:.3rem .75rem;" → class="admin-btn admin-btn--sm"
admin.css (Thesis info sections block):
- Added .admin-action-bar { margin-top:1.5rem; display:flex; gap:0.75rem; flex-wrap:wrap }
- Added .admin-muted { color: var(--text-secondary) }
The only remaining inline style in any admin PHP file is the dynamic
--disk-pct/--disk-color custom properties on the disk bar in system.php,
which carry PHP runtime values and cannot be moved to static CSS.
Scope: variables.css, search.css, todo/04-accessibility.md
- variables.css: add @media (prefers-color-scheme: dark) block scoped to
body:not(.admin-body); overrides all semantic tokens with dark equivalents:
--bg-* (#111→#333 range), --text-* (#eee/aaa/777),
--border-* (#333/#444), --accent-primary lightened to #b87fd4
(4.5:1 contrast on #111 background), --accent-secondary stays #9557b5,
--accent-foreground flipped to #111111 for dark buttons,
--accent-muted adjusted to rgba(184,127,212,0.15),
status colours muted for dark (success #4db886, error #e05555,
warning #d4a830); new --search-error-{bg,border,color} tokens added
to :root (light: #fff0f0/#c00) and overridden in dark (#2a1515/#e05555)
- search.css: replace three hardcoded hex values in .search-error rule
with var(--search-error-bg/border/color) so dark mode applies cleanly
- Admin pages are entirely unaffected: .admin-body body class is excluded
from the dark-mode selector; system.css already has its own dark palette
Consolidate action handlers into controller methods (todo/02-php-components.md).
src/ThesisCreateController.php (new, 435 lines)
Mirrors ThesisEditController for the add-thesis flow.
make() — factory; instantiates Database via new Database()
loadFormData() — returns all lookup tables needed by admin/add.php
(orientations, apPrograms, finalityTypes, languages,
formatTypes, licenseTypes)
submit(post, files) — full new-thesis creation pipeline:
1. validateAndSanitise() — trims/strips HTML, validates required fields,
year range, orientation/ap/finality IDs, language selection, max-10
keywords, URL format; throws named Exception on failure
2. findOrCreateAuthor() — reuses existing DB method
3. Transaction: createThesis + setThesisJury + setThesisLanguages +
setThesisFormats + setThesisTags; rolls back on any failure
4. File uploads outside transaction: cover image (JPG/PNG only, stored in
storage/covers/), banner via handleBannerUpload(), thesis files
(PDF/JPG/PNG/MP4/ZIP/VTT, stored in storage/theses/YEAR/IDENT/,
file_type auto-detected: caption/annex/main/other)
autofocusFieldForError() — static; maps exception messages to field names
for WCAG 3.3.1 autofocus on re-render (same contract as
ThesisEditController::autofocusFieldForError)
admin/actions/formulaire.php 346 → 45 lines
Now: bootstrap + CSRF guard + ThesisCreateController::make()->submit() +
flash/redirect on error. All validation, DB logic, and file handling removed.
admin/add.php
Lookup-table block (new Database() + 6 individual DB calls) replaced with
ThesisCreateController::make()->loadFormData() + extract().
src/Database.php — two new methods added
setPublished(int , bool ): void
UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
bulkSetPublished(int[] , bool ): void
Same but with an IN (...) clause for multiple IDs
admin/actions/publish.php 100 → 65 lines
Raw SQL (->prepare('UPDATE theses SET is_published = ?...')) replaced
with ->setPublished() / ->bulkSetPublished(). No raw PDO calls remain
in any action handler file.
Move all data-fetching and view-variable assembly out of public/index.php
into a new src/HomeController.php, following the same pattern as
SearchController, TfeController, SystemController, and ThesisEditController.
HomeController::create() builds the Database singleton dependency.
HomeController::handle() encapsulates:
- GET param parsing (page, year) with safe type coercion
- Display-mode detection: default random-latest view / year-filtered /
paginated-all theses
- All DB calls: getLatestPublishedYear, getLatestYearTheses, searchTheses,
countSearchResults, getPublishedTheses, countPublishedTheses,
getCoverPathsForTheses, getAvailableYears
- Batch cover-image loading for theses without a banner_path
- baseParams assembly for the pagination partial
- OG / meta tag array construction
- Graceful error handling (logs exception, returns safe empty state)
- Returns a flat array of view variables
public/index.php is now a 6-line dispatcher (require + create + handle +
extract) followed by a pure view template. Reduced from 100 to 71 lines.
All error-handling and data logic removed from the view layer entirely.
src/ThesisEditController.php (285 lines) centralises all data-fetching and
mutation logic for the thesis-edit workflow:
load(int $thesisId): array
Fetches the thesis row, current language/format/jury selections, and all
lookup tables (orientations, AP programmes, finality types, languages,
formats, licences, access types) in one call. Returns a flat view-variable
array that the dispatcher extracts directly.
save(int $thesisId, array $post, array $files): void
Runs the full edit inside a transaction: thesis metadata, authors, jury,
languages, formats, tags. Banner upload/removal is handled outside the
transaction (filesystem op). Rolls back and re-throws on any failure.
static autofocusFieldForError(string $msg): ?string
Centralises the WCAG 3.3.1 exception-message → field-name mapping that
was previously duplicated inline in actions/edit.php.
Dispatcher changes:
admin/edit.php 191 → 162 lines (pure view + ThesisEditController::create() + load())
actions/edit.php 153 → 53 lines (CSRF guard + ThesisEditController::save() call)
Follows the same pattern as SearchController and SystemController.
Move all data-fetching and request logic out of the 285-line search page
into src/SearchController.php:
- SearchController::create() — static factory; builds RateLimit + Database
dependencies, sends HTTP 429 (and exits) if rate limit is exceeded,
runs probabilistic cleanup, returns ready instance
- SearchController::handle() — sanitises GET params (query/year/orientation/
ap_program/keyword), runs all DB queries (searchTheses, countSearchResults,
getAvailableYears, getAllOrientations, getAllAPPrograms, getUsedTags,
getPublishedAuthors), builds alphabetical author→id map, assembles
OG/meta tags, returns a flat array of view variables
- Rate-limit 429 HTML response moved into private sendRateLimitResponse()
public/search.php is now a 6-line dispatcher:
require SearchController; extract(SearchController::create()->handle());
followed by the unchanged view template (162 lines total, was 285).
The view template is byte-for-byte equivalent: same HTML, same variable
names, same pagination partial include.
Move the ~130-line $extraJsInline heredoc from admin/system.php into a
static file public/assets/js/system.js, loaded via $extraJs so the
template footer emits a normal <script src=…>.
Replace 4 inline style= attributes with named CSS modifier classes in
system.css:
- style="margin:0;border:none;padding:0" on .srv-section-title
→ .srv-section-title--compact
- style="margin-bottom:.75rem" on sub-heading <h3>
→ .srv-section-title--sub
- style="margin-bottom:0" on .php-grid
→ .php-grid--flush
- style="font-size:.84rem;color:var(--text-secondary)" on <label>
→ .log-toolbar label rule in system.css
The one remaining inline style (--disk-pct / --disk-color CSS custom
properties on .disk-bar) is intentionally kept: it carries PHP runtime
values that cannot be expressed in a static stylesheet.
App::consumeFlash() had 18-line legacy fallback chains reading from seven old
session keys (error, admin_error, edit_error, form_error, success,
admin_success, edit_success) that were written by no code in the codebase.
All action handlers have used App::flash() -> _flash_error / _flash_success
since the App class was introduced. Removed the dead fallbacks; consumeFlash()
is now 4 lines.
admin/import.php was the last admin template with inline style= attributes.
Extracted four of them to named CSS classes in admin.css:
- admin-error-list — error <ul> spacing (was style="margin:.5rem 0 0;padding-left:1.2rem")
- admin-file-hint — <small> display + margin (was style="margin-top:.5rem")
- admin-import-results — results panel margin (was style="margin-top:2rem")
- admin-import-results__title — <h2> typography (was multi-property inline style)
Closes the 'unify flash message keys' item in todo/02-php-components.md and
the import.php inline style item in todo/01-css-semantic-refactor.md.
Add App::flashAutofocus(fieldName) and consumeAutofocus() to the thin App
helper so action handlers can identify which field caused a validation error
and the form page can move browser focus directly to it on reload.
Changes:
- src/App.php — flashAutofocus() stores field name in _flash_autofocus
session key; consumeAutofocus() drains it and returns the name (or null)
- actions/formulaire.php — catch block maps exception messages to field
names (auteurice, titre, synopsis, année, orientation, ap, finality,
languages, tag, lien) and calls App::flashAutofocus()
- actions/edit.php — catch block maps common edit errors to field names
and calls App::flashAutofocus()
- add.php — consumes the hint via App::consumeAutofocus() into
$autofocusField; withAutofocus() helper merges autofocus=>true into
$attrs for every field include; synopsis textarea gets inline autofocus
- edit.php — same pattern with inline ternary merges and textarea autofocus
- templates/partials/form/text-field.php — $attrs loop now emits bare
attribute names (no ="...") when value === true, supporting autofocus,
disabled, readonly etc. without special-casing
- templates/partials/form/select-field.php — same boolean-attr support
added; $attrs variable initialised to [] when caller omits it
Closes WCAG 3.3.1 autofocus item in todo/04-accessibility.md.
Names, roles, emails, and credits on the À propos page were hardcoded
directly in apropos.php HTML. To update a contact meant editing a
template file — risky for non-developers and easy to introduce a typo
or broken mailto link.
Changes:
- config/apropos.php: new config array with erg_url, contacts[] (name,
role, email per person) and credits[] (label/value pairs); follows
the same pattern as config/admin_credentials.php
- public/apropos.php: loads config via require; aside section now loops
over $apropos['contacts'] and $apropos['credits'] with htmlspecialchars
throughout; hardcoded HTML strings removed entirely
Also audited todo/02-php-components.md and marked 8 stale items as done:
all 5 form field partials were already implemented and in use, the
flash-message consolidation was already handled by App::consumeFlash(),
and the RateLimit cache dir was already at storage/cache/rate_limit
(excluded from deploy rsync).
Problem: <video> elements on tfe.php had no <track kind="captions"> element,
violating WCAG 4.1.2 (name, role, value) for video content.
Changes:
- public/tfe.php: collect all text/vtt files from the thesis file list before
rendering; skip standalone rendering of .vtt entries; for each MP4 emit a
<track kind="captions" srclang="fr" label="Sous-titres" default> pointing
to the N-th VTT file (N-th video paired with N-th caption in document order)
- public/media.php: add text/vtt to allowed MIME list; normalise finfo
text/plain -> text/vtt for .vtt files; add vtt branch to cache/header
block (Content-Type: text/vtt; charset=utf-8, 1-day cache)
- public/admin/actions/formulaire.php: allow .vtt uploads (text/vtt MIME,
vtt extension); normalise text/plain finfo result; set file_type='caption'
for VTT files so they are distinguishable from other thesis files
- public/admin/add.php: extend files field accept attr to include .vtt;
update hint text to document the VTT sidecar convention
VTT files uploaded under theses/ inherit the same access_type visibility
gate in media.php as all other thesis content (403 for access_type_id=3).
- admin/edit.php: remove mb_strimwidth(60) truncation from access_type
<select> option labels; full 'name — description' text is now the
accessible name so screen readers get unambiguous option text (WCAG 4.1.2)
- public/assets/favicon.svg: new public favicon — brand-purple (#9557b5)
rounded square with white 'P' lettermark; distinct from admin_favicon.svg
(archive-restore Lucide icon in #c104fc) which is admin-only
- templates/head.php: favicon <link> now conditionally serves favicon.svg
(public pages) or admin_favicon.svg (admin pages) based on $isAdmin;
closes the open favicon task in todo/01-css-semantic-refactor.md
- todo/04-accessibility.md: mark WCAG 3.1.1 lang audit and WCAG 4.1.2
select truncation items as done
- todo/01-css-semantic-refactor.md: mark favicon task as done
3.3.2 Labels or instructions
- Replace bare <label>Lecteur·ices :</label> (no 'for', no associated control)
with <fieldset class="admin-jury-lecteurs"><legend>Lecteur·ices</legend>
giving AT a proper programmatic label for the entire lecteur group
4.1.2 Name, role, value — Externe checkboxes lacked group context
- Add aria-label="Promoteur·ice — externe" on the promoteur Externe checkbox
- Add aria-label="Lecteur·ice N — nom" on every lecteur name input
- Add aria-label="Lecteur·ice N — externe" on every lecteur Externe checkbox
- All three attributes added to both PHP-rendered rows and the addJuryRow() JS
that builds new rows dynamically
2.1.1 Keyboard — remove buttons already had aria-label; verified and updated
label text to "Supprimer le lecteur·ice N" (consistent with new numbering)
CSS (admin.css)
- Add .admin-body fieldset fieldset.admin-jury-lecteurs rule: removes
border/padding/background from the nested fieldset so it reads as a
sub-group inside the outer jury fieldset, not a double-bordered card
Audit (no code change)
- WCAG 1.4.4: all font-size values use rem — no px text sizing anywhere
- WCAG 1.4.12: only overflow:hidden on media containers and .sr-only utility;
no essential text content is clipped by text-spacing overrides
- WCAG 4.1.2 bulk JS: result is a redirect to flash-messages.php which already
emits role="alert"/role="status" — no additional JS announcement needed
Add <span class="sr-only">, YEAR</span> to each thesis card <p> in
public/index.php. Screen readers now read "Author – Title, 2024" instead
of bare "Author – Title", so two theses sharing the same title produce
distinct accessible names (WCAG 2.4.4 Link Purpose — In Context).
Also audit and close WCAG 2.4.3: the tfe.php back link (<a class="tfe-back-link">
← Retour</a>) is already the first child of <header class="tfe-left">
in DOM order, preceding <h1 class="tfe-title">. No code change needed;
TODO item marked done.
- Updated 6 admin templates: add.php, edit.php, login.php, account.php,
import.php, pages-edit.php — replaced <div class="admin-submit-wrap">
with <div class="admin-form-footer">
- Updated 8 CSS selectors in admin.css:
- .admin-form-footer { margin-top/padding-top } (was .admin-submit-wrap)
- .admin-form > div:not(.admin-form-footer) grid exclusion guard (×3)
- .admin-login-box .admin-form > div:not(.admin-form-footer) overrides (×2)
- .admin-login-box .admin-form-footer compact spacing override
- No visual change; purely a semantic rename to a descriptive class name
- Also marked status-badge.php partial and WCAG 1.3.1 badge tasks as
already-done in todo/02-php-components.md and todo/04-accessibility.md
(partial + CSS were fully implemented but todo had not been updated)
WCAG 1.4.1 — Active nav link had no non-colour indicator in the admin panel.
Public nav already had border-bottom via common.css; admin nav had nothing.
admin.css:
- Add `[aria-current="page"]` rule on admin nav links:
border-bottom: 2px solid currentColor; padding-bottom: 1px
This gives a visible underline as a non-colour signal for the active page.
- Fix `--admin-purple` undefined CSS variable in pagination button hover.
The variable was referenced but never defined in variables.css (which was
refactored to use --accent-primary / --accent-secondary). Replaced both
border-color and color usages with var(--accent-primary) (#9557b5 — same
value), restoring the intended purple hover tint on pagination buttons.
todo/01-css-semantic-refactor.md:
- Audited ~15 pending CSS/HTML tasks; all were already implemented.
Marked as done: .admin-main, .admin-page-title, .admin-form-row,
.admin-label, .admin-input/select/textarea, .admin-table, .admin-fieldset,
tfe.css class replacements, search.css h2 selector, admin-alert replacement,
login.php/edit.php inline style removal, form partial hints (<small>).
todo/04-accessibility.md:
- Marked WCAG 1.4.1 admin nav and --admin-purple audit items as completed.
- Add Database::getThesesListCount(array $filters) — runs the same WHERE
clauses as getThesesList() but with COUNT(DISTINCT t.id); used to compute
total pages without loading all rows.
- Extend Database::getThesesList() with $limit/$offset parameters; when
$limit > 0 appends LIMIT/OFFSET and re-binds positional params individually
to avoid the PDO mixed-style restriction.
- Fix getThesesList() SELECT: add LEFT JOIN access_types + at.name as
access_type — the column was referenced in the template but never fetched.
- Wire admin/index.php: read ?page=, compute $totalPages/$offset, pass
$perPage=25 + $offset to getThesesList(); include pagination.php partial
below the table with filter-preserving $baseParams.
- Add result-count line (<p class="admin-list-meta">) showing "X–Y sur Z TFE"
when multiple pages exist.
- Add .admin-body .pagination-wrap / .pagination-btn / .pagination-info styles
to admin.css (scoped to .admin-body to avoid colliding with public pages).
Add a TTL-based cache for the expensive checks on the admin system page,
eliminating repeated systemctl subprocess calls (~4×~100ms), curl self-pings
(~200-500ms), disk_*_space() and PHP ini reads on every page load.
Changes:
- storage/migrations/007_system_cache.sql: new migration creating the
system_cache table (key TEXT PK, value TEXT, updated_at INTEGER)
- storage/schema.sql: system_cache table added before pages table
- Applied migration to live storage/posterg.db
- src/SystemCache.php: new class with get/set/isStale/ageSeconds/invalidate;
uses SQLite INSERT … ON CONFLICT upsert; no external dependencies
- src/Database.php: added getDatabasePath(): string accessor
- public/admin/system.php:
- Bootstrap SystemCache at request start using the existing DB PDO handle
- system_status: cached with 2-min TTL (systemctl + curl checks)
- php_info: cached with 1-hour TTL (PHP ini values are runtime-constant)
- disk_info: cached with 5-min TTL (total/free/used/pct tuple)
- Logs section: unchanged — always reads live log tail per active tab
- ?refresh=1 GET param invalidates all three cache keys before rendering
- Status panel heading shows cache badge: '⚡ Cache — il y a Xs' (hit)
or '⟳ Actualisé' (miss/fresh), styled via new .sys-cache-badge rules
- public/assets/css/system.css: .sys-cache-badge / --hit / --miss styles