feat: FilePond production hardening — extension-based validation, server-side size limits (2GB), annexe validation, drop accept attributes, FilePond file styling

This commit is contained in:
Pontoporeia
2026-05-10 20:41:37 +02:00
parent 7b5f3efe40
commit 8db7b6e9eb
23 changed files with 4770 additions and 216 deletions

View File

@@ -98,20 +98,8 @@ $hasAnnexesChecked = !empty($_POST['has_annexes']);
hx-trigger="change"
hx-include="[name='formats[]'], [name='website_url'], [name='admin_mode'], [name='edit_mode'], [name='_cover']"
hx-swap="outerHTML"
<?php elseif ((int)$opt['id'] === ($videoId ?? 0)): ?>
hx-post="<?= htmlspecialchars($hxPost) ?>"
hx-target="#slot-video"
hx-select="#slot-video"
hx-trigger="change"
hx-include="[name='formats[]'], [name='website_url'], [name='admin_mode'], [name='edit_mode'], [name='_cover']"
hx-swap="outerHTML"
<?php elseif ((int)$opt['id'] === ($audioId ?? 0)): ?>
hx-post="<?= htmlspecialchars($hxPost) ?>"
hx-target="#slot-audio"
hx-select="#slot-audio"
hx-trigger="change"
hx-include="[name='formats[]'], [name='website_url'], [name='admin_mode'], [name='edit_mode'], [name='_cover']"
<?php endif; ?>>
<?php endif; ?>
>
<?= htmlspecialchars($opt['name']) ?>
</label>
</li>
@@ -206,7 +194,6 @@ $hasAnnexesChecked = !empty($_POST['has_annexes']);
<div class="admin-file-input">
<input type="file" id="couverture"
name="couverture"
accept="image/jpeg,image/png,image/webp"
class="tfe-file-picker tfe-file-picker--single"
data-queue-type="cover">
<small>JPG, PNG ou WEBP. Format 4:3 recommandé. Max 20 MB.</small>
@@ -220,7 +207,6 @@ $hasAnnexesChecked = !empty($_POST['has_annexes']);
<div class="admin-file-input">
<input type="file" id="note_intention"
name="note_intention"
accept=".pdf"
class="tfe-file-picker tfe-file-picker--single"
data-queue-type="note_intention"
<?= !$adminMode ? 'required' : '' ?>>
@@ -235,47 +221,30 @@ $hasAnnexesChecked = !empty($_POST['has_annexes']);
<input type="file" id="tfe-files-input"
name="queue_file[tfe][]"
multiple
accept=".pdf,.jpg,.jpeg,.png,.gif,.webp,.mp4,.webm,.ogv,.mov,.mp3,.ogg,.oga,.wav,.flac,.aac,.m4a,.vtt,.zip,.tar,.gz,.tgz"
class="tfe-file-picker"
<?= !$adminMode ? 'required' : '' ?>>
<small class="admin-file-hint">
PDF (max 100 MB) · Images (JPG/PNG/GIF/WEBP) · Vidéo · Audio · VTT · Archives.
PDF (max 100 MB) · Images (max 500 MB) · Vidéo & Audio (max 2 GB) · VTT · Archives (max 500 MB).
Glissez pour réordonner.
PDFs trop lourds ? <a href="https://www.bentopdf.com" target="_blank" rel="noopener">https://bentopdf.com/</a>
</small>
</div>
</div>
<!-- ── 4. Annexes — multi-file upload (FilePond) ── -->
<!-- ── 4. Annexes — multi-file upload (FilePond), always visible ── -->
<div id="annexes-input-block">
<div class="admin-form-group">
<label class="admin-checkbox-label">
<input type="checkbox" name="has_annexes" value="1"
<?= $hasAnnexesChecked ? 'checked' : '' ?>
hx-post="<?= htmlspecialchars($hxPost) ?>"
hx-target="#annexes-input-block"
hx-select="#annexes-input-block"
hx-trigger="change"
hx-include="[name='formats[]'], [name='website_url'], [name='admin_mode'], [name='edit_mode'], [name='_cover'], [name='has_annexes']"
hx-swap="outerHTML">
Ce TFE comporte des annexes
</label>
</div>
<?php if ($hasAnnexesChecked): ?>
<!-- has_annexes checkbox disabled — annexe pool always on -->
<input type="hidden" name="has_annexes" value="0">
<div class="admin-form-group admin-files-fieldgroup">
<label for="annexe-files-input">Annexes<?= !$adminMode ? ' <span class="asterisk">*</span>' : '' ?></label>
<label for="annexe-files-input">Annexes (optionnel)</label>
<div class="admin-file-input">
<input type="file" id="annexe-files-input"
name="queue_file[annexe][]"
multiple
accept=".pdf,.zip,.tar,.gz"
class="tfe-file-picker"
<?= !$adminMode ? 'required' : '' ?>>
class="tfe-file-picker">
<small class="admin-file-hint">PDF ou archives ZIP/TAR. Max 500 MB. Glissez pour réordonner.</small>
</div>
</div>
<script>if(window.XamxamInitFilePonds)window.XamxamInitFilePonds();</script>
<?php endif; ?>
</div>
<!-- ── Format-specific extras (individual swappable slots) ── -->
@@ -296,7 +265,8 @@ $hasAnnexesChecked = !empty($_POST['has_annexes']);
<div id="slot-siteweb" hidden></div>
<?php endif; ?>
<!-- Slot: Video -->
<!-- Slot: Video (disabled — video files are now uploaded via the TFE input) -->
<!--
<?php if ($hasVideo): ?>
<?php if ($peerTubeEnabled): ?>
<div id="slot-video" class="admin-form-group">
@@ -325,8 +295,11 @@ $hasAnnexesChecked = !empty($_POST['has_annexes']);
<?php else: ?>
<div id="slot-video" hidden></div>
<?php endif; ?>
-->
<div id="slot-video" hidden></div>
<!-- Slot: Audio -->
<!-- Slot: Audio (disabled — audio files are now uploaded via the TFE input) -->
<!--
<?php if ($hasAudio): ?>
<?php if ($peerTubeEnabled): ?>
<div id="slot-audio" class="admin-form-group">
@@ -355,13 +328,10 @@ $hasAnnexesChecked = !empty($_POST['has_annexes']);
<?php else: ?>
<div id="slot-audio" hidden></div>
<?php endif; ?>
-->
<div id="slot-audio" hidden></div>
<script>if(window.XamxamInitFilePonds)window.XamxamInitFilePonds();</script>
</div>
</fieldset><!-- /Fichiers -->
<script>
if(window.XamxamInitFilePonds)window.XamxamInitFilePonds();
</script>
</div><!-- #format-fichiers-block -->

View File

@@ -317,10 +317,29 @@ function renderShareLinkForm(string $slug, array $link): void
$synopsisExtra = ob_get_clean();
// Jury data from repopulation
$juryPromoteur = old($formData, 'jury_promoteur');
$juryPromoteur = null;
$juryPromoteurs = [];
$juryPromoteurUlb = old($formData, 'jury_promoteur_ulb_name');
$juryPromoteurUlb = null;
$juryPromoteursUlb = [];
// promoteurices may be submitted as arrays (multiple entries)
$promoteursRaw = old($formData, 'jury_promoteur');
if (is_array($promoteursRaw)) {
foreach ($promoteursRaw as $name) {
$name = trim($name ?? '');
if ($name !== '') $juryPromoteurs[] = ['name' => $name];
}
} elseif (is_string($promoteursRaw) && trim($promoteursRaw) !== '') {
$juryPromoteur = $promoteursRaw;
}
$promoteursUlbRaw = old($formData, 'jury_promoteur_ulb_name');
if (is_array($promoteursUlbRaw)) {
foreach ($promoteursUlbRaw as $name) {
$name = trim($name ?? '');
if ($name !== '') $juryPromoteursUlb[] = ['name' => $name];
}
} elseif (is_string($promoteursUlbRaw) && trim($promoteursUlbRaw) !== '') {
$juryPromoteurUlb = $promoteursUlbRaw;
}
$lecteursInternes = [];
$lecteursExternes = [];
for ($i = 0; $i < 10; $i++) {
@@ -376,7 +395,12 @@ function renderShareLinkForm(string $slug, array $link): void
<link rel="stylesheet" href="<?= App::assetV('/assets/css/common.css') ?>">
<link rel="stylesheet" href="<?= App::assetV('/assets/css/form.css') ?>">
<link rel="stylesheet" href="<?= App::assetV('/assets/css/filepond.min.css') ?>">
<link rel="stylesheet" href="<?= App::assetV('/assets/css/filepond-plugin-image-preview.min.css') ?>">
<script src="<?= App::assetV('/assets/js/filepond.min.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/filepond-plugin-file-validate-type.min.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/filepond-plugin-file-validate-size.min.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/filepond-plugin-image-preview.min.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/filepond-plugin-image-exif-orientation.min.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/file-upload-filepond.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/beforeunload-guard.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/htmx.min.js') ?>" defer></script>
@@ -497,6 +521,12 @@ function handleShareLinkSubmission(string $slug): void
$ctrl = ThesisCreateController::make();
$thesisId = $ctrl->submit($_POST, $_FILES);
// Collect file processing warnings (invalid types, too large, etc.)
$fileWarnings = $ctrl->getFileWarnings();
if ($fileWarnings) {
$_SESSION['_flash_warning'] = implode("\n", $fileWarnings);
}
$identifier = $ctrl->getIdentifier($thesisId);
$logger->logSubmission('partage', $thesisId, $identifier, $authorName, [
'share_slug' => $slug,
@@ -572,19 +602,23 @@ function handleShareLinkSubmission(string $slug): void
/**
* Helper to retrieve old form data (with support for array keys via : delimiter)
*/
function old(array $data, string $key, string $default = ''): string {
/**
* Retrieve old form data for repopulation.
* Returns raw value (no escaping) — callers must htmlspecialchars() when rendering.
* For arrays, returns the array as-is so callers can iterate.
*/
function old(array $data, string $key, $default = '') {
// Support nested keys like "jury_lecteurs:0"
$parts = explode(':', $key);
$value = $data;
foreach ($parts as $part) {
if (is_array($value) && isset($value[$part])) {
if (is_array($value) && array_key_exists($part, $value)) {
$value = $value[$part];
} else {
$value = $default;
break;
return $default;
}
}
return is_array($value) ? htmlspecialchars(json_encode($value)) : htmlspecialchars((string)$value);
return $value;
}
/**