mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
Extract ThesisCreateController; add Database publish methods
Consolidate action handlers into controller methods (todo/02-php-components.md).
src/ThesisCreateController.php (new, 435 lines)
Mirrors ThesisEditController for the add-thesis flow.
make() — factory; instantiates Database via new Database()
loadFormData() — returns all lookup tables needed by admin/add.php
(orientations, apPrograms, finalityTypes, languages,
formatTypes, licenseTypes)
submit(post, files) — full new-thesis creation pipeline:
1. validateAndSanitise() — trims/strips HTML, validates required fields,
year range, orientation/ap/finality IDs, language selection, max-10
keywords, URL format; throws named Exception on failure
2. findOrCreateAuthor() — reuses existing DB method
3. Transaction: createThesis + setThesisJury + setThesisLanguages +
setThesisFormats + setThesisTags; rolls back on any failure
4. File uploads outside transaction: cover image (JPG/PNG only, stored in
storage/covers/), banner via handleBannerUpload(), thesis files
(PDF/JPG/PNG/MP4/ZIP/VTT, stored in storage/theses/YEAR/IDENT/,
file_type auto-detected: caption/annex/main/other)
autofocusFieldForError() — static; maps exception messages to field names
for WCAG 3.3.1 autofocus on re-render (same contract as
ThesisEditController::autofocusFieldForError)
admin/actions/formulaire.php 346 → 45 lines
Now: bootstrap + CSRF guard + ThesisCreateController::make()->submit() +
flash/redirect on error. All validation, DB logic, and file handling removed.
admin/add.php
Lookup-table block (new Database() + 6 individual DB calls) replaced with
ThesisCreateController::make()->loadFormData() + extract().
src/Database.php — two new methods added
setPublished(int , bool ): void
UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
bulkSetPublished(int[] , bool ): void
Same but with an IN (...) clause for multiple IDs
admin/actions/publish.php 100 → 65 lines
Raw SQL (->prepare('UPDATE theses SET is_published = ?...')) replaced
with ->setPublished() / ->bulkSetPublished(). No raw PDO calls remain
in any action handler file.
This commit is contained in:
@@ -871,6 +871,28 @@ class Database {
|
||||
)->execute($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the published state of a single thesis.
|
||||
*/
|
||||
public function setPublished(int $thesisId, bool $published): void {
|
||||
$this->pdo->prepare(
|
||||
'UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||||
)->execute([$published ? 1 : 0, $thesisId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the published state for multiple theses at once.
|
||||
* @param int[] $thesisIds
|
||||
*/
|
||||
public function bulkSetPublished(array $thesisIds, bool $published): void {
|
||||
if (empty($thesisIds)) return;
|
||||
$placeholders = implode(',', array_fill(0, count($thesisIds), '?'));
|
||||
$params = array_merge([$published ? 1 : 0], $thesisIds);
|
||||
$this->pdo->prepare(
|
||||
"UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ($placeholders)"
|
||||
)->execute($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all access types (visibility options).
|
||||
*/
|
||||
|
||||
435
src/ThesisCreateController.php
Normal file
435
src/ThesisCreateController.php
Normal file
@@ -0,0 +1,435 @@
|
||||
<?php
|
||||
/**
|
||||
* ThesisCreateController
|
||||
*
|
||||
* Centralises all validation, data-fetching, and persistence logic for the
|
||||
* admin "add new thesis" workflow (admin/add.php + admin/actions/formulaire.php).
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Loading lookup tables for the add-form view (loadFormData)
|
||||
* - Validating and sanitising POST submissions
|
||||
* - Creating the thesis record, linking authors / jury / languages / formats /
|
||||
* tags in a single database transaction
|
||||
* - Handling cover image, banner, and multi-file uploads
|
||||
* - WCAG 3.3.1: mapping validation exception messages to autofocus field hints
|
||||
*
|
||||
* The class has NO output side-effects; all redirects, flash writes, session
|
||||
* mutations, and template rendering stay in the thin dispatcher files so the
|
||||
* view layer remains easy to inspect and modify.
|
||||
*/
|
||||
class ThesisCreateController
|
||||
{
|
||||
/** Maximum allowed file size for thesis files (bytes). */
|
||||
private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
/** MIME types accepted for thesis files. */
|
||||
private const ALLOWED_MIME_TYPES = [
|
||||
'image/jpeg', 'image/png', 'application/pdf',
|
||||
'video/mp4', 'application/zip', 'text/vtt',
|
||||
];
|
||||
|
||||
/** File extensions accepted for thesis files. */
|
||||
private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'pdf', 'mp4', 'zip', 'vtt'];
|
||||
|
||||
private Database $db;
|
||||
|
||||
public function __construct(Database $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
// ── Factory ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convenience factory — instantiates Database and returns a ready
|
||||
* controller instance.
|
||||
*/
|
||||
public static function make(): self
|
||||
{
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
|
||||
return new self(new Database());
|
||||
}
|
||||
|
||||
// ── Read / view data ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load all lookup tables required to render the add-thesis form.
|
||||
*
|
||||
* Returns a flat array of view variables:
|
||||
* - 'orientations' – orientation lookup rows
|
||||
* - 'apPrograms' – AP program lookup rows
|
||||
* - 'finalityTypes' – finality type lookup rows
|
||||
* - 'languages' – language lookup rows
|
||||
* - 'formatTypes' – format type lookup rows
|
||||
* - 'licenseTypes' – license type lookup rows
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
* @throws Exception on DB error.
|
||||
*/
|
||||
public function loadFormData(): array
|
||||
{
|
||||
return [
|
||||
'orientations' => $this->db->getAllOrientations(),
|
||||
'apPrograms' => $this->db->getAllAPPrograms(),
|
||||
'finalityTypes' => $this->db->getAllFinalityTypes(),
|
||||
'languages' => $this->db->getAllLanguages(),
|
||||
'formatTypes' => $this->db->getAllFormatTypes(),
|
||||
'licenseTypes' => $this->db->getAllLicenseTypes(),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Write / action ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate and persist a new-thesis POST submission.
|
||||
*
|
||||
* On success, returns the new thesis ID so the caller can redirect to
|
||||
* thanks.php?id=<n>. On validation or DB failure, throws an Exception
|
||||
* (caller must flash the message and redirect back to the form).
|
||||
*
|
||||
* Execution order:
|
||||
* 1. Validate + sanitise POST fields
|
||||
* 2. Find/create author record
|
||||
* 3. INSERT thesis row + link author (inside transaction)
|
||||
* 4. Link jury, languages, formats, tags (inside transaction)
|
||||
* 5. COMMIT
|
||||
* 6. Handle file uploads: cover, banner, thesis files (outside transaction)
|
||||
*
|
||||
* @param array $post Sanitised $_POST array.
|
||||
* @param array $files $_FILES array.
|
||||
* @return int The newly created thesis ID.
|
||||
* @throws Exception On validation or DB error.
|
||||
*/
|
||||
public function submit(array $post, array $files): int
|
||||
{
|
||||
// ── 1. Validate + sanitise ────────────────────────────────────────────
|
||||
$data = $this->validateAndSanitise($post);
|
||||
|
||||
// ── 2. Find / create author ───────────────────────────────────────────
|
||||
$authorId = $this->db->findOrCreateAuthor($data['auteurName'], $data['mail'] ?: null);
|
||||
error_log("ThesisCreateController: author ID $authorId");
|
||||
|
||||
// ── 3–4. DB writes in a transaction ───────────────────────────────────
|
||||
$this->db->beginTransaction();
|
||||
|
||||
try {
|
||||
$thesisId = $this->db->createThesis([
|
||||
'year' => $data['annee'],
|
||||
'orientation_id' => $data['orientationId'],
|
||||
'ap_program_id' => $data['apProgramId'],
|
||||
'finality_id' => $data['finalityId'],
|
||||
'title' => $data['titre'],
|
||||
'subtitle' => $data['subtitle'],
|
||||
'synopsis' => $data['synopsis'],
|
||||
'file_size_info' => $data['durationInfo'],
|
||||
'baiu_link' => $data['lien'],
|
||||
'license_id' => $data['licenseId'],
|
||||
'author_id' => $authorId,
|
||||
]);
|
||||
|
||||
$identifier = $this->db->getThesisIdentifier($thesisId);
|
||||
error_log("ThesisCreateController: created thesis #$thesisId ($identifier)");
|
||||
|
||||
$this->db->setThesisJury($thesisId, $data['juryMembers']);
|
||||
$this->db->setThesisLanguages($thesisId, $data['languageIds']);
|
||||
$this->db->setThesisFormats($thesisId, $data['formatIds']);
|
||||
$this->db->setThesisTags($thesisId, $data['keywords']);
|
||||
|
||||
$this->db->commit();
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->db->rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// ── 5. File uploads (outside transaction — filesystem ops) ────────────
|
||||
$this->handleCoverUpload($thesisId, $files['couverture'] ?? null);
|
||||
$this->db->handleBannerUpload($thesisId, $files['banner'] ?? null);
|
||||
$this->handleThesisFiles($thesisId, $data['annee'], $identifier, $files['files'] ?? null);
|
||||
|
||||
return $thesisId;
|
||||
}
|
||||
|
||||
// ── WCAG 3.3.1 helper ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map a validation exception message to the name of the field that should
|
||||
* receive autofocus when the form is re-rendered after an error.
|
||||
*
|
||||
* Returns null when no field mapping is found.
|
||||
*/
|
||||
public static function autofocusFieldForError(string $message): ?string
|
||||
{
|
||||
if (str_contains($message, 'Nom/Prénom/Pseudo')) return 'auteurice';
|
||||
if (str_contains($message, 'Titre du mémoire')) return 'titre';
|
||||
if (str_contains($message, 'Synopsis')) return 'synopsis';
|
||||
if (str_contains($message, 'Année invalide')) return 'année';
|
||||
if (str_contains($message, 'orientation')) return 'orientation';
|
||||
if (str_contains($message, 'Atelier Pratique')) return 'ap';
|
||||
if (str_contains($message, 'finalité')) return 'finality';
|
||||
if (str_contains($message, 'langue')) return 'languages';
|
||||
if (str_contains($message, 'mots-clés')) return 'tag';
|
||||
if (str_contains($message, 'Lien URL')) return 'lien';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Private: validation ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate and sanitise all POST fields for a new thesis submission.
|
||||
*
|
||||
* Returns a flat associative array of clean values.
|
||||
*
|
||||
* @param array $post Raw $_POST.
|
||||
* @return array<string, mixed>
|
||||
* @throws Exception on validation failure.
|
||||
*/
|
||||
private function validateAndSanitise(array $post): array
|
||||
{
|
||||
$auteurName = $this->validateRequired(
|
||||
$this->sanitiseString($post['auteurice'] ?? ''),
|
||||
'Nom/Prénom/Pseudo'
|
||||
);
|
||||
|
||||
$mail = !empty($post['mail']) ? $this->sanitiseString($post['mail']) : '';
|
||||
|
||||
$annee = filter_var($post['année'] ?? '', FILTER_VALIDATE_INT);
|
||||
if ($annee === false || $annee < 2000 || $annee > ((int) date('Y') + 1)) {
|
||||
throw new Exception('Année invalide. Veuillez entrer une année valide.');
|
||||
}
|
||||
|
||||
$orientationId = filter_var($post['orientation'] ?? '', FILTER_VALIDATE_INT);
|
||||
if ($orientationId === false) {
|
||||
throw new Exception('Veuillez sélectionner une orientation.');
|
||||
}
|
||||
|
||||
$apProgramId = filter_var($post['ap'] ?? '', FILTER_VALIDATE_INT);
|
||||
if ($apProgramId === false) {
|
||||
throw new Exception('Veuillez sélectionner un Atelier Pratique.');
|
||||
}
|
||||
|
||||
$finalityId = filter_var($post['finality'] ?? '', FILTER_VALIDATE_INT);
|
||||
if ($finalityId === false) {
|
||||
throw new Exception('Veuillez sélectionner une finalité.');
|
||||
}
|
||||
|
||||
$titre = $this->validateRequired($this->sanitiseString($post['titre'] ?? ''), 'Titre du mémoire');
|
||||
$subtitle = $this->sanitiseString($post['subtitle'] ?? '');
|
||||
$synopsis = $this->validateRequired($this->sanitiseString($post['synopsis'] ?? ''), 'Synopsis');
|
||||
|
||||
$durationInfo = $this->sanitiseString($post['duration_info'] ?? '');
|
||||
|
||||
// Jury members
|
||||
$juryMembers = [];
|
||||
if (!empty(trim($post['jury_president'] ?? ''))) {
|
||||
$juryMembers[] = ['name' => trim($post['jury_president']), 'role' => 'president', 'is_external' => 0];
|
||||
}
|
||||
if (!empty(trim($post['jury_promoteur'] ?? ''))) {
|
||||
$juryMembers[] = [
|
||||
'name' => trim($post['jury_promoteur']),
|
||||
'role' => 'promoteur',
|
||||
'is_external' => isset($post['jury_promoteur_ext']) ? 1 : 0,
|
||||
];
|
||||
}
|
||||
foreach ($post['jury_lecteurs'] ?? [] as $i => $name) {
|
||||
$name = trim($name);
|
||||
if ($name !== '') {
|
||||
$juryMembers[] = [
|
||||
'name' => $name,
|
||||
'role' => 'lecteur',
|
||||
'is_external' => isset($post['jury_lecteurs_ext'][$i]) ? 1 : 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Keywords (max 10)
|
||||
$tagRaw = $this->sanitiseString($post['tag'] ?? '');
|
||||
$keywords = $tagRaw !== '' ? array_map('trim', explode(',', $tagRaw)) : [];
|
||||
if (count($keywords) > 10) {
|
||||
throw new Exception('Maximum 10 mots-clés autorisés.');
|
||||
}
|
||||
|
||||
// Languages (at least one required)
|
||||
$languageIds = isset($post['languages']) && is_array($post['languages'])
|
||||
? array_map('intval', $post['languages'])
|
||||
: [];
|
||||
if (empty($languageIds)) {
|
||||
throw new Exception('Veuillez sélectionner au moins une langue.');
|
||||
}
|
||||
|
||||
// Formats (optional)
|
||||
$formatIds = isset($post['formats']) && is_array($post['formats'])
|
||||
? array_map('intval', $post['formats'])
|
||||
: [];
|
||||
|
||||
$licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
|
||||
|
||||
// External link (optional)
|
||||
$lien = '';
|
||||
if (!empty($post['lien'])) {
|
||||
$lien = filter_var($post['lien'], FILTER_VALIDATE_URL);
|
||||
if ($lien === false) {
|
||||
throw new Exception('Lien URL invalide.');
|
||||
}
|
||||
}
|
||||
|
||||
return compact(
|
||||
'auteurName', 'mail', 'annee', 'orientationId', 'apProgramId',
|
||||
'finalityId', 'titre', 'subtitle', 'synopsis', 'durationInfo',
|
||||
'juryMembers', 'keywords', 'languageIds', 'formatIds',
|
||||
'licenseId', 'lien'
|
||||
);
|
||||
}
|
||||
|
||||
// ── Private: file uploads ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Process an optional cover image upload and record it in thesis_files.
|
||||
*
|
||||
* @param int $thesisId
|
||||
* @param array|null $upload Single-file $_FILES entry (may be null or have UPLOAD_ERR_NO_FILE).
|
||||
*/
|
||||
private function handleCoverUpload(int $thesisId, ?array $upload): void
|
||||
{
|
||||
if (!$upload || !isset($upload['error']) || $upload['error'] !== UPLOAD_ERR_OK) {
|
||||
return;
|
||||
}
|
||||
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($upload['tmp_name']);
|
||||
$ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION));
|
||||
|
||||
if (!in_array($mimeType, ['image/jpeg', 'image/png'], true)
|
||||
|| !in_array($ext, ['jpg', 'jpeg', 'png'], true)) {
|
||||
error_log("ThesisCreateController: invalid cover MIME $mimeType, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
$coverDir = STORAGE_ROOT . '/covers/';
|
||||
if (!is_dir($coverDir)) {
|
||||
mkdir($coverDir, 0755, true);
|
||||
}
|
||||
|
||||
$safeName = bin2hex(random_bytes(16)) . '.' . $ext;
|
||||
$targetPath = $coverDir . $safeName;
|
||||
|
||||
if (!move_uploaded_file($upload['tmp_name'], $targetPath)) {
|
||||
error_log("ThesisCreateController: failed to move cover to $targetPath");
|
||||
return;
|
||||
}
|
||||
|
||||
chmod($targetPath, 0644);
|
||||
$relPath = 'covers/' . $safeName;
|
||||
|
||||
$this->db->insertThesisFile($thesisId, 'cover', $relPath, basename($upload['name']), $upload['size'], $mimeType);
|
||||
error_log("ThesisCreateController: cover uploaded → $safeName");
|
||||
}
|
||||
|
||||
/**
|
||||
* Process multiple thesis-file uploads (PDFs, images, videos, ZIPs, VTTs).
|
||||
*
|
||||
* @param int $thesisId
|
||||
* @param int $year Used for the storage sub-directory path.
|
||||
* @param string $identifier Thesis identifier slug (e.g. "2024-003").
|
||||
* @param array|null $uploads Multi-file $_FILES entry (may be null).
|
||||
*/
|
||||
private function handleThesisFiles(int $thesisId, int $year, string $identifier, ?array $uploads): void
|
||||
{
|
||||
if (!$uploads || !is_array($uploads['name'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uploadDir = STORAGE_ROOT . "/theses/{$year}/{$identifier}/";
|
||||
if (!is_dir($uploadDir)) {
|
||||
mkdir($uploadDir, 0755, true);
|
||||
}
|
||||
|
||||
$count = count($uploads['name']);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($uploads['error'][$i] ?? -1) !== UPLOAD_ERR_OK) {
|
||||
error_log("ThesisCreateController: upload error code {$uploads['error'][$i]} for {$uploads['name'][$i]}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($uploads['tmp_name'][$i]);
|
||||
$ext = strtolower(pathinfo($uploads['name'][$i], PATHINFO_EXTENSION));
|
||||
|
||||
// finfo may return 'text/plain' for WebVTT on some systems.
|
||||
if ($mimeType === 'text/plain' && $ext === 'vtt') {
|
||||
$mimeType = 'text/vtt';
|
||||
}
|
||||
|
||||
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)
|
||||
|| !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||||
error_log("ThesisCreateController: invalid file type {$uploads['name'][$i]} ($mimeType), skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($uploads['size'][$i] > self::MAX_FILE_SIZE) {
|
||||
error_log("ThesisCreateController: file too large {$uploads['name'][$i]}, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
$safeName = bin2hex(random_bytes(16)) . '.' . $ext;
|
||||
$targetPath = $uploadDir . $safeName;
|
||||
|
||||
if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) {
|
||||
error_log("ThesisCreateController: failed to move file {$uploads['name'][$i]}");
|
||||
continue;
|
||||
}
|
||||
|
||||
chmod($targetPath, 0644);
|
||||
|
||||
$fileType = 'other';
|
||||
if ($ext === 'vtt') {
|
||||
$fileType = 'caption';
|
||||
} elseif (stripos($uploads['name'][$i], 'annex') !== false) {
|
||||
$fileType = 'annex';
|
||||
} elseif ($ext === 'pdf') {
|
||||
$fileType = 'main';
|
||||
}
|
||||
|
||||
$relPath = "theses/{$year}/{$identifier}/" . $safeName;
|
||||
$this->db->insertThesisFile(
|
||||
$thesisId,
|
||||
$fileType,
|
||||
$relPath,
|
||||
basename($uploads['name'][$i]),
|
||||
$uploads['size'][$i],
|
||||
$mimeType
|
||||
);
|
||||
error_log("ThesisCreateController: file uploaded → $safeName ($fileType)");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private: input helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Trim and strip HTML tags from a string value.
|
||||
* htmlspecialchars is applied at render time, not here.
|
||||
*/
|
||||
private function sanitiseString(string $input): string
|
||||
{
|
||||
return strip_tags(trim($input));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a string value is non-empty.
|
||||
*
|
||||
* @throws Exception if $value is empty.
|
||||
*/
|
||||
private function validateRequired(string $value, string $fieldName): string
|
||||
{
|
||||
if ($value === '') {
|
||||
throw new Exception("Le champ '$fieldName' est requis.");
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user