Add autosave draft system for partage form with HTMX-based session persistence

- New fragment endpoint POST/GET /partage/fragments/draft.php:
  saves all form fields to PHP session, excludes file/csrf/slug fields
  GET returns JSON for JS hydration on page load
  rotates both global CSRF and share CSRF tokens in sync

- form.php accepts optional $formExtraAttrs and $showAutosaveStatus:
  allows injecting HTMX attributes and 'Brouillon enregistré' indicator

- renderShareLinkForm adds hx-post with change/input debounce trigger,
  loads autosave-handler.js, hydrate fields from draft on page load

- Draft cleared on successful form submission in handleShareLinkSubmission

- autosave-handler.js now also updates share_link_token hidden input
  when rotating CSRF token (partage form uses both csrf_token and share_link_token)

- Added .autosave-status CSS to form.css (was admin.css-only)

- Updated fragment routing to accept GET requests (needed for draft hydration)
This commit is contained in:
Pontoporeia
2026-06-11 10:32:53 +02:00
parent 4b37a05be3
commit 99125cc8e3
33 changed files with 1388 additions and 806 deletions

View File

@@ -0,0 +1,98 @@
<?php
/**
* Partagé autosave draft endpoint.
*
* POST — receive all form fields and persist them to the session draft store.
* GET — return all stored draft fields as JSON for page-load hydration.
*
* The draft is scoped to the share link slug, kept in $_SESSION, and
* cleared on successful form submission.
*
* Excluded field patterns (not persisted as drafts):
* - csrf_token, share_link_token, share_password*
* - FilePond metadata (filepond_mode, queue_file, filepond_*)
* - Files-related fields (couverture, note_intention, files, annexes, etc.)
* - Empty values
*/
require_once __DIR__ . '/../../../bootstrap.php';
App::boot();
$method = $_SERVER['REQUEST_METHOD'];
// ── CSRF check ──────────────────────────────────────────────────────────
if ($method === 'POST') {
if (
!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])
) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Token de sécurité invalide.']);
exit;
}
}
// ── Slug validation ─────────────────────────────────────────────────────
$slug = $_GET['slug'] ?? ($_POST['slug'] ?? '');
if (!preg_match('#^\d{8}-[A-Z0-9+/]{8}$#', $slug)) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['error' => 'Slug invalide.']);
exit;
}
// Draft storage key
$draftKey = 'partage_draft_' . $slug;
// ── POST: save all form fields ──────────────────────────────────────────
if ($method === 'POST') {
// Fields that should never be persisted as drafts
$excludePrefixes = [
'csrf_token', 'share_link_token', 'share_password',
'filepond_mode', 'queue_file', 'filepond_',
];
$excludeExact = ['slug', 'couverture', 'note_intention', 'files', 'annexes',
'peertube_video', 'peertube_audio', 'cover_remove',
'go', 'MAX_FILE_SIZE'];
$draft = [];
foreach ($_POST as $key => $value) {
// Skip excluded fields
if (in_array($key, $excludeExact, true)) continue;
$skip = false;
foreach ($excludePrefixes as $prefix) {
if (str_starts_with($key, $prefix)) { $skip = true; break; }
}
if ($skip) continue;
// Skip empty values (but keep '0' as valid)
if ($value === '' || $value === null || (is_array($value) && count($value) === 0)) {
continue;
}
$draft[$key] = $value;
}
$_SESSION[$draftKey] = $draft;
// Rotate CSRF after mutation — keep share CSRF in sync
$newToken = bin2hex(random_bytes(32));
$_SESSION['csrf_token'] = $newToken;
$_SESSION['share_csrf_' . $slug] = $newToken;
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'csrf_token' => $newToken,
]);
exit;
}
// ── GET: return draft fields for hydration ──────────────────────────────
header('Content-Type: application/json');
$draft = $_SESSION[$draftKey] ?? [];
echo json_encode([
'success' => true,
'draft' => $draft,
]);
exit;