mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
- add exemplaire_baiu, exemplaire_erg, cc4r, remarks; - add is_ulb to jury; - split jury_lecteurs into interne/externe in view; - refactor admin edit form with backoffice fields; - update public fiche to show promoteur ULB and split lecteurs
703 lines
28 KiB
PHP
703 lines
28 KiB
PHP
<?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 = 500 * 1024 * 1024; // 500 MB
|
||
|
||
/** MIME types accepted for thesis files. */
|
||
private const ALLOWED_MIME_TYPES = [
|
||
// Images
|
||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||
// Documents
|
||
'application/pdf',
|
||
// Video
|
||
'video/mp4', 'video/webm', 'video/ogg', 'video/quicktime',
|
||
// Audio
|
||
'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav',
|
||
'audio/flac', 'audio/aac', 'audio/x-m4a', 'audio/mp4',
|
||
// Captions
|
||
'text/vtt',
|
||
// Archives / other downloadables
|
||
'application/zip', 'application/x-zip-compressed',
|
||
'application/x-tar', 'application/gzip',
|
||
'application/octet-stream',
|
||
];
|
||
|
||
/** File extensions accepted for thesis files. */
|
||
private const ALLOWED_EXTENSIONS = [
|
||
// Images
|
||
'jpg', 'jpeg', 'png', 'gif', 'webp',
|
||
// Documents
|
||
'pdf',
|
||
// Video
|
||
'mp4', 'webm', 'ogv', 'mov',
|
||
// Audio
|
||
'mp3', 'ogg', 'oga', 'wav', 'flac', 'aac', 'm4a',
|
||
// Captions
|
||
'vtt',
|
||
// Archives / other
|
||
'zip', 'tar', 'gz', 'tgz',
|
||
];
|
||
|
||
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());
|
||
}
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Get the identifier string for a thesis (caller supplies ID).
|
||
*/
|
||
public function getIdentifier(int $thesisId): string
|
||
{
|
||
return $this->db->getThesisIdentifier($thesisId);
|
||
}
|
||
|
||
// ── 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(),
|
||
'enabledAccessTypes' => $this->db->getEnabledFormAccessTypes(),
|
||
];
|
||
}
|
||
|
||
// ── Write / action ────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Validate and persist a new-thesis POST submission.
|
||
*
|
||
* On success, returns the new thesis ID so the caller can redirect to
|
||
* recapitulatif.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);
|
||
|
||
// ── 1b. Duplicate detection ───────────────────────────────────────────
|
||
require_once APP_ROOT . '/src/DuplicateThesisException.php';
|
||
$duplicate = $this->db->findDuplicateThesis($data['titre'], $data['authorNames'], $data['annee']);
|
||
if ($duplicate !== null) {
|
||
throw new DuplicateThesisException(
|
||
$duplicate['id'],
|
||
$duplicate['identifier'],
|
||
$duplicate['title'],
|
||
$duplicate['author'],
|
||
$duplicate['year']
|
||
);
|
||
}
|
||
|
||
// ── 2. Build author entries (alphabetically sorted) ───────────────────
|
||
$authorEntries = [];
|
||
foreach ($data['authorNames'] as $i => $name) {
|
||
$authorEntries[] = [
|
||
'name' => $name,
|
||
'email' => $i === 0 ? ($data['mail'] ?: null) : null,
|
||
'show_contact' => $i === 0 ? $data['showContact'] : false,
|
||
];
|
||
}
|
||
$allAuthorsStr = implode(', ', $data['authorNames']);
|
||
$authorSlug = $this->generateAuthorSlug($allAuthorsStr);
|
||
|
||
// ── 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'],
|
||
'access_type_id' => $data['accessTypeId'],
|
||
'objet' => $data['objet'],
|
||
]);
|
||
|
||
$identifier = $this->db->getThesisIdentifier($thesisId);
|
||
error_log("ThesisCreateController: created thesis #$thesisId ($identifier) with " . count($authorEntries) . " author(s)");
|
||
|
||
$this->db->setThesisAuthors($thesisId, $authorEntries);
|
||
$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, $authorSlug, $post);
|
||
|
||
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';
|
||
}
|
||
if (str_contains($message, 'e-mail de confirmation')) {
|
||
return 'confirmation_email';
|
||
}
|
||
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
|
||
{
|
||
// Split authors by comma, trim, filter empty, sort alphabetically.
|
||
$authorRaw = $this->sanitiseString($post['auteurice'] ?? '');
|
||
$authorNames = [];
|
||
if ($authorRaw !== '') {
|
||
$authorNames = array_filter(array_map('trim', explode(',', $authorRaw)), fn($n) => $n !== '');
|
||
$authorNames = array_values($authorNames);
|
||
sort($authorNames, SORT_NATURAL);
|
||
}
|
||
if (empty($authorNames)) {
|
||
throw new Exception("Le champ 'Nom/Prénom/Pseudo' est requis.");
|
||
}
|
||
|
||
$mail = !empty($post['mail']) ? $this->sanitiseString($post['mail']) : '';
|
||
$showContact = !empty($post['contact_public']) ? true : false;
|
||
|
||
$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,
|
||
'is_ulb' => isset($post['jury_promoteur_ulb']) ? 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;
|
||
|
||
// Access type — must be one of the enabled types; default 2 (Interne)
|
||
$accessTypeId = filter_var($post['access_type_id'] ?? '', FILTER_VALIDATE_INT);
|
||
if ($accessTypeId === false || $accessTypeId <= 0) {
|
||
$accessTypeId = 2; // Interne
|
||
}
|
||
|
||
// Objet — restricted to valid values
|
||
$validObjet = ['tfe', 'thèse', 'frart'];
|
||
$objet = in_array($post['objet'] ?? '', $validObjet, true) ? $post['objet'] : 'tfe';
|
||
|
||
// 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.');
|
||
}
|
||
}
|
||
|
||
// Confirmation e-mail (optional)
|
||
$confirmationEmail = trim($post['confirmation_email'] ?? '');
|
||
if ($confirmationEmail !== '') {
|
||
$confirmationEmail = filter_var($confirmationEmail, FILTER_VALIDATE_EMAIL);
|
||
if ($confirmationEmail === false) {
|
||
throw new Exception("L'adresse e-mail de confirmation n'est pas valide.");
|
||
}
|
||
}
|
||
|
||
return compact(
|
||
'authorNames',
|
||
'mail',
|
||
'showContact',
|
||
'confirmationEmail',
|
||
'annee',
|
||
'orientationId',
|
||
'apProgramId',
|
||
'finalityId',
|
||
'titre',
|
||
'subtitle',
|
||
'synopsis',
|
||
'durationInfo',
|
||
'juryMembers',
|
||
'keywords',
|
||
'languageIds',
|
||
'formatIds',
|
||
'licenseId',
|
||
'lien',
|
||
'accessTypeId',
|
||
'objet'
|
||
);
|
||
}
|
||
|
||
// ── 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).
|
||
* @param string $authorSlug Pre-computed author slug for folder and file naming.
|
||
*/
|
||
private function handleThesisFiles(int $thesisId, int $year, string $identifier, ?array $uploads, string $authorSlug, array $post = []): void
|
||
{
|
||
if (!$uploads || !is_array($uploads['name'] ?? null)) {
|
||
return;
|
||
}
|
||
|
||
$folderName = $this->ensureUniqueFolder($year, $authorSlug);
|
||
$uploadDir = STORAGE_ROOT . "/theses/{$year}/{$folderName}/";
|
||
|
||
if (!is_dir($uploadDir)) {
|
||
mkdir($uploadDir, 0755, true);
|
||
}
|
||
|
||
// Per-file labels and sort orders submitted alongside the upload inputs
|
||
$fileLabels = $post['file_labels'] ?? [];
|
||
$fileOrders = $post['file_orders'] ?? [];
|
||
|
||
$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';
|
||
}
|
||
// application/octet-stream is a valid fallback for arbitrary downloadable files
|
||
if ($mimeType === 'application/octet-stream' && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||
error_log("ThesisCreateController: extension not allowed {$uploads['name'][$i]} ($ext), skipping");
|
||
continue;
|
||
}
|
||
|
||
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 / $ext), skipping");
|
||
continue;
|
||
}
|
||
|
||
if ($uploads['size'][$i] > self::MAX_FILE_SIZE) {
|
||
error_log("ThesisCreateController: file too large {$uploads['name'][$i]}, skipping");
|
||
continue;
|
||
}
|
||
|
||
// Sanitize original filename and prepend author slug
|
||
$originalName = $uploads['name'][$i];
|
||
$sanitized = $this->sanitizeFilename($originalName);
|
||
$prefix = $authorSlug . '_' . $sanitized;
|
||
// Ensure unique filename in the folder
|
||
$candidate = $prefix;
|
||
$suffix = 1;
|
||
while (file_exists($uploadDir . $candidate)) {
|
||
$candidate = $authorSlug . '_' . pathinfo($sanitized, PATHINFO_FILENAME) . '_' . $suffix . '.' . $ext;
|
||
$suffix++;
|
||
}
|
||
$targetName = $candidate;
|
||
$targetPath = $uploadDir . $targetName;
|
||
|
||
if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) {
|
||
error_log("ThesisCreateController: failed to move file {$originalName}");
|
||
continue;
|
||
}
|
||
|
||
chmod($targetPath, 0644);
|
||
|
||
$fileType = $this->detectFileType($mimeType, $ext, $originalName);
|
||
|
||
$label = trim($fileLabels[$i] ?? '');
|
||
$sortOrder = isset($fileOrders[$i]) ? (int)$fileOrders[$i] : null;
|
||
|
||
$relPath = "theses/{$year}/{$folderName}/" . $targetName;
|
||
$this->db->insertThesisFile(
|
||
$thesisId,
|
||
$fileType,
|
||
$relPath,
|
||
basename($originalName),
|
||
$uploads['size'][$i],
|
||
$mimeType,
|
||
$label !== '' ? $label : null,
|
||
$sortOrder
|
||
);
|
||
error_log("ThesisCreateController: file uploaded → $targetName ($fileType)");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Determine the logical file_type from MIME type, extension, and original filename.
|
||
*/
|
||
private function detectFileType(string $mimeType, string $ext, string $originalName): string
|
||
{
|
||
if ($ext === 'vtt' || $mimeType === 'text/vtt') {
|
||
return 'caption';
|
||
}
|
||
if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
|
||
return 'audio';
|
||
}
|
||
if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) {
|
||
return 'video';
|
||
}
|
||
if ($mimeType === 'application/pdf' || $ext === 'pdf') {
|
||
return 'main';
|
||
}
|
||
if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
|
||
return 'image';
|
||
}
|
||
return 'other';
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
|
||
/**
|
||
* Generate a filesystem-safe author slug from the author name.
|
||
* Converts to uppercase, replaces spaces with underscores, removes accents.
|
||
*/
|
||
private function generateAuthorSlug(string $authorName): string
|
||
{
|
||
// Remove accents using iconv if available, otherwise simple mapping
|
||
$normalized = $authorName;
|
||
if (function_exists('iconv')) {
|
||
$normalized = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
|
||
}
|
||
// Fallback accent removal for common French characters
|
||
$accents = [
|
||
'à' => 'a', 'â' => 'a', 'ä' => 'a',
|
||
'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
|
||
'î' => 'i', 'ï' => 'i',
|
||
'ô' => 'o', 'ö' => 'o',
|
||
'ù' => 'u', 'û' => 'u', 'ü' => 'u',
|
||
'ç' => 'c',
|
||
'À' => 'A', 'Â' => 'A', 'Ä' => 'A',
|
||
'É' => 'E', 'È' => 'E', 'Ê' => 'E', 'Ë' => 'E',
|
||
'Î' => 'I', 'Ï' => 'I',
|
||
'Ô' => 'O', 'Ö' => 'O',
|
||
'Ù' => 'U', 'Û' => 'U', 'Ü' => 'U',
|
||
'Ç' => 'C',
|
||
];
|
||
$normalized = strtr($normalized, $accents);
|
||
// Replace spaces and punctuation with underscore, keep only alphanumeric and underscore
|
||
$slug = preg_replace('/[^A-Za-z0-9]+/', '_', $normalized);
|
||
$slug = trim($slug, '_');
|
||
// Convert to uppercase
|
||
$slug = strtoupper($slug);
|
||
// Ensure not empty
|
||
if ($slug === '') {
|
||
$slug = 'AUTHOR';
|
||
}
|
||
return $slug;
|
||
}
|
||
|
||
/**
|
||
* Sanitize a filename: remove accents, replace spaces with underscore, remove special chars.
|
||
* Keeps extension.
|
||
*/
|
||
private function sanitizeFilename(string $filename): string
|
||
{
|
||
$ext = pathinfo($filename, PATHINFO_EXTENSION);
|
||
$name = pathinfo($filename, PATHINFO_FILENAME);
|
||
// Remove accents similarly
|
||
$normalized = $name;
|
||
if (function_exists('iconv')) {
|
||
$normalized = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
|
||
}
|
||
$accents = [
|
||
'à' => 'a', 'â' => 'a', 'ä' => 'a',
|
||
'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
|
||
'î' => 'i', 'ï' => 'i',
|
||
'ô' => 'o', 'ö' => 'o',
|
||
'ù' => 'u', 'û' => 'u', 'ü' => 'u',
|
||
'ç' => 'c',
|
||
];
|
||
$normalized = strtr($normalized, $accents);
|
||
// Replace non-alphanumeric with underscore
|
||
$normalized = preg_replace('/[^A-Za-z0-9]+/', '_', $normalized);
|
||
$normalized = trim($normalized, '_');
|
||
// If empty, use 'file'
|
||
if ($normalized === '') {
|
||
$normalized = 'file';
|
||
}
|
||
// Reattach extension if any
|
||
if ($ext !== '') {
|
||
return $normalized . '.' . strtolower($ext);
|
||
}
|
||
return $normalized;
|
||
}
|
||
|
||
/**
|
||
* Find a unique folder name inside theses/{year}/.
|
||
* Pattern: {year}_{authorSlug} or {year}_{authorSlug}_{suffix} if exists.
|
||
*/
|
||
private function ensureUniqueFolder(int $year, string $authorSlug): string
|
||
{
|
||
$baseDir = STORAGE_ROOT . '/theses/' . $year . '/';
|
||
if (!is_dir($baseDir)) {
|
||
// No conflict possible, return base name
|
||
return $year . '_' . $authorSlug;
|
||
}
|
||
$candidate = $year . '_' . $authorSlug;
|
||
$suffix = 1;
|
||
while (is_dir($baseDir . $candidate)) {
|
||
$candidate = $year . '_' . $authorSlug . '_' . $suffix;
|
||
$suffix++;
|
||
}
|
||
return $candidate;
|
||
}
|
||
}
|