feat: Release version 3.1.4 - Mode terrain et génération PDF

 Nouvelles fonctionnalités:
- Ajout du mode terrain pour utilisation mobile hors connexion
- Génération automatique de reçus PDF avec template personnalisé
- Révision complète du système de cartes avec amélioration des performances

🔧 Améliorations techniques:
- Refactoring du module chat avec architecture simplifiée
- Optimisation du système de sécurité NIST SP 800-63B
- Amélioration de la gestion des secteurs géographiques
- Support UTF-8 étendu pour les noms d'utilisateurs

📱 Application mobile:
- Nouveau mode terrain dans user_field_mode_page
- Interface utilisateur adaptative pour conditions difficiles
- Synchronisation offline améliorée

🗺️ Cartographie:
- Optimisation des performances MapBox
- Meilleure gestion des tuiles hors ligne
- Amélioration de l'affichage des secteurs

📄 Documentation:
- Ajout guide Android (ANDROID-GUIDE.md)
- Documentation sécurité API (API-SECURITY.md)
- Guide module chat (CHAT_MODULE.md)

🐛 Corrections:
- Résolution des erreurs 400 lors de la création d'utilisateurs
- Correction de la validation des noms d'utilisateurs
- Fix des problèmes de synchronisation chat

🤖 Generated with Claude Code (https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-08-19 19:38:03 +02:00
parent c1f23c4345
commit 5ab03751e1
1823 changed files with 272663 additions and 198438 deletions

View File

@@ -0,0 +1,875 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
require_once __DIR__ . '/../Services/LogService.php';
require_once __DIR__ . '/../Services/ApiService.php';
use PDO;
use PDOException;
use Database;
use Request;
use Response;
use Session;
use LogService;
use ApiService;
class ChatController {
private PDO $db;
public function __construct() {
$this->db = Database::getInstance();
}
/**
* GET /api/chat/rooms
* Liste des conversations filtrées par rôle et entité
*/
public function getRooms(): void {
Session::requireAuth();
try {
$userId = Session::getUserId();
$entityId = Session::getEntityId();
// Récupérer le rôle de l'utilisateur
$userRole = $this->getUserRole($userId);
// Construction de la requête selon le rôle
$sql = '
SELECT DISTINCT
r.id,
r.title,
r.type,
r.created_at,
r.created_by,
r.updated_at,
-- Dernier message
(SELECT m.content
FROM chat_messages m
WHERE m.room_id = r.id
AND m.is_deleted = 0
ORDER BY m.sent_at DESC
LIMIT 1) as last_message,
(SELECT m.sent_at
FROM chat_messages m
WHERE m.room_id = r.id
AND m.is_deleted = 0
ORDER BY m.sent_at DESC
LIMIT 1) as last_message_at,
-- Nombre de messages non lus
(SELECT COUNT(*)
FROM chat_messages m
WHERE m.room_id = r.id
AND m.sent_at > COALESCE(p.last_read_at, p.joined_at)
AND m.sender_id != :user_id_count) as unread_count,
-- Participants
(SELECT COUNT(*)
FROM chat_participants cp
WHERE cp.room_id = r.id
AND cp.left_at IS NULL) as participant_count
FROM chat_rooms r
INNER JOIN chat_participants p ON r.id = p.room_id
WHERE r.is_active = 1
AND p.user_id = :user_id
AND p.left_at IS NULL
';
// Filtrage supplémentaire selon le rôle
if ($userRole == 1) {
// Utilisateur simple : seulement ses conversations privées et de groupe
$sql .= ' AND r.type IN ("private", "group")';
} elseif ($userRole == 2) {
// Admin d'entité : toutes les conversations de son entité
$sql .= ' AND (p.entite_id = :entity_id OR r.type = "broadcast")';
}
// Rôle > 2 : accès à toutes les conversations
$sql .= ' ORDER BY COALESCE(last_message_at, r.created_at) DESC';
$stmt = $this->db->prepare($sql);
$params = [
'user_id' => $userId,
'user_id_count' => $userId
];
if ($userRole == 2) {
$params['entity_id'] = $entityId;
}
$stmt->execute($params);
$rooms = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Pour chaque room, récupérer les participants
foreach ($rooms as &$room) {
$room['participants'] = $this->getRoomParticipants($room['id']);
}
LogService::log('Récupération des conversations', [
'level' => 'debug',
'user_id' => $userId,
'room_count' => count($rooms)
]);
Response::json([
'status' => 'success',
'rooms' => $rooms
]);
} catch (PDOException $e) {
LogService::log('Erreur lors de la récupération des conversations', [
'level' => 'error',
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
}
}
/**
* POST /api/chat/rooms
* Créer une nouvelle conversation
*/
public function createRoom(): void {
Session::requireAuth();
try {
$data = Request::getJson();
$userId = Session::getUserId();
$entityId = Session::getEntityId();
$userRole = $this->getUserRole($userId);
// Validation des données
if (!isset($data['type']) || !in_array($data['type'], ['private', 'group', 'broadcast'])) {
Response::json([
'status' => 'error',
'message' => 'Type de conversation invalide'
], 400);
return;
}
// Vérification des permissions pour broadcast
if ($data['type'] === 'broadcast' && $userRole < 2) {
Response::json([
'status' => 'error',
'message' => 'Permissions insuffisantes pour créer une diffusion'
], 403);
return;
}
// Validation des participants
if (!isset($data['participants']) || !is_array($data['participants']) || empty($data['participants'])) {
Response::json([
'status' => 'error',
'message' => 'Au moins un participant requis'
], 400);
return;
}
// Pour une conversation privée, limiter à 2 participants (incluant le créateur)
if ($data['type'] === 'private' && count($data['participants']) > 1) {
Response::json([
'status' => 'error',
'message' => 'Une conversation privée ne peut avoir que 2 participants'
], 400);
return;
}
// Vérifier que tous les participants existent et sont accessibles
$participantIds = array_map('intval', $data['participants']);
if (!in_array($userId, $participantIds)) {
$participantIds[] = $userId; // Ajouter le créateur
}
// Vérifier l'existence d'une conversation privée existante
if ($data['type'] === 'private' && count($participantIds) === 2) {
$existingRoom = $this->findExistingPrivateRoom($participantIds[0], $participantIds[1]);
if ($existingRoom) {
Response::json([
'status' => 'success',
'room' => $existingRoom,
'existing' => true
]);
return;
}
}
// Générer un UUID pour la room
$roomId = $this->generateUUID();
// Titre de la conversation
$title = $data['title'] ?? null;
if (!$title && $data['type'] === 'private') {
// Pour une conversation privée, pas de titre par défaut
$title = null;
}
$this->db->beginTransaction();
try {
// Créer la room
$stmt = $this->db->prepare('
INSERT INTO chat_rooms (id, title, type, created_by, created_at)
VALUES (:id, :title, :type, :created_by, NOW())
');
$stmt->execute([
'id' => $roomId,
'title' => $title,
'type' => $data['type'],
'created_by' => $userId
]);
// Ajouter les participants
$participantStmt = $this->db->prepare('
INSERT INTO chat_participants (room_id, user_id, role, entite_id, is_admin)
VALUES (:room_id, :user_id, :role, :entite_id, :is_admin)
');
foreach ($participantIds as $participantId) {
$participantData = $this->getUserData($participantId);
if (!$participantData) {
throw new \Exception("Participant invalide: $participantId");
}
$participantStmt->execute([
'room_id' => $roomId,
'user_id' => $participantId,
'role' => $participantData['fk_role'],
'entite_id' => $participantData['fk_entite'],
'is_admin' => ($participantId === $userId) ? 1 : 0
]);
}
// Si un message initial est fourni, l'envoyer
if (isset($data['initial_message']) && !empty($data['initial_message'])) {
$messageId = $this->generateUUID();
$msgStmt = $this->db->prepare('
INSERT INTO chat_messages (id, room_id, content, sender_id, sent_at)
VALUES (:id, :room_id, :content, :sender_id, NOW())
');
$msgStmt->execute([
'id' => $messageId,
'room_id' => $roomId,
'content' => $data['initial_message'],
'sender_id' => $userId
]);
}
$this->db->commit();
LogService::log('Conversation créée', [
'level' => 'info',
'room_id' => $roomId,
'type' => $data['type'],
'created_by' => $userId,
'participant_count' => count($participantIds)
]);
// Récupérer la room créée avec ses détails
$room = $this->getRoomDetails($roomId);
Response::json([
'status' => 'success',
'room' => $room
], 201);
} catch (\Exception $e) {
$this->db->rollBack();
throw $e;
}
} catch (PDOException $e) {
LogService::log('Erreur lors de la création de la conversation', [
'level' => 'error',
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
} catch (\Exception $e) {
LogService::log('Erreur lors de la création de la conversation', [
'level' => 'error',
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => $e->getMessage()
], 400);
}
}
/**
* GET /api/chat/rooms/{id}/messages
* Récupérer les messages d'une conversation
*/
public function getRoomMessages(string $roomId): void {
Session::requireAuth();
try {
$userId = Session::getUserId();
// Vérifier que l'utilisateur est participant
if (!$this->isUserInRoom($userId, $roomId)) {
Response::json([
'status' => 'error',
'message' => 'Accès non autorisé à cette conversation'
], 403);
return;
}
// Paramètres de pagination
$limit = isset($_GET['limit']) ? min(100, max(1, (int)$_GET['limit'])) : 50;
$before = $_GET['before'] ?? null; // Message ID pour pagination
$sql = '
SELECT
m.id,
m.content,
m.sender_id,
m.sent_at,
m.edited_at,
m.is_deleted,
u.encrypted_name as sender_name,
u.first_name as sender_first_name,
-- Statut de lecture
(SELECT COUNT(*)
FROM chat_read_receipts r
WHERE r.message_id = m.id) as read_count,
(SELECT COUNT(*)
FROM chat_read_receipts r
WHERE r.message_id = m.id
AND r.user_id = :user_id) as is_read
FROM chat_messages m
INNER JOIN users u ON m.sender_id = u.id
WHERE m.room_id = :room_id
';
$params = [
'room_id' => $roomId,
'user_id' => $userId
];
if ($before) {
$sql .= ' AND m.sent_at < (SELECT sent_at FROM chat_messages WHERE id = :before)';
$params['before'] = $before;
}
$sql .= ' ORDER BY m.sent_at DESC LIMIT :limit';
$stmt = $this->db->prepare($sql);
foreach ($params as $key => $value) {
if ($key === 'limit') {
$stmt->bindValue($key, $value, PDO::PARAM_INT);
} else {
$stmt->bindValue($key, $value);
}
}
$stmt->bindValue('limit', $limit, PDO::PARAM_INT);
$stmt->execute();
$messages = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Déchiffrer les noms
foreach ($messages as &$message) {
$message['sender_name'] = ApiService::decryptData($message['sender_name']);
$message['is_read'] = (bool)$message['is_read'];
$message['is_mine'] = ($message['sender_id'] == $userId);
}
// Inverser pour avoir l'ordre chronologique
$messages = array_reverse($messages);
// Mettre à jour last_read_at pour ce participant
$this->updateLastRead($roomId, $userId);
Response::json([
'status' => 'success',
'messages' => $messages,
'has_more' => count($messages) === $limit
]);
} catch (PDOException $e) {
LogService::log('Erreur lors de la récupération des messages', [
'level' => 'error',
'room_id' => $roomId,
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
}
}
/**
* POST /api/chat/rooms/{id}/messages
* Envoyer un message dans une conversation
*/
public function sendMessage(string $roomId): void {
Session::requireAuth();
try {
$data = Request::getJson();
$userId = Session::getUserId();
// Vérifier que l'utilisateur est participant
if (!$this->isUserInRoom($userId, $roomId)) {
Response::json([
'status' => 'error',
'message' => 'Accès non autorisé à cette conversation'
], 403);
return;
}
// Validation du contenu
if (!isset($data['content']) || empty(trim($data['content']))) {
Response::json([
'status' => 'error',
'message' => 'Le message ne peut pas être vide'
], 400);
return;
}
$content = trim($data['content']);
// Limiter la longueur du message
if (mb_strlen($content, 'UTF-8') > 5000) {
Response::json([
'status' => 'error',
'message' => 'Message trop long (max 5000 caractères)'
], 400);
return;
}
$messageId = $this->generateUUID();
// Insérer le message
$stmt = $this->db->prepare('
INSERT INTO chat_messages (id, room_id, content, sender_id, sent_at)
VALUES (:id, :room_id, :content, :sender_id, NOW())
');
$stmt->execute([
'id' => $messageId,
'room_id' => $roomId,
'content' => $content,
'sender_id' => $userId
]);
// Mettre à jour la date de dernière modification de la room
$updateStmt = $this->db->prepare('
UPDATE chat_rooms
SET updated_at = NOW()
WHERE id = :room_id
');
$updateStmt->execute(['room_id' => $roomId]);
// Récupérer le message créé avec les infos du sender
$msgStmt = $this->db->prepare('
SELECT
m.id,
m.content,
m.sender_id,
m.sent_at,
u.encrypted_name as sender_name,
u.first_name as sender_first_name
FROM chat_messages m
INNER JOIN users u ON m.sender_id = u.id
WHERE m.id = :id
');
$msgStmt->execute(['id' => $messageId]);
$message = $msgStmt->fetch(PDO::FETCH_ASSOC);
$message['sender_name'] = ApiService::decryptData($message['sender_name']);
$message['is_mine'] = true;
$message['is_read'] = false;
$message['read_count'] = 0;
LogService::log('Message envoyé', [
'level' => 'debug',
'room_id' => $roomId,
'message_id' => $messageId,
'sender_id' => $userId
]);
Response::json([
'status' => 'success',
'message' => $message
], 201);
} catch (PDOException $e) {
LogService::log('Erreur lors de l\'envoi du message', [
'level' => 'error',
'room_id' => $roomId,
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
}
}
/**
* POST /api/chat/rooms/{id}/read
* Marquer les messages comme lus
*/
public function markAsRead(string $roomId): void {
Session::requireAuth();
try {
$data = Request::getJson();
$userId = Session::getUserId();
// Vérifier que l'utilisateur est participant
if (!$this->isUserInRoom($userId, $roomId)) {
Response::json([
'status' => 'error',
'message' => 'Accès non autorisé à cette conversation'
], 403);
return;
}
// Si des message_ids spécifiques sont fournis
if (isset($data['message_ids']) && is_array($data['message_ids'])) {
$messageIds = $data['message_ids'];
// Marquer ces messages spécifiques comme lus
$stmt = $this->db->prepare('
INSERT IGNORE INTO chat_read_receipts (message_id, user_id, read_at)
VALUES (:message_id, :user_id, NOW())
');
foreach ($messageIds as $messageId) {
$stmt->execute([
'message_id' => $messageId,
'user_id' => $userId
]);
}
} else {
// Marquer tous les messages non lus de la room comme lus
$stmt = $this->db->prepare('
INSERT IGNORE INTO chat_read_receipts (message_id, user_id, read_at)
SELECT m.id, :user_id, NOW()
FROM chat_messages m
WHERE m.room_id = :room_id
AND m.id NOT IN (
SELECT message_id
FROM chat_read_receipts
WHERE user_id = :user_id_check
)
');
$stmt->execute([
'user_id' => $userId,
'user_id_check' => $userId,
'room_id' => $roomId
]);
}
// Mettre à jour last_read_at
$this->updateLastRead($roomId, $userId);
// Compter les messages non lus restants
$countStmt = $this->db->prepare('
SELECT COUNT(*) as unread_count
FROM chat_messages m
WHERE m.room_id = :room_id
AND m.sender_id != :user_id
AND m.id NOT IN (
SELECT message_id
FROM chat_read_receipts
WHERE user_id = :user_id_check
)
');
$countStmt->execute([
'room_id' => $roomId,
'user_id' => $userId,
'user_id_check' => $userId
]);
$result = $countStmt->fetch(PDO::FETCH_ASSOC);
Response::json([
'status' => 'success',
'unread_count' => (int)$result['unread_count']
]);
} catch (PDOException $e) {
LogService::log('Erreur lors du marquage comme lu', [
'level' => 'error',
'room_id' => $roomId,
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
}
}
/**
* GET /api/chat/recipients
* Liste des destinataires possibles selon le rôle
*/
public function getRecipients(): void {
Session::requireAuth();
try {
$userId = Session::getUserId();
$entityId = Session::getEntityId();
$userRole = $this->getUserRole($userId);
$sql = '
SELECT
u.id,
u.encrypted_name as name,
u.first_name,
u.sect_name,
u.fk_role as role,
u.fk_entite as entite_id,
e.encrypted_name as entite_name
FROM users u
LEFT JOIN entites e ON u.fk_entite = e.id
WHERE u.chk_active = 1
AND u.id != :user_id
';
$params = ['user_id' => $userId];
// Filtrage selon le rôle
if ($userRole == 1) {
// Utilisateur simple : seulement les utilisateurs de son entité
$sql .= ' AND u.fk_entite = :entity_id';
$params['entity_id'] = $entityId;
} elseif ($userRole == 2) {
// Admin d'entité :
// - Tous les membres actifs de son amicale (même entité)
// - Les super-admins (fk_role=9) de l'entité 1
$sql .= ' AND (
u.fk_entite = :entity_id
OR (u.fk_role = 9 AND u.fk_entite = 1)
)';
$params['entity_id'] = $entityId;
} elseif ($userRole == 9) {
// Super-administrateur :
// - Seulement les administrateurs actifs des amicales (fk_role=2)
// - Et les autres super-admins (fk_role=9)
$sql .= ' AND (u.fk_role = 2 OR u.fk_role = 9)';
}
// Autres rôles (3-8) : pas de filtrage spécifique pour le moment
$sql .= ' ORDER BY u.fk_entite, u.encrypted_name';
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$recipients = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Déchiffrer les données et organiser par entité
$recipientsByEntity = [];
$recipientsDecrypted = [];
foreach ($recipients as &$recipient) {
// Déchiffrer le nom
$recipient['name'] = ApiService::decryptData($recipient['name']);
// Déchiffrer le nom de l'entité
$entiteName = $recipient['entite_name'] ?
ApiService::decryptData($recipient['entite_name']) :
'Sans entité';
// Créer une copie pour recipients_by_entity
$recipientCopy = $recipient;
unset($recipientCopy['entite_name']);
// Organiser par entité
if (!isset($recipientsByEntity[$entiteName])) {
$recipientsByEntity[$entiteName] = [];
}
$recipientsByEntity[$entiteName][] = $recipientCopy;
// Remplacer entite_name chiffré par la version déchiffrée
$recipient['entite_name'] = $entiteName;
// Ajouter à la liste déchiffrée
$recipientsDecrypted[] = $recipient;
}
Response::json([
'status' => 'success',
'recipients' => $recipientsDecrypted,
'recipients_by_entity' => $recipientsByEntity
]);
} catch (PDOException $e) {
LogService::log('Erreur lors de la récupération des destinataires', [
'level' => 'error',
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
}
}
// ===== Méthodes utilitaires privées =====
/**
* Récupérer le rôle d'un utilisateur
*/
private function getUserRole(int $userId): int {
$stmt = $this->db->prepare('SELECT fk_role FROM users WHERE id = ?');
$stmt->execute([$userId]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result ? (int)$result['fk_role'] : 1;
}
/**
* Récupérer les données d'un utilisateur
*/
private function getUserData(int $userId): ?array {
$stmt = $this->db->prepare('
SELECT id, fk_role, fk_entite, encrypted_name, first_name
FROM users
WHERE id = ? AND chk_active = 1
');
$stmt->execute([$userId]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result ?: null;
}
/**
* Vérifier si un utilisateur est dans une room
*/
private function isUserInRoom(int $userId, string $roomId): bool {
$stmt = $this->db->prepare('
SELECT COUNT(*) as count
FROM chat_participants
WHERE room_id = ?
AND user_id = ?
AND left_at IS NULL
');
$stmt->execute([$roomId, $userId]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result && $result['count'] > 0;
}
/**
* Récupérer les participants d'une room
*/
private function getRoomParticipants(string $roomId): array {
$stmt = $this->db->prepare('
SELECT
p.user_id,
p.is_admin,
u.encrypted_name as name,
u.first_name
FROM chat_participants p
INNER JOIN users u ON p.user_id = u.id
WHERE p.room_id = ?
AND p.left_at IS NULL
');
$stmt->execute([$roomId]);
$participants = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($participants as &$participant) {
$participant['name'] = ApiService::decryptData($participant['name']);
}
return $participants;
}
/**
* Récupérer les détails d'une room
*/
private function getRoomDetails(string $roomId): array {
$stmt = $this->db->prepare('
SELECT
r.id,
r.title,
r.type,
r.created_at,
r.created_by,
r.updated_at
FROM chat_rooms r
WHERE r.id = ?
');
$stmt->execute([$roomId]);
$room = $stmt->fetch(PDO::FETCH_ASSOC);
if ($room) {
$room['participants'] = $this->getRoomParticipants($roomId);
}
return $room;
}
/**
* Trouver une conversation privée existante entre deux utilisateurs
*/
private function findExistingPrivateRoom(int $user1, int $user2): ?array {
$stmt = $this->db->prepare('
SELECT r.*
FROM chat_rooms r
WHERE r.type = "private"
AND r.is_active = 1
AND EXISTS (
SELECT 1 FROM chat_participants p1
WHERE p1.room_id = r.id
AND p1.user_id = ?
AND p1.left_at IS NULL
)
AND EXISTS (
SELECT 1 FROM chat_participants p2
WHERE p2.room_id = r.id
AND p2.user_id = ?
AND p2.left_at IS NULL
)
AND (
SELECT COUNT(*)
FROM chat_participants p
WHERE p.room_id = r.id
AND p.left_at IS NULL
) = 2
');
$stmt->execute([$user1, $user2]);
$room = $stmt->fetch(PDO::FETCH_ASSOC);
if ($room) {
$room['participants'] = $this->getRoomParticipants($room['id']);
return $room;
}
return null;
}
/**
* Mettre à jour la date de dernière lecture
*/
private function updateLastRead(string $roomId, int $userId): void {
$stmt = $this->db->prepare('
UPDATE chat_participants
SET last_read_at = NOW()
WHERE room_id = ?
AND user_id = ?
');
$stmt->execute([$roomId, $userId]);
}
/**
* Générer un UUID v4
*/
private function generateUUID(): string {
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
}

View File

@@ -20,6 +20,9 @@ use ApiService;
require_once __DIR__ . '/../Services/LogService.php';
require_once __DIR__ . '/../Services/ApiService.php';
require_once __DIR__ . '/EntiteController.php';
require_once __DIR__ . '/../Services/Security/SecurityMonitor.php';
use App\Services\Security\SecurityMonitor;
class LoginController {
private PDO $db;
@@ -76,6 +79,11 @@ class LoginController {
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
// Enregistrer la tentative échouée
$clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null;
SecurityMonitor::recordFailedLogin($clientIp, $username, 'user_not_found', $userAgent);
LogService::log('Tentative de connexion GeoSector échouée : utilisateur non trouvé', [
'level' => 'warning',
'username' => $username
@@ -88,6 +96,11 @@ class LoginController {
$passwordValid = password_verify($data['password'], $user['user_pass_hash']);
if (!$passwordValid) {
// Enregistrer la tentative échouée
$clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null;
SecurityMonitor::recordFailedLogin($clientIp, $username, 'invalid_password', $userAgent);
LogService::log('Tentative de connexion GeoSector échouée : mot de passe incorrect', [
'level' => 'warning',
'username' => $username
@@ -769,6 +782,88 @@ class LoginController {
$response['regions'] = $regionsData;
}
// Ajout des informations du module chat
$chatData = [];
// Récupérer le nombre total de conversations de l'utilisateur
$roomCountStmt = $this->db->prepare('
SELECT COUNT(DISTINCT r.id) as total_rooms
FROM chat_rooms r
INNER JOIN chat_participants p ON r.id = p.room_id
WHERE p.user_id = :user_id
AND p.left_at IS NULL
AND r.is_active = 1
');
$roomCountStmt->execute(['user_id' => $user['id']]);
$roomCount = $roomCountStmt->fetch(PDO::FETCH_ASSOC);
$chatData['total_rooms'] = (int)($roomCount['total_rooms'] ?? 0);
// Récupérer le nombre de messages non lus
$unreadStmt = $this->db->prepare('
SELECT COUNT(*) as unread_count
FROM chat_messages m
INNER JOIN chat_participants p ON m.room_id = p.room_id
WHERE p.user_id = :user_id
AND p.left_at IS NULL
AND m.sender_id != :sender_id
AND m.sent_at > COALESCE(p.last_read_at, p.joined_at)
AND m.is_deleted = 0
');
$unreadStmt->execute([
'user_id' => $user['id'],
'sender_id' => $user['id']
]);
$unreadResult = $unreadStmt->fetch(PDO::FETCH_ASSOC);
$chatData['unread_messages'] = (int)($unreadResult['unread_count'] ?? 0);
// Récupérer la dernière conversation active (optionnel, pour affichage rapide)
$lastRoomStmt = $this->db->prepare('
SELECT
r.id,
r.title,
r.type,
(SELECT m.content
FROM chat_messages m
WHERE m.room_id = r.id
AND m.is_deleted = 0
ORDER BY m.sent_at DESC
LIMIT 1) as last_message,
(SELECT m.sent_at
FROM chat_messages m
WHERE m.room_id = r.id
AND m.is_deleted = 0
ORDER BY m.sent_at DESC
LIMIT 1) as last_message_at
FROM chat_rooms r
INNER JOIN chat_participants p ON r.id = p.room_id
WHERE p.user_id = :user_id
AND p.left_at IS NULL
AND r.is_active = 1
ORDER BY COALESCE(
(SELECT MAX(m.sent_at) FROM chat_messages m WHERE m.room_id = r.id),
r.created_at
) DESC
LIMIT 1
');
$lastRoomStmt->execute(['user_id' => $user['id']]);
$lastRoom = $lastRoomStmt->fetch(PDO::FETCH_ASSOC);
if ($lastRoom) {
$chatData['last_active_room'] = [
'id' => $lastRoom['id'],
'title' => $lastRoom['title'],
'type' => $lastRoom['type'],
'last_message' => $lastRoom['last_message'],
'last_message_at' => $lastRoom['last_message_at']
];
}
// Indicateur si le chat est disponible pour cet utilisateur
$chatData['chat_enabled'] = true; // Peut être conditionné selon le rôle ou l'entité
// Ajouter les données du chat à la réponse
$response['chat'] = $chatData;
// Envoi de la réponse
Response::json($response);
} catch (PDOException $e) {

View File

@@ -6,6 +6,7 @@ namespace App\Controllers;
require_once __DIR__ . '/../Services/LogService.php';
require_once __DIR__ . '/../Services/ApiService.php';
require_once __DIR__ . '/../Services/ReceiptService.php';
use PDO;
use PDOException;
@@ -551,10 +552,44 @@ class PassageController {
'operationId' => $operationId
]);
// Générer automatiquement un reçu si c'est un don (fk_type = 1) avec email valide
$receiptGenerated = false;
if (isset($data['fk_type']) && (int)$data['fk_type'] === 1) {
// Vérifier si un email a été fourni
$hasEmail = false;
if (!empty($data['email'])) {
$hasEmail = filter_var($data['email'], FILTER_VALIDATE_EMAIL) !== false;
} elseif (!empty($encryptedEmail)) {
// L'email a déjà été validé lors du chiffrement
$hasEmail = true;
}
if ($hasEmail) {
try {
$receiptService = new \App\Services\ReceiptService();
$receiptGenerated = $receiptService->generateReceiptForPassage($passageId);
if ($receiptGenerated) {
LogService::log('Reçu généré automatiquement pour le passage', [
'level' => 'info',
'passageId' => $passageId
]);
}
} catch (Exception $e) {
LogService::log('Erreur lors de la génération automatique du reçu', [
'level' => 'warning',
'error' => $e->getMessage(),
'passageId' => $passageId
]);
}
}
}
Response::json([
'status' => 'success',
'message' => 'Passage créé avec succès',
'passage_id' => $passageId
'passage_id' => $passageId,
'receipt_generated' => $receiptGenerated
], 201);
} catch (Exception $e) {
LogService::log('Erreur lors de la création du passage', [
@@ -705,9 +740,52 @@ class PassageController {
'passageId' => $passageId
]);
// Vérifier si un reçu doit être généré après la mise à jour
$receiptGenerated = false;
// Récupérer les données actualisées du passage
$stmt = $this->db->prepare('SELECT fk_type, encrypted_email, nom_recu FROM ope_pass WHERE id = ?');
$stmt->execute([$passageId]);
$updatedPassage = $stmt->fetch(PDO::FETCH_ASSOC);
if ($updatedPassage) {
// Générer un reçu si :
// - C'est un don (fk_type = 1)
// - Il y a un email valide
// - Il n'y a pas encore de reçu (nom_recu est vide ou null)
if ((int)$updatedPassage['fk_type'] === 1 &&
!empty($updatedPassage['encrypted_email']) &&
empty($updatedPassage['nom_recu'])) {
// Vérifier que l'email est valide en le déchiffrant
try {
$email = ApiService::decryptSearchableData($updatedPassage['encrypted_email']);
if (!empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$receiptService = new \App\Services\ReceiptService();
$receiptGenerated = $receiptService->generateReceiptForPassage($passageId);
if ($receiptGenerated) {
LogService::log('Reçu généré automatiquement après mise à jour du passage', [
'level' => 'info',
'passageId' => $passageId
]);
}
}
} catch (Exception $e) {
LogService::log('Erreur lors de la génération automatique du reçu après mise à jour', [
'level' => 'warning',
'error' => $e->getMessage(),
'passageId' => $passageId
]);
}
}
}
Response::json([
'status' => 'success',
'message' => 'Passage mis à jour avec succès'
'message' => 'Passage mis à jour avec succès',
'receipt_generated' => $receiptGenerated
], 200);
} catch (Exception $e) {
LogService::log('Erreur lors de la mise à jour du passage', [
@@ -800,4 +878,150 @@ class PassageController {
], 500);
}
}
/**
* Récupère le reçu PDF d'un passage
*
* @param string $id ID du passage
* @return void
*/
public function getReceipt(string $id): void {
try {
$userId = Session::getUserId();
if (!$userId) {
Response::json([
'status' => 'error',
'message' => 'Vous devez être connecté pour effectuer cette action'
], 401);
return;
}
$passageId = (int)$id;
// Vérifier que le passage existe et que l'utilisateur y a accès
$entiteId = $this->getUserEntiteId($userId);
if (!$entiteId) {
Response::json([
'status' => 'error',
'message' => 'Entité non trouvée pour cet utilisateur'
], 404);
return;
}
// Récupérer les informations du passage et du reçu
$stmt = $this->db->prepare('
SELECT p.id, p.nom_recu, p.date_creat_recu, p.fk_operation, o.fk_entite
FROM ope_pass p
INNER JOIN operations o ON p.fk_operation = o.id
WHERE p.id = ? AND o.fk_entite = ? AND p.chk_active = 1
');
$stmt->execute([$passageId, $entiteId]);
$passage = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$passage) {
Response::json([
'status' => 'error',
'message' => 'Passage non trouvé ou accès non autorisé'
], 404);
return;
}
if (empty($passage['nom_recu'])) {
Response::json([
'status' => 'error',
'message' => 'Aucun reçu disponible pour ce passage'
], 404);
return;
}
// Récupérer le fichier depuis la table medias
$stmt = $this->db->prepare('
SELECT file_path, mime_type, file_size, fichier
FROM medias
WHERE support = ? AND support_id = ? AND file_category = ?
ORDER BY created_at DESC
LIMIT 1
');
$stmt->execute(['passage', $passageId, 'recu']);
$media = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$media) {
// Si pas trouvé dans medias, essayer de construire le chemin
$filePath = __DIR__ . '/../../uploads/entites/' . $passage['fk_entite'] .
'/recus/' . $passage['fk_operation'] . '/' . $passage['nom_recu'];
if (!file_exists($filePath)) {
Response::json([
'status' => 'error',
'message' => 'Fichier reçu introuvable'
], 404);
return;
}
$media = [
'file_path' => $filePath,
'mime_type' => 'application/pdf',
'fichier' => $passage['nom_recu'],
'file_size' => filesize($filePath)
];
}
// Vérifier que le fichier existe
if (!file_exists($media['file_path'])) {
Response::json([
'status' => 'error',
'message' => 'Fichier reçu introuvable sur le serveur'
], 404);
return;
}
// Lire le contenu du fichier
$pdfContent = file_get_contents($media['file_path']);
if ($pdfContent === false) {
Response::json([
'status' => 'error',
'message' => 'Impossible de lire le fichier reçu'
], 500);
return;
}
// Option 1: Retourner le PDF directement (pour téléchargement)
if (isset($_GET['download']) && $_GET['download'] === 'true') {
header('Content-Type: ' . $media['mime_type']);
header('Content-Disposition: attachment; filename="' . $media['fichier'] . '"');
header('Content-Length: ' . $media['file_size']);
header('Cache-Control: no-cache, must-revalidate');
echo $pdfContent;
exit;
}
// Option 2: Retourner le PDF en base64 dans JSON (pour Flutter)
$base64 = base64_encode($pdfContent);
Response::json([
'status' => 'success',
'receipt' => [
'passage_id' => $passageId,
'file_name' => $media['fichier'],
'mime_type' => $media['mime_type'],
'file_size' => $media['file_size'],
'created_at' => $passage['date_creat_recu'],
'data_base64' => $base64
]
], 200);
} catch (Exception $e) {
LogService::log('Erreur lors de la récupération du reçu', [
'level' => 'error',
'error' => $e->getMessage(),
'passageId' => $id,
'userId' => $userId ?? null
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la récupération du reçu'
], 500);
}
}
}

View File

@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
require_once __DIR__ . '/../Services/Security/AlertService.php';
require_once __DIR__ . '/../Services/Security/SecurityMonitor.php';
require_once __DIR__ . '/../Services/Security/PerformanceMonitor.php';
require_once __DIR__ . '/../Services/Security/IPBlocker.php';
require_once __DIR__ . '/../Services/Security/EmailThrottler.php';
use App\Services\Security\AlertService;
use App\Services\Security\SecurityMonitor;
use App\Services\Security\PerformanceMonitor;
use App\Services\Security\IPBlocker;
use App\Services\Security\EmailThrottler;
use Database;
use Session;
use Response;
use Request;
class SecurityController {
/**
* Obtenir les métriques de performance
* GET /api/admin/metrics
*/
public function getMetrics(): void {
// Vérifier l'authentification et les droits admin
if (!Session::isLoggedIn() || Session::getRole() < 2) {
Response::json(['error' => 'Unauthorized'], 401);
return;
}
$endpoint = Request::getQuery('endpoint');
$hours = (int)(Request::getQuery('hours') ?? 24);
$stats = PerformanceMonitor::getStats($endpoint, $hours);
Response::json([
'success' => true,
'data' => $stats
]);
}
/**
* Obtenir les alertes actives
* GET /api/admin/alerts
*/
public function getAlerts(): void {
// Vérifier l'authentification et les droits admin
if (!Session::isLoggedIn() || Session::getRole() < 2) {
Response::json(['error' => 'Unauthorized'], 401);
return;
}
$limit = (int)(Request::getQuery('limit') ?? 50);
$alerts = AlertService::getActiveAlerts($limit);
Response::json([
'success' => true,
'data' => $alerts
]);
}
/**
* Résoudre une alerte
* POST /api/admin/alerts/:id/resolve
*/
public function resolveAlert(string $id): void {
// Vérifier l'authentification et les droits admin
if (!Session::isLoggedIn() || Session::getRole() < 2) {
Response::json(['error' => 'Unauthorized'], 401);
return;
}
$data = Request::getJson();
$notes = $data['notes'] ?? '';
$userId = Session::getUserId();
$success = AlertService::resolve((int)$id, $userId, $notes);
Response::json([
'success' => $success,
'message' => $success ? 'Alerte résolue' : 'Erreur lors de la résolution'
]);
}
/**
* Obtenir les IPs bloquées
* GET /api/admin/blocked-ips
*/
public function getBlockedIPs(): void {
// Vérifier l'authentification et les droits admin
if (!Session::isLoggedIn() || Session::getRole() < 2) {
Response::json(['error' => 'Unauthorized'], 401);
return;
}
$activeOnly = Request::getQuery('active_only') !== 'false';
$ips = IPBlocker::getBlockedIPs($activeOnly);
Response::json([
'success' => true,
'data' => $ips
]);
}
/**
* Débloquer une IP
* POST /api/admin/unblock-ip
*/
public function unblockIP(): void {
// Vérifier l'authentification et les droits admin
if (!Session::isLoggedIn() || Session::getRole() < 2) {
Response::json(['error' => 'Unauthorized'], 401);
return;
}
$data = Request::getJson();
if (!isset($data['ip'])) {
Response::json(['error' => 'IP address required'], 400);
return;
}
$userId = Session::getUserId();
$success = IPBlocker::unblock($data['ip'], $userId);
Response::json([
'success' => $success,
'message' => $success ? 'IP débloquée' : 'Erreur lors du déblocage'
]);
}
/**
* Bloquer une IP manuellement
* POST /api/admin/block-ip
*/
public function blockIP(): void {
// Vérifier l'authentification et les droits admin
if (!Session::isLoggedIn() || Session::getRole() < 2) {
Response::json(['error' => 'Unauthorized'], 401);
return;
}
$data = Request::getJson();
if (!isset($data['ip'])) {
Response::json(['error' => 'IP address required'], 400);
return;
}
$reason = $data['reason'] ?? 'Blocked by admin';
$duration = (int)($data['duration'] ?? 3600);
$permanent = $data['permanent'] ?? false;
if ($permanent) {
$success = IPBlocker::blockPermanent($data['ip'], $reason, 'admin_' . Session::getUserId());
} else {
$success = IPBlocker::block($data['ip'], $duration, $reason, 'admin_' . Session::getUserId());
}
Response::json([
'success' => $success,
'message' => $success ? 'IP bloquée' : 'Erreur lors du blocage'
]);
}
/**
* Obtenir le rapport de sécurité
* GET /api/admin/security-report
*/
public function getSecurityReport(): void {
// Vérifier l'authentification et les droits admin
if (!Session::isLoggedIn() || Session::getRole() < 2) {
Response::json(['error' => 'Unauthorized'], 401);
return;
}
// Compiler le rapport
$report = [
'security_stats' => SecurityMonitor::getSecurityStats(),
'performance_stats' => PerformanceMonitor::getStats(null, 24),
'blocked_ips_stats' => IPBlocker::getStats(),
'email_throttle_stats' => (new EmailThrottler())->getStats(),
'recent_alerts' => AlertService::getActiveAlerts(10)
];
Response::json([
'success' => true,
'data' => $report,
'generated_at' => date('Y-m-d H:i:s')
]);
}
/**
* Nettoyer les anciennes données
* POST /api/admin/cleanup
*/
public function cleanup(): void {
// Vérifier l'authentification et les droits super admin
if (!Session::isLoggedIn() || Session::getRole() < 9) {
Response::json(['error' => 'Unauthorized'], 401);
return;
}
$data = Request::getJson();
$daysToKeep = (int)($data['days_to_keep'] ?? 30);
// Nettoyer les métriques de performance
$cleanedMetrics = PerformanceMonitor::cleanup($daysToKeep);
// Nettoyer les IPs expirées
$cleanedIPs = IPBlocker::cleanupExpired();
Response::json([
'success' => true,
'message' => 'Nettoyage effectué',
'cleaned' => [
'performance_metrics' => $cleanedMetrics,
'expired_ips' => $cleanedIPs
]
]);
}
/**
* Tester les alertes email
* POST /api/admin/test-alert
*/
public function testAlert(): void {
// Vérifier l'authentification et les droits super admin
if (!Session::isLoggedIn() || Session::getRole() < 9) {
Response::json(['error' => 'Unauthorized'], 401);
return;
}
// Déclencher une alerte de test
AlertService::trigger('TEST_ALERT', [
'message' => 'Ceci est une alerte de test déclenchée manuellement',
'triggered_by' => Session::getUsername(),
'timestamp' => date('Y-m-d H:i:s')
], 'INFO');
Response::json([
'success' => true,
'message' => 'Alerte de test envoyée'
]);
}
}

View File

@@ -214,11 +214,41 @@ class UserController {
$data = Request::getJson();
$currentUserId = Session::getUserId();
// Log de début de création avec les données reçues (sans données sensibles)
LogService::log('Tentative de création d\'utilisateur', [
'level' => 'debug',
'createdBy' => $currentUserId,
'fields_received' => array_keys($data ?? []),
'has_email' => isset($data['email']),
'has_name' => isset($data['name']),
'has_username' => isset($data['username']),
'has_password' => isset($data['password'])
]);
// Validation des données requises
if (!isset($data['email'], $data['name'])) {
if (!isset($data['email']) || empty(trim($data['email']))) {
LogService::log('Erreur création utilisateur : Email manquant', [
'level' => 'warning',
'createdBy' => $currentUserId
]);
Response::json([
'status' => 'error',
'message' => 'Email et nom requis'
'message' => 'Email requis',
'field' => 'email'
], 400);
return;
}
if (!isset($data['name']) || empty(trim($data['name']))) {
LogService::log('Erreur création utilisateur : Nom manquant', [
'level' => 'warning',
'createdBy' => $currentUserId,
'email' => $data['email'] ?? 'non fourni'
]);
Response::json([
'status' => 'error',
'message' => 'Nom requis',
'field' => 'name'
], 400);
return;
}
@@ -260,9 +290,16 @@ class UserController {
// Validation de l'email
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
LogService::log('Erreur création utilisateur : Format email invalide', [
'level' => 'warning',
'createdBy' => $currentUserId,
'email' => $email
]);
Response::json([
'status' => 'error',
'message' => 'Format d\'email invalide'
'message' => 'Format d\'email invalide',
'field' => 'email',
'value' => $email
], 400);
return;
}
@@ -290,20 +327,56 @@ class UserController {
if ($chkUsernameManuel === 1) {
// Username manuel obligatoire
if (!isset($data['username']) || empty(trim($data['username']))) {
LogService::log('Erreur création utilisateur : Username manuel requis mais non fourni', [
'level' => 'warning',
'createdBy' => $currentUserId,
'email' => $email,
'entite_id' => $entiteId,
'chk_username_manuel' => $chkUsernameManuel
]);
Response::json([
'status' => 'error',
'message' => 'Le nom d\'utilisateur est requis pour cette entité'
'message' => 'Identifiant requis',
'field' => 'username',
'details' => 'Saisie manuelle obligatoire pour cette entité'
], 400);
return;
}
$username = trim(strtolower($data['username']));
// Trim du username mais on garde la casse originale (plus de lowercase forcé)
$username = trim($data['username']);
// Validation du format du username
if (!preg_match('/^[a-z][a-z0-9._-]{9,29}$/', $username)) {
// Validation ultra-souple : seulement la longueur en caractères UTF-8
$usernameLength = mb_strlen($username, 'UTF-8');
if ($usernameLength < 8) {
LogService::log('Erreur création utilisateur : Username trop court', [
'level' => 'warning',
'createdBy' => $currentUserId,
'email' => $email,
'username_length' => $usernameLength
]);
Response::json([
'status' => 'error',
'message' => 'Format du nom d\'utilisateur invalide (10-30 caractères, commence par une lettre, caractères autorisés: a-z, 0-9, ., -, _)'
'message' => 'Identifiant trop court',
'field' => 'username',
'details' => 'Minimum 8 caractères'
], 400);
return;
}
if ($usernameLength > 30) {
LogService::log('Erreur création utilisateur : Username trop long', [
'level' => 'warning',
'createdBy' => $currentUserId,
'email' => $email,
'username_length' => $usernameLength
]);
Response::json([
'status' => 'error',
'message' => 'Identifiant trop long',
'field' => 'username',
'details' => 'Maximum 30 caractères'
], 400);
return;
}
@@ -316,7 +389,8 @@ class UserController {
if ($checkUsernameStmt->fetch()) {
Response::json([
'status' => 'error',
'message' => 'Ce nom d\'utilisateur est déjà utilisé dans GeoSector'
'message' => 'Identifiant déjà utilisé',
'field' => 'username'
], 409);
return;
}
@@ -338,9 +412,18 @@ class UserController {
if ($chkMdpManuel === 1) {
// Mot de passe manuel obligatoire
if (!isset($data['password']) || empty($data['password'])) {
LogService::log('Erreur création utilisateur : Mot de passe manuel requis mais non fourni', [
'level' => 'warning',
'createdBy' => $currentUserId,
'email' => $email,
'entite_id' => $entiteId,
'chk_mdp_manuel' => $chkMdpManuel
]);
Response::json([
'status' => 'error',
'message' => 'Le mot de passe est requis pour cette entité'
'message' => 'Mot de passe requis',
'field' => 'password',
'details' => 'Saisie manuelle obligatoire pour cette entité'
], 400);
return;
}
@@ -927,22 +1010,60 @@ class UserController {
try {
$data = Request::getJson();
// Log de la requête
LogService::log('Vérification de disponibilité username', [
'level' => 'debug',
'checkedBy' => Session::getUserId(),
'has_username' => isset($data['username'])
]);
// Validation de la présence du username
if (!isset($data['username']) || empty(trim($data['username']))) {
LogService::log('Erreur vérification username : Username manquant', [
'level' => 'warning',
'checkedBy' => Session::getUserId()
]);
Response::json([
'status' => 'error',
'message' => 'Username requis pour la vérification'
'message' => 'Identifiant requis',
'field' => 'username'
], 400);
return;
}
$username = trim(strtolower($data['username']));
// Trim du username mais on garde la casse originale
$username = trim($data['username']);
// Validation du format du username
if (!preg_match('/^[a-z][a-z0-9._-]{9,29}$/', $username)) {
// Validation ultra-souple : seulement la longueur en caractères UTF-8
$usernameLength = mb_strlen($username, 'UTF-8');
if ($usernameLength < 8) {
LogService::log('Erreur vérification username : Username trop court', [
'level' => 'warning',
'checkedBy' => Session::getUserId(),
'username_length' => $usernameLength
]);
Response::json([
'status' => 'error',
'message' => 'Format invalide : 10-30 caractères, commence par une lettre, caractères autorisés: a-z, 0-9, ., -, _',
'message' => 'Identifiant trop court',
'field' => 'username',
'details' => 'Minimum 8 caractères',
'available' => false
], 400);
return;
}
if ($usernameLength > 30) {
LogService::log('Erreur vérification username : Username trop long', [
'level' => 'warning',
'checkedBy' => Session::getUserId(),
'username_length' => $usernameLength
]);
Response::json([
'status' => 'error',
'message' => 'Identifiant trop long',
'field' => 'username',
'details' => 'Maximum 30 caractères',
'available' => false
], 400);
return;