- 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.
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).
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).
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)
- Reduce all spacing and padding in header for more compact fit
- Fix back button overflow by removing width: 100% and adding overflow handling
- Make filter section more compact with smaller fonts and spacing
- Add main-wrapper div to group main and footer
- Keep rounded corners (40px) on all three sections like main.css
- Footer stays at bottom of main content area
- Fix HTML structure: footer outside main, both inside wrapper
- Transform header into compact search bar with back button
- Move filters panel underneath search bar (collapsible)
- Display results in grid layout matching main.css style
- Add pagination controls in main section
- Show result count in footer
- Prevent overflow with responsive design and proper flex constraints
- Reduce padding and font sizes for denser layout
- Rename memoire.php to tfe.php throughout codebase
- Create dedicated tfe.css with rounded header/main/footer layout
- Move metadata (orientation, AP program, finality, keywords) to header
- Move back button from header to footer
- Create shared templates/head.php for common HTML head section
- Maintain rounded borders (40px) matching main site design
- Keep purple header (#9557b5), green main (#3c856b), dark footer (#222)
- Improve content readability with centered max-width layout
- Add responsive design for mobile devices
- Footer now displays all available years horizontally with scroll
- Click on year filters thesis list to that year
- Active year highlighted in footer
- 'Tous' link to reset filter
- Filter info banner shows when year selected with reset button
- Pagination preserves year filter
- Styled with horizontal scroll, smooth scrollbar
- Tests passing ✅
- Created /templates for main site (header.php, footer.php)
- Created /templates/admin for admin section (head.php, footer.php)
- Removed /public/includes and /public/admin/inc
- Updated all references in code and docs
- Tests passing ✅
Cleaner separation: /public only contains web-accessible files (PHP entry points + assets)
- lib/AdminAuth.php: new class with requireLogin(), login(), logout(),
isAuthenticated(); starts session with hardened cookie params
(HttpOnly, SameSite=Strict, Secure, Path=/admin) — also resolves
item #8 (session cookie hardening)
- requireLogin() auto-authenticates from nginx Basic Auth credentials
($_SERVER['PHP_AUTH_PW']) so the user only sees one browser prompt;
falls back to /admin/login.php if the proxy is absent/misconfigured
- config/admin_credentials.php: gitignored credential store; define
ADMIN_PASSWORD_HASH with a bcrypt hash to enable PHP auth
- config/admin_credentials.example.php: template for the above
- config/bootstrap.php: auto-loads admin_credentials.php if present
- .gitignore: exclude config/admin_credentials.php
- public/admin/login.php: fallback login form (shown only when nginx
Basic Auth is bypassed / proxy absent)
- public/admin/logout.php: session destruction + redirect to login
- All 7 admin PHP files: replace session_start() with
AdminAuth::requireLogin() (defence-in-depth behind nginx Basic Auth)
- public/admin/inc/head.php: Déconnexion button when ADMIN_PASSWORD_HASH
is defined
- nginx/PHP_AUTH_LAYER.md: documents dual-auth architecture, UX flow,
and setup instructions
- docs/TODO.SECURITY.md: items #2 and #8 moved to Resolved; priority
order updated (all CRITICAL done)
Items resolved:
- #3 (HIGH): Move file uploads outside webroot to STORAGE_ROOT (/var/www/posterg/storage).
Uploads were previously stored in public/admin/actions/data/ which is web-accessible.
- #4 (HIGH): Align file paths and add media.php controller.
DB paths are now storage-relative (theses/YEAR/ID/file, covers/file).
New public/media.php serves files with path-traversal jail, MIME allow-list,
and proper caching headers. memoire.php and search.php updated to use /media.php?path=.
Also fixed: cover images were never recorded in thesis_files (broken INSERT).
- #5 (HIGH): RateLimit::getClientIdentifier() now uses REMOTE_ADDR only.
HTTP_X_FORWARDED_FOR and HTTP_CLIENT_IP are attacker-controlled headers that
allowed unlimited rate-limit bypass by rotating spoofed IPs.
- #6 (HIGH): Port public/admin/.htaccess security rules to nginx/posterg.conf.
Apache .htaccess directives are silently ignored by nginx; none were active.
CSP added to /admin/ location block, .log file denial added globally,
autoindex off made explicit. Documented in nginx/HTACCESS_TO_NGINX.md.
Supporting changes:
- config/bootstrap.php: add STORAGE_ROOT constant
- nginx/SECURITY_HEADERS.md: updated to reflect admin CSP and pending public CSP
- docs/TODO.SECURITY.md: items #3-6 moved to resolved; priority order updated