mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
docs: keep only reference documentation, archive one-offs
This commit is contained in:
@@ -0,0 +1,478 @@
|
||||
# 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
|
||||
|
||||
```js
|
||||
// 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):
|
||||
```php
|
||||
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
|
||||
```
|
||||
|
||||
Possible causes:
|
||||
|
||||
1. **Stale CSRF after error**: First save succeeds and rotates the token, but
|
||||
`autosave.js` fails to update the form (e.g., JSON parse error from PHP
|
||||
warning in output). All subsequent saves fail with 403.
|
||||
|
||||
2. **Session not initialized**: If `$_SESSION['csrf_token']` is not set when
|
||||
`page.php` runs. 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()` calls `session_start()`
|
||||
|
||||
3. **Session collision**: PHP's default file-based session locking serializes
|
||||
concurrent requests. Unlikely with 1.5s debounce.
|
||||
|
||||
4. **OverType → hidden input sync**: OverType's `onChange` updates 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 v2 Migration Plan
|
||||
|
||||
### The Core Pattern
|
||||
|
||||
HTMX v2 replaces the entire `autosave.js` fetch/debounce/CSRF-update loop. The key pieces:
|
||||
|
||||
- `hx-trigger` handles debouncing
|
||||
- `hx-on::after-request` handles CSRF token rotation
|
||||
- `hx-swap="none"` since you only need the JSON response side-effect
|
||||
- A response header (`HX-Trigger`) can drive the status indicator
|
||||
|
||||
### 1. Native Input Forms (Contacts & Sidebar Links) — Full Replacement
|
||||
|
||||
These are straightforward since all inputs fire native DOM events.
|
||||
|
||||
```html
|
||||
<form
|
||||
hx-post="/admin/actions/apropos.php"
|
||||
hx-trigger="change delay:1500ms, input delay:1500ms"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="handleAutosaveResponse(event)"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="...">
|
||||
<input type="hidden" name="apropos_key" value="contacts">
|
||||
|
||||
<!-- your inputs -->
|
||||
|
||||
<span data-autosave-status></span>
|
||||
</form>
|
||||
```
|
||||
|
||||
One subtlety: HTMX v2 fires the trigger on the **element with `hx-trigger`**, which
|
||||
here is the `<form>` — so `change` and `input` events bubbling up from child inputs
|
||||
will correctly trigger it.
|
||||
|
||||
### 2. OverType Forms (Static Pages & Form Help) — Partial Replacement
|
||||
|
||||
OverType updates the hidden input programmatically without firing DOM events, so you
|
||||
need to dispatch a custom event from its `onChange` hook:
|
||||
|
||||
```js
|
||||
// In your OverType init
|
||||
onChange: function(value) {
|
||||
hiddenInput.value = value;
|
||||
// Dispatch a custom event that HTMX can listen for
|
||||
hiddenInput.dispatchEvent(
|
||||
new CustomEvent('overtype:change', { bubbles: true })
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Then on the form:
|
||||
|
||||
```html
|
||||
<form
|
||||
hx-post="/admin/actions/page.php"
|
||||
hx-trigger="overtype:change delay:1500ms"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="handleAutosaveResponse(event)"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="...">
|
||||
<input type="hidden" name="slug" value="charte">
|
||||
<input type="hidden" id="content" name="content" value="">
|
||||
|
||||
<span data-autosave-status></span>
|
||||
</form>
|
||||
```
|
||||
|
||||
### 3. CSRF Rotation + Status Indicator
|
||||
|
||||
Replace `autosave.js`'s `.json().then()` pattern with a single shared handler. The
|
||||
silent-parse-error risk disappears because you're explicitly handling the response:
|
||||
|
||||
```js
|
||||
function handleAutosaveResponse(event) {
|
||||
const status = event.target.closest('form')
|
||||
.querySelector('[data-autosave-status]');
|
||||
|
||||
if (!event.detail.successful) {
|
||||
if (status) status.textContent = 'Erreur !';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.detail.xhr.responseText);
|
||||
|
||||
// Rotate CSRF token in both the form and the meta tag
|
||||
if (data.csrf_token) {
|
||||
event.target.closest('form')
|
||||
.querySelector('input[name="csrf_token"]').value = data.csrf_token;
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (meta) meta.content = data.csrf_token;
|
||||
}
|
||||
|
||||
if (status) status.textContent = data.success ? 'Enregistré ✓' : 'Erreur !';
|
||||
|
||||
} catch {
|
||||
// JSON parse failed (e.g. PHP warning in output) — surface it rather than silently swallowing
|
||||
if (status) status.textContent = 'Erreur !';
|
||||
console.warn('Autosave: could not parse response', event.detail.xhr.responseText);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is a direct improvement over the current `autosave.js` `.catch(() => {})`
|
||||
silent swallow — you now see the PHP warning in the console instead of just
|
||||
getting a mystery 403 on the next save.
|
||||
|
||||
### 4. "Loading" State During Save
|
||||
|
||||
If you want a saving indicator (the current `"Enregistrement…"` state), use
|
||||
`htmx:beforeRequest` on the form — or use HTMX's built-in `htmx-request` class
|
||||
which is added to the element automatically while a request is in flight:
|
||||
|
||||
```css
|
||||
/* Target the class HTMX adds */
|
||||
form.htmx-request [data-autosave-status]::after {
|
||||
content: 'Enregistrement…';
|
||||
}
|
||||
```
|
||||
|
||||
Or explicitly with an event listener:
|
||||
|
||||
```js
|
||||
document.body.addEventListener('htmx:beforeRequest', e => {
|
||||
const status = e.target.querySelector('[data-autosave-status]');
|
||||
if (status) status.textContent = 'Enregistrement…';
|
||||
});
|
||||
```
|
||||
|
||||
### 5. The Add/Remove Group JS Still Works Unchanged
|
||||
|
||||
The reindex logic (updating `name` attributes after add/remove) is inline
|
||||
`<script>` independent of `autosave.js`. This continues to work — HTMX reads
|
||||
`FormData` at request time, so newly added/reindexed inputs are automatically
|
||||
included in the next triggered save.
|
||||
|
||||
### Migration Checklist
|
||||
|
||||
| Form | Change required |
|
||||
|---|---|
|
||||
| `contenus-edit.php` (pages) | Add `hx-*` attrs, add `overtype:change` dispatch in OverType `onChange` |
|
||||
| `contenus-edit.php` (form_help) | Same as above |
|
||||
| `apropos-groups-form.php` (contacts) | Add `hx-*` attrs only |
|
||||
| `contenus-edit.php` (sidebar_links) | Add `hx-*` attrs only |
|
||||
| `autosave.js` | **Delete** once all four forms are migrated |
|
||||
| Backend handlers | **No changes needed** — they already return `{success, csrf_token}` |
|
||||
|
||||
The backend is untouched throughout, which is the cleanest part of this migration.
|
||||
|
||||
## 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's `onChange`
|
||||
- Use `hx-swap="none"` or a status indicator swap
|
||||
- Need a custom event from OverType like `overtype:changed` to trigger saves
|
||||
at the right time (not polling-based like `every 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-post` with `hx-trigger="change delay:1500ms"` on each input
|
||||
- `hx-swap="none"` + custom status indicator via `hx-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:
|
||||
|
||||
```html
|
||||
<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`:
|
||||
```js
|
||||
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.
|
||||
Reference in New Issue
Block a user