templates/admin/head.php:
- admin-nav__logo now href="/" with target="_blank" rel="noopener noreferrer"
- Left arrow prefix (← via ←, aria-hidden) signals leaving admin
- sr-only suffix "(site public, nouvel onglet)" for screen readers
public/admin/login.php:
- Same treatment on the standalone login nav (was a bare <span>)
public/assets/css/admin.css:
- admin-nav__list: flex:1 removed; margin-left:auto added
→ entire link list now right-justified inside the nav bar,
mirroring the layout of the public site header
- .admin-nav__logout { margin-left:auto } removed (no longer needed;
logout is just the last item in a right-aligned list)
admin/index.php — status badges (WCAG 1.4.1 Use of Colour):
- Published badge: prefix ● symbol (aria-hidden) + aria-label="Statut : Publié"
- Pending badge: prefix ◌ symbol (aria-hidden) + aria-label="Statut : En attente"
- Access badges (Libre/Interne/Interdit): prefix ○/◑/● symbol per type (aria-hidden)
+ aria-label="Accès : [type]"; symbol chosen from a PHP map keyed on the slug
Each badge now communicates its state through shape AND colour, not colour alone.
admin/index.php — ✕ Réinitialiser link (WCAG 2.5.3 / 1.1.1):
- ✕ wrapped in <span aria-hidden="true"> so the decorative symbol is skipped by
screen readers; accessible name remains "Réinitialiser"
admin/add.php + admin/edit.php — jury remove buttons (WCAG 2.5.3):
- All four ✕ remove buttons (2 static template rows + 2 JS-generated innerHTML strings)
given aria-label="Supprimer ce lecteur"; the bare ✕ Unicode character has no
speech equivalent so the aria-label replaces rather than supplements the label
- pages-edit.php: EasyMDE CDN JS URL moved to $extraJs (rendered by footer.php before </body>);
inline EasyMDE init block moved to $extraJsInline, emitted by footer.php via new
`<?php if (!empty($extraJsInline))` guard - fixes invalid <script> floating in <body> (WCAG 4.1.1)
- pages-edit.php: add <small> keyboard-trap hint below the editor textarea:
'Appuyez sur Échap pour quitter l'éditeur au clavier.' (WCAG 2.1.2)
- templates/admin/footer.php: extend to support $extraJsInline (raw inline script string)
- index.php: add <h1 class="sr-only">Mémoires de l'ERG</h1> inside <main> so the page has
a document heading (WCAG 2.4.6; h2 columns in search.php already had a sr-only h1)
- TODO.md: mark completed items as [x]: skip links (2.4.1), focus-visible / outline:none
removal (2.4.7), search.php h1 + index.php h1 (2.4.6), pages-edit.php invalid HTML (4.1.1),
EasyMDE keyboard trap hint (2.1.2)
- templates/admin/head.php: all 7 nav links (+ conditional Modifier + Déconnexion)
wrapped in <ul class="admin-nav__list">/<li>; .active class removed, replaced
with aria-current="page" on each <a> based on $currentPage match
- Déconnexion link: removed inline style="margin-left:auto;opacity:.6;"; moved to
new .admin-nav__logout <li> class in admin.css
- public/assets/admin.css: replaced .admin-nav__link rules with .admin-nav__list a
selectors; added .admin-nav__list (flex list, gap 2.5rem, flex:1); added
.admin-nav__list a[aria-current="page"] rule (border-bottom underline indicator);
added .admin-nav__logout / .admin-nav__logout a for the push-right logout item
- Removes .admin-nav__link class entirely from the codebase (was only used in
templates/admin/head.php and admin.css)
Fixes WCAG 2.4.6 (nav landmark content model), 1.4.1 (colour-only active indicator),
and section VIII of the semantic HTML admin audit.
Replace presentational divs in index.php and main.css with elements that
carry correct semantic meaning, fixing multiple WCAG 2.1 AA issues:
index.php:
- <div class="cards-container"> → <ul class="cards-container"> (list of navigable items)
- <a class="card-link"><div class="card">…</div></a> → <li class="card"><a> (block link
is the <a>, <li> is the container; removes the redundant .card div wrapper)
- <div class="card__media"> → <figure class="card__media"> when wrapping an <img>;
gradient placeholder stays as <div> (presentational, aria-hidden)
- Improved alt text: "Couverture — [title] par [authors]" instead of bare title
- Removed <div class="card__info"> wrapper; caption is now a bare <p class="card__caption">
directly inside the <a>
- <div class="filter-info"> → <p class="filter-info" role="status"> (live-region
semantics; announces filter state to screen readers)
- ✕ symbol in clear-filter link wrapped in <span aria-hidden="true">
- Gradient placeholder div gets aria-hidden="true" (decorative; caption below carries text)
- Empty-state <p style="…"> → <li class="cards-empty"> (removes inline style)
- <div class="pagination-wrap"> → <nav class="pagination-wrap" aria-label="Pagination">
with <ul>/<li> children; page-info <span> → <li aria-current="page">
main.css:
- .cards-container: add list-style:none; margin:0; padding:0 (reset <ul> defaults)
- Remove .card-link rule; replace with .card > a (block flex link, no separate class)
- .card__media: add margin:0 to reset <figure> default margin
- Remove .card__info rules; rename .authors to .card__caption with same styles
- Add .cards-empty rule (removes last inline style from index.php)
- .pagination-wrap: restructured for <nav>/<ul>; inner <ul> carries the flex layout
- prefers-reduced-motion: add .card__media--gradient guard
WCAG criteria addressed: 1.1.1 (alt text), 1.3.1 (info & relationships via semantic
list/figure), 2.4.1 (filter-info now live region), role="status" on filter banner.
- Replace 4x <div class="repertoire-col"> with <section>; remove
.repertoire-col__header class, CSS now targets section > h2
- Wrap all index link groups in <ul>/<li>; delete the four per-column
link classes (year-index-item, cat-index-item, student-index-item,
keyword-index-item); active state switches from .active to
aria-current="page" on the <a>
- Add <h1 class="sr-only">Répertoire</h1> so the index view has a
page-level heading (WCAG 2.4.6)
- Remove redundant <div class="search-results-view"> wrapper; padding
moved to .results-grid and .search-results-header directly
- Replace <div class="results-grid"> with <ul class="results-grid">;
each result card becomes <li><a class="result-card">
- Replace <span class="result-card__meta"> with <small> (ancillary
metadata per HTML spec)
- Replace result-count <p> with <output role="status"> (computed value)
- Replace 3x <div class="search-filter-group"><label>…</label><select>
with <label> directly wrapping <select> (implicit association,
removes .search-filter-group divs); CSS updated to display:flex on
the label itself
- Pagination wrapper changed to <nav aria-label="Pagination">;
page-info span gets aria-current="page"
- search.css: delete .search-results-view, four index-item classes,
.cat-index-group, .search-filter-group; consolidate years/other
column link styles under .repertoire-col:first-child ul a and
.repertoire-col:not(:first-child) ul a selectors; add ul reset rule
templates/nav.php:
- Replace <div class="site-nav__links"> with <ul role="list"> + <li> children
- Move À Propos link inside the list (was a loose sibling <a>)
- Remove .site-nav__link and .site-nav__link--active classes from all <a> elements
- Active state now driven solely by aria-current="page" (already present)
public/assets/common.css:
- Remove .site-nav__links, .site-nav__link, .site-nav__link:hover, .site-nav__link--active rules
- Add .site-nav ul (flex, gap, list-style reset), .site-nav ul a, .site-nav ul a:hover
- Active indicator: .site-nav ul a[aria-current="page"] — self-documenting, screen-reader-announced
Fixes TODO section I (nav semantic HTML audit). All three BEM nav-link classes deleted;
zero references remain in the codebase.
- Replace three <span class='search-filter-label'> with proper <label for='...'> elements in
search.php filter bar; add id attributes to the corresponding <select> elements so the
label/control association is programmatic (WCAG 1.3.1, 3.3.2).
- Rewrite the rate-limit 429 early-exit in search.php from a bare one-liner echo to a full
HTML document with lang='fr', viewport meta, and inline dark styles matching maintenance.php;
inject the retry countdown into the user-facing message (Template audit F).
- Fix PHP 8.x __wakeup() deprecation in Database.php singleton guard: replace the throw
statement with trigger_error(..., E_USER_ERROR) and add an explicit void return type
(Refactor audit C).
- templates/public/head.php: add centralised OG/Twitter tag rendering via $ogTags array;
supports type, title, description, url, image, image_alt, site_name, article_author,
article_published_time; twitter:card switches between summary_large_image / summary
based on presence of og:image
- public/tfe.php: populate full article OG tags — og:type=article, canonical URL,
og:image resolved from banner_path → first image file in thesis_files → omitted,
og:image:alt, article:author, article:published_time (year-01-01); twitter:card
summary_large_image when image present
- public/index.php, search.php, apropos.php, licence.php: add basic og:type=website
tags (title, description, canonical url, site_name)
Sharing a thesis link on Slack, WhatsApp, iMessage, or any social platform will now
render a rich preview card with the thesis title, synopsis excerpt, and cover/banner image.
- Replace <div class="tfe-layout"> with <article>, <div class="tfe-left"> with
<header>, <div class="tfe-right"> with <aside> (supplementary media column)
- Fix inverted heading hierarchy: <h1> is now the thesis title (primary topic);
author demoted to <p class="tfe-author"> (metadata, not a heading)
- Replace <div class="tfe-meta-list"> / <div class="tfe-meta-item"> / <span class="label">
/ <span class="value"> with <dl> / <dt> / <dd> (WCAG 1.3.1 info & relationships)
- Replace <div class="tfe-media-block"> with <figure>; <p class="tfe-file-caption">
with <figcaption>; PDF <embed> gets .tfe-pdf-fallback download link (WCAG 4.1.2)
- Move back link to top of left column; extract inline styles to .tfe-back-link,
.tfe-note-value, .tfe-restricted CSS classes
- Fix image alt text: description column used when populated, fallback to
"Title — Author" instead of raw filename (WCAG 1.1.1)
- Add sr-only new-tab warning on baiu_link (WCAG 1.3.1 / 2.4.4)
- Fix PDF embed height: clamp(300px, 80vh, 700px) prevents horizontal overflow
on small screens (WCAG 1.4.10 reflow)
- tfe.css: update all selectors to match new structure; remove inline styles;
unify .tfe-restricted and .tfe-no-files; add .tfe-pdf-fallback, .tfe-back-link
- templates/nav.php: add aria-label="Navigation principale" to <nav>; emit
aria-current="page" on the active link alongside the existing CSS class
so screen readers announce the current page without relying on colour/style alone
- templates/search-bar.php: add role="search" + aria-label="Recherche" to
the <form>; add a visually-hidden <label for="site-search-input"> linked to
the input via id="site-search-input", satisfying WCAG 3.3.2 (labels/instructions)
and 4.1.2 (name/role/value) — placeholder text alone is not a label
- public/assets/main.css: add @media (prefers-reduced-motion: reduce) block that
sets transition:none and transform:none on .card__media img/video hover, so the
scale(1.02) zoom is fully suppressed for users who opt out of motion (WCAG 2.3.3 /
prefers-reduced-motion); the global transition-duration guard in common.css already
covers all other transitions but does not zero the transform value itself
Fixes TODO sections: G (nav/search-bar landmark names), I (site-search form ARIA),
3.3.2 (search input label), prefers-reduced-motion (card hover transform gate)
edit.php was a 530-line file mixing form display, POST handling, file
uploads, and reference-data loading. This refactor splits it along the
same action-file pattern already used by formulaire.php, tag.php, and
page.php.
Changes:
- public/admin/actions/edit.php (new): standalone POST handler; auth
guard, CSRF check, transaction, redirect with session flash messages
- public/admin/edit.php: display-only; reads edit_success/edit_error
flash keys from session; form action points to actions/edit.php via
a hidden thesis_id field instead of a query-string self-post
- src/Database.php: four new methods to remove all raw PDO from both
files:
- updateThesis(int, array): void — UPDATE theses core fields
- setThesisAuthors(int, array): void — delete-then-reinsert authors
- getThesisLanguageIds(int): array — SELECT language_id for form
- getThesisFormatIds(int): array — SELECT format_id for form
Move the repeated 'html, body { margin:0; padding:0; height:100% }' block from
main.css, search.css, tfe.css, and apropos.css into the single canonical location
in common.css. All four public page stylesheets already load common.css first, so
the rule applies identically — no visual change.
Fix pages-edit.php invalid HTML: the EasyMDE <link rel=stylesheet> was placed
inside <body> (after head.php was already closed), which is invalid. Add an
$extraCss hook to templates/admin/head.php so pages can inject <link> tags into
<head> via an array variable, matching the pattern already used by the public
templates/public/head.php. Also add a symmetric $extraJs hook to
templates/admin/footer.php for future use. pages-edit.php now sets
$extraCss = ['easymde.min.css'] before requiring head.php; the EasyMDE JS
<script> and its inline init remain in <body> in the correct load order.
Create templates/public/head.php accepting $pageTitle and $extraCss (array of
stylesheet hrefs), mirroring the existing templates/admin/head.php pattern.
The partial emits: DOCTYPE, <html lang=fr>, charset/viewport meta, favicon,
modern-normalize, common.css, any extra CSS links, and the dev-only live-reload
script. The live-reload snippet was previously copy-pasted verbatim into all
five public pages.
Updated pages:
- public/index.php ($pageTitle='Posterg', $extraCss=['assets/main.css'])
- public/search.php ($pageTitle='Répertoire – Posterg', search.css)
- public/tfe.php ($pageTitle=thesis title + suffix, tfe.css)
- public/apropos.php ($pageTitle='À Propos – Posterg', apropos.css)
- public/licence.php ($pageTitle=DB title + suffix, apropos.css)
tfe.php: removed redundant htmlspecialchars() call on $pageTitle (the partial
applies it); licence.php: renamed conflicting $page variable to $dbPage to
avoid collision with the shared $pageTitle expected by the partial.
All syntax checks and test suite pass (4/4).
- common.css: add font-display: swap to Combinedd.otf @font-face (eliminates FOIT)
- common.css: remove duplicate .site-nav__right block (identical to .site-nav__link);
update nav.php to use .site-nav__link on the À Propos link
- common.css: add .site-nav__link--active rule (opacity:1 + white underline); the class
was already applied in nav.php but had no CSS definition, making it invisible
- search.php: replace fully inline-styled pagination with .pagination-wrap / .pagination-btn
/ .pagination-info classes; add aria-disabled + tabindex=-1 on disabled links;
add aria-label on prev/next links
- search.css: add pagination rule block to match, keeping styles co-located with the page
- Delete templates/header.php and templates/head.php — both were legacy
partials from a previous design iteration (lang="en", broken nav markup)
that were never included anywhere in the current codebase.
- Delete public/assets/icons.svg — the full TrumboWYG icon sprite (~15 KB)
referenced nowhere; the only active WYSIWYG editor (EasyMDE in
pages-edit.php) loads its own assets from CDN.
- Fix licence.php layout: the page was borrowing the two-column
.apropos-layout grid but leaving the right column always empty, wasting
~40% of the viewport. Removed the grid wrapper and the empty .apropos-right
div. Added .apropos-single utility class to apropos.css (max-width: 720px)
so licence content now spans the full available width with a readable
line length.
Move the raw identifier-generation query and the INSERT INTO theses /
INSERT INTO thesis_authors statements out of formulaire.php into two new
Database methods:
generateThesisIdentifier(int $year): string
– counts existing theses for the year inside the open transaction so
concurrent workers cannot produce duplicate YYYY-NNN identifiers.
createThesis(array $data): int
– generates the identifier, INSERTs the thesis row, links the author
via thesis_authors (author_order=1), returns the new thesis ID.
getThesisIdentifier(int $id): string
– fetches the stored identifier for a thesis ID; used by formulaire.php
to reconstruct the upload path (storage/theses/YYYY/YYYY-NNN/).
formulaire.php now calls $db->createThesis([…]) + $db->getThesisIdentifier()
and no longer holds any raw PDO queries for the core thesis insert.
The $pdo local variable (previously $db->getPDO()) is removed entirely.
All four test suites (Unit, RateLimit, Integration, Security) pass.
Add three delete-then-reinsert helpers to Database.php that follow the same
pattern already used by setThesisJury():
setThesisLanguages(int $thesisId, array $languageIds)
setThesisFormats(int $thesisId, array $formatIds)
setThesisTags(int $thesisId, array $tagNames)
setThesisTags() calls findOrCreateTag() internally and enforces the 10-tag cap,
keeping that rule in one place.
Also extract the duplicated banner-upload block (MIME check, size cap,
random filename, move_uploaded_file, chmod, setBannerPath) into:
handleBannerUpload(int $thesisId, ?array $uploadedFile): ?string
Both formulaire.php and edit.php are updated to call these methods instead of
open-coding the SQL loops and file-upload logic. The edit.php banner-removal
branch is unchanged (unlink + setBannerPath(null) stays inline as it is
logically distinct from an upload).
src/config.php: remove the file-existence fallback that silently redirected
all requests to test.db whenever that file was present on disk. getDatabasePath()
now always returns the production DB unless DB_ENV=test is explicitly set.
tests/run-tests.php: putenv('DB_ENV=test') at the top so the suite always
targets test.db regardless of what is set in the shell environment.
tests/Unit/DatabaseTest.php, tests/Integration/SearchTest.php,
tests/Security/SecurityTest.php: same putenv() guard added to each file so
they work correctly when run standalone (e.g. just test-unit).
justfile: all test and DB-development recipes now prefix DB_ENV=test to their
php/sqlite3 commands, making the intent explicit in the recipe itself.
Fixes: a developer who ran the test suite and kept test.db on disk would
silently hit test data when browsing the local site with no DB_ENV set.
The répertoire page was loading the full v_theses_public view
(15 JOINs + 8 GROUP_CONCAT temp B-trees) via getAllPublishedTheses()
just to build the student name → thesis-id map on the index page.
Only two columns (id, authors) were ever consumed by the template.
Add Database::getPublishedAuthors(): array
- Queries thesis_authors JOIN authors directly on the theses base table
- Filters on theses.is_published = 1 using the existing index
- Returns only id + GROUP_CONCAT(authors) — no view expansion
- Results verified identical to the old getAllPublishedTheses() output
Update search.php to call getPublishedAuthors() instead.
Mark getAllPublishedTheses() @deprecated in Database.php.
All tests pass.
- Add getThesisAccessTypeId(int $id): ?int — replaces raw SELECT in tfe.php
- Add getCoverPathsForTheses(array $ids): array — replaces raw SELECT/IN query in index.php
- Add getFileVisibility(string $path): ?int — replaces raw join query in media.php
- Add getThesisBannerPath(int $id): ?string — replaces unparameterised SQL injection in
edit.php (SELECT banner_path FROM theses WHERE id = $thesisId was interpolating $thesisId
directly into the query string; now parameterised via prepared statement)
- Add getThesisRawFields(int $id): ?array — replaces raw SELECT license_id/access_type_id/
context_note in edit.php
- Add getThesisCount(): int — replaces raw SELECT COUNT(*) in system.php
Callers updated: public/tfe.php, public/index.php, public/media.php,
public/admin/edit.php, public/admin/system.php
- Wrap setThesisJury() in a transaction: the method did a DELETE then multiple
INSERTs with no atomicity guarantee. A partial failure (e.g. findOrCreateSupervisor
throwing) would leave the jury table with orphaned rows. The fix uses
pdo->inTransaction() to avoid nesting when called from within an outer transaction,
and performs beginTransaction/commit/rollBack otherwise.
- Replace raw PDO query in admin/thanks.php with db->getThesisFiles(): the file
listing after TFE submission was manually preparing a SELECT on thesis_files
instead of calling the existing Database::getThesisFiles() method. Removes the
getPDO() call entirely from that file.
admin/index.php showed "TFE total / Publiés / En attente" by running
array_filter() over the already-filtered $theses array returned by
getThesesList(). When any search or year filter was active the three
numbers reflected only the matching subset, making the stats misleading
(e.g. searching for a single student would show "1 total, 0 publiés").
Add Database::getThesesStats(): array — a single SQL aggregation query:
SELECT COUNT(*), SUM(is_published), SUM(NOT is_published) FROM theses
This runs against the raw theses table with no filters, so the counters
always display the true whole-database figures regardless of what filter
the admin has active. admin/index.php now calls getThesesStats() and
reads $stats['total'], $stats['published'], $stats['pending'] instead
of the array_filter expressions.
Remove 5 unused ID-lookup helpers (getOrientationId, getAPProgramId,
getFinalityId, getLanguageId, getFormatId) — forms have always passed
FK ids directly from <select> elements; these methods were never called
outside import.php, which now uses inline PDO queries instead.
Collapse 13 alias methods down to the single canonical name for each:
getAllOrientations, getAllAPPrograms, getAllFinalityTypes,
getAllFormatTypes, getAllLanguages, getAllLicenseTypes,
getUsedTags, findOrCreateTag
The short-name variants (getOrientations, getApPrograms, etc.) and
compat aliases (getUsedKeywords, findOrCreateKeyword, getAllLicenseTypes
delegating to getLicenseTypes) are deleted. All call-sites updated:
- public/search.php: getOrientations→getAllOrientations, etc.
- public/admin/import.php: findOrCreateKeyword→findOrCreateTag,
thesis_keywords→thesis_tags, keyword_id→tag_id (fixes stale table
reference from pre-migration-001 that bypassed the M2M rename)
- tests/Unit/DatabaseTest.php: remove alias smoke-test (test 7)
Database.php: 948 → 848 lines (-100).
HTML-escaping at write time stores &, < etc. in the DB, corrupting full-text
search, tag matching, exports, and any non-HTML consumer. PDO parameterised queries
already prevent SQL injection; templates call htmlspecialchars() on output.
sanitize_string() now does strip_tags(trim()) only — matching the pattern already
used by edit.php which never had this bug.
Also deleted the dead $problematique variable (read from POST[problématique] but
never passed to any INSERT or used anywhere in the codebase).
- Add idx_theses_pub_year composite index on theses(is_published, year DESC) to
schema.sql; replaces the need for the query planner to pick between the two
separate idx_theses_published / idx_theses_year indexes and sort with a temp
B-tree. Every public query filters on is_published=1 and orders/filters by year,
so this covering index eliminates the sort pass for those queries.
- Create storage/migrations/006_add_composite_index.sql and apply to both
posterg.db and test.db.
- Fix storage/migrations/005_add_banner.sql: the view recreation in that file
still referenced the pre-migration-001 table/column names (thesis_keywords,
keywords.keyword). Updated to use thesis_tags / tags tg to match the canonical
schema.sql. The live DB was unaffected (migration 001 ran before 005), but the
file was misleading and would fail if ever re-run from scratch.
SQLite performance (Database::__construct):
- PRAGMA journal_mode = WAL: eliminates full-DB read locks on write, safe
for concurrent PHP-FPM workers
- PRAGMA synchronous = NORMAL: durable on commit without full fsync per write
- PRAGMA cache_size = -8000: ~8 MB page cache per connection
Accessibility foundation (WCAG 2.1 AA):
- common.css: add .sr-only utility, .skip-link (hidden until focused),
global :focus-visible (2px purple outline, 2px offset),
prefers-reduced-motion guard; remove bare outline:none from
.site-search__input
- admin.css: same :focus-visible, skip-link, and motion guard scoped to
admin purple; remove outline:none from .admin-input/.admin-select/
.admin-textarea and .admin-filters select (both had :focus border rules
already, so focus is still visually communicated)
- search.css: remove outline:none from .search-filter-select (already has
:focus border-color rule)
- All 5 public pages (index, search, tfe, apropos, licence): add
<a href="#main-content" class="skip-link"> as first child of <body>;
add id="main-content" to <main>
- templates/admin/head.php: same skip link; aria-label="Navigation admin"
on <nav>; id="main-content" on all 10 admin <main> elements
All 4 test suites pass (unit, integration, security, rate-limit).
Measured contrast ratios, traced every interactive element, checked all four
WCAG principles across public and admin surfaces. Current state confirmed:
zero ARIA attributes, zero skip links, zero focus-visible styles, zero
prefers-reduced-motion guards in the live codebase.
Key failures by criterion:
1.1.1: TFE file images use raw filename as alt; search bar SVG not aria-hidden;
jury remove buttons have no accessible name (bare ✕)
1.3.1: TFE metadata is div/span soup with no programmatic label-value association;
search filter selects have no associated label; checkbox groups need fieldset/legend
1.4.1: Status badges distinguish state by colour only; active nav link has no
non-colour indicator and its CSS class has no rule at all
1.4.3 (measured failures):
- Nav links at opacity:0.92 on purple: 4.05:1 (fails AA 4.5:1)
- filter-info purple text on purple-light bg: 4.08:1 (fails AA)
- Placeholder #aaa on white: 2.32:1 (fails AA)
- Gradient cards: white text on L=65% HSL — every warm hue fails AA,
some as low as 1.46:1 (yellow). Only blue/indigo hues pass.
- admin-text-muted #888 on bg-alt #242424: 4.38:1 (fails AA)
- admin-purple on dark bg: 3.57:1 (fails for normal text size)
1.4.10: Répertoire 4-column grid has no mobile breakpoint
1.4.11: Search select border #ddd on white: 1.6:1; admin input border: 1.8:1
2.1.1: Disabled pagination links have pointer-events:none but remain keyboard-focusable
2.4.1: No skip-to-main link anywhere in the site
2.4.4: Pagination arrows (« ‹ › ») have no aria-label
2.4.6: tfe.php h1=author h2=title is inverted; index/search have no h1 at all
2.4.7: No :focus-visible defined anywhere; outline:none suppresses browser default
on search input with no replacement
3.1.1: 429 response has no lang attribute
3.3.1: Form errors not announced as live regions; no autofocus on invalid field
3.3.2: Search input has no label, only placeholder
4.1.1: pages-edit.php has <link> in <body>
4.1.2: <video> has no caption track; <embed> PDF has no fallback download link;
bulk action results not announced to AT
Motion: no prefers-reduced-motion guard on any transition or animation
Infrastructure gaps: no .sr-only class, no skip link, no :focus-visible,
four explicit outline:none suppressions with no replacement
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)
Full analysis of PHP and SQLite layer covering:
Performance:
- WAL mode + cache_size pragma missing from Database constructor
- Separate is_published/year indexes force temp B-tree sort on every public query
- v_theses_full materialised as CTE on every query, indexes never used by view
- getAllPublishedTheses() runs full 15-join view just for the author name index
PHP / Database.php:
- 5 dead CRUD helpers (getOrientationId etc.) never called anywhere
- 13 alias methods doubling every lookup; pick canonical names and remove
- getPDO()/getConnection() leaking to 8 call-sites with raw SQL that belongs in DB layer
- Unparameterised query in edit.php line 155 (SQL injection, fix immediately)
- sanitize_string() HTML-escapes at write time — stores & in DB, breaks search/export
- Dead variable $problematique in formulaire.php (read from POST, never used)
- setThesisJury() has no transaction guard of its own
- DB config auto-detection silently uses test.db if file exists locally
Maintainability:
- edit.php (530 lines) mixes display + POST + file upload — extract action file
- Banner upload logic copy-pasted between formulaire.php and edit.php
- Junction-table loops open-coded in every action; add setThesisLanguages/Formats/Tags
- RateLimit writes a JSON file on every public request
- __wakeup() throws from public method (PHP 8 deprecation)
SecurityTest::Test1 was calling $db->searchTheses($string) with a plain
string, but searchTheses() was refactored to require array $params when
the tag M2M work landed. This caused an immediate PHP fatal TypeError
before any SQL ever ran, killing the entire Security test suite with
exit code 255 and masking all three tests.
Fix: pass each malicious payload via ['query' => $string] which is the
correct API and properly exercises the parameterised query path through
validateSearchParams() + buildSearchConditions(). Added a clarifying
comment explaining why the array form is required.
All 4 test suites now pass:
- Database (Unit): 7/7
- Rate Limit (Unit): 5/5
- Search (Integration): 6/6
- Security: 3/3
Add a 'nginx — config' tab to the Système admin page (system.php).
- Reads /etc/nginx/sites-available/posterg (live deployed config) first;
falls back to nginx/posterg.conf (local reference copy) when the live
path is inaccessible (e.g. in dev, or wrong permissions).
- Displays a colour-coded badge: green '● Config déployée' for live,
amber '⚠ Référence locale' for the fallback.
- Renders the full config in the shared .log-output code block with
line numbers (data-n gutter via CSS ::before) and lightweight nginx
syntax colouring (comments grey, block keywords purple, directives blue).
- Reuses the existing copy-to-clipboard button.
- Tab routing: activeTab validation extended to accept 'nginx_config';
log pre-loading guards skip when activeTab is 'nginx_config'.
- No remote execution: read-only, zero new attack surface.
Replace the separate /admin/status.php and /admin/logs.php pages with a
single /admin/system.php page organised around a tab bar.
- system.php — top-level tab bar: 'Statut' + one tab per log file
(nginx accès, nginx erreurs, PHP-FPM). Switching tabs is a plain
href (?tab=…) so no JS required for navigation; the lines-selector
SELECT triggers a location change on 'change' for instant reload
without a submit button.
- Status tab preserves all existing service cards, PHP runtime grid,
and disk-usage bar from the old status.php.
- Log tabs preserve line-count selector, file metadata bar, and
per-line colour coding from the old logs.php.
- New: copy-to-clipboard button on each log output block (Clipboard
API with textarea execCommand fallback).
- status.php / logs.php replaced with 301 redirect stubs so existing
bookmarks and links keep working.
- templates/admin/head.php: 'Statut' + 'Journaux' nav items replaced
with a single 'Système' item; active state covers all three page
names for redirect compatibility.
Implements the admin user management UI as a self-contained PHP password
change/set flow — no SSH or sudo required.
- public/admin/account.php: shows auth status (PHP hash present, credentials
file path), password change form (requires current password when one exists,
min 12 chars, confirm field), and a danger-zone form to delete the
credentials file entirely
- public/admin/actions/account.php: CSRF-guarded POST handler; verifies
current password via AdminAuth::login() before accepting a new one;
generates bcrypt (cost 12) hash; writes config/admin_credentials.php
atomically via a temp file + rename; regenerates session on success;
redirects to /admin/login.php when credentials are deleted
- templates/admin/head.php: 'Compte' nav link added (active on account.php)
- public/assets/admin.css: .admin-account-status, .admin-section-title,
.admin-field-hint, .admin-danger-zone component styles added
Note: the nginx htpasswd flow (manage-admin-users.sh) requires root on the
server and is intentionally kept as a CLI-only operation.
- public/admin/logs.php: new page tailing nginx error/access + PHP-FPM logs.
Selector for log file and line count (50/100/200/500, default 100).
Lines reversed (newest first), colour-coded by severity, numbered gutter.
Graceful degradation when exec() unavailable or file unreadable (dev msg).
- templates/admin/head.php: 'Journaux' nav link added after 'Statut'.
- public/admin/status.php: remove curl_close() call deprecated in PHP 8.5
(no-op since PHP 8.0); replace with unset($ch) to silence the warning
that was leaking raw text above the page output.
New page /admin/status.php gives a real-time health dashboard:
- Services panel: nginx (systemctl), php-fpm (auto-detects versioned unit names),
site HTTP ping (curl HEAD with latency), SQLite DB (exists/writable/row count/size),
storage directory (writable, banner/cover file counts), maintenance-mode flag.
- PHP runtime panel: version, SAPI, memory_limit, upload_max_filesize, post_max_size,
max_execution_time.
- Disk usage bar for the partition containing APP_ROOT (colour-coded: green/amber/red).
- All shell calls go through safeExec() which suppresses stderr and checks exit code;
systemctl/curl unavailability degrades gracefully to 'unknown' without fatal errors.
- 'Statut' nav link added to templates/admin/head.php (active state on status.php).