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 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).
- 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)
- 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