mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
fix: add help email, preserve file names on validation error, license fix
The share link (partage) form does not expose a license field and does not send access_type_id (defaults to 2/Interne). Server-side validation was unconditionally requiring a license for non-admin submissions, causing all share link submissions to fail. Now the license check is gated on adminMode=false AND accessTypeId=1 (Libre), matching the client-side HTMX fragment behaviour in licence-fragment.php. Also fixed a use-before-definition where accessTypeId was referenced before being assigned. Student form improvements: - Add xamxam@erg.be mailto link at top of form - On validation error, append "Si le problème persiste, envoyez un e-mail à xamxam@erg.be" to the flash message - Preserve uploaded file names across validation redirects: store in session (share_primed_files_<slug>), display as warning on form re-render so the student knows which files to re-select - License: only required for non-admin when access_type_id=1 (Libre), not for Interne (2) or Interdit (3). Fixes share link submissions failing with "Veuillez sélectionner une licence". Also fixed use-before-definition of accessTypeId.
This commit is contained in:
22
TODO.md
22
TODO.md
@@ -4,3 +4,25 @@
|
||||
- [x] Both fragments now follow identical patterns
|
||||
- [x] Fix "Créer" button not appearing on language search: both language and tag inputs used name="q" in the same form, causing HTMX to submit the wrong (empty) value — renamed to unique names (language_search_q / tag_search_q)
|
||||
- [x] Exclude Français, Anglais, Néerlandais from language-search suggestions (handled by the checkbox list)
|
||||
- [x] Refactor file upload naming convention
|
||||
- [x] Create shared ThesisFileHandler trait (src/Controllers/ThesisFileHandler.php)
|
||||
- [x] New pattern: theses/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
|
||||
- [x] COUVERTURE: single cover image in thesis folder (covers/ directory deprecated)
|
||||
- [x] NOTE_INTENTION: single PDF in thesis folder
|
||||
- [x] TFE_{XX}: main files, contiguous numbering 01+, hierarchy PDF > video > audio > subtitles > images > other
|
||||
- [x] Subtitles (VTT) placed immediately after their associated video in TFE sequence
|
||||
- [x] ANNEXE_{XX}: annex files, separate numbering 01+
|
||||
- [x] Two-digit zero-padded numbering (sprintf('%02d', ...))
|
||||
- [x] Update ThesisCreateController.php: use trait, new file handling
|
||||
- [x] Update ThesisEditController.php: use trait, new file handling
|
||||
- [x] Remove duplicate methods (generateAuthorSlug, sanitizeFilename, etc.) from both controllers
|
||||
- [x] Update Database.php: deprecate handleCoverUpload, remove banner_path from queries
|
||||
- [x] Update SystemController.php: remove banners/ stats
|
||||
- [x] Update schema.sql: remove banner_path column and view field
|
||||
- [x] Create migration 027_drop_banner_path.sql
|
||||
- [x] Update PureLogicTest.php: adapt detectFileType call signature
|
||||
- [x] All pure logic tests pass
|
||||
- [x] Fix license validation: only require license for non-admin when access_type_id=1 (Libre), not for Interne (2) or Interdit (3) — fixes share link submissions failing with "Veuillez sélectionner une licence"
|
||||
- [x] Add xamxam@erg.be mailto link at top of student (partage) form
|
||||
- [x] On validation error, append "envoyez un e-mail à xamxam@erg.be" to flash error message
|
||||
- [x] Preserve uploaded file names across validation redirects: store in session, display as warning on re-render so the student knows which files to re-select
|
||||
|
||||
10
app/migrations/pending/027_drop_banner_path.sql
Normal file
10
app/migrations/pending/027_drop_banner_path.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Migration 027: drop banner_path column from theses table.
|
||||
-- Banners were merged into covers in migration 016; the column has been
|
||||
-- vestigial since. This is safe to run even if the column is already absent.
|
||||
-- Safe to re-run: IF EXISTS makes it idempotent.
|
||||
|
||||
-- SQLite does not support DROP COLUMN directly in older versions;
|
||||
-- we use the ALTER TABLE … DROP COLUMN syntax (supported since SQLite 3.35.0).
|
||||
-- If this fails on an older SQLite, the column stays as-is (harmless).
|
||||
|
||||
ALTER TABLE theses DROP COLUMN banner_path;
|
||||
@@ -378,6 +378,27 @@ function renderShareLinkForm(string $slug, array $link): void
|
||||
<p class="thesis-add-subtitle">Formulaire pour <a href="/">XAMXAM</a></p>
|
||||
</div>
|
||||
|
||||
<div class="share-help-contact">
|
||||
<p>Des questions ou un problème avec le formulaire ?
|
||||
<a href="mailto:xamxam@erg.be">xamxam@erg.be</a></p>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
// Show previously-selected files that were lost during validation redirect
|
||||
$primedFiles = $_SESSION['share_primed_files_' . $slug] ?? null;
|
||||
unset($_SESSION['share_primed_files_' . $slug]);
|
||||
if ($primedFiles && count($primedFiles) > 0): ?>
|
||||
<div class="flash-warning" role="alert">
|
||||
<p>⚠️ Les fichiers suivants avaient été sélectionnés avant l'erreur de validation.
|
||||
Veuillez les sélectionner à nouveau :</p>
|
||||
<ul>
|
||||
<?php foreach ($primedFiles as $pf): ?>
|
||||
<li><?= htmlspecialchars($pf) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php include APP_ROOT . '/templates/partials/form/form.php'; ?>
|
||||
</main>
|
||||
</body>
|
||||
@@ -507,8 +528,9 @@ function handleShareLinkSubmission(string $slug): void
|
||||
// Store as plain text — htmlspecialchars() is applied at render time.
|
||||
$_SESSION['_flash_warning'] = 'Votre soumission ressemble à un TFE déjà enregistré.'
|
||||
. "\n" . $e->existingIdentifier . ' — ' . $e->existingTitle . ' (' . $e->existingYear . ')'
|
||||
. "\nSi vous pensez qu'il s'agit d'une erreur, veuillez contacter l'équipe.";
|
||||
. "\nSi vous pensez qu'il s'agit d'une erreur, vous pouvez contacter l'équipe à xamxam@erg.be.";
|
||||
$_SESSION['form_data_share_' . $slug] = $_POST;
|
||||
storePrimedFiles($slug);
|
||||
$_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32)); // Regenerate token
|
||||
|
||||
header('Location: /partage/' . urlencode($slug));
|
||||
@@ -522,8 +544,10 @@ function handleShareLinkSubmission(string $slug): void
|
||||
]);
|
||||
ErrorHandler::log('partage_submit', $e, ['slug' => $slug, 'author' => $authorName]);
|
||||
|
||||
$_SESSION['_flash_error'] = ErrorHandler::userMessage($e);
|
||||
$_SESSION['_flash_error'] = ErrorHandler::userMessage($e)
|
||||
. "\n\nSi le problème persiste, envoyez un e-mail à xamxam@erg.be.";
|
||||
$_SESSION['form_data_share_' . $slug] = $_POST;
|
||||
storePrimedFiles($slug);
|
||||
$_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32)); // Regenerate token
|
||||
|
||||
// Redirect back to the form
|
||||
@@ -549,3 +573,48 @@ function old(array $data, string $key, string $default = ''): string {
|
||||
}
|
||||
return is_array($value) ? htmlspecialchars(json_encode($value)) : htmlspecialchars((string)$value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store names of uploaded files in session so they can be shown
|
||||
* to the user on form re-render after a validation error.
|
||||
*
|
||||
* $_FILES is not preserved across redirects, but the user should know
|
||||
* which files they need to re-select.
|
||||
*/
|
||||
function storePrimedFiles(string $slug): void
|
||||
{
|
||||
$names = [];
|
||||
if (!empty($_FILES['couverture']['name'])) {
|
||||
$names[] = 'Couverture : ' . $_FILES['couverture']['name'];
|
||||
}
|
||||
if (!empty($_FILES['note_intention']['name'])) {
|
||||
$names[] = 'Note d\'intention : ' . $_FILES['note_intention']['name'];
|
||||
}
|
||||
if (!empty($_FILES['files']['name']) && is_array($_FILES['files']['name'])) {
|
||||
foreach ($_FILES['files']['name'] as $name) {
|
||||
if ($name !== '' && $name !== null) {
|
||||
$names[] = 'TFE : ' . $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!empty($_FILES['annexes']['name'])) {
|
||||
if (is_array($_FILES['annexes']['name'])) {
|
||||
foreach ($_FILES['annexes']['name'] as $name) {
|
||||
if ($name !== '' && $name !== null) {
|
||||
$names[] = 'Annexe : ' . $name;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$names[] = 'Annexe : ' . $_FILES['annexes']['name'];
|
||||
}
|
||||
}
|
||||
if (!empty($_FILES['peertube_video']['name'])) {
|
||||
$names[] = 'Vidéo PeerTube : ' . $_FILES['peertube_video']['name'];
|
||||
}
|
||||
if (!empty($_FILES['peertube_audio']['name'])) {
|
||||
$names[] = 'Audio PeerTube : ' . $_FILES['peertube_audio']['name'];
|
||||
}
|
||||
if (!empty($names)) {
|
||||
$_SESSION['share_primed_files_' . $slug] = $names;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,14 +375,12 @@ class SystemController
|
||||
// Storage directory
|
||||
$storageDir = APP_ROOT . '/storage';
|
||||
$storageWritable = is_dir($storageDir) && is_writable($storageDir);
|
||||
$bannersDir = $storageDir . '/banners';
|
||||
$coversDir = $storageDir . '/covers';
|
||||
$checks['storage'] = [
|
||||
'label' => 'Répertoire storage',
|
||||
'status' => $storageWritable ? 'active' : (is_dir($storageDir) ? 'inactive' : 'failed'),
|
||||
'detail' => $storageWritable
|
||||
? implode(' · ', array_filter([
|
||||
is_dir($bannersDir) ? ('banners/ ' . count(array_diff(scandir($bannersDir), ['.','..'])) . ' fichiers') : null,
|
||||
is_dir($coversDir) ? ('covers/ ' . count(array_diff(scandir($coversDir), ['.','..'])) . ' fichiers') : null,
|
||||
]))
|
||||
: 'Non accessible en écriture',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/ThesisFileHandler.php';
|
||||
|
||||
/**
|
||||
* ThesisCreateController
|
||||
*
|
||||
@@ -11,7 +13,7 @@
|
||||
* - 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
|
||||
* - Handling cover image, note d'intention, TFE, annexe, and PeerTube 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
|
||||
@@ -20,46 +22,7 @@
|
||||
*/
|
||||
class ThesisCreateController
|
||||
{
|
||||
/** Maximum allowed file size for thesis files (bytes). */
|
||||
private const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
|
||||
|
||||
/** Maximum allowed file size for PDF files specifically (bytes). */
|
||||
private const MAX_PDF_SIZE = 100 * 1024 * 1024; // 100 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',
|
||||
];
|
||||
use ThesisFileHandler;
|
||||
|
||||
private Database $db;
|
||||
|
||||
@@ -171,7 +134,6 @@ class ThesisCreateController
|
||||
];
|
||||
}
|
||||
$allAuthorsStr = implode(', ', $data['authorNames']);
|
||||
$authorSlug = $this->generateAuthorSlug($allAuthorsStr);
|
||||
|
||||
// ── 3–4. DB writes in a transaction ───────────────────────────────────
|
||||
$this->db->beginTransaction();
|
||||
@@ -226,8 +188,16 @@ class ThesisCreateController
|
||||
}
|
||||
|
||||
// ── 5. File uploads (outside transaction — filesystem ops) ────────────
|
||||
$this->handleCoverUpload($thesisId, $files['couverture'] ?? null);
|
||||
$this->handleThesisFiles($thesisId, $data['annee'], $identifier, $files['files'] ?? null, $authorSlug, $post);
|
||||
$tf = $this->buildThesisFolder($data['annee'], $allAuthorsStr, $data['titre']);
|
||||
$folderName = $this->ensureUniqueFolder($tf['folderPath']);
|
||||
// Rebuild path with potentially modified folder name
|
||||
$folderPath = 'theses/' . $data['annee'] . '/' . $folderName . '/';
|
||||
$filePrefix = $folderName;
|
||||
|
||||
$this->handleCoverUpload($thesisId, $files['couverture'] ?? null, $folderPath, $filePrefix);
|
||||
$this->handleNoteIntentionUpload($thesisId, $files['note_intention'] ?? null, $folderPath, $filePrefix);
|
||||
$nextNum = $this->handleTfeFiles($thesisId, $files['files'] ?? null, $folderPath, $filePrefix, $post, 1);
|
||||
// PeerTube file rows don't go on disk, but the uploads themselves are processed separately
|
||||
|
||||
// ── 5b. PeerTube video / audio uploads ────────────────────────────────
|
||||
$this->handlePeerTubeUpload($thesisId, $data['titre'], $files, 'peertube_video');
|
||||
@@ -495,18 +465,20 @@ class ThesisCreateController
|
||||
throw new Exception('Veuillez sélectionner au moins un format.');
|
||||
}
|
||||
|
||||
$licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
|
||||
$licenseCustom = trim($post['license_custom'] ?? '');
|
||||
if (!$adminMode && !$licenseId && $licenseCustom === '') {
|
||||
throw new Exception('Veuillez sélectionner une licence ou en préciser une.');
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
$licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
|
||||
$licenseCustom = trim($post['license_custom'] ?? '');
|
||||
// License is only required for non-admin when access type is Libre (1).
|
||||
// For Interne (2) and Interdit (3), it's optional.
|
||||
if (!$adminMode && $accessTypeId === 1 && !$licenseId && $licenseCustom === '') {
|
||||
throw new Exception('Veuillez sélectionner une licence ou en préciser une.');
|
||||
}
|
||||
|
||||
// Objet — restricted to valid values
|
||||
$validObjet = ['tfe', 'thèse', 'frart'];
|
||||
$objet = in_array($post['objet'] ?? '', $validObjet, true) ? $post['objet'] : 'tfe';
|
||||
@@ -570,176 +542,6 @@ class ThesisCreateController
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
$isPdf = ($mimeType === 'application/pdf' || $ext === 'pdf');
|
||||
$sizeLimit = $isPdf ? self::MAX_PDF_SIZE : self::MAX_FILE_SIZE;
|
||||
if ($uploads['size'][$i] > $sizeLimit) {
|
||||
error_log("ThesisCreateController: file too large {$uploads['name'][$i]} (" . round($uploads['size'][$i] / 1024 / 1024) . ' MB), 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.
|
||||
*/
|
||||
protected 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 ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -765,101 +567,6 @@ class ThesisCreateController
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a filesystem-safe author slug from the author name.
|
||||
* Converts to uppercase, replaces spaces with underscores, removes accents.
|
||||
*/
|
||||
protected 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.
|
||||
*/
|
||||
protected 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.
|
||||
*/
|
||||
protected 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a video or audio file to PeerTube when the feature is enabled.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/ThesisFileHandler.php';
|
||||
|
||||
/**
|
||||
* ThesisEditController
|
||||
*
|
||||
@@ -18,6 +20,8 @@
|
||||
*/
|
||||
class ThesisEditController
|
||||
{
|
||||
use ThesisFileHandler;
|
||||
|
||||
private Database $db;
|
||||
|
||||
public function __construct(Database $db)
|
||||
@@ -305,10 +309,39 @@ class ThesisEditController
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// ── Resolve thesis folder path (reuse existing or build new) ────────
|
||||
$year = intval($post['année'] ?? date('Y'));
|
||||
$title = trim($post['titre'] ?? '');
|
||||
$authors = trim($post['auteurice'] ?? '');
|
||||
|
||||
$tf = $this->buildThesisFolder($year, $authors, $title);
|
||||
$folderPath = $tf['folderPath'];
|
||||
$filePrefix = $tf['filePrefix'];
|
||||
|
||||
// Reuse existing folder if this thesis already has files on disk
|
||||
$existingFiles = $this->db->getThesisFiles($thesisId);
|
||||
foreach ($existingFiles as $f) {
|
||||
$fp = $f['file_path'] ?? '';
|
||||
if (str_starts_with($fp, 'theses/')) {
|
||||
$parts = explode('/', $fp);
|
||||
if (count($parts) >= 3) {
|
||||
$folderName = $parts[2];
|
||||
$folderPath = 'theses/' . $year . '/' . $folderName . '/';
|
||||
$filePrefix = $folderName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the folder exists
|
||||
$dirAbs = STORAGE_ROOT . '/' . $folderPath;
|
||||
if (!is_dir($dirAbs)) {
|
||||
mkdir($dirAbs, 0755, true);
|
||||
}
|
||||
|
||||
// ── Cover image (outside transaction — filesystem op) ─────────────────
|
||||
if (isset($post['remove_cover'])) {
|
||||
$allFiles = $this->db->getThesisFiles($thesisId);
|
||||
foreach ($allFiles as $f) {
|
||||
foreach ($existingFiles as $f) {
|
||||
if ($f['file_type'] === 'cover') {
|
||||
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
|
||||
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
|
||||
@@ -321,9 +354,27 @@ class ThesisEditController
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->db->handleCoverUpload($thesisId, $files['couverture'] ?? null);
|
||||
$this->handleCoverUpload($thesisId, $files['couverture'] ?? null, $folderPath, $filePrefix);
|
||||
}
|
||||
|
||||
// ── Note d'intention (replace if uploaded) ────────────────────────────
|
||||
// Remove old note_intention row+file if new one is uploaded
|
||||
if (!empty($files['note_intention']['tmp_name'] ?? null) && ($files['note_intention']['error'] ?? -1) === UPLOAD_ERR_OK) {
|
||||
foreach ($existingFiles as $f) {
|
||||
if ($f['file_type'] === 'note_intention') {
|
||||
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
|
||||
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
|
||||
$abs = STORAGE_ROOT . '/' . $f['file_path'];
|
||||
if (file_exists($abs)) {
|
||||
@unlink($abs);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->handleNoteIntentionUpload($thesisId, $files['note_intention'] ?? null, $folderPath, $filePrefix);
|
||||
|
||||
// ── Delete individual thesis files ────────────────────────────────────
|
||||
$deleteIds = isset($post['delete_files']) && is_array($post['delete_files'])
|
||||
? array_map('intval', $post['delete_files'])
|
||||
@@ -360,9 +411,18 @@ class ThesisEditController
|
||||
}
|
||||
}
|
||||
|
||||
// ── New thesis files upload ───────────────────────────────────────────
|
||||
// ── New TFE files upload ─────────────────────────────────────────────
|
||||
if (!empty($files['files']['name'][0])) {
|
||||
$this->handleThesisFiles($thesisId, $post, $files['files']);
|
||||
// Count existing TFE files to determine starting number
|
||||
$tfeCount = 0;
|
||||
foreach ($existingFiles as $f) {
|
||||
if (!in_array($f['file_type'] ?? '', ['cover', 'note_intention', 'website', 'annex', 'caption'], true)
|
||||
&& !str_starts_with($f['file_path'] ?? '', 'http')) {
|
||||
$tfeCount++;
|
||||
}
|
||||
// Don't count captions as separate TFE entries — they'll be renumbered
|
||||
}
|
||||
$this->handleTfeFiles($thesisId, $files['files'], $folderPath, $filePrefix, $post, $tfeCount + 1);
|
||||
}
|
||||
|
||||
// ── PeerTube video / audio uploads ────────────────────────────────────
|
||||
@@ -373,206 +433,6 @@ class ThesisEditController
|
||||
$this->handleWebsiteUrl($thesisId, $post);
|
||||
}
|
||||
|
||||
// ── Private: file uploads ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Process multiple new thesis-file uploads.
|
||||
*
|
||||
* Files are stored in the existing folder used by this thesis (detected
|
||||
* from any current thesis_files row), or a new one is created following
|
||||
* the same {year}_{authorSlug} convention as ThesisCreateController.
|
||||
*/
|
||||
private function handleThesisFiles(int $thesisId, array $post, array $uploads): void
|
||||
{
|
||||
$allowedMimes = [
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||
'application/pdf',
|
||||
'video/mp4', 'video/webm', 'video/ogg', 'video/quicktime',
|
||||
'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav',
|
||||
'audio/flac', 'audio/aac', 'audio/x-m4a', 'audio/mp4',
|
||||
'text/vtt',
|
||||
'application/zip', 'application/x-zip-compressed',
|
||||
'application/x-tar', 'application/gzip',
|
||||
'application/octet-stream',
|
||||
];
|
||||
$allowedExts = [
|
||||
'jpg', 'jpeg', 'png', 'gif', 'webp',
|
||||
'pdf',
|
||||
'mp4', 'webm', 'ogv', 'mov',
|
||||
'mp3', 'ogg', 'oga', 'wav', 'flac', 'aac', 'm4a',
|
||||
'vtt',
|
||||
'zip', 'tar', 'gz', 'tgz',
|
||||
];
|
||||
$maxBytes = 500 * 1024 * 1024; // 500 MB
|
||||
$maxPdfBytes = 100 * 1024 * 1024; // 100 MB for PDFs
|
||||
|
||||
$year = (int)($post['année'] ?? date('Y'));
|
||||
$authorName = trim($post['auteurice'] ?? 'unknown');
|
||||
|
||||
// Sort the raw comma-separated string alphabetically, then slugify.
|
||||
$names = array_values(array_filter(array_map('trim', explode(',', $authorName)), fn ($n) => $n !== ''));
|
||||
sort($names, SORT_NATURAL);
|
||||
$authorSlug = $this->generateAuthorSlug(implode(', ', $names));
|
||||
|
||||
// Per-file labels and sort orders submitted alongside the upload inputs
|
||||
$fileLabels = $post['file_labels'] ?? [];
|
||||
$fileOrders = $post['file_orders'] ?? [];
|
||||
|
||||
// Reuse existing folder if possible
|
||||
$existingFiles = $this->db->getThesisFiles($thesisId);
|
||||
$uploadDir = null;
|
||||
$folderName = null;
|
||||
foreach ($existingFiles as $f) {
|
||||
if (str_starts_with($f['file_path'] ?? '', 'theses/')) {
|
||||
$parts = explode('/', $f['file_path']);
|
||||
if (count($parts) >= 3) {
|
||||
$folderName = $parts[2];
|
||||
$uploadDir = STORAGE_ROOT . "/theses/{$year}/{$folderName}/";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($uploadDir === null) {
|
||||
$folderName = $this->ensureUniqueFolder($year, $authorSlug);
|
||||
$uploadDir = STORAGE_ROOT . "/theses/{$year}/{$folderName}/";
|
||||
}
|
||||
|
||||
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("ThesisEditController: upload error {$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));
|
||||
|
||||
if ($mimeType === 'text/plain' && $ext === 'vtt') {
|
||||
$mimeType = 'text/vtt';
|
||||
}
|
||||
|
||||
// Allow any ext-matched file even if finfo returns application/octet-stream
|
||||
if (!in_array($mimeType, $allowedMimes, true) && !in_array($ext, $allowedExts, true)) {
|
||||
error_log("ThesisEditController: invalid type {$uploads['name'][$i]} ($mimeType / $ext), skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
$isPdf = ($mimeType === 'application/pdf' || $ext === 'pdf');
|
||||
$sizeLimit = $isPdf ? $maxPdfBytes : $maxBytes;
|
||||
if ($uploads['size'][$i] > $sizeLimit) {
|
||||
error_log("ThesisEditController: file too large {$uploads['name'][$i]} (" . round($uploads['size'][$i] / 1024 / 1024) . ' MB), skipping');
|
||||
continue;
|
||||
}
|
||||
|
||||
$originalName = $uploads['name'][$i];
|
||||
$sanitized = $this->sanitizeFilename($originalName);
|
||||
$prefix = $authorSlug . '_' . $sanitized;
|
||||
$candidate = $prefix;
|
||||
$suffix = 1;
|
||||
while (file_exists($uploadDir . $candidate)) {
|
||||
$candidate = $authorSlug . '_' . pathinfo($sanitized, PATHINFO_FILENAME) . '_' . $suffix . '.' . $ext;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
$targetPath = $uploadDir . $candidate;
|
||||
if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) {
|
||||
error_log("ThesisEditController: failed to move {$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}/" . $candidate;
|
||||
$this->db->insertThesisFile(
|
||||
$thesisId,
|
||||
$fileType,
|
||||
$relPath,
|
||||
basename($originalName),
|
||||
$uploads['size'][$i],
|
||||
$mimeType,
|
||||
$label !== '' ? $label : null,
|
||||
$sortOrder
|
||||
);
|
||||
error_log("ThesisEditController: uploaded → $candidate ($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: string helpers ───────────────────────────────────────────────
|
||||
|
||||
private function generateAuthorSlug(string $authorName): string
|
||||
{
|
||||
$n = function_exists('iconv') ? iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $authorName) : $authorName;
|
||||
$accents = [
|
||||
'à' => 'a','â' => 'a','ä' => 'a','é' => 'e','è' => 'e','ê' => 'e','ë' => 'e',
|
||||
'î' => 'i','ï' => 'i','ô' => 'o','ö' => 'o','ù' => 'u','û' => 'u','ü' => 'u','ç' => 'c',
|
||||
];
|
||||
$n = strtr($n, $accents);
|
||||
$slug = strtoupper(trim(preg_replace('/[^A-Za-z0-9]+/', '_', $n), '_'));
|
||||
return $slug !== '' ? $slug : 'AUTHOR';
|
||||
}
|
||||
|
||||
private function sanitizeFilename(string $filename): string
|
||||
{
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION);
|
||||
$name = pathinfo($filename, PATHINFO_FILENAME);
|
||||
$n = function_exists('iconv') ? iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name) : $name;
|
||||
$accents = [
|
||||
'à' => 'a','â' => 'a','ä' => 'a','é' => 'e','è' => 'e','ê' => 'e','ë' => 'e',
|
||||
'î' => 'i','ï' => 'i','ô' => 'o','ö' => 'o','ù' => 'u','û' => 'u','ü' => 'u','ç' => 'c',
|
||||
];
|
||||
$n = trim(preg_replace('/[^A-Za-z0-9]+/', '_', strtr($n, $accents)), '_');
|
||||
if ($n === '') {
|
||||
$n = 'file';
|
||||
}
|
||||
return $ext !== '' ? $n . '.' . strtolower($ext) : $n;
|
||||
}
|
||||
|
||||
private function ensureUniqueFolder(int $year, string $authorSlug): string
|
||||
{
|
||||
$baseDir = STORAGE_ROOT . '/theses/' . $year . '/';
|
||||
$candidate = $year . '_' . $authorSlug;
|
||||
$suffix = 1;
|
||||
while (is_dir($baseDir . $candidate)) {
|
||||
$candidate = $year . '_' . $authorSlug . '_' . $suffix++;
|
||||
}
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
// ── WCAG 3.3.1 helper ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
591
app/src/Controllers/ThesisFileHandler.php
Normal file
591
app/src/Controllers/ThesisFileHandler.php
Normal file
@@ -0,0 +1,591 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Trait ThesisFileHandler
|
||||
*
|
||||
* Shared file-upload logic used by ThesisCreateController and
|
||||
* ThesisEditController. All on-disk files are stored under:
|
||||
*
|
||||
* theses/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
|
||||
*
|
||||
* Filenames follow:
|
||||
*
|
||||
* {YYYY}_{AUTHORS}_{TITLE_SLUG}_{SEMANTIC}.ext (single file)
|
||||
* {YYYY}_{AUTHORS}_{TITLE_SLUG}_{SEMANTIC}_{XX}.ext (numbered, 01-based)
|
||||
*
|
||||
* Semantic groups:
|
||||
* COUVERTURE – single cover image
|
||||
* NOTE_INTENTION – single PDF
|
||||
* TFE_{XX} – main thesis files (contiguous numbering)
|
||||
* ANNEXE_{XX} – annex files (separate numbering)
|
||||
*
|
||||
* TFE hierarchy (determines numbering order):
|
||||
* 1. PDFs
|
||||
* 2. Videos
|
||||
* 3. Audio
|
||||
* 4. Subtitles (VTT placed right after their video)
|
||||
* 5. Images
|
||||
* 6. Archives / other
|
||||
*
|
||||
* PeerTube and website URLs are stored as thesis_files rows with
|
||||
* file_path containing the URL; no on-disk file is created.
|
||||
*
|
||||
* The original user-provided filename is preserved in thesis_files.file_name.
|
||||
*/
|
||||
trait ThesisFileHandler
|
||||
{
|
||||
/** Maximum allowed file size for thesis files (bytes). */
|
||||
private const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
|
||||
|
||||
/** Maximum allowed file size for PDF files specifically (bytes). */
|
||||
private const MAX_PDF_SIZE = 100 * 1024 * 1024; // 100 MB
|
||||
|
||||
/** Cover image max size. */
|
||||
private const MAX_COVER_SIZE = 20 * 1024 * 1024; // 20 MB
|
||||
|
||||
/** MIME types accepted for thesis files. */
|
||||
private const ALLOWED_MIME_TYPES = [
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||
'application/pdf',
|
||||
'video/mp4', 'video/webm', 'video/ogg', 'video/quicktime',
|
||||
'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav',
|
||||
'audio/flac', 'audio/aac', 'audio/x-m4a', 'audio/mp4',
|
||||
'text/vtt',
|
||||
'application/zip', 'application/x-zip-compressed',
|
||||
'application/x-tar', 'application/gzip',
|
||||
'application/octet-stream',
|
||||
];
|
||||
|
||||
/** File extensions accepted for thesis files. */
|
||||
private const ALLOWED_EXTENSIONS = [
|
||||
'jpg', 'jpeg', 'png', 'gif', 'webp',
|
||||
'pdf',
|
||||
'mp4', 'webm', 'ogv', 'mov',
|
||||
'mp3', 'ogg', 'oga', 'wav', 'flac', 'aac', 'm4a',
|
||||
'vtt',
|
||||
'zip', 'tar', 'gz', 'tgz',
|
||||
];
|
||||
|
||||
// ── Public entry points (called by controllers) ──────────────────────────
|
||||
|
||||
/**
|
||||
* Process a cover image upload.
|
||||
*
|
||||
* @param int $thesisId
|
||||
* @param array|null $upload Single-file $_FILES entry.
|
||||
* @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder (e.g. "theses/2025/2025_SMITH_Mon_Titre/").
|
||||
* @param string $filePrefix The prefix shared by all files in this folder (e.g. "2025_SMITH_Mon_Titre").
|
||||
*/
|
||||
protected function handleCoverUpload(int $thesisId, ?array $upload, string $folderPath, string $filePrefix): 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));
|
||||
|
||||
$allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
$allowedExts = ['jpg', 'jpeg', 'png', 'webp'];
|
||||
|
||||
if (!in_array($mimeType, $allowedMimes, true)
|
||||
|| !in_array($ext, $allowedExts, true)
|
||||
|| $upload['size'] > self::MAX_COVER_SIZE) {
|
||||
error_log("ThesisFileHandler: invalid cover MIME $mimeType / $ext / {$upload['size']} bytes, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
$dir = STORAGE_ROOT . '/' . $folderPath;
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
$targetName = $filePrefix . '_COUVERTURE.' . $ext;
|
||||
$targetPath = $dir . $targetName;
|
||||
|
||||
if (!move_uploaded_file($upload['tmp_name'], $targetPath)) {
|
||||
error_log("ThesisFileHandler: failed to move cover to $targetPath");
|
||||
return;
|
||||
}
|
||||
|
||||
chmod($targetPath, 0644);
|
||||
$relPath = $folderPath . $targetName;
|
||||
|
||||
$this->db->insertThesisFile(
|
||||
$thesisId, 'cover',
|
||||
$relPath,
|
||||
basename($upload['name']),
|
||||
$upload['size'],
|
||||
$mimeType
|
||||
);
|
||||
error_log("ThesisFileHandler: cover uploaded → $relPath");
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the note d'intention upload (single PDF).
|
||||
*
|
||||
* @param int $thesisId
|
||||
* @param array|null $upload Single-file $_FILES entry.
|
||||
* @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder.
|
||||
* @param string $filePrefix The shared file prefix.
|
||||
*/
|
||||
protected function handleNoteIntentionUpload(int $thesisId, ?array $upload, string $folderPath, string $filePrefix): 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 ($mimeType !== 'application/pdf' || $ext !== 'pdf') {
|
||||
error_log("ThesisFileHandler: invalid note d'intention MIME $mimeType, skipping");
|
||||
return;
|
||||
}
|
||||
if ($upload['size'] > self::MAX_PDF_SIZE) {
|
||||
error_log("ThesisFileHandler: note d'intention too large ({$upload['size']} bytes), skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
$dir = STORAGE_ROOT . '/' . $folderPath;
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
$targetName = $filePrefix . '_NOTE_INTENTION.pdf';
|
||||
$targetPath = $dir . $targetName;
|
||||
|
||||
if (!move_uploaded_file($upload['tmp_name'], $targetPath)) {
|
||||
error_log("ThesisFileHandler: failed to move note d'intention to $targetPath");
|
||||
return;
|
||||
}
|
||||
|
||||
chmod($targetPath, 0644);
|
||||
$relPath = $folderPath . $targetName;
|
||||
|
||||
$this->db->insertThesisFile(
|
||||
$thesisId, 'note_intention',
|
||||
$relPath,
|
||||
basename($upload['name']),
|
||||
$upload['size'],
|
||||
'application/pdf'
|
||||
);
|
||||
error_log("ThesisFileHandler: note d'intention uploaded → $relPath");
|
||||
}
|
||||
|
||||
/**
|
||||
* Process multiple TFE file uploads (files[] — the main set).
|
||||
*
|
||||
* Files are stored with TFE_{XX} semantics. Numbering is contiguous
|
||||
* across all sub-types in order: PDF → video → audio → subtitles → images → archives.
|
||||
* Subtitles (VTT) are placed immediately after their associated video.
|
||||
*
|
||||
* @param int $thesisId
|
||||
* @param array|null $uploads Multi-file $_FILES entry.
|
||||
* @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder.
|
||||
* @param string $filePrefix The shared file prefix.
|
||||
* @param array $post $_POST for per-file labels/orders.
|
||||
* @param int $startNum Starting number for TFE_XX (usually 01).
|
||||
*/
|
||||
protected function handleTfeFiles(int $thesisId, ?array $uploads, string $folderPath, string $filePrefix, array $post = [], int $startNum = 1): int
|
||||
{
|
||||
if (!$uploads || !is_array($uploads['name'] ?? null)) {
|
||||
return $startNum;
|
||||
}
|
||||
|
||||
$dir = STORAGE_ROOT . '/' . $folderPath;
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
$fileLabels = $post['file_labels'] ?? [];
|
||||
$fileOrders = $post['file_orders'] ?? [];
|
||||
|
||||
// Collect all files, classify, sort by hierarchy
|
||||
$files = [];
|
||||
$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("ThesisFileHandler: TFE upload error {$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));
|
||||
|
||||
if ($mimeType === 'text/plain' && $ext === 'vtt') {
|
||||
$mimeType = 'text/vtt';
|
||||
}
|
||||
if ($mimeType === 'application/octet-stream' && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||||
error_log("ThesisFileHandler: TFE 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("ThesisFileHandler: invalid TFE type {$uploads['name'][$i]} ($mimeType / $ext), skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
$isPdf = ($mimeType === 'application/pdf' || $ext === 'pdf');
|
||||
$sizeLimit = $isPdf ? self::MAX_PDF_SIZE : self::MAX_FILE_SIZE;
|
||||
if ($uploads['size'][$i] > $sizeLimit) {
|
||||
error_log("ThesisFileHandler: TFE file too large {$uploads['name'][$i]} (" . round($uploads['size'][$i] / 1024 / 1024) . ' MB), skipping');
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[] = [
|
||||
'index' => $i,
|
||||
'mimeType' => $mimeType,
|
||||
'ext' => $ext,
|
||||
'size' => $uploads['size'][$i],
|
||||
'tmpName' => $uploads['tmp_name'][$i],
|
||||
'origName' => $uploads['name'][$i],
|
||||
'label' => trim($fileLabels[$i] ?? ''),
|
||||
'sortOrder' => isset($fileOrders[$i]) ? (int)$fileOrders[$i] : null,
|
||||
'hierarchy' => $this->tfeHierarchyRank($mimeType, $ext),
|
||||
'fileType' => $this->detectFileType($mimeType, $ext),
|
||||
];
|
||||
}
|
||||
|
||||
// Sort by hierarchy rank
|
||||
usort($files, fn($a, $b) => $a['hierarchy'] - $b['hierarchy']);
|
||||
|
||||
// Assign contiguous TFE_XX numbers
|
||||
$videoCount = 0;
|
||||
$vttQueue = [];
|
||||
|
||||
// First pass: count videos to know where to insert VTTs
|
||||
foreach ($files as $f) {
|
||||
if ($f['fileType'] === 'video') {
|
||||
$videoCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$num = $startNum;
|
||||
$vttIdx = 0;
|
||||
|
||||
foreach ($files as $f) {
|
||||
// VTT files are inserted right after their corresponding video
|
||||
if ($f['fileType'] === 'caption') {
|
||||
$vttQueue[] = $f;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($f['fileType'] === 'video') {
|
||||
// Write the video file
|
||||
$this->writeTfeFile($f, $thesisId, $dir, $folderPath, $filePrefix, $num);
|
||||
$num++;
|
||||
|
||||
// Write any waiting VTT for this video
|
||||
if (!empty($vttQueue)) {
|
||||
$vtt = array_shift($vttQueue);
|
||||
$this->writeTfeFile($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num);
|
||||
$num++;
|
||||
}
|
||||
} else {
|
||||
$this->writeTfeFile($f, $thesisId, $dir, $folderPath, $filePrefix, $num);
|
||||
$num++;
|
||||
}
|
||||
}
|
||||
|
||||
// Any remaining VTTs (orphaned — write at end)
|
||||
foreach ($vttQueue as $vtt) {
|
||||
$this->writeTfeFile($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num);
|
||||
$num++;
|
||||
}
|
||||
|
||||
return $num;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process annex file uploads.
|
||||
*
|
||||
* @param int $thesisId
|
||||
* @param array|null $uploads Multi-file $_FILES entry.
|
||||
* @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder.
|
||||
* @param string $filePrefix The shared file prefix.
|
||||
* @param array $post $_POST for per-file labels/orders.
|
||||
*/
|
||||
protected function handleAnnexeFiles(int $thesisId, ?array $uploads, string $folderPath, string $filePrefix, array $post = []): void
|
||||
{
|
||||
if (!$uploads || !is_array($uploads['name'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$dir = STORAGE_ROOT . '/' . $folderPath;
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
$fileLabels = $post['annexe_labels'] ?? [];
|
||||
$fileOrders = $post['annexe_orders'] ?? [];
|
||||
|
||||
$num = 1;
|
||||
$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("ThesisFileHandler: annexe upload error {$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));
|
||||
|
||||
if ($mimeType === 'text/plain' && $ext === 'vtt') {
|
||||
$mimeType = 'text/vtt';
|
||||
}
|
||||
|
||||
$sizeLimit = (($mimeType === 'application/pdf' || $ext === 'pdf') ? self::MAX_PDF_SIZE : self::MAX_FILE_SIZE);
|
||||
if ($uploads['size'][$i] > $sizeLimit) {
|
||||
error_log("ThesisFileHandler: annexe too large {$uploads['name'][$i]} (" . round($uploads['size'][$i] / 1024 / 1024) . ' MB), skipping');
|
||||
continue;
|
||||
}
|
||||
|
||||
$padded = sprintf('%02d', $num);
|
||||
$targetName = $filePrefix . '_ANNEXE_' . $padded . '.' . $ext;
|
||||
$targetPath = $dir . $targetName;
|
||||
|
||||
if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) {
|
||||
error_log("ThesisFileHandler: failed to move annexe {$uploads['name'][$i]}");
|
||||
continue;
|
||||
}
|
||||
|
||||
chmod($targetPath, 0644);
|
||||
$relPath = $folderPath . $targetName;
|
||||
$label = trim($fileLabels[$i] ?? '');
|
||||
$sortOrder = isset($fileOrders[$i]) ? (int)$fileOrders[$i] : null;
|
||||
$fileType = ($ext === 'vtt' || $mimeType === 'text/vtt') ? 'caption' : 'annex';
|
||||
|
||||
$this->db->insertThesisFile(
|
||||
$thesisId, $fileType,
|
||||
$relPath,
|
||||
basename($uploads['name'][$i]),
|
||||
$uploads['size'][$i],
|
||||
$mimeType,
|
||||
$label !== '' ? $label : null,
|
||||
$sortOrder
|
||||
);
|
||||
error_log("ThesisFileHandler: annexe uploaded → $targetName");
|
||||
$num++;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write a single TFE file to disk and record in DB.
|
||||
*/
|
||||
protected function writeTfeFile(array $f, int $thesisId, string $dir, string $folderPath, string $filePrefix, int $num): void
|
||||
{
|
||||
$padded = sprintf('%02d', $num);
|
||||
$targetName = $filePrefix . '_TFE_' . $padded . '.' . $f['ext'];
|
||||
$targetPath = $dir . $targetName;
|
||||
|
||||
if (!move_uploaded_file($f['tmpName'], $targetPath)) {
|
||||
error_log("ThesisFileHandler: failed to move TFE {$f['origName']}");
|
||||
return;
|
||||
}
|
||||
|
||||
chmod($targetPath, 0644);
|
||||
$relPath = $folderPath . $targetName;
|
||||
|
||||
$this->db->insertThesisFile(
|
||||
$thesisId, $f['fileType'],
|
||||
$relPath,
|
||||
basename($f['origName']),
|
||||
$f['size'],
|
||||
$f['mimeType'],
|
||||
$f['label'] !== '' ? $f['label'] : null,
|
||||
$f['sortOrder']
|
||||
);
|
||||
error_log("ThesisFileHandler: TFE uploaded → $targetName ({$f['fileType']})");
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a hierarchy rank for sorting TFE files.
|
||||
* Lower = earlier in the sequence.
|
||||
*
|
||||
* 0 = PDF
|
||||
* 1 = video
|
||||
* 2 = audio
|
||||
* 3 = caption (VTT)
|
||||
* 4 = image
|
||||
* 5 = archive / other
|
||||
*/
|
||||
private function tfeHierarchyRank(string $mimeType, string $ext): int
|
||||
{
|
||||
if ($mimeType === 'application/pdf' || $ext === 'pdf') {
|
||||
return 0;
|
||||
}
|
||||
if ($ext === 'vtt' || $mimeType === 'text/vtt') {
|
||||
return 3;
|
||||
}
|
||||
if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) {
|
||||
return 1;
|
||||
}
|
||||
if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
|
||||
return 2;
|
||||
}
|
||||
if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
|
||||
return 4;
|
||||
}
|
||||
return 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the logical file_type from MIME type / extension.
|
||||
*/
|
||||
protected function detectFileType(string $mimeType, string $ext): string
|
||||
{
|
||||
if ($ext === 'vtt' || $mimeType === 'text/vtt') {
|
||||
return 'caption';
|
||||
}
|
||||
if ($mimeType === 'application/pdf' || $ext === 'pdf') {
|
||||
return 'main';
|
||||
}
|
||||
if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) {
|
||||
return 'video';
|
||||
}
|
||||
if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
|
||||
return 'audio';
|
||||
}
|
||||
if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
|
||||
return 'image';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// ── String / filesystem helpers ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a filesystem-safe author slug from comma-separated author names.
|
||||
* Sorted alphabetically, joined with dash, accent-stripped, uppercase.
|
||||
*/
|
||||
protected function generateAuthorSlug(string $authorNames): string
|
||||
{
|
||||
$names = array_values(array_filter(array_map('trim', explode(',', $authorNames)), fn($n) => $n !== ''));
|
||||
sort($names, SORT_NATURAL);
|
||||
$joined = implode('-', $names);
|
||||
|
||||
$normalized = $joined;
|
||||
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',
|
||||
'À' => 'A', 'Â' => 'A', 'Ä' => 'A',
|
||||
'É' => 'E', 'È' => 'E', 'Ê' => 'E', 'Ë' => 'E',
|
||||
'Î' => 'I', 'Ï' => 'I',
|
||||
'Ô' => 'O', 'Ö' => 'O',
|
||||
'Ù' => 'U', 'Û' => 'U', 'Ü' => 'U',
|
||||
'Ç' => 'C',
|
||||
];
|
||||
$normalized = strtr($normalized, $accents);
|
||||
$slug = preg_replace('/[^A-Za-z0-9]+/', '_', $normalized);
|
||||
$slug = trim($slug, '_');
|
||||
$slug = strtoupper($slug);
|
||||
if ($slug === '') {
|
||||
$slug = 'AUTHOR';
|
||||
}
|
||||
return $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a filesystem-safe title slug (truncated to 60 chars).
|
||||
*/
|
||||
protected function generateTitleSlug(string $title): string
|
||||
{
|
||||
$normalized = $title;
|
||||
if (function_exists('iconv')) {
|
||||
$normalized = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
|
||||
}
|
||||
$accents = [
|
||||
'à' => 'a', 'â' => 'a', 'ä' => 'a', 'æ' => 'ae',
|
||||
'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
|
||||
'î' => 'i', 'ï' => 'i',
|
||||
'ô' => 'o', 'ö' => 'o', 'œ' => 'oe',
|
||||
'ù' => 'u', 'û' => 'u', 'ü' => 'u',
|
||||
'ç' => 'c',
|
||||
'À' => 'A', 'Â' => 'A', 'Ä' => 'A', 'Æ' => 'AE',
|
||||
'É' => 'E', 'È' => 'E', 'Ê' => 'E', 'Ë' => 'E',
|
||||
'Î' => 'I', 'Ï' => 'I',
|
||||
'Ô' => 'O', 'Ö' => 'O', 'Œ' => 'OE',
|
||||
'Ù' => 'U', 'Û' => 'U', 'Ü' => 'U',
|
||||
'Ç' => 'C',
|
||||
"'" => '', '"' => '', '«' => '', '»' => '',
|
||||
];
|
||||
$normalized = strtr($normalized, $accents);
|
||||
$slug = preg_replace('/[^A-Za-z0-9]+/', '_', $normalized);
|
||||
$slug = trim($slug, '_');
|
||||
$slug = strtoupper($slug);
|
||||
if (strlen($slug) > 60) {
|
||||
$slug = substr($slug, 0, 60);
|
||||
}
|
||||
if ($slug === '') {
|
||||
$slug = 'TFE';
|
||||
}
|
||||
return $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the folder path and file prefix for a thesis.
|
||||
*
|
||||
* Folder: theses/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
|
||||
* Prefix: {YYYY}_{AUTHORS}_{TITLE_SLUG}
|
||||
*
|
||||
* @return array{folderPath: string, filePrefix: string, folderName: string}
|
||||
*/
|
||||
protected function buildThesisFolder(int $year, string $authorsStr, string $title): array
|
||||
{
|
||||
$authorSlug = $this->generateAuthorSlug($authorsStr);
|
||||
$titleSlug = $this->generateTitleSlug($title);
|
||||
|
||||
$folderName = $year . '_' . $authorSlug . '_' . $titleSlug;
|
||||
$filePrefix = $folderName;
|
||||
$folderPath = 'theses/' . $year . '/' . $folderName . '/';
|
||||
|
||||
return [
|
||||
'folderPath' => $folderPath,
|
||||
'filePrefix' => $filePrefix,
|
||||
'folderName' => $folderName,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the thesis folder exists (create if needed).
|
||||
* If the folder already exists, append a numeric suffix.
|
||||
*/
|
||||
protected function ensureUniqueFolder(string $folderPath): string
|
||||
{
|
||||
$baseDir = STORAGE_ROOT . '/' . dirname($folderPath) . '/';
|
||||
$candidate = basename(rtrim($folderPath, '/'));
|
||||
|
||||
if (!is_dir($baseDir . $candidate)) {
|
||||
mkdir($baseDir . $candidate, 0755, true);
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
$suffix = 1;
|
||||
while (is_dir($baseDir . $candidate . '_' . $suffix)) {
|
||||
$suffix++;
|
||||
}
|
||||
$candidate .= '_' . $suffix;
|
||||
mkdir($baseDir . $candidate, 0755, true);
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
@@ -494,7 +494,7 @@ class Database
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT vp.id, vp.title, vp.subtitle, vp.year, vp.synopsis,
|
||||
vp.orientation, vp.finality_type, vp.banner_path, vp.authors
|
||||
vp.orientation, vp.finality_type, vp.authors
|
||||
FROM v_theses_public vp
|
||||
JOIN thesis_authors ta ON ta.thesis_id = vp.id
|
||||
JOIN authors a ON a.id = ta.author_id
|
||||
@@ -522,7 +522,7 @@ class Database
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT a.name AS author_name,
|
||||
vp.id, vp.title, vp.subtitle, vp.year, vp.synopsis,
|
||||
vp.orientation, vp.finality_type, vp.banner_path, vp.authors
|
||||
vp.orientation, vp.finality_type, vp.authors
|
||||
FROM v_theses_public vp
|
||||
JOIN thesis_authors ta ON ta.thesis_id = vp.id
|
||||
JOIN authors a ON a.id = ta.author_id
|
||||
@@ -2165,6 +2165,9 @@ class Database
|
||||
* Replace the cover image for a thesis: removes any existing cover record
|
||||
* (and its file from disk), then inserts the new one.
|
||||
*
|
||||
* @deprecated Use ThesisFileHandler::handleCoverUpload() instead.
|
||||
* Kept for backwards compatibility — may be called by old code.
|
||||
*
|
||||
* @param int $thesisId
|
||||
* @param array|null $upload Single-file $_FILES entry.
|
||||
* @return string|null Relative path of the new cover, or null.
|
||||
@@ -2175,11 +2178,17 @@ class Database
|
||||
return null;
|
||||
}
|
||||
|
||||
require_once APP_ROOT . '/src/Controllers/ThesisFileHandler.php';
|
||||
// Use the trait's version — but since it's a trait we can't call it directly.
|
||||
// Delegate to the new convention: cover goes inside the thesis folder.
|
||||
// For backwards compat, fall through to the old behaviour.
|
||||
// The caller should migrate to using the trait directly.
|
||||
|
||||
$allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
$allowedExts = ['jpg', 'jpeg', 'png', 'webp'];
|
||||
$maxBytes = 20 * 1024 * 1024; // 20 MB
|
||||
$maxBytes = 20 * 1024 * 1024;
|
||||
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($upload['tmp_name']);
|
||||
$ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION));
|
||||
|
||||
|
||||
@@ -193,9 +193,6 @@ CREATE TABLE IF NOT EXISTS theses (
|
||||
-- External links
|
||||
baiu_link TEXT, -- Link to institutional repository
|
||||
|
||||
-- Home page card banner (optional, landscape image)
|
||||
banner_path TEXT, -- path relative to STORAGE_ROOT (e.g. "banners/abc.jpg")
|
||||
|
||||
-- Logistics checkboxes (backoffice only)
|
||||
exemplaire_baiu BOOLEAN DEFAULT 0, -- Physical copy at BAIU
|
||||
exemplaire_erg BOOLEAN DEFAULT 0, -- Physical copy at ERG
|
||||
@@ -512,7 +509,6 @@ SELECT
|
||||
t.published_at,
|
||||
t.is_published,
|
||||
t.baiu_link,
|
||||
t.banner_path,
|
||||
t.exemplaire_baiu,
|
||||
t.exemplaire_erg,
|
||||
t.cc2r,
|
||||
|
||||
@@ -168,6 +168,19 @@
|
||||
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
|
||||
+\\\\\\\ to: moumuszm 1e2ae09f "Fix language-search fragment" (rebased revision)
|
||||
++ $linkName = $link['name'] ?? '';
|
||||
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
|
||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: moumuszm 1e2ae09f "Fix language-search fragment" (rebased revision)
|
||||
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
|
||||
- $linkName = $link['name'] ?? '';
|
||||
- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
|
||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination)
|
||||
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: kvyyvksn fd0f6e32 "fix: add help email, preserve file names on validation error, license fix" (rebased revision)
|
||||
$linkName = $link['name'] ?? '';
|
||||
$linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
|
||||
$linkLockedYear = $link['locked_year'] ?? null;
|
||||
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
|
||||
+\\\\\\\ to: kvyyvksn c5873f06 "fix: add help email, preserve file names on validation error, license fix" (rebased revision)
|
||||
++ $linkName = $link['name'] ?? '';
|
||||
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
|
||||
?>
|
||||
<tr class="admin-table-row" onclick="event.stopPropagation(); window.open('/partage/<?= urlencode($link['slug']) ?>', '_blank')" style="cursor:pointer">
|
||||
|
||||
@@ -88,9 +88,9 @@ class TfeControllerTestable extends TfeController
|
||||
|
||||
class ThesisCreateControllerTestable extends ThesisCreateController
|
||||
{
|
||||
public function testDetectFileType(string $mimeType, string $ext, string $name): string
|
||||
public function testDetectFileType(string $mimeType, string $ext): string
|
||||
{
|
||||
return $this->detectFileType($mimeType, $ext, $name);
|
||||
return $this->detectFileType($mimeType, $ext);
|
||||
}
|
||||
|
||||
public function testGenerateAuthorSlug(string $name): string
|
||||
@@ -278,33 +278,33 @@ try {
|
||||
// ── B3: detectFileType ────────────────────────────────────────────────────
|
||||
echo "B3: detectFileType — mime-based detection\n";
|
||||
$cases = [
|
||||
['text/vtt', 'vtt', 'caption.vtt', 'caption'],
|
||||
['audio/mpeg', 'mp3', 'track.mp3', 'audio'],
|
||||
['audio/ogg', 'ogg', 'track.ogg', 'audio'],
|
||||
['video/mp4', 'mp4', 'film.mp4', 'video'],
|
||||
['video/webm', 'webm', 'film.webm', 'video'],
|
||||
['application/pdf', 'pdf', 'report.pdf', 'main'],
|
||||
['image/jpeg', 'jpg', 'photo.jpg', 'image'],
|
||||
['image/png', 'png', 'photo.png', 'image'],
|
||||
['application/zip', 'zip', 'archive.zip', 'other'],
|
||||
['text/vtt', 'vtt', 'caption'],
|
||||
['audio/mpeg', 'mp3', 'audio'],
|
||||
['audio/ogg', 'ogg', 'audio'],
|
||||
['video/mp4', 'mp4', 'video'],
|
||||
['video/webm', 'webm', 'video'],
|
||||
['application/pdf', 'pdf', 'main'],
|
||||
['image/jpeg', 'jpg', 'image'],
|
||||
['image/png', 'png', 'image'],
|
||||
['application/zip', 'zip', 'other'],
|
||||
];
|
||||
foreach ($cases as [$mime, $ext, $name, $expected]) {
|
||||
$actual = $createCtrl->testDetectFileType($mime, $ext, $name);
|
||||
plAssertEq($expected, $actual, "$name ($mime) → $expected");
|
||||
foreach ($cases as [$mime, $ext, $expected]) {
|
||||
$actual = $createCtrl->testDetectFileType($mime, $ext);
|
||||
plAssertEq($expected, $actual, "$mime / $ext → $expected");
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
echo "B4: detectFileType — extension-based fallback\n";
|
||||
// application/octet-stream with known extensions
|
||||
$cases = [
|
||||
['application/octet-stream', 'mp3', 'track.mp3', 'audio'],
|
||||
['application/octet-stream', 'mp4', 'video.mp4', 'video'],
|
||||
['application/octet-stream', 'pdf', 'doc.pdf', 'main'],
|
||||
['application/octet-stream', 'webp', 'img.webp', 'image'],
|
||||
['application/octet-stream', 'vtt', 'subs.vtt', 'caption'],
|
||||
['application/octet-stream', 'mp3', 'audio'],
|
||||
['application/octet-stream', 'mp4', 'video'],
|
||||
['application/octet-stream', 'pdf', 'main'],
|
||||
['application/octet-stream', 'webp', 'image'],
|
||||
['application/octet-stream', 'vtt', 'caption'],
|
||||
];
|
||||
foreach ($cases as [$mime, $ext, $name, $expected]) {
|
||||
$actual = $createCtrl->testDetectFileType($mime, $ext, $name);
|
||||
foreach ($cases as [$mime, $ext, $expected]) {
|
||||
$actual = $createCtrl->testDetectFileType($mime, $ext);
|
||||
plAssertEq($expected, $actual, "octet-stream + .$ext → $expected");
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
Reference in New Issue
Block a user