Add SQLite database schema and documentation

Added complete database schema for Post-ERG thesis archive:
- schema.sql with full relational database structure
- README.md with schema documentation and usage examples
- SETUP.md with comprehensive setup and maintenance guide
- posterg_fiche-technique.md with technical specifications
- Database_TFE_test.csv and .ods with example data

Database features:
- Normalized relational schema (3NF)
- Support for multiple authors, supervisors, languages, formats, keywords
- Publication workflow (submission → defense → jury review → publication)
- Access control (Libre/Interne/Interdit)
- File attachments tracking
- Predefined reference tables for orientations, AP programs, finalities
- Views for simplified querying
- Automatic timestamps and cascade deletes
This commit is contained in:
Théophile Gervreau-Mercier
2026-01-27 15:19:06 +01:00
parent 0d3fc3ab9a
commit 99ccd60f90
116 changed files with 2695 additions and 429 deletions

36
formulaire/.htaccess Normal file
View File

@@ -0,0 +1,36 @@
# Security headers
<IfModule mod_headers.c>
# Prevent clickjacking
Header always set X-Frame-Options "SAMEORIGIN"
# Prevent MIME type sniffing
Header always set X-Content-Type-Options "nosniff"
# Enable XSS protection
Header always set X-XSS-Protection "1; mode=block"
# Referrer policy
Header always set Referrer-Policy "strict-origin-when-cross-origin"
# Content Security Policy (adjust as needed)
Header always set Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"
</IfModule>
# Prevent directory listing
Options -Indexes
# Protect sensitive files
<FilesMatch "^\.">
Require all denied
</FilesMatch>
<FilesMatch "(composer\.(json|lock)|error\.log)$">
Require all denied
</FilesMatch>
# PHP security settings (if .htaccess can override)
<IfModule mod_php.c>
php_flag display_errors Off
php_flag log_errors On
php_value error_log error.log
</IfModule>

163
formulaire/SECURITY.md Normal file
View File

@@ -0,0 +1,163 @@
# Security Improvements
## Changes Made
### 1. Critical Vulnerability Fixes
#### Path Traversal in thanks.php (CRITICAL)
- **Before**: User could access ANY file on the system via `?file=../../../../etc/passwd`
- **After**:
- Validates file path using `realpath()` to resolve symlinks
- Ensures file is within allowed `data/yaml/` directory
- Verifies file extension is `.yaml`
- Proper error handling without exposing system paths
#### CSRF Protection
- **Before**: Form could be submitted from any website
- **After**:
- Session-based CSRF tokens generated for each form load
- Token validated on submission using timing-safe comparison (`hash_equals()`)
- Token cleared after successful submission
### 2. Input Validation & Sanitization
#### Deprecated Functions Replaced
- **Before**: Used `FILTER_SANITIZE_STRING` (deprecated in PHP 8.1+)
- **After**: Custom `sanitize_string()` function using `htmlspecialchars()` and `strip_tags()`
#### Enhanced Validation
- Required fields properly validated with custom `validate_required()` function
- Email validation using `FILTER_VALIDATE_EMAIL`
- URL validation using `FILTER_VALIDATE_URL`
- Year validation with reasonable range checking (2000 to current year + 1)
- Comprehensive error messages for validation failures
### 3. File Upload Security
#### Random Filenames
- **Before**: Used original or predictable filenames (author + timestamp)
- **After**:
- Generates cryptographically secure random filenames using `random_bytes()`
- Prevents file overwrites
- Prevents path traversal attacks via malicious filenames
- Stores mapping to original filename for reference
#### Enhanced File Validation
- MIME type checking using `finfo`
- File extension whitelist
- File size limits (50MB max)
- Proper error handling for upload errors
- Cover image restricted to JPEG/PNG only
### 4. Bug Fixes
- Fixed undefined variable `$memoireFolder` (used before definition)
- Fixed undefined variable `$resume` (should be `$description`)
- Fixed variable ordering (generate `$uniqueId` before using it)
- Added proper `__DIR__` prefix for absolute paths
### 5. Error Handling
- Try-catch block wraps entire form processing
- Detailed error logging (not exposed to users)
- User-friendly error messages
- Proper exit after redirect
- No system path exposure in error messages
## Nginx Configuration Notes
Since this form is behind nginx password authentication, additional security layers:
### Recommended nginx config:
```nginx
location /formulaire {
auth_basic "Restricted Access";
auth_basic_user_file /etc/nginx/.htpasswd;
# Rate limiting
limit_req zone=form_limit burst=5 nodelay;
# File upload size
client_max_body_size 100M;
# Timeout settings
client_body_timeout 60s;
# Prevent access to sensitive files
location ~ /\. {
deny all;
}
location ~ /(vendor|composer\.(json|lock)|error\.log)$ {
deny all;
}
}
```
## Additional Recommendations
### 1. Database Migration (In Progress)
Moving to SQLite will provide:
- Structured data storage
- Better query capabilities
- Easier data management
- Prepared statements for SQL injection prevention
### 2. File Storage
- Consider moving uploaded files outside web root
- Serve files through PHP script with access control
- Implement file scanning for malware if possible
### 3. Monitoring
- Regularly review `error.log` for suspicious activity
- Monitor file upload patterns
- Set up alerts for failed CSRF validations
### 4. Backup Strategy
- Regular backups of `data/` directory
- Version control for code changes
- Test restore procedures
### 5. PHP Configuration
Ensure these settings in php.ini:
```ini
file_uploads = On
upload_max_filesize = 100M
post_max_size = 100M
max_execution_time = 60
max_input_time = 60
memory_limit = 256M
# Security
expose_php = Off
allow_url_fopen = Off
allow_url_include = Off
display_errors = Off
log_errors = On
```
## Testing Checklist
- [ ] Form submission with all fields
- [ ] Form submission with minimal required fields
- [ ] Invalid email format
- [ ] Invalid URL format
- [ ] Invalid year
- [ ] File upload (various formats)
- [ ] Large file upload (>50MB, should fail)
- [ ] Invalid file types
- [ ] Multiple file uploads
- [ ] Cover image upload
- [ ] CSRF token validation (try submitting with wrong token)
- [ ] Path traversal attempt in thanks.php
- [ ] Error handling for missing directories
## Known Limitations
1. **No atomic transactions**: File operations and YAML save not atomic
2. **No rollback**: Failed submissions may leave partial files
3. **Session storage**: CSRF tokens in default PHP session (consider database sessions)
4. **No upload progress**: Large files have no progress indicator
5. **No duplicate detection**: Same submission can be made multiple times
These limitations will be addressed in the SQLite migration.

View File

@@ -5,6 +5,16 @@ ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('error_log', 'error.log');
// Start session for CSRF protection
session_start();
// Verify CSRF token
if (!isset($_POST['csrf_token']) || !isset($_SESSION['csrf_token']) ||
!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
error_log("CSRF token validation failed");
die("Erreur de sécurité : token invalide. Veuillez recharger le formulaire.");
}
// Log the content of the $_FILES array
error_log("FILES array: " . print_r($_FILES, true));
@@ -12,136 +22,211 @@ require_once 'vendor/autoload.php';
use Symfony\Component\Yaml\Yaml;
use Behat\Transliterator\Transliterator;
// Helper function to sanitize string input (replacement for deprecated FILTER_SANITIZE_STRING)
function sanitize_string($input) {
return htmlspecialchars(strip_tags(trim($input)), ENT_QUOTES, 'UTF-8');
}
// Helper function to validate required field
function validate_required($value, $fieldName) {
if (empty($value)) {
throw new Exception("Le champ '$fieldName' est requis.");
}
return $value;
}
// Define variables
$yamlFolder = "data/yaml/";
$yamlFolder = __DIR__ . "/data/yaml/";
$date = date("Y-m-d");
$errors = [];
// Sanitize input data
$auteurice = filter_var($_POST["auteurice"], FILTER_SANITIZE_STRING);
$annee = filter_var($_POST["année"], FILTER_SANITIZE_NUMBER_INT);
$mail = filter_var($_POST["mail"], FILTER_SANITIZE_EMAIL);
$titre = filter_var($_POST["titre"], FILTER_SANITIZE_STRING);
$tag = filter_var($_POST["tag"], FILTER_SANITIZE_STRING);
$promoteurice = filter_var($_POST["promoteurice"], FILTER_SANITIZE_STRING);
$problematique = filter_var($_POST["problématique"], FILTER_SANITIZE_STRING);
$description = filter_var($_POST["description"], FILTER_SANITIZE_STRING);
$orientation = filter_var($_POST["orientation"], FILTER_SANITIZE_STRING);
$ap = filter_var($_POST["ap"], FILTER_SANITIZE_STRING);
$lien = filter_var($_POST["lien"], FILTER_SANITIZE_STRING);
$couverture = $_FILES["couverture"];
$files = $_FILES["files"];
try {
// Validate and sanitize input data with proper error handling
$auteurice = validate_required(sanitize_string($_POST["auteurice"] ?? ''), "Nom/Prénom/Pseudo");
// Transformation du string de mot-clé en un array.
$tagArray = explode(', ', $tag);
$coverFolder = $memoireFolder . "data/cover/";
if (!file_exists($coverFolder)) {
mkdir($coverFolder, 0755, true);
}
$couverturePath = "";
if ($couverture["error"] === UPLOAD_ERR_OK) {
$fileExtension = pathinfo($couverture["name"], PATHINFO_EXTENSION);
$newCouvertureName = $auteurice . "_" . $annee . "_" . $uniqueId . "." . $fileExtension;
$targetFile = $coverFolder . $newCouvertureName;
if (move_uploaded_file($couverture["tmp_name"], $targetFile)) {
chmod($targetFile, 0644);
$couverturePath = $targetFile;
} else {
error_log("Failed to move uploaded couverture file: " . $couverture["name"]);
$annee = filter_var($_POST["année"] ?? '', FILTER_VALIDATE_INT);
if ($annee === false || $annee < 2000 || $annee > (int)date('Y') + 1) {
throw new Exception("Année invalide. Veuillez entrer une année valide.");
}
}
$uploadedFiles = [];
$mail = filter_var($_POST["mail"] ?? '', FILTER_VALIDATE_EMAIL);
if ($mail === false && !empty($_POST["mail"])) {
throw new Exception("Adresse email invalide.");
}
// Create necessary directories
$memoireFolder = "data/content/{$annee}/{$auteurice}/";
if (!file_exists($yamlFolder)) {
mkdir($yamlFolder, 0755, true);
}
if (!file_exists($memoireFolder)) {
mkdir($memoireFolder, 0755, true);
}
$titre = validate_required(sanitize_string($_POST["titre"] ?? ''), "Titre du mémoire");
$tag = sanitize_string($_POST["tag"] ?? '');
$promoteurice = sanitize_string($_POST["promoteurice"] ?? '');
$problematique = sanitize_string($_POST["problématique"] ?? '');
$description = sanitize_string($_POST["description"] ?? '');
$targetDir = $memoireFolder;
$orientation = validate_required(sanitize_string($_POST["orientation"] ?? ''), "Orientation");
$ap = validate_required(sanitize_string($_POST["ap"] ?? ''), "Atelier Pratique");
// Generate unique file name
$uniqueId = time() . "_" . rand(1000, 9999);
$sanitizedAuteurice = Transliterator::transliterate($auteurice);
$uniqueFileName = $sanitizedAuteurice . "_" . $date . "_" . $uniqueId;
// Define security constraints
$allowedMimeTypes = ['image/jpeg', 'image/png', 'application/pdf', 'video/mp4', 'application/zip'];
$allowedExtensions = ['jpg', 'jpeg', 'png', 'pdf', 'mp4', 'zip'];
$maxFileSize = 50 * 1024 * 1024; // 50 MB
// Process uploaded files
if (is_array($files["name"])) {
for ($i = 0; $i < count($files["name"]); $i++) {
// Log the file being processed
error_log("Processing file: " . $files["name"][$i]);
// Check for file upload errors
if ($files["error"][$i] !== UPLOAD_ERR_OK) {
error_log("File upload error: " . $files["name"][$i]);
continue;
// Validate URL if provided
$lien = $_POST["lien"] ?? '';
if (!empty($lien)) {
$lien = filter_var($lien, FILTER_VALIDATE_URL);
if ($lien === false) {
throw new Exception("Lien URL invalide.");
}
}
// Check MIME type and file extension
$couverture = $_FILES["couverture"] ?? null;
$files = $_FILES["files"] ?? null;
// Transformation du string de mot-clé en un array.
$tagArray = !empty($tag) ? array_map('trim', explode(',', $tag)) : [];
// Generate unique identifiers FIRST (before using them)
$uniqueId = time() . "_" . rand(1000, 9999);
$sanitizedAuteurice = Transliterator::transliterate($auteurice);
$uniqueFileName = $sanitizedAuteurice . "_" . $date . "_" . $uniqueId;
// Create necessary directories
$memoireFolder = __DIR__ . "/data/content/{$annee}/{$auteurice}/";
$coverFolder = __DIR__ . "/data/cover/";
if (!file_exists($yamlFolder)) {
mkdir($yamlFolder, 0755, true);
}
if (!file_exists($memoireFolder)) {
mkdir($memoireFolder, 0755, true);
}
if (!file_exists($coverFolder)) {
mkdir($coverFolder, 0755, true);
}
$targetDir = $memoireFolder;
$uploadedFiles = [];
$couverturePath = "";
// Define security constraints
$allowedMimeTypes = ['image/jpeg', 'image/png', 'application/pdf', 'video/mp4', 'application/zip'];
$allowedExtensions = ['jpg', 'jpeg', 'png', 'pdf', 'mp4', 'zip'];
$maxFileSize = 50 * 1024 * 1024; // 50 MB
// Process cover image first
if ($couverture && isset($couverture["error"]) && $couverture["error"] === UPLOAD_ERR_OK) {
// Security: validate MIME type
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($files["tmp_name"][$i]);
$fileExtension = pathinfo($files["name"][$i], PATHINFO_EXTENSION);
$mimeType = $finfo->file($couverture["tmp_name"]);
$fileExtension = strtolower(pathinfo($couverture["name"], PATHINFO_EXTENSION));
if (!in_array($mimeType, $allowedMimeTypes) || !in_array($fileExtension, $allowedExtensions)) {
error_log("Invalid file type or extension: " . $files["name"][$i]);
continue;
}
// Only allow image files for cover
if (in_array($mimeType, ['image/jpeg', 'image/png']) &&
in_array($fileExtension, ['jpg', 'jpeg', 'png'])) {
// Check file size
if ($files["size"][$i] > $maxFileSize) {
error_log("File is too large: " . $files["name"][$i]);
continue;
}
// Move and set permissions for the uploaded file
$targetFile = $targetDir . basename($files["name"][$i]);
if (move_uploaded_file($files["tmp_name"][$i], $targetFile)) {
// Log successful file move
error_log("File successfully moved: " . $targetFile);
chmod($targetFile, 0644);
$uploadedFiles[] = $targetFile;
// Security: Generate random filename to prevent overwrites and path traversal
$randomName = bin2hex(random_bytes(16));
$newCouvertureName = $randomName . "." . $fileExtension;
$targetFile = $coverFolder . $newCouvertureName;
if (move_uploaded_file($couverture["tmp_name"], $targetFile)) {
chmod($targetFile, 0644);
$couverturePath = "data/cover/" . $newCouvertureName;
error_log("Cover image uploaded: " . $newCouvertureName);
} else {
error_log("Failed to move uploaded couverture file: " . $couverture["name"]);
}
} else {
error_log("Failed to move uploaded file: " . $files["name"][$i]);
error_log("Invalid cover image type: " . $mimeType);
}
}
// Process uploaded files
if ($files && is_array($files["name"])) {
for ($i = 0; $i < count($files["name"]); $i++) {
// Skip if no file was uploaded for this slot
if ($files["error"][$i] === UPLOAD_ERR_NO_FILE) {
continue;
}
// Log the file being processed
error_log("Processing file: " . $files["name"][$i]);
// Check for file upload errors
if ($files["error"][$i] !== UPLOAD_ERR_OK) {
error_log("File upload error code " . $files["error"][$i] . ": " . $files["name"][$i]);
continue;
}
// Check MIME type and file extension
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($files["tmp_name"][$i]);
$fileExtension = strtolower(pathinfo($files["name"][$i], PATHINFO_EXTENSION));
if (!in_array($mimeType, $allowedMimeTypes) || !in_array($fileExtension, $allowedExtensions)) {
error_log("Invalid file type or extension: " . $files["name"][$i] . " (MIME: $mimeType, Ext: $fileExtension)");
continue;
}
// Check file size
if ($files["size"][$i] > $maxFileSize) {
error_log("File is too large: " . $files["name"][$i] . " (" . $files["size"][$i] . " bytes)");
continue;
}
// Security: Generate random filename to prevent overwrites and path traversal
$randomName = bin2hex(random_bytes(16));
$safeFileName = $randomName . "." . $fileExtension;
$targetFile = $targetDir . $safeFileName;
if (move_uploaded_file($files["tmp_name"][$i], $targetFile)) {
// Log successful file move
error_log("File successfully moved: " . $safeFileName);
chmod($targetFile, 0644);
$uploadedFiles[] = [
'path' => "data/content/{$annee}/{$auteurice}/" . $safeFileName,
'original_name' => basename($files["name"][$i]),
'size' => $files["size"][$i]
];
} else {
error_log("Failed to move uploaded file: " . $files["name"][$i]);
}
}
}
// Prepare form data for YAML
$formData = [
'auteurice' => $auteurice,
'année' => $annee,
'email' => $mail ?: '',
'titre' => $titre,
'tag' => $tagArray,
'promoteurice' => $promoteurice,
'problématique' => $problematique,
'description' => $description, // Fixed: was $resume
'orientation' => $orientation,
'ap' => $ap,
'lien' => $lien,
'couverture' => $couverturePath,
'files' => $uploadedFiles
];
// Convert form data to YAML
$yamlData = Yaml::dump($formData);
// Save YAML file
$yamlFilePath = $yamlFolder . $uniqueFileName . ".yaml";
if (file_put_contents($yamlFilePath, $yamlData) === false) {
throw new Exception("Erreur lors de l'écriture du fichier YAML.");
}
error_log("Form submission saved: " . $yamlFilePath);
// Clear CSRF token after successful submission
unset($_SESSION['csrf_token']);
// Redirect to the thank you page
header('Location: thanks.php?file=' . urlencode($yamlFilePath));
exit();
} catch (Exception $e) {
error_log("Form processing error: " . $e->getMessage());
die("Erreur lors du traitement du formulaire : " . htmlspecialchars($e->getMessage()) .
"<br><br><a href='index.php'>Retour au formulaire</a>");
}
// Prepare form data for YAML
$formData = [
'auteurice' => $auteurice,
'année' => $annee,
'email' => $mail,
'titre' => $titre,
'tag' => $tagArray,
'promoteurice' => $promoteurice,
'problématique' => $problematique,
'description' => $resume,
'orientation' => $orientation,
'ap' => $ap,
'lien' => $lien,
'couverture' => $couverturePath,
'files' => $uploadedFiles
];
// Convert form data to YAML
$yamlData = Yaml::dump($formData);
// Save YAML file
$yamlFilePath = $yamlFolder . $uniqueFileName . ".yaml";
file_put_contents($yamlFilePath, $yamlData);
// Redirect to the thank you page
header('Location: thanks.php?file=' . urlencode($yamlFilePath));
?>

View File

@@ -1,4 +1,10 @@
<?php
// Start session and generate CSRF token
session_start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
@@ -19,6 +25,8 @@
<main>
<form action="formulaire.php" method="post" enctype="multipart/form-data">
<!-- CSRF Protection -->
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
<label>Nom/Prénom/Pseudo</label>
<input type="text" name="auteurice" placeholder="..." required>

View File

@@ -8,8 +8,39 @@ require 'vendor/autoload.php';
use Symfony\Component\Yaml\Yaml;
$yamlFile = isset($_GET['file']) ? urldecode($_GET['file']) : '';
$data = Yaml::parseFile($yamlFile);
// Security: Validate file parameter to prevent path traversal
$yamlFile = '';
$data = null;
$error = null;
if (isset($_GET['file'])) {
$requestedFile = urldecode($_GET['file']);
// Security: Only allow files from the yaml directory
$yamlFolder = realpath(__DIR__ . '/data/yaml/');
$requestedPath = realpath($requestedFile);
// Verify the file exists and is within the allowed directory
if ($requestedPath &&
$yamlFolder &&
strpos($requestedPath, $yamlFolder) === 0 &&
file_exists($requestedPath) &&
pathinfo($requestedPath, PATHINFO_EXTENSION) === 'yaml') {
try {
$data = Yaml::parseFile($requestedPath);
$yamlFile = $requestedPath;
} catch (Exception $e) {
error_log("Error parsing YAML file: " . $e->getMessage());
$error = "Erreur lors de la lecture du fichier.";
}
} else {
error_log("Invalid file access attempt: " . $requestedFile);
$error = "Fichier non valide ou accès refusé.";
}
} else {
$error = "Aucun fichier spécifié.";
}
?>
<!DOCTYPE html>
@@ -30,11 +61,19 @@ $data = Yaml::parseFile($yamlFile);
<h1>Merci 💜</h1>
</header>
<main>
<p>d'avoir rempli le formulaire. Le contenu soumis a été sauvegardé et est en attente de traitement.</p>
<?php if ($error): ?>
<p style="color: red;">⚠️ <?php echo htmlspecialchars($error); ?></p>
<p>Pour revenir au <a href="index.php">formulaire</a>.</p>
<?php elseif ($data): ?>
<p>d'avoir rempli le formulaire. Le contenu soumis a été sauvegardé et est en attente de traitement.</p>
<h4>Voici les informations que vous avez encodées dans le formulaire, affiché tel que c'est stocké, en yaml:</h4>
<pre><code><?php echo htmlspecialchars(Yaml::dump($data)); ?></code></pre>
<p>Pour revenir au <a href="index.php">formulaire</a>.</p>
<?php else: ?>
<p>Aucune donnée à afficher.</p>
<p>Pour revenir au <a href="index.php">formulaire</a>.</p>
<?php endif; ?>
</main>
<footer>
<p>Formulaire fait avec ❤ en PHP et <a href="https://github.com/kevquirk/simple.css">SimpleCSS</a>.</p>