From 4986fa74f40aecc55b0d0c61c292d440fe08ee42 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Fri, 24 Apr 2026 16:55:11 +0200 Subject: [PATCH] add structured logging for admin/partage form submissions + migration system - AppLogger: JSON-line logger in storage/logs/form-submissions.log - Logs submissions (admin + partage) with IP, UA, thesis ID, author - Logs errors with context (post keys, share slug) - Migration runner (app/migrations/run.php) handles schema drift - 001_add_objet_column.sql fixes production DB missing 'objet' column - ThesisCreateController::getIdentifier() helper for logging --- TODO.md | 20 +++ .../applied/001_add_objet_column.sql | 7 + app/migrations/run.php | 84 +++++++++++ app/public/admin/actions/formulaire.php | 12 ++ app/public/partage/index.php | 15 ++ app/src/AppLogger.php | 75 ++++++++++ .../Controllers/ThesisCreateController.php | 141 ++++++++++++++++-- app/storage/posterg.db | Bin 270336 -> 270336 bytes app/storage/test.db | Bin 602112 -> 610304 bytes 9 files changed, 344 insertions(+), 10 deletions(-) create mode 100644 app/migrations/applied/001_add_objet_column.sql create mode 100644 app/migrations/run.php create mode 100644 app/src/AppLogger.php 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 ae8059b5accdaea2b9ff8764569cabb45db49901..0c5c54209f6279e03a7cb29ac98c9e2c514ba7ea 100644 GIT binary patch delta 160 zcmZoTAkc6?U_-fnJvS!@vp8cxVtQ(^fFL`wI%7^|a%x_2YJ5p$L29vv02{L^V`6f0 zYH=|{gkOLaD3X?6l$%%r5mDx4VU}miNz6+x1)0Jn#LTS8n3kEBn3Gvj3Dv~U#H`Gi uSP)-Ol%HOdm|LtMzz8%YzbKO{HLoPGBr`v+*t^M~&497ZfN7Zl^9KO*O)y3P delta 160 zcmZoTAkc6?U_-fnJtrpzvp8cxVtQ&ZuOK_KI%7^|a%x_2YJ5p$L29vr02{L^V`6f0 zYH=|{ghzlCD3X?6l$%%r5s~3#VU}miNz6+x1)0Jj#LTS8n3kEBn3Gvj3Dv~U#H`Gi uSP)-Ol%HOdm|H9*zz8%YzbKO{HLoPGBr`v+*rv&#&497ZfN7Zl^9KOuqA(Qz diff --git a/app/storage/test.db b/app/storage/test.db index a917362920c6837995379425cdd0873f72b26ee3..9a054469c69e52e344650da0df804be983ed50c6 100644 GIT binary patch delta 485 zcmZp8pwh5Fb%L~@2Ll7cR3L@{^NBjfj2;^k#P#dBIXRfc84D8AQ;P)z*_qWDb25`t z^NLgBODYRei!}t;m{l1Qlao`6iyzGA|3WJY!B`UV16W6fPlV zW=+Pl%)G>$%#uo|CVnPnWyZvU_=2MR^rFPvVg&(4pegx9nOv!PC5a`O`FX|OO$KcS zjBN%?+YFdl9?0;pBr))}uq5%!=ds~F&lScc%6^7Dfp04BPQK91h6+wB+ml3CN;p{f zdKn&1HdMH8(kSW9F0QQ1)L34Un3R(mpPLD^32ZVFlVOT;kgH>et3rsQlaH&y^vU+j z4%5?08I^eyfI!pCgH7C1nQ3}sKcl!DlD2q5Bn~(vIoc=6u>dhE5VHX>I}mehpD4$f zFUZ9U3}#0D3k>`hwktkhJjQRO$H;7JY+zs*pO}&opP!VKS`wd}pHrHfr&nBz3@;0_JY!B`UV16W6b>O~ zW=+Pl%)G>$%#uo|CVnPnWyZvU_=2MR^rFPvVkrSepegx9nOv!PC5a`O`FX`QO$KcS zjBN%?+YFdl9!PMpBr))}uq5%!=ds~F&lScc%6^7Dfp6+&Lj^aM?MWgmB^)da42=7M c{N0T0Q{`BIm=%cGfS4VKIkr!g