13 KiB
Autosave System — Architecture & HTMX Migration Assessment
Overview
The admin panel has a custom JavaScript autosave system (autosave.js) that
auto-submits forms after a 1.5s debounce. It is used on pages where content
is edited with the OverType Markdown editor (static pages, form help blocks)
and on structured-data forms (contacts, sidebar links).
Three server-side action handlers implement the same CSRF-rotation + JSON
response contract that autosave.js consumes.
Architecture
┌──────────────────────────────────┐
│ autosave.js │
│ ───────── │
│ Watches <form data-autosave> │
│ Listens: input / change (bubble)│
│ Debounce: 1500ms │
│ POST via fetch() │
│ Accept: application/json │
│ On 2xx: shows "Enregistré ✓" │
│ On err: shows "Erreur !", retry │
│ Updates CSRF token from response│
└──────────┬───────────────────────┘
│ POST (JSON)
▼
┌──────────────────────────────────┐
│ Backend handler (page.php, │
│ apropos.php, form-help.php) │
│ ────────────────────────────────│
│ 1. Check CSRF token │
│ 2. Validate payload │
│ 3. Save to DB │
│ 4. Regenerate CSRF token │
│ 5. Return JSON: │
│ {success:true, csrf_token:X} │
└──────────────────────────────────┘
Forms Using data-autosave
1. Static Pages (about, licenses, charte)
| Attribute | Value |
|---|---|
| Template | app/templates/admin/contenus-edit.php (branches about_page and page) |
| Form action | /admin/actions/page.php |
| Editor | OverType (custom contenteditable-based Markdown WYSIWYG) |
| Fields | csrf_token (hidden), slug (hidden), content (hidden, synced by OverType onChange) |
| How events reach autosave | User types in OverType's contenteditable div → native input events bubble up to <form> → autosave.js detects them |
Key interaction: OverType's onChange(value) { hiddenInput.value = value } sets
the hidden #content input's value programmatically. This does NOT fire native DOM
events. The autosave trigger comes from input events on the contenteditable editor
div, not from the hidden input.
2. About Contacts (apropos groups)
| Attribute | Value |
|---|---|
| Template | app/templates/admin/apropos-groups-form.php |
| Form action | /admin/actions/apropos.php |
| Editor | Native text/email/url inputs |
| Fields | csrf_token, apropos_key, groups[][role], groups[][entries][][text/email/url] |
Has custom JS for adding/removing contact groups and entries (inline <script>
in the template). The reindex logic updates name attributes after add/remove.
3. About Sidebar Links
| Attribute | Value |
|---|---|
| Template | Inline in app/templates/admin/contenus-edit.php (the about_page branch) |
| Form action | /admin/actions/apropos.php |
| Fields | csrf_token, apropos_key=sidebar_links, links[][label], links[][url] |
Has custom JS for add/remove/reindex (inline <script> in the template).
4. Form Help Blocks
| Attribute | Value |
|---|---|
| Template | app/templates/admin/contenus-edit.php (branch form_help) |
| Form action | /admin/actions/form-help.php |
| Editor | OverType (same as static pages) |
| Fields | csrf_token, form_help_key, content |
Same OverType + autosave integration as static pages.
Backend Handlers
page.php — /admin/actions/page.php
CSRF check → slug validation (about|licenses|charte) → savePage(slug, content)
→ AdminLogger::logPageEdit() → regenerate CSRF → return JSON
On AJAX: returns {success: true, csrf_token: "<new_token>"}.
On non-AJAX (regular form POST): flashes message, redirects to /admin/contenus.php.
apropos.php — /admin/actions/apropos.php
CSRF check → key validation → dispatch by key type:
URL keys (erg_site_url, source_code_url) → saveAproposContent(key, url)
Link lists (sidebar_links) → validate URLs, save structured array
Group-based (contacts) → validate groups/entries, save structured array
→ AdminLogger::logAproposEdit() → regenerate CSRF → return JSON
form-help.php — /admin/actions/form-help.php
CSRF check → key validation (FORM_HELP_KEYS) → setFormHelpBlock(key, content)
→ AdminLogger::logFormStructureEdit() → regenerate CSRF → return JSON
CSRF Rotation Pattern (shared by all three)
1. Verify: hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])
2. Save to DB
3. Rotate: $_SESSION['csrf_token'] = bin2hex(random_bytes(32))
4. Return: {success: true, csrf_token: <NEW>}
5. Client: autosave.js reads data.csrf_token, updates hidden input
Other Autosave-like UI (Non-autosave.js)
These use HTMX directly, NOT autosave.js:
| Location | Trigger | Target | Handler |
|---|---|---|---|
| Settings checkboxes (Accès, Types) | change via hx-post |
#acces-response / #types-response |
/admin/actions/settings.php |
| TFE messages textareas | change via hx-post |
inline toast | /admin/actions/settings.php |
| File restrictions toggle | change via hx-post |
inline toast | /admin/actions/settings.php |
| Form help inline editor | Manual Enregistrer via hx-post |
collapsed chip | /admin/form-help-inline-fragment.php |
| Form help toggle (dot button) | Click via hx-post |
collapsed chip | /admin/form-help-inline-fragment.php |
| Language inline renames | Manual submit via hx-post |
#langues-table-wrap |
/admin/actions/language.php |
autosave.js — Detailed Behavior
// Watches: form[data-autosave]
// Debounce: 1500ms after last input/change event
// Status display: sibling element matching [data-autosave-status]
const DEBOUNCE_MS = 1500;
let timer = null;
let dirty = false;
// schedule() — called on every input/change event
// Sets dirty=true, clears previous timer, starts new 1500ms timer
schedule() → clearTimeout(timer); timer = setTimeout(doSave, 1500);
// doSave() — fires after 1500ms of inactivity
doSave() → if (!dirty) return; // guard: skip if not dirty
dirty = false; // mark clean (re-set to true on error)
setStatus('Enregistrement…');
const fd = new FormData(form); // snapshots ALL current form values
fetch(form.action, {
method: 'POST',
body: fd,
headers: { 'Accept': 'application/json' }
})
.then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status); // 403/500/etc → catch
setStatus('Enregistré ✓');
r.json().then(data => {
if (data.csrf_token) {
// Update both form input and meta tag
form.querySelector('input[name="csrf_token"]').value = data.csrf_token;
document.querySelector('meta[name="csrf-token"]').content = data.csrf_token;
}
}).catch(() => {}); // silently ignore JSON parse errors
})
.catch(() => {
setStatus('Erreur !');
dirty = true; // re-arm: will retry on next input
});
Important: The CSRF token update happens asynchronously inside .json().then(),
after the status is already set to "Enregistré ✓". In practice this is fine
because the 1.5s debounce provides ample time for the microtask to complete
before the next save cycle.
If the JSON response is malformed (PHP warnings/notices in output), the
.catch(() => {}) silently discards the parse error, the CSRF token stays
stale, and all subsequent saves fail with 403.
Known Issues
403 Forbidden on Autosave
Observed on charte page (and possibly about/licenses). The response is:
HTTP/1.1 403 Forbidden
{"error": "Erreur de sécurité : token invalide."}
The 403 comes from the CSRF check in page.php (line ~18):
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
Possible causes:
-
Stale CSRF after error: First save succeeds and rotates the token, but
autosave.jsfails to update the form (e.g., JSON parse error from PHP warning in output). All subsequent saves fail with 403. -
Session not initialized: If
$_SESSION['csrf_token']is not set whenpage.phpruns. In admin context, it should be set by:contenus.php(list page):if (empty($_SESSION['csrf_token'])) { ... }contenus-edit.php(edit page):if (empty($_SESSION['csrf_token'])) { ... }AdminAuth::requireLogin()callssession_start()
-
Session collision: PHP's default file-based session locking serializes concurrent requests. Unlikely with 1.5s debounce.
-
OverType → hidden input sync: OverType's
onChangeupdates the hidden input, but if the OverType script fails to load, the hidden input stays empty, and the form submission has no content (but CSRF token should still be present).
Non-event-emitting value updates
autosave.js relies on input/change events bubbling. The OverType editor
updates hidden.value programmatically. If someone adds custom JS that updates
form values without firing events, autosave won't detect those changes.
HTMX Migration Feasibility
The settings toggles already use HTMX successfully for similar patterns (checkbox → toggle → server → toast). Here's an assessment for each form:
1. Static Pages (page.php) — Challenging
The OverType editor is a heavy custom JS component (contenteditable-based
Markdown WYSIWYG). HTMX could handle the submission side (replacing
fetch() with hx-post), but the editor itself would remain custom JS.
An HTMX approach would:
- Add
hx-post="/admin/actions/page.php"to the form - Add
hx-trigger="every 3s"or a custom event dispatched by OverType'sonChange - Use
hx-swap="none"or a status indicator swap - Need a custom event from OverType like
overtype:changedto trigger saves at the right time (not polling-based likeevery 3s)
Verdict: Partial replacement possible. HTMX for submission, keep OverType for editing. Could eliminate ~40 lines of autosave.js logic for this form.
2. About Contacts (apropos.php) — Easy
Simple text inputs. The add/remove group/entry logic is separate JS. Autosave
just watches input events.
HTMX approach:
hx-postwithhx-trigger="change delay:1500ms"on each inputhx-swap="none"+ custom status indicator viahx-on::after-request
Verdict: Straightforward replacement. Eliminates autosave.js dependency.
3. About Sidebar Links (apropos.php) — Easy
Same as contacts — simple text/URL inputs. The add/remove JS is independent of autosave.
Verdict: Same as contacts.
4. Form Help Blocks (form-help.php) — Challenging
Same OverType integration as static pages. Same considerations apply.
Verdict: Same as static pages.
5. The autosave.js Itself — Can be Replaced
If every form with data-autosave is converted to HTMX, autosave.js can be
removed entirely. The script is ~60 lines.
Summary Table
| Form | Editor | HTMX Feasibility | Effort |
|---|---|---|---|
| Static pages (about/licenses/charte) | OverType | Partial (keep OverType) | Medium |
| Form help blocks | OverType | Partial (keep OverType) | Medium |
| About contacts | Native inputs | Full | Low |
| About sidebar links | Native inputs | Full | Low |
Recommended HTMX Strategy
For static pages and form help blocks:
<form hx-post="/admin/actions/page.php"
hx-trigger="overtype:changed delay:1500ms"
hx-swap="none"
hx-on::after-request="
var data = JSON.parse(event.detail.xhr.responseText);
if (data.csrf_token) {
document.querySelector('input[name=csrf_token]').value = data.csrf_token;
}
"
data-autosave>
And dispatch overtype:changed from OverType's onChange:
onChange: function(value) {
hidden.value = value;
editorElement.dispatchEvent(new CustomEvent('overtype:changed', { bubbles: true }));
}
For contacts/sidebar links — use per-input hx-trigger="change delay:1500ms"
or a form-level trigger on any input change.
The key advantage: HTMX handles the CSRF token rotation natively via
hx-on::after-request, eliminating the async .json().then() pattern
and the associated silent-parse-error risk.