The admin idle timeout (30 min) was refreshed only by navigations and HTMX
requests. During long encoding sessions on an open form there are none, so
an actively-typing admin was logged out mid-work after ~30-45 min.
Add an activity-driven keepalive:
- /admin/session-keepalive.php: 204 when authenticated (refreshes
admin_last_activity via AdminAuth::isAuthenticated()), 401 otherwise.
- admin-session-keepalive.js: marks activity only on real user input
(pointer/keyboard/input/scroll/wheel/touch/focus) and pings at most once
per 5 min while the tab is visible. A genuinely idle tab never pings, so
the idle timeout still applies.
Raise the idle window 30 min -> 4 h: for a single-/few-admin back-office
whose main workflow is data entry, 30 min still kicked admins who stepped
away mid-form. With the keepalive in place, 4 h means "no interaction at
all", not "no navigation". Absolute timeout stays 12 h.
Also fix session ID rotation, which never fired: it used
`$absolute % IDLE_TIMEOUT_SECONDS === 0`, i.e. required a request to land
exactly on a multiple of the interval relative to login time. Replaced with
an explicit admin_last_rotation timestamp and a ROTATION_INTERVAL_SECONDS
(30 min) constant decoupled from the idle timeout, so raising the idle
window does not widen the fixation/replay window.
Refactor AdminAuth::enforceSessionTimeout() to return bool instead of
redirecting/exiting, so the keepalive endpoint can report 401 cleanly
rather than letting fetch follow a redirect to the login page.
Smoke test (just smoke-session-keepalive) covers activity refresh, 2 h idle
accepted, rotation firing, idle rejection+destruction, and unauthenticated
rejection. Docs updated.
The contenus page loaded both lookup tables in full via HTMX on page load:
760 tag rows + 216 language rows, ~2.4MB of HTML including ~1960 inline
SVG icons and per-row CSRF forms. The DB queries were already fast (~6ms);
the cost was pure client-side payload and DOM.
- Add paged lookups: getTagsPage/getLanguagesPage + countTagsWithCount/
countLanguagesWithCount, and optional limit/offset on the existing
unpaged variants (backward compatible).
- Fragments serve 25 rows per request. A sentinel <tr> with
hx-trigger="intersect once root:#<wrap>" appends the next page when it
scrolls into the table's own scroll container (htmx 'revealed' only
checks the window viewport, not nested scrollers).
- Search forms still swap the whole wrapper and reset to offset 0.
- Style the load-more row; add PagedLanguagesTagsTest coverage.
Initial DOM for the page drops from ~2.4MB to ~160KB; scrolling reaches the
full totals (736 tags, 216 languages) and stops cleanly.
The desktop TOC is its own scroll container (overflow-y:auto + max-height),
but unlike article content — which gets trailing space via
.page-content > article::after — it had nothing below the last link. A long
TOC scrolled to the bottom left the final link flush against the edge.
Add .toc::after as a block pseudo-element inside the desktop media query,
using the same technique (and the same Firefox-clips-padding-bottom reason)
as the article. Shared component, so public (about/charte/licence) and admin
(#admin-toc) both get it. Verified with Playwright: 0px -> ~60px gap;
mobile unaffected (TOC is not a scroll container there).
apropos/licence/charte use .page-content as a nested scroll container
(overflow-y:auto) inside html/body {height:100%; overflow:hidden} flex.
Firefox drops/clips that container's own padding-bottom (and last-child
margin) from the scrollable area -- scrollHeight came out ~80px short and
the last ~80px of content was unreachable. Chromium honours the padding.
Move the trailing space off the container padding onto a real content
spacer (.page-content > article::after, display:block; height) which both
browsers count as scrollable content. Verified via Playwright (Chromium +
Firefox): delta between article and scrollHeight is now 0 in both on all
three pages, consistent at all breakpoints. not-found unaffected (loads
its own not-found.css). 294 PHPUnit tests pass.
- nginx/docs/PRODUCTION_DEPLOYMENT.md: drop stale htpasswd/Basic-auth model,
manual server-side deploy step, /var/www/posterg paths and hardcoded IP; describe
the PHP-layer AdminAuth, automated 📋 Deploying nginx configuration…
xamxam.conf
sent 145 bytes received 125 bytes 540.00 bytes/sec
total size is 10,012 speedup is 37.08
deploy-server.sh
sent 143 bytes received 113 bytes 512.00 bytes/sec
total size is 8,519 speedup is 33.28, and what deploy-server.sh
actually does.
- nginx/docs/QUICK_REFERENCE.md: replace htpasswd user management with AdminAuth
guidance; fix php8.2->php8.4, log paths, admin rate limit (300r/m), socket path.
- nginx/README.md, nginx/SETUP.md: note 📋 Deploying nginx configuration…
xamxam.conf
sent 145 bytes received 125 bytes 540.00 bytes/sec
total size is 10,012 speedup is 37.08
deploy-server.sh
sent 143 bytes received 113 bytes 170.67 bytes/sec
total size is 8,519 speedup is 33.28 automates
deploy-server.sh (no manual ssh/sudo step).
- docs/deployment.md: correct deploy-code scope (code-only, no --chown), fix the
/var/log/xamxam provisioning step (deploy-server.sh via deploy-nginx, not
deploy-code), document the setup-server.sh prerequisite and fresh-box caveat.
- README.md: replace the incomplete manual first-time setup snippet with the
real setup-server.sh + provision-server flow and a fresh-box caveat.
Replace the eager <img> on the home page with an htmx placeholder <figure>
that fetches a /cover-fragment endpoint when it scrolls into view
(hx-trigger="revealed"), so heavy cover bytes load only on demand.
Add spinner + settle-fade transition CSS, and load htmx.min.js on home.
Ajoute une fonction SQLite UDF accfold() (app/src/AccentFolding.php) qui plie les caractères accentués latin vers leur base ASCII (é→e, ç→c, …), et l'applique à chaque condition de recherche côté colonne ET côté terme (accfold(column) LIKE accfold(:term)). Comme le runtime n'a ni intl (Normalizer) ni iconv, le repli passe par une map de translitération manuelle (Western Latin-1 + Latin Extended-A, NULL-safe).
Couvert :
- recherche publique (searchTheses / countSearchResults) : titre, sous-titre, synopsis, auteurs, promoteurs, tags ;
- popover étudiant (getThesesByAuthorName / getThesesForAuthors) : a.name ;
- recherche backoffice (getThesesList / getThesesListCount) : titre, sous-titre, identifiant, auteur.
L'UDF est enregistré dans Database::registerSqliteFunctions() (appelé par le constructeur et par le harnais de test TestDatabaseInstance). Évite aussi la dépréciation PHP 8.5 de PDO::sqliteCreateFunction() : la connexion est créée via \Pdo\Sqlite quand disponible (createFunction()) avec repli sur PDO (sqliteCreateFunction()) sur les anciens runtimes ; les tests passent de @dataProvider docblock à l'attribut #[DataProvider] (fin de la dépréciation PHPUnit). Détache .php-cs-fixer.cache (gitignoré) pour ne plus polluer le working copy.
294 tests PHPUnit verts sans dépréciation, phpstan niveau 5 OK.
- fins de ligne \r\n/\r → \n,
- blocs de lignes vides → \n\n, è
- espaces/tabulations/NBSP → espace unique,
- trim.
Les retours à la ligne simples intra-paragraphe sont LAISSÉS INTACTS :
une coupure de ligne peut être une vraie frontière de mot (→ espace)
ou une coupure du mot ("dyna\nmiques" → "dynamiques"),
indistinguables sans dictionnaire — automatiser corromprait l'entrée
("poursuivantsur").
Migration batch 045 (backfill des données existantes) RETIRÉE : le
nettoyage automatique fiable des synopsis existants est impossible.
Chaque modification est testée (277 tests PHPUnit).
(heure de dépôt was showing UTC)
- feat: add date_depot column (real TFE deposit date)
with CSV round-trip + Brussels→UTC sanitization
- fix: keep PHP default tz at UTC to preserve token/share-link
expiry consistency; convert to Brussels only in db_datetime()
.tfe-main (main) is a block container by default, so .tfe-layout's
height:100% and .tfe-content-row's flex:1 never resolved to a
definite height. The columns' overflow-y:auto thus had nothing to
constrain, and main's overflow:hidden clipped the content — scroll
was visually broken. Adding display:flex lets the column/height
chain resolve so both columns scroll independently as intended.
- Move 12 historical/superseded docs + 1 session log + 1 PDF + 1 HTML plan to archive/
- Merge 4 VM-crash docs into archive/vm-crash-incident.md
- Merge LDAP plan + spec into ldap.md
- Merge FilePond race investigation into filepond-crash-analysis.md
- Merge SPECS.md client notes into spec-sheet.md appendix
- Update README index and security.md cross-reference
- admin parametres: move Compte administrateur into Identifiants as a fieldset
- admin parametres: space the account fieldset and drop confirm-password top border
- admin parametres: tighten param-form row spacing, relabel password button
- admin parametres: move password submit button outside the account fieldset
Clicking orientation, AP program, finality, year, format, or keyword links
on the TFE page was passing both a dedicated filter param AND a query=
param. The query= text search doesn't look at the filter columns
(orientation, ap_program, etc.), so the AND combination yielded zero
results when the filter value didn't appear in title/authors/etc fields.
Also fix the language link: it was passing language as query= which
searches only title/authors/etc, not the languages column. Now uses
the dedicated language= param (DB layer already supported it, and
collectSearchParams now collects it).
FilePond 4.x wraps the original input element in its own DOM structure
which can break CSS attribute selectors. querySelector with
.tfe-file-picker[data-queue-type='...'] returned null even though the
element existed and was initialized.
Switched to getElementById with a queueType→inputId map:
cover → couverture
note_intention → note_intention
tfe → tfe-files-input
annexe → annexe-files-input
getElementById is both faster and immune to FilePond's DOM wrapping.