Commit Graph

27 Commits

Author SHA1 Message Date
Pontoporeia
0cb4451218 formulaire: default interne, unpublished, contact toggle, settings section 2026-04-15 14:24:44 +02:00
Pontoporeia
572ef75a1e répertoire: rename search.php, 6-column layout, HTMX filter, faded entries disabled, URL-shareable 2026-04-08 14:14:37 +02:00
Pontoporeia
480451aa2b replace EasyMDE (333KB) with OverType (118KB) in pages-edit.php
- Remove easymde.min.js (320KB) and easymde.min.css (13KB)
- Vendor overtype.min.js (118KB, v2.3.5)
- Replace <textarea name=content> + 60-line toolbar/SVG init with:
  - <input type=hidden name=content> for form submission
  - <div id=editor> as OverType mount target
  - 6-line init: value from hidden input, onChange syncs it back
- Net saving: ~215KB assets, ~54 lines of inline JS
2026-04-06 15:33:08 +02:00
Pontoporeia
6e68edfbff Fix WCAG 4.1.2 truncated select text + split admin/public favicons
- admin/edit.php: remove mb_strimwidth(60) truncation from access_type
  <select> option labels; full 'name — description' text is now the
  accessible name so screen readers get unambiguous option text (WCAG 4.1.2)

- public/assets/favicon.svg: new public favicon — brand-purple (#9557b5)
  rounded square with white 'P' lettermark; distinct from admin_favicon.svg
  (archive-restore Lucide icon in #c104fc) which is admin-only

- templates/head.php: favicon <link> now conditionally serves favicon.svg
  (public pages) or admin_favicon.svg (admin pages) based on $isAdmin;
  closes the open favicon task in todo/01-css-semantic-refactor.md

- todo/04-accessibility.md: mark WCAG 3.1.1 lang audit and WCAG 4.1.2
  select truncation items as done
- todo/01-css-semantic-refactor.md: mark favicon task as done
2026-04-06 15:33:08 +02:00
Pontoporeia
ba7814c6dc feat: system page caching via SystemCache + system_cache SQLite table
Add a TTL-based cache for the expensive checks on the admin system page,
eliminating repeated systemctl subprocess calls (~4×~100ms), curl self-pings
(~200-500ms), disk_*_space() and PHP ini reads on every page load.

Changes:
- storage/migrations/007_system_cache.sql: new migration creating the
  system_cache table (key TEXT PK, value TEXT, updated_at INTEGER)
- storage/schema.sql: system_cache table added before pages table
- Applied migration to live storage/posterg.db
- src/SystemCache.php: new class with get/set/isStale/ageSeconds/invalidate;
  uses SQLite INSERT … ON CONFLICT upsert; no external dependencies
- src/Database.php: added getDatabasePath(): string accessor
- public/admin/system.php:
  - Bootstrap SystemCache at request start using the existing DB PDO handle
  - system_status: cached with 2-min TTL (systemctl + curl checks)
  - php_info: cached with 1-hour TTL (PHP ini values are runtime-constant)
  - disk_info: cached with 5-min TTL (total/free/used/pct tuple)
  - Logs section: unchanged — always reads live log tail per active tab
  - ?refresh=1 GET param invalidates all three cache keys before rendering
  - Status panel heading shows cache badge: ' Cache — il y a Xs' (hit)
    or '⟳ Actualisé' (miss/fresh), styled via new .sys-cache-badge rules
- public/assets/css/system.css: .sys-cache-badge / --hit / --miss styles
2026-04-02 13:04:00 +02:00
Pontoporeia
a88e5562f8 fix(config): auto-route test.db locally, posterg.db on production
- 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.
2026-04-01 15:55:12 +02:00
Pontoporeia
72d48c49c3 feat(db): auto-migrate both DBs on serve via scripts/migrate.sh 2026-04-01 15:55:12 +02:00
Pontoporeia
e5d0598208 fix: correct require_once path depth in admin action files 2026-04-01 15:55:12 +02:00
Pontoporeia
1dee1ea73f Add <meta name=description> to all public pages; improve page titles
- templates/public/head.php: emit <meta name="description"> when $metaDescription is set
- index.php: title → 'Posterg – Mémoires de l\'ERG'; description = site blurb
- tfe.php: title → '[Titre] – [Auteur] – Posterg'; description = synopsis excerpt (strip_tags, truncate 160)
- search.php: description = répertoire purpose blurb
- apropos.php: description = about-page blurb
- licence.php: description = licences blurb

Fixes WCAG 2.4.2 (Page Titled) for index.php and tfe.php.
All descriptions properly htmlspecialchars-escaped at render time.
2026-03-28 19:38:21 +01:00
Pontoporeia
5c00886db6 fix fgetcsv deprecation and apply pending DB migrations 2026-03-28 19:13:52 +01:00
Pontoporeia
126703f340 tfe.php: full semantic HTML overhaul
- 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
2026-03-28 19:12:01 +01:00
Pontoporeia
4f5ff5a22c refactor: extract edit.php POST handler to actions/edit.php
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
2026-03-28 18:08:23 +01:00
Pontoporeia
18197bd468 Extract shared public <head> partial
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).
2026-03-28 16:49:09 +01:00
Pontoporeia
61ac3c002d refactor: encapsulate thesis creation SQL in Database::createThesis()
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.
2026-03-28 13:52:43 +01:00
Pontoporeia
e126e1a3b0 refactor: use encapsulated Database methods in formulaire.php and edit.php 2026-03-28 13:49:51 +01:00
Pontoporeia
71167b2cdf fix: remove DB_ENV auto-detection; require explicit DB_ENV=test for tests
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.
2026-03-28 13:43:04 +01:00
Pontoporeia
7d96a08324 perf: replace fat-view student index query with lean getPublishedAuthors()
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.
2026-03-28 13:35:43 +01:00
Pontoporeia
69e161ada3 fix(admin): stats bar always shows whole-DB counts, not filtered counts
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.
2026-03-28 11:42:44 +01:00
Pontoporeia
f37069720a schema: add composite index (is_published, year DESC) + fix stale migration 005
- 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.
2026-03-27 13:48:22 +01:00
Pontoporeia
42af4644c5 perf+a11y: WAL mode for SQLite, skip links, :focus-visible, .sr-only
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).
2026-03-27 13:45:01 +01:00
Pontoporeia
b12ae73e91 tests: fix SecurityTest fatal TypeError — update searchTheses call to use array params
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
2026-03-26 18:54:20 +01:00
Pontoporeia
372abb5cd6 feat: tag management tests, maintenance mode polish, répertoire pagination fix
- 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
2026-03-24 15:38:36 +01:00
Pontoporeia
92e344b757 feat: admin tag management, maintenance mode, TFE visibility states
Tags admin:
- Database: getAllTagsWithCount(), renameTag(), mergeTag(), deleteTag()
- public/admin/tags.php: table with inline rename/merge/delete forms, CSRF-guarded
- public/admin/actions/tag.php: routes on action=rename|merge|delete
- templates/admin/head.php: 'Mots-clés' nav link
- admin.css: admin-inline-form, admin-btn--sm/warning/danger variants

Maintenance mode:
- config/bootstrap.php: gate on MAINTENANCE_FLAG file; admin/ and maintenance.php exempt
- public/maintenance.php: 503 dark minimal page
- public/admin/actions/maintenance.php: enable/disable toggle
- public/admin/index.php: status bar with toggle button
- admin.css: admin-maintenance-bar styles

TFE Visibility (Libre/Interne/Interdit via existing access_type_id):
- migration 002_add_visibility.sql: seeds access_types if missing
- Database: setVisibility(), bulkSetVisibility(), getAccessTypes()
- public/media.php: blocks thesis files for access_type_id=3
- public/tfe.php: shows access_type, context_note; hides file panel for Interdit
- public/admin/edit.php: access_type_id select + context_note textarea; saves both
- public/admin/index.php: three-state badge (Libre/Interne/Interdit) per row
- public/admin/actions/visibility.php: single + bulk visibility action handler
- admin.css: status-access badge variants
2026-03-24 15:35:52 +01:00
Pontoporeia
0933137540 refactor: rename keywords→tags M2M (migration 001)
- migration 001_rename_keywords_to_tags.sql: CREATE tags/thesis_tags from keywords/thesis_keywords,
  copy data, drop old tables, rebuild indexes and views
- schema.sql: tags table, thesis_tags junction, updated indexes and v_theses_full/v_theses_public
- Database.php: findOrCreateTag(), getUsedTags() with proper JOIN; backwards-compat aliases;
  buildSearchConditions uses EXISTS subquery on thesis_tags+tags with vp. alias throughout
- admin/actions/formulaire.php: INSERT OR IGNORE INTO thesis_tags
- admin/edit.php: DELETE FROM thesis_tags + findOrCreateTag
- search.php: $kw['name'] (was $kw['keyword'])
- fixtures/CreateTestDatabase.php: tags/thesis_tags table names
2026-03-24 13:30:53 +01:00
Pontoporeia
cefceb046c feat: jury composition + banner image upload
- migration 004: thesis_supervisors.role + is_external; view adds jury_president/jury_promoteurs/jury_lecteurs
- migration 005: theses.banner_path; view exposes t.banner_path and t.license_id
- Database: getThesisJury(), setThesisJury(), setBannerPath()
- admin/add.php: jury fieldset (président/promoteur/lecteurs + externe checkboxes, JS add/remove rows); banner file input
- admin/edit.php: jury fieldset pre-populated from DB; banner preview + remove checkbox + upload; multipart form
- admin/actions/formulaire.php: parse jury fields → setThesisJury(); banner upload to banners/
- tfe.php: three conditional jury rows (président·e, promoteur·ice, lecteur·ices)
- schema.sql: updated thesis_supervisors, theses, v_theses_full, v_theses_public definitions
- admin.css: fieldset, jury-row, jury-entry, btn-remove styles
2026-03-24 13:25:23 +01:00
Pontoporeia
d87348c388 feat: licence page, admin pages editor, license types, gradient card placeholders, latest-year home view
- Feature 1: public /licence.php fetches 'licenses' page from DB, renders Markdown
- Feature 1: nav.php adds 'Licence' link with active state
- Feature 2: Database::getPage(), savePage(), getAllPages() methods
- Feature 2: bundled src/Parsedown.php (MIT, zero-dependency)
- Feature 2: apropos.php now renders 'about' page content from DB via Parsedown
- Feature 2: admin/pages.php (list) + admin/pages-edit.php (EasyMDE editor)
- Feature 2: admin/actions/page.php (auth+CSRF+validation+save)
- Feature 2: admin/head.php adds 'Pages statiques' nav link
- Feature 3: storage/schema.sql seeds 8 CC license types
- Feature 3: storage/migrations/003_seed_license_types.sql (applied to live DB)
- Feature 3: Database::getLicenseTypes() / getAllLicenseTypes()
- Feature 3: admin/add.php + formulaire.php: license_id field on add form
- Feature 3: admin/edit.php: license_id field on edit form with raw FK lookup
- Feature 3: tfe.php: shows 'Licence :' meta row when non-null
- Feature 6: main.css: .card__media--gradient styles
- Feature 6: index.php: deterministic HSL gradient placeholder cards
- Feature 6: Database::getLatestYearTheses() + getLatestPublishedYear()
- Feature 6: index.php default home = random latest-year theses with info label
2026-03-24 13:12:48 +01:00
Théophile Gervreau-Mercier
7fca85d1c1 refactor: rename database → storage
More semantically accurate: contains SQLite files, schema, fixtures, test data.
Updated all references in code, scripts, docs.
2026-02-12 12:12:58 +01:00