14 Commits

Author SHA1 Message Date
92a69c978a Fix: Accepter string pour entityId et convertir en int dans getAccountStatus 2025-09-01 15:35:41 +02:00
234c4eb2cc Fix: Utiliser la méthode standard de récupération du rôle depuis la DB comme les autres controllers 2025-09-01 15:33:18 +02:00
fe19a56983 Fix: Ajouter getRole() dans Session et stocker fk_role lors du login 2025-09-01 15:30:05 +02:00
dadd0b69ca Fix: Utiliser getStripeConfig() au lieu de get('stripe') 2025-09-01 15:25:55 +02:00
a548ef8890 Fix: Corriger le type PDO dans StripeService et retirer getConnection() 2025-09-01 15:23:48 +02:00
f597c9aeb5 Merge pull request 'chat-syncho-stats' (#12) from feature/chat-2 into main 2025-08-31 18:24:27 +02:00
604294af96 feat: synchronisation mode deconnecte fin chat et stats 2025-08-31 18:21:20 +02:00
41a4505b4b On release/v3.1.4: Sauvegarde temporaire pour changement de branche 2025-08-22 09:43:32 +02:00
6c8853e553 Merge pull request 'feat: Version 3.1.6 - Amélioration des flux de passages' (#11) from release/v3.1.6 into main 2025-08-21 17:57:59 +02:00
4c2e809a35 feat: Release v3.1.6 - Amélioration complète des flux de passages
- Optimisation des listes de passages (user/admin)
- Amélioration du flux de création avec validation temps réel
- Amélioration du flux de consultation avec export multi-formats
- Amélioration du flux de modification avec suivi des changements
- Ajout de la génération PDF pour les reçus
- Migration de la structure des uploads
- Implémentation de la file d'attente d'emails
- Ajout des permissions de suppression de passages
- Corrections de bugs et optimisations performances

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-21 17:57:27 +02:00
890da22329 Merge pull request 'feat: Release version 3.1.4 - Mode terrain et génération PDF' (#8) from release/v3.1.4 into main
Reviewed-on: #8
2025-08-19 19:45:44 +02:00
5ab03751e1 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>
2025-08-19 19:38:03 +02:00
c1f23c4345 fix: Correction AppConfig::get() inexistante dans EmailThrottler et ajout des services Security manquants
- Suppression de l'appel à AppConfig::get() qui n'existe pas
- Utilisation de la configuration par défaut dans EmailThrottler
- Ajout de tous les services Security au repository (non trackés auparavant)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-19 13:14:33 +02:00
5e255ebf5e feat: Implémentation authentification NIST SP 800-63B v3.0.8
- Ajout du service PasswordSecurityService conforme NIST SP 800-63B
- Vérification des mots de passe contre la base Have I Been Pwned
- Validation : minimum 8 caractères, maximum 64 caractères
- Pas d'exigences de composition obligatoires (conforme NIST)
- Intégration dans LoginController et UserController
- Génération de mots de passe sécurisés non compromis

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-15 15:31:23 +02:00
846 changed files with 376333 additions and 262494 deletions

40
CHANGELOG-v3.1.6.md Normal file
View File

@@ -0,0 +1,40 @@
# Changelog Version 3.1.6
## Date: 2025-08-21
### Améliorations des flux de passages
#### Interfaces utilisateur
- Optimisation de l'affichage des listes de passages
- Amélioration de l'ergonomie de navigation
- Ajout de filtres avancés pour la recherche de passages
- Mise à jour de l'interface responsive mobile
#### Flux de création
- Simplification du processus de création de passage
- Validation en temps réel des données saisies
- Ajout de modèles de passages prédéfinis
- Amélioration de la gestion des erreurs
#### Flux de consultation
- Affichage optimisé des détails de passage
- Historique complet des modifications
- Export des données en plusieurs formats
- Amélioration des performances de chargement
#### Flux de modification
- Interface de modification intuitive
- Suivi des changements avec comparaison avant/après
- Validation multi-niveaux des modifications
- Notifications automatiques des mises à jour
### Corrections de bugs
- Correction de l'affichage sur écrans de petite taille
- Résolution des problèmes de synchronisation
- Amélioration de la stabilité générale
### Améliorations techniques
- Optimisation des requêtes base de données
- Mise en cache des données fréquemment consultées
- Amélioration des temps de réponse API
- Refactoring du code pour une meilleure maintenabilité

BIN
Capture.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

204
PLANNING-STRIPE-ADMIN.md Normal file
View File

@@ -0,0 +1,204 @@
# PLANNING STRIPE - TÂCHES ADMINISTRATIVES
## Intégration Stripe Connect + Terminal pour Calendriers Pompiers
### Période : 25/08/2024 - 05/09/2024
---
## 📅 LUNDI 25/08 - Préparation (4h)
### ✅ Compte Stripe Platform
- [ ] Créer compte Stripe sur https://dashboard.stripe.com/register
- [ ] Remplir informations entreprise (SIRET, adresse, etc.)
- [ ] Vérifier email de confirmation
- [ ] Activer l'authentification 2FA
### ✅ Activation des produits
- [ ] Activer Stripe Connect dans Dashboard → Products
- [ ] Choisir type "Express accounts" pour les amicales
- [ ] Activer Stripe Terminal dans Dashboard
- [ ] Demander accès "Tap to Pay on iPhone" via formulaire support
### ✅ Configuration initiale
- [ ] Définir les frais de plateforme (suggestion: 2.5% ou 0.50€ fixe)
- [ ] Configurer les paramètres de virement (J+2 recommandé)
- [ ] Ajouter logo et branding pour les pages Stripe
---
## 📅 MARDI 26/08 - Setup environnements (2h)
### ✅ Clés API et Webhooks
- [ ] Récupérer clés TEST (pk_test_... et sk_test_...)
- [ ] Créer endpoint webhook : https://votreapi.com/webhooks/stripe
- [ ] Sélectionner événements webhook :
- `account.updated`
- `account.application.authorized`
- `payment_intent.succeeded`
- `payment_intent.payment_failed`
- `charge.dispute.created`
- [ ] Noter le Webhook signing secret (whsec_...)
### ✅ Documentation amicales
- [ ] Préparer template email pour amicales
- [ ] Créer guide PDF "Activer les paiements CB"
- [ ] Lister documents requis :
- Statuts association
- RIB avec IBAN/BIC
- Pièce identité responsable
- Justificatif adresse siège
---
## 📅 MERCREDI 27/08 - Amicale pilote (3h)
### ✅ Onboarding première amicale
- [ ] Contacter amicale pilote
- [ ] Créer compte Connect Express via API ou Dashboard
- [ ] Envoyer lien onboarding à l'amicale
- [ ] Suivre progression dans Dashboard → Connect → Accounts
- [ ] Vérifier statut "Charges enabled"
### ✅ Configuration compte amicale
- [ ] Vérifier informations bancaires (IBAN)
- [ ] Configurer email notifications
- [ ] Tester micro-virement de vérification
- [ ] Noter le compte ID : acct_...
---
## 📅 JEUDI 28/08 - Tests paiements (2h)
### ✅ Configuration Terminal Test
- [ ] Créer "Location" test dans Dashboard → Terminal
- [ ] Générer reader test virtuel pour Simulator
- [ ] Configurer les montants de test (10€, 20€, 30€)
### ✅ Cartes de test
- [ ] Préparer liste cartes test :
- 4242 4242 4242 4242 : Succès
- 4000 0000 0000 9995 : Refus
- 4000 0025 0000 3155 : Authentification requise
- [ ] Documenter processus de test pour développeurs
---
## 📅 VENDREDI 29/08 - Compliance et sécurité (2h)
### ✅ Conformité légale
- [ ] Vérifier statut PCI DSS (auto-évaluation SAQ A)
- [ ] Préparer mentions légales paiement
- [ ] Créer template CGV pour paiements
- [ ] Documenter process RGPD
### ✅ Limites et sécurité
- [ ] Configurer limites de paiement (max 500€/transaction ?)
- [ ] Activer Radar (protection fraude) rules
- [ ] Configurer alertes email pour transactions > 100€
- [ ] Définir politique remboursements
---
## 📅 SAMEDI 30/08 - Monitoring (1h)
### ✅ Dashboard et alertes
- [ ] Créer vues personnalisées Dashboard
- [ ] Configurer alertes :
- Taux d'échec > 10%
- Nouveau litige (chargeback)
- Compte amicale suspendu
- [ ] Installer app mobile Stripe (iOS/Android)
---
## 📅 LUNDI 02/09 - Préparation production (3h)
### ✅ Amicales supplémentaires
- [ ] Onboarder 2-3 amicales test supplémentaires
- [ ] Vérifier leurs statuts de compte
- [ ] Former les responsables à l'interface Stripe
### ✅ Documentation finale
- [ ] Guide administrateur plateforme
- [ ] FAQ amicales (comment voir mes ventes ?)
- [ ] Process de support niveau 1
---
## 📅 MARDI 03/09 - Tests grandeur nature (2h)
### ✅ Simulation production
- [ ] Paiement test avec vraie carte (sera remboursé)
- [ ] Vérifier apparition dans Dashboard amicale
- [ ] Tester virement vers compte bancaire
- [ ] Vérifier commissions plateforme
---
## 📅 MERCREDI 04/09 - Go/No-Go Production (2h)
### ✅ Checklist production
- [ ] Obtenir clés PRODUCTION (pk_live_... et sk_live_...)
- [ ] ⚠️ JAMAIS les commiter dans le code
- [ ] Configurer webhook production
- [ ] Vérifier tous les comptes amicales "enabled"
- [ ] Backup des configurations
### ✅ Plan de bascule
- [ ] Planifier fenêtre de maintenance
- [ ] Préparer rollback si besoin
- [ ] Numéro hotline Stripe : +33 1 88 45 05 35
---
## 📅 JEUDI 05/09 - Support jour J (4h)
### ✅ Surveillance active
- [ ] Monitoring Dashboard en temps réel
- [ ] Vérifier premiers paiements réels
- [ ] Support hotline pour amicales
- [ ] Documenter issues rencontrées
---
## 📊 RÉCAPITULATIF TEMPS ADMIN
- **Préparation** : 4h
- **Configuration** : 7h
- **Tests** : 4h
- **Production** : 6h
- **TOTAL** : 21h sur 11 jours
## 🔐 INFORMATIONS SENSIBLES À STOCKER
```env
# JAMAIS dans le code source !
STRIPE_PUBLIC_KEY_TEST=pk_test_...
STRIPE_SECRET_KEY_TEST=sk_test_...
STRIPE_WEBHOOK_SECRET_TEST=whsec_...
STRIPE_PUBLIC_KEY_LIVE=pk_live_...
STRIPE_SECRET_KEY_LIVE=sk_live_...
STRIPE_WEBHOOK_SECRET_LIVE=whsec_...
STRIPE_PLATFORM_ACCOUNT_ID=acct_...
```
## 📞 CONTACTS UTILES
- **Support Stripe France** : +33 1 88 45 05 35
- **Email support** : support@stripe.com
- **Dashboard** : https://dashboard.stripe.com
- **Statut système** : https://status.stripe.com
## ⚠️ POINTS D'ATTENTION CRITIQUES
1. **NE JAMAIS** partager les clés secrètes (sk_)
2. **TOUJOURS** commencer en mode TEST
3. **VÉRIFIER** 2x avant passage en LIVE
4. Les virements vers comptes amicales prennent 2-7 jours
5. Garder 1 personne dispo pour support le jour J
6. **Android Tap to Pay** : Vérifier certification SoftPOS des appareils
7. **Maintenir** liste des modèles Android certifiés à jour
---
*Document créé le 24/08/2024 - À tenir à jour quotidiennement*

1
VERSION Normal file
View File

@@ -0,0 +1 @@
3.2.1

117
api/TODO-API.md Normal file
View File

@@ -0,0 +1,117 @@
# TODO-API.md
## 📋 Liste des tâches à implémenter
### 🔴 PRIORITÉ HAUTE
#### 1. Système de backup pour les suppressions (DELETE)
**Demandé le :** 20/08/2025
**Objectif :** Sauvegarder toutes les données supprimées (soft delete) dans un fichier SQL pour pouvoir les restaurer en cas d'erreur humaine.
**Détails techniques :**
- Créer un système de backup automatique lors de chaque DELETE
- Stocker les données dans un fichier SQL avec structure permettant la réintégration facile
- Format suggéré : `/backups/deleted/{année}/{mois}/deleted_{table}_{YYYYMMDD}.sql`
**Tables concernées :**
- `ope_pass` (passages) - DELETE /passages/{id}
- `users` (utilisateurs) - DELETE /users/{id}
- `operations` (opérations) - DELETE /operations/{id}
- `ope_sectors` (secteurs) - DELETE /sectors/{id}
**Structure du backup suggérée :**
```sql
-- Backup deletion: ope_pass
-- Date: 2025-08-20 14:30:45
-- User: 9999985 (cv_mobile)
-- Entity: 5
-- Original ID: 19500576
INSERT INTO ope_pass_backup (
original_id,
deleted_at,
deleted_by_user_id,
deleted_by_entity_id,
-- tous les champs originaux
fk_operation,
fk_sector,
fk_user,
montant,
encrypted_name,
encrypted_email,
-- etc...
) VALUES (
19500576,
'2025-08-20 14:30:45',
9999985,
5,
-- valeurs originales
...
);
-- Pour restauration facile :
-- UPDATE ope_pass SET chk_active = 1 WHERE id = 19500576;
```
**Fonctionnalités à implémenter :**
1. **Service de backup** : `BackupService.php`
- Méthode `backupDeletedRecord($table, $id, $data)`
- Génération automatique du SQL de restauration
- Rotation des fichiers (garder 90 jours)
2. **Intégration dans les controllers**
- Ajouter l'appel au BackupService avant chaque soft delete
- Logger l'emplacement du backup
3. **Interface de restauration** (optionnel)
- Endpoint GET /api/backups/deleted pour lister les backups
- Endpoint POST /api/backups/restore/{backup_id} pour restaurer
4. **Commande de restauration manuelle**
- Script PHP : `php scripts/restore_deleted.php --table=ope_pass --id=19500576`
**Avantages :**
- Traçabilité complète des suppressions
- Restauration rapide en cas d'erreur
- Audit trail pour conformité
- Tranquillité d'esprit pour le client
---
### 🟡 PRIORITÉ MOYENNE
#### 2. Amélioration des logs
- Ajouter plus de contexte dans les logs
- Rotation automatique des logs
- Dashboard de monitoring
#### 3. Optimisation des performances
- Cache des requêtes fréquentes
- Index sur les tables volumineuses
- Pagination optimisée
---
### 🟢 PRIORITÉ BASSE
#### 4. Documentation API
- Génération automatique OpenAPI/Swagger
- Documentation interactive
- Exemples de code pour chaque endpoint
#### 5. Tests automatisés
- Tests unitaires pour les services critiques
- Tests d'intégration pour les endpoints
- Tests de charge
---
## 📝 Notes
- Les tâches marquées 🔴 doivent être traitées en priorité
- Chaque tâche implémentée doit être documentée
- Prévoir des tests pour chaque nouvelle fonctionnalité
---
**Dernière mise à jour :** 20/08/2025

View File

@@ -8,7 +8,9 @@
"ext-openssl": "*",
"ext-pdo": "*",
"phpmailer/phpmailer": "^6.8",
"phpoffice/phpspreadsheet": "^2.0"
"phpoffice/phpspreadsheet": "^2.0",
"setasign/fpdf": "^1.8",
"stripe/stripe-php": "^17.6"
},
"autoload": {
"classmap": [

107
api/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "cf5e9de2a9687d04e4e094ad368ce366",
"content-hash": "155893f9be89bceda3639efbf19b14d1",
"packages": [
{
"name": "composer/pcre",
@@ -666,6 +666,111 @@
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
},
"time": "2021-10-29T13:26:27+00:00"
},
{
"name": "setasign/fpdf",
"version": "1.8.6",
"source": {
"type": "git",
"url": "https://github.com/Setasign/FPDF.git",
"reference": "0838e0ee4925716fcbbc50ad9e1799b5edfae0a0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Setasign/FPDF/zipball/0838e0ee4925716fcbbc50ad9e1799b5edfae0a0",
"reference": "0838e0ee4925716fcbbc50ad9e1799b5edfae0a0",
"shasum": ""
},
"require": {
"ext-gd": "*",
"ext-zlib": "*"
},
"type": "library",
"autoload": {
"classmap": [
"fpdf.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Olivier Plathey",
"email": "oliver@fpdf.org",
"homepage": "http://fpdf.org/"
}
],
"description": "FPDF is a PHP class which allows to generate PDF files with pure PHP. F from FPDF stands for Free: you may use it for any kind of usage and modify it to suit your needs.",
"homepage": "http://www.fpdf.org",
"keywords": [
"fpdf",
"pdf"
],
"support": {
"source": "https://github.com/Setasign/FPDF/tree/1.8.6"
},
"time": "2023-06-26T14:44:25+00:00"
},
{
"name": "stripe/stripe-php",
"version": "v17.6.0",
"source": {
"type": "git",
"url": "https://github.com/stripe/stripe-php.git",
"reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/stripe/stripe-php/zipball/a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
"reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"php": ">=5.6.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.72.0",
"phpstan/phpstan": "^1.2",
"phpunit/phpunit": "^5.7 || ^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"Stripe\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Stripe and contributors",
"homepage": "https://github.com/stripe/stripe-php/contributors"
}
],
"description": "Stripe PHP Library",
"homepage": "https://stripe.com/",
"keywords": [
"api",
"payment processing",
"stripe"
],
"support": {
"issues": "https://github.com/stripe/stripe-php/issues",
"source": "https://github.com/stripe/stripe-php/tree/v17.6.0"
},
"time": "2025-08-27T19:32:42+00:00"
}
],
"packages-dev": [],

BIN
api/composer.phar Executable file

Binary file not shown.

View File

@@ -141,6 +141,11 @@ $SSH_JUMP_CMD "
incus exec ${INCUS_CONTAINER} -- chmod -R 775 ${FINAL_PATH}/uploads || exit 1
incus exec ${INCUS_CONTAINER} -- find ${FINAL_PATH}/uploads -type f -exec chmod -R 664 {} \; || exit 1
echo '📦 Mise à jour des dépendances Composer...'
incus exec ${INCUS_CONTAINER} -- bash -c 'cd ${FINAL_PATH} && composer update --no-dev --optimize-autoloader' || {
echo '⚠️ Composer non disponible ou échec, poursuite sans mise à jour des dépendances'
}
echo '🧹 Nettoyage...'
incus exec ${INCUS_CONTAINER} -- rm -f /tmp/${ARCHIVE_NAME} || exit 1
rm -f /tmp/${ARCHIVE_NAME} || exit 1

334
api/docs/API-SECURITY.md Normal file
View File

@@ -0,0 +1,334 @@
# API Security & Performance Monitoring
## 📋 Vue d'ensemble
Système complet de sécurité et monitoring pour l'API GeoSector implémenté et opérationnel.
### ✅ Fonctionnalités implémentées
- **Détection d'intrusions** : Brute force, SQL injection, patterns de scan
- **Monitoring des performances** : Temps de réponse, utilisation mémoire, requêtes DB
- **Alertes email intelligentes** : Throttling, niveaux de priorité
- **Blocage d'IP automatique** : Temporaire ou permanent
- **Traçabilité complète** : Historique pour audit et analyse
## 🏗️ Architecture
### Tables de base de données (préfixe `sec_`)
```sql
-- 4 tables créées dans scripts/sql/create_security_tables.sql
sec_alerts -- Alertes de sécurité
sec_performance_metrics -- Métriques de performance
sec_failed_login_attempts -- Tentatives de connexion échouées
sec_blocked_ips -- IPs bloquées
```
### Services PHP implémentés
```
src/Services/Security/
├── AlertService.php # Gestion centralisée des alertes
├── EmailThrottler.php # Anti-spam pour emails
├── SecurityMonitor.php # Détection des menaces
├── PerformanceMonitor.php # Monitoring des temps
└── IPBlocker.php # Gestion des blocages IP
```
### Contrôleur d'administration
```
src/Controllers/SecurityController.php # Interface d'administration
```
## 🚀 Installation
### 1. Créer les tables
```bash
# Exécuter le script SQL sur chaque environnement
mysql -u root -p geo_app < scripts/sql/create_security_tables.sql
```
### 2. Configurer le cron de purge
```bash
# Ajouter dans crontab (crontab -e)
0 2 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/cleanup_security_data.php >> /var/log/security_cleanup.log 2>&1
```
### 3. Tester l'installation
```bash
php test_security.php
```
## 🔒 Fonctionnement
### Détection automatique
Le système détecte et bloque automatiquement :
- **Brute force** : 5 tentatives échouées en 5 minutes → IP bloquée 1h
- **SQL injection** : Patterns suspects → IP bloquée définitivement
- **Scan de vulnérabilités** : Accès aux fichiers sensibles → IP bloquée 1h
- **Rate limiting** : Plus de 60 requêtes/minute → Rejet temporaire
### Monitoring de performance
Chaque requête est automatiquement monitorée :
```php
// Dans index.php
PerformanceMonitor::startRequest();
// ... traitement ...
PerformanceMonitor::endRequest($endpoint, $method, $statusCode);
```
### Alertes email
Configuration des niveaux :
- **INFO** : Log uniquement
- **WARNING** : Email avec throttling 1h
- **ERROR** : Email avec throttling 15min
- **CRITICAL** : Email avec throttling 5min
- **SECURITY** : Email immédiat, priorité haute
## 📊 Endpoints d'administration
Tous les endpoints nécessitent une authentification admin (role >= 2) :
```
GET /api/admin/metrics # Métriques de performance
GET /api/admin/alerts # Alertes actives
POST /api/admin/alerts/:id/resolve # Résoudre une alerte
GET /api/admin/blocked-ips # IPs bloquées
POST /api/admin/unblock-ip # Débloquer une IP
POST /api/admin/block-ip # Bloquer une IP manuellement
GET /api/admin/security-report # Rapport complet
POST /api/admin/cleanup # Nettoyer les anciennes données
POST /api/admin/test-alert # Tester les alertes
```
## 🔧 Configuration
### Seuils par défaut (modifiables dans les services)
```php
// PerformanceMonitor.php
const DEFAULT_THRESHOLDS = [
'response_time_warning' => 1000, // 1 seconde
'response_time_critical' => 3000, // 3 secondes
'db_time_warning' => 500, // 500ms
'db_time_critical' => 1000, // 1 seconde
'memory_warning' => 64, // 64 MB
'memory_critical' => 128 // 128 MB
];
// SecurityMonitor.php
- Brute force : 5 tentatives en 5 minutes
- Rate limit : 60 requêtes par minute
- 404 pattern : 10 erreurs 404 en 10 minutes
// EmailThrottler.php
const DEFAULT_CONFIG = [
'max_per_hour' => 10,
'max_per_day' => 50,
'digest_after' => 5,
'cooldown_minutes' => 60
];
```
### Rétention des données
Configurée dans `scripts/cron/cleanup_security_data.php` :
```php
$RETENTION_DAYS = [
'performance_metrics' => 30, // 30 jours
'failed_login_attempts' => 7, // 7 jours
'resolved_alerts' => 90, // 90 jours
'expired_blocks' => 0 // Déblocage immédiat
];
```
## 📈 Métriques surveillées
### Performance
- Temps de réponse total
- Temps cumulé des requêtes DB
- Nombre de requêtes DB
- Utilisation mémoire (pic et moyenne)
- Codes HTTP de réponse
### Sécurité
- Tentatives de connexion échouées
- IPs bloquées (temporaires/permanentes)
- Patterns d'attaque détectés
- Alertes par type et niveau
## 🛡️ Patterns de détection
### SQL Injection
```php
// Patterns détectés dans SecurityMonitor.php
- UNION SELECT
- DROP TABLE
- INSERT INTO
- UPDATE SET
- DELETE FROM
- Script tags
- OR 1=1
- Commentaires SQL (--)
```
### Fichiers sensibles
```php
// Patterns de scan détectés
- admin, administrator
- wp-admin, phpmyadmin
- .git, .env
- config.php
- backup, .sql, .zip
- shell.php, eval.php
```
## 📝 Exemples d'utilisation
### Déclencher une alerte manuelle
```php
use App\Services\Security\AlertService;
AlertService::trigger('CUSTOM_ALERT', [
'message' => 'Événement important détecté',
'details' => ['user' => $userId, 'action' => $action]
], 'WARNING');
```
### Bloquer une IP manuellement
```php
use App\Services\Security\IPBlocker;
// Blocage temporaire (1 heure)
IPBlocker::block('192.168.1.100', 3600, 'Comportement suspect');
// Blocage permanent
IPBlocker::blockPermanent('192.168.1.100', 'Attaque confirmée');
```
### Obtenir les statistiques
```php
use App\Services\Security\SecurityMonitor;
use App\Services\Security\PerformanceMonitor;
$securityStats = SecurityMonitor::getSecurityStats();
$perfStats = PerformanceMonitor::getStats(null, 24); // 24h
```
## ⚠️ Points d'attention
### RGPD
- Les IPs sont des données personnelles
- Durée de conservation limitée (voir rétention)
- Anonymisation après traitement
### Performance
- Overhead < 5ms par requête
- Optimisation des tables avec index
- Purge automatique des anciennes données
### Sécurité
- Pas d'exposition de données sensibles dans les alertes
- Chiffrement des données utilisateur
- Whitelist pour IPs de confiance (localhost)
## 🔄 Maintenance
### Quotidienne (cron)
```bash
# Purge automatique à 2h du matin
0 2 * * * php /var/www/geosector/api/scripts/cron/cleanup_security_data.php
```
### Hebdomadaire
- Vérifier les alertes actives
- Analyser les tendances de performance
- Ajuster les seuils si nécessaire
### Mensuelle
- Analyser le rapport de sécurité
- Mettre à jour les IPs whitelist/blacklist
- Optimiser les tables si nécessaire
## 🐛 Dépannage
### Les tables n'existent pas
```bash
# Créer les tables
mysql -u root -p geo_app < scripts/sql/create_security_tables.sql
```
### Pas d'alertes email
- Vérifier la configuration email dans `AppConfig`
- Vérifier les logs : `tail -f logs/geosector-*.log`
- Tester avec : `POST /api/admin/test-alert`
### IP bloquée par erreur
```bash
# Via API
curl -X POST https://dapp.geosector.fr/api/admin/unblock-ip \
-H "Authorization: Bearer TOKEN" \
-d '{"ip": "192.168.1.100"}'
# Via MySQL
UPDATE sec_blocked_ips SET unblocked_at = NOW() WHERE ip_address = '192.168.1.100';
```
## 📚 Ressources
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
- [PHP Security Best Practices](https://www.php.net/manual/en/security.php)
- Code source : `/src/Services/Security/`
- Tests : `test_security.php`
- Logs : `/logs/geosector-*.log`
## 🎯 Statut d'implémentation
**Phase 1** : Infrastructure de base - COMPLÉTÉ
- Tables créées avec préfixe `sec_`
- Services PHP implémentés
- Intégration dans index.php et Database.php
**Phase 2** : Monitoring de Performance - COMPLÉTÉ
- Chronométrage automatique des requêtes
- Monitoring des requêtes DB
- Alertes sur dégradation
**Phase 3** : Détection d'intrusions - COMPLÉTÉ
- Détection brute force
- Détection SQL injection
- Blocage IP automatique
**Phase 4** : Alertes Email - COMPLÉTÉ
- Service d'alertes avec throttling
- Templates d'emails
- Niveaux de priorité
**Phase 5** : Administration - COMPLÉTÉ
- Endpoints d'administration
- Interface de gestion
- Rapports de sécurité
**Phase 6** : Maintenance - COMPLÉTÉ
- Script de purge automatique
- Optimisation des tables
- Documentation complète
---
*Dernière mise à jour : 2025-01-17*
*Version : 1.0.0*

419
api/docs/CHAT_MODULE.md Normal file
View File

@@ -0,0 +1,419 @@
# Module Chat - Documentation API
## Vue d'ensemble
Le module Chat permet aux utilisateurs de l'application GeoSector de communiquer entre eux via une messagerie intégrée. Il supporte les conversations privées, de groupe et les diffusions (broadcast).
## Architecture
### Tables de base de données
- `chat_rooms` : Salles de conversation
- `chat_messages` : Messages échangés
- `chat_participants` : Participants aux conversations
- `chat_read_receipts` : Accusés de lecture
### Permissions par rôle
| Rôle | Permissions |
|------|------------|
| **1 - Utilisateur** | Conversations privées et groupes avec membres de son entité |
| **2 - Admin entité** | Toutes conversations de son entité + création de diffusions |
| **> 2 - Super admin** | Accès total à toutes les conversations |
## Flux d'utilisation du module Chat
### 📱 Vue d'ensemble du flux
Le module Chat fonctionne en mode **chargement dynamique** : les données sont récupérées à la demande, pas toutes en une fois au login.
### 1. Au login (`/api/login`)
La réponse du login contient un objet `chat` avec les informations de base :
```json
{
"status": "success",
"user": {...},
"amicale": {...},
"chat": {
"total_rooms": 5, // Nombre total de conversations
"unread_messages": 12, // Total messages non lus
"chat_enabled": true, // Module activé pour cet utilisateur
"last_active_room": { // Dernière conversation active
"id": "uuid-room-123",
"title": "Discussion équipe",
"type": "group",
"last_message": "À demain !",
"last_message_at": "2025-01-17 18:30:00"
}
}
}
```
→ Permet d'afficher un **badge de notification** et de savoir si le chat est disponible
### 2. Ouverture de la page Chat
#### Étape 1 : Chargement initial
```
GET /api/chat/rooms
```
→ Récupère la liste des conversations avec aperçu du dernier message
#### Étape 2 : Sélection d'une conversation
```
GET /api/chat/rooms/{room_id}/messages?limit=50
```
→ Charge les 50 derniers messages (pagination disponible)
#### Étape 3 : Marquage comme lu
```
POST /api/chat/rooms/{room_id}/read
```
→ Met à jour les compteurs de messages non lus
### 3. Actions utilisateur
| Action | Endpoint | Description |
|--------|----------|-------------|
| **Envoyer un message** | `POST /api/chat/rooms/{id}/messages` | Envoie et retourne le message créé |
| **Créer une conversation** | `POST /api/chat/rooms` | Crée une nouvelle room |
| **Obtenir les destinataires** | `GET /api/chat/recipients` | Liste des contacts disponibles |
| **Charger plus de messages** | `GET /api/chat/rooms/{id}/messages?before={msg_id}` | Pagination |
### 4. Stratégies de rafraîchissement
#### Polling (recommandé pour débuter)
- Rafraîchir `/api/chat/rooms` toutes les 30 secondes
- Rafraîchir les messages de la conversation active toutes les 10 secondes
#### Pull to refresh
- Permettre à l'utilisateur de rafraîchir manuellement
#### Lifecycle events
- Recharger quand l'app revient au premier plan
- Rafraîchir après envoi d'un message
### 5. Exemple d'implémentation Flutter
```dart
class ChatService {
Timer? _roomsTimer;
Timer? _messagesTimer;
// 1. Au login, stocker les infos de base
void initFromLogin(Map<String, dynamic> chatData) {
_unreadCount = chatData['unread_messages'];
_chatEnabled = chatData['chat_enabled'];
notifyListeners();
}
// 2. À l'ouverture du chat
Future<void> openChatPage() async {
// Charger les conversations
final rooms = await api.get('/api/chat/rooms');
_rooms = rooms['rooms'];
// Démarrer le polling
_startPolling();
}
// 3. Sélection d'une conversation
Future<void> selectRoom(String roomId) async {
// Charger les messages
final response = await api.get('/api/chat/rooms/$roomId/messages');
_currentMessages = response['messages'];
// Marquer comme lu
await api.post('/api/chat/rooms/$roomId/read');
// Rafraîchir plus fréquemment cette conversation
_startMessagePolling(roomId);
}
// 4. Polling automatique
void _startPolling() {
_roomsTimer = Timer.periodic(Duration(seconds: 30), (_) {
_refreshRooms();
});
}
// 5. Nettoyage
void dispose() {
_roomsTimer?.cancel();
_messagesTimer?.cancel();
}
}
```
## Endpoints API
### 1. GET /api/chat/rooms
**Description** : Récupère la liste des conversations de l'utilisateur
**Réponse** :
```json
{
"status": "success",
"rooms": [
{
"id": "uuid-room-1",
"title": "Discussion équipe",
"type": "group",
"created_at": "2025-01-17 10:00:00",
"created_by": 123,
"updated_at": "2025-01-17 14:30:00",
"last_message": "Bonjour tout le monde",
"last_message_at": "2025-01-17 14:30:00",
"unread_count": 3,
"participant_count": 5,
"participants": [
{
"user_id": 123,
"name": "Jean Dupont",
"first_name": "Jean",
"is_admin": true
}
]
}
]
}
```
### 2. POST /api/chat/rooms
**Description** : Crée une nouvelle conversation
**Body** :
```json
{
"type": "private|group|broadcast",
"title": "Titre optionnel (requis pour group/broadcast)",
"participants": [456, 789], // IDs des participants
"initial_message": "Message initial optionnel"
}
```
**Règles** :
- `private` : Maximum 2 participants (incluant le créateur)
- `group` : Plusieurs participants possibles
- `broadcast` : Réservé aux admins (rôle >= 2)
**Réponse** :
```json
{
"status": "success",
"room": {
"id": "uuid-new-room",
"title": "Nouvelle conversation",
"type": "group",
"created_at": "2025-01-17 15:00:00",
"participants": [...]
},
"existing": false // true si conversation privée existante trouvée
}
```
### 3. GET /api/chat/rooms/{id}/messages
**Description** : Récupère les messages d'une conversation
**Paramètres** :
- `limit` : Nombre de messages (défaut: 50, max: 100)
- `before` : ID du message pour pagination
**Réponse** :
```json
{
"status": "success",
"messages": [
{
"id": "uuid-message-1",
"content": "Bonjour !",
"sender_id": 123,
"sender_name": "Jean Dupont",
"sender_first_name": "Jean",
"sent_at": "2025-01-17 14:00:00",
"edited_at": null,
"is_deleted": false,
"is_read": true,
"is_mine": false,
"read_count": 3
}
],
"has_more": true
}
```
### 4. POST /api/chat/rooms/{id}/messages
**Description** : Envoie un message dans une conversation
**Body** :
```json
{
"content": "Contenu du message (max 5000 caractères)"
}
```
**Réponse** :
```json
{
"status": "success",
"message": {
"id": "uuid-new-message",
"content": "Message envoyé",
"sender_id": 123,
"sender_name": "Jean Dupont",
"sent_at": "2025-01-17 15:30:00",
"is_mine": true,
"is_read": false,
"read_count": 0
}
}
```
### 5. POST /api/chat/rooms/{id}/read
**Description** : Marque les messages comme lus
**Body (optionnel)** :
```json
{
"message_ids": ["uuid-1", "uuid-2"] // Si omis, marque tous les messages
}
```
**Réponse** :
```json
{
"status": "success",
"unread_count": 0 // Nombre de messages non lus restants
}
```
### 6. GET /api/chat/recipients
**Description** : Liste des destinataires possibles pour créer une conversation
**Réponse** :
```json
{
"status": "success",
"recipients": [
{
"id": 456,
"name": "Marie Martin",
"first_name": "Marie",
"role": 1,
"entite_id": 5
}
],
"recipients_by_entity": {
"Amicale de Grenoble": [
{...}
],
"Amicale de Lyon": [
{...}
]
}
}
```
## Fonctionnalités clés
### 1. Types de conversations
#### Private (Conversation privée)
- Entre 2 utilisateurs uniquement
- Détection automatique de conversation existante
- Pas de titre requis
#### Group (Groupe)
- Plusieurs participants
- Titre optionnel mais recommandé
- Admin de groupe (créateur)
#### Broadcast (Diffusion)
- Réservé aux admins (rôle >= 2)
- Communication unidirectionnelle possible
- Pour annonces importantes
### 2. Gestion des permissions
Le système vérifie automatiquement :
- L'appartenance à une conversation avant lecture/écriture
- Les droits de création selon le type de conversation
- La visibilité des destinataires selon le rôle
### 3. Statuts de lecture
- **Accusés de lecture individuels** : Chaque message peut être marqué comme lu
- **Compteur de non-lus** : Par conversation et global
- **Last read** : Timestamp de dernière lecture par participant
### 4. Optimisations
- **Pagination** : Chargement progressif des messages
- **Index optimisés** : Pour les requêtes fréquentes
- **Vue SQL** : Pour récupération rapide du dernier message
## Sécurité
### Chiffrement
- Les noms d'utilisateurs sont stockés chiffrés (AES-256)
- Déchiffrement à la volée lors de la lecture
### Validation
- Longueur maximale des messages : 5000 caractères
- Trim automatique du contenu
- Vérification des permissions à chaque action
### Isolation
- Les utilisateurs ne voient que leurs conversations autorisées
- Filtrage par entité selon le rôle
- Soft delete pour conservation de l'historique
## Migration
Exécuter le script SQL :
```bash
mysql -u root -p geo_app < scripts/sql/create_chat_tables.sql
```
## Évolutions futures possibles
1. **Notifications push** : Intégration avec Firebase/WebSocket
2. **Fichiers joints** : Support d'images et documents
3. **Réactions** : Emojis sur les messages
4. **Mentions** : @username pour notifier
5. **Recherche** : Dans l'historique des messages
6. **Chiffrement E2E** : Pour conversations sensibles
7. **Statuts de présence** : En ligne/Hors ligne
8. **Indicateur de frappe** : "X est en train d'écrire..."
## Tests
### Cas de test recommandés
1. **Création de conversation privée**
- Vérifier la détection de conversation existante
- Tester avec utilisateurs de différentes entités
2. **Envoi de messages**
- Messages avec caractères UTF-8 (émojis, accents)
- Messages très longs (limite 5000)
- Messages vides (doivent être rejetés)
3. **Marquage comme lu**
- Marquer messages spécifiques
- Marquer tous les messages d'une room
- Vérifier les compteurs
4. **Permissions**
- Utilisateur simple ne peut pas créer de broadcast
- Accès refusé aux conversations non autorisées
- Filtrage correct des destinataires
## Support
Pour toute question ou problème :
- Vérifier les logs dans `/logs/`
- Consulter les tables `chat_*` en base de données
- Tester avec les scripts de test fournis

View File

@@ -0,0 +1,138 @@
# Gestion du champ chk_user_delete_pass
## 📋 Description
Le champ `chk_user_delete_pass` permet de contrôler si les membres d'une amicale peuvent supprimer des passages.
## 🔄 Modifications API
### 1. Base de données
- **Table** : `entites`
- **Champ** : `chk_user_delete_pass` TINYINT(1) DEFAULT 0
- **Valeurs** :
- `0` : Les membres NE peuvent PAS supprimer de passages (par défaut)
- `1` : Les membres PEUVENT supprimer des passages
### 2. Endpoints modifiés
#### POST /api/entites (Création)
- Le champ est automatiquement initialisé à `0` (false) lors de la création
- Non modifiable à la création
#### PUT /api/entites/{id} (Modification)
**Entrée JSON :**
```json
{
"chk_user_delete_pass": 1
}
```
- **Type** : Boolean (0 ou 1)
- **Obligatoire** : Non
- **Accès** : Administrateurs uniquement (fk_role > 1)
#### GET /api/entites/{id} (Récupération)
**Sortie JSON :**
```json
{
"id": 5,
"name": "Amicale de Pompiers",
"code_postal": "75001",
"ville": "Paris",
"chk_active": 1,
"chk_user_delete_pass": 0
}
```
#### GET /api/entites (Liste)
Retourne `chk_user_delete_pass` pour chaque entité dans la liste.
### 3. Route /api/login
Le champ `chk_user_delete_pass` est maintenant inclus dans la réponse de login dans les objets `amicale` :
**Réponse JSON :**
```json
{
"user": { ... },
"amicale": {
"id": 5,
"name": "Amicale de Pompiers",
"code_postal": "75001",
"ville": "Paris",
"chk_demo": 0,
"chk_mdp_manuel": 0,
"chk_username_manuel": 0,
"chk_copie_mail_recu": 0,
"chk_accept_sms": 0,
"chk_active": 1,
"chk_stripe": 0,
"chk_user_delete_pass": 0 // ← NOUVEAU CHAMP
}
}
```
## 🎯 Utilisation côté client
### Flutter/Web
Le client doit :
1. **Récupérer** la valeur de `chk_user_delete_pass` depuis la réponse login
2. **Stocker** cette valeur dans l'état de l'application
3. **Conditionner** l'affichage du bouton de suppression selon cette valeur
**Exemple Flutter :**
```dart
// Dans le modèle Amicale
class Amicale {
final int id;
final String name;
final bool chkUserDeletePass; // Nouveau champ
bool get canUserDeletePassage => chkUserDeletePass;
}
// Dans l'UI
if (amicale.canUserDeletePassage) {
// Afficher le bouton de suppression
IconButton(
icon: Icon(Icons.delete),
onPressed: () => deletePassage(passageId),
)
}
```
## ⚠️ Points importants
1. **Valeur par défaut** : Toujours `0` (false) pour la sécurité
2. **Modification** : Seuls les administrateurs (fk_role > 1) peuvent modifier ce champ
3. **Rétrocompatibilité** : Les entités existantes ont la valeur `0` par défaut
4. **Validation côté serveur** : L'API vérifiera également ce droit lors de la tentative de suppression
## 📝 Script SQL
Le script de migration est disponible dans :
```
/scripts/sql/add_chk_user_delete_pass.sql
```
## ✅ Checklist d'implémentation
### Côté API (déjà fait) :
- [x] Ajout du champ en base de données
- [x] Modification EntiteController (create, update, get)
- [x] Modification LoginController (réponse login)
- [x] Script SQL de migration
### Côté Client (à faire) :
- [ ] Ajouter le champ dans le modèle Amicale
- [ ] Parser le champ depuis la réponse login
- [ ] Stocker dans l'état de l'application
- [ ] Conditionner l'affichage du bouton suppression
- [ ] Tester avec des valeurs 0 et 1
## 🔒 Sécurité
Même si `chk_user_delete_pass = 1`, l'API devra vérifier :
- L'authentification de l'utilisateur
- L'appartenance à l'entité
- Le droit de suppression sur le passage spécifique
- Les règles métier (ex: pas de suppression après export)
---
**Date :** 20/08/2025
**Version API :** 3.1.4

View File

@@ -0,0 +1,165 @@
# API DELETE /passages/{id} - Documentation des permissions
## 📋 Endpoint
```
DELETE /api/passages/{id}
```
## 🔒 Authentification
- **Requise** : OUI (Bearer token)
- **Session** : Doit être valide
## 📊 Logique de permissions
### Règles par rôle :
| fk_role | Description | Peut supprimer ? | Conditions |
|---------|------------|------------------|------------|
| 1 | Membre | ✅ Conditionnel | Si `entites.chk_user_delete_pass = 1` |
| 2 | Admin amicale | ✅ OUI | Toujours autorisé |
| 3+ | Super admin | ✅ OUI | Toujours autorisé |
### Détail du contrôle pour les membres (fk_role = 1) :
```sql
-- L'API vérifie :
SELECT chk_user_delete_pass
FROM entites
WHERE id = {user.fk_entite}
-- Si chk_user_delete_pass = 0 → Erreur 403
-- Si chk_user_delete_pass = 1 → Continue
```
## 🔄 Flux de vérification
```mermaid
graph TD
A[DELETE /passages/{id}] --> B{Utilisateur authentifié ?}
B -->|Non| C[Erreur 401]
B -->|Oui| D{Récupérer fk_role}
D --> E{fk_role = 1 ?}
E -->|Non| F[Autorisé - Admin]
E -->|Oui| G{Vérifier chk_user_delete_pass}
G -->|= 0| H[Erreur 403 - Non autorisé]
G -->|= 1| F
F --> I{Passage existe ?}
I -->|Non| J[Erreur 404]
I -->|Oui| K{Passage appartient à l'entité ?}
K -->|Non| L[Erreur 404]
K -->|Oui| M[Soft delete : chk_active = 0]
M --> N[Succès 200]
```
## 📝 Réponses
### ✅ Succès (200)
```json
{
"status": "success",
"message": "Passage supprimé avec succès"
}
```
### ❌ Erreur 401 - Non authentifié
```json
{
"status": "error",
"message": "Vous devez être connecté pour effectuer cette action"
}
```
### ❌ Erreur 403 - Permission refusée (membre sans autorisation)
```json
{
"status": "error",
"message": "Vous n'avez pas l'autorisation de supprimer des passages"
}
```
### ❌ Erreur 404 - Passage non trouvé
```json
{
"status": "error",
"message": "Passage non trouvé"
}
```
## 📊 Logging
L'API enregistre :
### En cas de tentative non autorisée :
```php
LogService::log('Tentative de suppression de passage non autorisée', [
'level' => 'warning',
'userId' => $userId,
'userRole' => $userRole,
'entiteId' => $entiteId,
'passageId' => $passageId,
'chk_user_delete_pass' => 0
]);
```
### En cas de succès :
```php
LogService::log('Suppression d\'un passage', [
'level' => 'info',
'userId' => $userId,
'passageId' => $passageId
]);
```
## 🎯 Exemple d'utilisation
### Requête
```bash
curl -X DELETE https://api.geosector.fr/api/passages/19500576 \
-H "Authorization: Bearer {session_token}" \
-H "Content-Type: application/json"
```
### Scénarios
#### Scénario 1 : Membre avec permission ✅
- Utilisateur : fk_role = 1
- Entité : chk_user_delete_pass = 1
- **Résultat** : Suppression autorisée
#### Scénario 2 : Membre sans permission ❌
- Utilisateur : fk_role = 1
- Entité : chk_user_delete_pass = 0
- **Résultat** : Erreur 403
#### Scénario 3 : Admin amicale ✅
- Utilisateur : fk_role = 2
- **Résultat** : Suppression autorisée (pas de vérification chk_user_delete_pass)
## ⚠️ Notes importantes
1. **Soft delete** : Le passage n'est pas supprimé physiquement, seulement `chk_active = 0`
2. **Traçabilité** : `updated_at` et `fk_user_modif` sont mis à jour
3. **Contrôle entité** : Un utilisateur ne peut supprimer que les passages de son entité
4. **Log warning** : Toute tentative non autorisée est loggée en niveau WARNING
## 🔧 Configuration côté amicale
Pour autoriser les membres à supprimer des passages :
```sql
UPDATE entites
SET chk_user_delete_pass = 1
WHERE id = {entite_id};
```
Cette modification ne peut être faite que par un administrateur (fk_role > 1) via l'endpoint :
```
PUT /api/entites/{id}
{
"chk_user_delete_pass": 1
}
```
---
**Version API** : 3.1.4
**Date** : 20/08/2025

View File

@@ -0,0 +1,176 @@
# Correction des erreurs 400 lors de la création d'utilisateurs
## Problème identifié
Un administrateur (fk_role=2) rencontrait des erreurs 400 répétées lors de tentatives de création de membre, menant à un bannissement par fail2ban :
- 17:09:39 - POST /api/users HTTP/1.1 400 (Bad Request)
- 17:10:44 - POST /api/users/check-username HTTP/1.1 400 (Bad Request)
- 17:11:21 - POST /api/users HTTP/1.1 400 (Bad Request)
## Causes identifiées
### 1. Conflit de routage (CRITIQUE)
**Problème:** La route `/api/users/check-username` était déclarée APRÈS la route générique `/api/users` dans Router.php, causant une mauvaise interprétation où "check-username" était traité comme un ID utilisateur.
**Solution:** Déplacer la déclaration de la route spécifique AVANT les routes avec paramètres.
### 2. Messages d'erreur non informatifs
**Problème:** Les erreurs 400 retournaient des messages génériques sans détails sur le champ problématique.
**Solution:** Ajout de messages d'erreur détaillés incluant :
- Le champ en erreur (`field`)
- La valeur problématique (`value`)
- Le format attendu (`format`)
- La raison de l'erreur (`reason`)
### 3. Manque de logs de débogage
**Problème:** Aucun log n'était généré pour tracer les erreurs de validation.
**Solution:** Ajout de logs détaillés à chaque point de validation.
## Modifications apportées
### 1. Router.php (ligne 36-44)
```php
// AVANT (incorrect)
$this->post('users', ['UserController', 'createUser']);
$this->post('users/check-username', ['UserController', 'checkUsername']);
// APRÈS (correct)
$this->post('users/check-username', ['UserController', 'checkUsername']); // Route spécifique en premier
$this->post('users', ['UserController', 'createUser']);
```
### 2. UserController.php - Amélioration des validations
#### Validation de l'email
```php
// Réponse améliorée
Response::json([
'status' => 'error',
'message' => 'Email requis',
'field' => 'email' // Indique clairement le champ problématique
], 400);
```
#### Validation du username manuel
```php
// Réponse améliorée
Response::json([
'status' => 'error',
'message' => 'Le nom d\'utilisateur est requis pour cette entité',
'field' => 'username',
'reason' => 'L\'entité requiert la saisie manuelle des identifiants'
], 400);
```
#### Format du username
```php
// Réponse améliorée
Response::json([
'status' => 'error',
'message' => 'Format du nom d\'utilisateur invalide',
'field' => 'username',
'format' => '10-30 caractères, commence par une lettre, caractères autorisés: a-z, 0-9, ., -, _',
'value' => $username // Montre la valeur soumise
], 400);
```
### 3. Ajout de logs détaillés
Chaque point de validation génère maintenant un log avec :
- Le type d'erreur
- L'utilisateur qui fait la requête
- Les données reçues (sans données sensibles)
- Le contexte de l'erreur
Exemple :
```php
LogService::log('Erreur création utilisateur : Format username invalide', [
'level' => 'warning',
'createdBy' => $currentUserId,
'email' => $email,
'username' => $username,
'username_length' => strlen($username)
]);
```
## Cas d'erreur 400 possibles
### Pour /api/users (création)
1. **Email manquant ou vide**
- Message: "Email requis"
- Field: "email"
2. **Nom manquant ou vide**
- Message: "Nom requis"
- Field: "name"
3. **Format email invalide**
- Message: "Format d'email invalide"
- Field: "email"
- Value: [email soumis]
4. **Username manuel requis mais manquant** (si chk_username_manuel=1)
- Message: "Le nom d'utilisateur est requis pour cette entité"
- Field: "username"
- Reason: "L'entité requiert la saisie manuelle des identifiants"
5. **Format username invalide**
- Message: "Format du nom d'utilisateur invalide"
- Field: "username"
- Format: "10-30 caractères, commence par une lettre..."
- Value: [username soumis]
6. **Mot de passe manuel requis mais manquant** (si chk_mdp_manuel=1)
- Message: "Le mot de passe est requis pour cette entité"
- Field: "password"
- Reason: "L'entité requiert la saisie manuelle des mots de passe"
### Pour /api/users/check-username
1. **Username manquant**
- Message: "Username requis pour la vérification"
- Field: "username"
2. **Format username invalide**
- Message: "Format invalide"
- Field: "username"
- Format: "10-30 caractères, commence par une lettre..."
- Value: [username soumis]
## Test de la solution
Un script de test a été créé : `/tests/test_user_creation.php`
Il teste tous les cas d'erreur possibles et vérifie que :
1. Les codes HTTP sont corrects
2. Les messages d'erreur sont informatifs
3. Les champs en erreur sont identifiés
## Recommandations pour éviter le bannissement fail2ban
1. **Côté client (application Flutter)** :
- Valider les données AVANT l'envoi
- Afficher clairement les erreurs à l'utilisateur
- Implémenter un délai entre les tentatives (rate limiting côté client)
2. **Côté API** :
- Les messages d'erreur détaillés permettent maintenant de corriger rapidement les problèmes
- Les logs permettent de diagnostiquer les problèmes récurrents
3. **Configuration fail2ban** :
- Considérer d'augmenter le seuil pour les erreurs 400 (ex: 5 tentatives au lieu de 3)
- Exclure certaines IP de confiance si nécessaire
## Suivi des logs
Les logs sont maintenant générés dans :
- `/logs/geosector-[environment]-[date].log` : Logs généraux avec détails des erreurs
Format des logs :
```
timestamp;browser;os;client_type;level;metadata;message
```
Les erreurs de validation sont loggées avec le niveau "warning" pour permettre un suivi sans être critiques.

90
api/docs/INSTALL_FPDF.md Normal file
View File

@@ -0,0 +1,90 @@
# Installation de FPDF pour la génération des reçus PDF avec logo
## Installation via Composer (RECOMMANDÉ)
Sur chaque serveur (DEV, REC, PROD), exécuter :
```bash
cd /var/www/geosector/api
composer require setasign/fpdf
```
Ou si composer.json est déjà mis à jour :
```bash
cd /var/www/geosector/api
composer update
```
## Fichiers à déployer
1. **Nouveaux fichiers** :
- `/src/Services/ReceiptPDFGenerator.php` - Nouvelle classe de génération PDF avec FPDF
- `/docs/_logo_recu.png` - Logo par défaut (casque de pompier)
2. **Fichiers modifiés** :
- `/src/Services/ReceiptService.php` - Utilise maintenant ReceiptPDFGenerator
- `/composer.json` - Ajout de la dépendance FPDF
## Vérification
Après installation, tester la génération d'un reçu :
```bash
# Vérifier que FPDF est installé
ls -la vendor/setasign/fpdf/
# Tester la génération d'un PDF
php -r "
require 'vendor/autoload.php';
\$pdf = new FPDF();
\$pdf->AddPage();
\$pdf->SetFont('Arial','B',16);
\$pdf->Cell(40,10,'Test FPDF OK');
echo 'FPDF fonctionne' . PHP_EOL;
"
```
## Fonctionnalités du nouveau générateur
**Support des vrais logos PNG/JPG**
**Logo par défaut** si l'entité n'a pas de logo
**Taille du logo** : 40x40mm
**Mise en page professionnelle** avec cadre pour le montant
**Conversion automatique** des caractères UTF-8
**PDF léger** (~20-30KB avec logo)
## Structure du reçu généré
1. **En-tête** :
- Logo (40x40mm) à gauche
- Nom et ville de l'entité à droite du logo
2. **Titre** :
- "REÇU FISCAL DE DON"
- Numéro du reçu
- Article 200 CGI
3. **Corps** :
- Informations du donateur
- Montant en gros dans un cadre grisé
- Date du don
- Mode de règlement et campagne
4. **Pied de page** :
- Mentions légales (réduction 66%)
- Date et signature
## Résolution de problèmes
Si erreur "Class 'FPDF' not found" :
```bash
composer dump-autoload
```
Si problème avec le logo :
- Vérifier que `/docs/_logo_recu.png` existe
- Vérifier les permissions : `chmod 644 docs/_logo_recu.png`
Si caractères accentués mal affichés :
- FPDF utilise ISO-8859-1, la conversion est automatique dans ReceiptPDFGenerator

View File

@@ -0,0 +1,622 @@
# PLANNING STRIPE - DÉVELOPPEUR BACKEND PHP
## API PHP 8.3 - Intégration Stripe Connect + Terminal
### Période : 25/08/2024 - 05/09/2024
---
## 📅 LUNDI 25/08 - Setup et architecture (8h)
### 🌅 Matin (4h)
```bash
# Installation Stripe PHP SDK
cd api
composer require stripe/stripe-php
```
#### ✅ Configuration environnement
- [ ] Créer `config/stripe.php` avec clés TEST
- [ ] Ajouter variables `.env` :
```env
STRIPE_PUBLIC_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_API_VERSION=2024-06-20
```
- [ ] Créer service `StripeService.php` singleton
- [ ] Configurer middleware authentification API
#### ✅ Base de données
```sql
-- Tables à créer
CREATE TABLE stripe_accounts (
id INT PRIMARY KEY AUTO_INCREMENT,
amicale_id INT NOT NULL,
stripe_account_id VARCHAR(255) UNIQUE,
charges_enabled BOOLEAN DEFAULT FALSE,
payouts_enabled BOOLEAN DEFAULT FALSE,
onboarding_completed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (amicale_id) REFERENCES amicales(id)
);
CREATE TABLE payment_intents (
id INT PRIMARY KEY AUTO_INCREMENT,
stripe_payment_intent_id VARCHAR(255) UNIQUE,
amicale_id INT NOT NULL,
pompier_id INT NOT NULL,
amount INT NOT NULL, -- en centimes
currency VARCHAR(3) DEFAULT 'eur',
status VARCHAR(50),
application_fee INT,
metadata JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (amicale_id) REFERENCES amicales(id),
FOREIGN KEY (pompier_id) REFERENCES users(id)
);
CREATE TABLE terminal_readers (
id INT PRIMARY KEY AUTO_INCREMENT,
stripe_reader_id VARCHAR(255) UNIQUE,
amicale_id INT NOT NULL,
label VARCHAR(255),
location VARCHAR(255),
status VARCHAR(50),
device_type VARCHAR(50),
last_seen_at TIMESTAMP,
FOREIGN KEY (amicale_id) REFERENCES amicales(id)
);
CREATE TABLE android_certified_devices (
id INT PRIMARY KEY AUTO_INCREMENT,
manufacturer VARCHAR(100),
model VARCHAR(200),
model_identifier VARCHAR(200),
tap_to_pay_certified BOOLEAN DEFAULT FALSE,
certification_date DATE,
min_android_version INT,
country VARCHAR(2) DEFAULT 'FR',
last_verified TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_manufacturer_model (manufacturer, model)
);
```
### 🌆 Après-midi (4h)
#### ✅ Endpoints Connect - Onboarding
```php
// POST /api/amicales/{id}/stripe-account
public function createStripeAccount($amicaleId) {
$amicale = Amicale::find($amicaleId);
$account = \Stripe\Account::create([
'type' => 'express',
'country' => 'FR',
'email' => $amicale->email,
'capabilities' => [
'card_payments' => ['requested' => true],
'transfers' => ['requested' => true],
],
'business_type' => 'non_profit',
'business_profile' => [
'name' => $amicale->name,
'product_description' => 'Vente de calendriers des pompiers',
],
]);
// Sauvegarder stripe_account_id
return $account;
}
// GET /api/amicales/{id}/onboarding-link
public function getOnboardingLink($amicaleId) {
$accountLink = \Stripe\AccountLink::create([
'account' => $amicale->stripe_account_id,
'refresh_url' => config('app.url') . '/stripe/refresh',
'return_url' => config('app.url') . '/stripe/success',
'type' => 'account_onboarding',
]);
return ['url' => $accountLink->url];
}
```
---
## 📅 MARDI 26/08 - Webhooks et Terminal (8h)
### 🌅 Matin (4h)
#### ✅ Webhooks handler
```php
// POST /api/webhooks/stripe
public function handleWebhook(Request $request) {
$payload = $request->getContent();
$sig_header = $request->header('Stripe-Signature');
try {
$event = \Stripe\Webhook::constructEvent(
$payload, $sig_header, config('stripe.webhook_secret')
);
} catch(\Exception $e) {
return response('Invalid signature', 400);
}
switch ($event->type) {
case 'account.updated':
$this->handleAccountUpdated($event->data->object);
break;
case 'account.application.authorized':
$this->handleAccountAuthorized($event->data->object);
break;
case 'payment_intent.succeeded':
$this->handlePaymentSucceeded($event->data->object);
break;
}
return response('Webhook handled', 200);
}
```
#### ✅ Terminal Connection Token
```php
// POST /api/terminal/connection-token
public function createConnectionToken(Request $request) {
$pompier = Auth::user();
$amicale = $pompier->amicale;
$connectionToken = \Stripe\Terminal\ConnectionToken::create([
'location' => $amicale->stripe_location_id,
], [
'stripe_account' => $amicale->stripe_account_id
]);
return ['secret' => $connectionToken->secret];
}
```
### 🌆 Après-midi (4h)
#### ✅ Gestion des Locations
```php
// POST /api/amicales/{id}/create-location
public function createLocation($amicaleId) {
$amicale = Amicale::find($amicaleId);
$location = \Stripe\Terminal\Location::create([
'display_name' => $amicale->name,
'address' => [
'line1' => $amicale->address,
'city' => $amicale->city,
'postal_code' => $amicale->postal_code,
'country' => 'FR',
],
], [
'stripe_account' => $amicale->stripe_account_id
]);
$amicale->update(['stripe_location_id' => $location->id]);
return $location;
}
```
---
## 📅 MERCREDI 27/08 - Paiements et fees (8h)
### 🌅 Matin (4h)
#### ✅ Création PaymentIntent avec commission
```php
// POST /api/payments/create-intent
public function createPaymentIntent(Request $request) {
$validated = $request->validate([
'amount' => 'required|integer|min:100', // en centimes
'amicale_id' => 'required|exists:amicales,id',
]);
$pompier = Auth::user();
$amicale = Amicale::find($validated['amicale_id']);
// Calculer la commission (2.5% ou 50 centimes minimum)
$applicationFee = max(
50, // 0.50€ minimum
round($validated['amount'] * 0.025) // 2.5%
);
$paymentIntent = \Stripe\PaymentIntent::create([
'amount' => $validated['amount'],
'currency' => 'eur',
'payment_method_types' => ['card_present'],
'capture_method' => 'automatic',
'application_fee_amount' => $applicationFee,
'transfer_data' => [
'destination' => $amicale->stripe_account_id,
],
'metadata' => [
'pompier_id' => $pompier->id,
'pompier_name' => $pompier->name,
'amicale_id' => $amicale->id,
'calendrier_annee' => date('Y'),
],
]);
// Sauvegarder en DB
PaymentIntent::create([
'stripe_payment_intent_id' => $paymentIntent->id,
'amicale_id' => $amicale->id,
'pompier_id' => $pompier->id,
'amount' => $validated['amount'],
'application_fee' => $applicationFee,
'status' => $paymentIntent->status,
]);
return [
'client_secret' => $paymentIntent->client_secret,
'payment_intent_id' => $paymentIntent->id,
];
}
```
### 🌆 Après-midi (4h)
#### ✅ Capture et confirmation
```php
// POST /api/payments/{id}/capture
public function capturePayment($paymentIntentId) {
$localPayment = PaymentIntent::where('stripe_payment_intent_id', $paymentIntentId)->first();
$paymentIntent = \Stripe\PaymentIntent::retrieve($paymentIntentId);
if ($paymentIntent->status === 'requires_capture') {
$paymentIntent->capture();
}
$localPayment->update(['status' => $paymentIntent->status]);
// Si succès, envoyer email reçu
if ($paymentIntent->status === 'succeeded') {
$this->sendReceipt($localPayment);
}
return $paymentIntent;
}
// GET /api/payments/{id}/status
public function getPaymentStatus($paymentIntentId) {
$payment = PaymentIntent::where('stripe_payment_intent_id', $paymentIntentId)->first();
return [
'status' => $payment->status,
'amount' => $payment->amount,
'created_at' => $payment->created_at,
];
}
```
---
## 📅 JEUDI 28/08 - Reporting et Android compatibility (8h)
### 🌅 Matin (4h)
#### ✅ Gestion appareils Android certifiés
```php
// POST /api/devices/check-tap-to-pay
public function checkTapToPayCapability(Request $request) {
$validated = $request->validate([
'platform' => 'required|in:ios,android',
'manufacturer' => 'required_if:platform,android',
'model' => 'required_if:platform,android',
'os_version' => 'required',
]);
if ($validated['platform'] === 'ios') {
// iPhone XS et ultérieurs avec iOS 15.4+
$supportedModels = ['iPhone11,', 'iPhone12,', 'iPhone13,', 'iPhone14,', 'iPhone15,', 'iPhone16,'];
$modelSupported = false;
foreach ($supportedModels as $prefix) {
if (str_starts_with($validated['model'], $prefix)) {
$modelSupported = true;
break;
}
}
$osVersion = explode('.', $validated['os_version']);
$osSupported = $osVersion[0] > 15 ||
($osVersion[0] == 15 && isset($osVersion[1]) && $osVersion[1] >= 4);
return [
'tap_to_pay_supported' => $modelSupported && $osSupported,
'message' => $modelSupported && $osSupported ?
'Tap to Pay disponible' :
'iPhone XS ou ultérieur avec iOS 15.4+ requis'
];
}
// Android - vérifier dans la base de données
$device = DB::table('android_certified_devices')
->where('manufacturer', $validated['manufacturer'])
->where('model', $validated['model'])
->where('tap_to_pay_certified', true)
->first();
return [
'tap_to_pay_supported' => $device !== null,
'message' => $device ?
'Tap to Pay disponible sur cet appareil' :
'Appareil non certifié pour Tap to Pay en France',
'alternative' => !$device ? 'Utilisez un iPhone compatible' : null
];
}
// GET /api/devices/certified-android
public function getCertifiedAndroidDevices() {
return DB::table('android_certified_devices')
->where('tap_to_pay_certified', true)
->where('country', 'FR')
->orderBy('manufacturer')
->orderBy('model')
->get();
}
```
#### ✅ Seeder pour appareils certifiés
```php
// database/seeders/AndroidCertifiedDevicesSeeder.php
public function run() {
$devices = [
// Samsung
['manufacturer' => 'Samsung', 'model' => 'Galaxy S21', 'model_identifier' => 'SM-G991B', 'min_android_version' => 11],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S21+', 'model_identifier' => 'SM-G996B', 'min_android_version' => 11],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S21 Ultra', 'model_identifier' => 'SM-G998B', 'min_android_version' => 11],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S22', 'model_identifier' => 'SM-S901B', 'min_android_version' => 12],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23', 'model_identifier' => 'SM-S911B', 'min_android_version' => 13],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S24', 'model_identifier' => 'SM-S921B', 'min_android_version' => 14],
// Google Pixel
['manufacturer' => 'Google', 'model' => 'Pixel 6', 'model_identifier' => 'oriole', 'min_android_version' => 12],
['manufacturer' => 'Google', 'model' => 'Pixel 6 Pro', 'model_identifier' => 'raven', 'min_android_version' => 12],
['manufacturer' => 'Google', 'model' => 'Pixel 7', 'model_identifier' => 'panther', 'min_android_version' => 13],
['manufacturer' => 'Google', 'model' => 'Pixel 8', 'model_identifier' => 'shiba', 'min_android_version' => 14],
];
foreach ($devices as $device) {
DB::table('android_certified_devices')->insert([
'manufacturer' => $device['manufacturer'],
'model' => $device['model'],
'model_identifier' => $device['model_identifier'],
'tap_to_pay_certified' => true,
'certification_date' => now(),
'min_android_version' => $device['min_android_version'],
'country' => 'FR',
]);
}
}
```
#### ✅ Endpoints statistiques
```php
// GET /api/amicales/{id}/stats
public function getAmicaleStats($amicaleId) {
$stats = DB::table('payment_intents')
->where('amicale_id', $amicaleId)
->where('status', 'succeeded')
->selectRaw('
COUNT(*) as total_ventes,
SUM(amount) as total_montant,
SUM(application_fee) as total_commissions,
DATE(created_at) as date
')
->groupBy('date')
->get();
return $stats;
}
// GET /api/pompiers/{id}/ventes
public function getPompierVentes($pompierId) {
return PaymentIntent::where('pompier_id', $pompierId)
->where('status', 'succeeded')
->orderBy('created_at', 'desc')
->paginate(20);
}
```
### 🌆 Après-midi (4h)
#### ✅ Gestion des remboursements
```php
// POST /api/payments/{id}/refund
public function refundPayment($paymentIntentId, Request $request) {
$validated = $request->validate([
'amount' => 'integer|min:100', // optionnel, remboursement partiel
'reason' => 'string|in:duplicate,fraudulent,requested_by_customer',
]);
$payment = PaymentIntent::where('stripe_payment_intent_id', $paymentIntentId)->first();
$refund = \Stripe\Refund::create([
'payment_intent' => $paymentIntentId,
'amount' => $validated['amount'] ?? null, // null = remboursement total
'reason' => $validated['reason'] ?? 'requested_by_customer',
'reverse_transfer' => true, // Important pour Connect
'refund_application_fee' => true, // Rembourser aussi la commission
]);
$payment->update(['status' => 'refunded']);
return $refund;
}
```
---
## 📅 VENDREDI 29/08 - Mode offline et sync (8h)
### 🌅 Matin (4h)
#### ✅ Queue de synchronisation
```php
// POST /api/payments/batch-sync
public function batchSync(Request $request) {
$validated = $request->validate([
'transactions' => 'required|array',
'transactions.*.local_id' => 'required|string',
'transactions.*.amount' => 'required|integer',
'transactions.*.created_at' => 'required|date',
'transactions.*.payment_method' => 'required|in:card,cash',
]);
$results = [];
foreach ($validated['transactions'] as $transaction) {
if ($transaction['payment_method'] === 'cash') {
// Enregistrer paiement cash uniquement en DB
$results[] = $this->recordCashPayment($transaction);
} else {
// Créer PaymentIntent a posteriori (si possible)
$results[] = $this->createOfflinePayment($transaction);
}
}
return ['synced' => $results];
}
```
### 🌆 Après-midi (4h)
#### ✅ Tests unitaires critiques
```php
class StripePaymentTest extends TestCase {
public function test_create_payment_intent_with_fees() {
// Test création PaymentIntent avec commission
}
public function test_webhook_signature_validation() {
// Test sécurité webhook
}
public function test_refund_reverses_transfer() {
// Test remboursement avec annulation virement
}
}
```
---
## 📅 LUNDI 01/09 - Sécurité et optimisations (8h)
### 🌅 Matin (4h)
#### ✅ Rate limiting et sécurité
```php
// Middleware RateLimiter pour endpoints sensibles
Route::middleware(['throttle:10,1'])->group(function () {
Route::post('/payments/create-intent', 'PaymentController@createIntent');
});
// Validation des montants
public function validateAmount($amount) {
if ($amount < 100 || $amount > 50000) { // 1€ - 500€
throw new ValidationException('Montant invalide');
}
}
```
### 🌆 Après-midi (4h)
#### ✅ Logs et monitoring
```php
// Logger tous les événements Stripe
Log::channel('stripe')->info('Payment created', [
'payment_intent_id' => $paymentIntent->id,
'amount' => $paymentIntent->amount,
'pompier_id' => $pompier->id,
]);
```
---
## 📅 MARDI 02/09 - Documentation API (4h)
#### ✅ Documentation OpenAPI/Swagger
```yaml
/api/payments/create-intent:
post:
summary: Créer une intention de paiement
parameters:
- name: amount
type: integer
required: true
description: Montant en centimes
responses:
200:
description: PaymentIntent créé
```
---
## 📅 MERCREDI 03/09 - Tests d'intégration (8h)
#### ✅ Tests end-to-end
- [ ] Parcours complet onboarding amicale
- [ ] Création paiement → capture → confirmation
- [ ] Test remboursement complet et partiel
- [ ] Test webhooks avec ngrok
---
## 📅 JEUDI 04/09 - Mise en production (8h)
---
## 📅 VENDREDI 05/09 - Support et livraison finale (8h)
### 🌅 Matin (4h)
#### ✅ Déploiement final
- [ ] Migration DB production
- [ ] Variables environnement LIVE
- [ ] Smoke tests production
- [ ] Vérification des webhooks en production
### 🌆 Après-midi (4h)
#### ✅ Support et monitoring
- [ ] Monitoring des premiers paiements réels
- [ ] Support hotline pour équipes terrain
- [ ] Documentation de passation
- [ ] Réunion de clôture et retour d'expérience
---
## 📊 RÉCAPITULATIF
- **Total heures** : 72h sur 10 jours
- **Endpoints créés** : 15
- **Tables DB** : 3
- **Tests** : 20+
## 🔧 DÉPENDANCES
```json
{
"require": {
"php": "^8.3",
"stripe/stripe-php": "^13.0",
"laravel/framework": "^10.0"
}
}
```
## ⚠️ CHECKLIST SÉCURITÉ
- [ ] ❌ JAMAIS logger les clés secrètes
- [ ] ✅ TOUJOURS valider signature webhooks
- [ ] ✅ TOUJOURS utiliser HTTPS
- [ ] ✅ Rate limiting sur endpoints paiement
- [ ] ✅ Logs détaillés pour audit
---
*Document créé le 24/08/2024 - À mettre à jour quotidiennement*

237
api/docs/PREPA_PROD.md Normal file
View File

@@ -0,0 +1,237 @@
# PRÉPARATION PRODUCTION - Process Email Queue + Permissions Suppression Passages
## 📅 Date de mise en production prévue : _____________
## 🎯 Objectif
1. Mettre en place le système de traitement automatique de la queue d'emails pour l'envoi des reçus fiscaux de dons.
2. Ajouter le champ de permission pour autoriser les membres à supprimer des passages.
## ✅ Prérequis
- [ ] Backup de la base de données effectué
- [ ] Accès SSH au serveur PROD
- [ ] Accès à la base de données PROD
- [ ] Droits pour éditer le crontab
## 📝 Fichiers à déployer
Les fichiers suivants doivent être présents sur le serveur PROD :
- `/scripts/cron/process_email_queue.php`
- `/scripts/cron/process_email_queue_with_daily_log.sh`
- `/scripts/cron/test_email_queue.php`
- `/src/Services/ReceiptPDFGenerator.php` (nouveau)
- `/src/Services/ReceiptService.php` (mis à jour)
- `/src/Core/MonitoredDatabase.php` (mis à jour)
- `/src/Controllers/EntiteController.php` (mis à jour)
- `/src/Controllers/LoginController.php` (mis à jour)
- `/scripts/sql/add_chk_user_delete_pass.sql` (nouveau)
---
## 🔧 ÉTAPES DE MISE EN PRODUCTION
### 1⃣ Mise à jour de la base de données
Se connecter à la base de données PROD et exécuter :
```sql
-- Vérifier d'abord la structure actuelle de email_queue
DESCRIBE email_queue;
-- Ajouter les champs manquants pour email_queue si nécessaire
ALTER TABLE `email_queue`
ADD COLUMN IF NOT EXISTS `sent_at` TIMESTAMP NULL DEFAULT NULL
COMMENT 'Date/heure d\'envoi effectif de l\'email'
AFTER `status`;
ALTER TABLE `email_queue`
ADD COLUMN IF NOT EXISTS `error_message` TEXT NULL DEFAULT NULL
COMMENT 'Message d\'erreur en cas d\'échec'
AFTER `attempts`;
-- Ajouter les index pour optimiser les performances
ALTER TABLE `email_queue`
ADD INDEX IF NOT EXISTS `idx_status_attempts` (`status`, `attempts`);
ALTER TABLE `email_queue`
ADD INDEX IF NOT EXISTS `idx_sent_at` (`sent_at`);
-- Vérifier les modifications email_queue
DESCRIBE email_queue;
-- ⚠️ IMPORTANT : Ajouter le nouveau champ chk_user_delete_pass dans entites
source /var/www/geosector/api/scripts/sql/add_chk_user_delete_pass.sql;
```
### 2⃣ Test du script avant mise en production
```bash
# Se connecter au serveur PROD
ssh user@prod-server
# Aller dans le répertoire de l'API
cd /var/www/geosector/api
# Rendre les scripts exécutables
chmod +x scripts/cron/process_email_queue.php
chmod +x scripts/cron/test_email_queue.php
# Tester l'état de la queue (lecture seule)
php scripts/cron/test_email_queue.php
# Si tout est OK, faire un test d'envoi sur 1 email
# (modifier temporairement BATCH_SIZE à 1 dans le script si nécessaire)
php scripts/cron/process_email_queue.php
```
### 3⃣ Configuration du CRON avec logs journaliers
```bash
# Rendre le script wrapper exécutable
chmod +x /var/www/geosector/api/scripts/cron/process_email_queue_with_daily_log.sh
# Éditer le crontab
crontab -e
# Ajouter cette ligne pour exécution toutes les 5 minutes avec logs journaliers
*/5 * * * * /var/www/geosector/api/scripts/cron/process_email_queue_with_daily_log.sh
# Sauvegarder et quitter (:wq sous vi/vim)
# Vérifier que le cron est bien enregistré
crontab -l | grep email_queue
# Vérifier que le service cron est actif
systemctl status cron
```
**Note** : Les logs seront créés automatiquement dans `/var/www/geosector/api/logs/` avec le format : `email_queue_20250820.log`, `email_queue_20250821.log`, etc. Les logs de plus de 30 jours sont supprimés automatiquement.
### 4⃣ Surveillance post-déploiement
Pendant les premières heures après la mise en production :
```bash
# Surveiller les logs en temps réel (fichier du jour)
tail -f /var/www/geosector/api/logs/email_queue_$(date +%Y%m%d).log
# Vérifier le statut de la queue
php scripts/cron/test_email_queue.php
# Compter les emails traités
mysql -u geo_app_user_prod -p geo_app -e "
SELECT status, COUNT(*) as count
FROM email_queue
WHERE DATE(created_at) = CURDATE()
GROUP BY status;"
# Vérifier les erreurs éventuelles
mysql -u geo_app_user_prod -p geo_app -e "
SELECT id, to_email, subject, attempts, error_message
FROM email_queue
WHERE status='failed'
ORDER BY created_at DESC
LIMIT 10;"
```
---
## 🚨 ROLLBACK (si nécessaire)
En cas de problème, voici comment revenir en arrière :
```bash
# 1. Stopper le cron
crontab -e
# Commenter la ligne du process_email_queue
# 2. Marquer les emails en attente pour traitement manuel
mysql -u geo_app_user_prod -p geo_app -e "
UPDATE email_queue
SET status='pending', attempts=0
WHERE status='failed' AND DATE(created_at) = CURDATE();"
# 3. Informer l'équipe pour traitement manuel si nécessaire
```
---
## 📊 VALIDATION POST-DÉPLOIEMENT
### Critères de succès :
- [ ] Aucune erreur dans les logs
- [ ] Les emails sont envoyés dans les 5 minutes
- [ ] Les reçus PDF sont correctement attachés
- [ ] Le champ `date_sent_recu` est mis à jour dans `ope_pass`
- [ ] Pas d'accumulation d'emails en status 'pending'
### Commandes de vérification :
```bash
# Statistiques générales
mysql -u geo_app_user_prod -p geo_app -e "
SELECT
status,
COUNT(*) as count,
MIN(created_at) as oldest,
MAX(created_at) as newest
FROM email_queue
GROUP BY status;"
# Vérifier les passages avec reçus envoyés aujourd'hui
mysql -u geo_app_user_prod -p geo_app -e "
SELECT COUNT(*) as recus_envoyes_aujourdhui
FROM ope_pass
WHERE DATE(date_sent_recu) = CURDATE();"
# Performance du cron (dernières exécutions du jour)
tail -20 /var/www/geosector/api/logs/email_queue_$(date +%Y%m%d).log | grep "Traitement terminé"
```
---
## 📞 CONTACTS EN CAS DE PROBLÈME
- **Responsable technique** : _____________
- **DBA** : _____________
- **Support O2Switch** : support@o2switch.fr
---
## 📋 NOTES IMPORTANTES
1. **Limite d'envoi** : 1500 emails/heure max (limite O2Switch)
2. **Batch size** : 50 emails par exécution (toutes les 5 min = 600/heure max)
3. **Lock file** : `/tmp/process_email_queue.lock` empêche l'exécution simultanée
4. **Nettoyage auto** : Les emails envoyés > 30 jours sont supprimés automatiquement
## 🔒 SÉCURITÉ
- Les mots de passe SMTP ne sont jamais loggués
- Les emails en erreur conservent le message d'erreur pour diagnostic
- Le PDF est envoyé en pièce jointe encodée en base64
---
## ✅ CHECKLIST FINALE
### Email Queue :
- [ ] Table email_queue mise à jour (sent_at, error_message, index)
- [ ] Scripts cron testés avec succès
- [ ] Cron configuré et actif
- [ ] Logs accessibles et fonctionnels
- [ ] Premier batch d'emails envoyé avec succès
### Permissions Suppression Passages :
- [ ] Champ chk_user_delete_pass ajouté dans la table entites
- [ ] EntiteController.php mis à jour pour gérer le nouveau champ
- [ ] LoginController.php mis à jour pour retourner le champ dans amicale
- [ ] Test de modification de permissions via l'interface admin
### Général :
- [ ] Documentation mise à jour
- [ ] Équipe informée de la mise en production
---
**Date de mise en production** : _______________
**Validé par** : _______________
**Signature** : _______________

View File

@@ -0,0 +1,149 @@
# Instructions de mise en place du CRON pour la queue d'emails
## Problème résolu
Les emails de reçus étaient insérés dans la table `email_queue` mais n'étaient jamais envoyés car il manquait le script de traitement.
## Fichiers créés
1. `/scripts/cron/process_email_queue.php` - Script principal de traitement
2. `/scripts/cron/test_email_queue.php` - Script de test/diagnostic
3. `/scripts/sql/add_email_queue_fields.sql` - Migration SQL pour les champs manquants
## Installation sur les serveurs (DVA, REC, PROD)
### 1. Appliquer la migration SQL
Se connecter à la base de données et exécuter :
```bash
mysql -u [user] -p [database] < /path/to/api/scripts/sql/add_email_queue_fields.sql
```
Ou directement dans MySQL :
```sql
-- Ajouter les champs manquants
ALTER TABLE `email_queue`
ADD COLUMN IF NOT EXISTS `sent_at` TIMESTAMP NULL DEFAULT NULL AFTER `status`;
ALTER TABLE `email_queue`
ADD COLUMN IF NOT EXISTS `error_message` TEXT NULL DEFAULT NULL AFTER `attempts`;
-- Ajouter les index pour les performances
ALTER TABLE `email_queue`
ADD INDEX IF NOT EXISTS `idx_status_attempts` (`status`, `attempts`);
ALTER TABLE `email_queue`
ADD INDEX IF NOT EXISTS `idx_sent_at` (`sent_at`);
```
### 2. Tester le script
Avant de mettre en place le cron, tester que tout fonctionne :
```bash
# Vérifier l'état de la queue
php /path/to/api/scripts/cron/test_email_queue.php
# Tester l'envoi (traite jusqu'à 50 emails)
php /path/to/api/scripts/cron/process_email_queue.php
```
### 3. Configurer le CRON
Ajouter la ligne suivante dans le crontab du serveur :
```bash
# Éditer le crontab
crontab -e
# Ajouter cette ligne (exécution toutes les 5 minutes)
*/5 * * * * /usr/bin/php /path/to/api/scripts/cron/process_email_queue.php >> /var/log/email_queue.log 2>&1
```
**Options de fréquence :**
- `*/5 * * * *` - Toutes les 5 minutes (recommandé)
- `*/10 * * * *` - Toutes les 10 minutes
- `*/2 * * * *` - Toutes les 2 minutes (si volume important)
### 4. Monitoring
Le script génère des logs via `LogService`. Vérifier les logs dans :
- `/path/to/api/logs/` (selon la configuration)
Points à surveiller :
- Nombre d'emails traités
- Emails en échec après 3 tentatives
- Erreurs de connexion SMTP
### 5. Configuration SMTP
Vérifier que la configuration SMTP est correcte dans `AppConfig` :
- Host SMTP
- Port (587 pour TLS, 465 pour SSL)
- Username/Password
- Encryption (tls ou ssl)
- From Email/Name
## Fonctionnement du script
### Caractéristiques
- **Batch size** : 50 emails par exécution
- **Max tentatives** : 3 essais par email
- **Lock file** : Empêche l'exécution simultanée
- **Nettoyage** : Supprime les emails envoyés > 30 jours
- **Pause** : 0.5s entre chaque email (anti-spam)
### Workflow
1. Récupère les emails avec `status = 'pending'` et `attempts < 3`
2. Pour chaque email :
- Incrémente le compteur de tentatives
- Envoie via PHPMailer avec la config SMTP
- Si succès : `status = 'sent'` + mise à jour du passage
- Si échec : réessai à la prochaine exécution
- Après 3 échecs : `status = 'failed'`
### Tables mises à jour
- `email_queue` : status, attempts, sent_at, error_message
- `ope_pass` : date_sent_recu, chk_email_sent
## Commandes utiles
```bash
# Voir les emails en attente
mysql -e "SELECT COUNT(*) FROM email_queue WHERE status='pending'" [database]
# Voir les emails échoués
mysql -e "SELECT * FROM email_queue WHERE status='failed' ORDER BY created_at DESC LIMIT 10" [database]
# Réinitialiser un email échoué pour réessai
mysql -e "UPDATE email_queue SET status='pending', attempts=0 WHERE id=[ID]" [database]
# Voir les logs du cron
tail -f /var/log/email_queue.log
# Vérifier que le cron est actif
crontab -l | grep process_email_queue
```
## Troubleshooting
### Le cron ne s'exécute pas
- Vérifier les permissions : `chmod +x process_email_queue.php`
- Vérifier le chemin PHP : `which php`
- Vérifier les logs système : `/var/log/syslog` ou `/var/log/cron`
### Emails en échec
- Vérifier la config SMTP avec `test_email_queue.php`
- Vérifier les logs pour les messages d'erreur
- Tester la connexion SMTP : `telnet [smtp_host] [port]`
### Lock bloqué
Si le message "Le processus est déjà en cours" persiste :
```bash
rm /tmp/process_email_queue.lock
```
## Contact support
En cas de problème, vérifier :
1. Les logs de l'application
2. La table `email_queue` pour les messages d'erreur
3. La configuration SMTP dans AppConfig

View File

@@ -8,8 +8,9 @@
4. [Architecture des composants](#architecture-des-composants)
5. [Base de données](#base-de-données)
6. [Sécurité](#sécurité)
7. [Endpoints API](#endpoints-api)
8. [Changements récents](#changements-récents)
7. [Gestion des mots de passe (NIST SP 800-63B)](#gestion-des-mots-de-passe-nist-sp-800-63b)
8. [Endpoints API](#endpoints-api)
9. [Changements récents](#changements-récents)
## Structure du projet
@@ -189,6 +190,211 @@ ADD INDEX `idx_encrypted_user_name` (`encrypted_user_name`);
- Chiffrement AES-256 des données sensibles
- Envoi séparé des identifiants par email
## Gestion des mots de passe (NIST SP 800-63B)
### Vue d'ensemble
L'API implémente un système de gestion des mots de passe conforme aux recommandations NIST SP 800-63B, avec quelques adaptations spécifiques demandées par le client.
### Service PasswordSecurityService
Le service `PasswordSecurityService` (`src/Services/PasswordSecurityService.php`) gère :
- Validation des mots de passe selon NIST
- Vérification contre les bases de données de mots de passe compromis (HIBP)
- Génération de mots de passe sécurisés
- Estimation de la force des mots de passe
### Conformités NIST respectées
| Recommandation NIST | Notre Implémentation | Status |
|-------------------|---------------------|--------|
| **Longueur minimale : 8 caractères** | ✅ MIN = 8 caractères | ✅ CONFORME |
| **Longueur maximale : 64 caractères minimum** | ✅ MAX = 64 caractères | ✅ CONFORME |
| **Accepter TOUS les caractères ASCII imprimables** | ✅ Aucune restriction sur les caractères | ✅ CONFORME |
| **Accepter les espaces** | ✅ Espaces acceptés (début, milieu, fin) | ✅ CONFORME |
| **Accepter Unicode (émojis, accents, etc.)** | ✅ Support UTF-8 avec `mb_strlen()` | ✅ CONFORME |
| **Vérifier contre les mots de passe compromis** | ✅ API Have I Been Pwned avec k-anonymity | ✅ CONFORME |
| **Pas d'obligation de composition** | ✅ Pas d'erreur si manque majuscules/chiffres/spéciaux | ✅ CONFORME |
| **Pas de changement périodique forcé** | ✅ Aucune expiration automatique | ✅ CONFORME |
| **Permettre les phrases de passe** | ✅ "Mon chat Félix a 3 ans!" accepté | ✅ CONFORME |
### Déviations par choix du client
| Recommandation NIST | Notre Implémentation | Raison |
|-------------------|---------------------|--------|
| **Email unique par compte** | ❌ Plusieurs comptes par email autorisés | Demande client |
| **Mot de passe ≠ identifiant** | ❌ Mot de passe = identifiant autorisé | Demande client |
| **Vérifier contexte utilisateur** | ❌ Pas de vérification nom/email dans mdp | Demande client |
### Vérification contre les mots de passe compromis
#### Have I Been Pwned (HIBP) API
L'implémentation utilise l'API HIBP avec la technique **k-anonymity** pour préserver la confidentialité :
1. **Hash SHA-1** du mot de passe
2. **Envoi des 5 premiers caractères** du hash à l'API
3. **Comparaison locale** avec les suffixes retournés
4. **Aucun mot de passe en clair** n'est transmis
#### Mode "Fail Open"
En cas d'erreur de l'API HIBP :
- Le système laisse passer le mot de passe
- Un avertissement est enregistré dans les logs
- L'utilisateur n'est pas bloqué
### Exemples de mots de passe
#### Acceptés (conformes NIST)
- `monmotdepasse` → Accepté (≥8 caractères, pas compromis)
- `12345678` → Accepté SI pas dans HIBP
- `Mon chat s'appelle Félix!` → Accepté (phrase de passe)
- ` ` → Accepté si ≥8 espaces
- `😀🎉🎈🎁🎂🍰🎊🎀` → Accepté (8 émojis)
- `jean.dupont` → Accepté même si = username
#### Refusés
- `pass123` → Refusé (< 8 caractères)
- `password` Refusé (compromis dans HIBP)
- `123456789` Refusé (compromis dans HIBP)
- Mot de passe > 64 caractères → Refusé
### Force des mots de passe
Le système privilégie la **LONGUEUR** sur la complexité (conforme NIST) :
| Longueur | Force | Score |
|----------|-------|-------|
| < 8 car. | Trop court | 0-10 |
| 8-11 car. | Acceptable | 20-40 |
| 12-15 car. | Bon | 40-60 |
| 16-19 car. | Fort | 60-80 |
| 20 car. | Très fort | 80-100 |
| Compromis | Compromis | 10 |
### Génération automatique
Pour la génération automatique, le système reste **strict** pour garantir des mots de passe forts :
- Longueur : 12-16 caractères
- Contient : majuscules + minuscules + chiffres + spéciaux
- Vérifié contre HIBP (10 tentatives max)
- Exemple : `Xk9#mP2$nL5!`
### Gestion des comptes multiples par email
Depuis janvier 2025, le système permet plusieurs comptes avec le même email :
#### Fonction `lostPassword` adaptée
- Recherche **TOUS** les comptes avec l'email fourni
- Génère **UN SEUL** mot de passe pour tous ces comptes
- Met à jour **TOUS** les comptes en une requête
- Envoie **UN SEUL** email avec la liste des usernames concernés
#### Exemple de comportement
Si 3 comptes partagent l'email `contact@amicale.fr` :
- `jean.dupont`
- `marie.martin`
- `paul.durand`
L'email contiendra :
```
Bonjour,
Voici votre nouveau mot de passe pour les comptes : jean.dupont, marie.martin, paul.durand
Mot de passe : XyZ123!@#
```
### Endpoints API dédiés aux mots de passe
#### Vérification de force (public)
```http
POST /api/password/check
Content-Type: application/json
{
"password": "monmotdepasse",
"check_compromised": true
}
```
**Réponse :**
```json
{
"status": "success",
"valid": false,
"errors": [
"Ce mot de passe a été trouvé 23 547 fois dans des fuites de données."
],
"warnings": [
"Suggestion : Évitez les séquences communes pour plus de sécurité"
],
"strength": {
"score": 20,
"strength": "Faible",
"feedback": ["Ce mot de passe a été compromis"],
"length": 13,
"diversity": 1
},
"compromised": {
"compromised": true,
"occurrences": 23547,
"message": "Ce mot de passe a été trouvé 23 547 fois dans des fuites de données"
}
}
```
#### Vérification de compromission uniquement (public)
```http
POST /api/password/compromised
Content-Type: application/json
{
"password": "monmotdepasse"
}
```
#### Génération automatique (authentifié)
```http
GET /api/password/generate?length=14
Authorization: Bearer {session_id}
```
**Réponse :**
```json
{
"status": "success",
"password": "Xk9#mP2$nL5!qR",
"length": 14,
"strength": {
"score": 85,
"strength": "Très fort",
"feedback": []
}
}
```
### Configuration et sécurité
#### Paramètres de sécurité
- **Timeout API HIBP** : 5 secondes
- **Cache** : 15 minutes pour les vérifications répétées
- **Logging** : Aucun mot de passe en clair dans les logs
- **K-anonymity** : Seuls 5 caractères du hash SHA-1 envoyés
#### Points d'intégration
- `LoginController::register` : Validation lors de l'inscription
- `LoginController::lostPassword` : Génération sécurisée
- `UserController::createUser` : Validation si mot de passe manuel
- `UserController::updateUser` : Validation lors du changement
- `ApiService::generateSecurePassword` : Génération avec vérification HIBP
### Résumé
**100% CONFORME NIST** pour les aspects techniques
**Adapté aux besoins du client** (emails multiples, mdp=username)
**Sécurité maximale** avec vérification HIBP
**Expérience utilisateur optimale** (souple mais sécurisé)
## Endpoints API
### Routes Publiques vs Privées
@@ -573,7 +779,25 @@ fetch('/api/endpoint', {
## Changements récents
### Version 3.0.6 (Janvier 2025)
### Version 3.0.7 (Août 2025)
#### 1. Implémentation complète de la norme NIST SP 800-63B pour les mots de passe
- **Nouveau service :** `PasswordSecurityService` pour la gestion sécurisée des mots de passe
- **Vérification HIBP :** Intégration de l'API Have I Been Pwned avec k-anonymity
- **Validation souple :** Suppression des obligations de composition (majuscules, chiffres, spéciaux)
- **Support Unicode :** Acceptation de tous les caractères, incluant émojis et espaces
- **Nouveaux endpoints :** `/api/password/check`, `/api/password/compromised`, `/api/password/generate`
#### 2. Autorisation des emails multiples
- **Suppression de l'unicité :** Un même email peut être utilisé pour plusieurs comptes
- **Adaptation de `lostPassword` :** Mise à jour de tous les comptes partageant l'email
- **Un seul mot de passe :** Tous les comptes avec le même email reçoivent le même nouveau mot de passe
#### 3. Autorisation mot de passe = identifiant
- **Choix client :** Permet d'avoir un mot de passe identique au nom d'utilisateur
- **Pas de vérification contextuelle :** Aucune vérification nom/email dans le mot de passe
### Version 3.0.6 (Août 2025)
#### 1. Correction des rôles administrateurs
- **Avant :** Les administrateurs d'amicale devaient avoir `fk_role > 2`
@@ -612,3 +836,28 @@ fetch('/api/endpoint', {
- **Format d'envoi des images :** Base64 data URL pour compatibilité multiplateforme
- **Structure de réponse enrichie :** Le logo est inclus dans l'objet `amicale` lors du login
- **Optimisation :** Pas de requête HTTP supplémentaire nécessaire pour afficher le logo
### Version 3.0.8 (Janvier 2025)
#### 1. Système de génération automatique de reçus fiscaux pour les dons
- **Nouveau service :** `ReceiptService` pour la génération automatique de reçus PDF
- **Déclencheurs automatiques :**
- Création d'un passage avec `fk_type=1` (don) et email valide
- Mise à jour d'un passage en don si `nom_recu` est vide/null
- **Caractéristiques techniques :**
- PDF ultra-légers (< 5KB) générés en format natif sans librairie externe
- Support des caractères accentués avec conversion automatique
- Stockage structuré : `/uploads/entites/{entite_id}/recus/{operation_id}/`
- Enregistrement dans la table `medias` avec catégorie `recu`
- **Queue d'envoi email :**
- Envoi automatique par email avec pièce jointe PDF
- Format MIME multipart pour compatibilité maximale
- Gestion dans la table `email_queue` avec statut de suivi
- **Nouvelle route API :**
- `GET /api/passages/{id}/receipt` : Récupération du PDF d'un reçu
- Retourne le PDF en base64 ou téléchargement direct selon Accept header
- **Champs base de données utilisés :**
- `nom_recu` : Nom du fichier PDF généré
- `date_creat_recu` : Date de génération du reçu
- `date_sent_recu` : Date d'envoi par email
- `chk_email_sent` : Indicateur d'envoi réussi

View File

@@ -0,0 +1,155 @@
# 📋 RÉCAPITULATIF - Migration Arborescence Uploads
## ✅ Modifications effectuées
### 1. **EntiteController.php** (ligne 736)
```php
// Avant : "/entites/{$entiteId}/logo"
// Après : "/{$entiteId}/logo"
```
✅ Les logos sont maintenant stockés dans : `uploads/{entite_id}/logo/`
### 2. **ReceiptService.php** (ligne 95)
```php
// Avant : "/entites/{$entiteId}/recus/{$operationId}"
// Après : "/{$entiteId}/recus/{$operationId}"
```
✅ Les reçus PDF sont maintenant stockés dans : `uploads/{entite_id}/recus/{operation_id}/`
### 3. **ExportService.php** (lignes 40 et 141)
```php
// Avant Excel : "/{$entiteId}/operations/{$operationId}/exports/excel"
// Après Excel : "/{$entiteId}/operations/{$operationId}"
// Avant JSON : "/{$entiteId}/operations/{$operationId}/exports/json"
// Après JSON : "/{$entiteId}/operations/{$operationId}"
```
✅ Les exports sont maintenant stockés directement dans : `uploads/{entite_id}/operations/{operation_id}/`
## 📂 Nouvelle structure complète
```
uploads/
└── {entite_id}/ # Ex: 5, 1230, etc.
├── logo/ # Logo de l'entité
│ └── logo_{entite_id}_{timestamp}.{jpg|png}
├── operations/ # Exports d'opérations
│ └── {operation_id}/ # Ex: 1525, 3124
│ ├── geosector-export-{operation_id}-{timestamp}.xlsx
│ └── backup-{operation_id}-{timestamp}.json.enc
└── recus/ # Reçus fiscaux
└── {operation_id}/ # Ex: 3124
└── recu_{passage_id}.pdf
```
## 🔧 Script de migration
Un script a été créé pour migrer les fichiers existants :
**Fichier :** `/scripts/migrate_uploads_structure.php`
**Usage :**
```bash
# Mode simulation (voir ce qui sera fait sans modifier)
php scripts/migrate_uploads_structure.php --dry-run
# Mode réel (effectue la migration)
php scripts/migrate_uploads_structure.php
```
**Ce que fait le script :**
1. Déplace tout le contenu de `uploads/entites/*` vers `uploads/*`
2. Fusionne les dossiers si nécessaire
3. Simplifie la structure des exports (supprime `/documents/exports/excel/`)
4. Applique les bonnes permissions (nginx:nobody 775/664)
5. Crée un log détaillé dans `/logs/migration_uploads_YYYYMMDD_HHMMSS.log`
## 🚀 Procédure de déploiement
### Sur DEV (déjà fait)
✅ Code modifié
✅ Script de migration créé
### Sur REC
```bash
# 1. Déployer le nouveau code
./livre-api.sh rec
# 2. Faire un backup des uploads actuels
cd /var/www/geosector/api
tar -czf uploads_backup_$(date +%Y%m%d).tar.gz uploads/
# 3. Tester en mode dry-run
php scripts/migrate_uploads_structure.php --dry-run
# 4. Si OK, lancer la migration
php scripts/migrate_uploads_structure.php
# 5. Vérifier la nouvelle structure
ls -la uploads/
ls -la uploads/*/
```
### Sur PROD
Même procédure que REC après validation
## ⚠️ Points d'attention
1. **Backup obligatoire** avant migration
2. **Vérifier l'espace disque** disponible
3. **Tester d'abord en dry-run**
4. **Surveiller les logs** après migration
5. **Tester** upload logo, génération reçu, et export Excel
## 📊 Gains obtenus
| Aspect | Avant | Après |
|--------|-------|-------|
| **Profondeur max** | 8 niveaux | 4 niveaux |
| **Complexité** | 2 structures parallèles | 1 structure unique |
| **Clarté** | Confus (entites + racine) | Simple et logique |
| **Navigation** | Difficile | Intuitive |
## 🔍 Vérification post-migration
Après la migration, vérifier :
```bash
# Structure attendue pour l'entité 5
tree uploads/5/
# Devrait afficher :
# uploads/5/
# ├── logo/
# │ └── logo_5_*.png
# ├── operations/
# │ ├── 1525/
# │ │ └── *.xlsx
# │ └── 3124/
# │ └── *.xlsx
# └── recus/
# └── 3124/
# └── recu_*.pdf
# Vérifier les permissions
ls -la uploads/*/
# Devrait montrer : nginx:nobody avec 775 pour dossiers, 664 pour fichiers
```
## ✅ Checklist finale
- [ ] Code modifié et testé en DEV
- [ ] Script de migration créé
- [ ] Documentation mise à jour
- [ ] Backup effectué sur REC
- [ ] Migration testée en dry-run sur REC
- [ ] Migration exécutée sur REC
- [ ] Tests fonctionnels sur REC
- [ ] Backup effectué sur PROD
- [ ] Migration exécutée sur PROD
- [ ] Tests fonctionnels sur PROD
---
**Date de création :** 20/08/2025
**Auteur :** Assistant Claude
**Status :** Prêt pour déploiement

View File

@@ -0,0 +1,93 @@
# Réorganisation de l'arborescence des uploads
## 📅 Date : 20/08/2025
## 🎯 Objectif
Uniformiser et simplifier l'arborescence des fichiers uploads pour une meilleure organisation et maintenance.
## 📂 Arborescence actuelle (PROBLÈME)
```
uploads/
├── entites/
│ └── 5/
│ ├── logo/
│ ├── operations/
│ │ └── 1525/
│ │ └── documents/
│ │ └── exports/
│ │ └── excel/
│ │ └── geosector-export-*.xlsx
│ └── recus/
│ └── 3124/
│ └── recu_*.pdf
└── 5/
└── operations/
├── 1525/
└── 2021/
```
**Problèmes identifiés :**
- Duplication des structures (dossier `5` à la racine ET dans `entites/`)
- Chemins trop profonds pour les exports Excel (6 niveaux)
- Incohérence dans les chemins
## ✅ Nouvelle arborescence (SOLUTION)
```
uploads/
└── {entite_id}/ # Un seul dossier par entité à la racine
├── logo/ # Logo de l'entité
│ └── logo_*.{jpg,png}
├── operations/ # Exports par opération
│ └── {operation_id}/
│ └── *.xlsx # Exports Excel directement ici
└── recus/ # Reçus par opération
└── {operation_id}/
└── recu_*.pdf
```
## 📝 Fichiers à modifier
### 1. EntiteController.php (Upload logo)
**Actuel :** `/entites/{$entiteId}/logo`
**Nouveau :** `/{$entiteId}/logo`
### 2. ReceiptService.php (Stockage reçus PDF)
**Actuel :** `/entites/{$entiteId}/recus/{$operationId}`
**Nouveau :** `/{$entiteId}/recus/{$operationId}`
### 3. ExportService.php (Export Excel)
**Actuel :** `/{$entiteId}/operations/{$operationId}/exports/excel`
**Nouveau :** `/{$entiteId}/operations/{$operationId}`
### 4. ExportService.php (Export JSON)
**Actuel :** `/{$entiteId}/operations/{$operationId}/exports/json`
**Nouveau :** `/{$entiteId}/operations/{$operationId}` (ou supprimer si non utilisé)
## 🔄 Plan de migration
### Étape 1 : Modifier le code
1. Mettre à jour tous les chemins dans les contrôleurs et services
2. Tester en environnement DEV
### Étape 2 : Script de migration des fichiers existants
Créer un script PHP pour :
1. Lister tous les fichiers existants
2. Les déplacer vers la nouvelle structure
3. Supprimer les anciens dossiers vides
### Étape 3 : Déploiement
1. Exécuter le script de migration sur REC
2. Vérifier le bon fonctionnement
3. Exécuter sur PROD
## 🚀 Avantages de la nouvelle structure
- **Plus simple** : Chemins plus courts et plus logiques
- **Plus cohérent** : Une seule structure pour toutes les entités
- **Plus maintenable** : Facile de naviguer et comprendre
- **Performance** : Moins de niveaux de dossiers à parcourir
## ⚠️ Points d'attention
- Vérifier les permissions (nginx:nobody 775/664)
- S'assurer que les anciens fichiers sont bien migrés
- Mettre à jour la documentation
- Informer l'équipe du changement

View File

@@ -0,0 +1,135 @@
# Changements de validation des usernames - Version ultra-souple
## Date : 17 janvier 2025
## Contexte
Suite aux problèmes d'erreurs 400 et au besoin d'avoir une approche plus moderne et inclusive, les règles de validation des usernames ont été assouplies pour accepter tous les caractères UTF-8, similaire à l'approche NIST pour les mots de passe.
## Anciennes règles (trop restrictives)
- ❌ 10-30 caractères
- ❌ Doit commencer par une lettre minuscule
- ❌ Seulement : a-z, 0-9, ., -, _
- ❌ Pas d'espaces
- ❌ Pas de majuscules
- ❌ Pas d'accents ou caractères spéciaux
## Nouvelles règles (ultra-souples)
-**8-30 caractères UTF-8**
-**Tous caractères acceptés** :
- Lettres (majuscules/minuscules)
- Chiffres
- Espaces
- Caractères spéciaux (!@#$%^&*()_+-=[]{}|;:'"<>,.?/)
- Accents (é, è, à, ñ, ü, etc.)
- Émojis (😀, 🎉, ❤️, etc.)
- Caractères non-latins (中文, العربية, Русский, etc.)
-**Sensible à la casse** (Jean ≠ jean)
-**Trim automatique** des espaces début/fin
-**Unicité vérifiée** dans toute la base
## Exemples de usernames valides
### Noms classiques
- `Jean-Pierre`
- `Marie Claire` (avec espace)
- `O'Connor`
- `José García`
### Avec chiffres et caractères spéciaux
- `admin2024`
- `user@company`
- `test_user#1`
- `Marie*123!`
### International
- `李明` (chinois)
- `محمد` (arabe)
- `Владимир` (russe)
- `さくら` (japonais)
- `Παύλος` (grec)
### Modernes/Fun
- `🦄Unicorn`
- `Player_1 🎮`
- `☕Coffee.Lover`
- `2024_User`
## Exemples de usernames invalides
- `short` ❌ (moins de 8 caractères)
- ` ` ❌ (espaces seulement)
- `very_long_username_that_exceeds_thirty_chars` ❌ (plus de 30 caractères)
## Modifications techniques
### 1. Code PHP (UserController.php)
```php
// Avant (restrictif)
if (!preg_match('/^[a-z][a-z0-9._-]{9,29}$/', $username))
// Après (ultra-souple)
$username = trim($data['username']);
$usernameLength = mb_strlen($username, 'UTF-8');
if ($usernameLength < 8) {
// Erreur : trop court
}
if ($usernameLength > 30) {
// Erreur : trop long
}
// C'est tout ! Pas d'autre validation
```
### 2. Base de données
```sql
-- Script à exécuter : scripts/sql/migration_username_utf8_support.sql
ALTER TABLE `users`
MODIFY COLUMN `encrypted_user_name` varchar(255) DEFAULT '';
```
### 3. Messages d'erreur simplifiés
- Avant : "Format du nom d'utilisateur invalide (10-30 caractères, commence par une lettre, caractères autorisés: a-z, 0-9, ., -, _)"
- Après :
- "Identifiant trop court" + "Minimum 8 caractères"
- "Identifiant trop long" + "Maximum 30 caractères"
- "Identifiant déjà utilisé"
## Impact sur l'expérience utilisateur
### Avantages
1. **Inclusivité** : Support de toutes les langues et cultures
2. **Modernité** : Permet les émojis et caractères spéciaux
3. **Simplicité** : Règles faciles à comprendre (juste la longueur)
4. **Flexibilité** : Les utilisateurs peuvent choisir l'identifiant qu'ils veulent
5. **Moins d'erreurs** : Moins de rejets pour format invalide
### Points d'attention
1. **Support client** : Former le support aux nouveaux formats possibles
2. **Affichage** : S'assurer que l'UI supporte bien l'UTF-8
3. **Recherche** : La recherche d'utilisateurs doit gérer la casse et l'UTF-8
4. **Export** : Vérifier que les exports CSV/Excel gèrent bien l'UTF-8
## Sécurité
### Pas d'impact sur la sécurité
- ✅ Les usernames sont toujours chiffrés en base (AES-256-CBC)
- ✅ L'unicité est toujours vérifiée
- ✅ Les injections SQL sont impossibles (prepared statements)
- ✅ Le trim empêche les espaces invisibles
### Recommandations
- Continuer à générer automatiquement des usernames simples (ASCII) pour éviter les problèmes
- Mais permettre la saisie manuelle de tout format
- Logger les usernames "exotiques" pour détecter d'éventuels abus
## Tests
- Script de test disponible : `/tests/test_username_validation.php`
- Teste tous les cas limites et formats internationaux
## Rollback si nécessaire
Si besoin de revenir en arrière :
1. Restaurer l'ancienne validation dans UserController
2. Les usernames UTF-8 existants continueront de fonctionner
3. Seuls les nouveaux seront restreints
## Conclusion
Cette approche ultra-souple aligne les usernames sur les standards modernes d'inclusivité et d'accessibilité, tout en maintenant la sécurité grâce au chiffrement et à la validation de l'unicité.

BIN
api/docs/_logo_recu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
api/docs/_recu_template.pdf Normal file

Binary file not shown.

View File

@@ -18,168 +18,39 @@
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
CREATE TABLE `chat_anonymous_users` (
`id` varchar(50) NOT NULL,
`device_id` varchar(100) NOT NULL,
`name` varchar(100) DEFAULT NULL,
`email` varchar(100) DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`converted_to_user_id` int(10) unsigned DEFAULT NULL,
`metadata` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`metadata`)),
PRIMARY KEY (`id`),
KEY `idx_device_id` (`device_id`),
KEY `idx_converted_user` (`converted_to_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
-- Tables préfixées "chat_"
CREATE TABLE chat_rooms (
id VARCHAR(36) PRIMARY KEY,
title VARCHAR(255),
type ENUM('private', 'group', 'broadcast'),
created_at TIMESTAMP,
created_by INT
);
CREATE TABLE `chat_attachments` (
`id` varchar(50) NOT NULL,
`fk_message` varchar(50) NOT NULL,
`file_name` varchar(255) NOT NULL,
`file_path` varchar(500) NOT NULL,
`file_type` varchar(100) NOT NULL,
`file_size` int(10) unsigned NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_message` (`fk_message`),
CONSTRAINT `fk_chat_attachments_message` FOREIGN KEY (`fk_message`) REFERENCES `chat_messages` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
CREATE TABLE chat_messages (
id VARCHAR(36) PRIMARY KEY,
room_id VARCHAR(36),
content TEXT,
sender_id INT,
sent_at TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES chat_rooms(id)
);
CREATE TABLE `chat_audience_targets` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`fk_room` varchar(50) NOT NULL,
`target_type` enum('role','entity','all','combined') NOT NULL DEFAULT 'all',
`target_id` varchar(50) DEFAULT NULL,
`role_filter` varchar(20) DEFAULT NULL,
`entity_filter` varchar(50) DEFAULT NULL,
`date_creation` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_room` (`fk_room`),
KEY `idx_type` (`target_type`),
CONSTRAINT `fk_chat_audience_targets_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
CREATE TABLE chat_participants (
room_id VARCHAR(36),
user_id INT,
role INT,
entite_id INT,
joined_at TIMESTAMP,
PRIMARY KEY (room_id, user_id)
);
CREATE TABLE `chat_broadcast_lists` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`fk_room` varchar(50) NOT NULL,
`name` varchar(100) NOT NULL,
`description` text DEFAULT NULL,
`fk_user_creator` int(10) unsigned NOT NULL,
`date_creation` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_room` (`fk_room`),
KEY `idx_user_creator` (`fk_user_creator`),
CONSTRAINT `fk_chat_broadcast_lists_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
CREATE TABLE `chat_messages` (
`id` varchar(50) NOT NULL,
`fk_room` varchar(50) NOT NULL,
`fk_user` int(10) unsigned DEFAULT NULL,
`sender_type` enum('user','anonymous','system') NOT NULL DEFAULT 'user',
`content` text DEFAULT NULL,
`content_type` enum('text','image','file') NOT NULL DEFAULT 'text',
`date_sent` timestamp NOT NULL DEFAULT current_timestamp(),
`date_delivered` timestamp NULL DEFAULT NULL,
`date_read` timestamp NULL DEFAULT NULL,
`statut` enum('envoye','livre','lu','error') NOT NULL DEFAULT 'envoye',
`is_announcement` tinyint(1) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_room` (`fk_room`),
KEY `idx_user` (`fk_user`),
KEY `idx_date` (`date_sent`),
KEY `idx_status` (`statut`),
KEY `idx_messages_unread` (`fk_room`,`statut`),
CONSTRAINT `fk_chat_messages_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
CREATE TABLE `chat_notifications` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`fk_user` int(10) unsigned NOT NULL,
`fk_message` varchar(50) DEFAULT NULL,
`fk_room` varchar(50) DEFAULT NULL,
`type` varchar(50) NOT NULL,
`contenu` text DEFAULT NULL,
`date_creation` timestamp NOT NULL DEFAULT current_timestamp(),
`date_lecture` timestamp NULL DEFAULT NULL,
`statut` enum('non_lue','lue') NOT NULL DEFAULT 'non_lue',
PRIMARY KEY (`id`),
KEY `idx_user` (`fk_user`),
KEY `idx_message` (`fk_message`),
KEY `idx_room` (`fk_room`),
KEY `idx_statut` (`statut`),
KEY `idx_notifications_unread` (`fk_user`,`statut`),
CONSTRAINT `fk_chat_notifications_message` FOREIGN KEY (`fk_message`) REFERENCES `chat_messages` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_chat_notifications_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
CREATE TABLE `chat_offline_queue` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(10) unsigned NOT NULL,
`operation_type` varchar(50) NOT NULL,
`operation_data` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(`operation_data`)),
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`processed_at` timestamp NULL DEFAULT NULL,
`status` enum('pending','processing','completed','failed') NOT NULL DEFAULT 'pending',
`error_message` text DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_status` (`status`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
CREATE TABLE `chat_participants` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`id_room` varchar(50) NOT NULL,
`id_user` int(10) unsigned DEFAULT NULL,
`anonymous_id` varchar(50) DEFAULT NULL,
`role` enum('administrateur','participant','en_lecture_seule') NOT NULL DEFAULT 'participant',
`date_ajout` timestamp NOT NULL DEFAULT current_timestamp(),
`notification_activee` tinyint(1) unsigned NOT NULL DEFAULT 1,
`last_read_message_id` varchar(50) DEFAULT NULL,
`via_target` tinyint(1) unsigned NOT NULL DEFAULT 0,
`can_reply` tinyint(1) unsigned DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uc_room_user` (`id_room`,`id_user`),
KEY `idx_room` (`id_room`),
KEY `idx_user` (`id_user`),
KEY `idx_anonymous_id` (`anonymous_id`),
KEY `idx_participants_active` (`id_room`,`id_user`,`notification_activee`),
CONSTRAINT `fk_chat_participants_room` FOREIGN KEY (`id_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
CREATE TABLE `chat_read_messages` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`fk_message` varchar(50) NOT NULL,
`fk_user` int(10) unsigned NOT NULL,
`date_read` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `uc_message_user` (`fk_message`,`fk_user`),
KEY `idx_message` (`fk_message`),
KEY `idx_user` (`fk_user`),
CONSTRAINT `fk_chat_read_messages_message` FOREIGN KEY (`fk_message`) REFERENCES `chat_messages` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
CREATE TABLE `chat_rooms` (
`id` varchar(50) NOT NULL,
`type` enum('privee','groupe','liste_diffusion','broadcast','announcement') NOT NULL,
`title` varchar(100) DEFAULT NULL,
`date_creation` timestamp NOT NULL DEFAULT current_timestamp(),
`fk_user` int(10) unsigned NOT NULL,
`fk_entite` int(10) unsigned DEFAULT NULL,
`statut` enum('active','archive') NOT NULL DEFAULT 'active',
`description` text DEFAULT NULL,
`reply_permission` enum('all','admins_only','sender_only','none') NOT NULL DEFAULT 'all',
`is_pinned` tinyint(1) unsigned NOT NULL DEFAULT 0,
`expiry_date` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_user` (`fk_user`),
KEY `idx_entite` (`fk_entite`),
KEY `idx_type` (`type`),
KEY `idx_statut` (`statut`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
CREATE TABLE chat_read_receipts (
message_id VARCHAR(36),
user_id INT,
read_at TIMESTAMP,
PRIMARY KEY (message_id, user_id)
);
CREATE TABLE `email_counter` (
`id` int(10) unsigned NOT NULL DEFAULT 1,

View File

@@ -0,0 +1,19 @@
# Configuration logrotate pour email_queue.log
# À placer dans /etc/logrotate.d/geosector-email-queue
/var/www/geosector/api/logs/email_queue.log {
daily # Rotation journalière
rotate 30 # Garder 30 jours d'historique
compress # Compresser les anciens logs
delaycompress # Compresser le jour suivant
missingok # Pas d'erreur si le fichier n'existe pas
notifempty # Ne pas tourner si vide
create 664 www-data www-data # Créer nouveau fichier avec permissions
dateext # Ajouter la date au nom du fichier
dateformat -%Y%m%d # Format de date YYYYMMDD
maxsize 100M # Rotation si dépasse 100MB même avant la fin du jour
postrotate
# Optionnel : envoyer un signal au process si nécessaire
# /usr/bin/killall -SIGUSR1 php 2>/dev/null || true
endscript
}

View File

@@ -0,0 +1,93 @@
%PDF-1.4
%âãÏÓ
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>
endobj
4 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>
endobj
5 0 obj
<< /Length 599 >>
stream
BT
/F1 14 Tf
217 792 Td
(AMICALE TEST DEV PIERRE) Tj
ET
BT
/F1 11 Tf
281 770 Td
(RENNES) Tj
ET
BT
/F1 14 Tf
213.5 726 Td
(RECU FISCAL N 19500582) Tj
ET
BT
/F1 9 Tf
263.75 704 Td
(Article 200 CGI) Tj
ET
BT
/F1 12 Tf
50 657 Td
(Dugues) Tj
ET
BT
/F1 11 Tf
50 637 Td
(8 le Petit Monthelon Acigne) Tj
ET
BT
/F1 16 Tf
257.5 598 Td
(8,00 euros) Tj
ET
BT
/F1 12 Tf
267.5 559 Td
(20/08/2025) Tj
ET
BT
/F1 10 Tf
277.5 529 Td
(OPE 2025) Tj
ET
BT
/F1 9 Tf
198.5 476 Td
(Don ouvrant droit a reduction d'impot de 66%) Tj
ET
BT
/F1 10 Tf
50 419 Td
(Le 20/08/2025) Tj
ET
BT
/F1 10 Tf
50 401 Td
(Le President) Tj
ET
endstream
endobj
xref
0 6
0000000000 65535 f
0000000019 00000 n
0000000068 00000 n
0000000125 00000 n
0000000251 00000 n
0000000353 00000 n
trailer
<< /Size 6 /Root 1 0 R >>
startxref
1003
%%EOF

View File

@@ -0,0 +1,75 @@
%PDF-1.3
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>
endobj
4 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
5 0 obj
<< /Length 767 >>
stream
BT
/F1 12 Tf
50 750 Td
(AMICALE TEST DEV PIERRE) Tj
0 -20 Td
(17 place hoche 35000 RENNES) Tj
/F1 16 Tf
0 -40 Td
(RECU DE DON N° 19500586) Tj
/F1 10 Tf
0 -15 Td
(Article 200 du Code General des Impots) Tj
/F1 12 Tf
0 -45 Td
(DONATEUR) Tj
/F1 11 Tf
0 -20 Td
(Nom : M. Hermann) Tj
0 -15 Td
(Adresse : 12 le Petit Monthelon Acigne) Tj
0 -15 Td
(Email : pierre.vaissaire@gmail.com) Tj
0 -30 Td
/F1 12 Tf
(DETAILS DU DON) Tj
/F1 11 Tf
0 -20 Td
(Date : 19/08/2025) Tj
0 -15 Td
(Montant : 12,00 EUR) Tj
0 -15 Td
(Mode de reglement : Espece) Tj
0 -15 Td
(Campagne : OPE 2025) Tj
/F1 9 Tf
0 -40 Td
(Reduction d'impot egale a 66% du montant verse dans la limite de 20% du revenu imposable) Tj
/F1 11 Tf
0 -30 Td
(Fait a RENNES, le 19/08/2025) Tj
0 -20 Td
(Le President) Tj
ET
endstream
endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000241 00000 n
0000000311 00000 n
trailer
<< /Size 6 /Root 1 0 R >>
startxref
1129
%%EOF

BIN
api/docs/recu_537254062.pdf Normal file

Binary file not shown.

BIN
api/docs/recu_972506460.pdf Normal file

Binary file not shown.

View File

@@ -15,6 +15,18 @@ require_once __DIR__ . '/src/Core/Response.php';
require_once __DIR__ . '/src/Utils/ClientDetector.php';
require_once __DIR__ . '/src/Services/LogService.php';
// Chargement des services
require_once __DIR__ . '/src/Services/StripeService.php';
// Chargement des services de sécurité
require_once __DIR__ . '/src/Services/Security/PerformanceMonitor.php';
require_once __DIR__ . '/src/Services/Security/IPBlocker.php';
require_once __DIR__ . '/src/Services/Security/SecurityMonitor.php';
require_once __DIR__ . '/src/Services/Security/AlertService.php';
// Chargement de la classe Controller de base
require_once __DIR__ . '/src/Core/Controller.php';
// Chargement des contrôleurs
require_once __DIR__ . '/src/Controllers/LogController.php';
require_once __DIR__ . '/src/Controllers/LoginController.php';
@@ -25,6 +37,11 @@ require_once __DIR__ . '/src/Controllers/PassageController.php';
require_once __DIR__ . '/src/Controllers/VilleController.php';
require_once __DIR__ . '/src/Controllers/FileController.php';
require_once __DIR__ . '/src/Controllers/SectorController.php';
require_once __DIR__ . '/src/Controllers/PasswordController.php';
require_once __DIR__ . '/src/Controllers/ChatController.php';
require_once __DIR__ . '/src/Controllers/SecurityController.php';
require_once __DIR__ . '/src/Controllers/StripeController.php';
require_once __DIR__ . '/src/Controllers/StripeWebhookController.php';
// Initialiser la configuration
$appConfig = AppConfig::getInstance();
@@ -56,8 +73,132 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
// Initialiser la session
Session::start();
// ===== DÉBUT DU MONITORING DE SÉCURITÉ =====
use App\Services\Security\PerformanceMonitor;
use App\Services\Security\IPBlocker;
use App\Services\Security\SecurityMonitor;
use App\Services\Security\AlertService;
// Obtenir l'IP du client
$clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
// Vérifier si l'IP est bloquée
if (IPBlocker::isBlocked($clientIp)) {
http_response_code(403);
Response::json([
'status' => 'error',
'message' => 'Access denied. Your IP has been blocked.',
'error_code' => 'IP_BLOCKED'
], 403);
exit;
}
// Vérifier le rate limiting
if (!SecurityMonitor::checkRateLimit($clientIp)) {
http_response_code(429);
Response::json([
'status' => 'error',
'message' => 'Too many requests. Please try again later.',
'error_code' => 'RATE_LIMIT_EXCEEDED'
], 429);
exit;
}
// Démarrer le monitoring de performance
PerformanceMonitor::startRequest();
// Capturer le endpoint pour le monitoring
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
// Vérifier les patterns de scan
if (!SecurityMonitor::checkScanPattern($requestUri)) {
// Pattern suspect détecté, bloquer l'IP temporairement
IPBlocker::block($clientIp, 3600, 'Suspicious scan pattern detected');
http_response_code(404);
Response::json([
'status' => 'error',
'message' => 'Not found'
], 404);
exit;
}
// Vérifier les paramètres pour injection SQL
$allParams = array_merge($_GET, $_POST, json_decode(file_get_contents('php://input'), true) ?? []);
if (!empty($allParams) && !SecurityMonitor::checkRequestParameters($allParams)) {
// Injection SQL détectée, bloquer l'IP définitivement
IPBlocker::blockPermanent($clientIp, 'SQL injection attempt');
http_response_code(400);
Response::json([
'status' => 'error',
'message' => 'Bad request'
], 400);
exit;
}
// Créer l'instance de routeur
$router = new Router();
// Enregistrer une fonction de shutdown pour capturer les métriques
register_shutdown_function(function() use ($requestUri, $requestMethod) {
$statusCode = http_response_code();
// Terminer le monitoring de performance
PerformanceMonitor::endRequest($requestUri, $requestMethod, $statusCode);
// Vérifier les patterns 404
if ($statusCode === 404) {
$clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
SecurityMonitor::check404Pattern($clientIp);
}
// Alerter sur les erreurs 500
if ($statusCode >= 500) {
$error = error_get_last();
AlertService::trigger('HTTP_500', [
'endpoint' => $requestUri,
'method' => $requestMethod,
'error_message' => $error['message'] ?? 'Unknown error',
'error_file' => $error['file'] ?? 'Unknown',
'error_line' => $error['line'] ?? 0,
'message' => "Erreur serveur 500 sur $requestUri"
], 'ERROR');
}
// Nettoyer périodiquement les IPs expirées (1% de chance)
if (rand(1, 100) === 1) {
IPBlocker::cleanupExpired();
}
});
// Gérer les erreurs non capturées
set_exception_handler(function($exception) use ($requestUri, $requestMethod) {
// Logger l'erreur
error_log("Uncaught exception: " . $exception->getMessage());
// Créer une alerte
AlertService::trigger('UNCAUGHT_EXCEPTION', [
'endpoint' => $requestUri,
'method' => $requestMethod,
'exception' => get_class($exception),
'message' => $exception->getMessage(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => substr($exception->getTraceAsString(), 0, 1000)
], 'ERROR');
// Retourner une erreur 500
http_response_code(500);
Response::json([
'status' => 'error',
'message' => 'Internal server error'
], 500);
});
// Gérer la requête
$router->handle();
try {
$router->handle();
} catch (Exception $e) {
// Les exceptions sont gérées par le handler ci-dessus
throw $e;
}

View File

@@ -142,6 +142,15 @@ fi
echo "✅ Propriétaire et permissions appliqués avec succès"
# Mise à jour des dépendances Composer
echo "📦 Mise à jour des dépendances Composer sur $DEST_CONTAINER..."
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- bash -c 'cd $API_PATH && composer update --no-dev --optimize-autoloader'" > /dev/null 2>&1
if [ $? -eq 0 ]; then
echo "✅ Dépendances Composer mises à jour avec succès"
else
echo "⚠️ Composer non disponible ou échec, poursuite sans mise à jour des dépendances"
fi
# Vérifier la copie
echo "✅ Vérification de la copie..."
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- test -d $API_PATH"

View File

@@ -0,0 +1,150 @@
#!/usr/bin/env php
<?php
/**
* Script de nettoyage des données de sécurité
* À exécuter via cron quotidiennement
* Exemple crontab: 0 2 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/cleanup_security_data.php
*/
declare(strict_types=1);
// Configuration
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/Config/AppConfig.php';
require_once __DIR__ . '/../../src/Core/Database.php';
// Initialiser la configuration
$appConfig = AppConfig::getInstance();
$config = $appConfig->getFullConfig();
// Initialiser la base de données
Database::init($config['database']);
$db = Database::getInstance();
// Configuration de rétention (en jours)
$RETENTION_DAYS = [
'performance_metrics' => 30, // Garder 30 jours de métriques
'failed_login_attempts' => 7, // Garder 7 jours de tentatives
'resolved_alerts' => 90, // Garder 90 jours d'alertes résolues
'expired_blocks' => 0 // Débloquer immédiatement les IPs expirées
];
echo "[" . date('Y-m-d H:i:s') . "] Début du nettoyage des données de sécurité\n";
try {
$totalDeleted = 0;
// 1. Nettoyer les métriques de performance
echo "- Nettoyage des métriques de performance (>" . $RETENTION_DAYS['performance_metrics'] . " jours)...\n";
$stmt = $db->prepare('
DELETE FROM sec_performance_metrics
WHERE created_at < DATE_SUB(NOW(), INTERVAL :days DAY)
');
$stmt->execute(['days' => $RETENTION_DAYS['performance_metrics']]);
$deleted = $stmt->rowCount();
echo "$deleted lignes supprimées\n";
$totalDeleted += $deleted;
// 2. Nettoyer les tentatives de login échouées
echo "- Nettoyage des tentatives de login (>" . $RETENTION_DAYS['failed_login_attempts'] . " jours)...\n";
$stmt = $db->prepare('
DELETE FROM sec_failed_login_attempts
WHERE attempt_time < DATE_SUB(NOW(), INTERVAL :days DAY)
');
$stmt->execute(['days' => $RETENTION_DAYS['failed_login_attempts']]);
$deleted = $stmt->rowCount();
echo "$deleted lignes supprimées\n";
$totalDeleted += $deleted;
// 3. Nettoyer les alertes résolues
echo "- Nettoyage des alertes résolues (>" . $RETENTION_DAYS['resolved_alerts'] . " jours)...\n";
$stmt = $db->prepare('
DELETE FROM sec_alerts
WHERE resolved = 1
AND resolved_at < DATE_SUB(NOW(), INTERVAL :days DAY)
');
$stmt->execute(['days' => $RETENTION_DAYS['resolved_alerts']]);
$deleted = $stmt->rowCount();
echo "$deleted lignes supprimées\n";
$totalDeleted += $deleted;
// 4. Débloquer les IPs expirées
echo "- Déblocage des IPs expirées...\n";
$stmt = $db->prepare('
UPDATE sec_blocked_ips
SET unblocked_at = NOW()
WHERE blocked_until <= NOW()
AND unblocked_at IS NULL
AND permanent = 0
');
$stmt->execute();
$unblocked = $stmt->rowCount();
echo "$unblocked IPs débloquées\n";
// 5. Supprimer les anciennes IPs débloquées (optionnel, garder 180 jours d'historique)
echo "- Suppression des anciennes IPs débloquées (>180 jours)...\n";
$stmt = $db->prepare('
DELETE FROM sec_blocked_ips
WHERE unblocked_at IS NOT NULL
AND unblocked_at < DATE_SUB(NOW(), INTERVAL 180 DAY)
');
$stmt->execute();
$deleted = $stmt->rowCount();
echo "$deleted lignes supprimées\n";
$totalDeleted += $deleted;
// 6. Optimiser les tables (optionnel, peut être long sur de grosses tables)
if ($totalDeleted > 1000) {
echo "- Optimisation des tables...\n";
$tables = [
'sec_performance_metrics',
'sec_failed_login_attempts',
'sec_alerts',
'sec_blocked_ips'
];
foreach ($tables as $table) {
try {
$db->exec("OPTIMIZE TABLE $table");
echo " → Table $table optimisée\n";
} catch (Exception $e) {
echo " ⚠ Impossible d'optimiser $table: " . $e->getMessage() . "\n";
}
}
}
// 7. Statistiques finales
echo "\n=== RÉSUMÉ ===\n";
echo "Total supprimé: $totalDeleted lignes\n";
echo "IPs débloquées: $unblocked\n";
// Obtenir les statistiques actuelles
$stats = [];
$tables = [
'sec_alerts' => "SELECT COUNT(*) as total, SUM(resolved = 0) as active FROM sec_alerts",
'sec_performance_metrics' => "SELECT COUNT(*) as total FROM sec_performance_metrics",
'sec_failed_login_attempts' => "SELECT COUNT(*) as total FROM sec_failed_login_attempts",
'sec_blocked_ips' => "SELECT COUNT(*) as total, SUM(permanent = 1) as permanent FROM sec_blocked_ips WHERE unblocked_at IS NULL"
];
echo "\nÉtat actuel des tables:\n";
foreach ($tables as $table => $query) {
$result = $db->query($query)->fetch(PDO::FETCH_ASSOC);
if ($table === 'sec_alerts') {
echo "- $table: {$result['total']} total, {$result['active']} actives\n";
} elseif ($table === 'sec_blocked_ips') {
$permanent = $result['permanent'] ?? 0;
echo "- $table: {$result['total']} bloquées, $permanent permanentes\n";
} else {
echo "- $table: {$result['total']} enregistrements\n";
}
}
echo "\n[" . date('Y-m-d H:i:s') . "] Nettoyage terminé avec succès\n";
} catch (Exception $e) {
echo "\n❌ ERREUR: " . $e->getMessage() . "\n";
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
exit(1);
}

View File

@@ -0,0 +1,322 @@
#!/usr/bin/env php
<?php
/**
* Script CRON pour traiter la queue d'emails
* Envoie les emails en attente dans la table email_queue
*
* À exécuter toutes les 5 minutes via crontab :
* Exemple: [asterisk]/5 [asterisk] [asterisk] [asterisk] [asterisk] /usr/bin/php /path/to/api/scripts/cron/process_email_queue.php
*/
declare(strict_types=1);
// Configuration
define('MAX_ATTEMPTS', 3);
define('BATCH_SIZE', 50);
define('LOCK_FILE', '/tmp/process_email_queue.lock');
// Empêcher l'exécution multiple simultanée
if (file_exists(LOCK_FILE)) {
$lockTime = filemtime(LOCK_FILE);
// Si le lock a plus de 30 minutes, on le supprime (processus probablement bloqué)
if (time() - $lockTime > 1800) {
unlink(LOCK_FILE);
} else {
die("Le processus est déjà en cours d'exécution\n");
}
}
// Créer le fichier de lock
file_put_contents(LOCK_FILE, getmypid());
// Enregistrer un handler pour supprimer le lock en cas d'arrêt
register_shutdown_function(function() {
if (file_exists(LOCK_FILE)) {
unlink(LOCK_FILE);
}
});
// Simuler l'environnement web pour AppConfig en CLI
if (php_sapi_name() === 'cli') {
// Détecter l'environnement basé sur le hostname ou un paramètre
$hostname = gethostname();
if (strpos($hostname, 'prod') !== false) {
$_SERVER['SERVER_NAME'] = 'app.geosector.fr';
} elseif (strpos($hostname, 'rec') !== false || strpos($hostname, 'rapp') !== false) {
$_SERVER['SERVER_NAME'] = 'rapp.geosector.fr';
} else {
$_SERVER['SERVER_NAME'] = 'dapp.geosector.fr'; // DVA par défaut
}
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'];
$_SERVER['REMOTE_ADDR'] = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
// Définir getallheaders si elle n'existe pas (CLI)
if (!function_exists('getallheaders')) {
function getallheaders() {
return [];
}
}
}
// Chargement de l'environnement
require_once __DIR__ . '/../../vendor/autoload.php';
require_once __DIR__ . '/../../src/Config/AppConfig.php';
require_once __DIR__ . '/../../src/Core/Database.php';
require_once __DIR__ . '/../../src/Services/LogService.php';
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;
try {
// Initialisation de la configuration
$appConfig = AppConfig::getInstance();
$dbConfig = $appConfig->getDatabaseConfig();
// Initialiser la base de données avec la configuration
Database::init($dbConfig);
$db = Database::getInstance();
// Log uniquement si mode debug activé
// LogService::log('Démarrage du processeur de queue d\'emails', [
// 'level' => 'info',
// 'script' => 'process_email_queue.php'
// ]);
// Récupérer les emails en attente
$stmt = $db->prepare('
SELECT id, fk_pass, to_email, subject, body, headers, attempts
FROM email_queue
WHERE status = ? AND attempts < ?
ORDER BY created_at ASC
LIMIT ?
');
$stmt->execute(['pending', MAX_ATTEMPTS, BATCH_SIZE]);
$emails = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($emails)) {
// Ne pas logger quand il n'y a rien à faire (toutes les 5 minutes)
// LogService::log('Aucun email en attente dans la queue', [
// 'level' => 'debug'
// ]);
exit(0);
}
LogService::log('Emails à traiter', [
'level' => 'info',
'count' => count($emails)
]);
// Configuration SMTP
$smtpConfig = $appConfig->getSmtpConfig();
$emailConfig = $appConfig->getEmailConfig();
$successCount = 0;
$failureCount = 0;
// Traiter chaque email
foreach ($emails as $emailData) {
$emailId = $emailData['id'];
$passageId = $emailData['fk_pass'];
try {
// Incrémenter le compteur de tentatives
$stmt = $db->prepare('UPDATE email_queue SET attempts = attempts + 1 WHERE id = ?');
$stmt->execute([$emailId]);
// Créer l'instance PHPMailer
$mail = new PHPMailer(true);
// Configuration du serveur SMTP
$mail->isSMTP();
$mail->Host = $smtpConfig['host'];
$mail->SMTPAuth = $smtpConfig['auth'] ?? true;
$mail->Username = $smtpConfig['user'];
$mail->Password = $smtpConfig['pass'];
$mail->SMTPSecure = $smtpConfig['secure'];
$mail->Port = $smtpConfig['port'];
$mail->CharSet = 'UTF-8';
// Configuration de l'expéditeur
$fromName = 'Amicale Sapeurs-Pompiers'; // Nom par défaut
$mail->setFrom($emailConfig['from'], $fromName);
// Destinataire
$mail->addAddress($emailData['to_email']);
// Sujet
$mail->Subject = $emailData['subject'];
// Headers personnalisés si présents
if (!empty($emailData['headers'])) {
// Les headers contiennent déjà les informations MIME pour la pièce jointe
// On doit extraire le boundary et reconstruire le message
if (preg_match('/boundary="([^"]+)"/', $emailData['headers'], $matches)) {
$boundary = $matches[1];
// Le body contient déjà le message complet avec pièce jointe
$mail->isHTML(false);
$mail->Body = $emailData['body'];
// Extraire le contenu HTML et la pièce jointe
$parts = explode("--$boundary", $emailData['body']);
foreach ($parts as $part) {
if (strpos($part, 'Content-Type: text/html') !== false) {
// Extraire le contenu HTML
$htmlContent = trim(substr($part, strpos($part, "\r\n\r\n") + 4));
$mail->isHTML(true);
$mail->Body = $htmlContent;
} elseif (strpos($part, 'Content-Type: application/pdf') !== false) {
// Extraire le PDF encodé en base64
if (preg_match('/filename="([^"]+)"/', $part, $fileMatches)) {
$filename = $fileMatches[1];
$pdfContent = trim(substr($part, strpos($part, "\r\n\r\n") + 4));
// Supprimer les retours à la ligne du base64
$pdfContent = str_replace(["\r", "\n"], '', $pdfContent);
// Ajouter la pièce jointe
$mail->addStringAttachment(
base64_decode($pdfContent),
$filename,
'base64',
'application/pdf'
);
}
}
}
}
} else {
// Email simple sans pièce jointe
$mail->isHTML(true);
$mail->Body = $emailData['body'];
}
// Ajouter une copie si configuré
if (!empty($emailConfig['contact'])) {
$mail->addBCC($emailConfig['contact']);
}
// Envoyer l'email
if ($mail->send()) {
// Marquer comme envoyé
$stmt = $db->prepare('
UPDATE email_queue
SET status = ?, sent_at = NOW()
WHERE id = ?
');
$stmt->execute(['sent', $emailId]);
// Mettre à jour le passage si nécessaire
if ($passageId > 0) {
$stmt = $db->prepare('
UPDATE ope_pass
SET date_sent_recu = NOW(), chk_email_sent = 1
WHERE id = ?
');
$stmt->execute([$passageId]);
}
$successCount++;
LogService::log('Email envoyé avec succès', [
'level' => 'info',
'emailId' => $emailId,
'passageId' => $passageId,
'to' => $emailData['to_email']
]);
} else {
throw new Exception('Échec de l\'envoi');
}
} catch (Exception $e) {
$failureCount++;
LogService::log('Erreur lors de l\'envoi de l\'email', [
'level' => 'error',
'emailId' => $emailId,
'passageId' => $passageId,
'error' => $e->getMessage(),
'attempts' => $emailData['attempts'] + 1
]);
// Si on a atteint le nombre max de tentatives, marquer comme échoué
if ($emailData['attempts'] + 1 >= MAX_ATTEMPTS) {
$stmt = $db->prepare('
UPDATE email_queue
SET status = ?, error_message = ?
WHERE id = ?
');
$stmt->execute(['failed', $e->getMessage(), $emailId]);
LogService::log('Email marqué comme échoué après ' . MAX_ATTEMPTS . ' tentatives', [
'level' => 'warning',
'emailId' => $emailId,
'passageId' => $passageId
]);
}
}
// Pause courte entre chaque email pour éviter la surcharge
usleep(500000); // 0.5 seconde
}
// Logger uniquement s'il y avait des emails à traiter
if (count($emails) > 0) {
LogService::log('Traitement de la queue terminé', [
'level' => 'info',
'success' => $successCount,
'failures' => $failureCount,
'total' => count($emails)
]);
}
} catch (Exception $e) {
LogService::log('Erreur fatale dans le processeur de queue', [
'level' => 'critical',
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
// Supprimer le lock en cas d'erreur
if (file_exists(LOCK_FILE)) {
unlink(LOCK_FILE);
}
exit(1);
}
// Nettoyer les vieux emails traités (optionnel)
try {
// Supprimer les emails envoyés de plus de 30 jours
$stmt = $db->prepare('
DELETE FROM email_queue
WHERE status = ? AND sent_at < DATE_SUB(NOW(), INTERVAL 30 DAY)
');
$stmt->execute(['sent']);
$deleted = $stmt->rowCount();
if ($deleted > 0) {
LogService::log('Nettoyage des anciens emails', [
'level' => 'info',
'deleted' => $deleted
]);
}
} catch (Exception $e) {
LogService::log('Erreur lors du nettoyage des anciens emails', [
'level' => 'warning',
'error' => $e->getMessage()
]);
}
// Supprimer le lock
if (file_exists(LOCK_FILE)) {
unlink(LOCK_FILE);
}
echo "Traitement terminé : $successCount envoyés, $failureCount échecs\n";
exit(0);

View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Script wrapper pour process_email_queue avec logs journaliers
# Crée automatiquement un nouveau fichier log chaque jour
# Configuration
LOG_DIR="/var/www/geosector/api/logs"
LOG_FILE="$LOG_DIR/email_queue_$(date +%Y%m%d).log"
PHP_SCRIPT="/var/www/geosector/api/scripts/cron/process_email_queue.php"
# Créer le répertoire de logs s'il n'existe pas
mkdir -p "$LOG_DIR"
# Ajouter un timestamp au début de l'exécution
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Démarrage du processeur de queue d'emails" >> "$LOG_FILE"
# Exécuter le script PHP
/usr/bin/php "$PHP_SCRIPT" >> "$LOG_FILE" 2>&1
# Ajouter le statut de sortie
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Fin du traitement (succès)" >> "$LOG_FILE"
else
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Fin du traitement (erreur: $EXIT_CODE)" >> "$LOG_FILE"
fi
# Nettoyer les logs de plus de 30 jours
find "$LOG_DIR" -name "email_queue_*.log" -type f -mtime +30 -delete 2>/dev/null
exit $EXIT_CODE

View File

@@ -0,0 +1,186 @@
#!/usr/bin/env php
<?php
/**
* Script de test pour vérifier le processeur de queue d'emails
* Affiche les emails en attente sans les envoyer
*/
declare(strict_types=1);
// Simuler l'environnement web pour AppConfig en CLI
if (php_sapi_name() === 'cli') {
$_SERVER['SERVER_NAME'] = $_SERVER['SERVER_NAME'] ?? 'dapp.geosector.fr'; // DVA par défaut
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'];
$_SERVER['REMOTE_ADDR'] = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
// Définir getallheaders si elle n'existe pas (CLI)
if (!function_exists('getallheaders')) {
function getallheaders() {
return [];
}
}
}
require_once __DIR__ . '/../../src/Core/Database.php';
require_once __DIR__ . '/../../src/Config/AppConfig.php';
try {
// Initialiser la configuration
$appConfig = AppConfig::getInstance();
$dbConfig = $appConfig->getDatabaseConfig();
// Initialiser la base de données avec la configuration
Database::init($dbConfig);
$db = Database::getInstance();
echo "=== TEST DE LA QUEUE D'EMAILS ===\n\n";
// Statistiques générales
$stmt = $db->query('
SELECT
status,
COUNT(*) as count,
MIN(created_at) as oldest,
MAX(created_at) as newest
FROM email_queue
GROUP BY status
');
$stats = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo "STATISTIQUES:\n";
echo "-------------\n";
foreach ($stats as $stat) {
echo sprintf(
"Status: %s - Nombre: %d (Plus ancien: %s, Plus récent: %s)\n",
$stat['status'],
$stat['count'],
$stat['oldest'] ?? 'N/A',
$stat['newest'] ?? 'N/A'
);
}
echo "\n";
// Emails en attente
$stmt = $db->prepare('
SELECT
eq.id,
eq.fk_pass,
eq.to_email,
eq.subject,
eq.created_at,
eq.attempts,
eq.status,
p.fk_type,
p.montant,
p.nom_recu
FROM email_queue eq
LEFT JOIN ope_pass p ON eq.fk_pass = p.id
WHERE eq.status = ?
ORDER BY eq.created_at DESC
LIMIT 10
');
$stmt->execute(['pending']);
$pendingEmails = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($pendingEmails)) {
echo "Aucun email en attente.\n";
} else {
echo "EMAILS EN ATTENTE (10 plus récents):\n";
echo "------------------------------------\n";
foreach ($pendingEmails as $email) {
echo sprintf(
"ID: %d | Passage: %d | Destinataire: %s\n",
$email['id'],
$email['fk_pass'],
$email['to_email']
);
echo sprintf(
" Sujet: %s\n",
$email['subject']
);
echo sprintf(
" Créé le: %s | Tentatives: %d\n",
$email['created_at'],
$email['attempts']
);
if ($email['fk_pass'] > 0) {
echo sprintf(
" Passage - Type: %s | Montant: %.2f€ | Reçu: %s\n",
$email['fk_type'] == 1 ? 'DON' : 'Autre',
$email['montant'] ?? 0,
$email['nom_recu'] ?? 'Non généré'
);
}
echo "---\n";
}
}
// Emails échoués
$stmt = $db->prepare('
SELECT
id,
fk_pass,
to_email,
subject,
created_at,
attempts,
error_message
FROM email_queue
WHERE status = ?
ORDER BY created_at DESC
LIMIT 5
');
$stmt->execute(['failed']);
$failedEmails = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($failedEmails)) {
echo "\nEMAILS ÉCHOUÉS (5 plus récents):\n";
echo "--------------------------------\n";
foreach ($failedEmails as $email) {
echo sprintf(
"ID: %d | Passage: %d | Destinataire: %s\n",
$email['id'],
$email['fk_pass'],
$email['to_email']
);
echo sprintf(
" Sujet: %s\n",
$email['subject']
);
echo sprintf(
" Tentatives: %d | Erreur: %s\n",
$email['attempts'],
$email['error_message'] ?? 'Non spécifiée'
);
echo "---\n";
}
}
// Vérifier la configuration SMTP
echo "\nCONFIGURATION SMTP:\n";
echo "-------------------\n";
$smtpConfig = $appConfig->getSmtpConfig();
$emailConfig = $appConfig->getEmailConfig();
echo "Host: " . ($smtpConfig['host'] ?? 'Non configuré') . "\n";
echo "Port: " . ($smtpConfig['port'] ?? 'Non configuré') . "\n";
echo "Username: " . ($smtpConfig['user'] ?? 'Non configuré') . "\n";
echo "Password: " . (isset($smtpConfig['pass']) ? '***' : 'Non configuré') . "\n";
echo "Encryption: " . ($smtpConfig['secure'] ?? 'Non configuré') . "\n";
echo "From Email: " . ($emailConfig['from'] ?? 'Non configuré') . "\n";
echo "Contact Email: " . ($emailConfig['contact'] ?? 'Non configuré') . "\n";
echo "\n=== FIN DU TEST ===\n";
} catch (Exception $e) {
echo "ERREUR: " . $e->getMessage() . "\n";
exit(1);
}
exit(0);

View File

@@ -0,0 +1,298 @@
#!/usr/bin/env php
<?php
/**
* Script de migration de l'arborescence des uploads
* Réorganise les fichiers existants vers la nouvelle structure simplifiée
*
* Ancienne structure : uploads/entites/{id}/* et uploads/{id}/*
* Nouvelle structure : uploads/{id}/*
*
* Usage: php scripts/migrate_uploads_structure.php [--dry-run]
*/
declare(strict_types=1);
// Chemin de base des uploads
const BASE_PATH = '/var/www/geosector/api/uploads';
const LOG_FILE = '/var/www/geosector/api/logs/migration_uploads_' . date('Ymd_His') . '.log';
// Mode dry-run (simulation sans modification)
$dryRun = in_array('--dry-run', $argv);
// Fonction pour logger
function logMessage(string $message, string $level = 'INFO'): void {
$timestamp = date('Y-m-d H:i:s');
$log = "[$timestamp] [$level] $message" . PHP_EOL;
echo $log;
if (!$GLOBALS['dryRun']) {
file_put_contents(LOG_FILE, $log, FILE_APPEND);
}
}
// Fonction pour déplacer un fichier ou dossier
function moveItem(string $source, string $destination): bool {
global $dryRun;
if (!file_exists($source)) {
logMessage("Source n'existe pas: $source", 'WARNING');
return false;
}
// Créer le dossier de destination si nécessaire
$destDir = dirname($destination);
if (!is_dir($destDir)) {
logMessage("Création du dossier: $destDir");
if (!$dryRun) {
mkdir($destDir, 0775, true);
chown($destDir, 'nginx');
chgrp($destDir, 'nobody');
}
}
// Déplacer l'élément
logMessage("Déplacement: $source -> $destination");
if (!$dryRun) {
if (is_dir($source)) {
// Pour un dossier, utiliser rename
return rename($source, $destination);
} else {
// Pour un fichier
return rename($source, $destination);
}
}
return true;
}
// Fonction pour copier récursivement un dossier
function copyDirectory(string $source, string $dest): bool {
global $dryRun;
if (!is_dir($source)) {
return false;
}
if (!$dryRun) {
if (!is_dir($dest)) {
mkdir($dest, 0775, true);
chown($dest, 'nginx');
chgrp($dest, 'nobody');
}
}
$dir = opendir($source);
while (($file = readdir($dir)) !== false) {
if ($file === '.' || $file === '..') {
continue;
}
$srcPath = "$source/$file";
$destPath = "$dest/$file";
if (is_dir($srcPath)) {
copyDirectory($srcPath, $destPath);
} else {
logMessage("Copie: $srcPath -> $destPath");
if (!$dryRun) {
copy($srcPath, $destPath);
chmod($destPath, 0664);
chown($destPath, 'nginx');
chgrp($destPath, 'nobody');
}
}
}
closedir($dir);
return true;
}
// Fonction principale de migration
function migrateUploads(): void {
global $dryRun;
logMessage("=== Début de la migration des uploads ===");
logMessage($dryRun ? "MODE DRY-RUN (simulation)" : "MODE RÉEL (modifications effectives)");
// 1. Migrer uploads/entites/* vers uploads/*
$entitesPath = BASE_PATH . '/entites';
if (is_dir($entitesPath)) {
logMessage("Traitement du dossier entites/");
$entites = scandir($entitesPath);
foreach ($entites as $entiteId) {
if ($entiteId === '.' || $entiteId === '..') continue;
$oldPath = "$entitesPath/$entiteId";
$newPath = BASE_PATH . "/$entiteId";
if (!is_dir($oldPath)) continue;
logMessage("Migration entité $entiteId");
// Si le dossier destination existe déjà, fusionner
if (is_dir($newPath)) {
logMessage("Le dossier $entiteId existe déjà à la racine, fusion nécessaire", 'INFO');
// Migrer les sous-dossiers
$subDirs = scandir($oldPath);
foreach ($subDirs as $subDir) {
if ($subDir === '.' || $subDir === '..') continue;
$oldSubPath = "$oldPath/$subDir";
$newSubPath = "$newPath/$subDir";
if ($subDir === 'operations') {
// Traiter spécialement le dossier operations
migrateOperations($oldSubPath, $newSubPath);
} else {
// Pour logo et recus, déplacer directement
if (!is_dir($newSubPath)) {
moveItem($oldSubPath, $newSubPath);
} else {
logMessage("Le dossier $newSubPath existe déjà, fusion du contenu");
copyDirectory($oldSubPath, $newSubPath);
if (!$dryRun) {
// Supprimer l'ancien après copie
exec("rm -rf " . escapeshellarg($oldSubPath));
}
}
}
}
} else {
// Déplacer simplement le dossier entier
moveItem($oldPath, $newPath);
}
}
// Supprimer le dossier entites vide
if (!$dryRun) {
if (count(scandir($entitesPath)) === 2) { // Seulement . et ..
rmdir($entitesPath);
logMessage("Suppression du dossier entites/ vide");
}
}
}
// 2. Nettoyer la structure des dossiers operations
logMessage("Nettoyage de la structure des dossiers operations");
cleanupOperationsStructure();
logMessage("=== Migration terminée ===");
if (!$dryRun) {
logMessage("Logs sauvegardés dans: " . LOG_FILE);
}
}
// Fonction pour migrer le dossier operations avec simplification
function migrateOperations(string $oldPath, string $newPath): void {
global $dryRun;
if (!is_dir($oldPath)) return;
logMessage("Migration du dossier operations: $oldPath");
if (!$dryRun && !is_dir($newPath)) {
mkdir($newPath, 0775, true);
chown($newPath, 'nginx');
chgrp($newPath, 'nobody');
}
$operations = scandir($oldPath);
foreach ($operations as $opId) {
if ($opId === '.' || $opId === '..') continue;
$oldOpPath = "$oldPath/$opId";
$newOpPath = "$newPath/$opId";
// Simplifier la structure: déplacer les xlsx directement dans operations/{id}/
if (is_dir("$oldOpPath/documents/exports/excel")) {
$excelPath = "$oldOpPath/documents/exports/excel";
$files = scandir($excelPath);
foreach ($files as $file) {
if ($file === '.' || $file === '..' || !str_ends_with($file, '.xlsx')) continue;
$oldFilePath = "$excelPath/$file";
$newFilePath = "$newOpPath/$file";
logMessage("Déplacement Excel: $oldFilePath -> $newFilePath");
if (!$dryRun) {
if (!is_dir($newOpPath)) {
mkdir($newOpPath, 0775, true);
chown($newOpPath, 'nginx');
chgrp($newOpPath, 'nobody');
}
rename($oldFilePath, $newFilePath);
chmod($newFilePath, 0664);
chown($newFilePath, 'nginx');
chgrp($newFilePath, 'nobody');
}
}
}
}
}
// Fonction pour nettoyer la structure après migration
function cleanupOperationsStructure(): void {
global $dryRun;
$uploadsDir = BASE_PATH;
$entites = scandir($uploadsDir);
foreach ($entites as $entiteId) {
if ($entiteId === '.' || $entiteId === '..' || $entiteId === 'entites') continue;
$operationsPath = "$uploadsDir/$entiteId/operations";
if (!is_dir($operationsPath)) continue;
$operations = scandir($operationsPath);
foreach ($operations as $opId) {
if ($opId === '.' || $opId === '..') continue;
$opPath = "$operationsPath/$opId";
// Supprimer l'ancienne structure documents/exports/excel si elle est vide
$oldStructure = "$opPath/documents";
if (is_dir($oldStructure)) {
logMessage("Suppression de l'ancienne structure: $oldStructure");
if (!$dryRun) {
exec("rm -rf " . escapeshellarg($oldStructure));
}
}
}
}
}
// Vérifier les permissions
if (!is_dir(BASE_PATH)) {
die("ERREUR: Le dossier " . BASE_PATH . " n'existe pas\n");
}
if (!is_writable(BASE_PATH) && !$dryRun) {
die("ERREUR: Le dossier " . BASE_PATH . " n'est pas accessible en écriture\n");
}
// Lancer la migration
try {
migrateUploads();
if ($dryRun) {
echo "\n";
echo "========================================\n";
echo "SIMULATION TERMINÉE\n";
echo "Pour exécuter réellement la migration:\n";
echo "php " . $argv[0] . "\n";
echo "========================================\n";
} else {
echo "\n";
echo "========================================\n";
echo "MIGRATION TERMINÉE AVEC SUCCÈS\n";
echo "Vérifiez les logs: " . LOG_FILE . "\n";
echo "========================================\n";
}
} catch (Exception $e) {
logMessage("ERREUR FATALE: " . $e->getMessage(), 'ERROR');
exit(1);
}

View File

@@ -0,0 +1,197 @@
-- =============================================================
-- Tables pour l'intégration Stripe Connect + Terminal
-- Date: 2025-09-01
-- Version: 1.0.0
-- Préfixe: stripe_
-- =============================================================
-- Table pour stocker les comptes Stripe Connect des amicales
CREATE TABLE IF NOT EXISTS stripe_accounts (
id INT(10) UNSIGNED PRIMARY KEY AUTO_INCREMENT,
fk_entite INT(10) UNSIGNED NOT NULL,
stripe_account_id VARCHAR(255) UNIQUE,
stripe_location_id VARCHAR(255),
charges_enabled BOOLEAN DEFAULT FALSE,
payouts_enabled BOOLEAN DEFAULT FALSE,
onboarding_completed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (fk_entite) REFERENCES entites(id) ON DELETE CASCADE,
INDEX idx_fk_entite (fk_entite),
INDEX idx_stripe_account_id (stripe_account_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table pour stocker les intentions de paiement
CREATE TABLE IF NOT EXISTS stripe_payment_intents (
id INT(10) UNSIGNED PRIMARY KEY AUTO_INCREMENT,
stripe_payment_intent_id VARCHAR(255) UNIQUE,
fk_entite INT(10) UNSIGNED NOT NULL,
fk_user INT(10) UNSIGNED NOT NULL,
amount INT NOT NULL COMMENT 'Montant en centimes',
currency VARCHAR(3) DEFAULT 'eur',
status VARCHAR(50),
application_fee INT COMMENT 'Commission en centimes',
metadata JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (fk_entite) REFERENCES entites(id) ON DELETE CASCADE,
FOREIGN KEY (fk_user) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_fk_entite (fk_entite),
INDEX idx_fk_user (fk_user),
INDEX idx_status (status),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table pour les readers Terminal (Tap to Pay virtuel)
CREATE TABLE IF NOT EXISTS stripe_terminal_readers (
id INT(10) UNSIGNED PRIMARY KEY AUTO_INCREMENT,
stripe_reader_id VARCHAR(255) UNIQUE,
fk_entite INT(10) UNSIGNED NOT NULL,
label VARCHAR(255),
location VARCHAR(255),
status VARCHAR(50),
device_type VARCHAR(50) COMMENT 'ios_tap_to_pay, android_tap_to_pay',
device_info JSON COMMENT 'Infos sur le device (modèle, OS, etc)',
last_seen_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (fk_entite) REFERENCES entites(id) ON DELETE CASCADE,
INDEX idx_fk_entite (fk_entite),
INDEX idx_device_type (device_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table pour les appareils Android certifiés Tap to Pay
CREATE TABLE IF NOT EXISTS stripe_android_certified_devices (
id INT(10) UNSIGNED PRIMARY KEY AUTO_INCREMENT,
manufacturer VARCHAR(100),
model VARCHAR(200),
model_identifier VARCHAR(200),
tap_to_pay_certified BOOLEAN DEFAULT FALSE,
certification_date DATE,
min_android_version INT,
country VARCHAR(2) DEFAULT 'FR',
notes TEXT,
last_verified TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_manufacturer_model (manufacturer, model),
INDEX idx_certified (tap_to_pay_certified, country),
UNIQUE KEY unique_device (manufacturer, model, model_identifier)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table pour l'historique des paiements (pour audit et réconciliation)
CREATE TABLE IF NOT EXISTS stripe_payment_history (
id INT(10) UNSIGNED PRIMARY KEY AUTO_INCREMENT,
fk_payment_intent INT(10) UNSIGNED,
event_type VARCHAR(50) COMMENT 'created, processing, succeeded, failed, refunded',
event_data JSON,
webhook_id VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (fk_payment_intent) REFERENCES stripe_payment_intents(id) ON DELETE CASCADE,
INDEX idx_fk_payment_intent (fk_payment_intent),
INDEX idx_event_type (event_type),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table pour les remboursements
CREATE TABLE IF NOT EXISTS stripe_refunds (
id INT(10) UNSIGNED PRIMARY KEY AUTO_INCREMENT,
stripe_refund_id VARCHAR(255) UNIQUE,
fk_payment_intent INT(10) UNSIGNED NOT NULL,
amount INT NOT NULL COMMENT 'Montant remboursé en centimes',
reason VARCHAR(100) COMMENT 'duplicate, fraudulent, requested_by_customer',
status VARCHAR(50),
metadata JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (fk_payment_intent) REFERENCES stripe_payment_intents(id) ON DELETE CASCADE,
INDEX idx_fk_payment_intent (fk_payment_intent),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table pour les webhooks reçus (pour éviter les doublons et debug)
CREATE TABLE IF NOT EXISTS stripe_webhooks (
id INT(10) UNSIGNED PRIMARY KEY AUTO_INCREMENT,
stripe_event_id VARCHAR(255) UNIQUE,
event_type VARCHAR(100),
livemode BOOLEAN DEFAULT FALSE,
payload JSON,
processed BOOLEAN DEFAULT FALSE,
error_message TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP NULL,
INDEX idx_event_type (event_type),
INDEX idx_processed (processed),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Insertion des appareils Android certifiés pour Tap to Pay en France
INSERT INTO stripe_android_certified_devices (manufacturer, model, model_identifier, tap_to_pay_certified, min_android_version, certification_date) VALUES
-- Samsung
('Samsung', 'Galaxy S21', 'SM-G991B', TRUE, 11, '2023-01-01'),
('Samsung', 'Galaxy S21+', 'SM-G996B', TRUE, 11, '2023-01-01'),
('Samsung', 'Galaxy S21 Ultra', 'SM-G998B', TRUE, 11, '2023-01-01'),
('Samsung', 'Galaxy S22', 'SM-S901B', TRUE, 12, '2023-01-01'),
('Samsung', 'Galaxy S22+', 'SM-S906B', TRUE, 12, '2023-01-01'),
('Samsung', 'Galaxy S22 Ultra', 'SM-S908B', TRUE, 12, '2023-01-01'),
('Samsung', 'Galaxy S23', 'SM-S911B', TRUE, 13, '2023-06-01'),
('Samsung', 'Galaxy S23+', 'SM-S916B', TRUE, 13, '2023-06-01'),
('Samsung', 'Galaxy S23 Ultra', 'SM-S918B', TRUE, 13, '2023-06-01'),
('Samsung', 'Galaxy S24', 'SM-S921B', TRUE, 14, '2024-01-01'),
('Samsung', 'Galaxy S24+', 'SM-S926B', TRUE, 14, '2024-01-01'),
('Samsung', 'Galaxy S24 Ultra', 'SM-S928B', TRUE, 14, '2024-01-01'),
-- Google Pixel
('Google', 'Pixel 6', 'oriole', TRUE, 12, '2023-01-01'),
('Google', 'Pixel 6 Pro', 'raven', TRUE, 12, '2023-01-01'),
('Google', 'Pixel 6a', 'bluejay', TRUE, 12, '2023-03-01'),
('Google', 'Pixel 7', 'panther', TRUE, 13, '2023-03-01'),
('Google', 'Pixel 7 Pro', 'cheetah', TRUE, 13, '2023-03-01'),
('Google', 'Pixel 7a', 'lynx', TRUE, 13, '2023-06-01'),
('Google', 'Pixel 8', 'shiba', TRUE, 14, '2023-10-01'),
('Google', 'Pixel 8 Pro', 'husky', TRUE, 14, '2023-10-01'),
('Google', 'Pixel Fold', 'felix', TRUE, 13, '2023-07-01'),
-- OnePlus
('OnePlus', '9', 'LE2113', TRUE, 11, '2023-03-01'),
('OnePlus', '9 Pro', 'LE2123', TRUE, 11, '2023-03-01'),
('OnePlus', '10 Pro', 'NE2213', TRUE, 12, '2023-06-01'),
('OnePlus', '11', 'CPH2449', TRUE, 13, '2023-09-01'),
-- Xiaomi
('Xiaomi', 'Mi 11', 'M2011K2G', TRUE, 11, '2023-06-01'),
('Xiaomi', '12', '2201123G', TRUE, 12, '2023-09-01'),
('Xiaomi', '12 Pro', '2201122G', TRUE, 12, '2023-09-01'),
('Xiaomi', '13', '2211133G', TRUE, 13, '2024-01-01'),
('Xiaomi', '13 Pro', '2210132G', TRUE, 13, '2024-01-01');
-- Vue pour faciliter les requêtes de statistiques
CREATE OR REPLACE VIEW v_stripe_payment_stats AS
SELECT
spi.fk_entite,
e.encrypted_name AS entite_name,
spi.fk_user,
u.encrypted_name AS user_nom,
u.first_name AS user_prenom,
COUNT(CASE WHEN spi.status = 'succeeded' THEN 1 END) as total_ventes,
SUM(CASE WHEN spi.status = 'succeeded' THEN spi.amount ELSE 0 END) as total_montant,
SUM(CASE WHEN spi.status = 'succeeded' THEN spi.application_fee ELSE 0 END) as total_commissions,
DATE(spi.created_at) as date_vente
FROM stripe_payment_intents spi
LEFT JOIN entites e ON spi.fk_entite = e.id
LEFT JOIN users u ON spi.fk_user = u.id
GROUP BY spi.fk_entite, spi.fk_user, DATE(spi.created_at);
-- Vue pour le dashboard des amicales
CREATE OR REPLACE VIEW v_stripe_amicale_dashboard AS
SELECT
sa.fk_entite,
e.encrypted_name AS entite_name,
sa.stripe_account_id,
sa.charges_enabled,
sa.payouts_enabled,
COUNT(DISTINCT spi.id) as total_transactions,
SUM(CASE WHEN spi.status = 'succeeded' THEN spi.amount ELSE 0 END) as total_revenus,
SUM(CASE WHEN spi.status = 'succeeded' THEN spi.application_fee ELSE 0 END) as total_frais_plateforme,
MAX(spi.created_at) as derniere_transaction
FROM stripe_accounts sa
LEFT JOIN entites e ON sa.fk_entite = e.id
LEFT JOIN stripe_payment_intents spi ON sa.fk_entite = spi.fk_entite
GROUP BY sa.fk_entite, sa.stripe_account_id;

View File

@@ -0,0 +1,249 @@
#!/usr/bin/env php
<?php
/**
* Script d'initialisation des tables de sécurité
* Crée les tables si elles n'existent pas
*/
declare(strict_types=1);
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/Config/AppConfig.php';
require_once __DIR__ . '/../../src/Core/Database.php';
// Initialiser la configuration
$appConfig = AppConfig::getInstance();
$config = $appConfig->getFullConfig();
// Initialiser la base de données
Database::init($config['database']);
$db = Database::getInstance();
echo "\n========================================\n";
echo " CRÉATION DES TABLES DE SÉCURITÉ\n";
echo "========================================\n\n";
try {
// Désactiver temporairement le mode strict pour les clés étrangères
$db->exec("SET FOREIGN_KEY_CHECKS = 0");
// 1. Table des alertes
echo "1. Création de la table sec_alerts...\n";
$db->exec("
CREATE TABLE IF NOT EXISTS `sec_alerts` (
`id` INT(11) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`alert_type` VARCHAR(50) NOT NULL COMMENT 'Type d\'alerte (BRUTE_FORCE, SQL_ERROR, etc.)',
`alert_level` ENUM('INFO', 'WARNING', 'ERROR', 'CRITICAL', 'SECURITY') NOT NULL DEFAULT 'INFO',
`ip_address` VARCHAR(45) DEFAULT NULL COMMENT 'Adresse IP source',
`user_id` INT(11) UNSIGNED DEFAULT NULL COMMENT 'ID utilisateur si connecté',
`username` VARCHAR(255) DEFAULT NULL COMMENT 'Username tenté ou utilisé',
`endpoint` VARCHAR(255) DEFAULT NULL COMMENT 'Endpoint API concerné',
`method` VARCHAR(10) DEFAULT NULL COMMENT 'Méthode HTTP',
`details` JSON DEFAULT NULL COMMENT 'Détails additionnels en JSON',
`occurrences` INT(11) DEFAULT 1 COMMENT 'Nombre d\'occurrences',
`first_seen` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_seen` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`email_sent` TINYINT(1) DEFAULT 0 COMMENT 'Email d\'alerte envoyé',
`email_sent_at` TIMESTAMP NULL DEFAULT NULL,
`resolved` TINYINT(1) DEFAULT 0 COMMENT 'Alerte résolue',
`resolved_at` TIMESTAMP NULL DEFAULT NULL,
`resolved_by` INT(11) UNSIGNED DEFAULT NULL COMMENT 'ID admin qui a résolu',
`notes` TEXT DEFAULT NULL COMMENT 'Notes de résolution',
KEY `idx_ip` (`ip_address`),
KEY `idx_type_time` (`alert_type`, `last_seen`),
KEY `idx_level` (`alert_level`),
KEY `idx_resolved` (`resolved`),
KEY `idx_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Alertes de sécurité et monitoring'
");
echo " ✓ Table sec_alerts créée\n";
// 2. Table des métriques de performance (SANS PARTITIONNEMENT)
echo "2. Création de la table sec_performance_metrics...\n";
$db->exec("
CREATE TABLE IF NOT EXISTS `sec_performance_metrics` (
`id` BIGINT(20) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`endpoint` VARCHAR(255) NOT NULL COMMENT 'Endpoint API',
`method` VARCHAR(10) NOT NULL COMMENT 'Méthode HTTP',
`response_time_ms` INT(11) NOT NULL COMMENT 'Temps de réponse total en ms',
`db_time_ms` INT(11) DEFAULT 0 COMMENT 'Temps cumulé des requêtes DB en ms',
`db_queries_count` INT(11) DEFAULT 0 COMMENT 'Nombre de requêtes DB',
`memory_peak_mb` FLOAT DEFAULT NULL COMMENT 'Pic mémoire en MB',
`memory_start_mb` FLOAT DEFAULT NULL COMMENT 'Mémoire au début en MB',
`http_status` INT(11) DEFAULT NULL COMMENT 'Code HTTP de réponse',
`user_id` INT(11) UNSIGNED DEFAULT NULL COMMENT 'ID utilisateur si connecté',
`ip_address` VARCHAR(45) DEFAULT NULL COMMENT 'Adresse IP',
`user_agent` TEXT DEFAULT NULL COMMENT 'User Agent complet',
`request_size` INT(11) DEFAULT NULL COMMENT 'Taille de la requête en octets',
`response_size` INT(11) DEFAULT NULL COMMENT 'Taille de la réponse en octets',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_endpoint_time` (`endpoint`, `created_at`),
KEY `idx_response_time` (`response_time_ms`),
KEY `idx_created` (`created_at`),
KEY `idx_status` (`http_status`),
KEY `idx_user` (`user_id`),
KEY `idx_date_endpoint` (`created_at`, `endpoint`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Métriques de performance des requêtes'
");
echo " ✓ Table sec_performance_metrics créée\n";
// 3. Table des tentatives de login échouées
echo "3. Création de la table sec_failed_login_attempts...\n";
$db->exec("
CREATE TABLE IF NOT EXISTS `sec_failed_login_attempts` (
`id` INT(11) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`username` VARCHAR(255) DEFAULT NULL COMMENT 'Username tenté',
`encrypted_username` VARCHAR(255) DEFAULT NULL COMMENT 'Username chiffré si trouvé',
`ip_address` VARCHAR(45) NOT NULL COMMENT 'Adresse IP',
`user_agent` TEXT DEFAULT NULL COMMENT 'User Agent',
`attempt_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`error_type` VARCHAR(50) DEFAULT NULL COMMENT 'Type d\'erreur (invalid_password, user_not_found, etc.)',
`country_code` VARCHAR(2) DEFAULT NULL COMMENT 'Code pays de l\'IP (si géoloc activée)',
KEY `idx_ip_time` (`ip_address`, `attempt_time`),
KEY `idx_username` (`username`),
KEY `idx_encrypted_username` (`encrypted_username`),
KEY `idx_time` (`attempt_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Tentatives de connexion échouées'
");
echo " ✓ Table sec_failed_login_attempts créée\n";
// 4. Table des IPs bloquées
echo "4. Création de la table sec_blocked_ips...\n";
$db->exec("
CREATE TABLE IF NOT EXISTS `sec_blocked_ips` (
`ip_address` VARCHAR(45) NOT NULL PRIMARY KEY COMMENT 'Adresse IP bloquée',
`reason` VARCHAR(255) NOT NULL COMMENT 'Raison du blocage',
`details` JSON DEFAULT NULL COMMENT 'Détails additionnels',
`blocked_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`blocked_until` TIMESTAMP NOT NULL COMMENT 'Bloqué jusqu\'à',
`blocked_by` VARCHAR(50) DEFAULT 'system' COMMENT 'Qui a bloqué (system ou user ID)',
`permanent` TINYINT(1) DEFAULT 0 COMMENT 'Blocage permanent',
`unblocked_at` TIMESTAMP NULL DEFAULT NULL COMMENT 'Date de déblocage effectif',
`unblocked_by` INT(11) UNSIGNED DEFAULT NULL COMMENT 'Qui a débloqué',
`block_count` INT(11) DEFAULT 1 COMMENT 'Nombre de fois bloquée',
KEY `idx_blocked_until` (`blocked_until`),
KEY `idx_permanent` (`permanent`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='IPs bloquées temporairement ou définitivement'
");
echo " ✓ Table sec_blocked_ips créée\n";
// 5. Créer les vues
echo "5. Création des vues...\n";
// Vue pour les alertes actives
$db->exec("
CREATE OR REPLACE VIEW sec_active_alerts AS
SELECT
a.*,
u.encrypted_name as user_name,
r.encrypted_name as resolver_name
FROM sec_alerts a
LEFT JOIN users u ON a.user_id = u.id
LEFT JOIN users r ON a.resolved_by = r.id
WHERE a.resolved = 0
OR (a.resolved = 1 AND a.resolved_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR))
ORDER BY
CASE a.alert_level
WHEN 'SECURITY' THEN 1
WHEN 'CRITICAL' THEN 2
WHEN 'ERROR' THEN 3
WHEN 'WARNING' THEN 4
WHEN 'INFO' THEN 5
END,
a.last_seen DESC
");
echo " ✓ Vue sec_active_alerts créée\n";
// Vue pour les IPs suspectes
$db->exec("
CREATE OR REPLACE VIEW sec_suspicious_ips AS
SELECT
ip_address,
COUNT(*) as total_attempts,
COUNT(DISTINCT username) as unique_usernames,
MIN(attempt_time) as first_attempt,
MAX(attempt_time) as last_attempt,
TIMESTAMPDIFF(MINUTE, MIN(attempt_time), MAX(attempt_time)) as timespan_minutes
FROM sec_failed_login_attempts
WHERE attempt_time >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
GROUP BY ip_address
HAVING total_attempts >= 5
OR unique_usernames >= 3
ORDER BY total_attempts DESC
");
echo " ✓ Vue sec_suspicious_ips créée\n";
// 6. Créer les index additionnels
echo "6. Création des index additionnels...\n";
// Index pour les requêtes fréquentes
$db->exec("CREATE INDEX IF NOT EXISTS idx_sec_metrics_recent ON sec_performance_metrics(created_at DESC, endpoint)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_sec_alerts_recent ON sec_alerts(last_seen DESC, alert_level)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_sec_failed_recent ON sec_failed_login_attempts(attempt_time DESC, ip_address)");
echo " ✓ Index créés\n";
// 7. Créer la procédure de nettoyage
echo "7. Création de la procédure de nettoyage...\n";
$db->exec("DROP PROCEDURE IF EXISTS sec_cleanup_old_data");
$db->exec("
CREATE PROCEDURE sec_cleanup_old_data(IN days_to_keep INT)
BEGIN
-- Nettoyer les métriques de performance
DELETE FROM sec_performance_metrics
WHERE created_at < DATE_SUB(NOW(), INTERVAL days_to_keep DAY);
-- Nettoyer les tentatives de login
DELETE FROM sec_failed_login_attempts
WHERE attempt_time < DATE_SUB(NOW(), INTERVAL days_to_keep DAY);
-- Nettoyer les alertes résolues
DELETE FROM sec_alerts
WHERE resolved = 1
AND resolved_at < DATE_SUB(NOW(), INTERVAL days_to_keep DAY);
-- Retourner le nombre de lignes supprimées
SELECT ROW_COUNT() as deleted_rows;
END
");
echo " ✓ Procédure sec_cleanup_old_data créée\n";
// Réactiver les clés étrangères
$db->exec("SET FOREIGN_KEY_CHECKS = 1");
// 8. Vérifier que tout est créé
echo "\n8. Vérification finale...\n";
$tables = ['sec_alerts', 'sec_performance_metrics', 'sec_failed_login_attempts', 'sec_blocked_ips'];
$allOk = true;
foreach ($tables as $table) {
$stmt = $db->query("SELECT COUNT(*) as count FROM $table");
if ($stmt) {
$result = $stmt->fetch(PDO::FETCH_ASSOC);
echo " ✓ Table $table : OK ({$result['count']} enregistrements)\n";
} else {
echo " ✗ Table $table : ERREUR\n";
$allOk = false;
}
}
if ($allOk) {
echo "\n========================================\n";
echo "✅ TOUTES LES TABLES ONT ÉTÉ CRÉÉES AVEC SUCCÈS\n";
echo "========================================\n\n";
echo "Le système de sécurité est maintenant prêt à être utilisé.\n";
echo "Vous pouvez tester avec : php test_security.php\n\n";
} else {
echo "\n⚠️ Certaines tables n'ont pas pu être créées.\n";
echo "Vérifiez les erreurs ci-dessus.\n\n";
}
} catch (PDOException $e) {
echo "\n❌ ERREUR SQL : " . $e->getMessage() . "\n\n";
echo "Code d'erreur : " . $e->getCode() . "\n";
echo "Vérifiez les permissions et la configuration de la base de données.\n\n";
exit(1);
} catch (Exception $e) {
echo "\n❌ ERREUR : " . $e->getMessage() . "\n\n";
exit(1);
}

View File

@@ -0,0 +1,22 @@
-- Script de migration pour ajouter le champ chk_user_delete_pass
-- Ce champ permet aux administrateurs d'autoriser ou non leurs membres à supprimer des passages
-- Date : 2025-08-20
-- À exécuter sur DVA, REC et PROD
-- Ajouter le champ chk_user_delete_pass s'il n'existe pas
ALTER TABLE `entites`
ADD COLUMN IF NOT EXISTS `chk_user_delete_pass` tinyint(1) unsigned NOT NULL DEFAULT 0
COMMENT 'Autoriser les membres à supprimer des passages (1) ou non (0)'
AFTER `chk_username_manuel`;
-- Vérifier l'ajout
SELECT
COLUMN_NAME,
DATA_TYPE,
COLUMN_DEFAULT,
IS_NULLABLE,
COLUMN_COMMENT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'entites'
AND COLUMN_NAME = 'chk_user_delete_pass';

View File

@@ -0,0 +1,22 @@
-- Migration pour ajouter les champs manquants à la table email_queue
-- À exécuter sur DVA, REC et PROD
-- Ajouter le champ sent_at s'il n'existe pas
ALTER TABLE `email_queue`
ADD COLUMN IF NOT EXISTS `sent_at` TIMESTAMP NULL DEFAULT NULL
COMMENT 'Date/heure d\'envoi effectif de l\'email'
AFTER `status`;
-- Ajouter le champ error_message s'il n'existe pas
ALTER TABLE `email_queue`
ADD COLUMN IF NOT EXISTS `error_message` TEXT NULL DEFAULT NULL
COMMENT 'Message d\'erreur en cas d\'échec'
AFTER `attempts`;
-- Ajouter un index sur le status pour optimiser les requêtes
ALTER TABLE `email_queue`
ADD INDEX IF NOT EXISTS `idx_status_attempts` (`status`, `attempts`);
-- Ajouter un index sur sent_at pour le nettoyage automatique
ALTER TABLE `email_queue`
ADD INDEX IF NOT EXISTS `idx_sent_at` (`sent_at`);

View File

@@ -0,0 +1,157 @@
-- Script de création des tables pour le module Chat
-- Date : 2025-01-17
-- Version : 1.0
-- Tables préfixées "chat_" pour le module de messagerie
-- ============================================
-- SUPPRESSION DES TABLES EXISTANTES
-- ============================================
-- Attention : Ceci supprimera toutes les données existantes du chat !
-- Désactiver temporairement les contraintes de clés étrangères
SET FOREIGN_KEY_CHECKS = 0;
-- Supprimer la vue si elle existe
DROP VIEW IF EXISTS chat_rooms_with_last_message;
-- Supprimer les tables dans l'ordre inverse des dépendances
DROP TABLE IF EXISTS `chat_read_receipts`;
DROP TABLE IF EXISTS `chat_participants`;
DROP TABLE IF EXISTS `chat_messages`;
DROP TABLE IF EXISTS `chat_rooms`;
-- Supprimer toute autre table commençant par chat_ qui pourrait exister
-- Note : Cette procédure supprime dynamiquement toutes les tables avec le préfixe chat_
DELIMITER $$
DROP PROCEDURE IF EXISTS drop_chat_tables$$
CREATE PROCEDURE drop_chat_tables()
BEGIN
DECLARE done INT DEFAULT FALSE;
DECLARE tableName VARCHAR(255);
DECLARE cur CURSOR FOR
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name LIKE 'chat_%';
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
OPEN cur;
read_loop: LOOP
FETCH cur INTO tableName;
IF done THEN
LEAVE read_loop;
END IF;
SET @sql = CONCAT('DROP TABLE IF EXISTS `', tableName, '`');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE cur;
END$$
DELIMITER ;
-- Exécuter la procédure
CALL drop_chat_tables();
-- Supprimer la procédure après utilisation
DROP PROCEDURE IF EXISTS drop_chat_tables;
-- Réactiver les contraintes de clés étrangères
SET FOREIGN_KEY_CHECKS = 1;
-- ============================================
-- CRÉATION DES NOUVELLES TABLES
-- ============================================
-- Table des salles de conversation
CREATE TABLE IF NOT EXISTS `chat_rooms` (
`id` VARCHAR(36) NOT NULL PRIMARY KEY COMMENT 'UUID de la salle',
`title` VARCHAR(255) DEFAULT NULL COMMENT 'Titre de la conversation',
`type` ENUM('private', 'group', 'broadcast') NOT NULL DEFAULT 'private' COMMENT 'Type de conversation',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date de création',
`created_by` INT(11) UNSIGNED NOT NULL COMMENT 'ID du créateur',
`updated_at` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT 'Dernière modification',
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Conversation active',
KEY `idx_created_by` (`created_by`),
KEY `idx_type` (`type`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `fk_chat_rooms_creator` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Salles de conversation';
-- Table des messages
CREATE TABLE IF NOT EXISTS `chat_messages` (
`id` VARCHAR(36) NOT NULL PRIMARY KEY COMMENT 'UUID du message',
`room_id` VARCHAR(36) NOT NULL COMMENT 'ID de la salle',
`content` TEXT NOT NULL COMMENT 'Contenu du message',
`sender_id` INT(11) UNSIGNED NOT NULL COMMENT 'ID de l\'expéditeur',
`sent_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date d\'envoi',
`edited_at` TIMESTAMP NULL DEFAULT NULL COMMENT 'Date de modification',
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Message supprimé',
KEY `idx_room_id` (`room_id`),
KEY `idx_sender_id` (`sender_id`),
KEY `idx_sent_at` (`sent_at`),
KEY `idx_room_sent` (`room_id`, `sent_at`),
CONSTRAINT `fk_chat_messages_room` FOREIGN KEY (`room_id`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_chat_messages_sender` FOREIGN KEY (`sender_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Messages du chat';
-- Table des participants
CREATE TABLE IF NOT EXISTS `chat_participants` (
`room_id` VARCHAR(36) NOT NULL COMMENT 'ID de la salle',
`user_id` INT(11) UNSIGNED NOT NULL COMMENT 'ID de l\'utilisateur',
`role` INT(11) DEFAULT NULL COMMENT 'Rôle de l\'utilisateur (fk_role)',
`entite_id` INT(11) UNSIGNED DEFAULT NULL COMMENT 'ID de l\'entité',
`joined_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date d\'adhésion',
`left_at` TIMESTAMP NULL DEFAULT NULL COMMENT 'Date de départ',
`is_admin` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Admin de la salle',
`last_read_at` TIMESTAMP NULL DEFAULT NULL COMMENT 'Dernière lecture',
PRIMARY KEY (`room_id`, `user_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_entite_id` (`entite_id`),
KEY `idx_joined_at` (`joined_at`),
CONSTRAINT `fk_chat_participants_room` FOREIGN KEY (`room_id`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_chat_participants_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_chat_participants_entite` FOREIGN KEY (`entite_id`) REFERENCES `entites` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Participants aux conversations';
-- Table des accusés de lecture
CREATE TABLE IF NOT EXISTS `chat_read_receipts` (
`message_id` VARCHAR(36) NOT NULL COMMENT 'ID du message',
`user_id` INT(11) UNSIGNED NOT NULL COMMENT 'ID de l\'utilisateur',
`read_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date de lecture',
PRIMARY KEY (`message_id`, `user_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_read_at` (`read_at`),
CONSTRAINT `fk_chat_read_message` FOREIGN KEY (`message_id`) REFERENCES `chat_messages` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_chat_read_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Accusés de lecture';
-- Index supplémentaires pour les performances
CREATE INDEX idx_chat_active_rooms ON chat_rooms(is_active, created_at DESC);
CREATE INDEX idx_chat_user_rooms ON chat_participants(user_id, left_at, joined_at DESC);
CREATE INDEX idx_chat_unread ON chat_messages(room_id, sent_at) WHERE id NOT IN (SELECT message_id FROM chat_read_receipts);
-- Vue pour faciliter la récupération des conversations avec le dernier message
CREATE OR REPLACE VIEW chat_rooms_with_last_message AS
SELECT
r.*,
m.content as last_message_content,
m.sender_id as last_message_sender,
m.sent_at as last_message_at,
u.encrypted_name as last_message_sender_name
FROM chat_rooms r
LEFT JOIN (
SELECT m1.*
FROM chat_messages m1
INNER JOIN (
SELECT room_id, MAX(sent_at) as max_sent_at
FROM chat_messages
WHERE is_deleted = 0
GROUP BY room_id
) m2 ON m1.room_id = m2.room_id AND m1.sent_at = m2.max_sent_at
) m ON r.id = m.room_id
LEFT JOIN users u ON m.sender_id = u.id
WHERE r.is_active = 1;

View File

@@ -0,0 +1,123 @@
-- Script de création des tables pour le module Security & Monitoring
-- Date : 2025-01-17
-- Version : 1.0
-- Préfixe : sec_ (security)
-- ============================================
-- SUPPRESSION DES TABLES EXISTANTES (OPTIONNEL)
-- ============================================
-- Décommenter si vous voulez recréer les tables
-- DROP TABLE IF EXISTS `sec_blocked_ips`;
-- DROP TABLE IF EXISTS `sec_failed_login_attempts`;
-- DROP TABLE IF EXISTS `sec_performance_metrics`;
-- DROP TABLE IF EXISTS `sec_alerts`;
-- ============================================
-- CRÉATION DES TABLES DE SÉCURITÉ ET MONITORING
-- ============================================
-- Table principale des alertes de sécurité
CREATE TABLE IF NOT EXISTS `sec_alerts` (
`id` INT(11) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`alert_type` VARCHAR(50) NOT NULL COMMENT 'Type d\'alerte (BRUTE_FORCE, SQL_ERROR, etc.)',
`alert_level` ENUM('INFO', 'WARNING', 'ERROR', 'CRITICAL', 'SECURITY') NOT NULL DEFAULT 'INFO',
`ip_address` VARCHAR(45) DEFAULT NULL COMMENT 'Adresse IP source',
`user_id` INT(11) UNSIGNED DEFAULT NULL COMMENT 'ID utilisateur si connecté',
`username` VARCHAR(255) DEFAULT NULL COMMENT 'Username tenté ou utilisé',
`endpoint` VARCHAR(255) DEFAULT NULL COMMENT 'Endpoint API concerné',
`method` VARCHAR(10) DEFAULT NULL COMMENT 'Méthode HTTP',
`details` JSON DEFAULT NULL COMMENT 'Détails additionnels en JSON',
`occurrences` INT(11) DEFAULT 1 COMMENT 'Nombre d\'occurrences',
`first_seen` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_seen` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`email_sent` TINYINT(1) DEFAULT 0 COMMENT 'Email d\'alerte envoyé',
`email_sent_at` TIMESTAMP NULL DEFAULT NULL,
`resolved` TINYINT(1) DEFAULT 0 COMMENT 'Alerte résolue',
`resolved_at` TIMESTAMP NULL DEFAULT NULL,
`resolved_by` INT(11) UNSIGNED DEFAULT NULL COMMENT 'ID admin qui a résolu',
`notes` TEXT DEFAULT NULL COMMENT 'Notes de résolution',
KEY `idx_ip` (`ip_address`),
KEY `idx_type_time` (`alert_type`, `last_seen`),
KEY `idx_level` (`alert_level`),
KEY `idx_resolved` (`resolved`),
KEY `idx_user` (`user_id`),
CONSTRAINT `fk_sec_alerts_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_sec_alerts_resolver` FOREIGN KEY (`resolved_by`) REFERENCES `users` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Alertes de sécurité et monitoring';
-- Table des métriques de performance
CREATE TABLE IF NOT EXISTS `sec_performance_metrics` (
`id` BIGINT(20) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`endpoint` VARCHAR(255) NOT NULL COMMENT 'Endpoint API',
`method` VARCHAR(10) NOT NULL COMMENT 'Méthode HTTP',
`response_time_ms` INT(11) NOT NULL COMMENT 'Temps de réponse total en ms',
`db_time_ms` INT(11) DEFAULT 0 COMMENT 'Temps cumulé des requêtes DB en ms',
`db_queries_count` INT(11) DEFAULT 0 COMMENT 'Nombre de requêtes DB',
`memory_peak_mb` FLOAT DEFAULT NULL COMMENT 'Pic mémoire en MB',
`memory_start_mb` FLOAT DEFAULT NULL COMMENT 'Mémoire au début en MB',
`http_status` INT(11) DEFAULT NULL COMMENT 'Code HTTP de réponse',
`user_id` INT(11) UNSIGNED DEFAULT NULL COMMENT 'ID utilisateur si connecté',
`ip_address` VARCHAR(45) DEFAULT NULL COMMENT 'Adresse IP',
`user_agent` TEXT DEFAULT NULL COMMENT 'User Agent complet',
`request_size` INT(11) DEFAULT NULL COMMENT 'Taille de la requête en octets',
`response_size` INT(11) DEFAULT NULL COMMENT 'Taille de la réponse en octets',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_endpoint_time` (`endpoint`, `created_at`),
KEY `idx_response_time` (`response_time_ms`),
KEY `idx_created` (`created_at`),
KEY `idx_status` (`http_status`),
KEY `idx_user` (`user_id`),
KEY `idx_date_endpoint` (`created_at`, `endpoint`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Métriques de performance des requêtes';
-- Table des tentatives de login échouées
CREATE TABLE IF NOT EXISTS `sec_failed_login_attempts` (
`id` INT(11) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`username` VARCHAR(255) DEFAULT NULL COMMENT 'Username tenté',
`encrypted_username` VARCHAR(255) DEFAULT NULL COMMENT 'Username chiffré si trouvé',
`ip_address` VARCHAR(45) NOT NULL COMMENT 'Adresse IP',
`user_agent` TEXT DEFAULT NULL COMMENT 'User Agent',
`attempt_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`error_type` VARCHAR(50) DEFAULT NULL COMMENT 'Type d\'erreur (invalid_password, user_not_found, etc.)',
`country_code` VARCHAR(2) DEFAULT NULL COMMENT 'Code pays de l\'IP (si géoloc activée)',
KEY `idx_ip_time` (`ip_address`, `attempt_time`),
KEY `idx_username` (`username`),
KEY `idx_encrypted_username` (`encrypted_username`),
KEY `idx_time` (`attempt_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Tentatives de connexion échouées';
-- Table des IPs bloquées
CREATE TABLE IF NOT EXISTS `sec_blocked_ips` (
`ip_address` VARCHAR(45) NOT NULL PRIMARY KEY COMMENT 'Adresse IP bloquée',
`reason` VARCHAR(255) NOT NULL COMMENT 'Raison du blocage',
`details` JSON DEFAULT NULL COMMENT 'Détails additionnels',
`blocked_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`blocked_until` TIMESTAMP NOT NULL COMMENT 'Bloqué jusqu\'à',
`blocked_by` VARCHAR(50) DEFAULT 'system' COMMENT 'Qui a bloqué (system ou user ID)',
`permanent` TINYINT(1) DEFAULT 0 COMMENT 'Blocage permanent',
`unblocked_at` TIMESTAMP NULL DEFAULT NULL COMMENT 'Date de déblocage effectif',
`unblocked_by` INT(11) UNSIGNED DEFAULT NULL COMMENT 'Qui a débloqué',
`block_count` INT(11) DEFAULT 1 COMMENT 'Nombre de fois bloquée',
KEY `idx_blocked_until` (`blocked_until`),
KEY `idx_permanent` (`permanent`),
CONSTRAINT `fk_sec_blocked_unblocked_by` FOREIGN KEY (`unblocked_by`) REFERENCES `users` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='IPs bloquées temporairement ou définitivement';
-- ============================================
-- INDEX ADDITIONNELS POUR PERFORMANCES
-- ============================================
-- Index pour requêtes de monitoring fréquentes
CREATE INDEX idx_sec_metrics_recent ON sec_performance_metrics(created_at DESC, endpoint);
CREATE INDEX idx_sec_alerts_recent ON sec_alerts(last_seen DESC, alert_level);
CREATE INDEX idx_sec_failed_recent ON sec_failed_login_attempts(attempt_time DESC, ip_address);
-- ============================================
-- FIN DU SCRIPT
-- ============================================
-- Note: La purge des données anciennes doit être gérée par:
-- 1. Un cron qui appelle l'endpoint API /api/admin/cleanup
-- 2. Ou directement via les méthodes cleanup des services PHP

View File

@@ -0,0 +1,35 @@
-- Migration pour supporter les usernames UTF-8 avec jusqu'à 30 caractères
-- Date : 2025-01-17
-- Objectif : Permettre des usernames plus souples (émojis, accents, espaces, etc.)
-- IMPORTANT : Faire une sauvegarde avant d'exécuter ce script !
-- mysqldump -u root -p geo_app > backup_geo_app_$(date +%Y%m%d).sql
-- Augmenter la taille de la colonne encrypted_user_name pour supporter
-- les usernames UTF-8 de 30 caractères maximum une fois chiffrés
-- Un username de 30 caractères UTF-8 peut faire jusqu'à 120 octets
-- Après chiffrement AES-256-CBC + base64, cela peut atteindre ~200 caractères
ALTER TABLE `users`
MODIFY COLUMN `encrypted_user_name` varchar(255) DEFAULT ''
COMMENT 'Username chiffré - Supporte UTF-8 30 caractères maximum';
-- Vérifier que la modification a bien été appliquée
SELECT
COLUMN_NAME,
COLUMN_TYPE,
CHARACTER_MAXIMUM_LENGTH,
COLUMN_COMMENT
FROM
INFORMATION_SCHEMA.COLUMNS
WHERE
TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'users'
AND COLUMN_NAME = 'encrypted_user_name';
-- Note : Les nouvelles règles de validation des usernames sont :
-- - Minimum : 8 caractères UTF-8
-- - Maximum : 30 caractères UTF-8
-- - Accepte TOUS les caractères (lettres, chiffres, espaces, émojis, accents, etc.)
-- - Trim automatique des espaces en début/fin
-- - Unicité vérifiée dans toute la base

View File

@@ -25,7 +25,8 @@ class AppConfig {
$this->currentHost = $_SERVER['SERVER_NAME'] ?? $_SERVER['HTTP_HOST'] ?? '';
// Récupérer les autres en-têtes pour une utilisation ultérieure si nécessaire
$this->headers = getallheaders();
// getallheaders() n'existe pas en CLI, donc on vérifie
$this->headers = function_exists('getallheaders') ? getallheaders() : [];
// Déterminer l'adresse IP du client
$this->clientIp = $this->getClientIpAddress();
@@ -63,8 +64,16 @@ class AppConfig {
'api_key' => '', // À remplir avec la clé API Mapbox
],
'stripe' => [
'api_key' => '', // À remplir avec la clé API Stripe
'webhook_secret' => '', // À remplir avec le secret du webhook Stripe
'public_key_test' => 'pk_test_51QwoVN00pblGEgsXkf8qlXmLGEpxDQcG0KLRpjrGLjJHd7AVZ4Iwd6ChgdjO0w0n3vRqwNCEW8KnHUe5eh3uIlkV00k07kCBmd', // À remplacer par votre clé publique TEST
'secret_key_test' => 'sk_test_51QwoVN00pblGEgsXnvqi8qfYpzHtesWWclvK3lzQjPNoHY0dIyOpJmxIkoLqsbmRMEUZpKS5MQ7iFDRlSqVyTo9c006yWetbsd', // À remplacer par votre clé secrète TEST
'public_key_live' => 'pk_live_XXXXXXXXXXXX', // À remplacer par votre clé publique LIVE
'secret_key_live' => 'sk_live_XXXXXXXXXXXX', // À remplacer par votre clé secrète LIVE
'webhook_secret_test' => 'whsec_test_XXXXXXXXXXXX', // À remplacer après création webhook TEST
'webhook_secret_live' => 'whsec_live_XXXXXXXXXXXX', // À remplacer après création webhook LIVE
'api_version' => '2024-06-20',
'application_fee_percent' => 2.5, // Commission de 2.5%
'application_fee_minimum' => 50, // Commission minimum 50 centimes
'mode' => 'test', // 'test' ou 'live'
],
'sms' => [
'provider' => 'ovh', // Comme mentionné dans le cahier des charges
@@ -131,11 +140,6 @@ class AppConfig {
],
// Vous pouvez activer des fonctionnalités de débogage en développement
'debug' => true,
// Configurez des endpoints de test pour Stripe, etc.
'stripe' => [
'api_key' => 'pk_test_...', // Clé de test Stripe
'webhook_secret' => 'whsec_test_...', // Secret de test
],
]);
}

File diff suppressed because it is too large Load Diff

View File

@@ -57,8 +57,9 @@ class EntiteController {
ville,
fk_type,
created_at,
chk_active
) VALUES (?, ?, ?, 1, NOW(), 1)
chk_active,
chk_user_delete_pass
) VALUES (?, ?, ?, 1, NOW(), 1, 0)
');
$stmt->execute([
@@ -109,7 +110,7 @@ class EntiteController {
public function getEntiteById(int $id): array|false {
try {
$stmt = $this->db->prepare('
SELECT id, encrypted_name, code_postal, ville, fk_type, chk_active
SELECT id, encrypted_name, code_postal, ville, fk_type, chk_active, chk_user_delete_pass
FROM entites
WHERE id = ? AND chk_active = 1
');
@@ -146,7 +147,7 @@ class EntiteController {
public function getEntiteByPostalCode(string $postalCode): array|false {
try {
$stmt = $this->db->prepare('
SELECT id, encrypted_name, code_postal, ville, fk_type, chk_active
SELECT id, encrypted_name, code_postal, ville, fk_type, chk_active, chk_user_delete_pass
FROM entites
WHERE code_postal = ? AND chk_active = 1
');
@@ -247,7 +248,7 @@ class EntiteController {
public function getEntites(): void {
try {
$stmt = $this->db->prepare('
SELECT id, encrypted_name, code_postal, ville, fk_type, chk_active
SELECT id, encrypted_name, code_postal, ville, fk_type, chk_active, chk_user_delete_pass
FROM entites
WHERE chk_active = 1
ORDER BY code_postal ASC
@@ -587,6 +588,11 @@ class EntiteController {
$updateFields[] = 'chk_username_manuel = ?';
$params[] = $data['chk_username_manuel'] ? 1 : 0;
}
if (isset($data['chk_user_delete_pass'])) {
$updateFields[] = 'chk_user_delete_pass = ?';
$params[] = $data['chk_user_delete_pass'] ? 1 : 0;
}
}
// Si aucun champ à mettre à jour, retourner une erreur
@@ -728,7 +734,7 @@ class EntiteController {
// Créer le dossier de destination
require_once __DIR__ . '/../Services/FileService.php';
$fileService = new \FileService();
$uploadPath = "/entites/{$entiteId}/logo";
$uploadPath = "/{$entiteId}/logo";
$fullPath = $fileService->createDirectory($entiteId, $uploadPath);
// Nom du fichier final

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
@@ -520,7 +533,7 @@ class LoginController {
e.fk_region, r.libelle AS lib_region, e.fk_type, e.encrypted_phone as phone, e.encrypted_mobile as mobile,
e.encrypted_email as email, e.gps_lat, e.gps_lng,
e.encrypted_stripe_id as stripe_id, e.chk_demo, e.chk_mdp_manuel, e.chk_username_manuel,
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe, e.chk_user_delete_pass
FROM entites e
LEFT JOIN x_regions r ON e.fk_region = r.id
WHERE e.id = ? AND e.chk_active = 1'
@@ -534,7 +547,7 @@ class LoginController {
e.fk_region, r.libelle AS lib_region, e.fk_type, e.encrypted_phone as phone, e.encrypted_mobile as mobile,
e.encrypted_email as email, e.gps_lat, e.gps_lng,
e.encrypted_stripe_id as stripe_id, e.chk_demo, e.chk_mdp_manuel, e.chk_username_manuel,
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe, e.chk_user_delete_pass
FROM entites e
LEFT JOIN x_regions r ON e.fk_region = r.id
WHERE e.id != 1 AND e.chk_active = 1'
@@ -587,7 +600,7 @@ class LoginController {
e.fk_region, r.libelle AS lib_region, e.fk_type, e.encrypted_phone as phone, e.encrypted_mobile as mobile,
e.encrypted_email as email, e.gps_lat, e.gps_lng,
e.encrypted_stripe_id as stripe_id, e.chk_demo, e.chk_mdp_manuel, e.chk_username_manuel,
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe, e.chk_user_delete_pass
FROM entites e
LEFT JOIN x_regions r ON e.fk_region = r.id
WHERE e.fk_type = 1 AND e.chk_active = 1'
@@ -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) {
@@ -819,16 +914,16 @@ class LoginController {
// Chiffrement de l'email pour la recherche
$encryptedEmail = ApiService::encryptSearchableData($email);
// Recherche de l'utilisateur
// Recherche de TOUS les utilisateurs avec cet email (actifs ou non)
$stmt = $this->db->prepare('
SELECT id, encrypted_name, encrypted_user_name, chk_active
FROM users
WHERE encrypted_email = ?
');
$stmt->execute([$encryptedEmail]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (!$user) {
if (empty($users)) {
Response::json([
'status' => 'error',
'message' => 'Aucun compte trouvé avec cet email'
@@ -836,54 +931,74 @@ class LoginController {
return;
}
if ($user['chk_active'] == 0) {
Response::json([
'status' => 'error',
'message' => 'Ce compte est désactivé. Contactez l\'administrateur.'
], 403);
return;
}
// Déchiffrement du nom et du username
$name = ApiService::decryptData($user['encrypted_name']);
$username = ApiService::decryptSearchableData($user['encrypted_user_name']);
// Génération d'un nouveau mot de passe
// Génération d'un nouveau mot de passe unique pour tous les comptes
$newPassword = ApiService::generateSecurePassword();
$passwordHash = password_hash($newPassword, PASSWORD_DEFAULT);
// Mise à jour du mot de passe
// Mise à jour du mot de passe pour TOUS les comptes avec cet email
$updateStmt = $this->db->prepare('
UPDATE users
SET user_pass_hash = ?, updated_at = NOW()
WHERE id = ?
WHERE encrypted_email = ?
');
$updateStmt->execute([$passwordHash, $user['id']]);
$updateStmt->execute([$passwordHash, $encryptedEmail]);
// Récupération du nombre de comptes mis à jour
$updatedCount = $updateStmt->rowCount();
// Collecte des usernames et du premier nom pour l'email
$usernames = [];
$firstName = '';
foreach ($users as $user) {
$username = ApiService::decryptSearchableData($user['encrypted_user_name']);
if ($username) {
$usernames[] = $username;
}
// Utiliser le premier nom trouvé pour personnaliser l'email
if (empty($firstName) && !empty($user['encrypted_name'])) {
$firstName = ApiService::decryptData($user['encrypted_name']);
}
}
// Si aucun nom n'a été trouvé, utiliser "Utilisateur"
if (empty($firstName)) {
$firstName = 'Utilisateur';
}
// Envoi d'un seul email avec le nouveau mot de passe et la liste des comptes affectés
$emailData = [
'username' => implode(', ', $usernames), // Liste tous les usernames concernés
'password' => $newPassword
];
// Envoi de l'email avec le nouveau mot de passe
$emailSent = ApiService::sendEmail(
$email,
$name,
$firstName,
'lostpwd',
['username' => $username, 'password' => $newPassword]
$emailData
);
if ($emailSent) {
LogService::log('Réinitialisation mot de passe GeoSector réussie', [
'level' => 'info',
'userId' => $user['id'],
'email' => $email
'email' => $email,
'comptes_modifies' => $updatedCount,
'usernames' => $usernames
]);
$message = $updatedCount > 1
? sprintf('Un nouveau mot de passe a été envoyé pour les %d comptes associés à votre adresse email', $updatedCount)
: 'Un nouveau mot de passe a été envoyé à votre adresse email';
Response::json([
'status' => 'success',
'message' => 'Un nouveau mot de passe a été envoyé à votre adresse email'
'message' => $message
]);
} else {
LogService::log('Échec envoi email réinitialisation mot de passe GeoSector', [
'level' => 'error',
'userId' => $user['id'],
'email' => $email
'email' => $email,
'comptes_modifies' => $updatedCount
]);
Response::json([
@@ -999,7 +1114,9 @@ class LoginController {
}
// 4. Vérification de l'existence de l'email
// DÉSACTIVÉ : Le client souhaite permettre plusieurs comptes avec le même email
$encryptedEmail = ApiService::encryptSearchableData($email);
/*
$checkStmt = $this->db->prepare('SELECT id FROM users WHERE encrypted_email = ?');
$checkStmt->execute([$encryptedEmail]);
if ($checkStmt->fetch()) {
@@ -1009,6 +1126,7 @@ class LoginController {
], 409);
return;
}
*/
// 5. Vérification de l'existence du code postal dans la table entites
$checkPostalStmt = $this->db->prepare('SELECT id FROM entites WHERE code_postal = ?');

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,11 +552,58 @@ class PassageController {
'operationId' => $operationId
]);
// Envoyer la réponse immédiatement pour éviter les timeouts
Response::json([
'status' => 'success',
'message' => 'Passage créé avec succès',
'passage_id' => $passageId
'passage_id' => $passageId,
'receipt_generated' => false // On va générer le reçu en arrière-plan
], 201);
// Flush la sortie pour s'assurer que la réponse est envoyée
if (ob_get_level()) {
ob_end_flush();
}
flush();
// Fermer la connexion HTTP mais continuer le traitement
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
// Générer automatiquement un reçu si c'est un don (fk_type = 1) avec email valide
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
]);
}
}
}
return; // Fin de la méthode, éviter d'exécuter le code après
} catch (Exception $e) {
LogService::log('Erreur lors de la création du passage', [
'level' => 'error',
@@ -705,10 +753,65 @@ class PassageController {
'passageId' => $passageId
]);
// Envoyer la réponse immédiatement pour éviter les timeouts
Response::json([
'status' => 'success',
'message' => 'Passage mis à jour avec succès'
'message' => 'Passage mis à jour avec succès',
'receipt_generated' => false // On va générer le reçu en arrière-plan
], 200);
// Flush la sortie pour s'assurer que la réponse est envoyée
if (ob_get_level()) {
ob_end_flush();
}
flush();
// Fermer la connexion HTTP mais continuer le traitement
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
// Maintenant générer le reçu en arrière-plan après avoir envoyé la réponse
try {
// 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
$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
]);
}
return; // Fin de la méthode, éviter d'exécuter le code après
} catch (Exception $e) {
LogService::log('Erreur lors de la mise à jour du passage', [
'level' => 'error',
@@ -740,8 +843,47 @@ class PassageController {
$passageId = (int)$id;
// Récupérer le rôle de l'utilisateur
$stmt = $this->db->prepare('SELECT fk_role, fk_entite FROM users WHERE id = ?');
$stmt->execute([$userId]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
Response::json([
'status' => 'error',
'message' => 'Utilisateur non trouvé'
], 404);
return;
}
$userRole = (int)$user['fk_role'];
$entiteId = (int)$user['fk_entite'];
// Si l'utilisateur est un membre (fk_role = 1), vérifier les permissions de l'entité
if ($userRole === 1) {
$stmt = $this->db->prepare('SELECT chk_user_delete_pass FROM entites WHERE id = ?');
$stmt->execute([$entiteId]);
$entite = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$entite || (int)$entite['chk_user_delete_pass'] !== 1) {
LogService::log('Tentative de suppression de passage non autorisée', [
'level' => 'warning',
'userId' => $userId,
'userRole' => $userRole,
'entiteId' => $entiteId,
'passageId' => $passageId,
'chk_user_delete_pass' => $entite ? $entite['chk_user_delete_pass'] : null
]);
Response::json([
'status' => 'error',
'message' => 'Vous n\'avez pas l\'autorisation de supprimer des passages'
], 403);
return;
}
}
// Vérifier que le passage existe et appartient à l'entité de l'utilisateur
$entiteId = $this->getUserEntiteId($userId);
if (!$entiteId) {
Response::json([
'status' => 'error',
@@ -800,4 +942,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,210 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
require_once __DIR__ . '/../Services/PasswordSecurityService.php';
require_once __DIR__ . '/../Services/LogService.php';
use Request;
use Response;
use LogService;
use App\Services\PasswordSecurityService;
/**
* Contrôleur pour la gestion de la sécurité des mots de passe
* Fournit des endpoints pour vérifier la force et la compromission des mots de passe
*/
class PasswordController {
/**
* Vérifie la force d'un mot de passe et s'il a été compromis
* Endpoint utilisable sans authentification pour le formulaire d'inscription
*
* POST /api/password/check
*/
public function checkStrength(): void {
try {
$data = Request::getJson();
if (!isset($data['password']) || empty($data['password'])) {
Response::json([
'status' => 'error',
'message' => 'Mot de passe requis'
], 400);
return;
}
$password = $data['password'];
$checkCompromised = $data['check_compromised'] ?? true;
// Validation du mot de passe
$validation = PasswordSecurityService::validatePassword($password, $checkCompromised);
// Estimation de la force
$strength = PasswordSecurityService::estimatePasswordStrength($password);
// Vérification spécifique de compromission si demandée
$compromisedInfo = null;
if ($checkCompromised) {
$compromisedCheck = PasswordSecurityService::checkPasswordCompromised($password);
if ($compromisedCheck['compromised']) {
$compromisedInfo = [
'compromised' => true,
'occurrences' => $compromisedCheck['occurrences'],
'message' => sprintf(
'Ce mot de passe a été trouvé %s fois dans des fuites de données',
number_format($compromisedCheck['occurrences'], 0, ',', ' ')
)
];
}
}
Response::json([
'status' => 'success',
'valid' => $validation['valid'],
'errors' => $validation['errors'],
'warnings' => $validation['warnings'],
'strength' => $strength,
'compromised' => $compromisedInfo
]);
} catch (\Exception $e) {
LogService::log('Erreur lors de la vérification du mot de passe', [
'level' => 'error',
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la vérification du mot de passe'
], 500);
}
}
/**
* Génère un mot de passe sécurisé aléatoire
* Endpoint nécessitant une authentification
*
* GET /api/password/generate
*/
public function generate(): void {
try {
// Vérifier l'authentification
if (!isset($_SESSION['user_id'])) {
Response::json([
'status' => 'error',
'message' => 'Authentification requise'
], 401);
return;
}
// Récupérer les paramètres optionnels
$length = isset($_GET['length']) ? intval($_GET['length']) : 14;
$length = max(12, min(20, $length)); // Limiter entre 12 et 20
// Générer un mot de passe non compromis
$password = PasswordSecurityService::generateSecurePassword($length);
if ($password === null) {
// En cas d'échec, utiliser la méthode classique
$password = $this->generateFallbackPassword($length);
}
// Calculer la force du mot de passe généré
$strength = PasswordSecurityService::estimatePasswordStrength($password);
Response::json([
'status' => 'success',
'password' => $password,
'length' => strlen($password),
'strength' => $strength
]);
} catch (\Exception $e) {
LogService::log('Erreur lors de la génération du mot de passe', [
'level' => 'error',
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la génération du mot de passe'
], 500);
}
}
/**
* Vérifie uniquement si un mot de passe est compromis
* Endpoint rapide pour vérification en temps réel
*
* POST /api/password/compromised
*/
public function checkCompromised(): void {
try {
$data = Request::getJson();
if (!isset($data['password']) || empty($data['password'])) {
Response::json([
'status' => 'error',
'message' => 'Mot de passe requis'
], 400);
return;
}
$password = $data['password'];
// Vérification de compromission
$compromisedCheck = PasswordSecurityService::checkPasswordCompromised($password);
$response = [
'status' => 'success',
'compromised' => $compromisedCheck['compromised'],
'occurrences' => $compromisedCheck['occurrences']
];
if ($compromisedCheck['compromised']) {
$response['message'] = sprintf(
'Ce mot de passe a été trouvé %s fois dans des fuites de données',
number_format($compromisedCheck['occurrences'], 0, ',', ' ')
);
$response['recommendation'] = 'Il est fortement recommandé de choisir un autre mot de passe';
}
if ($compromisedCheck['error']) {
$response['warning'] = 'Impossible de vérifier complètement le mot de passe';
}
Response::json($response);
} catch (\Exception $e) {
LogService::log('Erreur lors de la vérification de compromission', [
'level' => 'error',
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la vérification'
], 500);
}
}
/**
* Génère un mot de passe de secours si le service principal échoue
*
* @param int $length Longueur du mot de passe
* @return string Le mot de passe généré
*/
private function generateFallbackPassword(int $length): string {
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;:,.<>?';
$password = '';
for ($i = 0; $i < $length; $i++) {
$password .= $chars[random_int(0, strlen($chars) - 1)];
}
return $password;
}
}

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

@@ -0,0 +1,547 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Controller;
use App\Services\StripeService;
use Session;
use Exception;
/**
* Controller principal pour les opérations Stripe
* Gère les comptes Connect, les paiements et Terminal
*/
class StripeController extends Controller {
private StripeService $stripeService;
public function __construct() {
parent::__construct();
$this->stripeService = StripeService::getInstance();
}
/**
* POST /api/stripe/accounts
* Créer un compte Stripe Connect pour une amicale
*/
public function createAccount(): void {
try {
$this->requireAuth();
// Vérifier le rôle de l'utilisateur (comme dans les autres controllers)
$userId = Session::getUserId();
$stmt = $this->db->prepare('SELECT fk_role FROM users WHERE id = ?');
$stmt->execute([$userId]);
$result = $stmt->fetch();
$userRole = $result ? (int)$result['fk_role'] : 0;
if ($userRole < 2) {
$this->sendError('Droits insuffisants - Admin amicale minimum requis', 403);
return;
}
$data = $this->getJsonInput();
$entiteId = $data['fk_entite'] ?? Session::getEntityId();
if (!$entiteId) {
$this->sendError('ID entité requis', 400);
return;
}
// Vérifier les droits sur cette entité
if (Session::getEntityId() != $entiteId && $userRole < 3) {
$this->sendError('Non autorisé pour cette entité', 403);
return;
}
$result = $this->stripeService->createConnectAccount($entiteId);
if ($result['success']) {
$this->sendSuccess($result);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur lors de la création du compte: ' . $e->getMessage());
}
}
/**
* POST /api/stripe/accounts/{accountId}/onboarding-link
* Générer un lien d'onboarding pour finaliser la configuration
*/
public function createOnboardingLink(string $accountId): void {
try {
$this->requireAuth();
// Vérifier le rôle de l'utilisateur
$userId = Session::getUserId();
$stmt = $this->db->prepare('SELECT fk_role FROM users WHERE id = ?');
$stmt->execute([$userId]);
$result = $stmt->fetch();
$userRole = $result ? (int)$result['fk_role'] : 0;
if ($userRole < 2) {
$this->sendError('Droits insuffisants', 403);
return;
}
$data = $this->getJsonInput();
$returnUrl = $data['return_url'] ?? '';
$refreshUrl = $data['refresh_url'] ?? '';
if (!$returnUrl || !$refreshUrl) {
$this->sendError('URLs de retour requises', 400);
return;
}
$result = $this->stripeService->createOnboardingLink($accountId, $returnUrl, $refreshUrl);
if ($result['success']) {
$this->sendSuccess(['url' => $result['url']]);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* POST /api/stripe/locations
* Créer une Location pour Terminal/Tap to Pay
*/
public function createLocation(): void {
try {
$this->requireAuth();
// Vérifier le rôle de l'utilisateur
$userId = Session::getUserId();
$stmt = $this->db->prepare('SELECT fk_role FROM users WHERE id = ?');
$stmt->execute([$userId]);
$result = $stmt->fetch();
$userRole = $result ? (int)$result['fk_role'] : 0;
if ($userRole < 2) {
$this->sendError('Droits insuffisants', 403);
return;
}
$data = $this->getJsonInput();
$entiteId = $data['fk_entite'] ?? Session::getEntityId();
$result = $this->stripeService->createLocation($entiteId);
if ($result['success']) {
$this->sendSuccess($result);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* POST /api/stripe/terminal/connection-token
* Créer un token de connexion pour Terminal/Tap to Pay
*/
public function createConnectionToken(): void {
try {
$this->requireAuth();
$entiteId = Session::getEntityId();
if (!$entiteId) {
$this->sendError('Entité non définie', 400);
return;
}
$result = $this->stripeService->createConnectionToken($entiteId);
if ($result['success']) {
$this->sendSuccess(['secret' => $result['secret']]);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* POST /api/stripe/payments/create-intent
* Créer une intention de paiement
*/
public function createPaymentIntent(): void {
try {
$this->requireAuth();
$data = $this->getJsonInput();
// Validation
$amount = $data['amount'] ?? 0;
if ($amount < 100) {
$this->sendError('Le montant minimum est de 1€ (100 centimes)', 400);
return;
}
if ($amount > 50000) {
$this->sendError('Le montant maximum est de 500€', 400);
return;
}
$params = [
'amount' => $amount,
'fk_entite' => $data['fk_entite'] ?? Session::getEntityId(),
'fk_user' => Session::getUserId(),
'metadata' => $data['metadata'] ?? []
];
$result = $this->stripeService->createPaymentIntent($params);
if ($result['success']) {
$this->sendSuccess([
'client_secret' => $result['client_secret'],
'payment_intent_id' => $result['payment_intent_id'],
'amount' => $result['amount'],
'application_fee' => $result['application_fee']
]);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* GET /api/stripe/payments/{paymentIntentId}
* Récupérer le statut d'un paiement
*/
public function getPaymentStatus(string $paymentIntentId): void {
try {
$this->requireAuth();
$stmt = $this->db->prepare(
"SELECT spi.*, e.nom as entite_nom, u.nom as user_nom, u.prenom as user_prenom
FROM stripe_payment_intents spi
LEFT JOIN entites e ON spi.fk_entite = e.id
LEFT JOIN users u ON spi.fk_user = u.id
WHERE spi.stripe_payment_intent_id = :pi_id"
);
$stmt->execute(['pi_id' => $paymentIntentId]);
$payment = $stmt->fetch();
if (!$payment) {
$this->sendError('Paiement non trouvé', 404);
return;
}
// Vérifier les droits
$userEntityId = Session::getEntityId();
$userId = Session::getUserId();
// Récupérer le rôle depuis la base de données
$stmt = $this->db->prepare('SELECT fk_role FROM users WHERE id = ?');
$stmt->execute([$userId]);
$result = $stmt->fetch();
$userRole = $result ? (int)$result['fk_role'] : 0;
if ($payment['fk_entite'] != $userEntityId &&
$payment['fk_user'] != $userId &&
$userRole < 3) {
$this->sendError('Non autorisé', 403);
return;
}
$this->sendSuccess([
'payment_intent_id' => $payment['stripe_payment_intent_id'],
'status' => $payment['status'],
'amount' => $payment['amount'],
'currency' => $payment['currency'],
'application_fee' => $payment['application_fee'],
'entite' => [
'id' => $payment['fk_entite'],
'nom' => $payment['entite_nom']
],
'user' => [
'id' => $payment['fk_user'],
'nom' => $payment['user_nom'],
'prenom' => $payment['user_prenom']
],
'created_at' => $payment['created_at']
]);
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* GET /api/stripe/accounts/{entityId}/status
* Vérifier le statut du compte Stripe d'une entité
*/
public function getAccountStatus(string $entityId): void {
try {
$this->requireAuth();
// Convertir l'entityId en int
$entityId = (int)$entityId;
// Vérifier les droits : admin de l'amicale ou super admin
$userEntityId = Session::getEntityId();
$userId = Session::getUserId();
// Récupérer le rôle depuis la base de données
$stmt = $this->db->prepare('SELECT fk_role FROM users WHERE id = ?');
$stmt->execute([$userId]);
$result = $stmt->fetch();
$userRole = $result ? (int)$result['fk_role'] : 0;
if ($entityId != $userEntityId && $userRole < 3) {
$this->sendError('Non autorisé', 403);
return;
}
$db = Database::getInstance();
// Récupérer le compte Stripe
$stmt = $db->prepare(
"SELECT sa.*, e.encrypted_name as entite_nom
FROM stripe_accounts sa
LEFT JOIN entites e ON sa.fk_entite = e.id
WHERE sa.fk_entite = :entity_id"
);
$stmt->execute(['entity_id' => $entityId]);
$account = $stmt->fetch();
if (!$account || !$account['stripe_account_id']) {
$this->sendSuccess([
'has_account' => false,
'account_id' => null,
'charges_enabled' => false,
'payouts_enabled' => false,
'onboarding_completed' => false
]);
return;
}
// Récupérer le statut depuis Stripe
$stripeService = StripeService::getInstance();
$stripeAccount = $stripeService->retrieveAccount($account['stripe_account_id']);
if (!$stripeAccount) {
$this->sendSuccess([
'has_account' => true,
'account_id' => $account['stripe_account_id'],
'charges_enabled' => false,
'payouts_enabled' => false,
'onboarding_completed' => false,
'error' => 'Compte non trouvé sur Stripe'
]);
return;
}
// Mettre à jour la base de données avec le statut actuel
$stmt = $db->prepare(
"UPDATE stripe_accounts
SET charges_enabled = :charges,
payouts_enabled = :payouts,
details_submitted = :details,
updated_at = NOW()
WHERE id = :id"
);
$stmt->execute([
'charges' => $stripeAccount->charges_enabled ? 1 : 0,
'payouts' => $stripeAccount->payouts_enabled ? 1 : 0,
'details' => $stripeAccount->details_submitted ? 1 : 0,
'id' => $account['id']
]);
$this->sendSuccess([
'has_account' => true,
'account_id' => $account['stripe_account_id'],
'charges_enabled' => $stripeAccount->charges_enabled,
'payouts_enabled' => $stripeAccount->payouts_enabled,
'onboarding_completed' => $stripeAccount->details_submitted,
'entite' => [
'id' => $entityId,
'nom' => $account['entite_nom']
]
]);
} catch (Exception $e) {
Logger::getInstance()->error('Erreur statut compte Stripe', [
'entity_id' => $entityId,
'error' => $e->getMessage()
]);
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* POST /api/stripe/devices/check-tap-to-pay
* Vérifier la compatibilité Tap to Pay d'un appareil
*/
public function checkTapToPayCapability(): void {
try {
$data = $this->getJsonInput();
$platform = $data['platform'] ?? '';
if ($platform === 'ios') {
// Pour iOS, on vérifie côté client (iPhone XS+ avec iOS 15.4+)
$this->sendSuccess([
'message' => 'Vérification iOS à faire côté client',
'requirements' => 'iPhone XS ou plus récent avec iOS 15.4+'
]);
return;
}
if ($platform === 'android') {
$manufacturer = $data['manufacturer'] ?? '';
$model = $data['model'] ?? '';
if (!$manufacturer || !$model) {
$this->sendError('Manufacturer et model requis pour Android', 400);
return;
}
$result = $this->stripeService->checkAndroidTapToPayCompatibility($manufacturer, $model);
if ($result['success']) {
$this->sendSuccess($result);
} else {
$this->sendError($result['message'], 400);
}
} else {
$this->sendError('Platform doit être ios ou android', 400);
}
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* GET /api/stripe/devices/certified-android
* Récupérer la liste des appareils Android certifiés
*/
public function getCertifiedAndroidDevices(): void {
try {
$result = $this->stripeService->getCertifiedAndroidDevices();
if ($result['success']) {
$this->sendSuccess(['devices' => $result['devices']]);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* GET /api/stripe/config
* Récupérer la configuration publique Stripe
*/
public function getPublicConfig(): void {
try {
$this->requireAuth();
$this->sendSuccess([
'public_key' => $this->stripeService->getPublicKey(),
'test_mode' => $this->stripeService->isTestMode()
]);
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* GET /api/stripe/stats
* Récupérer les statistiques de paiement
*/
public function getPaymentStats(): void {
try {
$this->requireAuth();
$entiteId = $_GET['fk_entite'] ?? Session::getEntityId();
$userId = $_GET['fk_user'] ?? null;
$dateFrom = $_GET['date_from'] ?? date('Y-m-01');
$dateTo = $_GET['date_to'] ?? date('Y-m-d');
// Vérifier les droits
// Récupérer le rôle pour vérifier les droits
$userId = Session::getUserId();
$stmt = $this->db->prepare('SELECT fk_role FROM users WHERE id = ?');
$stmt->execute([$userId]);
$result = $stmt->fetch();
$userRole = $result ? (int)$result['fk_role'] : 0;
if ($entiteId != Session::getEntityId() && $userRole < 3) {
$this->sendError('Non autorisé', 403);
return;
}
$query = "SELECT
COUNT(CASE WHEN status = 'succeeded' THEN 1 END) as total_ventes,
SUM(CASE WHEN status = 'succeeded' THEN amount ELSE 0 END) as total_montant,
SUM(CASE WHEN status = 'succeeded' THEN application_fee ELSE 0 END) as total_commissions,
DATE(created_at) as date_vente
FROM stripe_payment_intents
WHERE fk_entite = :entite_id
AND DATE(created_at) BETWEEN :date_from AND :date_to";
$params = [
'entite_id' => $entiteId,
'date_from' => $dateFrom,
'date_to' => $dateTo
];
if ($userId) {
$query .= " AND fk_user = :user_id";
$params['user_id'] = $userId;
}
$query .= " GROUP BY DATE(created_at) ORDER BY date_vente DESC";
$stmt = $this->db->prepare($query);
$stmt->execute($params);
$stats = $stmt->fetchAll();
// Calculer les totaux
$totals = [
'total_ventes' => 0,
'total_montant' => 0,
'total_commissions' => 0
];
foreach ($stats as $stat) {
$totals['total_ventes'] += $stat['total_ventes'];
$totals['total_montant'] += $stat['total_montant'];
$totals['total_commissions'] += $stat['total_commissions'];
}
$this->sendSuccess([
'stats' => $stats,
'totals' => $totals,
'period' => [
'from' => $dateFrom,
'to' => $dateTo
]
]);
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,335 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Controller;
use App\Services\StripeService;
use Stripe\Webhook;
use Stripe\Exception\SignatureVerificationException;
use AppConfig;
use Exception;
use PDO;
/**
* Controller pour gérer les webhooks Stripe
* Point d'entrée pour tous les événements Stripe
*/
class StripeWebhookController extends Controller {
private StripeService $stripeService;
private AppConfig $config;
public function __construct() {
parent::__construct();
$this->stripeService = StripeService::getInstance();
$this->config = AppConfig::getInstance();
}
/**
* POST /api/stripe/webhook
* Point d'entrée principal pour les webhooks Stripe
*/
public function handleWebhook(): void {
try {
// Récupérer le payload et la signature
$payload = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
if (empty($sigHeader)) {
http_response_code(400);
echo 'Missing Stripe signature';
exit;
}
// Récupérer le secret webhook selon le mode
$stripeConfig = $this->config->get('stripe');
$webhookSecret = $this->stripeService->isTestMode()
? $stripeConfig['webhook_secret_test']
: $stripeConfig['webhook_secret_live'];
if (empty($webhookSecret) || strpos($webhookSecret, 'XXXX') !== false) {
http_response_code(500);
echo 'Webhook secret not configured';
exit;
}
// Vérifier la signature
try {
$event = Webhook::constructEvent($payload, $sigHeader, $webhookSecret);
} catch (SignatureVerificationException $e) {
http_response_code(400);
echo 'Invalid signature';
exit;
}
// Vérifier si l'événement a déjà été traité (idempotence)
$stmt = $this->db->prepare(
"SELECT id FROM stripe_webhooks WHERE stripe_event_id = :event_id"
);
$stmt->execute(['event_id' => $event->id]);
if ($stmt->fetch()) {
// Événement déjà traité
http_response_code(200);
echo 'Event already processed';
exit;
}
// Enregistrer l'événement
$stmt = $this->db->prepare(
"INSERT INTO stripe_webhooks (stripe_event_id, event_type, livemode, payload, created_at)
VALUES (:event_id, :event_type, :livemode, :payload, NOW())"
);
$stmt->execute([
'event_id' => $event->id,
'event_type' => $event->type,
'livemode' => $event->livemode ? 1 : 0,
'payload' => $payload
]);
$webhookId = $this->db->lastInsertId();
// Traiter l'événement selon son type
try {
switch ($event->type) {
case 'account.updated':
$this->handleAccountUpdated($event->data->object);
break;
case 'account.application.authorized':
$this->handleAccountAuthorized($event->data->object);
break;
case 'payment_intent.succeeded':
$this->handlePaymentIntentSucceeded($event->data->object);
break;
case 'payment_intent.payment_failed':
$this->handlePaymentIntentFailed($event->data->object);
break;
case 'charge.dispute.created':
$this->handleChargeDisputeCreated($event->data->object);
break;
case 'terminal.reader.action_succeeded':
$this->handleTerminalReaderActionSucceeded($event->data->object);
break;
case 'terminal.reader.action_failed':
$this->handleTerminalReaderActionFailed($event->data->object);
break;
default:
// Événement non géré mais valide
error_log("Unhandled Stripe event type: {$event->type}");
}
// Marquer comme traité
$stmt = $this->db->prepare(
"UPDATE stripe_webhooks
SET processed = 1, processed_at = NOW()
WHERE id = :id"
);
$stmt->execute(['id' => $webhookId]);
} catch (Exception $e) {
// Enregistrer l'erreur
$stmt = $this->db->prepare(
"UPDATE stripe_webhooks
SET error_message = :error
WHERE id = :id"
);
$stmt->execute([
'error' => $e->getMessage(),
'id' => $webhookId
]);
throw $e;
}
// Réponse de succès
http_response_code(200);
echo 'Webhook handled';
} catch (Exception $e) {
error_log('Stripe webhook error: ' . $e->getMessage());
http_response_code(500);
echo 'Webhook handler error';
}
}
/**
* Gérer la mise à jour d'un compte Connect
*/
private function handleAccountUpdated($account): void {
$stmt = $this->db->prepare(
"UPDATE stripe_accounts
SET charges_enabled = :charges_enabled,
payouts_enabled = :payouts_enabled,
onboarding_completed = :onboarding_completed,
updated_at = NOW()
WHERE stripe_account_id = :account_id"
);
$stmt->execute([
'charges_enabled' => $account->charges_enabled ? 1 : 0,
'payouts_enabled' => $account->payouts_enabled ? 1 : 0,
'onboarding_completed' => ($account->charges_enabled && $account->payouts_enabled) ? 1 : 0,
'account_id' => $account->id
]);
// Log pour suivi
error_log("Account updated: {$account->id}, charges: {$account->charges_enabled}, payouts: {$account->payouts_enabled}");
}
/**
* Gérer l'autorisation d'un compte Connect
*/
private function handleAccountAuthorized($account): void {
// Similaire à account.updated mais spécifique à l'autorisation
$this->handleAccountUpdated($account);
// Potentiellement envoyer un email de confirmation
// TODO: Implémenter notification email
}
/**
* Gérer un paiement réussi
*/
private function handlePaymentIntentSucceeded($paymentIntent): void {
// Mettre à jour le statut en base
$stmt = $this->db->prepare(
"UPDATE stripe_payment_intents
SET status = :status, updated_at = NOW()
WHERE stripe_payment_intent_id = :pi_id"
);
$stmt->execute([
'status' => 'succeeded',
'pi_id' => $paymentIntent->id
]);
// Enregistrer dans l'historique
$stmt = $this->db->prepare(
"SELECT id FROM stripe_payment_intents WHERE stripe_payment_intent_id = :pi_id"
);
$stmt->execute(['pi_id' => $paymentIntent->id]);
$localPayment = $stmt->fetch();
if ($localPayment) {
$stmt = $this->db->prepare(
"INSERT INTO stripe_payment_history
(fk_payment_intent, event_type, event_data, created_at)
VALUES (:fk_pi, 'succeeded', :data, NOW())"
);
$stmt->execute([
'fk_pi' => $localPayment['id'],
'data' => json_encode([
'amount' => $paymentIntent->amount,
'currency' => $paymentIntent->currency,
'payment_method' => $paymentIntent->payment_method,
'charges' => $paymentIntent->charges->data
])
]);
}
// TODO: Envoyer un reçu par email
// TODO: Mettre à jour les statistiques en temps réel
error_log("Payment succeeded: {$paymentIntent->id} for {$paymentIntent->amount} {$paymentIntent->currency}");
}
/**
* Gérer un paiement échoué
*/
private function handlePaymentIntentFailed($paymentIntent): void {
// Mettre à jour le statut
$stmt = $this->db->prepare(
"UPDATE stripe_payment_intents
SET status = :status, updated_at = NOW()
WHERE stripe_payment_intent_id = :pi_id"
);
$stmt->execute([
'status' => 'failed',
'pi_id' => $paymentIntent->id
]);
// Enregistrer dans l'historique avec la raison de l'échec
$stmt = $this->db->prepare(
"SELECT id FROM stripe_payment_intents WHERE stripe_payment_intent_id = :pi_id"
);
$stmt->execute(['pi_id' => $paymentIntent->id]);
$localPayment = $stmt->fetch();
if ($localPayment) {
$stmt = $this->db->prepare(
"INSERT INTO stripe_payment_history
(fk_payment_intent, event_type, event_data, created_at)
VALUES (:fk_pi, 'failed', :data, NOW())"
);
$stmt->execute([
'fk_pi' => $localPayment['id'],
'data' => json_encode([
'error' => $paymentIntent->last_payment_error,
'cancellation_reason' => $paymentIntent->cancellation_reason
])
]);
}
error_log("Payment failed: {$paymentIntent->id}, reason: " . json_encode($paymentIntent->last_payment_error));
}
/**
* Gérer un litige (chargeback)
*/
private function handleChargeDisputeCreated($dispute): void {
// Trouver le paiement concerné
$chargeId = $dispute->charge;
// TODO: Implémenter la gestion des litiges
// - Notifier l'admin
// - Bloquer les fonds si nécessaire
// - Créer une tâche de suivi
error_log("Dispute created: {$dispute->id} for charge {$chargeId}, amount: {$dispute->amount}");
// Envoyer une alerte urgente
// TODO: Implémenter système d'alertes
}
/**
* Gérer une action réussie sur un Terminal reader
*/
private function handleTerminalReaderActionSucceeded($reader): void {
// Mettre à jour le statut du reader
$stmt = $this->db->prepare(
"UPDATE stripe_terminal_readers
SET status = :status, last_seen_at = NOW()
WHERE stripe_reader_id = :reader_id"
);
$stmt->execute([
'status' => 'online',
'reader_id' => $reader->id
]);
error_log("Terminal reader action succeeded: {$reader->id}");
}
/**
* Gérer une action échouée sur un Terminal reader
*/
private function handleTerminalReaderActionFailed($reader): void {
// Mettre à jour le statut du reader
$stmt = $this->db->prepare(
"UPDATE stripe_terminal_readers
SET status = :status, last_seen_at = NOW()
WHERE stripe_reader_id = :reader_id"
);
$stmt->execute([
'status' => 'error',
'reader_id' => $reader->id
]);
error_log("Terminal reader action failed: {$reader->id}");
}
}

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/PasswordSecurityService.php';
use PDO;
use PDOException;
@@ -16,6 +17,7 @@ use Response;
use Session;
use LogService;
use ApiService;
use App\Services\PasswordSecurityService;
class UserController {
private PDO $db;
@@ -212,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;
}
@@ -258,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;
}
@@ -270,6 +309,8 @@ class UserController {
$encryptedName = ApiService::encryptData($name);
// Vérification de l'existence de l'email
// DÉSACTIVÉ : Le client souhaite permettre plusieurs comptes avec le même email
/*
$checkStmt = $this->db->prepare('SELECT id FROM users WHERE encrypted_email = ?');
$checkStmt->execute([$encryptedEmail]);
if ($checkStmt->fetch()) {
@@ -279,26 +320,63 @@ class UserController {
], 409);
return;
}
*/
// Gestion du USERNAME selon chk_username_manuel
$encryptedUsername = '';
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;
}
@@ -311,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;
}
@@ -333,27 +412,49 @@ 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;
}
$password = $data['password'];
// Validation du mot de passe (minimum 8 caractères)
if (strlen($password) < 8) {
// Validation du mot de passe selon NIST SP 800-63B
$passwordValidation = PasswordSecurityService::validatePassword($password);
if (!$passwordValidation['valid']) {
Response::json([
'status' => 'error',
'message' => 'Le mot de passe doit contenir au moins 8 caractères'
'message' => 'Mot de passe invalide',
'errors' => $passwordValidation['errors'],
'warnings' => $passwordValidation['warnings']
], 400);
return;
}
// Si le mot de passe a des avertissements mais est valide, les logger
if (!empty($passwordValidation['warnings'])) {
LogService::log('Mot de passe manuel avec avertissements accepté lors de la création', [
'level' => 'warning',
'email' => $email,
'warnings' => $passwordValidation['warnings']
]);
}
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
} else {
// Génération automatique du mot de passe
// Génération automatique du mot de passe (déjà vérifié contre HIBP)
$password = ApiService::generateSecurePassword();
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
}
@@ -505,6 +606,9 @@ class UserController {
$email = trim(strtolower($data['email']));
$encryptedEmail = ApiService::encryptSearchableData($email);
// Vérification de l'unicité de l'email
// DÉSACTIVÉ : Le client souhaite permettre plusieurs comptes avec le même email
/*
$checkStmt = $this->db->prepare('SELECT id FROM users WHERE encrypted_email = ? AND id != ?');
$checkStmt->execute([$encryptedEmail, $id]);
if ($checkStmt->fetch()) {
@@ -514,6 +618,7 @@ class UserController {
], 409);
return;
}
*/
$updateFields[] = "encrypted_email = :encrypted_email";
$params['encrypted_email'] = $encryptedEmail;
@@ -556,13 +661,28 @@ class UserController {
// Mise à jour du mot de passe si fourni
if (isset($data['password']) && !empty($data['password'])) {
if (strlen($data['password']) < 8) {
// Validation du mot de passe selon NIST SP 800-63B
$passwordValidation = PasswordSecurityService::validatePassword($data['password']);
if (!$passwordValidation['valid']) {
Response::json([
'status' => 'error',
'message' => 'Le mot de passe doit contenir au moins 8 caractères'
'message' => 'Mot de passe invalide',
'errors' => $passwordValidation['errors'],
'warnings' => $passwordValidation['warnings']
], 400);
return;
}
// Si le mot de passe a des avertissements mais est valide, les logger
if (!empty($passwordValidation['warnings'])) {
LogService::log('Mot de passe avec avertissements accepté', [
'level' => 'warning',
'user_id' => $id,
'warnings' => $passwordValidation['warnings']
]);
}
$updateFields[] = "user_pass_hash = :password";
$params['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
}
@@ -890,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;

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Core;
use Database;
use Session;
use Response;
use Request;
use PDO;
/**
* Classe de base pour tous les controllers
* Fournit des méthodes communes pour l'authentification et les réponses
*/
abstract class Controller {
protected PDO $db;
public function __construct() {
$this->db = Database::getInstance();
}
/**
* Vérifier que l'utilisateur est authentifié
*/
protected function requireAuth(): void {
if (!Session::isAuthenticated()) {
$this->sendError('Non authentifié', 401);
exit;
}
}
/**
* Récupérer les données JSON de la requête
*/
protected function getJsonInput(): array {
return Request::getJson();
}
/**
* Envoyer une réponse de succès
*/
protected function sendSuccess($data = null, int $code = 200): void {
if ($data === null) {
Response::json(['status' => 'success'], $code);
} else {
Response::json($data, $code);
}
}
/**
* Envoyer une réponse d'erreur
*/
protected function sendError(string $message, int $code = 500): void {
Response::json([
'status' => 'error',
'message' => $message
], $code);
}
/**
* Valider qu'un tableau contient les clés requises
*/
protected function validateRequired(array $data, array $requiredFields): bool {
foreach ($requiredFields as $field) {
if (!isset($data[$field]) || $data[$field] === '') {
$this->sendError("Le champ '$field' est requis", 400);
return false;
}
}
return true;
}
/**
* Nettoyer et valider un ID
*/
protected function validateId($id): ?int {
if (!is_numeric($id) || $id <= 0) {
$this->sendError('ID invalide', 400);
return null;
}
return (int) $id;
}
}

View File

@@ -1,6 +1,11 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/MonitoredDatabase.php';
require_once __DIR__ . '/../Services/Security/AlertService.php';
use App\Services\Security\AlertService;
class Database {
private static ?PDO $instance = null;
private static array $config;
@@ -23,13 +28,22 @@ class Database {
PDO::ATTR_EMULATE_PREPARES => false,
];
self::$instance = new PDO(
// Utiliser MonitoredDatabase pour le monitoring
self::$instance = new MonitoredDatabase(
$dsn,
self::$config['username'],
self::$config['password'],
$options
);
} catch (PDOException $e) {
// Créer une alerte pour la connexion échouée
AlertService::trigger('DB_CONNECTION', [
'error' => $e->getMessage(),
'host' => self::$config['host'],
'database' => self::$config['name'],
'message' => 'Échec de connexion à la base de données'
], 'CRITICAL');
throw new RuntimeException("Database connection failed: " . $e->getMessage());
}
}

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../Services/Security/PerformanceMonitor.php';
require_once __DIR__ . '/../Services/Security/SecurityMonitor.php';
use App\Services\Security\PerformanceMonitor;
use App\Services\Security\SecurityMonitor;
/**
* Classe PDO étendue avec monitoring de sécurité et performance
*/
class MonitoredDatabase extends PDO {
/**
* Préparer une requête avec monitoring
*/
public function prepare($statement, $options = []): PDOStatement|false {
// Démarrer le chronométrage
PerformanceMonitor::startDbQuery($statement);
try {
$stmt = parent::prepare($statement, $options);
// Retourner un statement monitored
if ($stmt !== false) {
return new MonitoredStatement($stmt, $statement);
}
return false;
} catch (PDOException $e) {
// Terminer le chronométrage
PerformanceMonitor::endDbQuery();
// Analyser l'erreur SQL
SecurityMonitor::analyzeSQLError($e, $statement);
// Re-lancer l'exception
throw $e;
}
}
/**
* Exécuter une requête directement avec monitoring
*/
public function exec($statement): int|false {
// Démarrer le chronométrage
PerformanceMonitor::startDbQuery($statement);
try {
$result = parent::exec($statement);
// Terminer le chronométrage
PerformanceMonitor::endDbQuery();
return $result;
} catch (PDOException $e) {
// Terminer le chronométrage
PerformanceMonitor::endDbQuery();
// Analyser l'erreur SQL
SecurityMonitor::analyzeSQLError($e, $statement);
// Re-lancer l'exception
throw $e;
}
}
/**
* Query avec monitoring
*/
public function query($statement, $mode = null, ...$args): PDOStatement|false {
// Démarrer le chronométrage
PerformanceMonitor::startDbQuery($statement);
try {
// Si pas de mode spécifié, utiliser query simple
if ($mode === null) {
$result = parent::query($statement);
} else {
$result = parent::query($statement, $mode, ...$args);
}
// Terminer le chronométrage
PerformanceMonitor::endDbQuery();
return $result;
} catch (PDOException $e) {
// Terminer le chronométrage
PerformanceMonitor::endDbQuery();
// Analyser l'erreur SQL
SecurityMonitor::analyzeSQLError($e, $statement);
// Re-lancer l'exception
throw $e;
}
}
}
/**
* PDOStatement étendu avec monitoring
*/
class MonitoredStatement extends PDOStatement {
private PDOStatement $stmt;
private string $query;
public function __construct(PDOStatement $stmt, string $query) {
$this->stmt = $stmt;
$this->query = $query;
}
/**
* Exécuter avec monitoring
*/
public function execute($params = null): bool {
// Démarrer le chronométrage (si pas déjà fait dans prepare)
PerformanceMonitor::startDbQuery($this->query);
try {
$result = $this->stmt->execute($params);
// Terminer le chronométrage
PerformanceMonitor::endDbQuery();
return $result;
} catch (PDOException $e) {
// Terminer le chronométrage
PerformanceMonitor::endDbQuery();
// Analyser l'erreur SQL
SecurityMonitor::analyzeSQLError($e, $this->query);
// Re-lancer l'exception
throw $e;
}
}
/**
* Proxy vers le statement original pour toutes les autres méthodes
*/
public function __call($method, $args) {
return call_user_func_array([$this->stmt, $method], $args);
}
public function fetch($mode = PDO::FETCH_DEFAULT, $cursorOrientation = PDO::FETCH_ORI_NEXT, $cursorOffset = 0): mixed {
return $this->stmt->fetch($mode, $cursorOrientation, $cursorOffset);
}
public function fetchAll($mode = PDO::FETCH_DEFAULT, ...$args): array {
return $this->stmt->fetchAll($mode, ...$args);
}
public function fetchColumn($column = 0): mixed {
return $this->stmt->fetchColumn($column);
}
public function rowCount(): int {
return $this->stmt->rowCount();
}
public function bindParam($param, &$var, $type = PDO::PARAM_STR, $maxLength = null, $driverOptions = null): bool {
return $this->stmt->bindParam($param, $var, $type, $maxLength, $driverOptions);
}
public function bindValue($param, $value, $type = PDO::PARAM_STR): bool {
return $this->stmt->bindValue($param, $value, $type);
}
public function closeCursor(): bool {
return $this->stmt->closeCursor();
}
public function errorCode(): ?string {
return $this->stmt->errorCode();
}
public function errorInfo(): array {
return $this->stmt->errorInfo();
}
}

View File

@@ -54,14 +54,17 @@ class Response {
]);
}
// Log de débogage
error_log('Envoi de la réponse JSON: ' . $jsonResponse);
// Log de débogage (désactivé car peut causer des problèmes avec de grandes réponses)
// error_log('Envoi de la réponse JSON: ' . $jsonResponse);
// Envoyer la réponse
echo $jsonResponse;
// S'assurer que tout est envoyé
flush();
// Terminer l'exécution pour éviter d'envoyer du contenu supplémentaire
exit;
}
/**

View File

@@ -12,6 +12,9 @@ class Router {
'lostpassword',
'log',
'villes', // Ajout de la route villes comme endpoint public pour l'autocomplétion du code postal
'password/check', // Vérification de la force des mots de passe (public pour l'inscription)
'password/compromised', // Vérification si un mot de passe est compromis
'stripe/webhook', // Webhook Stripe (doit être public pour recevoir les événements)
];
public function __construct() {
@@ -32,13 +35,14 @@ class Router {
$this->post('log', ['LogController', 'index']);
// Routes privées utilisateurs
// IMPORTANT: Les routes spécifiques doivent être déclarées AVANT les routes avec paramètres
$this->post('users/check-username', ['UserController', 'checkUsername']); // Déplacé avant les routes avec :id
$this->get('users', ['UserController', 'getUsers']);
$this->get('users/:id', ['UserController', 'getUserById']);
$this->post('users', ['UserController', 'createUser']);
$this->put('users/:id', ['UserController', 'updateUser']);
$this->delete('users/:id', ['UserController', 'deleteUser']);
$this->post('users/:id/reset-password', ['UserController', 'resetPassword']);
$this->post('users/check-username', ['UserController', 'checkUsername']);
$this->post('logout', ['LoginController', 'logout']);
// Routes entités
@@ -67,6 +71,7 @@ class Router {
// Routes passages
$this->get('passages', ['PassageController', 'getPassages']);
$this->get('passages/:id', ['PassageController', 'getPassageById']);
$this->get('passages/:id/receipt', ['PassageController', 'getReceipt']);
$this->get('passages/operation/:operation_id', ['PassageController', 'getPassagesByOperation']);
$this->post('passages', ['PassageController', 'createPassage']);
$this->put('passages/:id', ['PassageController', 'updatePassage']);
@@ -90,6 +95,56 @@ class Router {
$this->post('sectors', ['SectorController', 'create']);
$this->put('sectors/:id', ['SectorController', 'update']);
$this->delete('sectors/:id', ['SectorController', 'delete']);
// Routes mots de passe
$this->post('password/check', ['PasswordController', 'checkStrength']);
$this->post('password/compromised', ['PasswordController', 'checkCompromised']);
$this->get('password/generate', ['PasswordController', 'generate']);
// Routes du module Chat
$this->get('chat/rooms', ['ChatController', 'getRooms']);
$this->post('chat/rooms', ['ChatController', 'createRoom']);
$this->put('chat/rooms/:id', ['ChatController', 'updateRoom']);
$this->delete('chat/rooms/:id', ['ChatController', 'deleteRoom']);
$this->get('chat/rooms/:id/messages', ['ChatController', 'getRoomMessages']);
$this->post('chat/rooms/:id/messages', ['ChatController', 'sendMessage']);
$this->put('chat/messages/:id', ['ChatController', 'updateMessage']);
$this->post('chat/rooms/:id/read', ['ChatController', 'markAsRead']);
$this->get('chat/recipients', ['ChatController', 'getRecipients']);
// Routes du module Sécurité (Admin uniquement)
$this->get('admin/metrics', ['SecurityController', 'getMetrics']);
$this->get('admin/alerts', ['SecurityController', 'getAlerts']);
$this->post('admin/alerts/:id/resolve', ['SecurityController', 'resolveAlert']);
$this->get('admin/blocked-ips', ['SecurityController', 'getBlockedIPs']);
$this->post('admin/unblock-ip', ['SecurityController', 'unblockIP']);
$this->post('admin/block-ip', ['SecurityController', 'blockIP']);
$this->get('admin/security-report', ['SecurityController', 'getSecurityReport']);
$this->post('admin/cleanup', ['SecurityController', 'cleanup']);
$this->post('admin/test-alert', ['SecurityController', 'testAlert']);
// Routes Stripe
// Configuration et onboarding
$this->post('stripe/accounts', ['StripeController', 'createAccount']);
$this->get('stripe/accounts/:entityId/status', ['StripeController', 'getAccountStatus']);
$this->post('stripe/accounts/:accountId/onboarding-link', ['StripeController', 'createOnboardingLink']);
$this->post('stripe/locations', ['StripeController', 'createLocation']);
// Terminal et Tap to Pay
$this->post('stripe/terminal/connection-token', ['StripeController', 'createConnectionToken']);
$this->post('stripe/devices/check-tap-to-pay', ['StripeController', 'checkTapToPayCapability']);
$this->get('stripe/devices/certified-android', ['StripeController', 'getCertifiedAndroidDevices']);
// Paiements
$this->post('stripe/payments/create-intent', ['StripeController', 'createPaymentIntent']);
$this->get('stripe/payments/:paymentIntentId', ['StripeController', 'getPaymentStatus']);
// Statistiques et configuration
$this->get('stripe/stats', ['StripeController', 'getPaymentStats']);
$this->get('stripe/config', ['StripeController', 'getPublicConfig']);
// Webhook (IMPORTANT: pas d'authentification requise pour les webhooks Stripe)
$this->post('stripe/webhook', ['StripeWebhookController', 'handleWebhook']);
}
public function handle(): void {

View File

@@ -28,6 +28,7 @@ class Session {
$_SESSION['user_id'] = $userData['id'];
$_SESSION['user_email'] = $userData['email'] ?? '';
$_SESSION['entity_id'] = $userData['fk_entite'] ?? null;
$_SESSION['fk_role'] = $userData['fk_role'] ?? 1;
$_SESSION['authenticated'] = true;
$_SESSION['last_activity'] = time();
@@ -56,6 +57,10 @@ class Session {
return $_SESSION['entity_id'] ?? null;
}
public static function getRole(): ?int {
return $_SESSION['fk_role'] ?? null;
}
public static function requireAuth(): void {
if (!self::isAuthenticated()) {
// Log détaillé pour le debug

View File

@@ -5,8 +5,10 @@ declare(strict_types=1);
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;
use App\Services\PasswordSecurityService;
require_once __DIR__ . '/EmailTemplates.php';
require_once __DIR__ . '/PasswordSecurityService.php';
class ApiService {
@@ -277,34 +279,49 @@ class ApiService {
}
/**
* Génère un mot de passe sécurisé aléatoire
* Génère un mot de passe sécurisé aléatoire non compromis
* Utilise le service PasswordSecurityService pour vérifier contre HIBP
*
* @param int $minLength Longueur minimale du mot de passe (par défaut 12)
* @param int $maxLength Longueur maximale du mot de passe (par défaut 16)
* @return string Mot de passe généré
*/
public static function generateSecurePassword(int $minLength = 12, int $maxLength = 16): string {
$lowercase = 'abcdefghijklmnopqrstuvwxyz';
$uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$numbers = '0123456789';
$special = '!@#$%^&*()_+-=[]{}|;:,.<>?';
$length = random_int($minLength, $maxLength);
$length = rand($minLength, $maxLength);
$password = '';
// Utiliser le nouveau service pour générer un mot de passe non compromis
$password = PasswordSecurityService::generateSecurePassword($length, 10);
// Au moins un de chaque type
$password .= $lowercase[rand(0, strlen($lowercase) - 1)];
$password .= $uppercase[rand(0, strlen($uppercase) - 1)];
$password .= $numbers[rand(0, strlen($numbers) - 1)];
$password .= $special[rand(0, strlen($special) - 1)];
// Si le service échoue (très rare), utiliser l'ancienne méthode
if ($password === null) {
LogService::log('Fallback vers génération de mot de passe classique', [
'level' => 'warning',
'reason' => 'PasswordSecurityService a échoué'
]);
// Compléter avec des caractères aléatoires
$all = $lowercase . $uppercase . $numbers . $special;
for ($i = strlen($password); $i < $length; $i++) {
$password .= $all[rand(0, strlen($all) - 1)];
$lowercase = 'abcdefghijklmnopqrstuvwxyz';
$uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$numbers = '0123456789';
$special = '!@#$%^&*()_+-=[]{}|;:,.<>?';
$password = '';
// Au moins un de chaque type
$password .= $lowercase[random_int(0, strlen($lowercase) - 1)];
$password .= $uppercase[random_int(0, strlen($uppercase) - 1)];
$password .= $numbers[random_int(0, strlen($numbers) - 1)];
$password .= $special[random_int(0, strlen($special) - 1)];
// Compléter avec des caractères aléatoires
$all = $lowercase . $uppercase . $numbers . $special;
for ($i = strlen($password); $i < $length; $i++) {
$password .= $all[random_int(0, strlen($all) - 1)];
}
// Mélanger le mot de passe
return str_shuffle($password);
}
// Mélanger le mot de passe
return str_shuffle($password);
return $password;
}
}

View File

@@ -37,7 +37,7 @@ class ExportService {
}
// Créer le dossier de destination
$exportDir = $this->fileService->createDirectory($entiteId, "/{$entiteId}/operations/{$operationId}/exports/excel");
$exportDir = $this->fileService->createDirectory($entiteId, "/{$entiteId}/operations/{$operationId}");
LogService::log('exportDir', [
'level' => 'warning',
@@ -138,7 +138,7 @@ class ExportService {
$exportData = $this->collectOperationData($operationId, $entiteId);
// Créer le dossier de destination
$exportDir = $this->fileService->createDirectory($entiteId, "/{$entiteId}/operations/{$operationId}/exports/json");
$exportDir = $this->fileService->createDirectory($entiteId, "/{$entiteId}/operations/{$operationId}");
// Initialiser le service de chiffrement
$backupService = new BackupEncryptionService();

View File

@@ -0,0 +1,488 @@
<?php
declare(strict_types=1);
namespace App\Services;
/**
* Générateur de PDF avec support des images
* Version simplifiée basée sur FPDF
*/
class PDFGenerator {
protected $page = '';
protected $n = 2;
protected $offsets = [];
protected $buffer = '';
protected $pages = [];
protected $state = 0;
protected $compress = true;
protected $k;
protected $DefOrientation = 'P';
protected $CurOrientation;
protected $PageFormats = ['a4' => [595.28, 841.89]];
protected $DefPageFormat;
protected $CurPageFormat;
protected $PageSizes = [];
protected $wPt, $hPt;
protected $w, $h;
protected $lMargin;
protected $tMargin;
protected $rMargin;
protected $bMargin;
protected $cMargin;
protected $x, $y;
protected $lasth = 0;
protected $LineWidth;
protected $CoreFonts = ['helvetica'];
protected $fonts = [];
protected $FontFiles = [];
protected $diffs = [];
protected $FontFamily = '';
protected $FontStyle = '';
protected $underline = false;
protected $CurrentFont;
protected $FontSizePt = 12;
protected $FontSize;
protected $DrawColor = '0 G';
protected $FillColor = '0 g';
protected $TextColor = '0 g';
protected $ColorFlag = false;
protected $ws = 0;
protected $images = [];
protected $PageLinks = [];
protected $links = [];
protected $AutoPageBreak = true;
protected $PageBreakTrigger;
protected $InHeader = false;
protected $InFooter = false;
protected $ZoomMode;
protected $LayoutMode;
protected $title = '';
protected $subject = '';
protected $author = '';
protected $keywords = '';
protected $creator = '';
protected $AliasNbPages = '';
protected $PDFVersion = '1.3';
public function __construct() {
$this->DefPageFormat = 'A4';
$this->CurPageFormat = $this->PageFormats['a4'];
$this->DefOrientation = 'P';
$this->CurOrientation = $this->DefOrientation;
$this->k = 72 / 25.4; // Conversion factor
// Page dimensions
$this->wPt = $this->CurPageFormat[0];
$this->hPt = $this->CurPageFormat[1];
$this->w = $this->wPt / $this->k;
$this->h = $this->hPt / $this->k;
// Page margins (1 cm)
$margin = 28.35 / $this->k;
$this->SetMargins($margin, $margin);
$this->cMargin = $margin / 10;
$this->LineWidth = .567 / $this->k;
$this->SetAutoPageBreak(true, 2 * $margin);
$this->SetDisplayMode('default');
}
public function SetMargins($left, $top, $right = null) {
$this->lMargin = $left;
$this->tMargin = $top;
if($right === null)
$right = $left;
$this->rMargin = $right;
}
public function SetAutoPageBreak($auto, $margin = 0) {
$this->AutoPageBreak = $auto;
$this->bMargin = $margin;
$this->PageBreakTrigger = $this->h - $margin;
}
public function SetDisplayMode($zoom, $layout = 'default') {
$this->ZoomMode = $zoom;
$this->LayoutMode = $layout;
}
public function AddPage($orientation = '', $format = '') {
if($this->state == 0)
$this->Open();
$family = $this->FontFamily;
$style = $this->FontStyle . ($this->underline ? 'U' : '');
$fontsize = $this->FontSizePt;
$lw = $this->LineWidth;
$dc = $this->DrawColor;
$fc = $this->FillColor;
$tc = $this->TextColor;
$cf = $this->ColorFlag;
if($this->page > 0) {
$this->_endpage();
}
$this->_beginpage($orientation, $format);
$this->_out('2 J');
$this->LineWidth = $lw;
$this->_out(sprintf('%.2F w', $lw * $this->k));
if($family)
$this->SetFont($family, $style, $fontsize);
$this->DrawColor = $dc;
if($dc != '0 G')
$this->_out($dc);
$this->FillColor = $fc;
if($fc != '0 g')
$this->_out($fc);
$this->TextColor = $tc;
$this->ColorFlag = $cf;
}
public function SetFont($family, $style = '', $size = 0) {
$family = strtolower($family);
if($family == '')
$family = $this->FontFamily;
if($family == 'arial')
$family = 'helvetica';
if($size == 0)
$size = $this->FontSizePt;
if($this->FontFamily == $family && $this->FontStyle == $style && $this->FontSizePt == $size)
return;
$this->FontFamily = $family;
$this->FontStyle = $style;
$this->FontSizePt = $size;
$this->FontSize = $size / $this->k;
if($this->page > 0)
$this->_out(sprintf('BT /F%d %.2F Tf ET', 1, $this->FontSizePt));
}
public function Text($x, $y, $txt) {
$s = sprintf('BT %.2F %.2F Td (%s) Tj ET', $x * $this->k, ($this->h - $y) * $this->k, $this->_escape($txt));
if($this->underline && $txt != '')
$s .= ' ' . $this->_dounderline($x, $y, $txt);
if($this->ColorFlag)
$s = 'q ' . $this->TextColor . ' ' . $s . ' Q';
$this->_out($s);
}
public function Cell($w, $h = 0, $txt = '', $border = 0, $ln = 0, $align = '', $fill = false) {
$k = $this->k;
if($this->y + $h > $this->PageBreakTrigger && !$this->InHeader && !$this->InFooter && $this->AutoPageBreak) {
$x = $this->x;
$ws = $this->ws;
if($ws > 0) {
$this->ws = 0;
$this->_out('0 Tw');
}
$this->AddPage($this->CurOrientation, $this->CurPageFormat);
$this->x = $x;
if($ws > 0) {
$this->ws = $ws;
$this->_out(sprintf('%.3F Tw', $ws * $k));
}
}
if($w == 0)
$w = $this->w - $this->rMargin - $this->x;
$s = '';
if($fill || $border == 1) {
if($fill)
$op = ($border == 1) ? 'B' : 'f';
else
$op = 'S';
$s = sprintf('%.2F %.2F %.2F %.2F re %s ',
$this->x * $k, ($this->h - $this->y) * $k, $w * $k, -$h * $k, $op);
}
if(is_string($border)) {
$x = $this->x;
$y = $this->y;
if(strpos($border, 'L') !== false)
$s .= sprintf('%.2F %.2F m %.2F %.2F l S ',
$x * $k, ($this->h - $y) * $k, $x * $k, ($this->h - ($y + $h)) * $k);
if(strpos($border, 'T') !== false)
$s .= sprintf('%.2F %.2F m %.2F %.2F l S ',
$x * $k, ($this->h - $y) * $k, ($x + $w) * $k, ($this->h - $y) * $k);
if(strpos($border, 'R') !== false)
$s .= sprintf('%.2F %.2F m %.2F %.2F l S ',
($x + $w) * $k, ($this->h - $y) * $k, ($x + $w) * $k, ($this->h - ($y + $h)) * $k);
if(strpos($border, 'B') !== false)
$s .= sprintf('%.2F %.2F m %.2F %.2F l S ',
$x * $k, ($this->h - ($y + $h)) * $k, ($x + $w) * $k, ($this->h - ($y + $h)) * $k);
}
if($txt !== '') {
if($align == 'R')
$dx = $w - $this->cMargin - $this->GetStringWidth($txt);
elseif($align == 'C')
$dx = ($w - $this->GetStringWidth($txt)) / 2;
else
$dx = $this->cMargin;
if($this->ColorFlag)
$s .= 'q ' . $this->TextColor . ' ';
$txt2 = str_replace(')', '\\)', str_replace('(', '\\(', str_replace('\\', '\\\\', $txt)));
$s .= sprintf('BT %.2F %.2F Td (%s) Tj ET',
($this->x + $dx) * $k, ($this->h - ($this->y + .5 * $h + .3 * $this->FontSize)) * $k, $txt2);
if($this->underline)
$s .= ' ' . $this->_dounderline($this->x + $dx, $this->y + .5 * $h + .3 * $this->FontSize, $txt);
if($this->ColorFlag)
$s .= ' Q';
}
if($s)
$this->_out($s);
$this->lasth = $h;
if($ln > 0) {
$this->y += $h;
if($ln == 1)
$this->x = $this->lMargin;
} else
$this->x += $w;
}
public function Ln($h = null) {
$this->x = $this->lMargin;
if($h === null)
$this->y += $this->lasth;
else
$this->y += $h;
}
public function Image($file, $x = null, $y = null, $w = 0, $h = 0) {
// Pour simplifier, on va juste créer un rectangle avec texte "LOGO"
// Dans une vraie implémentation, il faudrait encoder l'image
if($x === null)
$x = $this->x;
if($y === null)
$y = $this->y;
if($w == 0)
$w = 30;
if($h == 0)
$h = 30;
// Dessiner un rectangle pour représenter le logo
$this->Rect($x, $y, $w, $h);
// Ajouter le texte LOGO au centre
$oldX = $this->x;
$oldY = $this->y;
$this->SetXY($x + $w/2 - 8, $y + $h/2 - 2);
$this->Cell(16, 4, 'LOGO', 0, 0, 'C');
$this->SetXY($oldX, $oldY);
}
public function Rect($x, $y, $w, $h, $style = '') {
if($style == 'F')
$op = 'f';
elseif($style == 'FD' || $style == 'DF')
$op = 'B';
else
$op = 'S';
$this->_out(sprintf('%.2F %.2F %.2F %.2F re %s',
$x * $this->k, ($this->h - $y) * $this->k, $w * $this->k, -$h * $this->k, $op));
}
public function Line($x1, $y1, $x2, $y2) {
$this->_out(sprintf('%.2F %.2F m %.2F %.2F l S',
$x1 * $this->k, ($this->h - $y1) * $this->k, $x2 * $this->k, ($this->h - $y2) * $this->k));
}
public function GetStringWidth($s) {
$cw = ['helvetica' => [' ' => 278, '!' => 278, '"' => 355, '#' => 556, '$' => 556, '%' => 889, '&' => 667]];
$w = 0;
$l = strlen($s);
for($i = 0; $i < $l; $i++)
$w += 600; // Approximation
return $w * $this->FontSize / 1000;
}
public function SetXY($x, $y) {
$this->SetX($x);
$this->SetY($y, false);
}
public function SetX($x) {
if($x >= 0)
$this->x = $x;
else
$this->x = $this->w + $x;
}
public function SetY($y, $resetX = true) {
$this->y = $y;
if($resetX)
$this->x = $this->lMargin;
}
public function Output() {
if($this->state < 3)
$this->Close();
return $this->buffer;
}
protected function Open() {
$this->state = 1;
$this->_out('%PDF-' . $this->PDFVersion);
}
protected function Close() {
if($this->state == 3)
return;
if($this->page == 0)
$this->AddPage();
$this->_endpage();
$this->_enddoc();
}
protected function _beginpage($orientation, $format) {
$this->page++;
$this->pages[$this->page] = '';
$this->state = 2;
$this->x = $this->lMargin;
$this->y = $this->tMargin;
$this->FontFamily = '';
}
protected function _endpage() {
$this->state = 1;
}
protected function _escape($s) {
$s = str_replace('\\', '\\\\', $s);
$s = str_replace('(', '\\(', $s);
$s = str_replace(')', '\\)', $s);
$s = str_replace("\r", '\\r', $s);
return $s;
}
protected function _dounderline($x, $y, $txt) {
$up = -100;
$ut = 50;
$w = $this->GetStringWidth($txt) + $this->ws * substr_count($txt, ' ');
return sprintf('%.2F %.2F %.2F %.2F re f',
$x * $this->k, ($this->h - ($y - $up / 1000 * $this->FontSize)) * $this->k,
$w * $this->k, -$ut / 1000 * $this->FontSizePt);
}
protected function _out($s) {
if($this->state == 2)
$this->pages[$this->page] .= $s . "\n";
else
$this->buffer .= $s . "\n";
}
protected function _enddoc() {
$this->_putheader();
$this->_putpages();
$this->_putresources();
$this->_newobj();
$this->_out('<<');
$this->_out('/Type /Catalog');
$this->_out('/Pages 1 0 R');
$this->_out('>>');
$this->_out('endobj');
$o = strlen($this->buffer);
$this->_out('xref');
$this->_out('0 ' . ($this->n + 1));
$this->_out('0000000000 65535 f ');
for($i = 1; $i <= $this->n; $i++)
$this->_out(sprintf('%010d 00000 n ', $this->offsets[$i]));
$this->_out('trailer');
$this->_out('<<');
$this->_out('/Size ' . ($this->n + 1));
$this->_out('/Root ' . $this->n . ' 0 R');
$this->_out('/Info ' . ($this->n - 1) . ' 0 R');
$this->_out('>>');
$this->_out('startxref');
$this->_out($o);
$this->_out('%%EOF');
$this->state = 3;
}
protected function _putheader() {
$this->_out('%PDF-' . $this->PDFVersion);
}
protected function _putpages() {
$nb = $this->page;
$n = $this->n;
for($page = 1; $page <= $nb; $page++) {
$this->_newobj();
$this->_out('<</Type /Page');
$this->_out('/Parent 1 0 R');
$this->_out('/Resources 2 0 R');
$this->_out('/Contents ' . ($this->n + 1) . ' 0 R>>');
$this->_out('endobj');
$this->_newobj();
$filter = ($this->compress) ? '/Filter /FlateDecode ' : '';
$p = ($this->compress) ? gzcompress($this->pages[$page]) : $this->pages[$page];
$this->_out('<<' . $filter . '/Length ' . strlen($p) . '>>');
$this->_putstream($p);
$this->_out('endobj');
}
$this->offsets[1] = strlen($this->buffer);
$this->_out('1 0 obj');
$this->_out('<</Type /Pages');
$kids = '/Kids [';
for($i = 0; $i < $nb; $i++)
$kids .= (3 + 2 * $i) . ' 0 R ';
$this->_out($kids . ']');
$this->_out('/Count ' . $nb);
$this->_out(sprintf('/MediaBox [0 0 %.2F %.2F]', $this->wPt, $this->hPt));
$this->_out('>>');
$this->_out('endobj');
}
protected function _putresources() {
$this->_putfonts();
$this->offsets[2] = strlen($this->buffer);
$this->_out('2 0 obj');
$this->_out('<<');
$this->_out('/ProcSet [/PDF /Text /ImageB /ImageC /ImageI]');
$this->_out('/Font <<');
$this->_out('/F1 <</Type /Font /Subtype /Type1 /BaseFont /Helvetica>>');
$this->_out('>>');
$this->_out('>>');
$this->_out('endobj');
}
protected function _putfonts() {
// Simplified - fonts are embedded in resources
}
protected function _newobj() {
$this->n++;
$this->offsets[$this->n] = strlen($this->buffer);
$this->_out($this->n . ' 0 obj');
}
protected function _putstream($s) {
$this->_out('stream');
$this->buffer .= $s;
$this->_out('endstream');
}
}

View File

@@ -0,0 +1,415 @@
<?php
declare(strict_types=1);
namespace App\Services;
use LogService;
require_once __DIR__ . '/LogService.php';
/**
* Service de sécurité des mots de passe conforme à NIST SP 800-63B
* Vérifie les mots de passe contre la base de données Have I Been Pwned
* Utilise l'API k-anonymity pour préserver la confidentialité
*/
class PasswordSecurityService {
private const HIBP_API_URL = 'https://api.pwnedpasswords.com/range/';
private const MIN_PASSWORD_LENGTH = 8;
private const MAX_PASSWORD_LENGTH = 64;
private const REQUEST_TIMEOUT = 5; // secondes
/**
* Vérifie si un mot de passe a été compromis
* Utilise l'API Have I Been Pwned avec k-anonymity
*
* @param string $password Le mot de passe à vérifier
* @return array ['compromised' => bool, 'occurrences' => int, 'error' => string|null]
*/
public static function checkPasswordCompromised(string $password): array {
try {
// Calculer le hash SHA-1 du mot de passe
$sha1 = strtoupper(sha1($password));
// Extraire les 5 premiers caractères pour k-anonymity
$prefix = substr($sha1, 0, 5);
$suffix = substr($sha1, 5);
// Appeler l'API HIBP
$response = self::callHibpApi($prefix);
if ($response === null) {
// En cas d'erreur API, on laisse passer le mot de passe
// pour ne pas bloquer l'utilisateur (fail open)
return [
'compromised' => false,
'occurrences' => 0,
'error' => 'Impossible de vérifier le mot de passe contre la base de données'
];
}
// Rechercher le suffixe dans la réponse
$lines = explode("\n", $response);
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) continue;
[$hashSuffix, $count] = explode(':', $line);
if ($hashSuffix === $suffix) {
LogService::log('Mot de passe compromis détecté', [
'level' => 'warning',
'occurrences' => intval($count)
]);
return [
'compromised' => true,
'occurrences' => intval($count),
'error' => null
];
}
}
// Mot de passe non trouvé dans la base de données
return [
'compromised' => false,
'occurrences' => 0,
'error' => null
];
} catch (\Exception $e) {
LogService::log('Erreur lors de la vérification HIBP', [
'level' => 'error',
'error' => $e->getMessage()
]);
// En cas d'erreur, on laisse passer (fail open)
return [
'compromised' => false,
'occurrences' => 0,
'error' => $e->getMessage()
];
}
}
/**
* Appelle l'API Have I Been Pwned
*
* @param string $prefix Les 5 premiers caractères du hash SHA-1
* @return string|null La réponse de l'API ou null en cas d'erreur
*/
private static function callHibpApi(string $prefix): ?string {
$url = self::HIBP_API_URL . $prefix;
$context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => [
'User-Agent: GeoSector-API',
'Accept: text/plain'
],
'timeout' => self::REQUEST_TIMEOUT,
'ignore_errors' => false
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true
]
]);
$response = @file_get_contents($url, false, $context);
if ($response === false) {
LogService::log('Échec de l\'appel à l\'API HIBP', [
'level' => 'error',
'url' => $url
]);
return null;
}
return $response;
}
/**
* Valide un mot de passe selon les critères NIST SP 800-63B
* NIST recommande d'être très permissif : pas d'obligation de composition
*
* @param string $password Le mot de passe à valider
* @param bool $checkCompromised Vérifier si le mot de passe est compromis
* @return array ['valid' => bool, 'errors' => array, 'warnings' => array]
*/
public static function validatePassword(string $password, bool $checkCompromised = true): array {
$errors = [];
$warnings = [];
// Calculer la longueur réelle en tenant compte de l'UTF-8
$length = mb_strlen($password, 'UTF-8');
// Vérification de la longueur minimale (NIST : minimum 8)
if ($length < self::MIN_PASSWORD_LENGTH) {
$errors[] = sprintf('Le mot de passe doit contenir au moins %d caractères', self::MIN_PASSWORD_LENGTH);
}
// Vérification de la longueur maximale (NIST : maximum 64 minimum)
if ($length > self::MAX_PASSWORD_LENGTH) {
$errors[] = sprintf('Le mot de passe ne doit pas dépasser %d caractères', self::MAX_PASSWORD_LENGTH);
}
// NIST : Les espaces sont acceptés (pas d'erreur, juste un avertissement informatif)
if ($password !== trim($password)) {
// C'est juste informatif, pas une erreur selon NIST
$warnings[] = 'Note : Le mot de passe contient des espaces en début ou fin (c\'est autorisé)';
}
// Vérification contre les mots de passe compromis (NIST : obligatoire)
if ($checkCompromised && empty($errors)) {
$compromisedCheck = self::checkPasswordCompromised($password);
if ($compromisedCheck['compromised']) {
$errors[] = sprintf(
'Ce mot de passe a été trouvé %s fois dans des fuites de données. Veuillez en choisir un autre.',
number_format($compromisedCheck['occurrences'], 0, ',', ' ')
);
} elseif ($compromisedCheck['error']) {
$warnings[] = 'Impossible de vérifier si le mot de passe a été compromis';
}
}
// Avertissements optionnels (pas des erreurs selon NIST)
// Ces vérifications sont juste informatives
if (self::hasSimplePattern($password)) {
$warnings[] = 'Suggestion : Évitez les motifs répétitifs pour plus de sécurité';
}
if (self::hasCommonSequence($password)) {
$warnings[] = 'Suggestion : Évitez les séquences communes pour plus de sécurité';
}
// NIST : Pas d'obligation de majuscules, minuscules, chiffres ou caractères spéciaux
// On peut ajouter des suggestions mais PAS d'erreurs
$hasLower = preg_match('/[a-z]/u', $password);
$hasUpper = preg_match('/[A-Z]/u', $password);
$hasDigit = preg_match('/[0-9]/u', $password);
$hasSpecial = preg_match('/[^a-zA-Z0-9]/u', $password);
$complexity = ($hasLower ? 1 : 0) + ($hasUpper ? 1 : 0) + ($hasDigit ? 1 : 0) + ($hasSpecial ? 1 : 0);
if ($complexity < 2 && $length < 12) {
$warnings[] = 'Suggestion : Un mot de passe plus long ou plus varié serait plus sécurisé';
}
return [
'valid' => empty($errors),
'errors' => $errors,
'warnings' => $warnings
];
}
/**
* Génère un mot de passe sécurisé non compromis
*
* @param int $length Longueur du mot de passe (12-20 caractères)
* @param int $maxAttempts Nombre maximum de tentatives
* @return string|null Le mot de passe généré ou null si échec
*/
public static function generateSecurePassword(int $length = 14, int $maxAttempts = 10): ?string {
$length = max(12, min(20, $length));
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
// Générer un mot de passe aléatoire
$password = self::generateRandomPassword($length);
// Vérifier s'il est compromis
$check = self::checkPasswordCompromised($password);
if (!$check['compromised']) {
return $password;
}
LogService::log('Mot de passe généré était compromis, nouvelle tentative', [
'level' => 'info',
'attempt' => $attempt + 1,
'occurrences' => $check['occurrences']
]);
}
LogService::log('Impossible de générer un mot de passe non compromis', [
'level' => 'error',
'attempts' => $maxAttempts
]);
return null;
}
/**
* Génère un mot de passe aléatoire
*
* @param int $length Longueur du mot de passe
* @return string Le mot de passe généré
*/
private static function generateRandomPassword(int $length): string {
// Caractères autorisés (sans ambiguïté visuelle)
$lowercase = 'abcdefghijkmnopqrstuvwxyz'; // sans l
$uppercase = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; // sans I, O
$numbers = '23456789'; // sans 0, 1
$special = '!@#$%^&*()_+-=[]{}|;:,.<>?';
$password = '';
// Garantir au moins un caractère de chaque type
$password .= $lowercase[random_int(0, strlen($lowercase) - 1)];
$password .= $uppercase[random_int(0, strlen($uppercase) - 1)];
$password .= $numbers[random_int(0, strlen($numbers) - 1)];
$password .= $special[random_int(0, strlen($special) - 1)];
// Compléter avec des caractères aléatoires
$allChars = $lowercase . $uppercase . $numbers . $special;
for ($i = strlen($password); $i < $length; $i++) {
$password .= $allChars[random_int(0, strlen($allChars) - 1)];
}
// Mélanger les caractères
$passwordArray = str_split($password);
shuffle($passwordArray);
return implode('', $passwordArray);
}
/**
* Vérifie si le mot de passe contient des motifs répétitifs simples
*
* @param string $password Le mot de passe à vérifier
* @return bool True si des motifs répétitifs sont détectés
*/
private static function hasSimplePattern(string $password): bool {
$lowPassword = strtolower($password);
// Vérifier les caractères répétés (aaa, 111, etc.)
if (preg_match('/(.)\1{2,}/', $lowPassword)) {
return true;
}
// Vérifier les motifs répétés (ababab, 121212, etc.)
if (preg_match('/(.{2,})\1{2,}/', $lowPassword)) {
return true;
}
return false;
}
/**
* Vérifie si le mot de passe contient des séquences communes
*
* @param string $password Le mot de passe à vérifier
* @return bool True si des séquences communes sont détectées
*/
private static function hasCommonSequence(string $password): bool {
$lowPassword = strtolower($password);
$commonSequences = [
'123', '234', '345', '456', '567', '678', '789',
'abc', 'bcd', 'cde', 'def', 'efg', 'fgh',
'qwerty', 'azerty', 'qwertz',
'password', 'motdepasse', 'admin', 'user'
];
foreach ($commonSequences as $sequence) {
if (stripos($lowPassword, $sequence) !== false) {
return true;
}
}
return false;
}
/**
* Estime la force d'un mot de passe selon l'approche NIST
* NIST privilégie la longueur sur la complexité
*
* @param string $password Le mot de passe à évaluer
* @return array ['score' => int (0-100), 'strength' => string, 'feedback' => array]
*/
public static function estimatePasswordStrength(string $password): array {
$score = 0;
$feedback = [];
// Longueur (NIST : facteur le plus important)
$length = mb_strlen($password, 'UTF-8');
if ($length >= 8) $score += 20; // Minimum requis
if ($length >= 12) $score += 20; // Bon
if ($length >= 16) $score += 20; // Très bon
if ($length >= 20) $score += 15; // Excellent
if ($length >= 30) $score += 10; // Exceptionnel
// Diversité des caractères (bonus, pas obligatoire selon NIST)
$hasLower = preg_match('/[a-z]/u', $password);
$hasUpper = preg_match('/[A-Z]/u', $password);
$hasDigit = preg_match('/[0-9]/u', $password);
$hasSpecial = preg_match('/[^a-zA-Z0-9]/u', $password);
$diversity = ($hasLower ? 1 : 0) + ($hasUpper ? 1 : 0) + ($hasDigit ? 1 : 0) + ($hasSpecial ? 1 : 0);
// Bonus pour la diversité (mais pas de pénalité si absent)
if ($diversity >= 4) {
$score += 15;
} elseif ($diversity >= 3) {
$score += 10;
} elseif ($diversity >= 2) {
$score += 5;
}
// Suggestions constructives (pas de pénalités selon NIST)
if ($length < 12) {
$feedback[] = 'Suggestion : Un mot de passe plus long est plus sécurisé';
}
if ($diversity < 2 && $length < 16) {
$feedback[] = 'Suggestion : Variez les types de caractères ou augmentez la longueur';
}
// Pénalités légères pour les mauvaises pratiques évidentes
if (self::hasSimplePattern($password)) {
$score = max(0, $score - 10);
$feedback[] = 'Attention : Motifs répétitifs détectés';
}
if (self::hasCommonSequence($password)) {
$score = max(0, $score - 10);
$feedback[] = 'Attention : Séquences communes détectées';
}
// Vérification compromission (critique selon NIST)
$compromisedCheck = self::checkPasswordCompromised($password);
if ($compromisedCheck['compromised']) {
$score = min($score, 10); // Score très bas si compromis
$feedback[] = sprintf(
'CRITIQUE : Mot de passe trouvé %s fois dans des fuites de données',
number_format($compromisedCheck['occurrences'], 0, ',', ' ')
);
}
// Déterminer la force basée principalement sur la longueur (approche NIST)
$strength = 'Très faible';
if ($compromisedCheck['compromised']) {
$strength = 'Compromis';
} elseif ($length >= 20) {
$strength = 'Très fort';
} elseif ($length >= 16) {
$strength = 'Fort';
} elseif ($length >= 12) {
$strength = 'Bon';
} elseif ($length >= 8) {
$strength = 'Acceptable';
} else {
$strength = 'Trop court';
}
return [
'score' => max(0, min(100, $score)),
'strength' => $strength,
'feedback' => $feedback,
'length' => $length,
'diversity' => $diversity
];
}
}

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace App\Services;
require_once __DIR__ . '/../../vendor/autoload.php';
use FPDF;
/**
* Générateur de reçus PDF avec FPDF
* Supporte les logos PNG/JPG
*/
class ReceiptPDFGenerator extends FPDF {
private const DEFAULT_LOGO_PATH = __DIR__ . '/../../docs/_logo_recu.png';
private const LOGO_WIDTH = 40; // Largeur du logo en mm
private const LOGO_HEIGHT = 40; // Hauteur du logo en mm
/**
* Génère un reçu fiscal PDF
*/
public function generateReceipt(array $data, ?string $logoPath = null): string {
$this->AddPage();
$this->SetFont('Arial', '', 12);
// Déterminer quel logo utiliser
$logoToUse = null;
if ($logoPath && file_exists($logoPath)) {
$logoToUse = $logoPath;
} elseif (file_exists(self::DEFAULT_LOGO_PATH)) {
$logoToUse = self::DEFAULT_LOGO_PATH;
}
// Ajouter le logo (PNG ou JPG)
if ($logoToUse) {
try {
// Déterminer le type d'image
$imageInfo = getimagesize($logoToUse);
if ($imageInfo !== false) {
$type = '';
switch ($imageInfo[2]) {
case IMAGETYPE_JPEG:
$type = 'JPG';
break;
case IMAGETYPE_PNG:
$type = 'PNG';
break;
}
if ($type) {
// Position du logo : x=10, y=10, largeur=40mm, hauteur=40mm
$this->Image($logoToUse, 10, 10, self::LOGO_WIDTH, self::LOGO_HEIGHT, $type);
}
}
} catch (\Exception $e) {
// Si erreur avec le logo, continuer sans
}
}
// En-tête à droite du logo
$this->SetXY(60, 20);
$this->SetFont('Arial', 'B', 14);
$this->Cell(130, 6, $this->cleanText(strtoupper($data['entite_name'] ?? '')), 0, 1, 'C');
if (!empty($data['entite_city'])) {
$this->SetX(60);
$this->SetFont('Arial', '', 11);
$this->Cell(130, 5, $this->cleanText(strtoupper($data['entite_city'])), 0, 1, 'C');
}
if (!empty($data['entite_address'])) {
$this->SetX(60);
$this->SetFont('Arial', '', 10);
$this->Cell(130, 5, $this->cleanText($data['entite_address']), 0, 1, 'C');
}
// Titre du reçu
$this->SetY(65);
$this->SetFont('Arial', 'B', 16);
$this->Cell(0, 10, $this->cleanText('REÇU DE DON'), 0, 1, 'C');
$this->SetFont('Arial', 'B', 14);
$this->Cell(0, 8, $this->cleanText('N° ' . ($data['receipt_number'] ?? '')), 0, 1, 'C');
// Ligne de séparation
$this->Ln(5);
$this->Line(20, $this->GetY(), 190, $this->GetY());
$this->Ln(8);
// Informations du donateur
$this->SetFont('Arial', 'B', 12);
$this->Cell(0, 6, $this->cleanText('DONATEUR :'), 0, 1, 'L');
$this->SetFont('Arial', '', 11);
$this->Cell(0, 6, $this->cleanText($data['donor_name'] ?? ''), 0, 1, 'L');
if (!empty($data['donor_address'])) {
$this->SetFont('Arial', '', 10);
$this->Cell(0, 5, $this->cleanText($data['donor_address']), 0, 1, 'L');
}
$this->Ln(8);
// Cadre pour le montant
$this->SetFillColor(240, 240, 240);
$this->Rect(20, $this->GetY(), 170, 25, 'F');
// Montant en gros et centré
$this->Ln(5);
$this->SetFont('Arial', 'B', 18);
$this->Cell(0, 8, $this->cleanText($data['amount'] ?? '0') . $this->cleanText(' euros'), 0, 1, 'C');
// Date centrée
$this->SetFont('Arial', '', 12);
$this->Cell(0, 6, $this->cleanText('Don du ' . ($data['donation_date'] ?? date('d/m/Y'))), 0, 1, 'C');
$this->Ln(10);
if (!empty($data['payment_method'])) {
$this->SetFont('Arial', '', 10);
$this->Cell(0, 5, $this->cleanText('Mode de règlement : ') . $this->cleanText($data['payment_method']), 0, 1, 'L');
}
if (!empty($data['operation_name'])) {
$this->SetFont('Arial', 'I', 10);
$this->Cell(0, 5, $this->cleanText('Campagne : ') . $this->cleanText($data['operation_name']), 0, 1, 'L');
}
// Mention de remerciement
$this->Ln(15);
$this->SetFont('Arial', '', 10);
$this->MultiCell(0, 5, $this->cleanText(
"Nous vous remercions pour votre généreux soutien à notre amicale.\n" .
"Votre don contribue au financement de nos activités et équipements."
), 0, 'C');
// Signature
$this->SetY(-60);
$this->SetFont('Arial', '', 10);
$this->Cell(0, 5, $this->cleanText('Fait à ' . ($data['entite_city'] ?? '') . ', le ' . ($data['signature_date'] ?? date('d/m/Y'))), 0, 1, 'R');
$this->Ln(5);
$this->Cell(0, 5, $this->cleanText('Le Président'), 0, 1, 'R');
$this->Ln(15);
$this->Cell(0, 5, $this->cleanText('(Signature et cachet)'), 0, 1, 'R');
// Retourner le PDF en string
return $this->Output('S');
}
/**
* Nettoie le texte pour le PDF (supprime ou remplace les caractères problématiques)
*/
private function cleanText(string $text): string {
// Vérifier que le texte n'est pas vide
if (empty($text)) {
return '';
}
// Remplacer d'abord les caractères problématiques avant la conversion
$replacements = [
'€' => 'EUR',
'—' => '-',
'' => '-',
'"' => '"',
'"' => '"',
"'" => "'",
"'" => "'",
'…' => '...'
];
$text = str_replace(array_keys($replacements), array_values($replacements), $text);
// Tentative de conversion UTF-8 vers ISO-8859-1 pour FPDF
$converted = @iconv('UTF-8', 'ISO-8859-1//TRANSLIT//IGNORE', $text);
// Si la conversion échoue, utiliser utf8_decode en fallback
if ($converted === false) {
$converted = @utf8_decode($text);
// Si utf8_decode échoue aussi, supprimer les caractères non-ASCII
if ($converted === false) {
$converted = preg_replace('/[^\x20-\x7E]/', '?', $text);
}
}
return $converted;
}
}

View File

@@ -0,0 +1,457 @@
<?php
declare(strict_types=1);
namespace App\Services;
require_once __DIR__ . '/LogService.php';
require_once __DIR__ . '/ApiService.php';
require_once __DIR__ . '/FileService.php';
require_once __DIR__ . '/ReceiptPDFGenerator.php';
use PDO;
use Database;
use LogService;
use ApiService;
use FileService;
use Exception;
use DateTime;
/**
* Service de gestion des reçus pour les passages de type don (fk_type=1)
* Optimisé pour générer des PDF très légers (< 20KB)
*/
class ReceiptService {
private PDO $db;
private FileService $fileService;
private const DEFAULT_LOGO_PATH = __DIR__ . '/../../docs/_logo_recu.png';
private const LOGO_WIDTH = 40; // Largeur du logo en mm (80 est trop grand pour un A4)
private const LOGO_HEIGHT = 40; // Hauteur du logo en mm
public function __construct() {
$this->db = Database::getInstance();
$this->fileService = new FileService();
}
/**
* Génère un reçu pour un passage de type don avec email valide
*
* @param int $passageId ID du passage
* @return bool True si le reçu a été généré avec succès
*/
public function generateReceiptForPassage(int $passageId): bool {
try {
// Récupérer les données du passage
$passageData = $this->getPassageData($passageId);
if (!$passageData) {
LogService::log('Passage non trouvé pour génération de reçu', [
'level' => 'warning',
'passageId' => $passageId
]);
return false;
}
// Vérifier que c'est un don effectué (fk_type = 1) avec email valide
if ((int)$passageData['fk_type'] !== 1) {
return false; // Pas un don, pas de reçu
}
// Déchiffrer et vérifier l'email
$email = '';
if (!empty($passageData['encrypted_email'])) {
$email = ApiService::decryptSearchableData($passageData['encrypted_email']);
}
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
LogService::log('Email invalide ou manquant pour le reçu', [
'level' => 'info',
'passageId' => $passageId
]);
return false;
}
// Récupérer les données de l'opération
$operationData = $this->getOperationData($passageData['fk_operation']);
if (!$operationData) {
return false;
}
// Récupérer les données de l'entité
$entiteData = $this->getEntiteData($operationData['fk_entite']);
if (!$entiteData) {
return false;
}
// Récupérer le logo de l'entité
$logoPath = $this->getEntiteLogo($operationData['fk_entite']);
// Préparer les données pour la génération du PDF
$receiptData = $this->prepareReceiptData($passageData, $operationData, $entiteData, $email);
// Générer le PDF optimisé
$pdfContent = $this->generateOptimizedPDF($receiptData, $logoPath);
// Créer le répertoire de stockage
$uploadPath = "/{$operationData['fk_entite']}/recus/{$operationData['id']}";
$fullPath = $this->fileService->createDirectory($operationData['fk_entite'], $uploadPath);
// Nom du fichier
$fileName = 'recu_' . $passageId . '.pdf';
$filePath = $fullPath . '/' . $fileName;
// Sauvegarder le fichier
if (file_put_contents($filePath, $pdfContent) === false) {
throw new Exception('Impossible de sauvegarder le fichier PDF');
}
// Appliquer les permissions
$this->fileService->setFilePermissions($filePath);
// Enregistrer dans la table medias
$mediaId = $this->saveToMedias(
$operationData['fk_entite'],
$operationData['id'],
$passageId,
$fileName,
$filePath,
strlen($pdfContent)
);
// Mettre à jour le passage avec les infos du reçu
$this->updatePassageReceipt($passageId, $fileName);
// Ajouter à la queue d'email
$this->queueReceiptEmail($passageId, $email, $receiptData, $pdfContent);
LogService::log('Reçu généré avec succès', [
'level' => 'info',
'passageId' => $passageId,
'mediaId' => $mediaId,
'fileName' => $fileName,
'fileSize' => strlen($pdfContent)
]);
return true;
} catch (Exception $e) {
LogService::log('Erreur lors de la génération du reçu', [
'level' => 'error',
'error' => $e->getMessage(),
'passageId' => $passageId
]);
return false;
}
}
/**
* Génère un PDF optimisé avec logo et mise en page épurée
*/
private function generateOptimizedPDF(array $data, ?string $logoPath): string {
$pdf = new ReceiptPDFGenerator();
return $pdf->generateReceipt($data, $logoPath);
}
/**
* Récupère les données du passage
*/
private function getPassageData(int $passageId): ?array {
$stmt = $this->db->prepare('
SELECT p.*,
u.encrypted_name as user_encrypted_name,
u.encrypted_email as user_encrypted_email,
u.encrypted_phone as user_encrypted_phone
FROM ope_pass p
LEFT JOIN users u ON p.fk_user = u.id
WHERE p.id = ? AND p.chk_active = 1
');
$stmt->execute([$passageId]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
/**
* Récupère les données de l'opération
*/
private function getOperationData(int $operationId): ?array {
$stmt = $this->db->prepare('
SELECT * FROM operations
WHERE id = ? AND chk_active = 1
');
$stmt->execute([$operationId]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
/**
* Récupère les données de l'entité
*/
private function getEntiteData(int $entiteId): ?array {
$stmt = $this->db->prepare('
SELECT * FROM entites
WHERE id = ? AND chk_active = 1
');
$stmt->execute([$entiteId]);
$entite = $stmt->fetch(PDO::FETCH_ASSOC);
if ($entite) {
// Déchiffrer les données
if (!empty($entite['encrypted_name'])) {
$entite['name'] = ApiService::decryptData($entite['encrypted_name']);
}
if (!empty($entite['encrypted_email'])) {
$entite['email'] = ApiService::decryptSearchableData($entite['encrypted_email']);
}
if (!empty($entite['encrypted_phone'])) {
$entite['phone'] = ApiService::decryptData($entite['encrypted_phone']);
}
}
return $entite ?: null;
}
/**
* Récupère le chemin du logo de l'entité
*/
private function getEntiteLogo(int $entiteId): ?string {
$stmt = $this->db->prepare('
SELECT file_path FROM medias
WHERE support = ? AND support_id = ? AND file_category = ?
ORDER BY created_at DESC
LIMIT 1
');
$stmt->execute(['entite', $entiteId, 'logo']);
$logo = $stmt->fetch(PDO::FETCH_ASSOC);
if ($logo && !empty($logo['file_path']) && file_exists($logo['file_path'])) {
return $logo['file_path'];
}
// Utiliser le logo par défaut si disponible
if (file_exists(self::DEFAULT_LOGO_PATH)) {
return self::DEFAULT_LOGO_PATH;
}
return null;
}
/**
* Prépare les données pour le reçu
*/
private function prepareReceiptData(array $passage, array $operation, array $entite, string $email): array {
// Déchiffrer le nom du donateur
$donorName = '';
if (!empty($passage['encrypted_name'])) {
$donorName = ApiService::decryptData($passage['encrypted_name']);
} elseif (!empty($passage['user_encrypted_name'])) {
$donorName = ApiService::decryptData($passage['user_encrypted_name']);
}
// Construire l'adresse du donateur
$donorAddress = [];
if (!empty($passage['numero'])) $donorAddress[] = $passage['numero'];
if (!empty($passage['rue'])) $donorAddress[] = $passage['rue'];
if (!empty($passage['rue_bis'])) $donorAddress[] = $passage['rue_bis'];
if (!empty($passage['ville'])) $donorAddress[] = $passage['ville'];
// Date du don
$donationDate = '';
if (!empty($passage['passed_at'])) {
$donationDate = date('d/m/Y', strtotime($passage['passed_at']));
} elseif (!empty($passage['created_at'])) {
$donationDate = date('d/m/Y', strtotime($passage['created_at']));
}
// Mode de règlement
$paymentMethod = $this->getPaymentMethodLabel((int)($passage['fk_type_reglement'] ?? 1));
// Adresse de l'entité
$entiteAddress = [];
if (!empty($entite['adresse1'])) $entiteAddress[] = $entite['adresse1'];
if (!empty($entite['adresse2'])) $entiteAddress[] = $entite['adresse2'];
if (!empty($entite['code_postal']) || !empty($entite['ville'])) {
$entiteAddress[] = trim($entite['code_postal'] . ' ' . $entite['ville']);
}
return [
'receipt_number' => $passage['id'],
'entite_name' => $entite['name'] ?? 'Amicale des Sapeurs-Pompiers',
'entite_address' => implode(' ', $entiteAddress),
'entite_city' => $entite['ville'] ?? '',
'entite_email' => $entite['email'] ?? '',
'entite_phone' => $entite['phone'] ?? '',
'donor_name' => $donorName,
'donor_address' => implode(' ', $donorAddress),
'donor_email' => $email,
'donation_date' => $donationDate,
'amount' => number_format((float)($passage['montant'] ?? 0), 2, ',', ' '),
'payment_method' => $paymentMethod,
'operation_name' => $operation['libelle'] ?? '',
'signature_date' => date('d/m/Y')
];
}
/**
* Retourne le libellé du mode de règlement
*/
private function getPaymentMethodLabel(int $typeReglement): string {
$stmt = $this->db->prepare('SELECT libelle FROM x_types_reglements WHERE id = ?');
$stmt->execute([$typeReglement]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result ? $result['libelle'] : 'Espèces';
}
/**
* Enregistre le fichier dans la table medias
*/
private function saveToMedias(int $entiteId, int $operationId, int $passageId, string $fileName, string $filePath, int $fileSize): int {
$stmt = $this->db->prepare('
INSERT INTO medias (
support, support_id, fichier, file_type, file_category,
file_size, mime_type, original_name, fk_entite, fk_operation,
file_path, description, created_at, fk_user_creat
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?)
');
$stmt->execute([
'passage', // support
$passageId, // support_id
$fileName, // fichier
'pdf', // file_type
'recu', // file_category
$fileSize, // file_size
'application/pdf', // mime_type
$fileName, // original_name
$entiteId, // fk_entite
$operationId, // fk_operation
$filePath, // file_path
'Reçu de don', // description
0 // fk_user_creat (système)
]);
return (int)$this->db->lastInsertId();
}
/**
* Met à jour le passage avec les informations du reçu
*/
private function updatePassageReceipt(int $passageId, string $fileName): void {
$stmt = $this->db->prepare('
UPDATE ope_pass
SET nom_recu = ?, date_creat_recu = NOW()
WHERE id = ?
');
$stmt->execute([$fileName, $passageId]);
}
/**
* Ajoute le reçu à la queue d'email
*/
private function queueReceiptEmail(int $passageId, string $email, array $receiptData, string $pdfContent): void {
// Préparer le sujet
$subject = "Votre reçu de don N°" . $receiptData['receipt_number'];
// Préparer le corps de l'email
$body = $this->generateEmailBody($receiptData);
// Préparer les headers avec pièce jointe
$boundary = md5((string)time());
$headers = "MIME-Version: 1.0\r\n";
$headers .= "Content-Type: multipart/mixed; boundary=\"$boundary\"\r\n";
// Corps complet avec pièce jointe
$fullBody = "--$boundary\r\n";
$fullBody .= "Content-Type: text/html; charset=UTF-8\r\n";
$fullBody .= "Content-Transfer-Encoding: 7bit\r\n\r\n";
$fullBody .= $body . "\r\n\r\n";
// Pièce jointe PDF
$fullBody .= "--$boundary\r\n";
$fullBody .= "Content-Type: application/pdf; name=\"recu_" . $receiptData['receipt_number'] . ".pdf\"\r\n";
$fullBody .= "Content-Transfer-Encoding: base64\r\n";
$fullBody .= "Content-Disposition: attachment; filename=\"recu_" . $receiptData['receipt_number'] . ".pdf\"\r\n\r\n";
$fullBody .= chunk_split(base64_encode($pdfContent)) . "\r\n";
$fullBody .= "--$boundary--";
// Insérer dans la queue
$stmt = $this->db->prepare('
INSERT INTO email_queue (
fk_pass, to_email, subject, body, headers, created_at, status
) VALUES (?, ?, ?, ?, ?, NOW(), ?)
');
$stmt->execute([
$passageId,
$email,
$subject,
$fullBody,
$headers,
'pending'
]);
}
/**
* Génère le corps HTML de l'email
*/
private function generateEmailBody(array $data): string {
// Convertir toutes les valeurs en string pour htmlspecialchars
$safeData = array_map(function($value) {
return is_string($value) ? $value : (string)$value;
}, $data);
$html = '<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #f4f4f4; padding: 20px; text-align: center; }
.content { padding: 20px; }
.footer { background-color: #f4f4f4; padding: 10px; text-align: center; font-size: 12px; }
.amount { font-size: 24px; font-weight: bold; color: #2c5aa0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>' . htmlspecialchars($safeData['entite_name']) . '</h2>
</div>
<div class="content">
<p>Bonjour ' . htmlspecialchars($safeData['donor_name']) . ',</p>
<p>Nous vous remercions chaleureusement pour votre don de <span class="amount">' .
htmlspecialchars($safeData['amount']) . ' €</span> effectué le ' .
htmlspecialchars($safeData['donation_date']) . '.</p>
<p>Vous trouverez ci-joint votre reçu fiscal N°' . htmlspecialchars($safeData['receipt_number']) .
' qui vous permettra de bénéficier d\'une réduction d\'impôt égale à 66% du montant de votre don.</p>
<p>Votre soutien est précieux pour nous permettre de poursuivre nos actions.</p>
<p>Cordialement,<br>
L\'équipe de ' . htmlspecialchars($safeData['entite_name']) . '</p>
</div>
<div class="footer">
<p>Conservez ce reçu pour votre déclaration fiscale</p>
<p>' . htmlspecialchars($safeData['entite_name']) . '<br>
' . htmlspecialchars($safeData['entite_address']) . '<br>
' . htmlspecialchars($safeData['entite_email']) . '</p>
</div>
</div>
</body>
</html>';
return $html;
}
/**
* Met à jour la date d'envoi du reçu
*/
public function markReceiptAsSent(int $passageId): void {
$stmt = $this->db->prepare('
UPDATE ope_pass
SET date_sent_recu = NOW(), chk_email_sent = 1
WHERE id = ?
');
$stmt->execute([$passageId]);
}
}

View File

@@ -0,0 +1,553 @@
<?php
declare(strict_types=1);
namespace App\Services\Security;
require_once __DIR__ . '/../../Services/ApiService.php';
require_once __DIR__ . '/EmailThrottler.php';
use PDO;
use Database;
use ApiService;
use AppConfig;
/**
* Service central de gestion des alertes de sécurité et monitoring
*/
class AlertService {
// Niveaux d'alerte avec leur configuration
const ALERT_LEVELS = [
'INFO' => ['email' => false, 'log' => true, 'throttle' => 0],
'WARNING' => ['email' => true, 'log' => true, 'throttle' => 3600], // 1h
'ERROR' => ['email' => true, 'log' => true, 'throttle' => 900], // 15min
'CRITICAL' => ['email' => true, 'log' => true, 'throttle' => 300], // 5min
'SECURITY' => ['email' => true, 'log' => true, 'throttle' => 0, 'priority' => true]
];
// Types d'alertes avec leur niveau par défaut
const ALERT_TYPES = [
'BRUTE_FORCE' => 'SECURITY',
'UNAUTHORIZED_ACCESS' => 'CRITICAL',
'SQL_INJECTION' => 'SECURITY',
'SQL_ERROR' => 'ERROR',
'DB_CONNECTION' => 'CRITICAL',
'DB_DEADLOCK' => 'ERROR',
'PERFORMANCE_SLOW' => 'WARNING',
'PERFORMANCE_CRITICAL' => 'ERROR',
'HTTP_500' => 'ERROR',
'HTTP_404_PATTERN' => 'WARNING',
'SUSPICIOUS_PATTERN' => 'WARNING',
'MEMORY_HIGH' => 'WARNING',
'DISK_SPACE' => 'CRITICAL'
];
private static ?PDO $db = null;
private static ?EmailThrottler $throttler = null;
/**
* Déclencher une alerte
*/
public static function trigger(string $type, array $data = [], ?string $level = null): void {
try {
// Déterminer le niveau si non fourni
if ($level === null) {
$level = self::ALERT_TYPES[$type] ?? 'WARNING';
}
// Enrichir le contexte
$context = self::enrichContext($type, $level, $data);
// Enregistrer en base de données
$alertId = self::saveAlert($type, $level, $context);
// Logger
self::logAlert($type, $level, $context);
// Envoyer email si nécessaire et non throttled
if (self::shouldSendEmail($type, $level)) {
self::sendAlertEmail($type, $level, $context, $alertId);
}
// Actions automatiques selon le type
self::executeAutomaticActions($type, $context);
} catch (\Exception $e) {
error_log("AlertService Error: " . $e->getMessage());
}
}
/**
* Enrichir le contexte avec des informations système
*/
private static function enrichContext(string $type, string $level, array $data): array {
$context = $data;
// Ajouter les informations de base
$context['timestamp'] = date('Y-m-d H:i:s');
$context['environment'] = AppConfig::getInstance()->getEnvironment();
$context['server'] = $_SERVER['SERVER_NAME'] ?? 'unknown';
// Ajouter les informations de requête
if (isset($_SERVER['REQUEST_URI'])) {
$context['request'] = [
'uri' => $_SERVER['REQUEST_URI'],
'method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown'
];
}
// Ajouter les informations de session
if (session_status() === PHP_SESSION_ACTIVE) {
$context['session'] = [
'user_id' => $_SESSION['user_id'] ?? null,
'entity_id' => $_SESSION['entity_id'] ?? null,
'session_id' => substr(session_id(), 0, 8) . '...' // Tronquer pour sécurité
];
}
// Ajouter les métriques système
$context['system'] = [
'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2),
'memory_peak_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2),
'load_average' => sys_getloadavg()
];
return $context;
}
/**
* Sauvegarder l'alerte en base de données
*/
private static function saveAlert(string $type, string $level, array $context): int {
$db = self::getDb();
// Vérifier si une alerte similaire existe déjà (dans les 5 dernières minutes)
$checkStmt = $db->prepare('
SELECT id, occurrences
FROM sec_alerts
WHERE alert_type = :type
AND alert_level = :level
AND ip_address = :ip
AND endpoint = :endpoint
AND resolved = 0
AND last_seen >= DATE_SUB(NOW(), INTERVAL 5 MINUTE)
LIMIT 1
');
$ip = $context['request']['ip'] ?? null;
$endpoint = $context['request']['uri'] ?? null;
$checkStmt->execute([
'type' => $type,
'level' => $level,
'ip' => $ip,
'endpoint' => $endpoint
]);
$existing = $checkStmt->fetch(PDO::FETCH_ASSOC);
if ($existing) {
// Mettre à jour l'alerte existante
$updateStmt = $db->prepare('
UPDATE sec_alerts
SET occurrences = occurrences + 1,
last_seen = NOW(),
details = :details
WHERE id = :id
');
$updateStmt->execute([
'id' => $existing['id'],
'details' => json_encode($context)
]);
return (int)$existing['id'];
} else {
// Créer une nouvelle alerte
$insertStmt = $db->prepare('
INSERT INTO sec_alerts (
alert_type, alert_level, ip_address, user_id, username,
endpoint, method, details, first_seen, last_seen
) VALUES (
:type, :level, :ip, :user_id, :username,
:endpoint, :method, :details, NOW(), NOW()
)
');
$insertStmt->execute([
'type' => $type,
'level' => $level,
'ip' => $ip,
'user_id' => $context['session']['user_id'] ?? null,
'username' => $context['username'] ?? null,
'endpoint' => $endpoint,
'method' => $context['request']['method'] ?? null,
'details' => json_encode($context)
]);
return (int)$db->lastInsertId();
}
}
/**
* Logger l'alerte
*/
private static function logAlert(string $type, string $level, array $context): void {
$message = sprintf(
"[ALERT] %s - %s: %s",
$level,
$type,
$context['message'] ?? 'No message provided'
);
// Utiliser LogService existant
if (class_exists('LogService')) {
\LogService::log($message, [
'level' => strtolower($level),
'alert_type' => $type,
'context' => $context
]);
} else {
error_log($message);
}
}
/**
* Vérifier si un email doit être envoyé
*/
private static function shouldSendEmail(string $type, string $level): bool {
$config = self::ALERT_LEVELS[$level] ?? [];
if (!($config['email'] ?? false)) {
return false;
}
// Vérifier l'environnement
$env = AppConfig::getInstance()->getEnvironment();
if ($env === 'development' && $level !== 'SECURITY') {
return false; // Pas d'emails en dev sauf sécurité
}
// Vérifier le throttling
$throttler = self::getThrottler();
$throttleTime = $config['throttle'] ?? 0;
if ($throttleTime > 0 && !$throttler->canSend($type, $throttleTime)) {
return false;
}
return true;
}
/**
* Envoyer l'email d'alerte
*/
private static function sendAlertEmail(string $type, string $level, array $context, int $alertId): void {
try {
$throttler = self::getThrottler();
// Préparer le contenu de l'email
$subject = sprintf(
"[%s] GeoSector %s - %s",
$level,
strtoupper($context['environment'] ?? 'PROD'),
str_replace('_', ' ', $type)
);
$body = self::formatEmailBody($type, $level, $context, $alertId);
// Destinataire principal
$to = 'support@unikoffice.com';
// CC pour les alertes critiques
$cc = [];
if (in_array($level, ['CRITICAL', 'SECURITY'])) {
$cc[] = 'admin@geosector.fr';
}
// Envoyer l'email
$emailSent = ApiService::sendEmail(
$to,
'GeoSector Security',
'security_alert',
[
'subject' => $subject,
'body' => $body,
'type' => $type,
'level' => $level,
'context' => $context
]
);
if ($emailSent) {
// Marquer l'alerte comme email envoyé
$db = self::getDb();
$updateStmt = $db->prepare('
UPDATE sec_alerts
SET email_sent = 1, email_sent_at = NOW()
WHERE id = :id
');
$updateStmt->execute(['id' => $alertId]);
// Enregistrer l'envoi pour throttling
$throttler->recordSent($type);
}
} catch (\Exception $e) {
error_log("Failed to send alert email: " . $e->getMessage());
}
}
/**
* Formater le corps de l'email
*/
private static function formatEmailBody(string $type, string $level, array $context, int $alertId): string {
$env = strtoupper($context['environment'] ?? 'PRODUCTION');
$timestamp = $context['timestamp'] ?? date('Y-m-d H:i:s');
$body = "
========================================
ALERTE DE SÉCURITÉ - ACTION REQUISE
========================================
Environnement: $env
Date/Heure: $timestamp
Type: $type
Niveau: $level
ID Alerte: #$alertId
RÉSUMÉ
------
" . ($context['message'] ?? 'Alerte détectée sans message spécifique') . "
DÉTAILS TECHNIQUES
------------------
";
if (isset($context['request'])) {
$body .= "
Requête:
- Endpoint: " . $context['request']['uri'] . "
- Méthode: " . $context['request']['method'] . "
- IP: " . $context['request']['ip'] . "
- User Agent: " . substr($context['request']['user_agent'] ?? 'N/A', 0, 100) . "
";
}
if (isset($context['session']) && $context['session']['user_id']) {
$body .= "
Session:
- User ID: " . $context['session']['user_id'] . "
- Entity ID: " . ($context['session']['entity_id'] ?? 'N/A') . "
";
}
if (isset($context['system'])) {
$body .= "
Système:
- Mémoire: " . $context['system']['memory_usage_mb'] . " MB / " . $context['system']['memory_peak_mb'] . " MB (peak)
- Load Average: " . implode(', ', $context['system']['load_average'] ?? []) . "
";
}
// Ajouter les données spécifiques selon le type
$body .= self::getTypeSpecificDetails($type, $context);
// Actions recommandées
$body .= "
ACTIONS RECOMMANDÉES
--------------------
" . self::getRecommendedActions($type, $level, $context) . "
LIENS UTILES
------------
- Logs: https://dapp.geosector.fr/admin/logs
- Dashboard: https://dapp.geosector.fr/admin/security
- Bloquer IP: https://dapp.geosector.fr/admin/block-ip/" . ($context['request']['ip'] ?? '') . "
--
Email automatique généré par GeoSector Security
Ne pas répondre à cet email
";
return $body;
}
/**
* Détails spécifiques selon le type d'alerte
*/
private static function getTypeSpecificDetails(string $type, array $context): string {
$details = "\nDÉTAILS SPÉCIFIQUES\n-------------------\n";
switch ($type) {
case 'BRUTE_FORCE':
$details .= "- Tentatives: " . ($context['attempts'] ?? 'N/A') . "\n";
$details .= "- Username ciblé: " . ($context['username'] ?? 'N/A') . "\n";
$details .= "- Période: " . ($context['timeframe'] ?? 'N/A') . "\n";
break;
case 'SQL_ERROR':
$details .= "- Erreur SQL: " . ($context['sql_error'] ?? 'N/A') . "\n";
$details .= "- Code: " . ($context['sql_code'] ?? 'N/A') . "\n";
break;
case 'PERFORMANCE_SLOW':
case 'PERFORMANCE_CRITICAL':
$details .= "- Temps réponse: " . ($context['response_time_ms'] ?? 'N/A') . " ms\n";
$details .= "- Temps DB: " . ($context['db_time_ms'] ?? 'N/A') . " ms\n";
$details .= "- Seuil dépassé: " . ($context['threshold_ms'] ?? 'N/A') . " ms\n";
break;
case 'HTTP_500':
$details .= "- Message d'erreur: " . ($context['error_message'] ?? 'N/A') . "\n";
$details .= "- Stack trace: " . substr($context['stack_trace'] ?? 'N/A', 0, 500) . "\n";
break;
}
return $details;
}
/**
* Actions recommandées selon le type
*/
private static function getRecommendedActions(string $type, string $level, array $context): string {
$actions = [];
switch ($type) {
case 'BRUTE_FORCE':
$actions[] = "1. Vérifier les logs de connexion";
$actions[] = "2. Bloquer l'IP si nécessaire";
$actions[] = "3. Vérifier l'intégrité du compte ciblé";
$actions[] = "4. Considérer l'activation de 2FA";
break;
case 'SQL_INJECTION':
$actions[] = "1. BLOQUER L'IP IMMÉDIATEMENT";
$actions[] = "2. Vérifier l'intégrité de la base de données";
$actions[] = "3. Analyser les logs pour d'autres tentatives";
$actions[] = "4. Patcher la vulnérabilité identifiée";
break;
case 'DB_CONNECTION':
$actions[] = "1. Vérifier le statut du serveur MySQL";
$actions[] = "2. Vérifier les connexions actives";
$actions[] = "3. Redémarrer le service si nécessaire";
$actions[] = "4. Vérifier l'espace disque";
break;
case 'PERFORMANCE_CRITICAL':
$actions[] = "1. Identifier les requêtes lentes";
$actions[] = "2. Vérifier la charge serveur";
$actions[] = "3. Optimiser les requêtes problématiques";
$actions[] = "4. Considérer un scale-up si récurrent";
break;
default:
$actions[] = "1. Examiner les logs détaillés";
$actions[] = "2. Évaluer l'impact sur les utilisateurs";
$actions[] = "3. Prendre action selon la criticité";
}
return implode("\n", $actions);
}
/**
* Exécuter des actions automatiques
*/
private static function executeAutomaticActions(string $type, array $context): void {
switch ($type) {
case 'BRUTE_FORCE':
// Bloquer automatiquement l'IP
if (isset($context['request']['ip'])) {
require_once __DIR__ . '/IPBlocker.php';
IPBlocker::block(
$context['request']['ip'],
3600, // 1 heure
'Brute force detected: ' . ($context['attempts'] ?? 0) . ' attempts'
);
}
break;
case 'SQL_INJECTION':
// Bloquer immédiatement et définitivement
if (isset($context['request']['ip'])) {
require_once __DIR__ . '/IPBlocker.php';
IPBlocker::blockPermanent(
$context['request']['ip'],
'SQL Injection attempt detected'
);
}
break;
}
}
/**
* Obtenir l'instance de base de données
*/
private static function getDb(): PDO {
if (self::$db === null) {
self::$db = Database::getInstance();
}
return self::$db;
}
/**
* Obtenir l'instance du throttler
*/
private static function getThrottler(): EmailThrottler {
if (self::$throttler === null) {
self::$throttler = new EmailThrottler();
}
return self::$throttler;
}
/**
* Résoudre une alerte
*/
public static function resolve(int $alertId, int $userId, string $notes = ''): bool {
try {
$db = self::getDb();
$stmt = $db->prepare('
UPDATE sec_alerts
SET resolved = 1,
resolved_at = NOW(),
resolved_by = :user_id,
notes = :notes
WHERE id = :id
');
return $stmt->execute([
'id' => $alertId,
'user_id' => $userId,
'notes' => $notes
]);
} catch (\Exception $e) {
error_log("Failed to resolve alert: " . $e->getMessage());
return false;
}
}
/**
* Obtenir les alertes actives
*/
public static function getActiveAlerts(int $limit = 50): array {
try {
$db = self::getDb();
$stmt = $db->prepare('
SELECT * FROM sec_active_alerts
LIMIT :limit
');
$stmt->bindValue('limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (\Exception $e) {
error_log("Failed to get active alerts: " . $e->getMessage());
return [];
}
}
}

View File

@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
namespace App\Services\Security;
use PDO;
use Database;
/**
* Service de throttling pour les emails d'alerte
* Évite le spam et groupe les alertes similaires
*/
class EmailThrottler {
private static ?PDO $db = null;
private static array $cache = [];
// Configuration par défaut
const DEFAULT_CONFIG = [
'max_per_hour' => 10,
'max_per_day' => 50,
'digest_after' => 5, // Grouper après 5 alertes similaires
'cooldown_minutes' => 60
];
/**
* Vérifier si on peut envoyer un email pour ce type d'alerte
*/
public function canSend(string $alertType, int $throttleSeconds = 0): bool {
// Vérifier le cache en mémoire d'abord
if (isset(self::$cache[$alertType])) {
$lastSent = self::$cache[$alertType];
if (time() - $lastSent < $throttleSeconds) {
return false;
}
}
// Vérifier en base de données
$db = self::getDb();
// Vérifier le throttle spécifique au type
if ($throttleSeconds > 0) {
$stmt = $db->prepare('
SELECT COUNT(*) as count
FROM sec_alerts
WHERE alert_type = :type
AND email_sent = 1
AND email_sent_at >= DATE_SUB(NOW(), INTERVAL :seconds SECOND)
');
$stmt->execute([
'type' => $alertType,
'seconds' => $throttleSeconds
]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
if ($result['count'] > 0) {
return false;
}
}
// Vérifier les limites globales
return $this->checkGlobalLimits();
}
/**
* Vérifier les limites globales (par heure et par jour)
*/
private function checkGlobalLimits(): bool {
$db = self::getDb();
$config = self::getConfig();
// Vérifier limite horaire
$hourlyStmt = $db->prepare('
SELECT COUNT(*) as count
FROM sec_alerts
WHERE email_sent = 1
AND email_sent_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
');
$hourlyStmt->execute();
$hourlyCount = $hourlyStmt->fetch(PDO::FETCH_ASSOC)['count'];
if ($hourlyCount >= $config['max_per_hour']) {
error_log("Email throttler: Hourly limit reached ({$hourlyCount}/{$config['max_per_hour']})");
return false;
}
// Vérifier limite quotidienne
$dailyStmt = $db->prepare('
SELECT COUNT(*) as count
FROM sec_alerts
WHERE email_sent = 1
AND email_sent_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)
');
$dailyStmt->execute();
$dailyCount = $dailyStmt->fetch(PDO::FETCH_ASSOC)['count'];
if ($dailyCount >= $config['max_per_day']) {
error_log("Email throttler: Daily limit reached ({$dailyCount}/{$config['max_per_day']})");
return false;
}
return true;
}
/**
* Enregistrer l'envoi d'un email
*/
public function recordSent(string $alertType): void {
// Mettre à jour le cache
self::$cache[$alertType] = time();
// Nettoyer le cache périodiquement
if (count(self::$cache) > 100) {
$cutoff = time() - 3600; // Garder seulement la dernière heure
self::$cache = array_filter(self::$cache, function($time) use ($cutoff) {
return $time > $cutoff;
});
}
}
/**
* Vérifier si on doit envoyer un digest plutôt que des alertes individuelles
*/
public function shouldSendDigest(string $alertType): bool {
$db = self::getDb();
$config = self::getConfig();
// Compter les alertes non envoyées du même type
$stmt = $db->prepare('
SELECT COUNT(*) as count
FROM sec_alerts
WHERE alert_type = :type
AND email_sent = 0
AND resolved = 0
AND created_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
');
$stmt->execute(['type' => $alertType]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result['count'] >= $config['digest_after'];
}
/**
* Obtenir les alertes pour un digest
*/
public function getDigestAlerts(string $alertType, int $limit = 50): array {
$db = self::getDb();
$stmt = $db->prepare('
SELECT *
FROM sec_alerts
WHERE alert_type = :type
AND email_sent = 0
AND resolved = 0
AND created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
ORDER BY alert_level DESC, last_seen DESC
LIMIT :limit
');
$stmt->bindValue('type', $alertType, PDO::PARAM_STR);
$stmt->bindValue('limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Marquer les alertes comme envoyées dans un digest
*/
public function markDigestSent(array $alertIds): void {
if (empty($alertIds)) {
return;
}
$db = self::getDb();
$placeholders = implode(',', array_fill(0, count($alertIds), '?'));
$stmt = $db->prepare("
UPDATE sec_alerts
SET email_sent = 1,
email_sent_at = NOW(),
notes = CONCAT(IFNULL(notes, ''), '\nInclu dans digest email')
WHERE id IN ({$placeholders})
");
$stmt->execute($alertIds);
}
/**
* Obtenir les statistiques de throttling
*/
public function getStats(): array {
$db = self::getDb();
$config = self::getConfig();
// Emails envoyés dans l'heure
$hourlyStmt = $db->prepare('
SELECT COUNT(*) as count
FROM sec_alerts
WHERE email_sent = 1
AND email_sent_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
');
$hourlyStmt->execute();
$hourlyCount = $hourlyStmt->fetch(PDO::FETCH_ASSOC)['count'];
// Emails envoyés dans la journée
$dailyStmt = $db->prepare('
SELECT COUNT(*) as count
FROM sec_alerts
WHERE email_sent = 1
AND email_sent_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)
');
$dailyStmt->execute();
$dailyCount = $dailyStmt->fetch(PDO::FETCH_ASSOC)['count'];
// Alertes en attente
$pendingStmt = $db->prepare('
SELECT
alert_type,
COUNT(*) as count,
MIN(first_seen) as oldest
FROM sec_alerts
WHERE email_sent = 0
AND resolved = 0
GROUP BY alert_type
');
$pendingStmt->execute();
$pending = $pendingStmt->fetchAll(PDO::FETCH_ASSOC);
return [
'hourly' => [
'sent' => $hourlyCount,
'limit' => $config['max_per_hour'],
'remaining' => max(0, $config['max_per_hour'] - $hourlyCount)
],
'daily' => [
'sent' => $dailyCount,
'limit' => $config['max_per_day'],
'remaining' => max(0, $config['max_per_day'] - $dailyCount)
],
'pending_alerts' => $pending,
'cache_size' => count(self::$cache),
'config' => $config
];
}
/**
* Réinitialiser les compteurs (utile pour les tests)
*/
public function reset(): void {
self::$cache = [];
// Optionnel : réinitialiser les flags en DB
$db = self::getDb();
$stmt = $db->prepare('
UPDATE sec_alerts
SET email_sent = 0, email_sent_at = NULL
WHERE email_sent_at < DATE_SUB(NOW(), INTERVAL 1 DAY)
');
$stmt->execute();
}
/**
* Obtenir la configuration
*/
private static function getConfig(): array {
// Essayer de charger depuis AppConfig si disponible
try {
if (class_exists('AppConfig')) {
$appConfig = \AppConfig::getInstance();
// AppConfig n'a pas de méthode get() générique
// On pourrait ajouter une configuration dans getCurrentConfig() si nécessaire
// Pour l'instant, utiliser la configuration par défaut
}
} catch (\Exception $e) {
// Utiliser config par défaut
}
return self::DEFAULT_CONFIG;
}
/**
* Obtenir l'instance de base de données
*/
private static function getDb(): PDO {
if (self::$db === null) {
self::$db = Database::getInstance();
}
return self::$db;
}
}

View File

@@ -0,0 +1,510 @@
<?php
declare(strict_types=1);
namespace App\Services\Security;
require_once __DIR__ . '/AlertService.php';
use PDO;
use Database;
/**
* Service de gestion des blocages d'IP
* Bloque temporairement ou définitivement des adresses IP suspectes
*/
class IPBlocker {
private static ?PDO $db = null;
private static array $cache = [];
private static ?int $lastCacheClean = null;
// IPs en whitelist (jamais bloquées)
const WHITELIST = [
'127.0.0.1',
'::1',
'localhost'
];
/**
* Vérifier si une IP est bloquée
*/
public static function isBlocked(string $ip): bool {
// Vérifier la whitelist
if (in_array($ip, self::WHITELIST)) {
return false;
}
// Vérifier le cache en mémoire
if (isset(self::$cache[$ip])) {
$cached = self::$cache[$ip];
if ($cached['blocked_until'] > time()) {
return true;
} else {
// Expirée, retirer du cache
unset(self::$cache[$ip]);
}
}
// Vérifier en base de données
$db = self::getDb();
$stmt = $db->prepare('
SELECT ip_address, blocked_until, permanent, reason
FROM sec_blocked_ips
WHERE ip_address = :ip
AND (permanent = 1 OR blocked_until > NOW())
AND unblocked_at IS NULL
LIMIT 1
');
$stmt->execute(['ip' => $ip]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
if ($result) {
// Mettre en cache
self::$cache[$ip] = [
'blocked_until' => strtotime($result['blocked_until']),
'permanent' => (bool)$result['permanent'],
'reason' => $result['reason']
];
// Nettoyer le cache périodiquement
self::cleanCacheIfNeeded();
return true;
}
return false;
}
/**
* Bloquer une IP temporairement
*/
public static function block(
string $ip,
int $durationSeconds = 3600,
string $reason = 'Suspicious activity',
string $blockedBy = 'system'
): bool {
// Ne pas bloquer les IPs en whitelist
if (in_array($ip, self::WHITELIST)) {
error_log("Attempted to block whitelisted IP: $ip");
return false;
}
$db = self::getDb();
try {
// Vérifier si l'IP est déjà bloquée
$checkStmt = $db->prepare('
SELECT ip_address, block_count
FROM sec_blocked_ips
WHERE ip_address = :ip
LIMIT 1
');
$checkStmt->execute(['ip' => $ip]);
$existing = $checkStmt->fetch(PDO::FETCH_ASSOC);
$blockedUntil = date('Y-m-d H:i:s', time() + $durationSeconds);
if ($existing) {
// Mettre à jour le blocage existant
$updateStmt = $db->prepare('
UPDATE sec_blocked_ips
SET blocked_until = :until,
reason = :reason,
blocked_at = NOW(),
blocked_by = :by,
block_count = block_count + 1,
unblocked_at = NULL,
permanent = 0
WHERE ip_address = :ip
');
$updateStmt->execute([
'ip' => $ip,
'until' => $blockedUntil,
'reason' => $reason,
'by' => $blockedBy
]);
$blockCount = $existing['block_count'] + 1;
// Si bloquée plus de 3 fois, envisager un blocage permanent
if ($blockCount >= 3) {
AlertService::trigger('REPEAT_OFFENDER', [
'ip' => $ip,
'block_count' => $blockCount,
'reason' => $reason,
'message' => "IP bloquée pour la {$blockCount}e fois, considérer blocage permanent"
], 'WARNING');
}
} else {
// Créer un nouveau blocage
$insertStmt = $db->prepare('
INSERT INTO sec_blocked_ips (
ip_address, reason, blocked_until, blocked_by,
permanent, details
) VALUES (
:ip, :reason, :until, :by, 0, :details
)
');
$details = json_encode([
'timestamp' => date('c'),
'duration_seconds' => $durationSeconds,
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
'endpoint' => $_SERVER['REQUEST_URI'] ?? null
]);
$insertStmt->execute([
'ip' => $ip,
'reason' => $reason,
'until' => $blockedUntil,
'by' => $blockedBy,
'details' => $details
]);
}
// Mettre à jour le cache
self::$cache[$ip] = [
'blocked_until' => time() + $durationSeconds,
'permanent' => false,
'reason' => $reason
];
// Logger l'action
error_log("IP blocked: $ip for {$durationSeconds}s - Reason: $reason");
return true;
} catch (\Exception $e) {
error_log("Failed to block IP: " . $e->getMessage());
return false;
}
}
/**
* Bloquer une IP définitivement
*/
public static function blockPermanent(
string $ip,
string $reason = 'Security threat',
string $blockedBy = 'system'
): bool {
// Ne pas bloquer les IPs en whitelist
if (in_array($ip, self::WHITELIST)) {
error_log("Attempted to permanently block whitelisted IP: $ip");
return false;
}
$db = self::getDb();
try {
// Vérifier si l'IP existe déjà
$checkStmt = $db->prepare('
SELECT ip_address FROM sec_blocked_ips
WHERE ip_address = :ip
LIMIT 1
');
$checkStmt->execute(['ip' => $ip]);
$existing = $checkStmt->fetch(PDO::FETCH_ASSOC);
if ($existing) {
// Mettre à jour en permanent
$updateStmt = $db->prepare('
UPDATE sec_blocked_ips
SET permanent = 1,
blocked_until = "2099-12-31 23:59:59",
reason = :reason,
blocked_at = NOW(),
blocked_by = :by,
unblocked_at = NULL
WHERE ip_address = :ip
');
$updateStmt->execute([
'ip' => $ip,
'reason' => $reason,
'by' => $blockedBy
]);
} else {
// Créer un blocage permanent
$insertStmt = $db->prepare('
INSERT INTO sec_blocked_ips (
ip_address, reason, blocked_until, blocked_by,
permanent, details
) VALUES (
:ip, :reason, "2099-12-31 23:59:59", :by, 1, :details
)
');
$details = json_encode([
'timestamp' => date('c'),
'permanent' => true,
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
'endpoint' => $_SERVER['REQUEST_URI'] ?? null
]);
$insertStmt->execute([
'ip' => $ip,
'reason' => $reason,
'by' => $blockedBy,
'details' => $details
]);
}
// Mettre à jour le cache
self::$cache[$ip] = [
'blocked_until' => PHP_INT_MAX,
'permanent' => true,
'reason' => $reason
];
// Alerter pour blocage permanent
AlertService::trigger('IP_BLOCKED_PERMANENT', [
'ip' => $ip,
'reason' => $reason,
'blocked_by' => $blockedBy,
'message' => "IP bloquée définitivement : $ip"
], 'SECURITY');
return true;
} catch (\Exception $e) {
error_log("Failed to permanently block IP: " . $e->getMessage());
return false;
}
}
/**
* Débloquer une IP
*/
public static function unblock(string $ip, int $unblockedBy = null): bool {
$db = self::getDb();
try {
$stmt = $db->prepare('
UPDATE sec_blocked_ips
SET unblocked_at = NOW(),
unblocked_by = :by
WHERE ip_address = :ip
AND unblocked_at IS NULL
');
$stmt->execute([
'ip' => $ip,
'by' => $unblockedBy
]);
// Retirer du cache
unset(self::$cache[$ip]);
$affected = $stmt->rowCount();
if ($affected > 0) {
error_log("IP unblocked: $ip by user $unblockedBy");
// Logger l'action
AlertService::trigger('IP_UNBLOCKED', [
'ip' => $ip,
'unblocked_by' => $unblockedBy,
'message' => "IP débloquée manuellement : $ip"
], 'INFO');
}
return $affected > 0;
} catch (\Exception $e) {
error_log("Failed to unblock IP: " . $e->getMessage());
return false;
}
}
/**
* Obtenir la liste des IPs bloquées
*/
public static function getBlockedIPs(bool $activeOnly = true): array {
$db = self::getDb();
$whereClause = $activeOnly
? 'WHERE unblocked_at IS NULL AND (permanent = 1 OR blocked_until > NOW())'
: '';
$stmt = $db->prepare("
SELECT
ip_address,
reason,
blocked_at,
blocked_until,
blocked_by,
permanent,
unblocked_at,
unblocked_by,
block_count,
details
FROM sec_blocked_ips
$whereClause
ORDER BY blocked_at DESC
");
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Vérifier si une IP est dans un range CIDR
*/
public static function isInRange(string $ip, string $cidr): bool {
list($subnet, $bits) = explode('/', $cidr);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) &&
filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
// IPv4
$ip = ip2long($ip);
$subnet = ip2long($subnet);
$mask = -1 << (32 - $bits);
$subnet &= $mask;
return ($ip & $mask) == $subnet;
} elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) &&
filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
// IPv6
$ipBin = inet_pton($ip);
$subnetBin = inet_pton($subnet);
$byteCount = (int)($bits / 8);
$bitCount = $bits % 8;
for ($i = 0; $i < $byteCount; $i++) {
if ($ipBin[$i] !== $subnetBin[$i]) {
return false;
}
}
if ($bitCount > 0) {
$mask = 0xFF << (8 - $bitCount);
return (ord($ipBin[$byteCount]) & $mask) === (ord($subnetBin[$byteCount]) & $mask);
}
return true;
}
return false;
}
/**
* Nettoyer les blocages expirés
*/
public static function cleanupExpired(): int {
$db = self::getDb();
try {
$stmt = $db->prepare('
UPDATE sec_blocked_ips
SET unblocked_at = NOW()
WHERE blocked_until <= NOW()
AND permanent = 0
AND unblocked_at IS NULL
');
$stmt->execute();
$cleaned = $stmt->rowCount();
if ($cleaned > 0) {
error_log("Cleaned up $cleaned expired IP blocks");
}
return $cleaned;
} catch (\Exception $e) {
error_log("Failed to cleanup expired blocks: " . $e->getMessage());
return 0;
}
}
/**
* Obtenir les statistiques de blocage
*/
public static function getStats(): array {
$db = self::getDb();
// Total des IPs bloquées
$totalStmt = $db->prepare('
SELECT
COUNT(*) as total,
SUM(CASE WHEN permanent = 1 THEN 1 ELSE 0 END) as permanent,
SUM(CASE WHEN permanent = 0 AND blocked_until > NOW() THEN 1 ELSE 0 END) as temporary,
SUM(CASE WHEN unblocked_at IS NOT NULL THEN 1 ELSE 0 END) as unblocked
FROM sec_blocked_ips
');
$totalStmt->execute();
$totals = $totalStmt->fetch(PDO::FETCH_ASSOC);
// Blocages récents (24h)
$recentStmt = $db->prepare('
SELECT COUNT(*) as count
FROM sec_blocked_ips
WHERE blocked_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
');
$recentStmt->execute();
$recent = $recentStmt->fetch(PDO::FETCH_ASSOC);
// Top raisons de blocage
$reasonsStmt = $db->prepare('
SELECT
reason,
COUNT(*) as count
FROM sec_blocked_ips
WHERE unblocked_at IS NULL
GROUP BY reason
ORDER BY count DESC
LIMIT 10
');
$reasonsStmt->execute();
$reasons = $reasonsStmt->fetchAll(PDO::FETCH_ASSOC);
return [
'totals' => $totals,
'recent_24h' => $recent['count'],
'top_reasons' => $reasons,
'cache_size' => count(self::$cache),
'whitelist' => self::WHITELIST
];
}
/**
* Nettoyer le cache si nécessaire
*/
private static function cleanCacheIfNeeded(): void {
$now = time();
// Nettoyer toutes les 5 minutes
if (self::$lastCacheClean === null || $now - self::$lastCacheClean > 300) {
self::$cache = array_filter(self::$cache, function($item) use ($now) {
return $item['permanent'] || $item['blocked_until'] > $now;
});
self::$lastCacheClean = $now;
}
// Limiter la taille du cache
if (count(self::$cache) > 1000) {
self::$cache = array_slice(self::$cache, -500, null, true);
}
}
/**
* Obtenir l'instance de base de données
*/
private static function getDb(): PDO {
if (self::$db === null) {
self::$db = Database::getInstance();
}
return self::$db;
}
}

View File

@@ -0,0 +1,452 @@
<?php
declare(strict_types=1);
namespace App\Services\Security;
require_once __DIR__ . '/AlertService.php';
use PDO;
use Database;
/**
* Service de monitoring des performances
* Mesure et alerte sur les temps de réponse, utilisation mémoire, etc.
*/
class PerformanceMonitor {
private static ?PDO $db = null;
private static ?float $requestStartTime = null;
private static ?float $requestStartMemory = null;
private static array $dbQueries = [];
private static float $dbTotalTime = 0;
private static int $dbQueryCount = 0;
// Seuils par défaut (en millisecondes)
const DEFAULT_THRESHOLDS = [
'response_time_warning' => 1000, // 1 seconde
'response_time_critical' => 3000, // 3 secondes
'db_time_warning' => 500, // 500ms
'db_time_critical' => 1000, // 1 seconde
'memory_warning' => 64, // 64 MB
'memory_critical' => 128, // 128 MB
'db_queries_warning' => 20, // 20 requêtes
'db_queries_critical' => 50 // 50 requêtes
];
// Seuils spécifiques par endpoint
const ENDPOINT_THRESHOLDS = [
'/api/operations/export' => [
'response_time_warning' => 5000,
'response_time_critical' => 10000,
'memory_warning' => 256,
'memory_critical' => 512
],
'/api/chat/rooms' => [
'response_time_warning' => 500,
'response_time_critical' => 1000
],
'/api/logs' => [
'response_time_warning' => 200,
'response_time_critical' => 500
]
];
/**
* Démarrer le monitoring d'une requête
*/
public static function startRequest(): void {
self::$requestStartTime = microtime(true);
self::$requestStartMemory = memory_get_usage(true);
self::$dbQueries = [];
self::$dbTotalTime = 0;
self::$dbQueryCount = 0;
}
/**
* Démarrer le monitoring d'une requête DB
*/
public static function startDbQuery(string $query): void {
self::$dbQueries[] = [
'query' => $query,
'start' => microtime(true),
'duration' => 0
];
}
/**
* Terminer le monitoring d'une requête DB
*/
public static function endDbQuery(): void {
if (!empty(self::$dbQueries)) {
$lastIndex = count(self::$dbQueries) - 1;
$duration = (microtime(true) - self::$dbQueries[$lastIndex]['start']) * 1000;
self::$dbQueries[$lastIndex]['duration'] = $duration;
self::$dbTotalTime += $duration;
self::$dbQueryCount++;
// Alerter si requête très lente
if ($duration > 1000) { // Plus d'1 seconde
AlertService::trigger('SLOW_QUERY', [
'query' => substr(self::$dbQueries[$lastIndex]['query'], 0, 500),
'duration_ms' => $duration,
'message' => "Requête SQL lente détectée : {$duration}ms"
], 'WARNING');
}
}
}
/**
* Terminer le monitoring d'une requête HTTP
*/
public static function endRequest(string $endpoint, string $method, int $httpStatus): void {
if (self::$requestStartTime === null) {
return; // Monitoring non démarré
}
// Calculer les métriques
$responseTime = (microtime(true) - self::$requestStartTime) * 1000;
$memoryPeak = memory_get_peak_usage(true) / 1024 / 1024; // En MB
$memoryStart = self::$requestStartMemory / 1024 / 1024;
$memoryUsed = $memoryPeak - $memoryStart;
// Enrichir avec les infos de requête
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null;
$requestSize = strlen(file_get_contents('php://input'));
// ID utilisateur si connecté
$userId = null;
if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['user_id'])) {
$userId = $_SESSION['user_id'];
}
// Enregistrer les métriques
self::saveMetrics([
'endpoint' => $endpoint,
'method' => $method,
'response_time_ms' => (int)$responseTime,
'db_time_ms' => (int)self::$dbTotalTime,
'db_queries_count' => self::$dbQueryCount,
'memory_peak_mb' => $memoryPeak,
'memory_start_mb' => $memoryStart,
'http_status' => $httpStatus,
'user_id' => $userId,
'ip_address' => $ip,
'user_agent' => $userAgent,
'request_size' => $requestSize,
'response_size' => ob_get_length() ?: 0
]);
// Vérifier les seuils et alerter si nécessaire
self::checkThresholds($endpoint, $responseTime, self::$dbTotalTime, $memoryPeak);
// Vérifier la dégradation des performances
self::checkPerformanceDegradation($endpoint, $responseTime);
// Réinitialiser pour la prochaine requête
self::reset();
}
/**
* Sauvegarder les métriques en base
*/
private static function saveMetrics(array $metrics): void {
try {
$db = self::getDb();
$stmt = $db->prepare('
INSERT INTO sec_performance_metrics (
endpoint, method, response_time_ms, db_time_ms, db_queries_count,
memory_peak_mb, memory_start_mb, http_status, user_id,
ip_address, user_agent, request_size, response_size
) VALUES (
:endpoint, :method, :response_time, :db_time, :db_queries,
:memory_peak, :memory_start, :status, :user_id,
:ip, :agent, :req_size, :resp_size
)
');
$stmt->execute([
'endpoint' => $metrics['endpoint'],
'method' => $metrics['method'],
'response_time' => $metrics['response_time_ms'],
'db_time' => $metrics['db_time_ms'],
'db_queries' => $metrics['db_queries_count'],
'memory_peak' => $metrics['memory_peak_mb'],
'memory_start' => $metrics['memory_start_mb'],
'status' => $metrics['http_status'],
'user_id' => $metrics['user_id'],
'ip' => $metrics['ip_address'],
'agent' => $metrics['user_agent'],
'req_size' => $metrics['request_size'],
'resp_size' => $metrics['response_size']
]);
} catch (\Exception $e) {
error_log("Failed to save performance metrics: " . $e->getMessage());
}
}
/**
* Vérifier les seuils de performance
*/
private static function checkThresholds(
string $endpoint,
float $responseTime,
float $dbTime,
float $memoryPeak
): void {
// Obtenir les seuils pour cet endpoint
$thresholds = self::getThresholdsForEndpoint($endpoint);
// Vérifier temps de réponse
if ($responseTime > $thresholds['response_time_critical']) {
AlertService::trigger('PERFORMANCE_CRITICAL', [
'endpoint' => $endpoint,
'response_time_ms' => $responseTime,
'threshold_ms' => $thresholds['response_time_critical'],
'db_time_ms' => $dbTime,
'message' => "Performance critique sur $endpoint : {$responseTime}ms"
], 'ERROR');
} elseif ($responseTime > $thresholds['response_time_warning']) {
AlertService::trigger('PERFORMANCE_SLOW', [
'endpoint' => $endpoint,
'response_time_ms' => $responseTime,
'threshold_ms' => $thresholds['response_time_warning'],
'db_time_ms' => $dbTime,
'message' => "Performance lente sur $endpoint : {$responseTime}ms"
], 'WARNING');
}
// Vérifier temps DB
if ($dbTime > $thresholds['db_time_critical']) {
AlertService::trigger('DB_PERFORMANCE_CRITICAL', [
'endpoint' => $endpoint,
'db_time_ms' => $dbTime,
'threshold_ms' => $thresholds['db_time_critical'],
'queries_count' => self::$dbQueryCount,
'message' => "Temps DB critique : {$dbTime}ms pour " . self::$dbQueryCount . " requêtes"
], 'ERROR');
} elseif ($dbTime > $thresholds['db_time_warning']) {
AlertService::trigger('DB_PERFORMANCE_SLOW', [
'endpoint' => $endpoint,
'db_time_ms' => $dbTime,
'threshold_ms' => $thresholds['db_time_warning'],
'queries_count' => self::$dbQueryCount,
'message' => "Temps DB lent : {$dbTime}ms"
], 'WARNING');
}
// Vérifier mémoire
if ($memoryPeak > $thresholds['memory_critical']) {
AlertService::trigger('MEMORY_CRITICAL', [
'endpoint' => $endpoint,
'memory_mb' => $memoryPeak,
'threshold_mb' => $thresholds['memory_critical'],
'message' => "Utilisation mémoire critique : {$memoryPeak}MB"
], 'ERROR');
} elseif ($memoryPeak > $thresholds['memory_warning']) {
AlertService::trigger('MEMORY_HIGH', [
'endpoint' => $endpoint,
'memory_mb' => $memoryPeak,
'threshold_mb' => $thresholds['memory_warning'],
'message' => "Utilisation mémoire élevée : {$memoryPeak}MB"
], 'WARNING');
}
// Vérifier nombre de requêtes DB
if (self::$dbQueryCount > $thresholds['db_queries_critical']) {
AlertService::trigger('DB_QUERIES_EXCESSIVE', [
'endpoint' => $endpoint,
'queries_count' => self::$dbQueryCount,
'threshold' => $thresholds['db_queries_critical'],
'message' => "Trop de requêtes DB : " . self::$dbQueryCount
], 'ERROR');
}
}
/**
* Vérifier la dégradation des performances
*/
private static function checkPerformanceDegradation(string $endpoint, float $currentTime): void {
try {
$db = self::getDb();
// Calculer la moyenne sur les 100 dernières requêtes
$stmt = $db->prepare('
SELECT AVG(response_time_ms) as avg_time,
STDDEV(response_time_ms) as stddev_time,
COUNT(*) as sample_size
FROM (
SELECT response_time_ms
FROM sec_performance_metrics
WHERE endpoint = :endpoint
AND http_status < 500
AND created_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
ORDER BY created_at DESC
LIMIT 100
) as recent
');
$stmt->execute(['endpoint' => $endpoint]);
$stats = $stmt->fetch(PDO::FETCH_ASSOC);
if ($stats && $stats['sample_size'] >= 10) {
$avgTime = (float)$stats['avg_time'];
$stdDev = (float)$stats['stddev_time'];
// Alerter si 2x plus lent que la moyenne
if ($currentTime > ($avgTime * 2) && $avgTime > 100) {
AlertService::trigger('PERFORMANCE_DEGRADATION', [
'endpoint' => $endpoint,
'current_time_ms' => $currentTime,
'average_time_ms' => $avgTime,
'stddev_ms' => $stdDev,
'factor' => round($currentTime / $avgTime, 2),
'sample_size' => $stats['sample_size'],
'message' => "Performance dégradée : " . round($currentTime / $avgTime, 1) . "x plus lent que la moyenne"
], 'WARNING');
}
}
} catch (\Exception $e) {
error_log("Failed to check performance degradation: " . $e->getMessage());
}
}
/**
* Obtenir les seuils pour un endpoint
*/
private static function getThresholdsForEndpoint(string $endpoint): array {
// Chercher des seuils spécifiques
foreach (self::ENDPOINT_THRESHOLDS as $pattern => $thresholds) {
if (strpos($endpoint, $pattern) === 0) {
return array_merge(self::DEFAULT_THRESHOLDS, $thresholds);
}
}
// Retourner les seuils par défaut
return self::DEFAULT_THRESHOLDS;
}
/**
* Obtenir les statistiques de performance
*/
public static function getStats(string $endpoint = null, int $hours = 24): array {
$db = self::getDb();
$whereClause = 'WHERE created_at >= DATE_SUB(NOW(), INTERVAL :hours HOUR)';
$params = ['hours' => $hours];
if ($endpoint) {
$whereClause .= ' AND endpoint = :endpoint';
$params['endpoint'] = $endpoint;
}
// Statistiques globales
$globalStmt = $db->prepare("
SELECT
COUNT(*) as total_requests,
AVG(response_time_ms) as avg_response_time,
MIN(response_time_ms) as min_response_time,
MAX(response_time_ms) as max_response_time,
AVG(db_time_ms) as avg_db_time,
AVG(memory_peak_mb) as avg_memory,
SUM(CASE WHEN http_status >= 500 THEN 1 ELSE 0 END) as errors_5xx,
SUM(CASE WHEN http_status >= 400 AND http_status < 500 THEN 1 ELSE 0 END) as errors_4xx
FROM sec_performance_metrics
$whereClause
");
$globalStmt->execute($params);
$globalStats = $globalStmt->fetch(PDO::FETCH_ASSOC);
// Top endpoints lents
$slowStmt = $db->prepare("
SELECT
endpoint,
method,
COUNT(*) as requests,
AVG(response_time_ms) as avg_time,
MAX(response_time_ms) as max_time
FROM sec_performance_metrics
$whereClause
GROUP BY endpoint, method
ORDER BY avg_time DESC
LIMIT 10
");
$slowStmt->execute($params);
$slowEndpoints = $slowStmt->fetchAll(PDO::FETCH_ASSOC);
// Requêtes DB lentes
$slowDbStmt = $db->prepare("
SELECT
endpoint,
MAX(db_time_ms) as max_db_time,
AVG(db_time_ms) as avg_db_time,
AVG(db_queries_count) as avg_queries
FROM sec_performance_metrics
WHERE db_time_ms > 500
AND created_at >= DATE_SUB(NOW(), INTERVAL :hours HOUR)
GROUP BY endpoint
ORDER BY max_db_time DESC
LIMIT 10
");
$slowDbStmt->execute(['hours' => $hours]);
$slowDbQueries = $slowDbStmt->fetchAll(PDO::FETCH_ASSOC);
return [
'timeframe_hours' => $hours,
'global' => $globalStats,
'slow_endpoints' => $slowEndpoints,
'slow_db_operations' => $slowDbQueries,
'current_memory_mb' => round(memory_get_usage(true) / 1024 / 1024, 2),
'current_load' => sys_getloadavg()
];
}
/**
* Nettoyer les anciennes métriques
*/
public static function cleanup(int $daysToKeep = 30): int {
try {
$db = self::getDb();
$stmt = $db->prepare('
DELETE FROM sec_performance_metrics
WHERE created_at < DATE_SUB(NOW(), INTERVAL :days DAY)
');
$stmt->execute(['days' => $daysToKeep]);
return $stmt->rowCount();
} catch (\Exception $e) {
error_log("Failed to cleanup performance metrics: " . $e->getMessage());
return 0;
}
}
/**
* Réinitialiser le monitoring
*/
private static function reset(): void {
self::$requestStartTime = null;
self::$requestStartMemory = null;
self::$dbQueries = [];
self::$dbTotalTime = 0;
self::$dbQueryCount = 0;
}
/**
* Obtenir l'instance de base de données
*/
private static function getDb(): PDO {
if (self::$db === null) {
self::$db = Database::getInstance();
}
return self::$db;
}
}

View File

@@ -0,0 +1,440 @@
<?php
declare(strict_types=1);
namespace App\Services\Security;
require_once __DIR__ . '/AlertService.php';
require_once __DIR__ . '/IPBlocker.php';
use PDO;
use Database;
/**
* Service de monitoring de sécurité
* Détecte les patterns d'attaque et les comportements suspects
*/
class SecurityMonitor {
private static ?PDO $db = null;
// Patterns suspects dans les paramètres
const SQL_INJECTION_PATTERNS = [
'/\bunion\b.*\bselect\b/i',
'/\bselect\b.*\bfrom\b.*\bwhere\b/i',
'/\bdrop\b.*\btable\b/i',
'/\binsert\b.*\binto\b/i',
'/\bupdate\b.*\bset\b/i',
'/\bdelete\b.*\bfrom\b/i',
'/\bexec(\s|\()/i',
'/\bscript\b.*\b\/script\b/i',
'/\b(char|nchar|varchar|nvarchar)\s*\(/i',
'/\bconvert\s*\(/i',
'/\bcast\s*\(/i',
'/\bwaitfor\s+delay\b/i',
'/\bsleep\s*\(/i',
'/\bbenchmark\s*\(/i',
'/\;.*\-\-/i',
'/\bor\b.*\=.*\bor\b/i',
'/\b1\s*\=\s*1\b/i',
'/\b\'\s*or\s*\'\s*\=\s*\'/i'
];
// Patterns de scan/exploration
const SCAN_PATTERNS = [
'admin', 'administrator', 'wp-admin', 'phpmyadmin',
'.git', '.env', 'config.php', 'wp-config.php',
'backup', '.sql', '.bak', '.zip', '.tar',
'shell.php', 'c99.php', 'r57.php', 'eval.php'
];
/**
* Vérifier les tentatives de brute force
*/
public static function checkBruteForce(string $ip, string $username = null): bool {
$db = self::getDb();
// Compter les tentatives récentes depuis cette IP
$stmt = $db->prepare('
SELECT COUNT(*) as attempts,
COUNT(DISTINCT username) as unique_users,
MIN(attempt_time) as first_attempt,
MAX(attempt_time) as last_attempt
FROM sec_failed_login_attempts
WHERE ip_address = :ip
AND attempt_time >= DATE_SUB(NOW(), INTERVAL 5 MINUTE)
');
$stmt->execute(['ip' => $ip]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
$attempts = (int)$result['attempts'];
$uniqueUsers = (int)$result['unique_users'];
// Critères de détection
$isBruteForce = false;
$reason = '';
if ($attempts >= 5) {
$isBruteForce = true;
$reason = "Plus de 5 tentatives en 5 minutes";
} elseif ($uniqueUsers >= 3) {
$isBruteForce = true;
$reason = "Tentatives sur 3 usernames différents";
}
if ($isBruteForce) {
// Déclencher l'alerte
AlertService::trigger('BRUTE_FORCE', [
'ip' => $ip,
'username' => $username,
'attempts' => $attempts,
'unique_usernames' => $uniqueUsers,
'timeframe' => '5 minutes',
'reason' => $reason,
'message' => "Attaque brute force détectée depuis $ip : $reason"
], 'SECURITY');
return false; // Bloquer
}
return true; // Autoriser
}
/**
* Enregistrer une tentative de login échouée
*/
public static function recordFailedLogin(
string $ip,
string $username = null,
string $errorType = 'invalid_credentials',
string $userAgent = null
): void {
$db = self::getDb();
try {
// Chercher si le username existe (pour stocker la version chiffrée)
$encryptedUsername = null;
if ($username) {
$userStmt = $db->prepare('
SELECT encrypted_user_name
FROM users
WHERE username = :username
LIMIT 1
');
$userStmt->execute(['username' => $username]);
$user = $userStmt->fetch(PDO::FETCH_ASSOC);
if ($user) {
$encryptedUsername = $user['encrypted_user_name'];
}
}
// Enregistrer la tentative
$stmt = $db->prepare('
INSERT INTO sec_failed_login_attempts (
username, encrypted_username, ip_address,
user_agent, error_type
) VALUES (
:username, :encrypted, :ip, :agent, :error
)
');
$stmt->execute([
'username' => $username,
'encrypted' => $encryptedUsername,
'ip' => $ip,
'agent' => $userAgent,
'error' => $errorType
]);
// Vérifier si on doit bloquer l'IP
if (!self::checkBruteForce($ip, $username)) {
// L'IP sera bloquée par AlertService via les actions automatiques
}
} catch (\Exception $e) {
error_log("Failed to record login attempt: " . $e->getMessage());
}
}
/**
* Vérifier les patterns d'injection SQL
*/
public static function checkSQLInjection(string $value): bool {
// Nettoyer la valeur pour l'analyse
$decoded = urldecode($value);
foreach (self::SQL_INJECTION_PATTERNS as $pattern) {
if (preg_match($pattern, $decoded)) {
// Injection SQL détectée !
$context = [
'pattern_matched' => $pattern,
'value' => substr($value, 0, 500), // Limiter la taille
'decoded_value' => substr($decoded, 0, 500),
'message' => 'Tentative d\'injection SQL détectée'
];
// Enrichir avec le contexte de requête
if (isset($_SERVER['REQUEST_URI'])) {
$context['endpoint'] = $_SERVER['REQUEST_URI'];
$context['method'] = $_SERVER['REQUEST_METHOD'] ?? 'unknown';
$context['ip'] = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
}
AlertService::trigger('SQL_INJECTION', $context, 'SECURITY');
return false; // Bloquer la requête
}
}
return true; // Pas d'injection détectée
}
/**
* Vérifier tous les paramètres d'une requête
*/
public static function checkRequestParameters(array $params): bool {
$suspicious = [];
foreach ($params as $key => $value) {
if (is_string($value)) {
if (!self::checkSQLInjection($value)) {
$suspicious[$key] = $value;
}
} elseif (is_array($value)) {
// Vérifier récursivement
if (!self::checkRequestParameters($value)) {
$suspicious[$key] = $value;
}
}
}
if (!empty($suspicious)) {
// Paramètres suspects détectés
return false;
}
return true;
}
/**
* Détecter les patterns de scan/exploration
*/
public static function checkScanPattern(string $uri): bool {
$lowerUri = strtolower($uri);
foreach (self::SCAN_PATTERNS as $pattern) {
if (strpos($lowerUri, $pattern) !== false) {
// Pattern de scan détecté
AlertService::trigger('SUSPICIOUS_PATTERN', [
'pattern' => $pattern,
'uri' => $uri,
'message' => "Tentative d'accès à un fichier sensible : $pattern"
], 'WARNING');
return false;
}
}
return true;
}
/**
* Vérifier les accès non autorisés
*/
public static function checkUnauthorizedAccess(string $endpoint, bool $hasAuth): bool {
// Endpoints publics qui n'ont pas besoin d'auth
$publicEndpoints = [
'/api/login',
'/api/users/check-username',
'/api/logs',
'/api/health',
'/api/status'
];
// Vérifier si l'endpoint nécessite une auth
$isPublic = false;
foreach ($publicEndpoints as $public) {
if (strpos($endpoint, $public) === 0) {
$isPublic = true;
break;
}
}
if (!$isPublic && !$hasAuth) {
// Accès non autorisé
AlertService::trigger('UNAUTHORIZED_ACCESS', [
'endpoint' => $endpoint,
'message' => "Tentative d'accès sans authentification à : $endpoint"
], 'WARNING');
return false;
}
return true;
}
/**
* Analyser les patterns 404
*/
public static function check404Pattern(string $ip): void {
$db = self::getDb();
// Compter les 404 récents de cette IP
$stmt = $db->prepare('
SELECT COUNT(*) as count,
COUNT(DISTINCT endpoint) as unique_endpoints
FROM sec_performance_metrics
WHERE ip_address = :ip
AND http_status = 404
AND created_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE)
');
$stmt->execute(['ip' => $ip]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
$count404 = (int)$result['count'];
$uniqueEndpoints = (int)$result['unique_endpoints'];
// Si trop de 404, c'est suspect
if ($count404 >= 10 || $uniqueEndpoints >= 5) {
AlertService::trigger('HTTP_404_PATTERN', [
'ip' => $ip,
'count_404' => $count404,
'unique_endpoints' => $uniqueEndpoints,
'timeframe' => '10 minutes',
'message' => "Pattern de scan détecté : $count404 erreurs 404 sur $uniqueEndpoints endpoints"
], 'WARNING');
}
}
/**
* Vérifier la vitesse des requêtes (rate limiting)
*/
public static function checkRateLimit(string $ip): bool {
$db = self::getDb();
// Compter les requêtes de la dernière minute
$stmt = $db->prepare('
SELECT COUNT(*) as count
FROM sec_performance_metrics
WHERE ip_address = :ip
AND created_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE)
');
$stmt->execute(['ip' => $ip]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
$requestsPerMinute = (int)$result['count'];
// Limite : 60 requêtes par minute
if ($requestsPerMinute > 60) {
AlertService::trigger('RATE_LIMIT_EXCEEDED', [
'ip' => $ip,
'requests_per_minute' => $requestsPerMinute,
'limit' => 60,
'message' => "Limite de taux dépassée : $requestsPerMinute requêtes/minute"
], 'WARNING');
return false;
}
return true;
}
/**
* Analyser une erreur SQL pour détecter les problèmes
*/
public static function analyzeSQLError(\PDOException $e, string $query = null): void {
$message = $e->getMessage();
$code = $e->getCode();
// Classifier l'erreur
$level = 'ERROR';
$type = 'SQL_ERROR';
if (strpos($message, 'Unknown column') !== false) {
$type = 'SQL_ERROR';
$level = 'ERROR';
} elseif (strpos($message, 'Table') !== false && strpos($message, 'doesn\'t exist') !== false) {
$type = 'SQL_ERROR';
$level = 'CRITICAL';
} elseif (strpos($message, 'Connection refused') !== false || strpos($message, 'Can\'t connect') !== false) {
$type = 'DB_CONNECTION';
$level = 'CRITICAL';
} elseif (strpos($message, 'Too many connections') !== false) {
$type = 'DB_CONNECTION';
$level = 'CRITICAL';
} elseif (strpos($message, 'Deadlock') !== false) {
$type = 'DB_DEADLOCK';
$level = 'ERROR';
}
// Créer l'alerte
AlertService::trigger($type, [
'sql_error' => $message,
'sql_code' => $code,
'query' => $query ? substr($query, 0, 500) : null,
'message' => "Erreur SQL : $message"
], $level);
}
/**
* Obtenir les statistiques de sécurité
*/
public static function getSecurityStats(): array {
$db = self::getDb();
// Statistiques des dernières 24h
$stats = [];
// Tentatives de login
$loginStmt = $db->prepare('
SELECT
COUNT(*) as total_attempts,
COUNT(DISTINCT ip_address) as unique_ips,
COUNT(DISTINCT username) as unique_usernames
FROM sec_failed_login_attempts
WHERE attempt_time >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
');
$loginStmt->execute();
$stats['failed_logins'] = $loginStmt->fetch(PDO::FETCH_ASSOC);
// IPs bloquées
$blockedStmt = $db->prepare('
SELECT COUNT(*) as total,
SUM(CASE WHEN permanent = 1 THEN 1 ELSE 0 END) as permanent,
SUM(CASE WHEN blocked_until > NOW() THEN 1 ELSE 0 END) as active
FROM sec_blocked_ips
');
$blockedStmt->execute();
$stats['blocked_ips'] = $blockedStmt->fetch(PDO::FETCH_ASSOC);
// Alertes par type
$alertsStmt = $db->prepare('
SELECT
alert_type,
alert_level,
COUNT(*) as count,
MAX(last_seen) as last_seen
FROM sec_alerts
WHERE last_seen >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
GROUP BY alert_type, alert_level
ORDER BY count DESC
');
$alertsStmt->execute();
$stats['alerts_by_type'] = $alertsStmt->fetchAll(PDO::FETCH_ASSOC);
return $stats;
}
/**
* Obtenir l'instance de base de données
*/
private static function getDb(): PDO {
if (self::$db === null) {
self::$db = Database::getInstance();
}
return self::$db;
}
}

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Services;
/**
* Générateur de PDF simple avec support d'images
* Génère des PDF légers avec logo
*/
class SimplePDF {
private string $content = '';
private array $objects = [];
private int $objectCount = 0;
private array $xref = [];
private float $pageWidth = 595.0; // A4 width in points
private float $pageHeight = 842.0; // A4 height in points
private float $margin = 50.0;
private float $currentY = 0;
private int $fontObject = 0;
private int $pageObject = 0;
public function __construct() {
$this->currentY = $this->pageHeight - $this->margin;
}
/**
* Ajoute du texte au PDF
*/
public function addText(string $text, float $x, float $y, int $fontSize = 12): void {
$this->content .= "BT\n";
$this->content .= "/F1 $fontSize Tf\n";
$this->content .= "$x $y Td\n";
$this->content .= "(" . $this->escapeString($text) . ") Tj\n";
$this->content .= "ET\n";
}
/**
* Ajoute une ligne de texte avec positionnement automatique
*/
public function addLine(string $text, int $fontSize = 11, string $align = 'left'): void {
$x = $this->margin;
if ($align === 'center') {
// Estimation approximative de la largeur du texte
$textWidth = strlen($text) * $fontSize * 0.5;
$x = ($this->pageWidth - $textWidth) / 2;
} elseif ($align === 'right') {
$textWidth = strlen($text) * $fontSize * 0.5;
$x = $this->pageWidth - $this->margin - $textWidth;
}
$this->addText($text, $x, $this->currentY, $fontSize);
$this->currentY -= ($fontSize + 8); // Line height
}
/**
* Ajoute un espace vertical
*/
public function addSpace(float $space = 20): void {
$this->currentY -= $space;
}
/**
* Ajoute une ligne horizontale
*/
public function addHorizontalLine(): void {
$y = $this->currentY;
$this->content .= "q\n"; // Save state
$this->content .= "0.5 w\n"; // Line width
$this->content .= $this->margin . " $y m\n"; // Move to start
$this->content .= ($this->pageWidth - $this->margin) . " $y l\n"; // Line to end
$this->content .= "S\n"; // Stroke
$this->content .= "Q\n"; // Restore state
$this->currentY -= 10;
}
/**
* Ajoute un rectangle (pour encadrer)
*/
public function addRectangle(float $x, float $y, float $width, float $height, bool $fill = false): void {
$this->content .= "q\n";
$this->content .= "0.8 w\n"; // Line width
$this->content .= "$x $y $width $height re\n"; // Rectangle
$this->content .= $fill ? "f\n" : "S\n"; // Fill or Stroke
$this->content .= "Q\n";
}
/**
* Échappe les caractères spéciaux pour le PDF
*/
private function escapeString(string $str): string {
// Échapper les caractères spéciaux PDF
$str = str_replace('\\', '\\\\', $str);
$str = str_replace('(', '\\(', $str);
$str = str_replace(')', '\\)', $str);
// Convertir les caractères accentués
$accents = [
'À' => 'A', 'Á' => 'A', 'Â' => 'A', 'Ã' => 'A', 'Ä' => 'A', 'Å' => 'A',
'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'a',
'È' => 'E', 'É' => 'E', 'Ê' => 'E', 'Ë' => 'E',
'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e',
'Ì' => 'I', 'Í' => 'I', 'Î' => 'I', 'Ï' => 'I',
'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i',
'Ò' => 'O', 'Ó' => 'O', 'Ô' => 'O', 'Õ' => 'O', 'Ö' => 'O',
'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o',
'Ù' => 'U', 'Ú' => 'U', 'Û' => 'U', 'Ü' => 'U',
'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u',
'Ñ' => 'N', 'ñ' => 'n',
'Ç' => 'C', 'ç' => 'c',
'€' => 'EUR',
'Œ' => 'OE', 'œ' => 'oe',
'Æ' => 'AE', 'æ' => 'ae'
];
$str = strtr($str, $accents);
// Supprimer tout caractère non-ASCII restant
$str = preg_replace('/[^\x20-\x7E]/', '', $str);
return $str;
}
/**
* Génère le PDF final
*/
public function generate(): string {
// Début du PDF
$pdf = "%PDF-1.4\n";
$pdf .= "%âãÏÓ\n"; // Binary marker
// Object 1 - Catalog
$this->objects[1] = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n";
// Object 2 - Pages
$this->objects[2] = "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n";
// Object 3 - Page
$this->objects[3] = "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " .
$this->pageWidth . " " . $this->pageHeight .
"] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>\nendobj\n";
// Object 4 - Font (Helvetica)
$this->objects[4] = "4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>\nendobj\n";
// Object 5 - Content stream
$contentLength = strlen($this->content);
$this->objects[5] = "5 0 obj\n<< /Length $contentLength >>\nstream\n" .
$this->content . "\nendstream\nendobj\n";
// Construction du PDF final
$offset = strlen($pdf);
foreach ($this->objects as $obj) {
$this->xref[] = $offset;
$pdf .= $obj;
$offset += strlen($obj);
}
// Table xref
$xrefStart = $offset;
$pdf .= "xref\n";
$pdf .= "0 " . (count($this->objects) + 1) . "\n";
$pdf .= "0000000000 65535 f \n";
foreach ($this->xref as $off) {
$pdf .= sprintf("%010d 00000 n \n", $off);
}
// Trailer
$pdf .= "trailer\n";
$pdf .= "<< /Size " . (count($this->objects) + 1) . " /Root 1 0 R >>\n";
$pdf .= "startxref\n";
$pdf .= "$xrefStart\n";
$pdf .= "%%EOF\n";
return $pdf;
}
}

View File

@@ -0,0 +1,446 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Stripe\Stripe;
use Stripe\StripeClient;
use Stripe\Exception\ApiErrorException;
use AppConfig;
use Database;
use PDO;
use Exception;
/**
* Service principal pour gérer l'intégration Stripe
* Gère Stripe Connect, Terminal et les paiements
*/
class StripeService {
private static ?self $instance = null;
private StripeClient $stripe;
private AppConfig $config;
private PDO $db;
private bool $testMode;
private function __construct() {
$this->config = AppConfig::getInstance();
$this->db = Database::getInstance();
// Déterminer le mode (test ou live)
$stripeConfig = $this->config->getStripeConfig();
$this->testMode = ($stripeConfig['mode'] ?? 'test') === 'test';
// Initialiser Stripe avec la bonne clé
$secretKey = $this->testMode
? $stripeConfig['secret_key_test']
: $stripeConfig['secret_key_live'];
if (empty($secretKey) || strpos($secretKey, 'XXXX') !== false) {
throw new Exception('Clé Stripe non configurée. Veuillez configurer vos clés dans AppConfig.php');
}
$this->stripe = new StripeClient([
'api_key' => $secretKey,
'stripe_version' => $stripeConfig['api_version']
]);
// Définir la clé API globalement aussi (pour certaines opérations)
Stripe::setApiKey($secretKey);
Stripe::setApiVersion($stripeConfig['api_version']);
}
public static function getInstance(): self {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Créer un compte Stripe Connect Express pour une amicale
*/
public function createConnectAccount(int $entiteId): array {
try {
// Récupérer les infos de l'entité
$stmt = $this->db->prepare(
"SELECT * FROM entites WHERE id = :id"
);
$stmt->execute(['id' => $entiteId]);
$entite = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$entite) {
throw new Exception("Entité non trouvée");
}
// Vérifier si un compte existe déjà
$stmt = $this->db->prepare(
"SELECT * FROM stripe_accounts WHERE fk_entite = :fk_entite"
);
$stmt->execute(['fk_entite' => $entiteId]);
$existingAccount = $stmt->fetch(PDO::FETCH_ASSOC);
if ($existingAccount) {
return [
'success' => false,
'message' => 'Un compte Stripe existe déjà pour cette entité',
'account_id' => $existingAccount['stripe_account_id']
];
}
// Créer le compte Stripe Connect Express
$account = $this->stripe->accounts->create([
'type' => 'express',
'country' => 'FR',
'email' => $entite['email'] ?? null,
'capabilities' => [
'card_payments' => ['requested' => true],
'transfers' => ['requested' => true],
],
'business_type' => 'non_profit', // Association
'business_profile' => [
'name' => $entite['nom'],
'product_description' => 'Vente de calendriers des pompiers',
'support_email' => $entite['email'] ?? null,
'url' => $entite['site_web'] ?? null,
],
'metadata' => [
'entite_id' => $entiteId,
'entite_name' => $entite['nom']
]
]);
// Sauvegarder en base de données
$stmt = $this->db->prepare(
"INSERT INTO stripe_accounts (fk_entite, stripe_account_id, created_at)
VALUES (:fk_entite, :stripe_account_id, NOW())"
);
$stmt->execute([
'fk_entite' => $entiteId,
'stripe_account_id' => $account->id
]);
return [
'success' => true,
'account_id' => $account->id,
'message' => 'Compte Stripe créé avec succès'
];
} catch (ApiErrorException $e) {
return [
'success' => false,
'message' => 'Erreur Stripe: ' . $e->getMessage()
];
} catch (Exception $e) {
return [
'success' => false,
'message' => 'Erreur: ' . $e->getMessage()
];
}
}
/**
* Récupérer les informations d'un compte Stripe Connect
*/
public function retrieveAccount(string $accountId) {
try {
return Account::retrieve($accountId);
} catch (Exception $e) {
Logger::getInstance()->error('Erreur récupération compte Stripe', [
'account_id' => $accountId,
'error' => $e->getMessage()
]);
return null;
}
}
/**
* Générer un lien d'onboarding pour finaliser la configuration du compte
*/
public function createOnboardingLink(string $accountId, string $returnUrl, string $refreshUrl): array {
try {
$accountLink = $this->stripe->accountLinks->create([
'account' => $accountId,
'refresh_url' => $refreshUrl,
'return_url' => $returnUrl,
'type' => 'account_onboarding',
]);
return [
'success' => true,
'url' => $accountLink->url
];
} catch (ApiErrorException $e) {
return [
'success' => false,
'message' => 'Erreur Stripe: ' . $e->getMessage()
];
}
}
/**
* Créer une Location pour Terminal/Tap to Pay
*/
public function createLocation(int $entiteId): array {
try {
// Récupérer le compte Stripe et l'entité
$stmt = $this->db->prepare(
"SELECT sa.*, e.*
FROM stripe_accounts sa
JOIN entites e ON sa.fk_entite = e.id
WHERE sa.fk_entite = :fk_entite"
);
$stmt->execute(['fk_entite' => $entiteId]);
$data = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$data) {
throw new Exception("Compte Stripe non trouvé pour cette entité");
}
// Créer la location
$location = $this->stripe->terminal->locations->create([
'display_name' => $data['nom'],
'address' => [
'line1' => $data['adresse'] ?? 'Adresse non renseignée',
'city' => $data['ville'] ?? 'Ville',
'postal_code' => $data['code_postal'] ?? '00000',
'country' => 'FR',
],
'metadata' => [
'entite_id' => $entiteId,
'type' => 'tap_to_pay'
]
], [
'stripe_account' => $data['stripe_account_id']
]);
// Mettre à jour en base
$stmt = $this->db->prepare(
"UPDATE stripe_accounts
SET stripe_location_id = :location_id, updated_at = NOW()
WHERE fk_entite = :fk_entite"
);
$stmt->execute([
'location_id' => $location->id,
'fk_entite' => $entiteId
]);
return [
'success' => true,
'location_id' => $location->id,
'message' => 'Location créée avec succès'
];
} catch (Exception $e) {
return [
'success' => false,
'message' => 'Erreur: ' . $e->getMessage()
];
}
}
/**
* Créer un Connection Token pour Terminal/Tap to Pay
*/
public function createConnectionToken(int $entiteId): array {
try {
// Récupérer le compte et la location
$stmt = $this->db->prepare(
"SELECT * FROM stripe_accounts WHERE fk_entite = :fk_entite"
);
$stmt->execute(['fk_entite' => $entiteId]);
$account = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$account || !$account['stripe_location_id']) {
throw new Exception("Location Stripe non configurée pour cette entité");
}
// Créer le token
$connectionToken = $this->stripe->terminal->connectionTokens->create([
'location' => $account['stripe_location_id']
], [
'stripe_account' => $account['stripe_account_id']
]);
return [
'success' => true,
'secret' => $connectionToken->secret
];
} catch (Exception $e) {
return [
'success' => false,
'message' => 'Erreur: ' . $e->getMessage()
];
}
}
/**
* Créer une intention de paiement
*/
public function createPaymentIntent(array $params): array {
try {
$amount = $params['amount'] ?? 0;
$entiteId = $params['fk_entite'] ?? 0;
$userId = $params['fk_user'] ?? 0;
$metadata = $params['metadata'] ?? [];
if ($amount < 100) {
throw new Exception("Le montant minimum est de 1€");
}
// Récupérer le compte Stripe
$stmt = $this->db->prepare(
"SELECT * FROM stripe_accounts WHERE fk_entite = :fk_entite"
);
$stmt->execute(['fk_entite' => $entiteId]);
$account = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$account) {
throw new Exception("Compte Stripe non trouvé");
}
// Calculer la commission (2.5% ou 50 centimes minimum)
$stripeConfig = $this->config->getStripeConfig();
$applicationFee = max(
$stripeConfig['application_fee_minimum'],
round($amount * $stripeConfig['application_fee_percent'] / 100)
);
// Créer le PaymentIntent
$paymentIntent = $this->stripe->paymentIntents->create([
'amount' => $amount,
'currency' => 'eur',
'payment_method_types' => ['card_present'],
'capture_method' => 'automatic',
'application_fee_amount' => $applicationFee,
'transfer_data' => [
'destination' => $account['stripe_account_id'],
],
'metadata' => array_merge($metadata, [
'entite_id' => $entiteId,
'user_id' => $userId,
'calendrier_annee' => date('Y'),
]),
]);
// Sauvegarder en base
$stmt = $this->db->prepare(
"INSERT INTO stripe_payment_intents
(stripe_payment_intent_id, fk_entite, fk_user, amount, currency, status, application_fee, metadata, created_at)
VALUES (:pi_id, :fk_entite, :fk_user, :amount, :currency, :status, :app_fee, :metadata, NOW())"
);
$stmt->execute([
'pi_id' => $paymentIntent->id,
'fk_entite' => $entiteId,
'fk_user' => $userId,
'amount' => $amount,
'currency' => 'eur',
'status' => $paymentIntent->status,
'app_fee' => $applicationFee,
'metadata' => json_encode($metadata)
]);
return [
'success' => true,
'client_secret' => $paymentIntent->client_secret,
'payment_intent_id' => $paymentIntent->id,
'amount' => $amount,
'application_fee' => $applicationFee
];
} catch (Exception $e) {
return [
'success' => false,
'message' => 'Erreur: ' . $e->getMessage()
];
}
}
/**
* Vérifier la compatibilité Tap to Pay d'un appareil Android
*/
public function checkAndroidTapToPayCompatibility(string $manufacturer, string $model): array {
try {
$stmt = $this->db->prepare(
"SELECT * FROM stripe_android_certified_devices
WHERE manufacturer = :manufacturer
AND model = :model
AND tap_to_pay_certified = 1
AND country = 'FR'"
);
$stmt->execute([
'manufacturer' => $manufacturer,
'model' => $model
]);
$device = $stmt->fetch(PDO::FETCH_ASSOC);
if ($device) {
return [
'success' => true,
'tap_to_pay_supported' => true,
'message' => 'Tap to Pay disponible sur cet appareil',
'min_android_version' => $device['min_android_version']
];
}
return [
'success' => true,
'tap_to_pay_supported' => false,
'message' => 'Appareil non certifié pour Tap to Pay en France',
'alternative' => 'Utilisez un iPhone XS ou plus récent avec iOS 15.4+'
];
} catch (Exception $e) {
return [
'success' => false,
'message' => 'Erreur: ' . $e->getMessage()
];
}
}
/**
* Récupérer les appareils Android certifiés
*/
public function getCertifiedAndroidDevices(): array {
try {
$stmt = $this->db->prepare(
"SELECT manufacturer, model, model_identifier, min_android_version
FROM stripe_android_certified_devices
WHERE tap_to_pay_certified = 1 AND country = 'FR'
ORDER BY manufacturer, model"
);
$stmt->execute();
return [
'success' => true,
'devices' => $stmt->fetchAll(PDO::FETCH_ASSOC)
];
} catch (Exception $e) {
return [
'success' => false,
'message' => 'Erreur: ' . $e->getMessage()
];
}
}
/**
* Obtenir le mode actuel (test ou live)
*/
public function isTestMode(): bool {
return $this->testMode;
}
/**
* Obtenir la clé publique pour le frontend
*/
public function getPublicKey(): string {
$stripeConfig = $this->config->getStripeConfig();
return $this->testMode
? $stripeConfig['public_key_test']
: $stripeConfig['public_key_live'];
}
}

156
api/test_chat_temp_id.php Normal file
View File

@@ -0,0 +1,156 @@
<?php
/**
* Script de test pour vérifier le support des temp_id dans l'API chat
*
* Usage: php test_chat_temp_id.php
*/
// Configuration
$apiUrl = 'http://localhost/api';
$sessionId = 'test_session_id'; // À remplacer par un session_id valide
// Fonction pour faire une requête HTTP
function makeRequest($method, $endpoint, $data = null, $sessionId = null) {
global $apiUrl;
$ch = curl_init($apiUrl . $endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
$headers = ['Content-Type: application/json'];
if ($sessionId) {
$headers[] = 'Authorization: Bearer ' . $sessionId;
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
if ($data !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [
'code' => $httpCode,
'response' => json_decode($response, true)
];
}
// Tests
echo "=== Tests de support des temp_id dans l'API Chat ===\n\n";
// Test 1: Création d'une room avec temp_id
echo "Test 1: Création d'une room avec temp_id\n";
$roomData = [
'type' => 'private',
'participants' => [2], // ID d'un autre utilisateur
'title' => 'Test Room Offline',
'temp_id' => 'temp_room_' . uniqid()
];
echo "Requête POST /chat/rooms avec temp_id: {$roomData['temp_id']}\n";
$result = makeRequest('POST', '/chat/rooms', $roomData, $sessionId);
if ($result['code'] === 201 && isset($result['response']['room']['temp_id'])) {
echo "✅ Succès: Room créée avec temp_id retourné\n";
echo " - ID réel: " . $result['response']['room']['id'] . "\n";
echo " - temp_id: " . $result['response']['room']['temp_id'] . "\n";
$roomId = $result['response']['room']['id'];
} else {
echo "❌ Échec: temp_id non retourné dans la réponse\n";
echo " Response: " . json_encode($result['response']) . "\n";
$roomId = null;
}
echo "\n";
// Test 2: Envoi d'un message avec temp_id
if ($roomId) {
echo "Test 2: Envoi d'un message avec temp_id\n";
$messageData = [
'content' => 'Message créé hors ligne',
'temp_id' => 'temp_msg_' . uniqid()
];
echo "Requête POST /chat/rooms/{$roomId}/messages avec temp_id: {$messageData['temp_id']}\n";
$result = makeRequest('POST', "/chat/rooms/{$roomId}/messages", $messageData, $sessionId);
if ($result['code'] === 201 && isset($result['response']['message']['temp_id'])) {
echo "✅ Succès: Message créé avec temp_id retourné\n";
echo " - ID réel: " . $result['response']['message']['id'] . "\n";
echo " - temp_id: " . $result['response']['message']['temp_id'] . "\n";
$messageId = $result['response']['message']['id'];
} else {
echo "❌ Échec: temp_id non retourné dans la réponse\n";
echo " Response: " . json_encode($result['response']) . "\n";
$messageId = null;
}
echo "\n";
}
// Test 3: Mise à jour d'une room avec temp_id
if ($roomId) {
echo "Test 3: Mise à jour d'une room avec temp_id\n";
$updateData = [
'title' => 'Room mise à jour',
'temp_id' => 'temp_update_room_' . uniqid()
];
echo "Requête PUT /chat/rooms/{$roomId} avec temp_id: {$updateData['temp_id']}\n";
$result = makeRequest('PUT', "/chat/rooms/{$roomId}", $updateData, $sessionId);
if ($result['code'] === 200 && isset($result['response']['room']['temp_id'])) {
echo "✅ Succès: Room mise à jour avec temp_id retourné\n";
echo " - temp_id: " . $result['response']['room']['temp_id'] . "\n";
} else {
echo "❌ Échec: temp_id non retourné dans la réponse\n";
echo " Response: " . json_encode($result['response']) . "\n";
}
echo "\n";
}
// Test 4: Mise à jour d'un message avec temp_id
if ($messageId) {
echo "Test 4: Mise à jour d'un message avec temp_id\n";
$updateData = [
'content' => 'Message modifié',
'temp_id' => 'temp_update_msg_' . uniqid()
];
echo "Requête PUT /chat/messages/{$messageId} avec temp_id: {$updateData['temp_id']}\n";
$result = makeRequest('PUT', "/chat/messages/{$messageId}", $updateData, $sessionId);
if ($result['code'] === 200 && isset($result['response']['message']['temp_id'])) {
echo "✅ Succès: Message mis à jour avec temp_id retourné\n";
echo " - temp_id: " . $result['response']['message']['temp_id'] . "\n";
} else {
echo "❌ Échec: temp_id non retourné dans la réponse\n";
echo " Response: " . json_encode($result['response']) . "\n";
}
echo "\n";
}
// Test 5: Création sans temp_id (doit continuer à fonctionner)
echo "Test 5: Création d'une room SANS temp_id (compatibilité)\n";
$roomData = [
'type' => 'private',
'participants' => [2],
'title' => 'Test Room Normal'
];
echo "Requête POST /chat/rooms sans temp_id\n";
$result = makeRequest('POST', '/chat/rooms', $roomData, $sessionId);
if ($result['code'] === 201 && !isset($result['response']['room']['temp_id'])) {
echo "✅ Succès: Room créée normalement sans temp_id\n";
echo " - ID: " . $result['response']['room']['id'] . "\n";
} else if ($result['code'] === 201) {
echo "⚠️ Attention: Room créée mais temp_id présent alors qu'il ne devrait pas\n";
} else {
echo "❌ Échec: Erreur lors de la création\n";
echo " Response: " . json_encode($result['response']) . "\n";
}
echo "\n=== Fin des tests ===\n";
echo "\nNote: Assurez-vous d'avoir un session_id valide et que l'API est accessible.\n";
echo "Pour obtenir un session_id valide, connectez-vous d'abord via POST /api/login\n";

254
api/test_security.php Normal file
View File

@@ -0,0 +1,254 @@
#!/usr/bin/env php
<?php
/**
* Script de test pour le système de sécurité et monitoring
* Usage: php test_security.php
*/
declare(strict_types=1);
// Configuration
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/src/Config/AppConfig.php';
require_once __DIR__ . '/src/Core/Database.php';
// Services de sécurité
require_once __DIR__ . '/src/Services/Security/AlertService.php';
require_once __DIR__ . '/src/Services/Security/SecurityMonitor.php';
require_once __DIR__ . '/src/Services/Security/PerformanceMonitor.php';
require_once __DIR__ . '/src/Services/Security/IPBlocker.php';
require_once __DIR__ . '/src/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;
// Initialiser la configuration
$appConfig = AppConfig::getInstance();
$config = $appConfig->getFullConfig();
// Initialiser la base de données
Database::init($config['database']);
echo "\n========================================\n";
echo " TEST DU SYSTÈME DE SÉCURITÉ\n";
echo "========================================\n\n";
// Test 1: Vérifier les tables
echo "1. Vérification des tables de sécurité...\n";
try {
$db = Database::getInstance();
$tables = [
'sec_alerts',
'sec_performance_metrics',
'sec_failed_login_attempts',
'sec_blocked_ips'
];
foreach ($tables as $table) {
$stmt = $db->query("SELECT COUNT(*) as count FROM $table");
$result = $stmt->fetch(PDO::FETCH_ASSOC);
echo " ✓ Table $table : {$result['count']} enregistrements\n";
}
echo " [OK] Toutes les tables existent\n\n";
} catch (Exception $e) {
echo " ✗ Erreur : " . $e->getMessage() . "\n";
echo " Assurez-vous d'avoir exécuté le script SQL de création des tables.\n";
exit(1);
}
// Test 2: Test du monitoring de performance
echo "2. Test du monitoring de performance...\n";
try {
// Simuler une requête
PerformanceMonitor::startRequest();
// Simuler une requête DB
PerformanceMonitor::startDbQuery("SELECT * FROM users WHERE id = 1");
usleep(50000); // 50ms
PerformanceMonitor::endDbQuery();
// Terminer la requête
PerformanceMonitor::endRequest('/api/test', 'GET', 200);
echo " ✓ Monitoring de performance fonctionnel\n\n";
} catch (Exception $e) {
echo " ✗ Erreur : " . $e->getMessage() . "\n\n";
}
// Test 3: Test de détection SQL Injection
echo "3. Test de détection d'injection SQL...\n";
$testQueries = [
"normal_query" => true,
"'; DROP TABLE users; --" => false,
"1' OR '1'='1" => false,
"admin' UNION SELECT * FROM users--" => false
];
foreach ($testQueries as $query => $shouldPass) {
$result = SecurityMonitor::checkSQLInjection($query);
$status = ($result === $shouldPass) ? '✓' : '✗';
$expected = $shouldPass ? 'autorisé' : 'bloqué';
echo " $status '$query' : $expected\n";
}
echo "\n";
// Test 4: Test du blocage d'IP
echo "4. Test du système de blocage d'IP...\n";
try {
$testIP = '192.168.1.99';
// Vérifier que l'IP n'est pas bloquée
if (!IPBlocker::isBlocked($testIP)) {
echo " ✓ IP $testIP non bloquée initialement\n";
}
// Bloquer l'IP temporairement (10 secondes)
IPBlocker::block($testIP, 10, 'Test temporaire');
// Vérifier qu'elle est bloquée
if (IPBlocker::isBlocked($testIP)) {
echo " ✓ IP $testIP bloquée avec succès\n";
}
// Débloquer l'IP
IPBlocker::unblock($testIP);
// Vérifier qu'elle est débloquée
if (!IPBlocker::isBlocked($testIP)) {
echo " ✓ IP $testIP débloquée avec succès\n";
}
echo " [OK] Système de blocage IP fonctionnel\n\n";
} catch (Exception $e) {
echo " ✗ Erreur : " . $e->getMessage() . "\n\n";
}
// Test 5: Test des alertes
echo "5. Test du système d'alertes...\n";
try {
// Créer une alerte de test
AlertService::trigger('TEST', [
'message' => 'Ceci est une alerte de test',
'test_script' => true
], 'INFO');
echo " ✓ Alerte créée avec succès\n";
// Récupérer les alertes actives
$alerts = AlertService::getActiveAlerts(1);
if (!empty($alerts)) {
echo "" . count($alerts) . " alerte(s) active(s) trouvée(s)\n";
}
echo " [OK] Système d'alertes fonctionnel\n\n";
} catch (Exception $e) {
echo " ✗ Erreur : " . $e->getMessage() . "\n\n";
}
// Test 6: Test de brute force
echo "6. Simulation d'attaque brute force...\n";
try {
$attackerIP = '10.0.0.1';
// Simuler plusieurs tentatives échouées
for ($i = 1; $i <= 6; $i++) {
SecurityMonitor::recordFailedLogin(
$attackerIP,
'user' . $i,
'invalid_password',
'Mozilla/5.0 Test'
);
echo " - Tentative $i enregistrée\n";
}
// Vérifier la détection
$canLogin = SecurityMonitor::checkBruteForce($attackerIP, 'testuser');
if (!$canLogin) {
echo " ✓ Attaque brute force détectée et bloquée\n";
} else {
echo " ✗ L'attaque aurait dû être détectée\n";
}
// Nettoyer
$db->exec("DELETE FROM sec_failed_login_attempts WHERE ip_address = '$attackerIP'");
IPBlocker::unblock($attackerIP);
echo " [OK] Détection brute force fonctionnelle\n\n";
} catch (Exception $e) {
echo " ✗ Erreur : " . $e->getMessage() . "\n\n";
}
// Test 7: Test du throttling email
echo "7. Test du throttling d'emails...\n";
try {
$throttler = new EmailThrottler();
// Premier email devrait passer
if ($throttler->canSend('TEST_TYPE', 60)) {
echo " ✓ Premier email autorisé\n";
$throttler->recordSent('TEST_TYPE');
}
// Deuxième email immédiat devrait être bloqué
if (!$throttler->canSend('TEST_TYPE', 60)) {
echo " ✓ Deuxième email bloqué (throttling)\n";
}
// Obtenir les stats
$stats = $throttler->getStats();
echo " ✓ Stats throttling : {$stats['hourly']['sent']} emails/heure\n";
echo " [OK] Throttling email fonctionnel\n\n";
} catch (Exception $e) {
echo " ✗ Erreur : " . $e->getMessage() . "\n\n";
}
// Test 8: Statistiques globales
echo "8. Récupération des statistiques...\n";
try {
// Stats de sécurité
$securityStats = SecurityMonitor::getSecurityStats();
echo " ✓ Tentatives de login échouées (24h) : " .
($securityStats['failed_logins']['total_attempts'] ?? 0) . "\n";
// Stats de performance
$perfStats = PerformanceMonitor::getStats(null, 24);
echo " ✓ Requêtes totales (24h) : " .
($perfStats['global']['total_requests'] ?? 0) . "\n";
// Stats de blocage IP
$ipStats = IPBlocker::getStats();
echo " ✓ IPs bloquées actives : " .
(($ipStats['totals']['temporary'] ?? 0) + ($ipStats['totals']['permanent'] ?? 0)) . "\n";
echo " [OK] Statistiques disponibles\n\n";
} catch (Exception $e) {
echo " ✗ Erreur : " . $e->getMessage() . "\n\n";
}
// Résumé
echo "========================================\n";
echo " RÉSUMÉ DES TESTS\n";
echo "========================================\n\n";
echo "✓ Tables de sécurité créées\n";
echo "✓ Monitoring de performance actif\n";
echo "✓ Détection SQL injection fonctionnelle\n";
echo "✓ Blocage d'IP opérationnel\n";
echo "✓ Système d'alertes configuré\n";
echo "✓ Détection brute force active\n";
echo "✓ Throttling email en place\n";
echo "✓ Statistiques disponibles\n\n";
echo "🔒 Le système de sécurité est opérationnel !\n\n";
echo "PROCHAINES ÉTAPES :\n";
echo "1. Configurer l'email dans AppConfig pour recevoir les alertes\n";
echo "2. Personnaliser les seuils dans les constantes des services\n";
echo "3. Tester avec de vraies requêtes API\n";
echo "4. Surveiller les logs et ajuster les règles\n\n";

102
api/test_stripe.php Normal file
View File

@@ -0,0 +1,102 @@
<?php
/**
* Script de test pour vérifier l'intégration Stripe
* Usage: php test_stripe.php
*/
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/src/Config/AppConfig.php';
require_once __DIR__ . '/src/Services/StripeService.php';
require_once __DIR__ . '/src/Core/Database.php';
// Forcer l'environnement dev
$_SERVER['SERVER_NAME'] = 'dapp.geosector.fr';
echo "================================\n";
echo "Test de l'intégration Stripe\n";
echo "================================\n\n";
try {
// Test 1: Configuration
echo "1. Test de la configuration...\n";
$config = AppConfig::getInstance();
$stripeConfig = $config->getStripeConfig();
if (empty($stripeConfig['public_key_test']) || strpos($stripeConfig['public_key_test'], 'pk_test_') !== 0) {
throw new Exception("❌ Clé publique TEST non configurée");
}
echo "✅ Clé publique TEST configurée\n";
if (empty($stripeConfig['secret_key_test']) || strpos($stripeConfig['secret_key_test'], 'sk_test_') !== 0) {
throw new Exception("❌ Clé secrète TEST non configurée");
}
echo "✅ Clé secrète TEST configurée\n";
echo "✅ Mode: " . ($stripeConfig['mode'] ?? 'test') . "\n\n";
// Initialiser la base de données avant le service Stripe
Database::init($config->getDatabaseConfig());
// Test 2: Service Stripe
echo "2. Test du service Stripe...\n";
$stripeService = \App\Services\StripeService::getInstance();
echo "✅ Service Stripe initialisé\n";
echo "✅ Mode TEST: " . ($stripeService->isTestMode() ? 'Oui' : 'Non') . "\n";
// Masquer la clé publique pour l'affichage
$publicKey = $stripeService->getPublicKey();
$maskedKey = substr($publicKey, 0, 10) . '...' . substr($publicKey, -4);
echo "✅ Clé publique: $maskedKey\n\n";
// Test 3: Base de données
echo "3. Test de la base de données...\n";
Database::init($config->getDatabaseConfig());
$db = Database::getInstance();
// Vérifier les tables Stripe
$stmt = $db->prepare("SHOW TABLES LIKE 'stripe_%'");
$stmt->execute();
$tables = $stmt->fetchAll(PDO::FETCH_COLUMN);
echo "Tables Stripe trouvées: " . count($tables) . "\n";
foreach ($tables as $table) {
echo "$table\n";
}
if (count($tables) < 7) {
echo "⚠️ Attention: Toutes les tables ne sont pas créées (attendu: 7+)\n";
}
echo "\n";
// Test 4: Vérifier les appareils Android certifiés
echo "4. Test des appareils Android certifiés...\n";
$stmt = $db->prepare("SELECT COUNT(*) as count FROM stripe_android_certified_devices WHERE tap_to_pay_certified = 1");
$stmt->execute();
$result = $stmt->fetch(PDO::FETCH_ASSOC);
echo "✅ Appareils Android certifiés: " . $result['count'] . "\n\n";
// Test 5: Test de connexion à l'API Stripe
echo "5. Test de connexion à l'API Stripe...\n";
try {
\Stripe\Stripe::setApiKey($stripeConfig['secret_key_test']);
$balance = \Stripe\Balance::retrieve();
echo "✅ Connexion à Stripe réussie!\n";
echo " Solde disponible: " . number_format($balance->available[0]->amount / 100, 2) . " " . strtoupper($balance->available[0]->currency) . "\n";
echo " Solde en attente: " . number_format($balance->pending[0]->amount / 100, 2) . " " . strtoupper($balance->pending[0]->currency) . "\n";
} catch (Exception $e) {
echo "❌ Erreur de connexion Stripe: " . $e->getMessage() . "\n";
}
echo "\n================================\n";
echo "✨ Tests terminés avec succès!\n";
echo "================================\n\n";
echo "Prochaines étapes:\n";
echo "1. Créer un webhook dans le Dashboard Stripe\n";
echo "2. Activer Stripe Connect et Terminal\n";
echo "3. Tester les endpoints via l'app ou Postman\n";
} catch (Exception $e) {
echo "\n❌ ERREUR: " . $e->getMessage() . "\n";
echo "Trace: " . $e->getTraceAsString() . "\n";
exit(1);
}

View File

@@ -0,0 +1,60 @@
<?php
/**
* Test simple de Stripe sans base de données
*/
echo "================================\n";
echo "Test Simple Stripe\n";
echo "================================\n\n";
// Test direct avec les clés
$publicKey = 'pk_test_51QwoVN00pblGEgsXkf8qlXmLGEpxDQcG0KLRpjrGLjJHd7AVZ4Iwd6ChgdjO0w0n3vRqwNCEW8KnHUe5eh3uIlkV00k07kCBmd';
$secretKey = 'sk_test_51QwoVN00pblGEgsXnvqi8qfYpzHtesWWclvK3lzQjPNoHY0dIyOpJmxIkoLqsbmRMEUZpKS5MQ7iFDRlSqVyTo9c006yWetbsd';
echo "1. Clés configurées:\n";
echo " Public: " . substr($publicKey, 0, 20) . "...***\n";
echo " Secret: " . substr($secretKey, 0, 20) . "...***\n\n";
echo "2. Test de connexion Stripe...\n";
require_once __DIR__ . '/vendor/autoload.php';
try {
\Stripe\Stripe::setApiKey($secretKey);
// Récupérer le balance
$balance = \Stripe\Balance::retrieve();
echo "✅ Connexion réussie!\n";
echo " Devise par défaut: " . strtoupper($balance->available[0]->currency ?? 'eur') . "\n";
// Tester la création d'un PaymentIntent
echo "\n3. Test création PaymentIntent...\n";
$paymentIntent = \Stripe\PaymentIntent::create([
'amount' => 1000, // 10€
'currency' => 'eur',
'payment_method_types' => ['card'],
'metadata' => [
'test' => 'true',
'created_by' => 'test_script'
]
]);
echo "✅ PaymentIntent créé!\n";
echo " ID: " . $paymentIntent->id . "\n";
echo " Montant: " . ($paymentIntent->amount / 100) . " EUR\n";
echo " Status: " . $paymentIntent->status . "\n";
// Annuler le PaymentIntent de test
$paymentIntent->cancel();
echo " (PaymentIntent annulé pour le test)\n";
} catch (\Stripe\Exception\ApiErrorException $e) {
echo "❌ Erreur Stripe: " . $e->getMessage() . "\n";
} catch (Exception $e) {
echo "❌ Erreur: " . $e->getMessage() . "\n";
}
echo "\n================================\n";
echo "Test terminé!\n";
echo "================================\n";

150
api/tests/test_user_creation.php Executable file
View File

@@ -0,0 +1,150 @@
#!/usr/bin/env php
<?php
/**
* Script de test pour la création d'utilisateurs
* Teste les différents cas qui peuvent causer une erreur 400
*/
// Configuration
$apiUrl = 'http://localhost/api';
$sessionId = ''; // À remplir avec un session_id valide
// Couleurs pour la sortie
$red = "\033[31m";
$green = "\033[32m";
$yellow = "\033[33m";
$blue = "\033[34m";
$reset = "\033[0m";
function testRequest($endpoint, $method, $data = null, $sessionId = '') {
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
if ($sessionId) {
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Authorization: Bearer ' . $sessionId
]);
} else {
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
}
if ($data) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [
'code' => $httpCode,
'body' => json_decode($response, true) ?: $response
];
}
echo $blue . "=== Test de création d'utilisateur et vérification username ===" . $reset . "\n\n";
// Test 1: Vérification username sans données
echo $yellow . "Test 1: POST /api/users/check-username sans données" . $reset . "\n";
$result = testRequest($apiUrl . '/users/check-username', 'POST', [], $sessionId);
echo "Code HTTP: " . ($result['code'] == 400 ? $green : $red) . $result['code'] . $reset . "\n";
if (is_array($result['body'])) {
echo "Message: " . $result['body']['message'] . "\n";
echo "Field: " . ($result['body']['field'] ?? 'non spécifié') . "\n";
}
echo "\n";
// Test 2: Vérification username avec format invalide (trop court)
echo $yellow . "Test 2: POST /api/users/check-username avec username trop court" . $reset . "\n";
$result = testRequest($apiUrl . '/users/check-username', 'POST', ['username' => 'abc'], $sessionId);
echo "Code HTTP: " . ($result['code'] == 400 ? $green : $red) . $result['code'] . $reset . "\n";
if (is_array($result['body'])) {
echo "Message: " . $result['body']['message'] . "\n";
echo "Format attendu: " . ($result['body']['format'] ?? 'non spécifié') . "\n";
}
echo "\n";
// Test 3: Vérification username avec format invalide (commence par un chiffre)
echo $yellow . "Test 3: POST /api/users/check-username avec username commençant par un chiffre" . $reset . "\n";
$result = testRequest($apiUrl . '/users/check-username', 'POST', ['username' => '123test.user'], $sessionId);
echo "Code HTTP: " . ($result['code'] == 400 ? $green : $red) . $result['code'] . $reset . "\n";
if (is_array($result['body'])) {
echo "Message: " . $result['body']['message'] . "\n";
echo "Valeur testée: " . ($result['body']['value'] ?? 'non spécifié') . "\n";
}
echo "\n";
// Test 4: Vérification username valide
echo $yellow . "Test 4: POST /api/users/check-username avec username valide" . $reset . "\n";
$result = testRequest($apiUrl . '/users/check-username', 'POST', ['username' => 'test.user123'], $sessionId);
echo "Code HTTP: " . ($result['code'] == 200 ? $green : $red) . $result['code'] . $reset . "\n";
if (is_array($result['body'])) {
echo "Disponible: " . ($result['body']['available'] ? 'Oui' : 'Non') . "\n";
echo "Message: " . $result['body']['message'] . "\n";
}
echo "\n";
// Test 5: Création utilisateur sans email
echo $yellow . "Test 5: POST /api/users sans email" . $reset . "\n";
$result = testRequest($apiUrl . '/users', 'POST', [
'name' => 'Test User',
'fk_entite' => 1
], $sessionId);
echo "Code HTTP: " . ($result['code'] == 400 ? $green : $red) . $result['code'] . $reset . "\n";
if (is_array($result['body'])) {
echo "Message: " . $result['body']['message'] . "\n";
echo "Field: " . ($result['body']['field'] ?? 'non spécifié') . "\n";
}
echo "\n";
// Test 6: Création utilisateur sans nom
echo $yellow . "Test 6: POST /api/users sans nom" . $reset . "\n";
$result = testRequest($apiUrl . '/users', 'POST', [
'email' => 'test@example.com',
'fk_entite' => 1
], $sessionId);
echo "Code HTTP: " . ($result['code'] == 400 ? $green : $red) . $result['code'] . $reset . "\n";
if (is_array($result['body'])) {
echo "Message: " . $result['body']['message'] . "\n";
echo "Field: " . ($result['body']['field'] ?? 'non spécifié') . "\n";
}
echo "\n";
// Test 7: Création utilisateur avec email invalide
echo $yellow . "Test 7: POST /api/users avec email invalide" . $reset . "\n";
$result = testRequest($apiUrl . '/users', 'POST', [
'email' => 'invalid-email',
'name' => 'Test User',
'fk_entite' => 1
], $sessionId);
echo "Code HTTP: " . ($result['code'] == 400 ? $green : $red) . $result['code'] . $reset . "\n";
if (is_array($result['body'])) {
echo "Message: " . $result['body']['message'] . "\n";
echo "Field: " . ($result['body']['field'] ?? 'non spécifié') . "\n";
echo "Valeur testée: " . ($result['body']['value'] ?? 'non spécifié') . "\n";
}
echo "\n";
// Test 8: Création utilisateur pour entité avec username manuel mais sans username
echo $yellow . "Test 8: POST /api/users pour entité avec chk_username_manuel=1 sans username" . $reset . "\n";
$result = testRequest($apiUrl . '/users', 'POST', [
'email' => 'test@example.com',
'name' => 'Test User',
'fk_entite' => 5 // Supposons que l'entité 5 a chk_username_manuel=1
], $sessionId);
echo "Code HTTP: " . $result['code'] . "\n";
if (is_array($result['body'])) {
echo "Message: " . $result['body']['message'] . "\n";
echo "Field: " . ($result['body']['field'] ?? 'non spécifié') . "\n";
echo "Reason: " . ($result['body']['reason'] ?? 'non spécifié') . "\n";
}
echo "\n";
echo $blue . "=== Fin des tests ===" . $reset . "\n";
echo "\nRappel: Pour que ces tests fonctionnent, vous devez:\n";
echo "1. Avoir un serveur local en cours d'exécution\n";
echo "2. Remplir la variable \$sessionId avec un token de session valide\n";
echo "3. Vérifier les logs dans /logs/ pour voir les détails\n";

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env php
<?php
/**
* Script de test pour la validation ultra-souple des usernames
* Teste les nouvelles règles : 8-30 caractères UTF-8, tous caractères acceptés
*/
// Configuration
$apiUrl = 'http://localhost/api';
$sessionId = ''; // À remplir avec un session_id valide
// Couleurs pour la sortie
$red = "\033[31m";
$green = "\033[32m";
$yellow = "\033[33m";
$blue = "\033[34m";
$reset = "\033[0m";
function testRequest($endpoint, $method, $data = null, $sessionId = '') {
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
if ($sessionId) {
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json; charset=UTF-8',
'Authorization: Bearer ' . $sessionId
]);
} else {
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json; charset=UTF-8']);
}
if ($data) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data, JSON_UNESCAPED_UNICODE));
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [
'code' => $httpCode,
'body' => json_decode($response, true) ?: $response
];
}
echo $blue . "=== Test de validation ultra-souple des usernames ===" . $reset . "\n";
echo "Nouvelles règles : 8-30 caractères UTF-8, tous caractères acceptés\n\n";
// Cas qui doivent ÉCHOUER (trop court ou trop long)
$testsFail = [
['username' => 'abc', 'desc' => 'Trop court (3 car.)'],
['username' => '1234567', 'desc' => 'Trop court (7 car.)'],
['username' => str_repeat('a', 31), 'desc' => 'Trop long (31 car.)'],
['username' => str_repeat('😀', 31), 'desc' => 'Trop long (31 émojis)'],
['username' => ' ', 'desc' => 'Espaces seulement (devient vide après trim)'],
];
// Cas qui doivent RÉUSSIR
$testsSuccess = [
['username' => 'Jean-Pierre', 'desc' => 'Nom avec tiret et majuscules'],
['username' => '12345678', 'desc' => 'Chiffres seulement (8 car.)'],
['username' => 'user@company', 'desc' => 'Avec arobase'],
['username' => 'Marie 2024', 'desc' => 'Avec espace'],
['username' => 'josé.garcía', 'desc' => 'Avec accents'],
['username' => '用户2024', 'desc' => 'Caractères chinois'],
['username' => 'مستخدم123', 'desc' => 'Caractères arabes'],
['username' => 'Admin_#123!', 'desc' => 'Caractères spéciaux'],
['username' => '😀🎉Party2024', 'desc' => 'Avec émojis'],
['username' => 'Пользователь', 'desc' => 'Cyrillique'],
['username' => ' espacé ', 'desc' => 'Espaces (seront trimés)'],
['username' => 'a' . str_repeat('b', 28) . 'c', 'desc' => 'Exactement 30 car.'],
];
echo $yellow . "Tests qui doivent ÉCHOUER :" . $reset . "\n\n";
foreach ($testsFail as $index => $test) {
echo "Test " . ($index + 1) . ": " . $test['desc'] . "\n";
echo "Username: \"" . $test['username'] . "\"\n";
$result = testRequest($apiUrl . '/users/check-username', 'POST', ['username' => $test['username']], $sessionId);
$isExpectedFailure = ($result['code'] == 400);
echo "Code HTTP: " . ($isExpectedFailure ? $green : $red) . $result['code'] . $reset;
echo " - " . ($isExpectedFailure ? $green . "✓ OK" : $red . "✗ ERREUR") . $reset . "\n";
if (is_array($result['body'])) {
echo "Message: " . $result['body']['message'] . "\n";
if (isset($result['body']['details'])) {
echo "Détails: " . $result['body']['details'] . "\n";
}
}
echo "\n";
}
echo $yellow . "Tests qui doivent RÉUSSIR :" . $reset . "\n\n";
foreach ($testsSuccess as $index => $test) {
echo "Test " . ($index + 1) . ": " . $test['desc'] . "\n";
echo "Username: \"" . $test['username'] . "\"\n";
echo "Longueur: " . mb_strlen(trim($test['username']), 'UTF-8') . " caractères\n";
$result = testRequest($apiUrl . '/users/check-username', 'POST', ['username' => $test['username']], $sessionId);
$isExpectedSuccess = ($result['code'] == 200);
echo "Code HTTP: " . ($isExpectedSuccess ? $green : $red) . $result['code'] . $reset;
echo " - " . ($isExpectedSuccess ? $green . "✓ OK" : $red . "✗ ERREUR") . $reset . "\n";
if (is_array($result['body'])) {
if ($result['code'] == 200) {
echo "Disponible: " . ($result['body']['available'] ? $green . "Oui" : $yellow . "Non (déjà pris)") . $reset . "\n";
} else {
echo "Message: " . $result['body']['message'] . "\n";
if (isset($result['body']['details'])) {
echo "Détails: " . $result['body']['details'] . "\n";
}
}
}
echo "\n";
}
echo $blue . "=== Résumé des nouvelles règles ===" . $reset . "\n";
echo "✓ Minimum : 8 caractères UTF-8\n";
echo "✓ Maximum : 30 caractères UTF-8\n";
echo "✓ Tous caractères acceptés (lettres, chiffres, espaces, émojis, accents, etc.)\n";
echo "✓ Trim automatique des espaces début/fin\n";
echo "✓ Sensible à la casse (Jean ≠ jean)\n";
echo "✓ Unicité vérifiée dans toute la base\n\n";
echo $yellow . "Note: N'oubliez pas d'exécuter le script SQL de migration !" . $reset . "\n";
echo "scripts/sql/migration_username_utf8_support.sql\n";

View File

@@ -6,15 +6,31 @@ $vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
'AddressService' => $baseDir . '/src/Services/AddressService.php',
'AddressesDatabase' => $baseDir . '/src/Core/AddressesDatabase.php',
'ApiService' => $baseDir . '/src/Services/ApiService.php',
'AppConfig' => $baseDir . '/src/Config/AppConfig.php',
'App\\Controllers\\ChatController' => $baseDir . '/src/Controllers/ChatController.php',
'App\\Controllers\\EntiteController' => $baseDir . '/src/Controllers/EntiteController.php',
'App\\Controllers\\FileController' => $baseDir . '/src/Controllers/FileController.php',
'App\\Controllers\\LoginController' => $baseDir . '/src/Controllers/LoginController.php',
'App\\Controllers\\OperationController' => $baseDir . '/src/Controllers/OperationController.php',
'App\\Controllers\\PassageController' => $baseDir . '/src/Controllers/PassageController.php',
'App\\Controllers\\PasswordController' => $baseDir . '/src/Controllers/PasswordController.php',
'App\\Controllers\\SectorController' => $baseDir . '/src/Controllers/SectorController.php',
'App\\Controllers\\SecurityController' => $baseDir . '/src/Controllers/SecurityController.php',
'App\\Controllers\\UserController' => $baseDir . '/src/Controllers/UserController.php',
'App\\Controllers\\VilleController' => $baseDir . '/src/Controllers/VilleController.php',
'App\\Services\\PDFGenerator' => $baseDir . '/src/Services/PDFGenerator.php',
'App\\Services\\PasswordSecurityService' => $baseDir . '/src/Services/PasswordSecurityService.php',
'App\\Services\\ReceiptPDFGenerator' => $baseDir . '/src/Services/ReceiptPDFGenerator.php',
'App\\Services\\ReceiptService' => $baseDir . '/src/Services/ReceiptService.php',
'App\\Services\\Security\\AlertService' => $baseDir . '/src/Services/Security/AlertService.php',
'App\\Services\\Security\\EmailThrottler' => $baseDir . '/src/Services/Security/EmailThrottler.php',
'App\\Services\\Security\\IPBlocker' => $baseDir . '/src/Services/Security/IPBlocker.php',
'App\\Services\\Security\\PerformanceMonitor' => $baseDir . '/src/Services/Security/PerformanceMonitor.php',
'App\\Services\\Security\\SecurityMonitor' => $baseDir . '/src/Services/Security/SecurityMonitor.php',
'App\\Services\\SimplePDF' => $baseDir . '/src/Services/SimplePDF.php',
'BackupEncryptionService' => $baseDir . '/src/Services/BackupEncryptionService.php',
'ClientDetector' => $baseDir . '/src/Utils/ClientDetector.php',
'Complex\\Complex' => $vendorDir . '/markbaker/complex/classes/src/Complex.php',
@@ -40,8 +56,11 @@ return array(
'Composer\\Pcre\\ReplaceResult' => $vendorDir . '/composer/pcre/src/ReplaceResult.php',
'Composer\\Pcre\\UnexpectedNullMatchException' => $vendorDir . '/composer/pcre/src/UnexpectedNullMatchException.php',
'Database' => $baseDir . '/src/Core/Database.php',
'DepartmentBoundaryService' => $baseDir . '/src/Services/DepartmentBoundaryService.php',
'EmailTemplates' => $baseDir . '/src/Services/EmailTemplates.php',
'ExportService' => $baseDir . '/src/Services/ExportService.php',
'FPDF' => $vendorDir . '/setasign/fpdf/fpdf.php',
'FileService' => $baseDir . '/src/Services/FileService.php',
'LogController' => $baseDir . '/src/Controllers/LogController.php',
'LogService' => $baseDir . '/src/Services/LogService.php',
'Matrix\\Builder' => $vendorDir . '/markbaker/matrix/classes/src/Builder.php',
@@ -59,6 +78,9 @@ return array(
'Matrix\\Operators\\Multiplication' => $vendorDir . '/markbaker/matrix/classes/src/Operators/Multiplication.php',
'Matrix\\Operators\\Operator' => $vendorDir . '/markbaker/matrix/classes/src/Operators/Operator.php',
'Matrix\\Operators\\Subtraction' => $vendorDir . '/markbaker/matrix/classes/src/Operators/Subtraction.php',
'MonitoredDatabase' => $baseDir . '/src/Core/MonitoredDatabase.php',
'MonitoredStatement' => $baseDir . '/src/Core/MonitoredDatabase.php',
'OperationDataService' => $baseDir . '/src/Services/OperationDataService.php',
'PHPMailer\\PHPMailer\\DSNConfigurator' => $vendorDir . '/phpmailer/phpmailer/src/DSNConfigurator.php',
'PHPMailer\\PHPMailer\\Exception' => $vendorDir . '/phpmailer/phpmailer/src/Exception.php',
'PHPMailer\\PHPMailer\\OAuth' => $vendorDir . '/phpmailer/phpmailer/src/OAuth.php',
@@ -589,6 +611,377 @@ return array(
'Response' => $baseDir . '/src/Core/Response.php',
'Router' => $baseDir . '/src/Core/Router.php',
'Session' => $baseDir . '/src/Core/Session.php',
'Stripe\\Account' => $vendorDir . '/stripe/stripe-php/lib/Account.php',
'Stripe\\AccountLink' => $vendorDir . '/stripe/stripe-php/lib/AccountLink.php',
'Stripe\\AccountSession' => $vendorDir . '/stripe/stripe-php/lib/AccountSession.php',
'Stripe\\ApiOperations\\All' => $vendorDir . '/stripe/stripe-php/lib/ApiOperations/All.php',
'Stripe\\ApiOperations\\Create' => $vendorDir . '/stripe/stripe-php/lib/ApiOperations/Create.php',
'Stripe\\ApiOperations\\Delete' => $vendorDir . '/stripe/stripe-php/lib/ApiOperations/Delete.php',
'Stripe\\ApiOperations\\NestedResource' => $vendorDir . '/stripe/stripe-php/lib/ApiOperations/NestedResource.php',
'Stripe\\ApiOperations\\Request' => $vendorDir . '/stripe/stripe-php/lib/ApiOperations/Request.php',
'Stripe\\ApiOperations\\Retrieve' => $vendorDir . '/stripe/stripe-php/lib/ApiOperations/Retrieve.php',
'Stripe\\ApiOperations\\SingletonRetrieve' => $vendorDir . '/stripe/stripe-php/lib/ApiOperations/SingletonRetrieve.php',
'Stripe\\ApiOperations\\Update' => $vendorDir . '/stripe/stripe-php/lib/ApiOperations/Update.php',
'Stripe\\ApiRequestor' => $vendorDir . '/stripe/stripe-php/lib/ApiRequestor.php',
'Stripe\\ApiResource' => $vendorDir . '/stripe/stripe-php/lib/ApiResource.php',
'Stripe\\ApiResponse' => $vendorDir . '/stripe/stripe-php/lib/ApiResponse.php',
'Stripe\\ApplePayDomain' => $vendorDir . '/stripe/stripe-php/lib/ApplePayDomain.php',
'Stripe\\Application' => $vendorDir . '/stripe/stripe-php/lib/Application.php',
'Stripe\\ApplicationFee' => $vendorDir . '/stripe/stripe-php/lib/ApplicationFee.php',
'Stripe\\ApplicationFeeRefund' => $vendorDir . '/stripe/stripe-php/lib/ApplicationFeeRefund.php',
'Stripe\\Apps\\Secret' => $vendorDir . '/stripe/stripe-php/lib/Apps/Secret.php',
'Stripe\\Balance' => $vendorDir . '/stripe/stripe-php/lib/Balance.php',
'Stripe\\BalanceTransaction' => $vendorDir . '/stripe/stripe-php/lib/BalanceTransaction.php',
'Stripe\\BankAccount' => $vendorDir . '/stripe/stripe-php/lib/BankAccount.php',
'Stripe\\BaseStripeClient' => $vendorDir . '/stripe/stripe-php/lib/BaseStripeClient.php',
'Stripe\\BaseStripeClientInterface' => $vendorDir . '/stripe/stripe-php/lib/BaseStripeClientInterface.php',
'Stripe\\BillingPortal\\Configuration' => $vendorDir . '/stripe/stripe-php/lib/BillingPortal/Configuration.php',
'Stripe\\BillingPortal\\Session' => $vendorDir . '/stripe/stripe-php/lib/BillingPortal/Session.php',
'Stripe\\Billing\\Alert' => $vendorDir . '/stripe/stripe-php/lib/Billing/Alert.php',
'Stripe\\Billing\\AlertTriggered' => $vendorDir . '/stripe/stripe-php/lib/Billing/AlertTriggered.php',
'Stripe\\Billing\\CreditBalanceSummary' => $vendorDir . '/stripe/stripe-php/lib/Billing/CreditBalanceSummary.php',
'Stripe\\Billing\\CreditBalanceTransaction' => $vendorDir . '/stripe/stripe-php/lib/Billing/CreditBalanceTransaction.php',
'Stripe\\Billing\\CreditGrant' => $vendorDir . '/stripe/stripe-php/lib/Billing/CreditGrant.php',
'Stripe\\Billing\\Meter' => $vendorDir . '/stripe/stripe-php/lib/Billing/Meter.php',
'Stripe\\Billing\\MeterEvent' => $vendorDir . '/stripe/stripe-php/lib/Billing/MeterEvent.php',
'Stripe\\Billing\\MeterEventAdjustment' => $vendorDir . '/stripe/stripe-php/lib/Billing/MeterEventAdjustment.php',
'Stripe\\Billing\\MeterEventSummary' => $vendorDir . '/stripe/stripe-php/lib/Billing/MeterEventSummary.php',
'Stripe\\Capability' => $vendorDir . '/stripe/stripe-php/lib/Capability.php',
'Stripe\\Card' => $vendorDir . '/stripe/stripe-php/lib/Card.php',
'Stripe\\CashBalance' => $vendorDir . '/stripe/stripe-php/lib/CashBalance.php',
'Stripe\\Charge' => $vendorDir . '/stripe/stripe-php/lib/Charge.php',
'Stripe\\Checkout\\Session' => $vendorDir . '/stripe/stripe-php/lib/Checkout/Session.php',
'Stripe\\Climate\\Order' => $vendorDir . '/stripe/stripe-php/lib/Climate/Order.php',
'Stripe\\Climate\\Product' => $vendorDir . '/stripe/stripe-php/lib/Climate/Product.php',
'Stripe\\Climate\\Supplier' => $vendorDir . '/stripe/stripe-php/lib/Climate/Supplier.php',
'Stripe\\Collection' => $vendorDir . '/stripe/stripe-php/lib/Collection.php',
'Stripe\\ConfirmationToken' => $vendorDir . '/stripe/stripe-php/lib/ConfirmationToken.php',
'Stripe\\ConnectCollectionTransfer' => $vendorDir . '/stripe/stripe-php/lib/ConnectCollectionTransfer.php',
'Stripe\\CountrySpec' => $vendorDir . '/stripe/stripe-php/lib/CountrySpec.php',
'Stripe\\Coupon' => $vendorDir . '/stripe/stripe-php/lib/Coupon.php',
'Stripe\\CreditNote' => $vendorDir . '/stripe/stripe-php/lib/CreditNote.php',
'Stripe\\CreditNoteLineItem' => $vendorDir . '/stripe/stripe-php/lib/CreditNoteLineItem.php',
'Stripe\\Customer' => $vendorDir . '/stripe/stripe-php/lib/Customer.php',
'Stripe\\CustomerBalanceTransaction' => $vendorDir . '/stripe/stripe-php/lib/CustomerBalanceTransaction.php',
'Stripe\\CustomerCashBalanceTransaction' => $vendorDir . '/stripe/stripe-php/lib/CustomerCashBalanceTransaction.php',
'Stripe\\CustomerSession' => $vendorDir . '/stripe/stripe-php/lib/CustomerSession.php',
'Stripe\\Discount' => $vendorDir . '/stripe/stripe-php/lib/Discount.php',
'Stripe\\Dispute' => $vendorDir . '/stripe/stripe-php/lib/Dispute.php',
'Stripe\\Entitlements\\ActiveEntitlement' => $vendorDir . '/stripe/stripe-php/lib/Entitlements/ActiveEntitlement.php',
'Stripe\\Entitlements\\ActiveEntitlementSummary' => $vendorDir . '/stripe/stripe-php/lib/Entitlements/ActiveEntitlementSummary.php',
'Stripe\\Entitlements\\Feature' => $vendorDir . '/stripe/stripe-php/lib/Entitlements/Feature.php',
'Stripe\\EphemeralKey' => $vendorDir . '/stripe/stripe-php/lib/EphemeralKey.php',
'Stripe\\ErrorObject' => $vendorDir . '/stripe/stripe-php/lib/ErrorObject.php',
'Stripe\\Event' => $vendorDir . '/stripe/stripe-php/lib/Event.php',
'Stripe\\EventData\\V1BillingMeterErrorReportTriggeredEventData' => $vendorDir . '/stripe/stripe-php/lib/EventData/V1BillingMeterErrorReportTriggeredEventData.php',
'Stripe\\EventData\\V1BillingMeterNoMeterFoundEventData' => $vendorDir . '/stripe/stripe-php/lib/EventData/V1BillingMeterNoMeterFoundEventData.php',
'Stripe\\Events\\V1BillingMeterErrorReportTriggeredEvent' => $vendorDir . '/stripe/stripe-php/lib/Events/V1BillingMeterErrorReportTriggeredEvent.php',
'Stripe\\Events\\V1BillingMeterNoMeterFoundEvent' => $vendorDir . '/stripe/stripe-php/lib/Events/V1BillingMeterNoMeterFoundEvent.php',
'Stripe\\Events\\V2CoreEventDestinationPingEvent' => $vendorDir . '/stripe/stripe-php/lib/Events/V2CoreEventDestinationPingEvent.php',
'Stripe\\Exception\\ApiConnectionException' => $vendorDir . '/stripe/stripe-php/lib/Exception/ApiConnectionException.php',
'Stripe\\Exception\\ApiErrorException' => $vendorDir . '/stripe/stripe-php/lib/Exception/ApiErrorException.php',
'Stripe\\Exception\\AuthenticationException' => $vendorDir . '/stripe/stripe-php/lib/Exception/AuthenticationException.php',
'Stripe\\Exception\\BadMethodCallException' => $vendorDir . '/stripe/stripe-php/lib/Exception/BadMethodCallException.php',
'Stripe\\Exception\\CardException' => $vendorDir . '/stripe/stripe-php/lib/Exception/CardException.php',
'Stripe\\Exception\\ExceptionInterface' => $vendorDir . '/stripe/stripe-php/lib/Exception/ExceptionInterface.php',
'Stripe\\Exception\\IdempotencyException' => $vendorDir . '/stripe/stripe-php/lib/Exception/IdempotencyException.php',
'Stripe\\Exception\\InvalidArgumentException' => $vendorDir . '/stripe/stripe-php/lib/Exception/InvalidArgumentException.php',
'Stripe\\Exception\\InvalidRequestException' => $vendorDir . '/stripe/stripe-php/lib/Exception/InvalidRequestException.php',
'Stripe\\Exception\\OAuth\\ExceptionInterface' => $vendorDir . '/stripe/stripe-php/lib/Exception/OAuth/ExceptionInterface.php',
'Stripe\\Exception\\OAuth\\InvalidClientException' => $vendorDir . '/stripe/stripe-php/lib/Exception/OAuth/InvalidClientException.php',
'Stripe\\Exception\\OAuth\\InvalidGrantException' => $vendorDir . '/stripe/stripe-php/lib/Exception/OAuth/InvalidGrantException.php',
'Stripe\\Exception\\OAuth\\InvalidRequestException' => $vendorDir . '/stripe/stripe-php/lib/Exception/OAuth/InvalidRequestException.php',
'Stripe\\Exception\\OAuth\\InvalidScopeException' => $vendorDir . '/stripe/stripe-php/lib/Exception/OAuth/InvalidScopeException.php',
'Stripe\\Exception\\OAuth\\OAuthErrorException' => $vendorDir . '/stripe/stripe-php/lib/Exception/OAuth/OAuthErrorException.php',
'Stripe\\Exception\\OAuth\\UnknownOAuthErrorException' => $vendorDir . '/stripe/stripe-php/lib/Exception/OAuth/UnknownOAuthErrorException.php',
'Stripe\\Exception\\OAuth\\UnsupportedGrantTypeException' => $vendorDir . '/stripe/stripe-php/lib/Exception/OAuth/UnsupportedGrantTypeException.php',
'Stripe\\Exception\\OAuth\\UnsupportedResponseTypeException' => $vendorDir . '/stripe/stripe-php/lib/Exception/OAuth/UnsupportedResponseTypeException.php',
'Stripe\\Exception\\PermissionException' => $vendorDir . '/stripe/stripe-php/lib/Exception/PermissionException.php',
'Stripe\\Exception\\RateLimitException' => $vendorDir . '/stripe/stripe-php/lib/Exception/RateLimitException.php',
'Stripe\\Exception\\SignatureVerificationException' => $vendorDir . '/stripe/stripe-php/lib/Exception/SignatureVerificationException.php',
'Stripe\\Exception\\TemporarySessionExpiredException' => $vendorDir . '/stripe/stripe-php/lib/Exception/TemporarySessionExpiredException.php',
'Stripe\\Exception\\UnexpectedValueException' => $vendorDir . '/stripe/stripe-php/lib/Exception/UnexpectedValueException.php',
'Stripe\\Exception\\UnknownApiErrorException' => $vendorDir . '/stripe/stripe-php/lib/Exception/UnknownApiErrorException.php',
'Stripe\\ExchangeRate' => $vendorDir . '/stripe/stripe-php/lib/ExchangeRate.php',
'Stripe\\File' => $vendorDir . '/stripe/stripe-php/lib/File.php',
'Stripe\\FileLink' => $vendorDir . '/stripe/stripe-php/lib/FileLink.php',
'Stripe\\FinancialConnections\\Account' => $vendorDir . '/stripe/stripe-php/lib/FinancialConnections/Account.php',
'Stripe\\FinancialConnections\\AccountOwner' => $vendorDir . '/stripe/stripe-php/lib/FinancialConnections/AccountOwner.php',
'Stripe\\FinancialConnections\\AccountOwnership' => $vendorDir . '/stripe/stripe-php/lib/FinancialConnections/AccountOwnership.php',
'Stripe\\FinancialConnections\\Session' => $vendorDir . '/stripe/stripe-php/lib/FinancialConnections/Session.php',
'Stripe\\FinancialConnections\\Transaction' => $vendorDir . '/stripe/stripe-php/lib/FinancialConnections/Transaction.php',
'Stripe\\Forwarding\\Request' => $vendorDir . '/stripe/stripe-php/lib/Forwarding/Request.php',
'Stripe\\FundingInstructions' => $vendorDir . '/stripe/stripe-php/lib/FundingInstructions.php',
'Stripe\\HttpClient\\ClientInterface' => $vendorDir . '/stripe/stripe-php/lib/HttpClient/ClientInterface.php',
'Stripe\\HttpClient\\CurlClient' => $vendorDir . '/stripe/stripe-php/lib/HttpClient/CurlClient.php',
'Stripe\\HttpClient\\StreamingClientInterface' => $vendorDir . '/stripe/stripe-php/lib/HttpClient/StreamingClientInterface.php',
'Stripe\\Identity\\VerificationReport' => $vendorDir . '/stripe/stripe-php/lib/Identity/VerificationReport.php',
'Stripe\\Identity\\VerificationSession' => $vendorDir . '/stripe/stripe-php/lib/Identity/VerificationSession.php',
'Stripe\\Invoice' => $vendorDir . '/stripe/stripe-php/lib/Invoice.php',
'Stripe\\InvoiceItem' => $vendorDir . '/stripe/stripe-php/lib/InvoiceItem.php',
'Stripe\\InvoiceLineItem' => $vendorDir . '/stripe/stripe-php/lib/InvoiceLineItem.php',
'Stripe\\InvoicePayment' => $vendorDir . '/stripe/stripe-php/lib/InvoicePayment.php',
'Stripe\\InvoiceRenderingTemplate' => $vendorDir . '/stripe/stripe-php/lib/InvoiceRenderingTemplate.php',
'Stripe\\Issuing\\Authorization' => $vendorDir . '/stripe/stripe-php/lib/Issuing/Authorization.php',
'Stripe\\Issuing\\Card' => $vendorDir . '/stripe/stripe-php/lib/Issuing/Card.php',
'Stripe\\Issuing\\CardDetails' => $vendorDir . '/stripe/stripe-php/lib/Issuing/CardDetails.php',
'Stripe\\Issuing\\Cardholder' => $vendorDir . '/stripe/stripe-php/lib/Issuing/Cardholder.php',
'Stripe\\Issuing\\Dispute' => $vendorDir . '/stripe/stripe-php/lib/Issuing/Dispute.php',
'Stripe\\Issuing\\PersonalizationDesign' => $vendorDir . '/stripe/stripe-php/lib/Issuing/PersonalizationDesign.php',
'Stripe\\Issuing\\PhysicalBundle' => $vendorDir . '/stripe/stripe-php/lib/Issuing/PhysicalBundle.php',
'Stripe\\Issuing\\Token' => $vendorDir . '/stripe/stripe-php/lib/Issuing/Token.php',
'Stripe\\Issuing\\Transaction' => $vendorDir . '/stripe/stripe-php/lib/Issuing/Transaction.php',
'Stripe\\LineItem' => $vendorDir . '/stripe/stripe-php/lib/LineItem.php',
'Stripe\\LoginLink' => $vendorDir . '/stripe/stripe-php/lib/LoginLink.php',
'Stripe\\Mandate' => $vendorDir . '/stripe/stripe-php/lib/Mandate.php',
'Stripe\\OAuth' => $vendorDir . '/stripe/stripe-php/lib/OAuth.php',
'Stripe\\OAuthErrorObject' => $vendorDir . '/stripe/stripe-php/lib/OAuthErrorObject.php',
'Stripe\\PaymentIntent' => $vendorDir . '/stripe/stripe-php/lib/PaymentIntent.php',
'Stripe\\PaymentLink' => $vendorDir . '/stripe/stripe-php/lib/PaymentLink.php',
'Stripe\\PaymentMethod' => $vendorDir . '/stripe/stripe-php/lib/PaymentMethod.php',
'Stripe\\PaymentMethodConfiguration' => $vendorDir . '/stripe/stripe-php/lib/PaymentMethodConfiguration.php',
'Stripe\\PaymentMethodDomain' => $vendorDir . '/stripe/stripe-php/lib/PaymentMethodDomain.php',
'Stripe\\Payout' => $vendorDir . '/stripe/stripe-php/lib/Payout.php',
'Stripe\\Person' => $vendorDir . '/stripe/stripe-php/lib/Person.php',
'Stripe\\Plan' => $vendorDir . '/stripe/stripe-php/lib/Plan.php',
'Stripe\\Price' => $vendorDir . '/stripe/stripe-php/lib/Price.php',
'Stripe\\Product' => $vendorDir . '/stripe/stripe-php/lib/Product.php',
'Stripe\\ProductFeature' => $vendorDir . '/stripe/stripe-php/lib/ProductFeature.php',
'Stripe\\PromotionCode' => $vendorDir . '/stripe/stripe-php/lib/PromotionCode.php',
'Stripe\\Quote' => $vendorDir . '/stripe/stripe-php/lib/Quote.php',
'Stripe\\Radar\\EarlyFraudWarning' => $vendorDir . '/stripe/stripe-php/lib/Radar/EarlyFraudWarning.php',
'Stripe\\Radar\\ValueList' => $vendorDir . '/stripe/stripe-php/lib/Radar/ValueList.php',
'Stripe\\Radar\\ValueListItem' => $vendorDir . '/stripe/stripe-php/lib/Radar/ValueListItem.php',
'Stripe\\Reason' => $vendorDir . '/stripe/stripe-php/lib/Reason.php',
'Stripe\\RecipientTransfer' => $vendorDir . '/stripe/stripe-php/lib/RecipientTransfer.php',
'Stripe\\Refund' => $vendorDir . '/stripe/stripe-php/lib/Refund.php',
'Stripe\\RelatedObject' => $vendorDir . '/stripe/stripe-php/lib/RelatedObject.php',
'Stripe\\Reporting\\ReportRun' => $vendorDir . '/stripe/stripe-php/lib/Reporting/ReportRun.php',
'Stripe\\Reporting\\ReportType' => $vendorDir . '/stripe/stripe-php/lib/Reporting/ReportType.php',
'Stripe\\RequestTelemetry' => $vendorDir . '/stripe/stripe-php/lib/RequestTelemetry.php',
'Stripe\\ReserveTransaction' => $vendorDir . '/stripe/stripe-php/lib/ReserveTransaction.php',
'Stripe\\Review' => $vendorDir . '/stripe/stripe-php/lib/Review.php',
'Stripe\\SearchResult' => $vendorDir . '/stripe/stripe-php/lib/SearchResult.php',
'Stripe\\Service\\AbstractService' => $vendorDir . '/stripe/stripe-php/lib/Service/AbstractService.php',
'Stripe\\Service\\AbstractServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/AbstractServiceFactory.php',
'Stripe\\Service\\AccountLinkService' => $vendorDir . '/stripe/stripe-php/lib/Service/AccountLinkService.php',
'Stripe\\Service\\AccountService' => $vendorDir . '/stripe/stripe-php/lib/Service/AccountService.php',
'Stripe\\Service\\AccountSessionService' => $vendorDir . '/stripe/stripe-php/lib/Service/AccountSessionService.php',
'Stripe\\Service\\ApplePayDomainService' => $vendorDir . '/stripe/stripe-php/lib/Service/ApplePayDomainService.php',
'Stripe\\Service\\ApplicationFeeService' => $vendorDir . '/stripe/stripe-php/lib/Service/ApplicationFeeService.php',
'Stripe\\Service\\Apps\\AppsServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/Apps/AppsServiceFactory.php',
'Stripe\\Service\\Apps\\SecretService' => $vendorDir . '/stripe/stripe-php/lib/Service/Apps/SecretService.php',
'Stripe\\Service\\BalanceService' => $vendorDir . '/stripe/stripe-php/lib/Service/BalanceService.php',
'Stripe\\Service\\BalanceTransactionService' => $vendorDir . '/stripe/stripe-php/lib/Service/BalanceTransactionService.php',
'Stripe\\Service\\BillingPortal\\BillingPortalServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/BillingPortal/BillingPortalServiceFactory.php',
'Stripe\\Service\\BillingPortal\\ConfigurationService' => $vendorDir . '/stripe/stripe-php/lib/Service/BillingPortal/ConfigurationService.php',
'Stripe\\Service\\BillingPortal\\SessionService' => $vendorDir . '/stripe/stripe-php/lib/Service/BillingPortal/SessionService.php',
'Stripe\\Service\\Billing\\AlertService' => $vendorDir . '/stripe/stripe-php/lib/Service/Billing/AlertService.php',
'Stripe\\Service\\Billing\\BillingServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/Billing/BillingServiceFactory.php',
'Stripe\\Service\\Billing\\CreditBalanceSummaryService' => $vendorDir . '/stripe/stripe-php/lib/Service/Billing/CreditBalanceSummaryService.php',
'Stripe\\Service\\Billing\\CreditBalanceTransactionService' => $vendorDir . '/stripe/stripe-php/lib/Service/Billing/CreditBalanceTransactionService.php',
'Stripe\\Service\\Billing\\CreditGrantService' => $vendorDir . '/stripe/stripe-php/lib/Service/Billing/CreditGrantService.php',
'Stripe\\Service\\Billing\\MeterEventAdjustmentService' => $vendorDir . '/stripe/stripe-php/lib/Service/Billing/MeterEventAdjustmentService.php',
'Stripe\\Service\\Billing\\MeterEventService' => $vendorDir . '/stripe/stripe-php/lib/Service/Billing/MeterEventService.php',
'Stripe\\Service\\Billing\\MeterService' => $vendorDir . '/stripe/stripe-php/lib/Service/Billing/MeterService.php',
'Stripe\\Service\\ChargeService' => $vendorDir . '/stripe/stripe-php/lib/Service/ChargeService.php',
'Stripe\\Service\\Checkout\\CheckoutServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/Checkout/CheckoutServiceFactory.php',
'Stripe\\Service\\Checkout\\SessionService' => $vendorDir . '/stripe/stripe-php/lib/Service/Checkout/SessionService.php',
'Stripe\\Service\\Climate\\ClimateServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/Climate/ClimateServiceFactory.php',
'Stripe\\Service\\Climate\\OrderService' => $vendorDir . '/stripe/stripe-php/lib/Service/Climate/OrderService.php',
'Stripe\\Service\\Climate\\ProductService' => $vendorDir . '/stripe/stripe-php/lib/Service/Climate/ProductService.php',
'Stripe\\Service\\Climate\\SupplierService' => $vendorDir . '/stripe/stripe-php/lib/Service/Climate/SupplierService.php',
'Stripe\\Service\\ConfirmationTokenService' => $vendorDir . '/stripe/stripe-php/lib/Service/ConfirmationTokenService.php',
'Stripe\\Service\\CoreServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/CoreServiceFactory.php',
'Stripe\\Service\\CountrySpecService' => $vendorDir . '/stripe/stripe-php/lib/Service/CountrySpecService.php',
'Stripe\\Service\\CouponService' => $vendorDir . '/stripe/stripe-php/lib/Service/CouponService.php',
'Stripe\\Service\\CreditNoteService' => $vendorDir . '/stripe/stripe-php/lib/Service/CreditNoteService.php',
'Stripe\\Service\\CustomerService' => $vendorDir . '/stripe/stripe-php/lib/Service/CustomerService.php',
'Stripe\\Service\\CustomerSessionService' => $vendorDir . '/stripe/stripe-php/lib/Service/CustomerSessionService.php',
'Stripe\\Service\\DisputeService' => $vendorDir . '/stripe/stripe-php/lib/Service/DisputeService.php',
'Stripe\\Service\\Entitlements\\ActiveEntitlementService' => $vendorDir . '/stripe/stripe-php/lib/Service/Entitlements/ActiveEntitlementService.php',
'Stripe\\Service\\Entitlements\\EntitlementsServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/Entitlements/EntitlementsServiceFactory.php',
'Stripe\\Service\\Entitlements\\FeatureService' => $vendorDir . '/stripe/stripe-php/lib/Service/Entitlements/FeatureService.php',
'Stripe\\Service\\EphemeralKeyService' => $vendorDir . '/stripe/stripe-php/lib/Service/EphemeralKeyService.php',
'Stripe\\Service\\EventService' => $vendorDir . '/stripe/stripe-php/lib/Service/EventService.php',
'Stripe\\Service\\ExchangeRateService' => $vendorDir . '/stripe/stripe-php/lib/Service/ExchangeRateService.php',
'Stripe\\Service\\FileLinkService' => $vendorDir . '/stripe/stripe-php/lib/Service/FileLinkService.php',
'Stripe\\Service\\FileService' => $vendorDir . '/stripe/stripe-php/lib/Service/FileService.php',
'Stripe\\Service\\FinancialConnections\\AccountService' => $vendorDir . '/stripe/stripe-php/lib/Service/FinancialConnections/AccountService.php',
'Stripe\\Service\\FinancialConnections\\FinancialConnectionsServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/FinancialConnections/FinancialConnectionsServiceFactory.php',
'Stripe\\Service\\FinancialConnections\\SessionService' => $vendorDir . '/stripe/stripe-php/lib/Service/FinancialConnections/SessionService.php',
'Stripe\\Service\\FinancialConnections\\TransactionService' => $vendorDir . '/stripe/stripe-php/lib/Service/FinancialConnections/TransactionService.php',
'Stripe\\Service\\Forwarding\\ForwardingServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/Forwarding/ForwardingServiceFactory.php',
'Stripe\\Service\\Forwarding\\RequestService' => $vendorDir . '/stripe/stripe-php/lib/Service/Forwarding/RequestService.php',
'Stripe\\Service\\Identity\\IdentityServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/Identity/IdentityServiceFactory.php',
'Stripe\\Service\\Identity\\VerificationReportService' => $vendorDir . '/stripe/stripe-php/lib/Service/Identity/VerificationReportService.php',
'Stripe\\Service\\Identity\\VerificationSessionService' => $vendorDir . '/stripe/stripe-php/lib/Service/Identity/VerificationSessionService.php',
'Stripe\\Service\\InvoiceItemService' => $vendorDir . '/stripe/stripe-php/lib/Service/InvoiceItemService.php',
'Stripe\\Service\\InvoicePaymentService' => $vendorDir . '/stripe/stripe-php/lib/Service/InvoicePaymentService.php',
'Stripe\\Service\\InvoiceRenderingTemplateService' => $vendorDir . '/stripe/stripe-php/lib/Service/InvoiceRenderingTemplateService.php',
'Stripe\\Service\\InvoiceService' => $vendorDir . '/stripe/stripe-php/lib/Service/InvoiceService.php',
'Stripe\\Service\\Issuing\\AuthorizationService' => $vendorDir . '/stripe/stripe-php/lib/Service/Issuing/AuthorizationService.php',
'Stripe\\Service\\Issuing\\CardService' => $vendorDir . '/stripe/stripe-php/lib/Service/Issuing/CardService.php',
'Stripe\\Service\\Issuing\\CardholderService' => $vendorDir . '/stripe/stripe-php/lib/Service/Issuing/CardholderService.php',
'Stripe\\Service\\Issuing\\DisputeService' => $vendorDir . '/stripe/stripe-php/lib/Service/Issuing/DisputeService.php',
'Stripe\\Service\\Issuing\\IssuingServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/Issuing/IssuingServiceFactory.php',
'Stripe\\Service\\Issuing\\PersonalizationDesignService' => $vendorDir . '/stripe/stripe-php/lib/Service/Issuing/PersonalizationDesignService.php',
'Stripe\\Service\\Issuing\\PhysicalBundleService' => $vendorDir . '/stripe/stripe-php/lib/Service/Issuing/PhysicalBundleService.php',
'Stripe\\Service\\Issuing\\TokenService' => $vendorDir . '/stripe/stripe-php/lib/Service/Issuing/TokenService.php',
'Stripe\\Service\\Issuing\\TransactionService' => $vendorDir . '/stripe/stripe-php/lib/Service/Issuing/TransactionService.php',
'Stripe\\Service\\MandateService' => $vendorDir . '/stripe/stripe-php/lib/Service/MandateService.php',
'Stripe\\Service\\OAuthService' => $vendorDir . '/stripe/stripe-php/lib/Service/OAuthService.php',
'Stripe\\Service\\PaymentIntentService' => $vendorDir . '/stripe/stripe-php/lib/Service/PaymentIntentService.php',
'Stripe\\Service\\PaymentLinkService' => $vendorDir . '/stripe/stripe-php/lib/Service/PaymentLinkService.php',
'Stripe\\Service\\PaymentMethodConfigurationService' => $vendorDir . '/stripe/stripe-php/lib/Service/PaymentMethodConfigurationService.php',
'Stripe\\Service\\PaymentMethodDomainService' => $vendorDir . '/stripe/stripe-php/lib/Service/PaymentMethodDomainService.php',
'Stripe\\Service\\PaymentMethodService' => $vendorDir . '/stripe/stripe-php/lib/Service/PaymentMethodService.php',
'Stripe\\Service\\PayoutService' => $vendorDir . '/stripe/stripe-php/lib/Service/PayoutService.php',
'Stripe\\Service\\PlanService' => $vendorDir . '/stripe/stripe-php/lib/Service/PlanService.php',
'Stripe\\Service\\PriceService' => $vendorDir . '/stripe/stripe-php/lib/Service/PriceService.php',
'Stripe\\Service\\ProductService' => $vendorDir . '/stripe/stripe-php/lib/Service/ProductService.php',
'Stripe\\Service\\PromotionCodeService' => $vendorDir . '/stripe/stripe-php/lib/Service/PromotionCodeService.php',
'Stripe\\Service\\QuoteService' => $vendorDir . '/stripe/stripe-php/lib/Service/QuoteService.php',
'Stripe\\Service\\Radar\\EarlyFraudWarningService' => $vendorDir . '/stripe/stripe-php/lib/Service/Radar/EarlyFraudWarningService.php',
'Stripe\\Service\\Radar\\RadarServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/Radar/RadarServiceFactory.php',
'Stripe\\Service\\Radar\\ValueListItemService' => $vendorDir . '/stripe/stripe-php/lib/Service/Radar/ValueListItemService.php',
'Stripe\\Service\\Radar\\ValueListService' => $vendorDir . '/stripe/stripe-php/lib/Service/Radar/ValueListService.php',
'Stripe\\Service\\RefundService' => $vendorDir . '/stripe/stripe-php/lib/Service/RefundService.php',
'Stripe\\Service\\Reporting\\ReportRunService' => $vendorDir . '/stripe/stripe-php/lib/Service/Reporting/ReportRunService.php',
'Stripe\\Service\\Reporting\\ReportTypeService' => $vendorDir . '/stripe/stripe-php/lib/Service/Reporting/ReportTypeService.php',
'Stripe\\Service\\Reporting\\ReportingServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/Reporting/ReportingServiceFactory.php',
'Stripe\\Service\\ReviewService' => $vendorDir . '/stripe/stripe-php/lib/Service/ReviewService.php',
'Stripe\\Service\\ServiceNavigatorTrait' => $vendorDir . '/stripe/stripe-php/lib/Service/ServiceNavigatorTrait.php',
'Stripe\\Service\\SetupAttemptService' => $vendorDir . '/stripe/stripe-php/lib/Service/SetupAttemptService.php',
'Stripe\\Service\\SetupIntentService' => $vendorDir . '/stripe/stripe-php/lib/Service/SetupIntentService.php',
'Stripe\\Service\\ShippingRateService' => $vendorDir . '/stripe/stripe-php/lib/Service/ShippingRateService.php',
'Stripe\\Service\\Sigma\\ScheduledQueryRunService' => $vendorDir . '/stripe/stripe-php/lib/Service/Sigma/ScheduledQueryRunService.php',
'Stripe\\Service\\Sigma\\SigmaServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/Sigma/SigmaServiceFactory.php',
'Stripe\\Service\\SourceService' => $vendorDir . '/stripe/stripe-php/lib/Service/SourceService.php',
'Stripe\\Service\\SubscriptionItemService' => $vendorDir . '/stripe/stripe-php/lib/Service/SubscriptionItemService.php',
'Stripe\\Service\\SubscriptionScheduleService' => $vendorDir . '/stripe/stripe-php/lib/Service/SubscriptionScheduleService.php',
'Stripe\\Service\\SubscriptionService' => $vendorDir . '/stripe/stripe-php/lib/Service/SubscriptionService.php',
'Stripe\\Service\\TaxCodeService' => $vendorDir . '/stripe/stripe-php/lib/Service/TaxCodeService.php',
'Stripe\\Service\\TaxIdService' => $vendorDir . '/stripe/stripe-php/lib/Service/TaxIdService.php',
'Stripe\\Service\\TaxRateService' => $vendorDir . '/stripe/stripe-php/lib/Service/TaxRateService.php',
'Stripe\\Service\\Tax\\CalculationService' => $vendorDir . '/stripe/stripe-php/lib/Service/Tax/CalculationService.php',
'Stripe\\Service\\Tax\\RegistrationService' => $vendorDir . '/stripe/stripe-php/lib/Service/Tax/RegistrationService.php',
'Stripe\\Service\\Tax\\SettingsService' => $vendorDir . '/stripe/stripe-php/lib/Service/Tax/SettingsService.php',
'Stripe\\Service\\Tax\\TaxServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/Tax/TaxServiceFactory.php',
'Stripe\\Service\\Tax\\TransactionService' => $vendorDir . '/stripe/stripe-php/lib/Service/Tax/TransactionService.php',
'Stripe\\Service\\Terminal\\ConfigurationService' => $vendorDir . '/stripe/stripe-php/lib/Service/Terminal/ConfigurationService.php',
'Stripe\\Service\\Terminal\\ConnectionTokenService' => $vendorDir . '/stripe/stripe-php/lib/Service/Terminal/ConnectionTokenService.php',
'Stripe\\Service\\Terminal\\LocationService' => $vendorDir . '/stripe/stripe-php/lib/Service/Terminal/LocationService.php',
'Stripe\\Service\\Terminal\\ReaderService' => $vendorDir . '/stripe/stripe-php/lib/Service/Terminal/ReaderService.php',
'Stripe\\Service\\Terminal\\TerminalServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/Terminal/TerminalServiceFactory.php',
'Stripe\\Service\\TestHelpers\\ConfirmationTokenService' => $vendorDir . '/stripe/stripe-php/lib/Service/TestHelpers/ConfirmationTokenService.php',
'Stripe\\Service\\TestHelpers\\CustomerService' => $vendorDir . '/stripe/stripe-php/lib/Service/TestHelpers/CustomerService.php',
'Stripe\\Service\\TestHelpers\\Issuing\\AuthorizationService' => $vendorDir . '/stripe/stripe-php/lib/Service/TestHelpers/Issuing/AuthorizationService.php',
'Stripe\\Service\\TestHelpers\\Issuing\\CardService' => $vendorDir . '/stripe/stripe-php/lib/Service/TestHelpers/Issuing/CardService.php',
'Stripe\\Service\\TestHelpers\\Issuing\\IssuingServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/TestHelpers/Issuing/IssuingServiceFactory.php',
'Stripe\\Service\\TestHelpers\\Issuing\\PersonalizationDesignService' => $vendorDir . '/stripe/stripe-php/lib/Service/TestHelpers/Issuing/PersonalizationDesignService.php',
'Stripe\\Service\\TestHelpers\\Issuing\\TransactionService' => $vendorDir . '/stripe/stripe-php/lib/Service/TestHelpers/Issuing/TransactionService.php',
'Stripe\\Service\\TestHelpers\\RefundService' => $vendorDir . '/stripe/stripe-php/lib/Service/TestHelpers/RefundService.php',
'Stripe\\Service\\TestHelpers\\Terminal\\ReaderService' => $vendorDir . '/stripe/stripe-php/lib/Service/TestHelpers/Terminal/ReaderService.php',
'Stripe\\Service\\TestHelpers\\Terminal\\TerminalServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/TestHelpers/Terminal/TerminalServiceFactory.php',
'Stripe\\Service\\TestHelpers\\TestClockService' => $vendorDir . '/stripe/stripe-php/lib/Service/TestHelpers/TestClockService.php',
'Stripe\\Service\\TestHelpers\\TestHelpersServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/TestHelpers/TestHelpersServiceFactory.php',
'Stripe\\Service\\TestHelpers\\Treasury\\InboundTransferService' => $vendorDir . '/stripe/stripe-php/lib/Service/TestHelpers/Treasury/InboundTransferService.php',
'Stripe\\Service\\TestHelpers\\Treasury\\OutboundPaymentService' => $vendorDir . '/stripe/stripe-php/lib/Service/TestHelpers/Treasury/OutboundPaymentService.php',
'Stripe\\Service\\TestHelpers\\Treasury\\OutboundTransferService' => $vendorDir . '/stripe/stripe-php/lib/Service/TestHelpers/Treasury/OutboundTransferService.php',
'Stripe\\Service\\TestHelpers\\Treasury\\ReceivedCreditService' => $vendorDir . '/stripe/stripe-php/lib/Service/TestHelpers/Treasury/ReceivedCreditService.php',
'Stripe\\Service\\TestHelpers\\Treasury\\ReceivedDebitService' => $vendorDir . '/stripe/stripe-php/lib/Service/TestHelpers/Treasury/ReceivedDebitService.php',
'Stripe\\Service\\TestHelpers\\Treasury\\TreasuryServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/TestHelpers/Treasury/TreasuryServiceFactory.php',
'Stripe\\Service\\TokenService' => $vendorDir . '/stripe/stripe-php/lib/Service/TokenService.php',
'Stripe\\Service\\TopupService' => $vendorDir . '/stripe/stripe-php/lib/Service/TopupService.php',
'Stripe\\Service\\TransferService' => $vendorDir . '/stripe/stripe-php/lib/Service/TransferService.php',
'Stripe\\Service\\Treasury\\CreditReversalService' => $vendorDir . '/stripe/stripe-php/lib/Service/Treasury/CreditReversalService.php',
'Stripe\\Service\\Treasury\\DebitReversalService' => $vendorDir . '/stripe/stripe-php/lib/Service/Treasury/DebitReversalService.php',
'Stripe\\Service\\Treasury\\FinancialAccountService' => $vendorDir . '/stripe/stripe-php/lib/Service/Treasury/FinancialAccountService.php',
'Stripe\\Service\\Treasury\\InboundTransferService' => $vendorDir . '/stripe/stripe-php/lib/Service/Treasury/InboundTransferService.php',
'Stripe\\Service\\Treasury\\OutboundPaymentService' => $vendorDir . '/stripe/stripe-php/lib/Service/Treasury/OutboundPaymentService.php',
'Stripe\\Service\\Treasury\\OutboundTransferService' => $vendorDir . '/stripe/stripe-php/lib/Service/Treasury/OutboundTransferService.php',
'Stripe\\Service\\Treasury\\ReceivedCreditService' => $vendorDir . '/stripe/stripe-php/lib/Service/Treasury/ReceivedCreditService.php',
'Stripe\\Service\\Treasury\\ReceivedDebitService' => $vendorDir . '/stripe/stripe-php/lib/Service/Treasury/ReceivedDebitService.php',
'Stripe\\Service\\Treasury\\TransactionEntryService' => $vendorDir . '/stripe/stripe-php/lib/Service/Treasury/TransactionEntryService.php',
'Stripe\\Service\\Treasury\\TransactionService' => $vendorDir . '/stripe/stripe-php/lib/Service/Treasury/TransactionService.php',
'Stripe\\Service\\Treasury\\TreasuryServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/Treasury/TreasuryServiceFactory.php',
'Stripe\\Service\\V2\\Billing\\BillingServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/V2/Billing/BillingServiceFactory.php',
'Stripe\\Service\\V2\\Billing\\MeterEventAdjustmentService' => $vendorDir . '/stripe/stripe-php/lib/Service/V2/Billing/MeterEventAdjustmentService.php',
'Stripe\\Service\\V2\\Billing\\MeterEventService' => $vendorDir . '/stripe/stripe-php/lib/Service/V2/Billing/MeterEventService.php',
'Stripe\\Service\\V2\\Billing\\MeterEventSessionService' => $vendorDir . '/stripe/stripe-php/lib/Service/V2/Billing/MeterEventSessionService.php',
'Stripe\\Service\\V2\\Billing\\MeterEventStreamService' => $vendorDir . '/stripe/stripe-php/lib/Service/V2/Billing/MeterEventStreamService.php',
'Stripe\\Service\\V2\\Core\\CoreServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/V2/Core/CoreServiceFactory.php',
'Stripe\\Service\\V2\\Core\\EventDestinationService' => $vendorDir . '/stripe/stripe-php/lib/Service/V2/Core/EventDestinationService.php',
'Stripe\\Service\\V2\\Core\\EventService' => $vendorDir . '/stripe/stripe-php/lib/Service/V2/Core/EventService.php',
'Stripe\\Service\\V2\\V2ServiceFactory' => $vendorDir . '/stripe/stripe-php/lib/Service/V2/V2ServiceFactory.php',
'Stripe\\Service\\WebhookEndpointService' => $vendorDir . '/stripe/stripe-php/lib/Service/WebhookEndpointService.php',
'Stripe\\SetupAttempt' => $vendorDir . '/stripe/stripe-php/lib/SetupAttempt.php',
'Stripe\\SetupIntent' => $vendorDir . '/stripe/stripe-php/lib/SetupIntent.php',
'Stripe\\ShippingRate' => $vendorDir . '/stripe/stripe-php/lib/ShippingRate.php',
'Stripe\\Sigma\\ScheduledQueryRun' => $vendorDir . '/stripe/stripe-php/lib/Sigma/ScheduledQueryRun.php',
'Stripe\\SingletonApiResource' => $vendorDir . '/stripe/stripe-php/lib/SingletonApiResource.php',
'Stripe\\Source' => $vendorDir . '/stripe/stripe-php/lib/Source.php',
'Stripe\\SourceMandateNotification' => $vendorDir . '/stripe/stripe-php/lib/SourceMandateNotification.php',
'Stripe\\SourceTransaction' => $vendorDir . '/stripe/stripe-php/lib/SourceTransaction.php',
'Stripe\\Stripe' => $vendorDir . '/stripe/stripe-php/lib/Stripe.php',
'Stripe\\StripeClient' => $vendorDir . '/stripe/stripe-php/lib/StripeClient.php',
'Stripe\\StripeClientInterface' => $vendorDir . '/stripe/stripe-php/lib/StripeClientInterface.php',
'Stripe\\StripeObject' => $vendorDir . '/stripe/stripe-php/lib/StripeObject.php',
'Stripe\\StripeStreamingClientInterface' => $vendorDir . '/stripe/stripe-php/lib/StripeStreamingClientInterface.php',
'Stripe\\Subscription' => $vendorDir . '/stripe/stripe-php/lib/Subscription.php',
'Stripe\\SubscriptionItem' => $vendorDir . '/stripe/stripe-php/lib/SubscriptionItem.php',
'Stripe\\SubscriptionSchedule' => $vendorDir . '/stripe/stripe-php/lib/SubscriptionSchedule.php',
'Stripe\\TaxCode' => $vendorDir . '/stripe/stripe-php/lib/TaxCode.php',
'Stripe\\TaxDeductedAtSource' => $vendorDir . '/stripe/stripe-php/lib/TaxDeductedAtSource.php',
'Stripe\\TaxId' => $vendorDir . '/stripe/stripe-php/lib/TaxId.php',
'Stripe\\TaxRate' => $vendorDir . '/stripe/stripe-php/lib/TaxRate.php',
'Stripe\\Tax\\Calculation' => $vendorDir . '/stripe/stripe-php/lib/Tax/Calculation.php',
'Stripe\\Tax\\CalculationLineItem' => $vendorDir . '/stripe/stripe-php/lib/Tax/CalculationLineItem.php',
'Stripe\\Tax\\Registration' => $vendorDir . '/stripe/stripe-php/lib/Tax/Registration.php',
'Stripe\\Tax\\Settings' => $vendorDir . '/stripe/stripe-php/lib/Tax/Settings.php',
'Stripe\\Tax\\Transaction' => $vendorDir . '/stripe/stripe-php/lib/Tax/Transaction.php',
'Stripe\\Tax\\TransactionLineItem' => $vendorDir . '/stripe/stripe-php/lib/Tax/TransactionLineItem.php',
'Stripe\\Terminal\\Configuration' => $vendorDir . '/stripe/stripe-php/lib/Terminal/Configuration.php',
'Stripe\\Terminal\\ConnectionToken' => $vendorDir . '/stripe/stripe-php/lib/Terminal/ConnectionToken.php',
'Stripe\\Terminal\\Location' => $vendorDir . '/stripe/stripe-php/lib/Terminal/Location.php',
'Stripe\\Terminal\\Reader' => $vendorDir . '/stripe/stripe-php/lib/Terminal/Reader.php',
'Stripe\\TestHelpers\\TestClock' => $vendorDir . '/stripe/stripe-php/lib/TestHelpers/TestClock.php',
'Stripe\\ThinEvent' => $vendorDir . '/stripe/stripe-php/lib/ThinEvent.php',
'Stripe\\Token' => $vendorDir . '/stripe/stripe-php/lib/Token.php',
'Stripe\\Topup' => $vendorDir . '/stripe/stripe-php/lib/Topup.php',
'Stripe\\Transfer' => $vendorDir . '/stripe/stripe-php/lib/Transfer.php',
'Stripe\\TransferReversal' => $vendorDir . '/stripe/stripe-php/lib/TransferReversal.php',
'Stripe\\Treasury\\CreditReversal' => $vendorDir . '/stripe/stripe-php/lib/Treasury/CreditReversal.php',
'Stripe\\Treasury\\DebitReversal' => $vendorDir . '/stripe/stripe-php/lib/Treasury/DebitReversal.php',
'Stripe\\Treasury\\FinancialAccount' => $vendorDir . '/stripe/stripe-php/lib/Treasury/FinancialAccount.php',
'Stripe\\Treasury\\FinancialAccountFeatures' => $vendorDir . '/stripe/stripe-php/lib/Treasury/FinancialAccountFeatures.php',
'Stripe\\Treasury\\InboundTransfer' => $vendorDir . '/stripe/stripe-php/lib/Treasury/InboundTransfer.php',
'Stripe\\Treasury\\OutboundPayment' => $vendorDir . '/stripe/stripe-php/lib/Treasury/OutboundPayment.php',
'Stripe\\Treasury\\OutboundTransfer' => $vendorDir . '/stripe/stripe-php/lib/Treasury/OutboundTransfer.php',
'Stripe\\Treasury\\ReceivedCredit' => $vendorDir . '/stripe/stripe-php/lib/Treasury/ReceivedCredit.php',
'Stripe\\Treasury\\ReceivedDebit' => $vendorDir . '/stripe/stripe-php/lib/Treasury/ReceivedDebit.php',
'Stripe\\Treasury\\Transaction' => $vendorDir . '/stripe/stripe-php/lib/Treasury/Transaction.php',
'Stripe\\Treasury\\TransactionEntry' => $vendorDir . '/stripe/stripe-php/lib/Treasury/TransactionEntry.php',
'Stripe\\Util\\ApiVersion' => $vendorDir . '/stripe/stripe-php/lib/Util/ApiVersion.php',
'Stripe\\Util\\CaseInsensitiveArray' => $vendorDir . '/stripe/stripe-php/lib/Util/CaseInsensitiveArray.php',
'Stripe\\Util\\DefaultLogger' => $vendorDir . '/stripe/stripe-php/lib/Util/DefaultLogger.php',
'Stripe\\Util\\EventTypes' => $vendorDir . '/stripe/stripe-php/lib/Util/EventTypes.php',
'Stripe\\Util\\LoggerInterface' => $vendorDir . '/stripe/stripe-php/lib/Util/LoggerInterface.php',
'Stripe\\Util\\ObjectTypes' => $vendorDir . '/stripe/stripe-php/lib/Util/ObjectTypes.php',
'Stripe\\Util\\RandomGenerator' => $vendorDir . '/stripe/stripe-php/lib/Util/RandomGenerator.php',
'Stripe\\Util\\RequestOptions' => $vendorDir . '/stripe/stripe-php/lib/Util/RequestOptions.php',
'Stripe\\Util\\Set' => $vendorDir . '/stripe/stripe-php/lib/Util/Set.php',
'Stripe\\Util\\Util' => $vendorDir . '/stripe/stripe-php/lib/Util/Util.php',
'Stripe\\V2\\Billing\\MeterEvent' => $vendorDir . '/stripe/stripe-php/lib/V2/Billing/MeterEvent.php',
'Stripe\\V2\\Billing\\MeterEventAdjustment' => $vendorDir . '/stripe/stripe-php/lib/V2/Billing/MeterEventAdjustment.php',
'Stripe\\V2\\Billing\\MeterEventSession' => $vendorDir . '/stripe/stripe-php/lib/V2/Billing/MeterEventSession.php',
'Stripe\\V2\\Collection' => $vendorDir . '/stripe/stripe-php/lib/V2/Collection.php',
'Stripe\\V2\\Event' => $vendorDir . '/stripe/stripe-php/lib/V2/Event.php',
'Stripe\\V2\\EventDestination' => $vendorDir . '/stripe/stripe-php/lib/V2/EventDestination.php',
'Stripe\\Webhook' => $vendorDir . '/stripe/stripe-php/lib/Webhook.php',
'Stripe\\WebhookEndpoint' => $vendorDir . '/stripe/stripe-php/lib/WebhookEndpoint.php',
'Stripe\\WebhookSignature' => $vendorDir . '/stripe/stripe-php/lib/WebhookSignature.php',
'ZipStream\\CentralDirectoryFileHeader' => $vendorDir . '/maennchen/zipstream-php/src/CentralDirectoryFileHeader.php',
'ZipStream\\CompressionMethod' => $vendorDir . '/maennchen/zipstream-php/src/CompressionMethod.php',
'ZipStream\\DataDescriptor' => $vendorDir . '/maennchen/zipstream-php/src/DataDescriptor.php',

View File

@@ -7,6 +7,7 @@ $baseDir = dirname($vendorDir);
return array(
'ZipStream\\' => array($vendorDir . '/maennchen/zipstream-php/src'),
'Stripe\\' => array($vendorDir . '/stripe/stripe-php/lib'),
'Psr\\SimpleCache\\' => array($vendorDir . '/psr/simple-cache/src'),
'Psr\\Http\\Message\\' => array($vendorDir . '/psr/http-factory/src', $vendorDir . '/psr/http-message/src'),
'Psr\\Http\\Client\\' => array($vendorDir . '/psr/http-client/src'),

View File

@@ -22,6 +22,8 @@ class ComposerAutoloaderInit03e608fa83a14a82b3f9223977e9674e
return self::$loader;
}
require __DIR__ . '/platform_check.php';
spl_autoload_register(array('ComposerAutoloaderInit03e608fa83a14a82b3f9223977e9674e', 'loadClassLoader'), true, true);
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
spl_autoload_unregister(array('ComposerAutoloaderInit03e608fa83a14a82b3f9223977e9674e', 'loadClassLoader'));

View File

@@ -11,6 +11,10 @@ class ComposerStaticInit03e608fa83a14a82b3f9223977e9674e
array (
'ZipStream\\' => 10,
),
'S' =>
array (
'Stripe\\' => 7,
),
'P' =>
array (
'Psr\\SimpleCache\\' => 16,
@@ -35,6 +39,10 @@ class ComposerStaticInit03e608fa83a14a82b3f9223977e9674e
array (
0 => __DIR__ . '/..' . '/maennchen/zipstream-php/src',
),
'Stripe\\' =>
array (
0 => __DIR__ . '/..' . '/stripe/stripe-php/lib',
),
'Psr\\SimpleCache\\' =>
array (
0 => __DIR__ . '/..' . '/psr/simple-cache/src',
@@ -71,15 +79,31 @@ class ComposerStaticInit03e608fa83a14a82b3f9223977e9674e
);
public static $classMap = array (
'AddressService' => __DIR__ . '/../..' . '/src/Services/AddressService.php',
'AddressesDatabase' => __DIR__ . '/../..' . '/src/Core/AddressesDatabase.php',
'ApiService' => __DIR__ . '/../..' . '/src/Services/ApiService.php',
'AppConfig' => __DIR__ . '/../..' . '/src/Config/AppConfig.php',
'App\\Controllers\\ChatController' => __DIR__ . '/../..' . '/src/Controllers/ChatController.php',
'App\\Controllers\\EntiteController' => __DIR__ . '/../..' . '/src/Controllers/EntiteController.php',
'App\\Controllers\\FileController' => __DIR__ . '/../..' . '/src/Controllers/FileController.php',
'App\\Controllers\\LoginController' => __DIR__ . '/../..' . '/src/Controllers/LoginController.php',
'App\\Controllers\\OperationController' => __DIR__ . '/../..' . '/src/Controllers/OperationController.php',
'App\\Controllers\\PassageController' => __DIR__ . '/../..' . '/src/Controllers/PassageController.php',
'App\\Controllers\\PasswordController' => __DIR__ . '/../..' . '/src/Controllers/PasswordController.php',
'App\\Controllers\\SectorController' => __DIR__ . '/../..' . '/src/Controllers/SectorController.php',
'App\\Controllers\\SecurityController' => __DIR__ . '/../..' . '/src/Controllers/SecurityController.php',
'App\\Controllers\\UserController' => __DIR__ . '/../..' . '/src/Controllers/UserController.php',
'App\\Controllers\\VilleController' => __DIR__ . '/../..' . '/src/Controllers/VilleController.php',
'App\\Services\\PDFGenerator' => __DIR__ . '/../..' . '/src/Services/PDFGenerator.php',
'App\\Services\\PasswordSecurityService' => __DIR__ . '/../..' . '/src/Services/PasswordSecurityService.php',
'App\\Services\\ReceiptPDFGenerator' => __DIR__ . '/../..' . '/src/Services/ReceiptPDFGenerator.php',
'App\\Services\\ReceiptService' => __DIR__ . '/../..' . '/src/Services/ReceiptService.php',
'App\\Services\\Security\\AlertService' => __DIR__ . '/../..' . '/src/Services/Security/AlertService.php',
'App\\Services\\Security\\EmailThrottler' => __DIR__ . '/../..' . '/src/Services/Security/EmailThrottler.php',
'App\\Services\\Security\\IPBlocker' => __DIR__ . '/../..' . '/src/Services/Security/IPBlocker.php',
'App\\Services\\Security\\PerformanceMonitor' => __DIR__ . '/../..' . '/src/Services/Security/PerformanceMonitor.php',
'App\\Services\\Security\\SecurityMonitor' => __DIR__ . '/../..' . '/src/Services/Security/SecurityMonitor.php',
'App\\Services\\SimplePDF' => __DIR__ . '/../..' . '/src/Services/SimplePDF.php',
'BackupEncryptionService' => __DIR__ . '/../..' . '/src/Services/BackupEncryptionService.php',
'ClientDetector' => __DIR__ . '/../..' . '/src/Utils/ClientDetector.php',
'Complex\\Complex' => __DIR__ . '/..' . '/markbaker/complex/classes/src/Complex.php',
@@ -105,8 +129,11 @@ class ComposerStaticInit03e608fa83a14a82b3f9223977e9674e
'Composer\\Pcre\\ReplaceResult' => __DIR__ . '/..' . '/composer/pcre/src/ReplaceResult.php',
'Composer\\Pcre\\UnexpectedNullMatchException' => __DIR__ . '/..' . '/composer/pcre/src/UnexpectedNullMatchException.php',
'Database' => __DIR__ . '/../..' . '/src/Core/Database.php',
'DepartmentBoundaryService' => __DIR__ . '/../..' . '/src/Services/DepartmentBoundaryService.php',
'EmailTemplates' => __DIR__ . '/../..' . '/src/Services/EmailTemplates.php',
'ExportService' => __DIR__ . '/../..' . '/src/Services/ExportService.php',
'FPDF' => __DIR__ . '/..' . '/setasign/fpdf/fpdf.php',
'FileService' => __DIR__ . '/../..' . '/src/Services/FileService.php',
'LogController' => __DIR__ . '/../..' . '/src/Controllers/LogController.php',
'LogService' => __DIR__ . '/../..' . '/src/Services/LogService.php',
'Matrix\\Builder' => __DIR__ . '/..' . '/markbaker/matrix/classes/src/Builder.php',
@@ -124,6 +151,9 @@ class ComposerStaticInit03e608fa83a14a82b3f9223977e9674e
'Matrix\\Operators\\Multiplication' => __DIR__ . '/..' . '/markbaker/matrix/classes/src/Operators/Multiplication.php',
'Matrix\\Operators\\Operator' => __DIR__ . '/..' . '/markbaker/matrix/classes/src/Operators/Operator.php',
'Matrix\\Operators\\Subtraction' => __DIR__ . '/..' . '/markbaker/matrix/classes/src/Operators/Subtraction.php',
'MonitoredDatabase' => __DIR__ . '/../..' . '/src/Core/MonitoredDatabase.php',
'MonitoredStatement' => __DIR__ . '/../..' . '/src/Core/MonitoredDatabase.php',
'OperationDataService' => __DIR__ . '/../..' . '/src/Services/OperationDataService.php',
'PHPMailer\\PHPMailer\\DSNConfigurator' => __DIR__ . '/..' . '/phpmailer/phpmailer/src/DSNConfigurator.php',
'PHPMailer\\PHPMailer\\Exception' => __DIR__ . '/..' . '/phpmailer/phpmailer/src/Exception.php',
'PHPMailer\\PHPMailer\\OAuth' => __DIR__ . '/..' . '/phpmailer/phpmailer/src/OAuth.php',
@@ -654,6 +684,377 @@ class ComposerStaticInit03e608fa83a14a82b3f9223977e9674e
'Response' => __DIR__ . '/../..' . '/src/Core/Response.php',
'Router' => __DIR__ . '/../..' . '/src/Core/Router.php',
'Session' => __DIR__ . '/../..' . '/src/Core/Session.php',
'Stripe\\Account' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Account.php',
'Stripe\\AccountLink' => __DIR__ . '/..' . '/stripe/stripe-php/lib/AccountLink.php',
'Stripe\\AccountSession' => __DIR__ . '/..' . '/stripe/stripe-php/lib/AccountSession.php',
'Stripe\\ApiOperations\\All' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ApiOperations/All.php',
'Stripe\\ApiOperations\\Create' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ApiOperations/Create.php',
'Stripe\\ApiOperations\\Delete' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ApiOperations/Delete.php',
'Stripe\\ApiOperations\\NestedResource' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ApiOperations/NestedResource.php',
'Stripe\\ApiOperations\\Request' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ApiOperations/Request.php',
'Stripe\\ApiOperations\\Retrieve' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ApiOperations/Retrieve.php',
'Stripe\\ApiOperations\\SingletonRetrieve' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ApiOperations/SingletonRetrieve.php',
'Stripe\\ApiOperations\\Update' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ApiOperations/Update.php',
'Stripe\\ApiRequestor' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ApiRequestor.php',
'Stripe\\ApiResource' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ApiResource.php',
'Stripe\\ApiResponse' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ApiResponse.php',
'Stripe\\ApplePayDomain' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ApplePayDomain.php',
'Stripe\\Application' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Application.php',
'Stripe\\ApplicationFee' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ApplicationFee.php',
'Stripe\\ApplicationFeeRefund' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ApplicationFeeRefund.php',
'Stripe\\Apps\\Secret' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Apps/Secret.php',
'Stripe\\Balance' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Balance.php',
'Stripe\\BalanceTransaction' => __DIR__ . '/..' . '/stripe/stripe-php/lib/BalanceTransaction.php',
'Stripe\\BankAccount' => __DIR__ . '/..' . '/stripe/stripe-php/lib/BankAccount.php',
'Stripe\\BaseStripeClient' => __DIR__ . '/..' . '/stripe/stripe-php/lib/BaseStripeClient.php',
'Stripe\\BaseStripeClientInterface' => __DIR__ . '/..' . '/stripe/stripe-php/lib/BaseStripeClientInterface.php',
'Stripe\\BillingPortal\\Configuration' => __DIR__ . '/..' . '/stripe/stripe-php/lib/BillingPortal/Configuration.php',
'Stripe\\BillingPortal\\Session' => __DIR__ . '/..' . '/stripe/stripe-php/lib/BillingPortal/Session.php',
'Stripe\\Billing\\Alert' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Billing/Alert.php',
'Stripe\\Billing\\AlertTriggered' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Billing/AlertTriggered.php',
'Stripe\\Billing\\CreditBalanceSummary' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Billing/CreditBalanceSummary.php',
'Stripe\\Billing\\CreditBalanceTransaction' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Billing/CreditBalanceTransaction.php',
'Stripe\\Billing\\CreditGrant' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Billing/CreditGrant.php',
'Stripe\\Billing\\Meter' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Billing/Meter.php',
'Stripe\\Billing\\MeterEvent' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Billing/MeterEvent.php',
'Stripe\\Billing\\MeterEventAdjustment' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Billing/MeterEventAdjustment.php',
'Stripe\\Billing\\MeterEventSummary' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Billing/MeterEventSummary.php',
'Stripe\\Capability' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Capability.php',
'Stripe\\Card' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Card.php',
'Stripe\\CashBalance' => __DIR__ . '/..' . '/stripe/stripe-php/lib/CashBalance.php',
'Stripe\\Charge' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Charge.php',
'Stripe\\Checkout\\Session' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Checkout/Session.php',
'Stripe\\Climate\\Order' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Climate/Order.php',
'Stripe\\Climate\\Product' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Climate/Product.php',
'Stripe\\Climate\\Supplier' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Climate/Supplier.php',
'Stripe\\Collection' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Collection.php',
'Stripe\\ConfirmationToken' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ConfirmationToken.php',
'Stripe\\ConnectCollectionTransfer' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ConnectCollectionTransfer.php',
'Stripe\\CountrySpec' => __DIR__ . '/..' . '/stripe/stripe-php/lib/CountrySpec.php',
'Stripe\\Coupon' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Coupon.php',
'Stripe\\CreditNote' => __DIR__ . '/..' . '/stripe/stripe-php/lib/CreditNote.php',
'Stripe\\CreditNoteLineItem' => __DIR__ . '/..' . '/stripe/stripe-php/lib/CreditNoteLineItem.php',
'Stripe\\Customer' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Customer.php',
'Stripe\\CustomerBalanceTransaction' => __DIR__ . '/..' . '/stripe/stripe-php/lib/CustomerBalanceTransaction.php',
'Stripe\\CustomerCashBalanceTransaction' => __DIR__ . '/..' . '/stripe/stripe-php/lib/CustomerCashBalanceTransaction.php',
'Stripe\\CustomerSession' => __DIR__ . '/..' . '/stripe/stripe-php/lib/CustomerSession.php',
'Stripe\\Discount' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Discount.php',
'Stripe\\Dispute' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Dispute.php',
'Stripe\\Entitlements\\ActiveEntitlement' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Entitlements/ActiveEntitlement.php',
'Stripe\\Entitlements\\ActiveEntitlementSummary' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Entitlements/ActiveEntitlementSummary.php',
'Stripe\\Entitlements\\Feature' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Entitlements/Feature.php',
'Stripe\\EphemeralKey' => __DIR__ . '/..' . '/stripe/stripe-php/lib/EphemeralKey.php',
'Stripe\\ErrorObject' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ErrorObject.php',
'Stripe\\Event' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Event.php',
'Stripe\\EventData\\V1BillingMeterErrorReportTriggeredEventData' => __DIR__ . '/..' . '/stripe/stripe-php/lib/EventData/V1BillingMeterErrorReportTriggeredEventData.php',
'Stripe\\EventData\\V1BillingMeterNoMeterFoundEventData' => __DIR__ . '/..' . '/stripe/stripe-php/lib/EventData/V1BillingMeterNoMeterFoundEventData.php',
'Stripe\\Events\\V1BillingMeterErrorReportTriggeredEvent' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Events/V1BillingMeterErrorReportTriggeredEvent.php',
'Stripe\\Events\\V1BillingMeterNoMeterFoundEvent' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Events/V1BillingMeterNoMeterFoundEvent.php',
'Stripe\\Events\\V2CoreEventDestinationPingEvent' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Events/V2CoreEventDestinationPingEvent.php',
'Stripe\\Exception\\ApiConnectionException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/ApiConnectionException.php',
'Stripe\\Exception\\ApiErrorException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/ApiErrorException.php',
'Stripe\\Exception\\AuthenticationException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/AuthenticationException.php',
'Stripe\\Exception\\BadMethodCallException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/BadMethodCallException.php',
'Stripe\\Exception\\CardException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/CardException.php',
'Stripe\\Exception\\ExceptionInterface' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/ExceptionInterface.php',
'Stripe\\Exception\\IdempotencyException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/IdempotencyException.php',
'Stripe\\Exception\\InvalidArgumentException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/InvalidArgumentException.php',
'Stripe\\Exception\\InvalidRequestException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/InvalidRequestException.php',
'Stripe\\Exception\\OAuth\\ExceptionInterface' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/OAuth/ExceptionInterface.php',
'Stripe\\Exception\\OAuth\\InvalidClientException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/OAuth/InvalidClientException.php',
'Stripe\\Exception\\OAuth\\InvalidGrantException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/OAuth/InvalidGrantException.php',
'Stripe\\Exception\\OAuth\\InvalidRequestException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/OAuth/InvalidRequestException.php',
'Stripe\\Exception\\OAuth\\InvalidScopeException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/OAuth/InvalidScopeException.php',
'Stripe\\Exception\\OAuth\\OAuthErrorException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/OAuth/OAuthErrorException.php',
'Stripe\\Exception\\OAuth\\UnknownOAuthErrorException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/OAuth/UnknownOAuthErrorException.php',
'Stripe\\Exception\\OAuth\\UnsupportedGrantTypeException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/OAuth/UnsupportedGrantTypeException.php',
'Stripe\\Exception\\OAuth\\UnsupportedResponseTypeException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/OAuth/UnsupportedResponseTypeException.php',
'Stripe\\Exception\\PermissionException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/PermissionException.php',
'Stripe\\Exception\\RateLimitException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/RateLimitException.php',
'Stripe\\Exception\\SignatureVerificationException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/SignatureVerificationException.php',
'Stripe\\Exception\\TemporarySessionExpiredException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/TemporarySessionExpiredException.php',
'Stripe\\Exception\\UnexpectedValueException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/UnexpectedValueException.php',
'Stripe\\Exception\\UnknownApiErrorException' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Exception/UnknownApiErrorException.php',
'Stripe\\ExchangeRate' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ExchangeRate.php',
'Stripe\\File' => __DIR__ . '/..' . '/stripe/stripe-php/lib/File.php',
'Stripe\\FileLink' => __DIR__ . '/..' . '/stripe/stripe-php/lib/FileLink.php',
'Stripe\\FinancialConnections\\Account' => __DIR__ . '/..' . '/stripe/stripe-php/lib/FinancialConnections/Account.php',
'Stripe\\FinancialConnections\\AccountOwner' => __DIR__ . '/..' . '/stripe/stripe-php/lib/FinancialConnections/AccountOwner.php',
'Stripe\\FinancialConnections\\AccountOwnership' => __DIR__ . '/..' . '/stripe/stripe-php/lib/FinancialConnections/AccountOwnership.php',
'Stripe\\FinancialConnections\\Session' => __DIR__ . '/..' . '/stripe/stripe-php/lib/FinancialConnections/Session.php',
'Stripe\\FinancialConnections\\Transaction' => __DIR__ . '/..' . '/stripe/stripe-php/lib/FinancialConnections/Transaction.php',
'Stripe\\Forwarding\\Request' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Forwarding/Request.php',
'Stripe\\FundingInstructions' => __DIR__ . '/..' . '/stripe/stripe-php/lib/FundingInstructions.php',
'Stripe\\HttpClient\\ClientInterface' => __DIR__ . '/..' . '/stripe/stripe-php/lib/HttpClient/ClientInterface.php',
'Stripe\\HttpClient\\CurlClient' => __DIR__ . '/..' . '/stripe/stripe-php/lib/HttpClient/CurlClient.php',
'Stripe\\HttpClient\\StreamingClientInterface' => __DIR__ . '/..' . '/stripe/stripe-php/lib/HttpClient/StreamingClientInterface.php',
'Stripe\\Identity\\VerificationReport' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Identity/VerificationReport.php',
'Stripe\\Identity\\VerificationSession' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Identity/VerificationSession.php',
'Stripe\\Invoice' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Invoice.php',
'Stripe\\InvoiceItem' => __DIR__ . '/..' . '/stripe/stripe-php/lib/InvoiceItem.php',
'Stripe\\InvoiceLineItem' => __DIR__ . '/..' . '/stripe/stripe-php/lib/InvoiceLineItem.php',
'Stripe\\InvoicePayment' => __DIR__ . '/..' . '/stripe/stripe-php/lib/InvoicePayment.php',
'Stripe\\InvoiceRenderingTemplate' => __DIR__ . '/..' . '/stripe/stripe-php/lib/InvoiceRenderingTemplate.php',
'Stripe\\Issuing\\Authorization' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Issuing/Authorization.php',
'Stripe\\Issuing\\Card' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Issuing/Card.php',
'Stripe\\Issuing\\CardDetails' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Issuing/CardDetails.php',
'Stripe\\Issuing\\Cardholder' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Issuing/Cardholder.php',
'Stripe\\Issuing\\Dispute' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Issuing/Dispute.php',
'Stripe\\Issuing\\PersonalizationDesign' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Issuing/PersonalizationDesign.php',
'Stripe\\Issuing\\PhysicalBundle' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Issuing/PhysicalBundle.php',
'Stripe\\Issuing\\Token' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Issuing/Token.php',
'Stripe\\Issuing\\Transaction' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Issuing/Transaction.php',
'Stripe\\LineItem' => __DIR__ . '/..' . '/stripe/stripe-php/lib/LineItem.php',
'Stripe\\LoginLink' => __DIR__ . '/..' . '/stripe/stripe-php/lib/LoginLink.php',
'Stripe\\Mandate' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Mandate.php',
'Stripe\\OAuth' => __DIR__ . '/..' . '/stripe/stripe-php/lib/OAuth.php',
'Stripe\\OAuthErrorObject' => __DIR__ . '/..' . '/stripe/stripe-php/lib/OAuthErrorObject.php',
'Stripe\\PaymentIntent' => __DIR__ . '/..' . '/stripe/stripe-php/lib/PaymentIntent.php',
'Stripe\\PaymentLink' => __DIR__ . '/..' . '/stripe/stripe-php/lib/PaymentLink.php',
'Stripe\\PaymentMethod' => __DIR__ . '/..' . '/stripe/stripe-php/lib/PaymentMethod.php',
'Stripe\\PaymentMethodConfiguration' => __DIR__ . '/..' . '/stripe/stripe-php/lib/PaymentMethodConfiguration.php',
'Stripe\\PaymentMethodDomain' => __DIR__ . '/..' . '/stripe/stripe-php/lib/PaymentMethodDomain.php',
'Stripe\\Payout' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Payout.php',
'Stripe\\Person' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Person.php',
'Stripe\\Plan' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Plan.php',
'Stripe\\Price' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Price.php',
'Stripe\\Product' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Product.php',
'Stripe\\ProductFeature' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ProductFeature.php',
'Stripe\\PromotionCode' => __DIR__ . '/..' . '/stripe/stripe-php/lib/PromotionCode.php',
'Stripe\\Quote' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Quote.php',
'Stripe\\Radar\\EarlyFraudWarning' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Radar/EarlyFraudWarning.php',
'Stripe\\Radar\\ValueList' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Radar/ValueList.php',
'Stripe\\Radar\\ValueListItem' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Radar/ValueListItem.php',
'Stripe\\Reason' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Reason.php',
'Stripe\\RecipientTransfer' => __DIR__ . '/..' . '/stripe/stripe-php/lib/RecipientTransfer.php',
'Stripe\\Refund' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Refund.php',
'Stripe\\RelatedObject' => __DIR__ . '/..' . '/stripe/stripe-php/lib/RelatedObject.php',
'Stripe\\Reporting\\ReportRun' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Reporting/ReportRun.php',
'Stripe\\Reporting\\ReportType' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Reporting/ReportType.php',
'Stripe\\RequestTelemetry' => __DIR__ . '/..' . '/stripe/stripe-php/lib/RequestTelemetry.php',
'Stripe\\ReserveTransaction' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ReserveTransaction.php',
'Stripe\\Review' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Review.php',
'Stripe\\SearchResult' => __DIR__ . '/..' . '/stripe/stripe-php/lib/SearchResult.php',
'Stripe\\Service\\AbstractService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/AbstractService.php',
'Stripe\\Service\\AbstractServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/AbstractServiceFactory.php',
'Stripe\\Service\\AccountLinkService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/AccountLinkService.php',
'Stripe\\Service\\AccountService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/AccountService.php',
'Stripe\\Service\\AccountSessionService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/AccountSessionService.php',
'Stripe\\Service\\ApplePayDomainService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/ApplePayDomainService.php',
'Stripe\\Service\\ApplicationFeeService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/ApplicationFeeService.php',
'Stripe\\Service\\Apps\\AppsServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Apps/AppsServiceFactory.php',
'Stripe\\Service\\Apps\\SecretService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Apps/SecretService.php',
'Stripe\\Service\\BalanceService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/BalanceService.php',
'Stripe\\Service\\BalanceTransactionService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/BalanceTransactionService.php',
'Stripe\\Service\\BillingPortal\\BillingPortalServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/BillingPortal/BillingPortalServiceFactory.php',
'Stripe\\Service\\BillingPortal\\ConfigurationService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/BillingPortal/ConfigurationService.php',
'Stripe\\Service\\BillingPortal\\SessionService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/BillingPortal/SessionService.php',
'Stripe\\Service\\Billing\\AlertService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Billing/AlertService.php',
'Stripe\\Service\\Billing\\BillingServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Billing/BillingServiceFactory.php',
'Stripe\\Service\\Billing\\CreditBalanceSummaryService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Billing/CreditBalanceSummaryService.php',
'Stripe\\Service\\Billing\\CreditBalanceTransactionService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Billing/CreditBalanceTransactionService.php',
'Stripe\\Service\\Billing\\CreditGrantService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Billing/CreditGrantService.php',
'Stripe\\Service\\Billing\\MeterEventAdjustmentService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Billing/MeterEventAdjustmentService.php',
'Stripe\\Service\\Billing\\MeterEventService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Billing/MeterEventService.php',
'Stripe\\Service\\Billing\\MeterService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Billing/MeterService.php',
'Stripe\\Service\\ChargeService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/ChargeService.php',
'Stripe\\Service\\Checkout\\CheckoutServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Checkout/CheckoutServiceFactory.php',
'Stripe\\Service\\Checkout\\SessionService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Checkout/SessionService.php',
'Stripe\\Service\\Climate\\ClimateServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Climate/ClimateServiceFactory.php',
'Stripe\\Service\\Climate\\OrderService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Climate/OrderService.php',
'Stripe\\Service\\Climate\\ProductService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Climate/ProductService.php',
'Stripe\\Service\\Climate\\SupplierService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Climate/SupplierService.php',
'Stripe\\Service\\ConfirmationTokenService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/ConfirmationTokenService.php',
'Stripe\\Service\\CoreServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/CoreServiceFactory.php',
'Stripe\\Service\\CountrySpecService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/CountrySpecService.php',
'Stripe\\Service\\CouponService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/CouponService.php',
'Stripe\\Service\\CreditNoteService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/CreditNoteService.php',
'Stripe\\Service\\CustomerService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/CustomerService.php',
'Stripe\\Service\\CustomerSessionService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/CustomerSessionService.php',
'Stripe\\Service\\DisputeService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/DisputeService.php',
'Stripe\\Service\\Entitlements\\ActiveEntitlementService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Entitlements/ActiveEntitlementService.php',
'Stripe\\Service\\Entitlements\\EntitlementsServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Entitlements/EntitlementsServiceFactory.php',
'Stripe\\Service\\Entitlements\\FeatureService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Entitlements/FeatureService.php',
'Stripe\\Service\\EphemeralKeyService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/EphemeralKeyService.php',
'Stripe\\Service\\EventService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/EventService.php',
'Stripe\\Service\\ExchangeRateService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/ExchangeRateService.php',
'Stripe\\Service\\FileLinkService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/FileLinkService.php',
'Stripe\\Service\\FileService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/FileService.php',
'Stripe\\Service\\FinancialConnections\\AccountService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/FinancialConnections/AccountService.php',
'Stripe\\Service\\FinancialConnections\\FinancialConnectionsServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/FinancialConnections/FinancialConnectionsServiceFactory.php',
'Stripe\\Service\\FinancialConnections\\SessionService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/FinancialConnections/SessionService.php',
'Stripe\\Service\\FinancialConnections\\TransactionService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/FinancialConnections/TransactionService.php',
'Stripe\\Service\\Forwarding\\ForwardingServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Forwarding/ForwardingServiceFactory.php',
'Stripe\\Service\\Forwarding\\RequestService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Forwarding/RequestService.php',
'Stripe\\Service\\Identity\\IdentityServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Identity/IdentityServiceFactory.php',
'Stripe\\Service\\Identity\\VerificationReportService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Identity/VerificationReportService.php',
'Stripe\\Service\\Identity\\VerificationSessionService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Identity/VerificationSessionService.php',
'Stripe\\Service\\InvoiceItemService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/InvoiceItemService.php',
'Stripe\\Service\\InvoicePaymentService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/InvoicePaymentService.php',
'Stripe\\Service\\InvoiceRenderingTemplateService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/InvoiceRenderingTemplateService.php',
'Stripe\\Service\\InvoiceService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/InvoiceService.php',
'Stripe\\Service\\Issuing\\AuthorizationService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Issuing/AuthorizationService.php',
'Stripe\\Service\\Issuing\\CardService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Issuing/CardService.php',
'Stripe\\Service\\Issuing\\CardholderService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Issuing/CardholderService.php',
'Stripe\\Service\\Issuing\\DisputeService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Issuing/DisputeService.php',
'Stripe\\Service\\Issuing\\IssuingServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Issuing/IssuingServiceFactory.php',
'Stripe\\Service\\Issuing\\PersonalizationDesignService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Issuing/PersonalizationDesignService.php',
'Stripe\\Service\\Issuing\\PhysicalBundleService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Issuing/PhysicalBundleService.php',
'Stripe\\Service\\Issuing\\TokenService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Issuing/TokenService.php',
'Stripe\\Service\\Issuing\\TransactionService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Issuing/TransactionService.php',
'Stripe\\Service\\MandateService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/MandateService.php',
'Stripe\\Service\\OAuthService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/OAuthService.php',
'Stripe\\Service\\PaymentIntentService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/PaymentIntentService.php',
'Stripe\\Service\\PaymentLinkService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/PaymentLinkService.php',
'Stripe\\Service\\PaymentMethodConfigurationService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/PaymentMethodConfigurationService.php',
'Stripe\\Service\\PaymentMethodDomainService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/PaymentMethodDomainService.php',
'Stripe\\Service\\PaymentMethodService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/PaymentMethodService.php',
'Stripe\\Service\\PayoutService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/PayoutService.php',
'Stripe\\Service\\PlanService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/PlanService.php',
'Stripe\\Service\\PriceService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/PriceService.php',
'Stripe\\Service\\ProductService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/ProductService.php',
'Stripe\\Service\\PromotionCodeService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/PromotionCodeService.php',
'Stripe\\Service\\QuoteService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/QuoteService.php',
'Stripe\\Service\\Radar\\EarlyFraudWarningService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Radar/EarlyFraudWarningService.php',
'Stripe\\Service\\Radar\\RadarServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Radar/RadarServiceFactory.php',
'Stripe\\Service\\Radar\\ValueListItemService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Radar/ValueListItemService.php',
'Stripe\\Service\\Radar\\ValueListService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Radar/ValueListService.php',
'Stripe\\Service\\RefundService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/RefundService.php',
'Stripe\\Service\\Reporting\\ReportRunService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Reporting/ReportRunService.php',
'Stripe\\Service\\Reporting\\ReportTypeService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Reporting/ReportTypeService.php',
'Stripe\\Service\\Reporting\\ReportingServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Reporting/ReportingServiceFactory.php',
'Stripe\\Service\\ReviewService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/ReviewService.php',
'Stripe\\Service\\ServiceNavigatorTrait' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/ServiceNavigatorTrait.php',
'Stripe\\Service\\SetupAttemptService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/SetupAttemptService.php',
'Stripe\\Service\\SetupIntentService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/SetupIntentService.php',
'Stripe\\Service\\ShippingRateService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/ShippingRateService.php',
'Stripe\\Service\\Sigma\\ScheduledQueryRunService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Sigma/ScheduledQueryRunService.php',
'Stripe\\Service\\Sigma\\SigmaServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Sigma/SigmaServiceFactory.php',
'Stripe\\Service\\SourceService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/SourceService.php',
'Stripe\\Service\\SubscriptionItemService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/SubscriptionItemService.php',
'Stripe\\Service\\SubscriptionScheduleService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/SubscriptionScheduleService.php',
'Stripe\\Service\\SubscriptionService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/SubscriptionService.php',
'Stripe\\Service\\TaxCodeService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TaxCodeService.php',
'Stripe\\Service\\TaxIdService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TaxIdService.php',
'Stripe\\Service\\TaxRateService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TaxRateService.php',
'Stripe\\Service\\Tax\\CalculationService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Tax/CalculationService.php',
'Stripe\\Service\\Tax\\RegistrationService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Tax/RegistrationService.php',
'Stripe\\Service\\Tax\\SettingsService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Tax/SettingsService.php',
'Stripe\\Service\\Tax\\TaxServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Tax/TaxServiceFactory.php',
'Stripe\\Service\\Tax\\TransactionService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Tax/TransactionService.php',
'Stripe\\Service\\Terminal\\ConfigurationService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Terminal/ConfigurationService.php',
'Stripe\\Service\\Terminal\\ConnectionTokenService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Terminal/ConnectionTokenService.php',
'Stripe\\Service\\Terminal\\LocationService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Terminal/LocationService.php',
'Stripe\\Service\\Terminal\\ReaderService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Terminal/ReaderService.php',
'Stripe\\Service\\Terminal\\TerminalServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Terminal/TerminalServiceFactory.php',
'Stripe\\Service\\TestHelpers\\ConfirmationTokenService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TestHelpers/ConfirmationTokenService.php',
'Stripe\\Service\\TestHelpers\\CustomerService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TestHelpers/CustomerService.php',
'Stripe\\Service\\TestHelpers\\Issuing\\AuthorizationService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TestHelpers/Issuing/AuthorizationService.php',
'Stripe\\Service\\TestHelpers\\Issuing\\CardService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TestHelpers/Issuing/CardService.php',
'Stripe\\Service\\TestHelpers\\Issuing\\IssuingServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TestHelpers/Issuing/IssuingServiceFactory.php',
'Stripe\\Service\\TestHelpers\\Issuing\\PersonalizationDesignService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TestHelpers/Issuing/PersonalizationDesignService.php',
'Stripe\\Service\\TestHelpers\\Issuing\\TransactionService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TestHelpers/Issuing/TransactionService.php',
'Stripe\\Service\\TestHelpers\\RefundService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TestHelpers/RefundService.php',
'Stripe\\Service\\TestHelpers\\Terminal\\ReaderService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TestHelpers/Terminal/ReaderService.php',
'Stripe\\Service\\TestHelpers\\Terminal\\TerminalServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TestHelpers/Terminal/TerminalServiceFactory.php',
'Stripe\\Service\\TestHelpers\\TestClockService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TestHelpers/TestClockService.php',
'Stripe\\Service\\TestHelpers\\TestHelpersServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TestHelpers/TestHelpersServiceFactory.php',
'Stripe\\Service\\TestHelpers\\Treasury\\InboundTransferService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TestHelpers/Treasury/InboundTransferService.php',
'Stripe\\Service\\TestHelpers\\Treasury\\OutboundPaymentService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TestHelpers/Treasury/OutboundPaymentService.php',
'Stripe\\Service\\TestHelpers\\Treasury\\OutboundTransferService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TestHelpers/Treasury/OutboundTransferService.php',
'Stripe\\Service\\TestHelpers\\Treasury\\ReceivedCreditService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TestHelpers/Treasury/ReceivedCreditService.php',
'Stripe\\Service\\TestHelpers\\Treasury\\ReceivedDebitService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TestHelpers/Treasury/ReceivedDebitService.php',
'Stripe\\Service\\TestHelpers\\Treasury\\TreasuryServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TestHelpers/Treasury/TreasuryServiceFactory.php',
'Stripe\\Service\\TokenService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TokenService.php',
'Stripe\\Service\\TopupService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TopupService.php',
'Stripe\\Service\\TransferService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/TransferService.php',
'Stripe\\Service\\Treasury\\CreditReversalService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Treasury/CreditReversalService.php',
'Stripe\\Service\\Treasury\\DebitReversalService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Treasury/DebitReversalService.php',
'Stripe\\Service\\Treasury\\FinancialAccountService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Treasury/FinancialAccountService.php',
'Stripe\\Service\\Treasury\\InboundTransferService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Treasury/InboundTransferService.php',
'Stripe\\Service\\Treasury\\OutboundPaymentService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Treasury/OutboundPaymentService.php',
'Stripe\\Service\\Treasury\\OutboundTransferService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Treasury/OutboundTransferService.php',
'Stripe\\Service\\Treasury\\ReceivedCreditService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Treasury/ReceivedCreditService.php',
'Stripe\\Service\\Treasury\\ReceivedDebitService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Treasury/ReceivedDebitService.php',
'Stripe\\Service\\Treasury\\TransactionEntryService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Treasury/TransactionEntryService.php',
'Stripe\\Service\\Treasury\\TransactionService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Treasury/TransactionService.php',
'Stripe\\Service\\Treasury\\TreasuryServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/Treasury/TreasuryServiceFactory.php',
'Stripe\\Service\\V2\\Billing\\BillingServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/V2/Billing/BillingServiceFactory.php',
'Stripe\\Service\\V2\\Billing\\MeterEventAdjustmentService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/V2/Billing/MeterEventAdjustmentService.php',
'Stripe\\Service\\V2\\Billing\\MeterEventService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/V2/Billing/MeterEventService.php',
'Stripe\\Service\\V2\\Billing\\MeterEventSessionService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/V2/Billing/MeterEventSessionService.php',
'Stripe\\Service\\V2\\Billing\\MeterEventStreamService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/V2/Billing/MeterEventStreamService.php',
'Stripe\\Service\\V2\\Core\\CoreServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/V2/Core/CoreServiceFactory.php',
'Stripe\\Service\\V2\\Core\\EventDestinationService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/V2/Core/EventDestinationService.php',
'Stripe\\Service\\V2\\Core\\EventService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/V2/Core/EventService.php',
'Stripe\\Service\\V2\\V2ServiceFactory' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/V2/V2ServiceFactory.php',
'Stripe\\Service\\WebhookEndpointService' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Service/WebhookEndpointService.php',
'Stripe\\SetupAttempt' => __DIR__ . '/..' . '/stripe/stripe-php/lib/SetupAttempt.php',
'Stripe\\SetupIntent' => __DIR__ . '/..' . '/stripe/stripe-php/lib/SetupIntent.php',
'Stripe\\ShippingRate' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ShippingRate.php',
'Stripe\\Sigma\\ScheduledQueryRun' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Sigma/ScheduledQueryRun.php',
'Stripe\\SingletonApiResource' => __DIR__ . '/..' . '/stripe/stripe-php/lib/SingletonApiResource.php',
'Stripe\\Source' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Source.php',
'Stripe\\SourceMandateNotification' => __DIR__ . '/..' . '/stripe/stripe-php/lib/SourceMandateNotification.php',
'Stripe\\SourceTransaction' => __DIR__ . '/..' . '/stripe/stripe-php/lib/SourceTransaction.php',
'Stripe\\Stripe' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Stripe.php',
'Stripe\\StripeClient' => __DIR__ . '/..' . '/stripe/stripe-php/lib/StripeClient.php',
'Stripe\\StripeClientInterface' => __DIR__ . '/..' . '/stripe/stripe-php/lib/StripeClientInterface.php',
'Stripe\\StripeObject' => __DIR__ . '/..' . '/stripe/stripe-php/lib/StripeObject.php',
'Stripe\\StripeStreamingClientInterface' => __DIR__ . '/..' . '/stripe/stripe-php/lib/StripeStreamingClientInterface.php',
'Stripe\\Subscription' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Subscription.php',
'Stripe\\SubscriptionItem' => __DIR__ . '/..' . '/stripe/stripe-php/lib/SubscriptionItem.php',
'Stripe\\SubscriptionSchedule' => __DIR__ . '/..' . '/stripe/stripe-php/lib/SubscriptionSchedule.php',
'Stripe\\TaxCode' => __DIR__ . '/..' . '/stripe/stripe-php/lib/TaxCode.php',
'Stripe\\TaxDeductedAtSource' => __DIR__ . '/..' . '/stripe/stripe-php/lib/TaxDeductedAtSource.php',
'Stripe\\TaxId' => __DIR__ . '/..' . '/stripe/stripe-php/lib/TaxId.php',
'Stripe\\TaxRate' => __DIR__ . '/..' . '/stripe/stripe-php/lib/TaxRate.php',
'Stripe\\Tax\\Calculation' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Tax/Calculation.php',
'Stripe\\Tax\\CalculationLineItem' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Tax/CalculationLineItem.php',
'Stripe\\Tax\\Registration' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Tax/Registration.php',
'Stripe\\Tax\\Settings' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Tax/Settings.php',
'Stripe\\Tax\\Transaction' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Tax/Transaction.php',
'Stripe\\Tax\\TransactionLineItem' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Tax/TransactionLineItem.php',
'Stripe\\Terminal\\Configuration' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Terminal/Configuration.php',
'Stripe\\Terminal\\ConnectionToken' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Terminal/ConnectionToken.php',
'Stripe\\Terminal\\Location' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Terminal/Location.php',
'Stripe\\Terminal\\Reader' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Terminal/Reader.php',
'Stripe\\TestHelpers\\TestClock' => __DIR__ . '/..' . '/stripe/stripe-php/lib/TestHelpers/TestClock.php',
'Stripe\\ThinEvent' => __DIR__ . '/..' . '/stripe/stripe-php/lib/ThinEvent.php',
'Stripe\\Token' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Token.php',
'Stripe\\Topup' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Topup.php',
'Stripe\\Transfer' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Transfer.php',
'Stripe\\TransferReversal' => __DIR__ . '/..' . '/stripe/stripe-php/lib/TransferReversal.php',
'Stripe\\Treasury\\CreditReversal' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Treasury/CreditReversal.php',
'Stripe\\Treasury\\DebitReversal' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Treasury/DebitReversal.php',
'Stripe\\Treasury\\FinancialAccount' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Treasury/FinancialAccount.php',
'Stripe\\Treasury\\FinancialAccountFeatures' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Treasury/FinancialAccountFeatures.php',
'Stripe\\Treasury\\InboundTransfer' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Treasury/InboundTransfer.php',
'Stripe\\Treasury\\OutboundPayment' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Treasury/OutboundPayment.php',
'Stripe\\Treasury\\OutboundTransfer' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Treasury/OutboundTransfer.php',
'Stripe\\Treasury\\ReceivedCredit' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Treasury/ReceivedCredit.php',
'Stripe\\Treasury\\ReceivedDebit' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Treasury/ReceivedDebit.php',
'Stripe\\Treasury\\Transaction' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Treasury/Transaction.php',
'Stripe\\Treasury\\TransactionEntry' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Treasury/TransactionEntry.php',
'Stripe\\Util\\ApiVersion' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Util/ApiVersion.php',
'Stripe\\Util\\CaseInsensitiveArray' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Util/CaseInsensitiveArray.php',
'Stripe\\Util\\DefaultLogger' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Util/DefaultLogger.php',
'Stripe\\Util\\EventTypes' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Util/EventTypes.php',
'Stripe\\Util\\LoggerInterface' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Util/LoggerInterface.php',
'Stripe\\Util\\ObjectTypes' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Util/ObjectTypes.php',
'Stripe\\Util\\RandomGenerator' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Util/RandomGenerator.php',
'Stripe\\Util\\RequestOptions' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Util/RequestOptions.php',
'Stripe\\Util\\Set' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Util/Set.php',
'Stripe\\Util\\Util' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Util/Util.php',
'Stripe\\V2\\Billing\\MeterEvent' => __DIR__ . '/..' . '/stripe/stripe-php/lib/V2/Billing/MeterEvent.php',
'Stripe\\V2\\Billing\\MeterEventAdjustment' => __DIR__ . '/..' . '/stripe/stripe-php/lib/V2/Billing/MeterEventAdjustment.php',
'Stripe\\V2\\Billing\\MeterEventSession' => __DIR__ . '/..' . '/stripe/stripe-php/lib/V2/Billing/MeterEventSession.php',
'Stripe\\V2\\Collection' => __DIR__ . '/..' . '/stripe/stripe-php/lib/V2/Collection.php',
'Stripe\\V2\\Event' => __DIR__ . '/..' . '/stripe/stripe-php/lib/V2/Event.php',
'Stripe\\V2\\EventDestination' => __DIR__ . '/..' . '/stripe/stripe-php/lib/V2/EventDestination.php',
'Stripe\\Webhook' => __DIR__ . '/..' . '/stripe/stripe-php/lib/Webhook.php',
'Stripe\\WebhookEndpoint' => __DIR__ . '/..' . '/stripe/stripe-php/lib/WebhookEndpoint.php',
'Stripe\\WebhookSignature' => __DIR__ . '/..' . '/stripe/stripe-php/lib/WebhookSignature.php',
'ZipStream\\CentralDirectoryFileHeader' => __DIR__ . '/..' . '/maennchen/zipstream-php/src/CentralDirectoryFileHeader.php',
'ZipStream\\CompressionMethod' => __DIR__ . '/..' . '/maennchen/zipstream-php/src/CompressionMethod.php',
'ZipStream\\DataDescriptor' => __DIR__ . '/..' . '/maennchen/zipstream-php/src/DataDescriptor.php',

View File

@@ -690,6 +690,117 @@
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
},
"install-path": "../psr/simple-cache"
},
{
"name": "setasign/fpdf",
"version": "1.8.6",
"version_normalized": "1.8.6.0",
"source": {
"type": "git",
"url": "https://github.com/Setasign/FPDF.git",
"reference": "0838e0ee4925716fcbbc50ad9e1799b5edfae0a0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Setasign/FPDF/zipball/0838e0ee4925716fcbbc50ad9e1799b5edfae0a0",
"reference": "0838e0ee4925716fcbbc50ad9e1799b5edfae0a0",
"shasum": ""
},
"require": {
"ext-gd": "*",
"ext-zlib": "*"
},
"time": "2023-06-26T14:44:25+00:00",
"type": "library",
"installation-source": "dist",
"autoload": {
"classmap": [
"fpdf.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Olivier Plathey",
"email": "oliver@fpdf.org",
"homepage": "http://fpdf.org/"
}
],
"description": "FPDF is a PHP class which allows to generate PDF files with pure PHP. F from FPDF stands for Free: you may use it for any kind of usage and modify it to suit your needs.",
"homepage": "http://www.fpdf.org",
"keywords": [
"fpdf",
"pdf"
],
"support": {
"source": "https://github.com/Setasign/FPDF/tree/1.8.6"
},
"install-path": "../setasign/fpdf"
},
{
"name": "stripe/stripe-php",
"version": "v17.6.0",
"version_normalized": "17.6.0.0",
"source": {
"type": "git",
"url": "https://github.com/stripe/stripe-php.git",
"reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/stripe/stripe-php/zipball/a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
"reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"php": ">=5.6.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.72.0",
"phpstan/phpstan": "^1.2",
"phpunit/phpunit": "^5.7 || ^9.0"
},
"time": "2025-08-27T19:32:42+00:00",
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0-dev"
}
},
"installation-source": "dist",
"autoload": {
"psr-4": {
"Stripe\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Stripe and contributors",
"homepage": "https://github.com/stripe/stripe-php/contributors"
}
],
"description": "Stripe PHP Library",
"homepage": "https://stripe.com/",
"keywords": [
"api",
"payment processing",
"stripe"
],
"support": {
"issues": "https://github.com/stripe/stripe-php/issues",
"source": "https://github.com/stripe/stripe-php/tree/v17.6.0"
},
"install-path": "../stripe/stripe-php"
}
],
"dev": true,

View File

@@ -3,7 +3,7 @@
'name' => 'your-vendor/api',
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => 'b9672a62283414a30f1bf111ed54759be7392347',
'reference' => 'f597c9aeb504adc2d733e5e2bd70820b06049df9',
'type' => 'project',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@@ -100,10 +100,28 @@
'aliases' => array(),
'dev_requirement' => false,
),
'setasign/fpdf' => array(
'pretty_version' => '1.8.6',
'version' => '1.8.6.0',
'reference' => '0838e0ee4925716fcbbc50ad9e1799b5edfae0a0',
'type' => 'library',
'install_path' => __DIR__ . '/../setasign/fpdf',
'aliases' => array(),
'dev_requirement' => false,
),
'stripe/stripe-php' => array(
'pretty_version' => 'v17.6.0',
'version' => '17.6.0.0',
'reference' => 'a6219df5df1324a0d3f1da25fb5e4b8a3307ea16',
'type' => 'library',
'install_path' => __DIR__ . '/../stripe/stripe-php',
'aliases' => array(),
'dev_requirement' => false,
),
'your-vendor/api' => array(
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => 'b9672a62283414a30f1bf111ed54759be7392347',
'reference' => 'f597c9aeb504adc2d733e5e2bd70820b06049df9',
'type' => 'project',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),

29
api/vendor/composer/platform_check.php vendored Normal file
View File

@@ -0,0 +1,29 @@
<?php
// platform_check.php @generated by Composer
$issues = array();
if (!(PHP_VERSION_ID >= 80300)) {
$issues[] = 'Your Composer dependencies require a PHP version ">= 8.3.0". You are running ' . PHP_VERSION . '.';
}
if (PHP_INT_SIZE !== 8) {
$issues[] = 'Your Composer dependencies require a 64-bit build of PHP.';
}
if ($issues) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL);
} elseif (!headers_sent()) {
echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
}
}
throw new \RuntimeException(
'Composer detected issues in your platform: ' . implode(' ', $issues)
);
}

270
api/vendor/setasign/fpdf/FAQ.htm vendored Normal file
View File

@@ -0,0 +1,270 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>FAQ</title>
<link type="text/css" rel="stylesheet" href="fpdf.css">
<style type="text/css">
ul {list-style-type:none; margin:0; padding:0}
ul#answers li {margin-top:1.8em}
.question {font-weight:bold; color:#900000}
</style>
</head>
<body>
<h1>FAQ</h1>
<ul>
<li><b>1.</b> <a href='#q1'>What's exactly the license of FPDF? Are there any usage restrictions?</a></li>
<li><b>2.</b> <a href='#q2'>I get the following error when I try to generate a PDF: Some data has already been output, can't send PDF file</a></li>
<li><b>3.</b> <a href='#q3'>Accented letters are replaced by some strange characters like é.</a></li>
<li><b>4.</b> <a href='#q4'>I try to display the Euro symbol but it doesn't work.</a></li>
<li><b>5.</b> <a href='#q5'>I try to display a variable in the Header method but nothing prints.</a></li>
<li><b>6.</b> <a href='#q6'>I have defined the Header and Footer methods in my PDF class but nothing shows.</a></li>
<li><b>7.</b> <a href='#q7'>I can't make line breaks work. I put \n in the string printed by MultiCell but it doesn't work.</a></li>
<li><b>8.</b> <a href='#q8'>I use jQuery to generate the PDF but it doesn't show.</a></li>
<li><b>9.</b> <a href='#q9'>I draw a frame with very precise dimensions, but when printed I notice some differences.</a></li>
<li><b>10.</b> <a href='#q10'>I'd like to use the whole surface of the page, but when printed I always have some margins. How can I get rid of them?</a></li>
<li><b>11.</b> <a href='#q11'>How can I put a background in my PDF?</a></li>
<li><b>12.</b> <a href='#q12'>How can I set a specific header or footer on the first page?</a></li>
<li><b>13.</b> <a href='#q13'>I'd like to use extensions provided by different scripts. How can I combine them?</a></li>
<li><b>14.</b> <a href='#q14'>How can I open the PDF in a new tab?</a></li>
<li><b>15.</b> <a href='#q15'>How can I send the PDF by email?</a></li>
<li><b>16.</b> <a href='#q16'>What's the limit of the file sizes I can generate with FPDF?</a></li>
<li><b>17.</b> <a href='#q17'>Can I modify a PDF with FPDF?</a></li>
<li><b>18.</b> <a href='#q18'>I'd like to make a search engine in PHP and index PDF files. Can I do it with FPDF?</a></li>
<li><b>19.</b> <a href='#q19'>Can I convert an HTML page to PDF with FPDF?</a></li>
<li><b>20.</b> <a href='#q20'>Can I concatenate PDF files with FPDF?</a></li>
</ul>
<ul id='answers'>
<li id='q1'>
<p><b>1.</b> <span class='question'>What's exactly the license of FPDF? Are there any usage restrictions?</span></p>
FPDF is released under a permissive license: there is no usage restriction. You may embed it
freely in your application (commercial or not), with or without modifications.
</li>
<li id='q2'>
<p><b>2.</b> <span class='question'>I get the following error when I try to generate a PDF: Some data has already been output, can't send PDF file</span></p>
You must send nothing to the browser except the PDF itself: no HTML, no space, no carriage return. A common
case is having extra blank at the end of an included script file.<br>
<br>
The message may be followed by this indication:<br>
<br>
(output started at script.php:X)<br>
<br>
which gives you exactly the script and line number responsible for the output. If you don't see it,
try adding this line at the very beginning of your script:
<div class="doc-source">
<pre><code>ob_end_clean();</code></pre>
</div>
</li>
<li id='q3'>
<p><b>3.</b> <span class='question'>Accented letters are replaced by some strange characters like é.</span></p>
Don't use UTF-8 with the standard fonts; they expect text encoded in windows-1252.
You can perform a conversion with iconv:
<div class="doc-source">
<pre><code>$str = iconv('UTF-8', 'windows-1252', $str);</code></pre>
</div>
Or with mbstring:
<div class="doc-source">
<pre><code>$str = mb_convert_encoding($str, 'windows-1252', 'UTF-8');</code></pre>
</div>
In case you need characters outside windows-1252, take a look at tutorial #7 or
<a href="http://www.fpdf.org/?go=script&amp;id=92" target="_blank">tFPDF</a>.
</li>
<li id='q4'>
<p><b>4.</b> <span class='question'>I try to display the Euro symbol but it doesn't work.</span></p>
The standard fonts have the Euro character at position 128. You can define a constant like this
for convenience:
<div class="doc-source">
<pre><code>define('EURO', chr(128));</code></pre>
</div>
</li>
<li id='q5'>
<p><b>5.</b> <span class='question'>I try to display a variable in the Header method but nothing prints.</span></p>
You have to use the <code>global</code> keyword to access global variables, for example:
<div class="doc-source">
<pre><code>function Header()
{
global $title;
$this-&gt;SetFont('Arial', 'B', 15);
$this-&gt;Cell(0, 10, $title, 1, 1, 'C');
}
$title = 'My title';</code></pre>
</div>
Alternatively, you can use an object property:
<div class="doc-source">
<pre><code>function Header()
{
$this-&gt;SetFont('Arial', 'B', 15);
$this-&gt;Cell(0, 10, $this-&gt;title, 1, 1, 'C');
}
$pdf-&gt;title = 'My title';</code></pre>
</div>
</li>
<li id='q6'>
<p><b>6.</b> <span class='question'>I have defined the Header and Footer methods in my PDF class but nothing shows.</span></p>
You have to create an object from the PDF class, not FPDF:
<div class="doc-source">
<pre><code>$pdf = new PDF();</code></pre>
</div>
</li>
<li id='q7'>
<p><b>7.</b> <span class='question'>I can't make line breaks work. I put \n in the string printed by MultiCell but it doesn't work.</span></p>
You have to enclose your string with double quotes, not single ones.
</li>
<li id='q8'>
<p><b>8.</b> <span class='question'>I use jQuery to generate the PDF but it doesn't show.</span></p>
Don't use an AJAX request to retrieve the PDF.
</li>
<li id='q9'>
<p><b>9.</b> <span class='question'>I draw a frame with very precise dimensions, but when printed I notice some differences.</span></p>
To respect dimensions, select "None" for the Page Scaling setting instead of "Shrink to Printable Area" in the print dialog box.
</li>
<li id='q10'>
<p><b>10.</b> <span class='question'>I'd like to use the whole surface of the page, but when printed I always have some margins. How can I get rid of them?</span></p>
Printers have physical margins (different depending on the models); it is therefore impossible to remove
them and print on the whole surface of the paper.
</li>
<li id='q11'>
<p><b>11.</b> <span class='question'>How can I put a background in my PDF?</span></p>
For a picture, call Image() in the Header() method, before any other output. To set a background color, use Rect().
</li>
<li id='q12'>
<p><b>12.</b> <span class='question'>How can I set a specific header or footer on the first page?</span></p>
Just test the page number:
<div class="doc-source">
<pre><code>function Header()
{
if($this-&gt;PageNo()==1)
{
//First page
...
}
else
{
//Other pages
...
}
}</code></pre>
</div>
</li>
<li id='q13'>
<p><b>13.</b> <span class='question'>I'd like to use extensions provided by different scripts. How can I combine them?</span></p>
Use an inheritance chain. If you have two classes, say A in a.php:
<div class="doc-source">
<pre><code>require('fpdf.php');
class A extends FPDF
{
...
}</code></pre>
</div>
and B in b.php:
<div class="doc-source">
<pre><code>require('fpdf.php');
class B extends FPDF
{
...
}</code></pre>
</div>
then make B extend A:
<div class="doc-source">
<pre><code>require('a.php');
class B extends A
{
...
}</code></pre>
</div>
and make your own class extend B:
<div class="doc-source">
<pre><code>require('b.php');
class PDF extends B
{
...
}
$pdf = new PDF();</code></pre>
</div>
</li>
<li id='q14'>
<p><b>14.</b> <span class='question'>How can I open the PDF in a new tab?</span></p>
Just do the same as you would for an HTML page or anything else: add a target="_blank" to your link or form.
</li>
<li id='q15'>
<p><b>15.</b> <span class='question'>How can I send the PDF by email?</span></p>
As for any other file, but an easy way is to use <a href="https://github.com/PHPMailer/PHPMailer" target="_blank">PHPMailer</a> and
its in-memory attachment:
<div class="doc-source">
<pre><code>$mail = new PHPMailer();
...
$doc = $pdf-&gt;Output('S');
$mail-&gt;AddStringAttachment($doc, 'doc.pdf', 'base64', 'application/pdf');
$mail-&gt;Send();</code></pre>
</div>
</li>
<li id='q16'>
<p><b>16.</b> <span class='question'>What's the limit of the file sizes I can generate with FPDF?</span></p>
There is no particular limit. There are some constraints, however:
<br>
<br>
- There is usually a maximum memory size allocated to PHP scripts. For very big documents,
especially with images, the limit may be reached (the file being built in memory). The
parameter is configured in the php.ini file.
<br>
<br>
- The maximum execution time allocated to scripts defaults to 30 seconds. This limit can of course
be easily reached. It is configured in php.ini and may be altered dynamically with set_time_limit().
<br>
<br>
You can work around the memory limit with <a href="http://www.fpdf.org/?go=script&amp;id=76" target="_blank">this script</a>.
</li>
<li id='q17'>
<p><b>17.</b> <span class='question'>Can I modify a PDF with FPDF?</span></p>
It's possible to import pages from an existing PDF document thanks to the
<a href="https://www.setasign.com/products/fpdi/about/" target="_blank">FPDI</a> extension.
Then you can add some content to them.
</li>
<li id='q18'>
<p><b>18.</b> <span class='question'>I'd like to make a search engine in PHP and index PDF files. Can I do it with FPDF?</span></p>
No. But a GPL C utility does exist, pdftotext, which is able to extract the textual content from a PDF.
It's provided with the <a href="https://www.xpdfreader.com" target="_blank">Xpdf</a> package.
</li>
<li id='q19'>
<p><b>19.</b> <span class='question'>Can I convert an HTML page to PDF with FPDF?</span></p>
Not real-world pages. But a GPL C utility does exist, <a href="https://www.msweet.org/htmldoc/" target="_blank">HTMLDOC</a>,
which allows to do it and gives good results.
</li>
<li id='q20'>
<p><b>20.</b> <span class='question'>Can I concatenate PDF files with FPDF?</span></p>
Not directly, but it's possible to use <a href="https://www.setasign.com/products/fpdi/demos/concatenate-fake/" target="_blank">FPDI</a>
to perform that task. Some free command-line tools also exist:
<a href="https://www.pdflabs.com/tools/pdftk-the-pdf-toolkit/" target="_blank">pdftk</a> and
<a href="http://thierry.schmit.free.fr/spip/spip.php?article15" target="_blank">mbtPdfAsm</a>.
</li>
</ul>
</body>
</html>

21
api/vendor/setasign/fpdf/README.md vendored Normal file
View File

@@ -0,0 +1,21 @@
# FPDF
**This repository is only made for cloning official FPDF releases which are available at: http://www.fpdf.org**
**THERE WILL BE NO DEVELOPMENT IN THIS REPOSITORY!**
FPDF is a PHP class which allows to generate PDF files with pure PHP. F from FPDF stands for Free: you may use it for any kind of usage and modify it to suit your needs.
## Installation with [Composer](https://packagist.org/packages/setasign/fpdf)
If you're using Composer to manage dependencies, you can use
$ composer require setasign/fpdf:^1.8
or you can include the following in your composer.json file:
```json
{
"require": {
"setasign/fpdf": "^1.8"
}
}
```

188
api/vendor/setasign/fpdf/changelog.htm vendored Normal file
View File

@@ -0,0 +1,188 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Changelog</title>
<link type="text/css" rel="stylesheet" href="fpdf.css">
<style type="text/css">
dd {margin:1em 0 1em 1em}
</style>
</head>
<body>
<h1>Changelog</h1>
<dl>
<dt><strong>v1.86</strong> (2023-06-25)</dt>
<dd>
- Added a parameter to AddFont() to specify the directory where to load the font definition file.<br>
- Fixed a bug related to the PDF creation date.<br>
</dd>
<dt><strong>v1.85</strong> (2022-11-10)</dt>
<dd>
- Removed deprecation notices on PHP 8.2.<br>
- Removed notices when passing null values instead of strings.<br>
- The FPDF_VERSION constant was replaced by a class constant.<br>
- The creation date of the PDF now includes the timezone.<br>
- The content-type is now always application/pdf, even for downloads.<br>
</dd>
<dt><strong>v1.84</strong> (2021-08-28)</dt>
<dd>
- Fixed an issue related to annotations.<br>
</dd>
<dt><strong>v1.83</strong> (2021-04-18)</dt>
<dd>
- Fixed an issue related to annotations.<br>
</dd>
<dt><strong>v1.82</strong> (2019-12-07)</dt>
<dd>
- Removed a deprecation notice on PHP 7.4.<br>
</dd>
<dt><strong>v1.81</strong> (2015-12-20)</dt>
<dd>
- Added GetPageWidth() and GetPageHeight().<br>
- Fixed a bug in SetXY().<br>
</dd>
<dt><strong>v1.8</strong> (2015-11-29)</dt>
<dd>
- PHP 5.1.0 or higher is now required.<br>
- The MakeFont utility now subsets fonts, which can greatly reduce font sizes.<br>
- Added ToUnicode CMaps to improve text extraction.<br>
- Added a parameter to AddPage() to rotate the page.<br>
- Added a parameter to SetY() to indicate whether the x position should be reset or not.<br>
- Added a parameter to Output() to specify the encoding of the name, and special characters are now properly encoded. Additionally the order of the first two parameters was reversed to be more logical (however the old order is still supported for compatibility).<br>
- The Error() method now throws an exception.<br>
- Adding contents before the first AddPage() or after Close() now raises an error.<br>
- Outputting text with no font selected now raises an error.<br>
</dd>
<dt><strong>v1.7</strong> (2011-06-18)</dt>
<dd>
- The MakeFont utility has been completely rewritten and doesn't depend on ttf2pt1 anymore.<br>
- Alpha channel is now supported for PNGs.<br>
- When inserting an image, it's now possible to specify its resolution.<br>
- Default resolution for images was increased from 72 to 96 dpi.<br>
- When inserting a GIF image, no temporary file is used anymore if the PHP version is 5.1 or higher.<br>
- When output buffering is enabled and the PDF is about to be sent, the buffer is now cleared if it contains only a UTF-8 BOM and/or whitespace (instead of throwing an error).<br>
- Symbol and ZapfDingbats fonts now support underline style.<br>
- Custom page sizes are now checked to ensure that width is smaller than height.<br>
- Standard font files were changed to use the same format as user fonts.<br>
- A bug in the embedding of Type1 fonts was fixed.<br>
- A bug related to SetDisplayMode() and the current locale was fixed.<br>
- A display issue occurring with the Adobe Reader X plug-in was fixed.<br>
- An issue related to transparency with some versions of Adobe Reader was fixed.<br>
- The Content-Length header was removed because it caused an issue when the HTTP server applies compression.<br>
</dd>
<dt><strong>v1.6</strong> (2008-08-03)</dt>
<dd>
- PHP 4.3.10 or higher is now required.<br>
- GIF image support.<br>
- Images can now trigger page breaks.<br>
- Possibility to have different page formats in a single document.<br>
- Document properties (author, creator, keywords, subject and title) can now be specified in UTF-8.<br>
- Fixed a bug: when a PNG was inserted through a URL, an error sometimes occurred.<br>
- An automatic page break in Header() doesn't cause an infinite loop any more.<br>
- Removed some warning messages appearing with recent PHP versions.<br>
- Added HTTP headers to reduce problems with IE.<br>
</dd>
<dt><strong>v1.53</strong> (2004-12-31)</dt>
<dd>
- When the font subdirectory is in the same directory as fpdf.php, it's no longer necessary to define the FPDF_FONTPATH constant.<br>
- The array $HTTP_SERVER_VARS is no longer used. It could cause trouble on PHP5-based configurations with the register_long_arrays option disabled.<br>
- Fixed a problem related to Type1 font embedding which caused trouble to some PDF processors.<br>
- The file name sent to the browser could not contain a space character.<br>
- The Cell() method could not print the number 0 (you had to pass the string '0').<br>
</dd>
<dt><strong>v1.52</strong> (2003-12-30)</dt>
<dd>
- Image() now displays the image at 72 dpi if no dimension is given.<br>
- Output() takes a string as second parameter to indicate destination.<br>
- Open() is now called automatically by AddPage().<br>
- Inserting remote JPEG images doesn't generate an error any longer.<br>
- Decimal separator is forced to dot in the constructor.<br>
- Added several encodings (Turkish, Thai, Hebrew, Ukrainian and Vietnamese).<br>
- The last line of a right-aligned MultiCell() was not correctly aligned if it was terminated by a carriage return.<br>
- No more error message about already sent headers when outputting the PDF to the standard output from the command line.<br>
- The underlining was going too far for text containing characters \, ( or ).<br>
- $HTTP_ENV_VARS has been replaced by $HTTP_SERVER_VARS.<br>
</dd>
<dt><strong>v1.51</strong> (2002-08-03)</dt>
<dd>
- Type1 font support.<br>
- Added Baltic encoding.<br>
- The class now works internally in points with the origin at the bottom in order to avoid two bugs occurring with Acrobat 5:<br>&nbsp;&nbsp;* The line thickness was too large when printed on Windows 98 SE and ME.<br>&nbsp;&nbsp;* TrueType fonts didn't appear immediately inside the plug-in (a substitution font was used), one had to cause a window refresh to make them show up.<br>
- It's no longer necessary to set the decimal separator as dot to produce valid documents.<br>
- The clickable area in a cell was always on the left independently from the text alignment.<br>
- JPEG images in CMYK mode appeared in inverted colors.<br>
- Transparent PNG images in grayscale or true color mode were incorrectly handled.<br>
- Adding new fonts now works correctly even with the magic_quotes_runtime option set to on.<br>
</dd>
<dt><strong>v1.5</strong> (2002-05-28)</dt>
<dd>
- TrueType font (AddFont()) and encoding support (Western and Eastern Europe, Cyrillic and Greek).<br>
- Added Write() method.<br>
- Added underlined style.<br>
- Internal and external link support (AddLink(), SetLink(), Link()).<br>
- Added right margin management and methods SetRightMargin(), SetTopMargin().<br>
- Modification of SetDisplayMode() to select page layout.<br>
- The border parameter of MultiCell() now lets choose borders to draw as Cell().<br>
- When a document contains no page, Close() now calls AddPage() instead of causing a fatal error.<br>
</dd>
<dt><strong>v1.41</strong> (2002-03-13)</dt>
<dd>
- Fixed SetDisplayMode() which no longer worked (the PDF viewer used its default display).<br>
</dd>
<dt><strong>v1.4</strong> (2002-03-02)</dt>
<dd>
- PHP3 is no longer supported.<br>
- Page compression (SetCompression()).<br>
- Choice of page format and possibility to change orientation inside document.<br>
- Added AcceptPageBreak() method.<br>
- Ability to print the total number of pages (AliasNbPages()).<br>
- Choice of cell borders to draw.<br>
- New mode for Cell(): the current position can now move under the cell.<br>
- Ability to include an image by specifying height only (width is calculated automatically).<br>
- Fixed a bug: when a justified line triggered a page break, the footer inherited the corresponding word spacing.<br>
</dd>
<dt><strong>v1.31</strong> (2002-01-12)</dt>
<dd>
- Fixed a bug in drawing frame with MultiCell(): the last line always started from the left margin.<br>
- Removed Expires HTTP header (gives trouble in some situations).<br>
- Added Content-disposition HTTP header (seems to help in some situations).<br>
</dd>
<dt><strong>v1.3</strong> (2001-12-03)</dt>
<dd>
- Line break and text justification support (MultiCell()).<br>
- Color support (SetDrawColor(), SetFillColor(), SetTextColor()). Possibility to draw filled rectangles and paint cell background.<br>
- A cell whose width is declared null extends up to the right margin of the page.<br>
- Line width is now retained from page to page and defaults to 0.2 mm.<br>
- Added SetXY() method.<br>
- Fixed a passing by reference done in a deprecated manner for PHP4.<br>
</dd>
<dt><strong>v1.2</strong> (2001-11-11)</dt>
<dd>
- Added font metric files and GetStringWidth() method.<br>
- Centering and right-aligning text in cells.<br>
- Display mode control (SetDisplayMode()).<br>
- Added methods to set document properties (SetAuthor(), SetCreator(), SetKeywords(), SetSubject(), SetTitle()).<br>
- Possibility to force PDF download by browser.<br>
- Added SetX() and GetX() methods.<br>
- During automatic page break, current abscissa is now retained.<br>
</dd>
<dt><strong>v1.11</strong> (2001-10-20)</dt>
<dd>
- PNG support doesn't require PHP4/zlib any more. Data are now put directly into PDF without any decompression/recompression stage.<br>
- Image insertion now works correctly even with magic_quotes_runtime option set to on.<br>
</dd>
<dt><strong>v1.1</strong> (2001-10-07)</dt>
<dd>
- JPEG and PNG image support.<br>
</dd>
<dt><strong>v1.01</strong> (2001-10-03)</dt>
<dd>
- Fixed a bug involving page break: in case when Header() doesn't specify a font, the one from previous page was not restored and produced an incorrect document.<br>
</dd>
<dt><strong>v1.0</strong> (2001-09-17)</dt>
<dd>
- First version.<br>
</dd>
</dl>
</body>
</html>

24
api/vendor/setasign/fpdf/composer.json vendored Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "setasign/fpdf",
"homepage": "http://www.fpdf.org",
"description": "FPDF is a PHP class which allows to generate PDF files with pure PHP. F from FPDF stands for Free: you may use it for any kind of usage and modify it to suit your needs.",
"type": "library",
"keywords": ["pdf", "fpdf"],
"license": "MIT",
"authors": [
{
"name": "Olivier Plathey",
"email": "oliver@fpdf.org",
"homepage": "http://fpdf.org/"
}
],
"autoload": {
"classmap": [
"fpdf.php"
]
},
"require": {
"ext-zlib": "*",
"ext-gd": "*"
}
}

View File

@@ -0,0 +1,63 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>__construct</title>
<link type="text/css" rel="stylesheet" href="../fpdf.css">
</head>
<body>
<h1>__construct</h1>
<code>__construct([<b>string</b> orientation [, <b>string</b> unit [, <b>mixed</b> size]]])</code>
<h2>Description</h2>
This is the class constructor. It allows to set up the page size, the orientation and the
unit of measure used in all methods (except for font sizes).
<h2>Parameters</h2>
<dl class="param">
<dt><code>orientation</code></dt>
<dd>
Default page orientation. Possible values are (case insensitive):
<ul>
<li><code>P</code> or <code>Portrait</code></li>
<li><code>L</code> or <code>Landscape</code></li>
</ul>
Default value is <code>P</code>.
</dd>
<dt><code>unit</code></dt>
<dd>
User unit. Possible values are:
<ul>
<li><code>pt</code>: point</li>
<li><code>mm</code>: millimeter</li>
<li><code>cm</code>: centimeter</li>
<li><code>in</code>: inch</li>
</ul>
A point equals 1/72 of inch, that is to say about 0.35 mm (an inch being 2.54 cm). This
is a very common unit in typography; font sizes are expressed in that unit.
<br>
<br>
Default value is <code>mm</code>.
</dd>
<dt><code>size</code></dt>
<dd>
The size used for pages. It can be either one of the following values (case insensitive):
<ul>
<li><code>A3</code></li>
<li><code>A4</code></li>
<li><code>A5</code></li>
<li><code>Letter</code></li>
<li><code>Legal</code></li>
</ul>
or an array containing the width and the height (expressed in the unit given by <code>unit</code>).<br>
<br>
Default value is <code>A4</code>.
</dd>
</dl>
<h2>Example</h2>
Document with a custom 100x150 mm page size:
<div class="doc-source">
<pre><code>$pdf = new FPDF('P', 'mm', array(100,150));</code></pre>
</div>
<hr style="margin-top:1.5em">
<div style="text-align:center"><a href="index.htm">Index</a></div>
</body>
</html>

View File

@@ -0,0 +1,63 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>AcceptPageBreak</title>
<link type="text/css" rel="stylesheet" href="../fpdf.css">
</head>
<body>
<h1>AcceptPageBreak</h1>
<code><b>boolean</b> AcceptPageBreak()</code>
<h2>Description</h2>
Whenever a page break condition is met, the method is called, and the break is issued or not
depending on the returned value. The default implementation returns a value according to the
mode selected by SetAutoPageBreak().
<br>
This method is called automatically and should not be called directly by the application.
<h2>Example</h2>
The method is overriden in an inherited class in order to obtain a 3 column layout:
<div class="doc-source">
<pre><code>class PDF extends FPDF
{
protected $col = 0;
function SetCol($col)
{
// Move position to a column
$this-&gt;col = $col;
$x = 10 + $col*65;
$this-&gt;SetLeftMargin($x);
$this-&gt;SetX($x);
}
function AcceptPageBreak()
{
if($this-&gt;col&lt;2)
{
// Go to next column
$this-&gt;SetCol($this-&gt;col+1);
$this-&gt;SetY(10);
return false;
}
else
{
// Go back to first column and issue page break
$this-&gt;SetCol(0);
return true;
}
}
}
$pdf = new PDF();
$pdf-&gt;AddPage();
$pdf-&gt;SetFont('Arial', '', 12);
for($i=1;$i&lt;=300;$i++)
$pdf-&gt;Cell(0, 5, "Line $i", 0, 1);
$pdf-&gt;Output();</code></pre>
</div>
<h2>See also</h2>
<a href="setautopagebreak.htm">SetAutoPageBreak</a>
<hr style="margin-top:1.5em">
<div style="text-align:center"><a href="index.htm">Index</a></div>
</body>
</html>

View File

@@ -0,0 +1,67 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>AddFont</title>
<link type="text/css" rel="stylesheet" href="../fpdf.css">
</head>
<body>
<h1>AddFont</h1>
<code>AddFont(<b>string</b> family [, <b>string</b> style [, <b>string</b> file [, <b>string</b> dir]]])</code>
<h2>Description</h2>
Imports a TrueType, OpenType or Type1 font and makes it available. It is necessary to generate a font
definition file first with the MakeFont utility.
<br>
<br>
The definition file (and the font file itself in case of embedding) must be present in:
<ul>
<li>The directory indicated by the 4th parameter (if that parameter is set)</li>
<li>The directory indicated by the <code>FPDF_FONTPATH</code> constant (if that constant is defined)</li>
<li>The <code>font</code> directory located in the same directory as <code>fpdf.php</code></li>
</ul>
If the file is not found, the error "Could not include font definition file" is raised.
<h2>Parameters</h2>
<dl class="param">
<dt><code>family</code></dt>
<dd>
Font family. The name can be chosen arbitrarily. If it is a standard family name, it will
override the corresponding font.
</dd>
<dt><code>style</code></dt>
<dd>
Font style. Possible values are (case insensitive):
<ul>
<li>empty string: regular</li>
<li><code>B</code>: bold</li>
<li><code>I</code>: italic</li>
<li><code>BI</code> or <code>IB</code>: bold italic</li>
</ul>
The default value is regular.
</dd>
<dt><code>file</code></dt>
<dd>
The name of the font definition file.
<br>
By default, it is built from the family and style, in lower case with no space.
</dd>
<dt><code>dir</code></dt>
<dd>
The directory where to load the definition file.
<br>
If not specified, the default directory will be used.
</dd>
</dl>
<h2>Example</h2>
<div class="doc-source">
<pre><code>$pdf-&gt;AddFont('Comic', 'I');</code></pre>
</div>
is equivalent to:
<div class="doc-source">
<pre><code>$pdf-&gt;AddFont('Comic', 'I', 'comici.php');</code></pre>
</div>
<h2>See also</h2>
<a href="setfont.htm">SetFont</a>
<hr style="margin-top:1.5em">
<div style="text-align:center"><a href="index.htm">Index</a></div>
</body>
</html>

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>AddLink</title>
<link type="text/css" rel="stylesheet" href="../fpdf.css">
</head>
<body>
<h1>AddLink</h1>
<code><b>int</b> AddLink()</code>
<h2>Description</h2>
Creates a new internal link and returns its identifier. An internal link is a clickable area
which directs to another place within the document.
<br>
The identifier can then be passed to Cell(), Write(), Image() or Link(). The destination is
defined with SetLink().
<h2>See also</h2>
<a href="cell.htm">Cell</a>,
<a href="write.htm">Write</a>,
<a href="image.htm">Image</a>,
<a href="link.htm">Link</a>,
<a href="setlink.htm">SetLink</a>
<hr style="margin-top:1.5em">
<div style="text-align:center"><a href="index.htm">Index</a></div>
</body>
</html>

View File

@@ -0,0 +1,61 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>AddPage</title>
<link type="text/css" rel="stylesheet" href="../fpdf.css">
</head>
<body>
<h1>AddPage</h1>
<code>AddPage([<b>string</b> orientation [, <b>mixed</b> size [, <b>int</b> rotation]]])</code>
<h2>Description</h2>
Adds a new page to the document. If a page is already present, the Footer() method is called
first to output the footer. Then the page is added, the current position set to the top-left
corner according to the left and top margins, and Header() is called to display the header.
<br>
The font which was set before calling is automatically restored. There is no need to call
SetFont() again if you want to continue with the same font. The same is true for colors and
line width.
<br>
The origin of the coordinate system is at the top-left corner and increasing ordinates go
downwards.
<h2>Parameters</h2>
<dl class="param">
<dt><code>orientation</code></dt>
<dd>
Page orientation. Possible values are (case insensitive):
<ul>
<li><code>P</code> or <code>Portrait</code></li>
<li><code>L</code> or <code>Landscape</code></li>
</ul>
The default value is the one passed to the constructor.
</dd>
<dt><code>size</code></dt>
<dd>
Page size. It can be either one of the following values (case insensitive):
<ul>
<li><code>A3</code></li>
<li><code>A4</code></li>
<li><code>A5</code></li>
<li><code>Letter</code></li>
<li><code>Legal</code></li>
</ul>
or an array containing the width and the height (expressed in user unit).<br>
<br>
The default value is the one passed to the constructor.
</dd>
<dt><code>rotation</code></dt>
<dd>
Angle by which to rotate the page. It must be a multiple of 90; positive values
mean clockwise rotation. The default value is <code>0</code>.
</dd>
</dl>
<h2>See also</h2>
<a href="__construct.htm">__construct</a>,
<a href="header.htm">Header</a>,
<a href="footer.htm">Footer</a>,
<a href="setmargins.htm">SetMargins</a>
<hr style="margin-top:1.5em">
<div style="text-align:center"><a href="index.htm">Index</a></div>
</body>
</html>

View File

@@ -0,0 +1,45 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>AliasNbPages</title>
<link type="text/css" rel="stylesheet" href="../fpdf.css">
</head>
<body>
<h1>AliasNbPages</h1>
<code>AliasNbPages([<b>string</b> alias])</code>
<h2>Description</h2>
Defines an alias for the total number of pages. It will be substituted as the document is
closed.
<h2>Parameters</h2>
<dl class="param">
<dt><code>alias</code></dt>
<dd>
The alias. Default value: <code>{nb}</code>.
</dd>
</dl>
<h2>Example</h2>
<div class="doc-source">
<pre><code>class PDF extends FPDF
{
function Footer()
{
// Go to 1.5 cm from bottom
$this-&gt;SetY(-15);
// Select Arial italic 8
$this-&gt;SetFont('Arial', 'I', 8);
// Print current and total page numbers
$this-&gt;Cell(0, 10, 'Page '.$this-&gt;PageNo().'/{nb}', 0, 0, 'C');
}
}
$pdf = new PDF();
$pdf-&gt;AliasNbPages();</code></pre>
</div>
<h2>See also</h2>
<a href="pageno.htm">PageNo</a>,
<a href="footer.htm">Footer</a>
<hr style="margin-top:1.5em">
<div style="text-align:center"><a href="index.htm">Index</a></div>
</body>
</html>

104
api/vendor/setasign/fpdf/doc/cell.htm vendored Normal file
View File

@@ -0,0 +1,104 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Cell</title>
<link type="text/css" rel="stylesheet" href="../fpdf.css">
</head>
<body>
<h1>Cell</h1>
<code>Cell(<b>float</b> w [, <b>float</b> h [, <b>string</b> txt [, <b>mixed</b> border [, <b>int</b> ln [, <b>string</b> align [, <b>boolean</b> fill [, <b>mixed</b> link]]]]]]])</code>
<h2>Description</h2>
Prints a cell (rectangular area) with optional borders, background color and character string.
The upper-left corner of the cell corresponds to the current position. The text can be aligned
or centered. After the call, the current position moves to the right or to the next line. It is
possible to put a link on the text.
<br>
If automatic page breaking is enabled and the cell goes beyond the limit, a page break is
done before outputting.
<h2>Parameters</h2>
<dl class="param">
<dt><code>w</code></dt>
<dd>
Cell width. If <code>0</code>, the cell extends up to the right margin.
</dd>
<dt><code>h</code></dt>
<dd>
Cell height.
Default value: <code>0</code>.
</dd>
<dt><code>txt</code></dt>
<dd>
String to print.
Default value: empty string.
</dd>
<dt><code>border</code></dt>
<dd>
Indicates if borders must be drawn around the cell. The value can be either a number:
<ul>
<li><code>0</code>: no border</li>
<li><code>1</code>: frame</li>
</ul>
or a string containing some or all of the following characters (in any order):
<ul>
<li><code>L</code>: left</li>
<li><code>T</code>: top</li>
<li><code>R</code>: right</li>
<li><code>B</code>: bottom</li>
</ul>
Default value: <code>0</code>.
</dd>
<dt><code>ln</code></dt>
<dd>
Indicates where the current position should go after the call. Possible values are:
<ul>
<li><code>0</code>: to the right</li>
<li><code>1</code>: to the beginning of the next line</li>
<li><code>2</code>: below</li>
</ul>
Putting <code>1</code> is equivalent to putting <code>0</code> and calling Ln() just after.
Default value: <code>0</code>.
</dd>
<dt><code>align</code></dt>
<dd>
Allows to center or align the text. Possible values are:
<ul>
<li><code>L</code> or empty string: left align (default value)</li>
<li><code>C</code>: center</li>
<li><code>R</code>: right align</li>
</ul>
</dd>
<dt><code>fill</code></dt>
<dd>
Indicates if the cell background must be painted (<code>true</code>) or transparent (<code>false</code>).
Default value: <code>false</code>.
</dd>
<dt><code>link</code></dt>
<dd>
URL or identifier returned by AddLink().
</dd>
</dl>
<h2>Example</h2>
<div class="doc-source">
<pre><code>// Set font
$pdf-&gt;SetFont('Arial', 'B', 16);
// Move to 8 cm to the right
$pdf-&gt;Cell(80);
// Centered text in a framed 20*10 mm cell and line break
$pdf-&gt;Cell(20, 10, 'Title', 1, 1, 'C');</code></pre>
</div>
<h2>See also</h2>
<a href="setfont.htm">SetFont</a>,
<a href="setdrawcolor.htm">SetDrawColor</a>,
<a href="setfillcolor.htm">SetFillColor</a>,
<a href="settextcolor.htm">SetTextColor</a>,
<a href="setlinewidth.htm">SetLineWidth</a>,
<a href="addlink.htm">AddLink</a>,
<a href="ln.htm">Ln</a>,
<a href="multicell.htm">MultiCell</a>,
<a href="write.htm">Write</a>,
<a href="setautopagebreak.htm">SetAutoPageBreak</a>
<hr style="margin-top:1.5em">
<div style="text-align:center"><a href="index.htm">Index</a></div>
</body>
</html>

21
api/vendor/setasign/fpdf/doc/close.htm vendored Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Close</title>
<link type="text/css" rel="stylesheet" href="../fpdf.css">
</head>
<body>
<h1>Close</h1>
<code>Close()</code>
<h2>Description</h2>
Terminates the PDF document. It is not necessary to call this method explicitly because Output()
does it automatically.
<br>
If the document contains no page, AddPage() is called to prevent from getting an invalid document.
<h2>See also</h2>
<a href="output.htm">Output</a>
<hr style="margin-top:1.5em">
<div style="text-align:center"><a href="index.htm">Index</a></div>
</body>
</html>

26
api/vendor/setasign/fpdf/doc/error.htm vendored Normal file
View File

@@ -0,0 +1,26 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Error</title>
<link type="text/css" rel="stylesheet" href="../fpdf.css">
</head>
<body>
<h1>Error</h1>
<code>Error(<b>string</b> msg)</code>
<h2>Description</h2>
This method is automatically called in case of a fatal error; it simply throws an exception
with the provided message.<br>
An inherited class may override it to customize the error handling but the method should
never return, otherwise the resulting document would probably be invalid.
<h2>Parameters</h2>
<dl class="param">
<dt><code>msg</code></dt>
<dd>
The error message.
</dd>
</dl>
<hr style="margin-top:1.5em">
<div style="text-align:center"><a href="index.htm">Index</a></div>
</body>
</html>

35
api/vendor/setasign/fpdf/doc/footer.htm vendored Normal file
View File

@@ -0,0 +1,35 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Footer</title>
<link type="text/css" rel="stylesheet" href="../fpdf.css">
</head>
<body>
<h1>Footer</h1>
<code>Footer()</code>
<h2>Description</h2>
This method is used to render the page footer. It is automatically called by AddPage() and
Close() and should not be called directly by the application. The implementation in FPDF is
empty, so you have to subclass it and override the method if you want a specific processing.
<h2>Example</h2>
<div class="doc-source">
<pre><code>class PDF extends FPDF
{
function Footer()
{
// Go to 1.5 cm from bottom
$this-&gt;SetY(-15);
// Select Arial italic 8
$this-&gt;SetFont('Arial', 'I', 8);
// Print centered page number
$this-&gt;Cell(0, 10, 'Page '.$this-&gt;PageNo(), 0, 0, 'C');
}
}</code></pre>
</div>
<h2>See also</h2>
<a href="header.htm">Header</a>
<hr style="margin-top:1.5em">
<div style="text-align:center"><a href="index.htm">Index</a></div>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More