Add files via upload

This commit is contained in:
2026-08-28 20:47:44 +02:00
committed by GitHub
parent 1abbb489d6
commit 61a0a08f64
17 changed files with 3962 additions and 0 deletions
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
<?php
/**
* ASPIC — API d'authentification (aspic.php, anciennement login.php —
* renommé le 21 août 2026 à la demande de Jean-Marc, purement cosmétique,
* le contenu et le comportement ne changent pas)
* ---------------------------------------------------------------------------
* Vérifie un couple identifiant/mot de passe contre ASP_Users et répond en
* JSON. Ne fait aucune gestion de compteur d'essais ni de verrouillage de
* compte : ce comportement (2 essais puis sortie, voir journal ASPIC section
* 8) est géré côté front (identification.php), application strictement
* interne, pas de mécanisme de bannissement d'IP prévu.
*
* Requête attendue : POST, corps JSON { "identifiant": "...", "motdepasse": "..." }
* (identifiant = USR_Code, 3 caractères — pas USR_Nom, voir journal section 2).
*
* Réponses :
* 200 { "success": true, "user": { "USR_Code": "...", "USR_Nom": "..." } }
* 200 { "success": false } (identifiants incorrects)
* 400 { "success": false, "error": "..." } (requête mal formée)
* 405 { "success": false, "error": "Méthode non autorisée" } (autre que POST)
* 500 { "success": false, "error": "..." } (connexion base impossible)
*
* ⚠️ Session utilisateur intégrée le 24 août 2026 (voir journal, sections 14
* et 15) : en cas d'identification réussie, une session PHP native est
* ouverte (`session_start()` + `$_SESSION['USR_Code']`), lue ensuite par
* contacts.php/professions.php/statuts.php/realisations.php pour autoriser
* leurs appels. `session_start()` doit rester la toute première instruction
* du fichier, avant tout `header()`/`echo` — sinon PHP refuse d'envoyer les
* cookies de session.
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Méthode non autorisée']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true);
$identifiant = isset($data['identifiant']) ? trim((string) $data['identifiant']) : '';
$motdepasse = isset($data['motdepasse']) ? (string) $data['motdepasse'] : '';
if ($identifiant === '' || $motdepasse === '') {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Identifiant et mot de passe sont obligatoires.']);
exit;
}
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Connexion base de données impossible']);
exit;
}
// USR_Code est un code court (3 caractères) conventionnellement saisi en
// majuscules ; comparaison insensible à la casse sur l'identifiant, comme
// dans le prototype front (voir ASPIC_ecran_identification.html,
// trouverUtilisateurDemo). Le mot de passe, lui, reste comparé strictement
// (sensible à la casse).
$stmt = $pdo->prepare('SELECT USR_Code, USR_Nom, USR_MDP FROM ASP_Users WHERE UPPER(USR_Code) = UPPER(?)');
$stmt->execute([$identifiant]);
$user = $stmt->fetch();
// hash_equals() plutôt que === : comparaison en temps constant, pour ne pas
// laisser une attaque par mesure de timing déduire des informations sur le
// mot de passe stocké. Mot de passe en clair (USR_MDP) assumé consciemment
// pour cette application interne (voir journal section 2) — cette précaution
// est un geste peu coûteux, pas une garantie de sécurité forte sur ce point.
if (!$user || !hash_equals($user['USR_MDP'], $motdepasse)) {
// Message volontairement générique : ne pas révéler si c'est l'identifiant
// ou le mot de passe qui est en cause. Le compteur d'essais / la sortie au
// 3e échec sont gérés côté front, pas ici (voir en-tête de ce fichier).
echo json_encode(['success' => false]);
exit;
}
// Session ouverte : les endpoints suivants (contacts.php, professions.php,
// statuts.php, realisations.php) exigent désormais $_SESSION['USR_Code']
// (voir journal, section 15).
$_SESSION['USR_Code'] = $user['USR_Code'];
echo json_encode([
'success' => true,
'user' => [
'USR_Code' => $user['USR_Code'],
'USR_Nom' => $user['USR_Nom'],
],
]);
+301
View File
@@ -0,0 +1,301 @@
<?php
/**
* ASPIC — contacts.php
* API CRUD sur ASP_Contacts (GET liste + fiche / POST création / PUT
* modification / DELETE suppression logique).
*
* ⚠️ Reconstruction du 24 août 2026, à comparer avec le fichier actuellement
* en production avant remplacement. Cette session n'avait pas accès au
* contenu exact de la version en ligne (alignée le 21 août 2026, avec les
* correctifs du 23 août sur le joker « * » et sur PUT/DELETE — voir journal
* de développement) ; ce fichier reprend fidèlement tout ce qui est
* documenté dans le journal, mais mérite une comparaison avant déploiement
* si la version en ligne contient des ajustements non journalisés.
*
* Reprend :
* - GET liste : tri restreint à une liste blanche de colonnes, jusqu'à 7
* filtres optionnels avec joker "*", WHERE CNT_Actif = 1 systématique.
* - GET fiche (?id=) : un contact actif.
* - POST : création. Génère CNT_Code (25 août 2026, voir journal section 16) :
* 1. Recherche ANN_Code dans ASP_Annees pour l'année courante — AVANT
* l'INSERT, pour ne jamais créer un contact sans code si l'année n'est
* pas encore configurée (échec net, 500, plutôt qu'un contact orphelin
* de code à corriger après coup).
* 2. INSERT du contact, lastInsertId() = CNT_Incr.
* 3. Nombre aléatoire 0-999, zéro-padé sur 3 caractères.
* 4. UPDATE CNT_Code = ANN_Code . "-" . CNT_Incr (sans zéro de tête) .
* nombre aléatoire (3 caractères).
* La journalisation ASP_Realisations (CNT_CRE) reste à la charge du
* front-end, sur le même principe que CNT_MAJ pour la modification (POST
* séparé vers realisations.php, best-effort, ne bloque pas la création).
* - PUT (?id=) : modification, sur l'ensemble des champs éditables du
* panneau de détail (étendu le 24 août 2026 — l'ancien prototype ne
* couvrait que Nom/Prénom/Mail).
* - DELETE (?id=) : suppression logique (CNT_Actif = 0), jamais de DELETE
* SQL réel.
* - PUT vérifie l'EXISTENCE du contact par un SELECT séparé de l'UPDATE lui
* même (corrigé le 24 août 2026 — voir note dans le bloc PUT ci-dessous).
* DELETE continue de s'appuyer sur rowCount(), valide dans son cas.
* - Toute exception PDO (contenu qui ne passe pas une contrainte de colonne,
* erreur de connexion perdue en cours de route, etc.) est interceptée et
* renvoyée en JSON avec un code 500, plutôt que de laisser remonter une
* erreur PHP brute (non-JSON) que le front-end ne peut pas interpréter
* proprement — correctif du 24 août 2026, voir journal.
*
* Note sur le joker "*" : contrairement à la première version (bug corrigé
* le 23 août 2026), l'échappement du LIKE n'est JAMAIS construit par
* concaténation dans le texte SQL. Le caractère d'échappement est passé en
* paramètre lié (ESCAPE ?), comme le motif lui-même — ce qui évite par
* construction le piège du antislash interprété différemment dans un
* littéral SQL selon le moteur (MySQL/MariaDB vs SQLite, voir journal).
*
* ⚠️ Session utilisateur intégrée le 24 août 2026 (voir journal, sections 14
* et 15) : tout appel exige désormais une session PHP valide, ouverte par
* `aspic.php` à l'identification (`$_SESSION['USR_Code']`). Sans elle,
* l'endpoint répond 401 — y compris en lecture (GET). C'est `aspic.php` qui
* doit appeler `session_start()` et renseigner `$_SESSION['USR_Code']` au
* moment où l'identifiant/mot de passe sont validés ; sans cette mise à jour
* côté `aspic.php`, plus aucun appel à ce fichier ne pourra aboutir.
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
// Accès réservé aux utilisateurs identifiés (session ouverte par aspic.php).
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
$method = $_SERVER['REQUEST_METHOD'];
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
// Colonnes éditables depuis le panneau de détail du contact (hors CNT_Code,
// toujours généré/lecture seule, et hors CNT_Incr/CNT_Actif).
const CHAMPS_EDITABLES = [
'CNT_Nom', 'CNT_Prenom', 'CNT_Statut', 'CNT_Société', 'CNT_Service',
'CNT_Fonction', 'CNT_AdrLig1', 'CNT_AdrLig2', 'CNT_AdrLig3', 'CNT_AdrCP',
'CNT_AdrVille', 'CNT_SIRET', 'CNT_Tel', 'CNT_Portable', 'CNT_Mail',
'CNT_Notes', 'CNT_CodeProfession',
];
// Liste blanche des colonnes de tri autorisées (celles réellement triables
// depuis l'écran liste des contacts).
const COLONNES_TRI_AUTORISEES = ['CNT_Nom', 'CNT_AdrVille'];
/**
* Construit un motif LIKE à partir d'une valeur de filtre utilisateur,
* en échappant d'abord les caractères spéciaux LIKE déjà présents
* (antislash, %, _) avant de traduire le joker "*" de l'utilisateur en "%".
* Le caractère d'échappement utilisé (un seul antislash) est renvoyé à part,
* pour être passé en paramètre lié à la clause ESCAPE — jamais concaténé
* dans le texte SQL (voir note d'en-tête).
*/
function construireMotifLike(string $valeur): array
{
$echappe = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $valeur);
$motif = str_replace('*', '%', $echappe);
return [$motif, '\\'];
}
try {
switch ($method) {
case 'GET':
if (isset($_GET['id'])) {
$stmt = $pdo->prepare('SELECT * FROM ASP_Contacts WHERE CNT_Incr = ? AND CNT_Actif = 1');
$stmt->execute([$_GET['id']]);
$contact = $stmt->fetch();
if (!$contact) {
http_response_code(404);
echo json_encode(['error' => 'Contact introuvable']);
exit;
}
echo json_encode($contact);
exit;
}
$tri = $_GET['tri'] ?? 'CNT_Nom';
if (!in_array($tri, COLONNES_TRI_AUTORISEES, true)) {
$tri = 'CNT_Nom';
}
$sens = (isset($_GET['sens']) && strtolower($_GET['sens']) === 'desc') ? 'DESC' : 'ASC';
$conditions = ['CNT_Actif = 1'];
$params = [];
$filtresColonnes = [
'f_nom' => 'CNT_Nom',
'f_prenom' => 'CNT_Prenom',
'f_ville' => 'CNT_AdrVille',
'f_tel' => 'CNT_Tel',
'f_portable' => 'CNT_Portable',
'f_mail' => 'CNT_Mail',
];
foreach ($filtresColonnes as $param => $colonne) {
if (!empty($_GET[$param])) {
[$motif, $echap] = construireMotifLike($_GET[$param]);
$conditions[] = "$colonne LIKE ? ESCAPE ?";
$params[] = $motif;
$params[] = $echap;
}
}
// Département = 2 premiers caractères de CNT_AdrCP.
if (!empty($_GET['f_dept'])) {
[$motif, $echap] = construireMotifLike($_GET['f_dept']);
$conditions[] = 'LEFT(CNT_AdrCP, 2) LIKE ? ESCAPE ?';
$params[] = $motif;
$params[] = $echap;
}
$sql = 'SELECT *, LEFT(CNT_AdrCP, 2) AS CNT_Dept FROM ASP_Contacts WHERE '
. implode(' AND ', $conditions)
. " ORDER BY $tri $sens, CNT_Prenom ASC";
$stmtTotal = $pdo->prepare('SELECT COUNT(*) FROM ASP_Contacts WHERE ' . implode(' AND ', $conditions));
$stmtTotal->execute($params);
$total = (int)$stmtTotal->fetchColumn();
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
echo json_encode(['contacts' => $stmt->fetchAll(), 'total' => $total]);
break;
case 'POST':
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$nom = trim($data['CNT_Nom'] ?? '');
if ($nom === '') {
http_response_code(400);
echo json_encode(['error' => 'Le nom est obligatoire']);
exit;
}
// Étape 1 (voir en-tête du fichier) : ANN_Code de l'année courante,
// recherché AVANT l'INSERT pour ne jamais créer un contact sans
// code faute d'année configurée dans ASP_Annees.
$anneeCourante = (int)date('Y');
$stmtAnnee = $pdo->prepare('SELECT ANN_Code FROM ASP_Annees WHERE ANN_Année = ?');
$stmtAnnee->execute([$anneeCourante]);
$annCode = $stmtAnnee->fetchColumn();
if ($annCode === false) {
http_response_code(500);
echo json_encode(['error' => "Aucun code configuré pour l'année $anneeCourante dans ASP_Annees — impossible de générer le Code du contact."]);
exit;
}
$colonnes = [];
$placeholders = [];
$params = [];
foreach (CHAMPS_EDITABLES as $champ) {
$colonnes[] = $champ;
$placeholders[] = '?';
$valeur = $data[$champ] ?? null;
$params[] = ($valeur === '' ? null : $valeur);
}
// Étape 2 : création du contact (CNT_Code encore NULL à ce stade,
// il dépend de CNT_Incr qui n'existe qu'après l'INSERT).
$sql = 'INSERT INTO ASP_Contacts (' . implode(', ', $colonnes) . ') VALUES (' . implode(', ', $placeholders) . ')';
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$nouvelIncr = (int)$pdo->lastInsertId();
// Étapes 3-4 : nombre aléatoire 0-999 zéro-padé sur 3 caractères,
// concaténation ANN_Code-CNT_Incr+aléatoire (voir journal section 2
// pour la logique détaillée, et section 16 pour cette implémentation).
$aleatoire = str_pad((string)random_int(0, 999), 3, '0', STR_PAD_LEFT);
$code = $annCode . '-' . $nouvelIncr . $aleatoire;
$stmtCode = $pdo->prepare('UPDATE ASP_Contacts SET CNT_Code = ? WHERE CNT_Incr = ?');
$stmtCode->execute([$code, $nouvelIncr]);
echo json_encode(['id' => $nouvelIncr, 'code' => $code]);
break;
case 'PUT':
if (!isset($_GET['id'])) {
http_response_code(400);
echo json_encode(['error' => 'id manquant']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$nom = trim($data['CNT_Nom'] ?? '');
if ($nom === '') {
http_response_code(400);
echo json_encode(['error' => 'Le nom est obligatoire']);
exit;
}
// Vérification d'EXISTENCE séparée de l'UPDATE lui-même : par
// défaut, rowCount() après un UPDATE reflète le nombre de lignes
// RÉELLEMENT MODIFIÉES par MySQL/MariaDB, pas le nombre de lignes
// trouvées par le WHERE. Une re-soumission du formulaire sans
// changement réel (ou un enregistrement où, par coïncidence,
// aucune des valeurs soumises ne diffère de ce qui est déjà en
// base) donnerait alors rowCount() = 0, et l'ancien code
// l'interprétait à tort comme "contact introuvable" (404) — bug
// corrigé le 24 août 2026, repéré par Jean-Marc ("erreur si je
// remplis Notes ou Informations", en réalité lié à des essais
// répétés sans changement effectif des champs du contact).
$verif = $pdo->prepare('SELECT CNT_Incr FROM ASP_Contacts WHERE CNT_Incr = ? AND CNT_Actif = 1');
$verif->execute([$_GET['id']]);
if (!$verif->fetch()) {
http_response_code(404);
echo json_encode(['error' => 'Contact introuvable ou déjà désactivé']);
exit;
}
$assignations = [];
$params = [];
foreach (CHAMPS_EDITABLES as $champ) {
$assignations[] = "$champ = ?";
$valeur = $data[$champ] ?? null;
$params[] = ($valeur === '' ? null : $valeur);
}
$params[] = $_GET['id'];
$sql = 'UPDATE ASP_Contacts SET ' . implode(', ', $assignations) . ' WHERE CNT_Incr = ? AND CNT_Actif = 1';
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
echo json_encode(['success' => true]);
break;
case 'DELETE':
if (!isset($_GET['id'])) {
http_response_code(400);
echo json_encode(['error' => 'id manquant']);
exit;
}
// Suppression logique uniquement (voir journal, section 2) —
// jamais de DELETE SQL réel. Ici rowCount() reste un test valide
// (contrairement au PUT ci-dessus) : CNT_Actif passe toujours
// réellement de 1 à 0, donc rowCount() = 0 signifie bien "déjà
// désactivé ou inexistant".
$stmt = $pdo->prepare('UPDATE ASP_Contacts SET CNT_Actif = 0 WHERE CNT_Incr = ? AND CNT_Actif = 1');
$stmt->execute([$_GET['id']]);
if ($stmt->rowCount() === 0) {
http_response_code(404);
echo json_encode(['error' => 'Contact introuvable ou déjà désactivé']);
exit;
}
echo json_encode(['success' => true]);
break;
default:
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
}
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Erreur base de données : ' . $e->getMessage()]);
}
+16
View File
@@ -0,0 +1,16 @@
<?php
function getDbConnection(): PDO
{
$host = getenv('DB_HOST') ?: 'mysql';
$db = getenv('DB_NAME') ?: 'crm';
$user = getenv('DB_USER') ?: 'crm_user';
$pass = getenv('DB_PASS') ?: '';
$dsn = "mysql:host=$host;dbname=$db;charset=utf8mb4";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
];
return new PDO($dsn, $user, $pass, $options);
}
+570
View File
File diff suppressed because one or more lines are too long
+17
View File
@@ -0,0 +1,17 @@
<?php
/**
* ASPIC — point d'entrée du domaine (index.php)
* ---------------------------------------------------------------------------
* Ne contient aucune logique : redirige simplement vers l'écran
* d'identification, qui garde son nom explicite (identification.html) plutôt
* que d'être lui-même renommé en index.html (voir journal ASPIC, section 3,
* convention de nommage des fichiers). Permet de charger
* https://aspic.mvl-tls.fr/ directement sur l'écran de connexion.
*
* À faire évoluer le jour où l'application aura une vraie notion de session :
* rediriger vers liste_contacts.html si une session valide existe déjà,
* vers identification.html sinon.
*/
header('Location: identification.html');
exit;
+2216
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+103
View File
@@ -0,0 +1,103 @@
<?php
/**
* ASPIC — preferences.php
* Préférences d'affichage liées à l'utilisateur connecté (ASP_Users), et non
* à un contact ou à une donnée métier — pour l'instant, uniquement le ratio
* de répartition du panneau de détail (grille des champs / sous-liste des
* réalisations), voir journal de développement, section 17.
*
* Toujours restreint à l'utilisateur de la session en cours
* ($_SESSION['USR_Code'], ouverte par aspic.php) : aucun USR_Code n'est
* jamais accepté depuis le corps de la requête, sur le même principe que
* REA_CodeUser dans realisations.php (voir journal, section 15) — impossible
* pour un utilisateur de lire ou modifier les préférences d'un autre.
*
* GET : renvoie les deux ratios (0-100) de l'utilisateur connecté.
* PUT : met à jour un ou plusieurs ratios (0-100 uniquement, whitelist des
* colonnes acceptées — voir CHAMPS_RATIO).
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
$method = $_SERVER['REQUEST_METHOD'];
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
// Whitelist des colonnes de préférence modifiables — n'accepter que celles-ci
// dans le corps d'un PUT, jamais un nom de colonne arbitraire fourni par le
// client (même logique que CHAMPS_EDITABLES dans contacts.php).
const CHAMPS_RATIO = ['USR_RatioPanneauContact', 'USR_RatioPanneauProspect'];
try {
switch ($method) {
case 'GET':
$stmt = $pdo->prepare(
'SELECT USR_RatioPanneauContact, USR_RatioPanneauProspect FROM ASP_Users WHERE USR_Code = ?'
);
$stmt->execute([$_SESSION['USR_Code']]);
$ligne = $stmt->fetch();
if (!$ligne) {
http_response_code(404);
echo json_encode(['error' => 'Utilisateur introuvable']);
exit;
}
echo json_encode([
'USR_RatioPanneauContact' => (int)$ligne['USR_RatioPanneauContact'],
'USR_RatioPanneauProspect' => (int)$ligne['USR_RatioPanneauProspect'],
]);
break;
case 'PUT':
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$assignations = [];
$params = [];
foreach (CHAMPS_RATIO as $champ) {
if (!array_key_exists($champ, $data)) {
continue;
}
$valeur = $data[$champ];
if (!is_numeric($valeur) || (int)$valeur < 0 || (int)$valeur > 100) {
http_response_code(400);
echo json_encode(['error' => "$champ doit être un entier compris entre 0 et 100"]);
exit;
}
$assignations[] = "$champ = ?";
$params[] = (int)$valeur;
}
if (!$assignations) {
http_response_code(400);
echo json_encode(['error' => 'Aucun champ de préférence reconnu à mettre à jour']);
exit;
}
$params[] = $_SESSION['USR_Code'];
$sql = 'UPDATE ASP_Users SET ' . implode(', ', $assignations) . ' WHERE USR_Code = ?';
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
echo json_encode(['success' => true]);
break;
default:
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
}
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Erreur base de données : ' . $e->getMessage()]);
}
+79
View File
@@ -0,0 +1,79 @@
<?php
/**
* ASPIC — professions.php
* Table de référence ASP_Professions (liée à ASP_Contacts via
* CNT_CodeProfession = PRF_Incr, lien applicatif, sans contrainte FOREIGN KEY
* SQL — voir journal de développement, section 2).
*
* GET : liste des professions, triée par libellé.
* POST : création d'une nouvelle profession (utilisée par le formulaire de
* détail du contact, option "+ Nouvelle profession…" — exception à la
* règle "table gérée à la main" décidée le 23 août 2026, car
* ASP_Professions est une liste ouverte destinée à grandir avec
* l'usage, contrairement à ASP_Statuts/ASP_Actions).
*
* ⚠️ Session utilisateur intégrée le 24 août 2026 (voir journal, section 14) :
* accès réservé aux utilisateurs identifiés (`$_SESSION['USR_Code']`, ouverte
* par `aspic.php`).
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
$method = $_SERVER['REQUEST_METHOD'];
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
try {
switch ($method) {
case 'GET':
$stmt = $pdo->query('SELECT PRF_Incr, PRF_Libelle FROM ASP_Professions ORDER BY PRF_Libelle ASC');
echo json_encode(['professions' => $stmt->fetchAll()]);
break;
case 'POST':
$data = json_decode(file_get_contents('php://input'), true);
$libelle = trim($data['PRF_Libelle'] ?? '');
if ($libelle === '') {
http_response_code(400);
echo json_encode(['error' => 'Le libellé de la profession est obligatoire']);
exit;
}
// Pas de contrainte UNIQUE en base sur PRF_Libelle à ce stade (voir
// journal) : on évite ici un doublon évident et strictement identique,
// sans garantie absolue en cas de création concurrente.
$verif = $pdo->prepare('SELECT PRF_Incr FROM ASP_Professions WHERE PRF_Libelle = ?');
$verif->execute([$libelle]);
$existant = $verif->fetch();
if ($existant) {
echo json_encode(['id' => (int)$existant['PRF_Incr'], 'existant' => true]);
exit;
}
$stmt = $pdo->prepare('INSERT INTO ASP_Professions (PRF_Libelle) VALUES (?)');
$stmt->execute([$libelle]);
echo json_encode(['id' => (int)$pdo->lastInsertId(), 'existant' => false]);
break;
default:
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
}
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Erreur base de données : ' . $e->getMessage()]);
}
+48
View File
@@ -0,0 +1,48 @@
<?php
// prospect_doc.php
// Point d'entrée public (clic sur le lien de documentation dans un mail) :
// vérifie que les paramètres n'ont pas été modifiés (signature), journalise
// la lecture du prospect, puis redirige vers l'URL réelle du document.
// URL attendue : prospect_doc.php?a=<PSP_Incr>&b=<DOC_Incr>&c=<CMP_Incr>&d=<signature>
require __DIR__ . '/prospects_core.php';
// Destination neutre si les paramètres ont été trafiqués : ta page d'accueil
// plutôt qu'une réponse d'erreur qui confirmerait la tentative au visiteur.
const URL_REDIRECTION_SI_INVALIDE = 'https://mevelia-solutions.bzh';
$idProspect = filter_input(INPUT_GET, 'a', FILTER_VALIDATE_INT);
$idDoc = filter_input(INPUT_GET, 'b', FILTER_VALIDATE_INT);
$idCampagne = filter_input(INPUT_GET, 'c', FILTER_VALIDATE_INT);
$signature = $_GET['d'] ?? '';
if (!$idProspect || !$idDoc || !$idCampagne || $signature === '') {
header('Location: ' . URL_REDIRECTION_SI_INVALIDE, true, 302);
exit;
}
$signatureAttendue = signerParametresDoc($idProspect, $idDoc, $idCampagne);
if (!hash_equals($signatureAttendue, $signature)) {
header('Location: ' . URL_REDIRECTION_SI_INVALIDE, true, 302);
exit;
}
$pdo = getDbConnection();
$stmt = $pdo->prepare('SELECT PSP_CodeCampagne FROM ASP_Prospects WHERE PSP_Incr = ?');
$stmt->execute([$idProspect]);
$prospect = $stmt->fetch();
$stmt = $pdo->prepare('SELECT DOC_URL, DOC_Libelle FROM ASP_Docs WHERE DOC_Incr = ?');
$stmt->execute([$idDoc]);
$doc = $stmt->fetch();
if (!$prospect || !$doc) {
header('Location: ' . URL_REDIRECTION_SI_INVALIDE, true, 302);
exit;
}
journaliserOperation($idProspect, $prospect['PSP_CodeCampagne'] . '-LEC', $doc['DOC_Libelle']);
header('Location: ' . $doc['DOC_URL'], true, 302);
exit;
+8
View File
@@ -0,0 +1,8 @@
<?php
// prospect_web-form-001_ajoute.php
// Point d'entrée public pour la campagne WEB-FORM-001 (formulaire du site web).
// Ne contient aucune logique propre : délègue entièrement à prospects_core.php.
require __DIR__ . '/prospects_core.php';
traiterAjoutProspect('WEB-FORM-001');
+277
View File
@@ -0,0 +1,277 @@
<?php
// prospects_core.php
// Logique commune à tous les scripts publics de prospection ASPIC.
// Réutilise db.php (déjà en production) pour la connexion PDO.
require __DIR__ . '/db.php';
// --- Configuration ---------------------------------------------------
const BREVO_FROM_EMAIL = 'viktor@ms.bzh';
const BREVO_FROM_NAME = 'Viktor - Mevelia Solutions';
const BREVO_REPLY_TO = 'contact@mevelia-solutions.bzh';
const ASPIC_BASE_URL = 'https://aspic.mvl-tls.fr';
// URL publique du logo, injectée automatiquement dans toutes les variables
// disponibles pour les modèles de mail (utilisable via {(LogoUrl)}).
const LOGO_URL = 'https://aspic.mvl-tls.fr/logo.png';
const ORIGINES_CORS_AUTORISEES = [
'https://mevelia-solutions.bzh',
];
// --- Utilitaires génériques ------------------------------------------
/**
* Envoie les en-têtes CORS nécessaires si l'origine de la requête est autorisée,
* et répond immédiatement aux requêtes de pré-vérification (OPTIONS).
*/
function autoriserCorsSiNecessaire(): void
{
$origine = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origine, ORIGINES_CORS_AUTORISEES, true)) {
header('Access-Control-Allow-Origin: ' . $origine);
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
}
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204);
exit;
}
}
/**
* Génère un token de vérification imprévisible (64 caractères hexadécimaux).
*/
function genererTokenVerification(): string
{
return bin2hex(random_bytes(32));
}
/**
* Envoie un mail depuis un modèle de ASP_Mails, en substituant les variables
* de la forme {(NomVariable)} dans l'objet et le corps (texte et HTML).
* {(LogoUrl)} est toujours disponible, sans avoir à la passer explicitement.
* Retourne true si l'envoi (via l'API Brevo) a réussi.
*/
function envoyerMail(string $codeMail, string $destinataire, array $variables): bool
{
$pdo = getDbConnection();
$stmt = $pdo->prepare('SELECT MAI_Objet, MAI_Corps, MAI_CorpsHtml FROM ASP_Mails WHERE MAI_Code = ?');
$stmt->execute([$codeMail]);
$modele = $stmt->fetch();
if (!$modele) {
error_log("ASPIC prospects: modèle de mail introuvable ($codeMail)");
return false;
}
$variables = $variables + ['LogoUrl' => LOGO_URL];
$objet = $modele['MAI_Objet'];
$corpsTexte = $modele['MAI_Corps'];
$corpsHtml = $modele['MAI_CorpsHtml'];
foreach ($variables as $cle => $valeur) {
$motif = '{(' . $cle . ')}';
$objet = str_replace($motif, (string) $valeur, $objet);
$corpsTexte = str_replace($motif, (string) $valeur, $corpsTexte);
if ($corpsHtml !== null) {
$corpsHtml = str_replace($motif, (string) $valeur, $corpsHtml);
}
}
$apiKey = getenv('BREVO_API_KEY');
if (!$apiKey) {
error_log('ASPIC prospects: BREVO_API_KEY non configurée');
return false;
}
$donneesEnvoi = [
'sender' => ['name' => BREVO_FROM_NAME, 'email' => BREVO_FROM_EMAIL],
'to' => [['email' => $destinataire]],
'replyTo' => ['email' => BREVO_REPLY_TO],
'subject' => $objet,
'textContent' => $corpsTexte,
];
if ($corpsHtml !== null && trim($corpsHtml) !== '') {
$donneesEnvoi['htmlContent'] = $corpsHtml;
}
$payload = json_encode($donneesEnvoi);
$ch = curl_init('https://api.brevo.com/v3/smtp/email');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'accept: application/json',
'api-key: ' . $apiKey,
'content-type: application/json',
],
CURLOPT_TIMEOUT => 10,
]);
$reponse = curl_exec($ch);
$codeHttp = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$erreurCurl = curl_error($ch);
curl_close($ch);
if ($codeHttp < 200 || $codeHttp >= 300) {
error_log("ASPIC prospects: échec envoi Brevo (HTTP $codeHttp) : $reponse $erreurCurl");
return false;
}
return true;
}
/**
* Journalise une opération sur un prospect dans ASP_Operations.
* OPE_CodeUser est toujours 'Vik' (Viktor) : ces événements sont automatiques,
* jamais déclenchés par un utilisateur ASPIC connecté.
*/
function journaliserOperation(int $idProspect, string $codeAction, ?string $commentaire = null): void
{
$pdo = getDbConnection();
$stmt = $pdo->prepare(
'INSERT INTO ASP_Operations (OPE_IncrProspect, OPE_CodeUser, OPE_Date, OPE_Action, OPE_Comment)
VALUES (?, ?, NOW(), ?, ?)'
);
$stmt->execute([$idProspect, 'Vik', $codeAction, $commentaire]);
}
/**
* Calcule la signature HMAC des paramètres d'un lien de documentation (a/b/c),
* pour empêcher qu'un visiteur ne les modifie sans que ça se voie.
* La clé ne doit JAMAIS être exposée côté client.
*/
function signerParametresDoc(int $idProspect, int $idDoc, int $idCampagne): string
{
$cle = getenv('LIEN_DOC_CLE_SECRETE');
if (!$cle) {
error_log('ASPIC prospects: LIEN_DOC_CLE_SECRETE non configurée');
$cle = '';
}
return substr(hash_hmac('sha256', "$idProspect-$idDoc-$idCampagne", $cle), 0, 16);
}
/**
* Construit un lien de documentation signé, à insérer dans un mail.
*/
function genererLienDoc(int $idProspect, int $idDoc, int $idCampagne): string
{
$signature = signerParametresDoc($idProspect, $idDoc, $idCampagne);
return ASPIC_BASE_URL . '/prospect_doc.php?a=' . $idProspect
. '&b=' . $idDoc . '&c=' . $idCampagne . '&d=' . $signature;
}
/**
* Détermine si un numéro de téléphone saisi va dans PSP_Tel ou PSP_Portable :
* préfixe 06/07 (après suppression des espaces) => portable, sinon => fixe.
*/
function routerTelephone(string $telephoneBrut): array
{
$nettoye = preg_replace('/\s+/', '', $telephoneBrut);
if ($nettoye === '') {
return ['tel' => null, 'portable' => null];
}
$prefixe = substr($nettoye, 0, 2);
if ($prefixe === '06' || $prefixe === '07') {
return ['tel' => null, 'portable' => $nettoye];
}
return ['tel' => $nettoye, 'portable' => null];
}
/**
* Traite la soumission du formulaire public pour une campagne donnée.
* Dérive automatiquement les codes ASP_Actions et le code du mail de vérification
* à partir du code de campagne, suivant la convention "<campagne>-CRE/-VER/-VAL/-DOC/-LEC"
* (codes ASP_Actions) et "<campagne>-DEM" (mail de vérification).
*/
function traiterAjoutProspect(string $codeCampagne): void
{
autoriserCorsSiNecessaire();
header('Content-Type: application/json; charset=utf-8');
$data = json_decode(file_get_contents('php://input'), true) ?? [];
// Honeypot anti-spam : champ invisible pour un humain, rempli seulement par un robot.
if (!empty($data['site_web'] ?? '')) {
http_response_code(200);
echo json_encode(['success' => true]);
exit;
}
$nom = trim((string) ($data['nom'] ?? ''));
$prenom = trim((string) ($data['prenom'] ?? ''));
$telephone = trim((string) ($data['telephone'] ?? ''));
$mail = trim((string) ($data['mail'] ?? ''));
if ($mail === '' && $telephone === '') {
http_response_code(400);
echo json_encode(['error' => 'Merci de renseigner au moins une adresse mail ou un numéro de téléphone.']);
exit;
}
if (strlen($nom) > 40 || strlen($prenom) > 40 || strlen($mail) > 40) {
http_response_code(400);
echo json_encode(['error' => 'Un des champs saisis est trop long.']);
exit;
}
$telNettoye = preg_replace('/\s+/', '', $telephone);
if (strlen($telNettoye) > 14) {
http_response_code(400);
echo json_encode(['error' => 'Le numéro de téléphone saisi est trop long.']);
exit;
}
if ($mail !== '' && !filter_var($mail, FILTER_VALIDATE_EMAIL)) {
http_response_code(400);
echo json_encode(['error' => "L'adresse mail saisie n'est pas valide."]);
exit;
}
$tel = routerTelephone($telephone);
try {
$pdo = getDbConnection();
$token = genererTokenVerification();
$expiration = date('Y-m-d H:i:s', strtotime('+48 hours'));
$stmt = $pdo->prepare(
'INSERT INTO ASP_Prospects
(PSP_Nom, PSP_Prenom, PSP_Tel, PSP_Portable, PSP_Mail, PSP_CodeCampagne,
PSP_MailVerifie, PSP_TokenVerification, PSP_TokenExpiration, PSP_Actif)
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, 1)'
);
$stmt->execute([
$nom !== '' ? $nom : null,
$prenom !== '' ? $prenom : null,
$tel['tel'],
$tel['portable'],
$mail !== '' ? $mail : null,
$codeCampagne,
$token,
$expiration,
]);
$idProspect = (int) $pdo->lastInsertId();
journaliserOperation($idProspect, $codeCampagne . '-CRE');
if ($mail !== '') {
$lien = ASPIC_BASE_URL . '/prospects_verifier.php?token=' . urlencode($token);
$envoiOk = envoyerMail($codeCampagne . '-DEM', $mail, [
'Prenom' => $prenom,
'Nom' => $nom,
'Lien' => $lien,
]);
if ($envoiOk) {
journaliserOperation($idProspect, $codeCampagne . '-VER');
} else {
error_log("ASPIC prospects: échec envoi mail de vérification pour le prospect $idProspect");
}
}
echo json_encode(['success' => true]);
} catch (PDOException $e) {
error_log('ASPIC prospects: erreur PDO - ' . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => 'Erreur serveur, merci de réessayer plus tard.']);
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
// prospects_verifier.php
// Point d'entrée public (clic sur le lien reçu par mail) : valide l'adresse mail
// d'un prospect à partir de son token, quelle que soit la campagne d'origine,
// puis envoie automatiquement la documentation par défaut de cette campagne.
require __DIR__ . '/prospects_core.php';
header('Content-Type: text/html; charset=utf-8');
function pageMessage(string $texte): void
{
echo '<!doctype html><html lang="fr"><head><meta charset="utf-8">'
. '<title>ASPIC - Vérification</title></head>'
. '<body style="font-family:sans-serif;max-width:480px;margin:80px auto;text-align:center;">'
. '<p>' . htmlspecialchars($texte) . '</p>'
. '</body></html>';
}
$token = $_GET['token'] ?? '';
if ($token === '') {
http_response_code(400);
pageMessage('Lien de vérification invalide.');
exit;
}
$pdo = getDbConnection();
$stmt = $pdo->prepare(
'SELECT PSP_Incr, PSP_Prenom, PSP_Nom, PSP_Mail, PSP_CodeCampagne, PSP_TokenExpiration
FROM ASP_Prospects
WHERE PSP_TokenVerification = ? AND PSP_MailVerifie = 0 AND PSP_Actif = 1'
);
$stmt->execute([$token]);
$prospect = $stmt->fetch();
if (!$prospect) {
pageMessage('Ce lien de vérification est invalide ou a déjà été utilisé.');
exit;
}
if (strtotime($prospect['PSP_TokenExpiration']) < time()) {
pageMessage('Ce lien de vérification a expiré.');
exit;
}
$idProspect = (int) $prospect['PSP_Incr'];
$codeCampagne = $prospect['PSP_CodeCampagne'];
$pdo->prepare(
'UPDATE ASP_Prospects
SET PSP_MailVerifie = 1, PSP_TokenVerification = NULL, PSP_TokenExpiration = NULL
WHERE PSP_Incr = ?'
)->execute([$idProspect]);
journaliserOperation($idProspect, $codeCampagne . '-VAL');
// Envoi automatique de la doc par défaut de la campagne, si elle en a une de définie.
$stmt = $pdo->prepare('SELECT CMP_Incr, CMP_DocDefaut FROM ASP_Campagnes WHERE CMP_CodeCampagne = ?');
$stmt->execute([$codeCampagne]);
$campagne = $stmt->fetch();
if ($campagne && $campagne['CMP_DocDefaut']) {
$idDoc = (int) $campagne['CMP_DocDefaut'];
$idCampagneIncr = (int) $campagne['CMP_Incr'];
$stmt = $pdo->prepare('SELECT DOC_Libelle FROM ASP_Docs WHERE DOC_Incr = ?');
$stmt->execute([$idDoc]);
$doc = $stmt->fetch();
if ($doc) {
$lien = genererLienDoc($idProspect, $idDoc, $idCampagneIncr);
$envoiOk = envoyerMail($codeCampagne . '-DOC', $prospect['PSP_Mail'], [
'Prenom' => $prospect['PSP_Prenom'],
'Nom' => $prospect['PSP_Nom'],
'Lien' => $lien,
'Titre' => $doc['DOC_Libelle'],
]);
if ($envoiOk) {
journaliserOperation($idProspect, $codeCampagne . '-DOC');
} else {
error_log("ASPIC prospects: échec envoi mail de doc pour le prospect $idProspect");
}
}
}
pageMessage('Merci, votre adresse mail est vérifiée ! Vous allez recevoir notre documentation très prochainement.');
+100
View File
@@ -0,0 +1,100 @@
<?php
/**
* ASPIC — realisations.php
* Journal des actions réalisées sur un contact (ASP_Realisations). Voir
* journal de développement, section 2, pour la distinction entre ACT_Comment
* (commentaire générique du type d'action, ASP_Actions) et REA_Comment
* (commentaire propre à une occurrence, ASP_Realisations) — c'est bien
* REA_Comment qui est manipulé ici.
*
* GET ?id=<CNT_Incr> : historique des réalisations d'un contact, avec le
* libellé de l'action (jointure sur ASP_Actions), trié du plus ancien
* au plus récent.
* POST : journalise une nouvelle réalisation (REA_IncrContact, REA_Action
* obligatoires ; REA_Comment optionnel). REA_Date est déterminée côté
* serveur (NOW()).
*
* ⚠️ Session utilisateur intégrée le 24 août 2026 (voir journal, sections 14
* et 15) : accès réservé aux utilisateurs identifiés, et REA_CodeUser est
* désormais lu directement dans `$_SESSION['USR_Code']` (ouverte par
* `aspic.php`) — le champ REA_CodeUser du corps de la requête POST n'est
* plus utilisé et ne peut plus être falsifié par le client. Le "JMP" en dur
* précédent a été supprimé (n'avait de sens que faute de session).
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
$method = $_SERVER['REQUEST_METHOD'];
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
try {
switch ($method) {
case 'GET':
if (!isset($_GET['id'])) {
http_response_code(400);
echo json_encode(['error' => 'id (REA_IncrContact) manquant']);
exit;
}
$stmt = $pdo->prepare(
'SELECT r.REA_Incr, r.REA_Date, r.REA_CodeUser, r.REA_Action, r.REA_Comment,
a.ACT_Libelle
FROM ASP_Realisations r
LEFT JOIN ASP_Actions a ON a.ACT_Code = r.REA_Action
WHERE r.REA_IncrContact = ?
ORDER BY r.REA_Date ASC'
);
$stmt->execute([$_GET['id']]);
echo json_encode(['realisations' => $stmt->fetchAll()]);
break;
case 'POST':
$data = json_decode(file_get_contents('php://input'), true);
$idContact = $data['REA_IncrContact'] ?? null;
$action = $data['REA_Action'] ?? null;
if (!$idContact || !$action) {
http_response_code(400);
echo json_encode(['error' => 'REA_IncrContact et REA_Action sont obligatoires']);
exit;
}
// REA_CodeUser vient de la session (voir en-tête de ce fichier),
// plus du corps de la requête : impossible pour le client de se
// faire passer pour un autre utilisateur.
$codeUser = $_SESSION['USR_Code'];
$commentaire = $data['REA_Comment'] ?? '';
$stmt = $pdo->prepare(
'INSERT INTO ASP_Realisations (REA_IncrContact, REA_CodeUser, REA_Date, REA_Action, REA_Comment)
VALUES (?, ?, NOW(), ?, ?)'
);
$stmt->execute([$idContact, $codeUser, $action, $commentaire]);
echo json_encode(['id' => (int)$pdo->lastInsertId()]);
break;
default:
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
}
} catch (PDOException $e) {
// Toute exception PDO (ex. REA_Action trop long pour la colonne, valeur
// rejetée par une contrainte) renvoie du JSON exploitable côté front
// plutôt qu'une erreur PHP brute — voir contacts.php pour le même
// correctif du 24 août 2026.
http_response_code(500);
echo json_encode(['error' => 'Erreur base de données : ' . $e->getMessage()]);
}
+48
View File
@@ -0,0 +1,48 @@
<?php
/**
* ASPIC — statuts.php
* Table de référence ASP_Statuts (liée à ASP_Contacts via CNT_Statut =
* STA_Code, lien applicatif, sans contrainte FOREIGN KEY SQL — voir journal
* de développement, section 2).
*
* GET seul : ASP_Statuts est une table gérée à la main via phpMyAdmin, pas de
* création depuis l'application (contrairement à ASP_Professions).
*
* ⚠️ Session utilisateur intégrée le 24 août 2026 (voir journal, section 14) :
* accès réservé aux utilisateurs identifiés (`$_SESSION['USR_Code']`, ouverte
* par `aspic.php`).
*/
session_start();
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/db.php';
if (empty($_SESSION['USR_Code'])) {
http_response_code(401);
echo json_encode(['error' => 'Non identifié — veuillez vous connecter.']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
exit;
}
try {
$pdo = getDbConnection();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Connexion base de données impossible']);
exit;
}
try {
// Tri par STA_Incr (ordre de création/incrément), pas par libellé —
// demande explicite de Jean-Marc du 24 août 2026.
$stmt = $pdo->query('SELECT STA_Code, STA_Libellé FROM ASP_Statuts ORDER BY STA_Incr ASC');
echo json_encode(['statuts' => $stmt->fetchAll()]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Erreur base de données : ' . $e->getMessage()]);
}