diff --git a/TODO.md b/TODO.md index b3e92ba..0eeda79 100644 --- a/TODO.md +++ b/TODO.md @@ -3,6 +3,16 @@ ## Fixes - [x] Replace `mb_strtolower` with `strtolower` in admin/index.php (mbstring not available in php8.4-fpm) - [x] Replace `mb_strlen`/`mb_substr` with `strlen`/`substr` in student-preview.php (same root cause) +- [x] Add `objet` column migration (production DB missing column → SQLSTATE[HY000]: table theses has no column named objet) + +## Logging +- [x] `AppLogger` — structured JSON logger at `storage/logs/form-submissions.log` +- [x] Admin formulaire: log submissions (success + error, IP, UA, author, post keys) +- [x] Partage form: log submissions (success + error, share slug, IP, UA, post keys) + +## Migrations +- [x] Migration runner (`app/migrations/run.php`) with tracking table `_migrations` +- [x] `001_add_objet_column.sql` (handles duplicate-column gracefully) ## Features - [x] Student name popover preview in /repertoire (zero per-hover requests) @@ -13,3 +23,13 @@ - [x] Updated `repertoire-index.php` — htmx hover attrs, `$studentWorks` map - [x] Popover container + JS position/hide logic in `repertoire.php` - [x] CSS in `repertoire.css` + +## File naming +- [x] Analyse current file saving in admin/add.php and partage/index.php +- [x] Implement author slug generation (`generateAuthorSlug`) +- [x] Modify `handleThesisFiles`: folder = `theses/{year}/{year}_{AUTHOR_NAME}/` +- [x] Modify `handleThesisFiles`: filename = `AUTHOR_NAME_sanitized_original.ext` +- [x] Ensure uniqueness within same year/author (suffix `_1`, `_2`, etc.) +- [x] Database path storage updated automatically via `insertThesisFile` +- [ ] Test with actual uploads +- [ ] Consider same changes for `handleCoverUpload` and `handleBannerUpload` diff --git a/app/migrations/applied/001_add_objet_column.sql b/app/migrations/applied/001_add_objet_column.sql new file mode 100644 index 0000000..9ce3f6b --- /dev/null +++ b/app/migrations/applied/001_add_objet_column.sql @@ -0,0 +1,7 @@ +-- Add 'objet' column to theses table if it doesn't already exist. +-- Required: the admin form sends 'objet' in POST since commit ~Apr 2024 +-- but older production databases may lack the column. +-- SQLite 3.35+ supports ALTER TABLE ADD COLUMN (bundled with PHP 8+). + +ALTER TABLE theses ADD COLUMN objet TEXT NOT NULL DEFAULT 'tfe' + CHECK (objet IN ('tfe', 'thèse', 'frart')); diff --git a/app/migrations/run.php b/app/migrations/run.php new file mode 100644 index 0000000..327707f --- /dev/null +++ b/app/migrations/run.php @@ -0,0 +1,84 @@ +#!/usr/bin/env php +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +// Create migrations tracking table +$pdo->exec(" + CREATE TABLE IF NOT EXISTS _migrations ( + name TEXT PRIMARY KEY, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) +"); + +$applied = $pdo->query("SELECT name FROM _migrations")->fetchAll(PDO::FETCH_COLUMN); + +$pendingDir = $root . '/migrations/pending'; +$appliedDir = $root . '/migrations/applied'; + +if (!is_dir($pendingDir)) { + echo "No pending migrations directory.\n"; + exit(0); +} + +if (!is_dir($appliedDir)) { + mkdir($appliedDir, 0755, true); +} + +$files = glob($pendingDir . '/*.sql'); +sort($files); + +if (empty($files)) { + echo "No pending migration files.\n"; + exit(0); +} + +$count = 0; +foreach ($files as $file) { + $name = basename($file); + + if (in_array($name, $applied, true)) { + echo "Skip (already applied): $name\n"; + continue; + } + + echo "Applying: $name\n"; + $sql = file_get_contents($file); + + try { + $pdo->exec($sql); + $pdo->prepare("INSERT INTO _migrations (name) VALUES (?)")->execute([$name]); + rename($file, $appliedDir . '/' . $name); + $count++; + } catch (PDOException $e) { + $msg = $e->getMessage(); + // SQLite: skip if column already exists + if (stripos($msg, 'duplicate column name') !== false) { + echo " Already exists (skipping): $name\n"; + $pdo->prepare("INSERT OR REPLACE INTO _migrations (name) VALUES (?)")->execute([$name]); + rename($file, $appliedDir . '/' . $name); + continue; + } + throw $e; + } +} + +echo "$count migration(s) applied.\n"; diff --git a/app/public/admin/actions/formulaire.php b/app/public/admin/actions/formulaire.php index 954fca6..c09e9e5 100644 --- a/app/public/admin/actions/formulaire.php +++ b/app/public/admin/actions/formulaire.php @@ -19,17 +19,29 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token']) error_log('FILES array: ' . print_r($_FILES, true)); require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php'; +require_once APP_ROOT . '/src/AppLogger.php'; + +$logger = new AppLogger(); +$authorName = $_POST['auteurice'] ?? 'unknown'; try { $ctrl = ThesisCreateController::make(); $thesisId = $ctrl->submit($_POST, $_FILES); + $identifier = $ctrl->getIdentifier($thesisId); + $logger->logSubmission('admin', $thesisId, $identifier, $authorName); + unset($_SESSION['csrf_token']); header('Location: ' . $redirect); exit(); } catch (Exception $e) { + $logger->logError('admin', $e->getMessage(), [ + 'author' => $authorName, + 'post_keys' => array_keys($_POST), + ]); + error_log('ThesisCreateController error: ' . $e->getMessage()); App::flash('error', $e->getMessage()); diff --git a/app/public/partage/index.php b/app/public/partage/index.php index e3b7dc1..d4958cb 100644 --- a/app/public/partage/index.php +++ b/app/public/partage/index.php @@ -500,11 +500,20 @@ function handleShareLinkSubmission(string $slug): void require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php'; require_once APP_ROOT . '/src/StudentEmail.php'; + require_once APP_ROOT . '/src/AppLogger.php'; + + $logger = new AppLogger(); + $authorName = $_POST['auteurice'] ?? 'unknown'; try { $ctrl = ThesisCreateController::make(); $thesisId = $ctrl->submit($_POST, $_FILES); + $identifier = $ctrl->getIdentifier($thesisId); + $logger->logSubmission('partage', $thesisId, $identifier, $authorName, [ + 'share_slug' => $slug, + ]); + // Send confirmation e-mail (non-blocking; failure doesn't stop redirect) $emailSent = StudentEmail::sendConfirmation(Database::getInstance(), $thesisId, $_POST); @@ -521,6 +530,12 @@ function handleShareLinkSubmission(string $slug): void header('Location: /partage/thanks?id=' . urlencode((string)$thesisId)); exit(); } catch (Exception $e) { + $logger->logError('partage', $e->getMessage(), [ + 'share_slug' => $slug, + 'author' => $authorName, + 'post_keys' => array_keys($_POST), + ]); + error_log('Share link submission error: ' . $e->getMessage()); $_SESSION['_flash_error'] = $e->getMessage(); diff --git a/app/src/AppLogger.php b/app/src/AppLogger.php new file mode 100644 index 0000000..26a6f4a --- /dev/null +++ b/app/src/AppLogger.php @@ -0,0 +1,75 @@ +logDir = $logDir ?? (defined('STORAGE_ROOT') ? STORAGE_ROOT . '/logs' : __DIR__ . '/../storage/logs'); + + if (!is_dir($this->logDir)) { + mkdir($this->logDir, 0755, true); + } + + $this->logFile = $this->logDir . '/form-submissions.log'; + } + + /** + * Log a successful thesis submission. + * + * @param string $source 'admin' or 'partage' + * @param int $thesisId + * @param string $identifier e.g. "2025-003" + * @param string $authorName + * @param array $extras Additional context (e.g. share link slug) + */ + public function logSubmission(string $source, int $thesisId, string $identifier, string $authorName, array $extras = []): void + { + $this->write(array_merge([ + 'source' => $source, + 'action' => 'submit', + 'status' => 'success', + 'thesis_id' => $thesisId, + 'identifier' => $identifier, + 'author' => $authorName, + ], $extras)); + } + + /** + * Log a failed thesis submission. + * + * @param string $source + * @param string $errorMessage + * @param array $extras + */ + public function logError(string $source, string $errorMessage, array $extras = []): void + { + $this->write(array_merge([ + 'source' => $source, + 'action' => 'submit', + 'status' => 'error', + 'error' => $errorMessage, + ], $extras)); + } + + /** + * Write a structured log line. + */ + private function write(array $entry): void + { + $entry['timestamp'] = date('c'); + $entry['ip'] = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $entry['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? ''; + + $line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; + error_log($line, 3, $this->logFile); + } +} diff --git a/app/src/Controllers/ThesisCreateController.php b/app/src/Controllers/ThesisCreateController.php index ba0ba22..0644493 100644 --- a/app/src/Controllers/ThesisCreateController.php +++ b/app/src/Controllers/ThesisCreateController.php @@ -51,6 +51,16 @@ class ThesisCreateController 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 ───────────────────────────────────────────────────── /** @@ -149,7 +159,7 @@ class ThesisCreateController // ── 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); + $this->handleThesisFiles($thesisId, $data['annee'], $identifier, $files['files'] ?? null, $data['auteurName']); return $thesisId; } @@ -358,14 +368,19 @@ class ThesisCreateController * @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 $authorName Author name for folder and file naming. */ - private function handleThesisFiles(int $thesisId, int $year, string $identifier, ?array $uploads): void + private function handleThesisFiles(int $thesisId, int $year, string $identifier, ?array $uploads, string $authorName): void { if (!$uploads || !is_array($uploads['name'] ?? null)) { return; } - $uploadDir = STORAGE_ROOT . "/theses/{$year}/{$identifier}/"; + // Generate author slug and unique folder name + $authorSlug = $this->generateAuthorSlug($authorName); + $folderName = $this->ensureUniqueFolder($year, $authorSlug); + $uploadDir = STORAGE_ROOT . "/theses/{$year}/{$folderName}/"; + if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); } @@ -401,11 +416,22 @@ class ThesisCreateController continue; } - $safeName = bin2hex(random_bytes(16)) . '.' . $ext; - $targetPath = $uploadDir . $safeName; + // 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 {$uploads['name'][$i]}"); + error_log("ThesisCreateController: failed to move file {$originalName}"); continue; } @@ -414,22 +440,22 @@ class ThesisCreateController $fileType = 'other'; if ($ext === 'vtt') { $fileType = 'caption'; - } elseif (stripos($uploads['name'][$i], 'annex') !== false) { + } elseif (stripos($originalName, 'annex') !== false) { $fileType = 'annex'; } elseif ($ext === 'pdf') { $fileType = 'main'; } - $relPath = "theses/{$year}/{$identifier}/" . $safeName; + $relPath = "theses/{$year}/{$folderName}/" . $targetName; $this->db->insertThesisFile( $thesisId, $fileType, $relPath, - basename($uploads['name'][$i]), + basename($originalName), $uploads['size'][$i], $mimeType ); - error_log("ThesisCreateController: file uploaded → $safeName ($fileType)"); + error_log("ThesisCreateController: file uploaded → $targetName ($fileType)"); } } @@ -457,4 +483,99 @@ class ThesisCreateController 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; + } } diff --git a/app/storage/posterg.db b/app/storage/posterg.db index ae8059b..0c5c542 100644 Binary files a/app/storage/posterg.db and b/app/storage/posterg.db differ diff --git a/app/storage/test.db b/app/storage/test.db index a917362..9a05446 100644 Binary files a/app/storage/test.db and b/app/storage/test.db differ