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)
23 KiB
TODO
Styling Redesign (matching design images)
- Redesign shared nav bar (purple gradient top, flat, POSTERG / RÉPERTOIRE / À PROPOS)
- Redesign shared search bar (full-width, icon, bottom border only, white bg)
- Rewrite
common.css(nav + search bar components) - Rewrite
main.css(home page — white bg, media card grid, label below) - Rewrite
search.css(répertoire index — 4-col ANNÉES/CATÉGORIES/ÉTUDIANTES/MOTS-CLÉS) - Rewrite
tfe.css(TFE page — 2-col, large author/title left, media right) - Add
apropos.css(À Propos — 2-col, large monospace text) - Rewrite
admin.css(dark bg, purple gradient nav, bottom-border-only form inputs) - Update
templates/nav.php(new shared nav partial) - Update
templates/search-bar.php(new shared search bar partial) - Rewrite
public/index.php(home page with new layout) - Rewrite
public/search.php(répertoire index view + search results view) - Rewrite
public/tfe.php(individual TFE page) - Create
public/apropos.php(À Propos page) - Rewrite
templates/admin/head.php(admin nav) - Rewrite
templates/admin/footer.php(clean close) - Rewrite
public/admin/add.php(form with row layout) - Rewrite
public/admin/index.php(dark table) - Rewrite
public/admin/edit.php(form with row layout) - Rewrite
public/admin/login.php(centered dark login box) - Rewrite
public/admin/thanks.php(dark info cards) - Rewrite
public/admin/import.php(clean dark form)
Justfile / Ops
- Simplify
serveanddeployto one recipe each - Remove sysadmin recipes (server-logs, server-status, deploy-nginx, deploy-admin-tools)
- Extract server scripts to
scripts/(deploy-server.sh, manage-admin-users.sh) - Guard
deploy-dbagainst overwriting existing remote database - Update README.md and docs/SERVER_SETUP.md to reflect current structure
NEW FEATURES
1 — License page (public)
Create a public-facing /licence.php page, styled consistently with apropos.php.
public/licence.php— new public page; fetches content frompagestable (slug'licenses'); renders with Parsedown Markdown; usesapropos.csslayouttemplates/nav.php— add "Licence" link between "Répertoire" and "À Propos"; applysite-nav__link--activewhen$currentNav === 'licence'- The
pagestable row for slug'licenses'verified in live DB
2 — Admin: WYSIWYG/Markdown editors for static pages
Allow admins to edit the content of the "À propos" and "Licence" pages from the admin
panel, stored in the existing pages table.
2a — src/Database.php
getPage(string $slug): array|null—SELECT * FROM pages WHERE slug = ?savePage(string $slug, string $content): void— throws if slug not foundgetAllPages(): array— for listing in admin
2b — Admin pages editor UI
public/admin/pages.php— list all editable pages; links to edit each onepublic/admin/pages-edit.php— EasyMDE WYSIWYG Markdown editor via CDN
2c — public/admin/actions/page.php
- Auth guard + CSRF check + slug validation + length validation + savePage + redirect
2d — Public pages render Markdown
public/apropos.php— renders$db->getPage('about')via Parsedown (bundledsrc/Parsedown.php)public/licence.php— renders$db->getPage('licenses')via Parsedown- Parsedown bundled as
src/Parsedown.php(zero-dependency, MIT)
2e — Nav links in admin
templates/admin/head.php— "Pages statiques" nav item added
3 — License field on TFE forms
Add a "Licence" dropdown to the Add and Edit TFE forms. The license_types table
already exists in the schema with an id, name, description structure but has
no seed data yet.
3a — Schema / DB
storage/schema.sql— seedINSERT OR IGNOREfor 8 CC licence types addedstorage/migrations/003_seed_license_types.sql— migration created + applied- Verified live DB has
license_typeswith 8 rows
3b — src/Database.php
getLicenseTypes(): arraygetAllLicenseTypes(): array— alias
3c — Add form (public/admin/add.php)
- Loads
$licenseTypes; "Licence"<select name="license_id">added before duration
3d — Add action (public/admin/actions/formulaire.php)
$licenseIdparsed + included in INSERT
3e — Edit form (public/admin/edit.php)
- Loads
$licenseTypes; rawlicense_idFK fetched directly; select pre-populated - POST handler:
license_idincluded in UPDATE
3f — View update
storage/schema.sql—v_theses_fullnow exposest.license_idraw FK (done as part of 004/005 view rebuild)
3g — TFE public page
public/tfe.php— "Licence :" meta row added, shown when non-null
4 — Jury composition section in Add/Edit forms
Replace the current flat "promoteur interne / externe" fields with a structured Composition du jury section: président·e, promoteur·ice, lecteur·ices (mixed internal/external).
4a — Schema / DB
Current state: supervisors table + thesis_supervisors junction with a bare
supervisor_order integer. No role distinction.
storage/migrations/004_jury_roles.sql: -ALTER TABLE thesis_supervisors ADD COLUMN role TEXT NOT NULL DEFAULT 'promoteur'— role values:'president','promoteur','lecteur'-ALTER TABLE thesis_supervisors ADD COLUMN is_external INTEGER NOT NULL DEFAULT 0— 1 = external, 0 = internal (replaces the old "promoteur externe" free-text field) - No data loss: existing rows getrole = 'promoteur',is_external = 0storage/schema.sql— add the two new columns tothesis_supervisorsdefinition; updatev_theses_fullto expose jury members grouped by role: -GROUP_CONCAT(DISTINCT CASE WHEN ts.role='president' THEN s.name END) as jury_president-GROUP_CONCAT(DISTINCT CASE WHEN ts.role='promoteur' THEN s.name END) as jury_promoteurs-GROUP_CONCAT(DISTINCT CASE WHEN ts.role='lecteur' THEN s.name END) as jury_lecteurs- Keep the existingsupervisorscolumn (all names) for backwards compat - Migration SQL must DROP + CREATE the view
4b — src/Database.php
getThesisJury(int $thesisId): array— fetch all supervisors for a thesis with their role and is_external flagsetThesisJury(int $thesisId, array $juryMembers): void— delete + re-insert
4c — Add form (public/admin/add.php)
- Remove
promoteuriceandpromoteurice_externefields - Add "Composition du jury" fieldset: président·e, promoteur·ice + externe checkbox, dynamic lecteur·ices list with JS add/remove
4d — Add action (public/admin/actions/formulaire.php)
- Removed old promoteurice parsing; parse jury fields; call
$db->setThesisJury()
4e — Edit form (public/admin/edit.php)
- Load
$jury = $db->getThesisJury($thesisId); jury fieldset pre-populated from DB - POST handler calls
$db->setThesisJury()
4f — TFE public page (public/tfe.php)
- Three conditional jury rows: Président·e, Promoteur·ice, Lecteur·ices
5 — Banner image upload for home page cards
Each TFE can have an optional banner image used as its home page card thumbnail.
This is distinct from the existing "couverture" file concept (which goes into
thesis_files as type 'cover') — the banner is specifically optimised for the
home grid (wider, shorter aspect ratio).
5a — Schema / DB
storage/migrations/005_add_banner.sql—ALTER TABLE theses ADD COLUMN banner_path TEXTstorage/schema.sql—banner_path TEXTintheses;t.banner_pathin view
5b — Add form (public/admin/add.php)
- "Image bannière" file input added after couverture row
5c — Add action (public/admin/actions/formulaire.php)
- Banner upload: MIME check, 5 MB cap, save to
banners/, callsetBannerPath()
5d — Edit form (public/admin/edit.php)
- Banner preview img shown; remove_banner checkbox; new banner upload input
- POST handler: unlinks old file on remove; processes new upload via
setBannerPath()
5e — src/Database.php
banner_pathexposed via view — verified;getThesisById()/getPublishedTheses()pick it up automaticallysetBannerPath(int $thesisId, ?string $path): voidadded
6 — Home page: gradient placeholder cards & random ordering
6a — Gradient placeholder for cards without a banner
public/index.php— gradient placeholder using HSL hue from thesis IDpublic/assets/main.css—.card__media--gradient,.card__gradient-author,.card__gradient-titlestyles added
6b — Banner image as card thumbnail
public/index.php— checksbanner_pathfirst, falls through to gradient
6c — Random ordering from the latest year
src/Database.php—getLatestYearTheses(int $limit = 24)+getLatestPublishedYear()public/index.php— default home view uses random latest-year selection; paginated view for?year=Xand?page=N; info label shown
Refactor: M2M tags via tags + thesis_tags junction table
The current schema stores keywords in a keywords table joined via thesis_keywords.
The field column is named keyword (not name), breaking the naming convention used by
every other lookup table (orientations.name, format_types.name, etc.).
More critically, buildSearchConditions and the view v_theses_full filter keywords
through GROUP_CONCAT strings with LIKE, bypassing the junction table entirely.
Goal: rename the tables and column to the canonical M2M pattern (tags, thesis_tags,
tags.name), add the missing index, and rewrite all tag queries to use a proper JOIN.
1 — Schema migration (storage/schema.sql + live DB)
- Rename table
keywords→tags; columnkeyword→name - Rename junction
thesis_keywords→thesis_tags; FKkeyword_id→tag_id - PK on
thesis_tags(tag_id, thesis_id);idx_tags_name; updated index names - Views
v_theses_full/v_theses_publicusethesis_tags/tags.name - Migration
storage/migrations/001_rename_keywords_to_tags.sqlwritten and applied
2 — src/Database.php
findOrCreateTag()added;findOrCreateKeyword()is a backwards-compat aliasgetUsedTags()rewritten with proper M2M JOIN;getUsedKeywords()alias keptbuildSearchConditions: keyword/query useEXISTSsubquery onthesis_tags/tags- All conditions prefixed with
vp.to match view alias;vpalias added to search queries
3 — Admin write paths
public/admin/actions/formulaire.php: usesfindOrCreateTag+thesis_tagspublic/admin/edit.php:DELETE FROM thesis_tags+findOrCreateTag+thesis_tags
4 — Public read paths
public/search.php: fixed$kw['keyword']→$kw['name'](tag column rename)getUsedKeywords()alias delegates togetUsedTags()— no functional change neededpublic/tfe.php:$data['keywords']still works — view column name unchangedtemplates/search-bar.php: no keyword param refs — verified
5 — Admin tag management UI (/admin/tags.php)
5a — src/Database.php
getAllTagsWithCount(),renameTag(),mergeTag(),deleteTag()
5b — public/admin/tags.php
- Auth guard, CSRF, table with rename/merge/delete per row, inline forms
5c — public/admin/actions/tag.php
- Routes on
$_POST['action']: rename, merge, delete
5d — Nav & routing
templates/admin/head.php: "Mots-clés" nav link added
5e — Propagation safety
- mergeTag() uses INSERT OR IGNORE to avoid PK conflicts; deleteTag() cascades via FK
6 — Tests
tests/Unit/DatabaseTest.php: tests 5–7 cover findOrCreateTag, getUsedTags, aliastests/Integration/SearchTest.php: tests 4–6 cover tag-filter subquery, full-text query, count consistency
6 — Fixtures / seed data
storage/fixtures/CreateTestDatabase.php: updated totags/thesis_tags/findOrCreateTag()
Feature: Mode Maintenance
- Storage flag file
storage/maintenance.flag(created on demand) - Public gate in
config/bootstrap.php— blocks non-admin routes when flag exists public/maintenance.php(503 page, minimal dark UI)public/admin/actions/maintenance.php(POST: enable/disable)- Admin UI toggle in
public/admin/index.php(bar with status + action button)
Feature: TFE Visibility States (publique / interne / interdit)
- DB migration
002_add_visibility.sql— seeds access_types rows (already existed) src/Database.php—setVisibility(),bulkSetVisibility(),getAccessTypes()public/media.php— blocks thesis files when access_type_id = 3 (Interdit)public/tfe.php— shows access type, context_note, hides files for Interditpublic/admin/edit.php— access_type_id select + context_note textareapublic/admin/index.php— three-state access badge per rowpublic/admin/actions/visibility.php— single + bulk visibility update
Fixes
- Fix
tests/Security/SecurityTest.php: update SQL injection test to callsearchTheses(['query' => $string])instead of bare string —searchTheses()signature was updated toarray $paramsbut the test was never updated, causing a fatalTypeErrorthat prevented the security suite from running at all
Pending
- Add flake.nix for Nix-based PHP dev environment
- Add favicon (
<link rel="icon">→ admin_favicon.svg) to all pages; nginx 204 for /favicon.ico - Remove 100-item cap from répertoire student index:
getAllPublishedTheses()fetches all published theses; search results remain paginated at 30/page - Cover image fallback for home grid cards: batch-load
thesis_filescovers for theses withoutbanner_path; resolution order: banner → cover → gradient
Admin / Server
- Create
scripts/setup-server.sh(one-time server setup: group, ownership, setgid 2775 on dirs) - Add
just setup-serverrecipe (rsync + run setup-server.sh on remote) - Exclude
.claudeand.pifrom rsync deploy - Update
docs/SERVER_SETUP.mdwith correct permissions rationale and troubleshooting - Add server status view in admin panel (nginx + php-fpm health, site HTTP check)
- Add server log viewer in admin panel (tail nginx error/access logs via SSH or log endpoint)
- Add nginx config viewer to admin panel: "nginx — config" tab in
system.phpreads/etc/nginx/sites-available/posterg(live, with badge) or falls back to localnginx/posterg.conf; line-numbered, syntax-coloured, copy-to-clipboard - Add admin user management UI — password change/set for PHP auth layer (
public/admin/account.php+actions/account.php; "Compte" nav link; account CSS) - Merge
status.phpandlogs.phpinto a singlesystem.phppage; remove "Statut" and "Journaux" nav links, add single "Système" link; preserve all existing content in their respective sections - Rework logs UI: replace the select-then-click-Afficher flow with instant tabs (nginx access, nginx error, php-fpm); switching tabs loads the selected log immediately without a form submit; add a copy-to-clipboard button per log view
Refactor & Maintenance (backend audit 2026-03-26)
A — SQLite / Query performance
-
WAL mode — set
PRAGMA journal_mode = WALandPRAGMA synchronous = NORMALinDatabase::__construct()afterforeign_keys = ON. Eliminates full-database read-locks on every write; makes concurrent PHP-FPM workers safe. Also addPRAGMA cache_size = -8000(≈8 MB page cache) while there. -
Composite index
(is_published, year DESC)ontheses— every public query filters on both. Currentlyidx_theses_publishedandidx_theses_yearare separate; the query planner picks one and sorts the other with a temp B-tree. A single covering index eliminates the sort:CREATE INDEX IF NOT EXISTS idx_theses_pub_year ON theses(is_published, year DESC);Add toschema.sql+ a migration. -
v_theses_fullis always fully materialised — every query againstv_theses_publicforces SQLite to expand the entire view CTE (all 15 JOINs + 8 GROUP_CONCAT temp B-trees) before applying theWHERE/LIMIT. The view cannot be used with an index. Fix: replacev_theses_publicwith a plain table query insearchThesesandgetPublishedTheses— build the minimal JOIN set per query and let indexes filterthesesfirst. Keep the views for reporting/admin use only. -
getAllPublishedTheses()insearch.php— fetches every published thesis (all columns, all JOINs) just to build the student-name index on the Répertoire page. This is a full table scan through the fat view. Replace with a dedicated lean query:SELECT id, authors FROM v_theses_public ORDER BY authors ASC— or addDatabase::getPublishedAuthors(): arraythat queriesthesis_authors JOIN authorsdirectly, avoiding the view entirely. -
migration 005view is stale in the file —005_add_banner.sqlrecreates the view still referencingthesis_keywords/keywords.keyword(the old pre-migration-001 names). The file is already applied to the live DB (correctly, since 001 ran first), but the migration file itself is wrong and misleading. Fix: rewrite it to referencethesis_tags/tags.name, or drop the view recreation from it (schema.sql is the canonical source).
B — PHP / Database.php
-
Dead CRUD helpers —
getOrientationId(),getAPProgramId(),getFinalityId(),getLanguageId(),getFormatId()are defined inDatabase.phpbut never called anywhere (forms now pass IDs directly from selects). Remove them to reduce surface area. -
Alias proliferation —
getOrientations()/getAllOrientations(),getApPrograms()/getAllAPPrograms(),getFinalityTypes()/getAllFinalityTypes(),getLanguages()/getAllLanguages(),getFormatTypes()/getAllFormatTypes(),getLicenseTypes()/getAllLicenseTypes(),findOrCreateKeyword(),getUsedKeywords()— 13 alias methods pointing at 6 real ones. Pick the canonical name for each pair, update all call-sites (there are few), and delete aliases. Reduces Database.php from ~945 lines significantly. -
getPDO()/getConnection()leaking to callers —edit.php,formulaire.php,thanks.php,import.php,tfe.php,index.php,media.php,system.phpall call$db->getPDO()or$db->getConnection()to run raw queries that belong inDatabase.php. Each is a missed encapsulation: -tfe.php: rawSELECT access_type_id FROM theses WHERE id = ?→ addgetThesisAccessTypeId(int $id): ?int-index.php: rawSELECT thesis_id, file_path FROM thesis_files WHERE … IN (…)→ addgetCoverPathsForTheses(array $ids): array-media.php: raw visibility join → move intoDatabase::getFileVisibility(string $path): ?int-edit.php(line 155): unparameterised"… WHERE id = $thesisId"SQL injection risk — fix immediately; also move to a DB method -edit.php: rawSELECT license_id, access_type_id, context_note FROM theses WHERE id = ?→ expose these viagetThesis()(already returnsv_theses_fullwhich haslicense_id) -formulaire.php: raw identifier-generation query + all junction-table INSERTs → encapsulate inDatabase::createThesis(array $data): int -
sanitize_string()informulaire.phpapplieshtmlspecialcharsat write time — HTML-escaping belongs at render time (in the template), not at storage time. Storing&or<in the DB means search, export, and any non-HTML consumer sees corrupt data. Removehtmlspecialcharsfromsanitize_string(); keep onlytrim(). The templates already callhtmlspecialchars()on output. -
Dead variable
$problematique—formulaire.phpline 84 reads$_POST["problématique"]into$problematiquebut the value is never used (no matching column, no INSERT reference). Delete it. -
setThesisJury()not wrapped in a transaction — the method does a DELETE then multiple INSERTs with no transaction guard of its own. If called from outside a transaction (e.g. a future API endpoint) a partial failure leaves orphaned rows. Wrap the body inBEGIN … COMMIT / ROLLBACK(check$this->pdo->inTransaction()to avoid nesting). -
DB config auto-detection is fragile —
src/config.phpswitches totest.dbwhenever the file exists locally, which means a developer who ran tests and forgot to deletetest.dbwill silently hit test data on a local production-mirror. Make the defaultprod; require explicitDB_ENV=testto use the test database.
C — Code organisation / maintainability
-
edit.phpdoes too much — 530 lines combining form display, POST handling, file upload, and all reference-data loading in one file. Extract the POST handler topublic/admin/actions/edit.php(matching the pattern already used byformulaire.php,tag.php,page.php, etc.). -
formulaire.phpduplicates banner-upload logic verbatim fromedit.php— the MIME check, size cap,random_bytesname,chmod, andsetBannerPath()call are copy-pasted. Extract a privateDatabase-level helper or a standaloneuploadBanner(array $file): ?stringutility function (returns the relative path or null) shared by both action files. -
Junction-table INSERTs are open-coded in every action —
formulaire.phpandedit.phpboth manually loopINSERT INTO thesis_languages,thesis_formats,thesis_tags. AddDatabase::setThesisLanguages(int $id, array $ids),setThesisFormats(int $id, array $ids),setThesisTags(int $id, array $names)— following the same delete-then-reinsert pattern already used bysetThesisJury(). -
RateLimituses per-file JSON on disk — reads, writes, andglob()s the filesystem on every public request. For a low-traffic art-school site this is fine, but it creates a write-on-every-hit pattern. Consider switching to APCu (if available) or SQLite (single INSERT) to avoid filesystem churn. At minimum, move the cache dir to/tmpor a dedicatedstorage/cache/path that is excluded from deploy rsync. -
__wakeup()singleton guard throws from a public method — PHP 8.x deprecates throwing exceptions from__wakeup. Change totrigger_error(…, E_USER_ERROR)or implement__serialize()/__unserialize()that always throw.