- 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).
- tests/Unit/DatabaseTest.php: tests 5-7 for findOrCreateTag round-trip, getUsedTags column, alias
- tests/Integration/SearchTest.php: tests 4-6 for tag subquery, full-text query, count consistency
- Database: getAllPublishedTheses() bypasses 100-row search cap for student index
- search.php: uses getAllPublishedTheses() for étudiantes column; all tests pass
- Flat purple-gradient nav bar with POSTERG/RÉPERTOIRE/À PROPOS links
- Full-width search bar with icon, bottom-border only, below nav
- Home: white bg, media card grid (thumbnail + author/title label below)
- Répertoire: 4-column index (Années/Catégories/Étudiantes/Mots-clés)
- TFE: 2-column layout (large text left, media right)
- À Propos: 2-column, large monospace text, new apropos.php page
- Admin: dark theme (#1a1a1a), purple gradient nav, bottom-border inputs
- New shared partials: templates/nav.php, templates/search-bar.php
- Rewrote all CSS: common, main, search, tfe, apropos, admin
- Database: extract private buildSearchConditions(array $params): array shared by
searchTheses() and countSearchResults(), eliminating ~80 lines of duplication;
add array type hints to both public methods
- Database: add getThesesList(array $filters) and getAllYears() so admin/index.php
no longer builds raw SQL inline
- admin/index.php: replace inline PDO query block with $db->getThesesList() /
$db->getAllYears(); drop the now-unused $pdo local
- config/bootstrap.php: remove dead include_template() helper and the
vendor/autoload.php Composer stub (no vendor/ directory exists)
- apps/: delete entire directory (leftover artefact, no code references it)
- tests/Integration/SearchTest.php: fix three searchTheses() calls from bare
strings to proper array params to match the method signature (prevented TypeError)