The default cache directory for the file-based rate limiter was
src/cache/rate_limit/, placing transient JSON files inside the source tree.
This meant:
- The directory was deployed via rsync on every deploy (wasted I/O)
- .gitignore had to track a src/-internal path
- Developers running tests could leave stale cache state in the source tree
Changes:
- src/RateLimit.php: default $cacheDir changed from __DIR__.'/cache/rate_limit'
to dirname(__DIR__).'/storage/cache/rate_limit'; dirname(__DIR__) resolves to
the project root regardless of how the file is loaded (with or without bootstrap)
- .gitignore: replaced 'src/cache/rate_limit/' with 'storage/cache/' (broader,
covers any future cache subdirs under storage/)
- storage/cache/.gitkeep: added so the directory is tracked in VCS and created
on fresh clones/deploys, but its contents are ignored
- justfile: added '--exclude storage/cache/*' to the deploy rsync recipe so
rate-limit state is never transferred to the server
- src/cache/: removed (no longer needed)
All RateLimit unit tests pass.
CSS: .site-search → header form[role="search"],
.site-search__icon → header form[role="search"] svg,
.site-search__input → header form[role="search"] input,
.site-search__input::placeholder → header form[role="search"] input::placeholder
HTML: Removed class="site-search", class="site-search__icon", and
class="site-search__input" from header.php and search-bar.php.
The form already uses role="search" and contains a single svg + input,
so the semantic selectors are unambiguous.
Create the central App helper that eliminates ~170 lines of duplicated
bootstrap/auth/CSRF preamble across 24 page and action handler files.
src/App.php provides:
- boot(): loads Database + ensures CSRF token (public pages)
- adminGuard(): requires AdminAuth login + boot (admin pages)
- verifyCsrf() / rotateCsrf(): centralised CSRF lifecycle
- flash() / consumeFlash(): unified flash messages with legacy key drain
(error, success, admin_error, admin_success, edit_error, edit_success,
form_error all consumed transparently for incremental migration)
- redirect(): flash + Location header + exit in one call
- render(): head → header → content → footer pipeline with auto admin
footer selection
App.php is auto-loaded from config/bootstrap.php so all existing pages
get the class for free without any changes.
templates/partials/flash-messages.php uses App::consumeFlash() to replace
the 5+ copy-pasted flash blocks across admin templates.
All existing tests pass. No existing page files modified — this is a
non-breaking addition that enables incremental controller extraction.
- config.php: getDatabasePath() detects php built-in CLI server
(php_sapi_name() === 'cli-server') and routes to test.db; all
other SAPIs (nginx/fpm) get posterg.db. DB_ENV env-var still
overrides either way.
- migrate.sh: auto-initialise the target DB from storage/schema.sql
when the file is absent or has no tables yet. Existing DBs with
data are left completely untouched (table_count check, no re-run
of schema on populated DB). Idempotent: safe to run repeatedly.
- justfile: serve still calls migrate (which now handles init too),
no DB_ENV prefix needed since sapi detection handles routing.
Imported theses were invisible on the public site because:
1. is_published defaulted to 0 (schema default) — the INSERT never
set it, so all imported rows stayed unpublished and were filtered
out by v_theses_public (WHERE is_published = 1) and every public
DB method.
2. The access column (CSV col 16 'Autorisation') was read into $access
but never written to access_type_id — silently dropped.
Fix: INSERT now includes is_published = 1 and access_type_id (resolved
from access_types.name via ucfirst/strtolower normalisation, defaulting
to 1/Libre when the CSV cell is empty or unrecognised).
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).