Sécurité et qualité : headers HTTP, permissions .env, lint PHPStan + PHP-CS-Fixer, réorganisation dossiers, scripts de déploiement

This commit is contained in:
Cedric Abonnel
2026-05-08 13:18:00 +02:00
parent 700329f156
commit 70304d3b31
44 changed files with 776 additions and 670 deletions
+114
View File
@@ -0,0 +1,114 @@
<?php
// includes/ConfigRepo.php
declare(strict_types=1);
function config_repo_get(): array
{
$pdo = db();
$row = $pdo->query('SELECT * FROM app_config WHERE id=1')->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return [
'allow_password' => true,'allow_oidc' => false,'registrations_open' => true,
'oidc_issuer' => null,'oidc_name' => null,'oidc_client_id' => null,'oidc_client_secret' => null,'oidc_redirect_uri' => null
];
}
return $row;
}
function config_repo_save(array $in): void
{
$pdo = db();
$sql = 'INSERT INTO app_config
(id, allow_password, allow_oidc, registrations_open, oidc_issuer, oidc_name, oidc_client_id, oidc_client_secret, oidc_redirect_uri, updated_at)
VALUES (1,:pw,:oidc,:open,:iss,:name,:cid,:sec,:redir, now())
ON CONFLICT (id) DO UPDATE SET
allow_password=:pw, allow_oidc=:oidc, registrations_open=:open,
oidc_issuer=:iss, oidc_name=:name, oidc_client_id=:cid, oidc_client_secret=:sec, oidc_redirect_uri=:redir,
updated_at=now()';
$stmt = $pdo->prepare($sql);
$stmt->execute([
':pw' => (bool)$in['allow_password'],
':oidc' => (bool)$in['allow_oidc'],
':open' => (bool)$in['registrations_open'],
':iss' => trim((string)($in['oidc_issuer'] ?? '')) ?: null,
':name' => trim((string)($in['oidc_name'] ?? '')) ?: null,
':cid' => trim((string)($in['oidc_client_id'] ?? '')) ?: null,
':sec' => trim((string)($in['oidc_client_secret'] ?? '')) ?: null,
':redir' => trim((string)($in['oidc_redirect_uri'] ?? '')) ?: null,
]);
}
/**
* Met à jour le fichier .env en conservant les autres lignes.
* $pairs = ['KEY'=>'value', ...] ; value null => supprime la clé.
*/
function env_set_pairs(string $envPath, array $pairs): void
{
if (!is_file($envPath)) {
file_put_contents($envPath, '');
}
$lines = file($envPath, FILE_IGNORE_NEW_LINES);
$map = [];
foreach ($lines as $i => $line) {
if (preg_match('/^\s*#/', $line) || trim($line) === '') {
$map[$i] = $line;
continue;
}
if (!str_contains($line, '=')) {
$map[$i] = $line;
continue;
}
[$k,$v] = explode('=', $line, 2);
$k = trim($k);
if ($k === '') {
$map[$i] = $line;
continue;
}
if (array_key_exists($k, $pairs)) {
if ($pairs[$k] === null) {
$map[$i] = null;
} // supprimé
else {
$map[$i] = $k.'='.env_quote((string)$pairs[$k]);
}
unset($pairs[$k]);
} else {
$map[$i] = $line;
}
}
// append keys restantes
foreach ($pairs as $k => $v) {
if ($v === null) {
continue;
}
$map[] = $k.'='.env_quote((string)$v);
}
// re-écriture
$out = [];
foreach ($map as $line) {
if ($line === null) {
continue;
} $out[] = $line;
}
file_put_contents($envPath, implode(PHP_EOL, $out).PHP_EOL);
}
function env_quote(string $v): string
{
if ($v === '' || preg_match('/\s|[#"\'=]/', $v)) {
// met entre guillemets et échappe
$v = str_replace(['\\','"'], ['\\\\','\\"'], $v);
return "\"$v\"";
}
return $v;
}
function ensure_admin(): void
{
// adapte à ton système
if (empty($_SESSION['user']['is_admin'])) {
http_response_code(403);
exit('Forbidden');
}
}
+3 -1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Domain;
@@ -10,5 +11,6 @@ final class User
public string $email,
public string $passwordHash,
public bool $isActive = true,
) {}
) {
}
}
+16 -8
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
class FileManager
{
private PDO $db;
@@ -27,10 +29,10 @@ class FileManager
return null;
}
$stmt = $this->db->prepare("
$stmt = $this->db->prepare('
INSERT INTO post_files (post_id, file_type, file_path, original_name)
VALUES (:post_id, :file_type, :file_path, :original_name)
");
');
$stmt->execute([
'post_id' => $postId,
'file_type' => $type,
@@ -43,14 +45,14 @@ class FileManager
public function getFilesForPost(int $postId): array
{
$stmt = $this->db->prepare("SELECT * FROM post_files WHERE post_id = :post_id ORDER BY uploaded_at");
$stmt = $this->db->prepare('SELECT * FROM post_files WHERE post_id = :post_id ORDER BY uploaded_at');
$stmt->execute(['post_id' => $postId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function delete(int $fileId): bool
{
$stmt = $this->db->prepare("SELECT file_path FROM post_files WHERE id = :id");
$stmt = $this->db->prepare('SELECT file_path FROM post_files WHERE id = :id');
$stmt->execute(['id' => $fileId]);
$file = $stmt->fetch(PDO::FETCH_ASSOC);
@@ -63,15 +65,21 @@ class FileManager
unlink($fullPath);
}
$stmt = $this->db->prepare("DELETE FROM post_files WHERE id = :id");
$stmt = $this->db->prepare('DELETE FROM post_files WHERE id = :id');
return $stmt->execute(['id' => $fileId]);
}
private function guessType(string $mime): string
{
if (str_starts_with($mime, 'image/')) return 'image';
if (str_starts_with($mime, 'video/')) return 'video';
if (str_starts_with($mime, 'audio/')) return 'audio';
if (str_starts_with($mime, 'image/')) {
return 'image';
}
if (str_starts_with($mime, 'video/')) {
return 'video';
}
if (str_starts_with($mime, 'audio/')) {
return 'audio';
}
return 'file';
}
}
+1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Http;
+11 -4
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure;
@@ -17,9 +18,11 @@ final class Database
return self::$pdo;
}
$get = static function (string $k, ?string $default=null): ?string {
$get = static function (string $k, ?string $default = null): ?string {
$v = getenv($k);
if ($v !== false && $v !== '') return (string)$v;
if ($v !== false && $v !== '') {
return (string)$v;
}
return $_ENV[$k] ?? $default;
};
@@ -31,7 +34,9 @@ final class Database
$host = $get('DB_HOST', 'localhost');
$port = $get('DB_PORT', '5432');
$name = $get('DB_NAME');
if ($name) $dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', $host, $port, $name);
if ($name) {
$dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', $host, $port, $name);
}
}
if (!$dsn) {
@@ -80,7 +85,9 @@ final class Database
$pdo->commit();
return $ret;
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
+7 -2
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure;
@@ -19,9 +20,13 @@ final class DbAdapter
$host = getenv('DB_HOST') ?: ($_ENV['DB_HOST'] ?? 'localhost');
$port = getenv('DB_PORT') ?: ($_ENV['DB_PORT'] ?? '5432');
$name = getenv('DB_NAME') ?: ($_ENV['DB_NAME'] ?? null);
if ($name) $dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', $host, $port, $name);
if ($name) {
$dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', $host, $port, $name);
}
}
if (!$dsn) {
throw new \RuntimeException('Aucun DSN pour initialiser PDO');
}
if (!$dsn) throw new \RuntimeException('Aucun DSN pour initialiser PDO');
return new PDO($dsn, (string)$user, (string)$pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+4 -1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure;
@@ -7,7 +8,9 @@ final class Session
{
public static function startSecure(string $name): void
{
if (session_status() === PHP_SESSION_ACTIVE) return;
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
session_name($name);
session_set_cookie_params([
+190 -356
View File
File diff suppressed because it is too large Load Diff
+11 -9
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
class PostManager
{
private PDO $db;
@@ -11,13 +13,13 @@ class PostManager
public function getAll(): array
{
$stmt = $this->db->query("SELECT * FROM posts ORDER BY created_at DESC");
$stmt = $this->db->query('SELECT * FROM posts ORDER BY created_at DESC');
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function get(int $id): ?array
{
$stmt = $this->db->prepare("SELECT * FROM posts WHERE id = :id");
$stmt = $this->db->prepare('SELECT * FROM posts WHERE id = :id');
$stmt->execute(['id' => $id]);
$post = $stmt->fetch(PDO::FETCH_ASSOC);
return $post ?: null;
@@ -25,10 +27,10 @@ class PostManager
public function create(string $title, string $content, string $published_at): int
{
$stmt = $this->db->prepare("
$stmt = $this->db->prepare('
INSERT INTO posts (title, content, created_at, is_published)
VALUES (:title, :content, :published_at, true)
");
');
$stmt->execute([
'title' => $title,
'content' => $content,
@@ -36,11 +38,11 @@ class PostManager
]);
return (int)$this->db->lastInsertId();
}
public function update(int $id, string $title, string $content, string $published_at, bool $published): bool
{
$stmt = $this->db->prepare("
$stmt = $this->db->prepare('
UPDATE posts
SET title = :title,
content = :content,
@@ -48,7 +50,7 @@ class PostManager
is_published = :published,
updated_at = NOW()
WHERE id = :id
");
');
return $stmt->execute([
'id' => $id,
'title' => $title,
@@ -57,11 +59,11 @@ class PostManager
'published' => $published,
]);
}
public function delete(int $id): bool
{
$stmt = $this->db->prepare("DELETE FROM posts WHERE id = :id");
$stmt = $this->db->prepare('DELETE FROM posts WHERE id = :id');
return $stmt->execute(['id' => $id]);
}
}
+19 -10
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Repository;
@@ -7,39 +8,47 @@ use PDO;
final class DictionaryRepository
{
public function __construct(private PDO $pdo) {}
public function __construct(private PDO $pdo)
{
}
public function getEntityByCode(string $code): ?array {
public function getEntityByCode(string $code): ?array
{
$st = $this->pdo->prepare('SELECT * FROM dd_entities WHERE code = :c AND is_active IS TRUE');
$st->execute([':c'=>$code]);
$st->execute([':c' => $code]);
$e = $st->fetch(PDO::FETCH_ASSOC);
if (!$e) return null;
if (!$e) {
return null;
}
$e['fields'] = $this->getFields((int)$e['id']);
$e['rules'] = $this->getRules((int)$e['id']);
return $e;
}
public function getFields(int $entityId): array {
public function getFields(int $entityId): array
{
$st = $this->pdo->prepare('SELECT * FROM dd_fields WHERE entity_id = :id ORDER BY ui_order NULLS LAST, id');
$st->execute([':id'=>$entityId]);
$st->execute([':id' => $entityId]);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
public function getRules(int $entityId): array {
public function getRules(int $entityId): array
{
$st = $this->pdo->prepare('SELECT * FROM dd_rules WHERE entity_id = :id AND active IS TRUE');
$st->execute([':id'=>$entityId]);
$st->execute([':id' => $entityId]);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
public function getEnum(string $name): array {
public function getEnum(string $name): array
{
$st = $this->pdo->prepare('
SELECT ev.code, ev.label
FROM dd_enums e JOIN dd_enum_values ev ON ev.enum_id = e.id
WHERE e.name = :n AND ev.active IS TRUE
ORDER BY ev.sort_order, ev.id
');
$st->execute([':n'=>$name]);
$st->execute([':n' => $name]);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
}
+27 -8
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Repository;
@@ -13,24 +14,39 @@ final class ProfileRepository
public function __construct(?PDO $pdo = null)
{
// 0) DI directe
if ($pdo instanceof PDO) { $this->pdo = $pdo; return; }
if ($pdo instanceof PDO) {
$this->pdo = $pdo;
return;
}
// 1) App\Infrastructure\Database (si elle expose quelque chose)
if (class_exists(Database::class)) {
if (method_exists(Database::class, 'pdo')) {
$try = Database::pdo();
if ($try instanceof PDO) { $this->pdo = $try; return; }
if ($try instanceof PDO) {
$this->pdo = $try;
return;
}
}
if (method_exists(Database::class, 'getPdo')) {
$try = Database::getPdo();
if ($try instanceof PDO) { $this->pdo = $try; return; }
if ($try instanceof PDO) {
$this->pdo = $try;
return;
}
}
if (method_exists(Database::class, 'getInstance')) {
$db = Database::getInstance();
if ($db instanceof PDO) { $this->pdo = $db; return; }
if ($db instanceof PDO) {
$this->pdo = $db;
return;
}
if (is_object($db) && method_exists($db, 'pdo')) {
$try = $db->pdo();
if ($try instanceof PDO) { $this->pdo = $try; return; }
if ($try instanceof PDO) {
$this->pdo = $try;
return;
}
}
}
}
@@ -38,7 +54,10 @@ final class ProfileRepository
// 2) Fonction globale éventuelle
if (function_exists('db')) {
$try = db();
if ($try instanceof PDO) { $this->pdo = $try; return; }
if ($try instanceof PDO) {
$this->pdo = $try;
return;
}
}
// 3) Variable globale éventuelle
@@ -110,7 +129,7 @@ final class ProfileRepository
':slug' => $slug,
':label' => $label,
':desc' => $description,
':perms' => json_encode($permissions, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES),
':perms' => json_encode($permissions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
':sys' => $isSystem,
':act' => $isActive,
]);
@@ -125,7 +144,7 @@ final class ProfileRepository
':slug' => $slug,
':label' => $label,
':desc' => $description,
':perms' => json_encode($permissions, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES),
':perms' => json_encode($permissions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
':sys' => $isSystem,
':act' => $isActive,
]);
+4 -1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Repository;
@@ -8,7 +9,9 @@ use PDO;
final class UserRepository
{
public function __construct(private PDO $pdo) {}
public function __construct(private PDO $pdo)
{
}
/**
* Crée (si besoin) un utilisateur OIDC.
+16 -11
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Service;
@@ -7,7 +8,9 @@ use App\Repository\UserRepository;
final class AuthService
{
public function __construct(private UserRepository $users) {}
public function __construct(private UserRepository $users)
{
}
public function canAttempt(string $email, string $ip): bool
{
@@ -30,13 +33,13 @@ final class AuthService
$user = $this->users->findByEmail($email);
$ok = $user && $user->isActive && password_verify($password, $user->passwordHash);
$pdo = \App\Infrastructure\Database::pdo();
$st = $pdo->prepare('insert into login_attempts(email, ip, success) values(:e, :ip, :s)');
$st->bindValue(':e', $email, \PDO::PARAM_STR);
$st->bindValue(':ip', $ip, \PDO::PARAM_STR);
$st->bindValue(':s', $ok, \PDO::PARAM_BOOL);
$st->execute();
$pdo = \App\Infrastructure\Database::pdo();
$st = $pdo->prepare('insert into login_attempts(email, ip, success) values(:e, :ip, :s)');
$st->bindValue(':e', $email, \PDO::PARAM_STR);
$st->bindValue(':ip', $ip, \PDO::PARAM_STR);
$st->bindValue(':s', $ok, \PDO::PARAM_BOOL);
$st->execute();
if ($ok) {
\App\Infrastructure\Session::regenerate();
$_SESSION['uid'] = $user->id;
@@ -44,8 +47,8 @@ final class AuthService
}
return $ok;
}
public function changePassword(string $userId, string $currentPassword, string $newPassword): bool
{
// Récupération de lutilisateur (rapide : requête directe ; tu peux créer findById() si tu préfères)
@@ -53,7 +56,9 @@ final class AuthService
$st = $pdo->prepare('select id, email, password_hash, is_active from users where id = :id');
$st->execute([':id' => $userId]);
$row = $st->fetch(\PDO::FETCH_ASSOC);
if (!$row || !(bool)$row['is_active']) return false;
if (!$row || !(bool)$row['is_active']) {
return false;
}
// Vérifier lancien mot de passe
if (!password_verify($currentPassword, (string)$row['password_hash'])) {
+1 -1
View File
@@ -144,7 +144,7 @@ final class MailQueue
if ($rows) {
// 2) Marquer locked_at + sending
$ids = array_map(static fn($r) => (int)$r['id'], $rows);
$ids = array_map(static fn ($r) => (int)$r['id'], $rows);
$in = implode(',', array_fill(0, count($ids), '?'));
$up = $this->pdo->prepare(
-1
View File
@@ -8,7 +8,6 @@ use App\Infrastructure\Database;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception as MailException;
use PDO;
use DateInterval;
use DateTimeImmutable;
/**
+17 -7
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Service;
@@ -7,21 +8,30 @@ use App\Repository\DictionaryRepository;
final class UiFormRenderer
{
public function __construct(private DictionaryRepository $dict) {}
public function __construct(private DictionaryRepository $dict)
{
}
public function renderControls(string $entityCode, array $values = []): string {
public function renderControls(string $entityCode, array $values = []): string
{
$e = $this->dict->getEntityByCode($entityCode);
if (!$e) return '<div class="alert alert-danger">Entité inconnue</div>';
if (!$e) {
return '<div class="alert alert-danger">Entité inconnue</div>';
}
$html = '';
foreach ($e['fields'] as $f) {
if (!$f['form_visible']) continue;
if ($f['read_only']) continue;
if (!$f['form_visible']) {
continue;
}
if ($f['read_only']) {
continue;
}
$name = $f['code'];
$label = $f['label'];
$help = $f['help_text'] ?? '';
$widget= $f['ui_widget'] ?? 'text';
$widget = $f['ui_widget'] ?? 'text';
$val = $values[$name] ?? '';
$html .= '<div class="mb-3">';
@@ -41,7 +51,7 @@ final class UiFormRenderer
'email' => 'email',
'number' => 'number',
'date' => 'date',
'checkbox'=> 'checkbox',
'checkbox' => 'checkbox',
default => 'text',
};
if ($type === 'checkbox') {
+21 -7
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Service;
@@ -7,12 +8,17 @@ use App\Repository\DictionaryRepository;
final class Validator
{
public function __construct(private DictionaryRepository $dict) {}
public function __construct(private DictionaryRepository $dict)
{
}
public function validate(string $entityCode, array $payload): array {
public function validate(string $entityCode, array $payload): array
{
$errors = [];
$e = $this->dict->getEntityByCode($entityCode);
if (!$e) return ['_global' => ['Entité inconnue']];
if (!$e) {
return ['_global' => ['Entité inconnue']];
}
// Index les champs
$fields = [];
@@ -30,7 +36,9 @@ final class Validator
switch ($type) {
case 'required':
if ($code && ($v === null || $v === '')) $errors[$code][] = $msg;
if ($code && ($v === null || $v === '')) {
$errors[$code][] = $msg;
}
break;
case 'regex':
if ($code && $v !== null && $v !== '' && !preg_match('#'.$val.'#u', (string)$v)) {
@@ -38,16 +46,22 @@ final class Validator
}
break;
case 'min':
if ($code && is_numeric($v) && (float)$v < (float)$val) $errors[$code][] = $msg;
if ($code && is_numeric($v) && (float)$v < (float)$val) {
$errors[$code][] = $msg;
}
break;
case 'max':
if ($code && is_numeric($v) && (float)$v > (float)$val) $errors[$code][] = $msg;
if ($code && is_numeric($v) && (float)$v > (float)$val) {
$errors[$code][] = $msg;
}
break;
case 'between':
if ($code && is_numeric($v)) {
[$a,$b] = array_map('floatval', explode(',', $val));
$fv = (float)$v;
if ($fv < $a || $fv > $b) $errors[$code][] = $msg;
if ($fv < $a || $fv > $b) {
$errors[$code][] = $msg;
}
}
break;
case 'unique':
+6 -2
View File
@@ -1,10 +1,13 @@
<?php
declare(strict_types=1);
use Jumbojett\OpenIDConnectClient;
require_once BASE_PATH . '/vendor/autoload.php';
session_start();
function require_auth() {
function require_auth()
{
if (!isset($_SESSION['user'])) {
// Redirige vers la page de login
header('Location: /auth/login.php');
@@ -12,7 +15,8 @@ function require_auth() {
}
}
function get_oidc_client(): OpenIDConnectClient {
function get_oidc_client(): OpenIDConnectClient
{
$oidc = new OpenIDConnectClient(
'https://idp.a5l.fr/realms/master',
'varlog-client-id',
+2
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
require_once BASE_PATH . '/config/config.php';
// Comment récupérer les valeurs de .env
+5 -2
View File
@@ -1,8 +1,11 @@
<?php
function vd($var, ...$moreVars) {
declare(strict_types=1);
function vd($var, ...$moreVars)
{
ob_start();
var_dump($var, ...$moreVars);
$output = ob_get_clean();
echo "<pre>$output</pre>";
}
}
+172
View File
@@ -0,0 +1,172 @@
<?php
// projet : mug.a5l.fr
// fichier : includes/mailer.php
// version : 20251011
declare(strict_types=1);
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
require_once dirname(__DIR__) . '/vendor/autoload.php';
if (!function_exists('env')) {
function env(string $key, ?string $default = null): ?string
{
if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') {
return (string)$_ENV[$key];
}
$v = getenv($key);
if ($v !== false && $v !== '') {
return (string)$v;
}
return $default;
}
}
if (!function_exists('db')) {
function db(): \PDO
{
return \App\Infrastructure\Database::get();
}
}
/**
* Anti-abus simple : 1 envoi / 5 min et 5 en 12 h par destinataire (status in ('sent','queued')).
*/
function mailer_can_send(string $email, int $coolMin = 5, int $maxPer12h = 5): array
{
// bypass complet si désactivé
$enabled = (int) (env('SMTP_RATE_LIMIT_ENABLE', '1'));
if ($enabled === 0) {
return [true, ''];
}
$pdo = db();
// Cooldown (actif seulement si >0)
if ($coolMin > 0) {
$q1 = "SELECT 1 FROM journal_smtp
WHERE to_email = :e AND created_at >= NOW() - INTERVAL :cool
AND status IN ('sent','queued')
LIMIT 1";
$stmt = $pdo->prepare($q1);
$stmt->execute([':e' => $email, ':cool' => sprintf('%d minutes', $coolMin)]);
if ($stmt->fetchColumn()) {
return [false, "Un email vient d’être envoyé. Réessayez dans {$coolMin} min."];
}
}
// Plafond 12h (actif seulement si >0)
if ($maxPer12h > 0) {
$q2 = "SELECT COUNT(*) FROM journal_smtp
WHERE to_email = :e AND created_at >= NOW() - INTERVAL '12 hours'
AND status IN ('sent','queued')";
$stmt = $pdo->prepare($q2);
$stmt->execute([':e' => $email]);
if ((int)$stmt->fetchColumn() >= $maxPer12h) {
return [false, 'Quota atteint. Réessayez plus tard.'];
}
}
return [true, ''];
}
/**
* Envoi immédiat SMTP avec PHPMailer + journalisation.
* @param string $to destinataire
* @param string $subject objet
* @param string $html corps HTML
* @param string|null $text corps texte brut (optionnel, auto-généré si null)
* @param array $opts ['reply_to'=>['email','name']]
*/
function envoyer_mail_smtp(string $to, string $subject, string $html, ?string $text = null, array $opts = []): bool
{
[$ok, $msg] = mailer_can_send($to, (int)env('SMTP_COOLDOWN_MINUTES', '5'), (int)env('SMTP_MAX_PER_12H', '5'));
if (!$ok) {
throw new RuntimeException($msg);
}
$pdo = db();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare("INSERT INTO journal_smtp
(created_at, script_path, to_email, subject, content_html, content_text, status, ip, user_agent)
VALUES (NOW(), :script, :to, :subj, :html, :text, 'queued', :ip, :ua)
RETURNING id");
$stmt->execute([
':script' => ($_SERVER['SCRIPT_NAME'] ?? ''),
':to' => $to,
':subj' => $subject,
':html' => $html,
':text' => $text ?? trim(html_entity_decode(strip_tags($html), ENT_QUOTES)),
':ip' => ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? ''),
':ua' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512),
]);
$rowId = (int)$stmt->fetchColumn();
$pdo->commit();
} catch (\Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
$mail = new PHPMailer(true);
try {
$mail->isSMTP();
$mail->Host = (string)env('SMTP_HOST', 'localhost');
$mail->Port = (int)env('SMTP_PORT', '587');
$mail->SMTPAuth = (env('SMTP_USER') || env('SMTP_PASS')) ? true : false;
$mail->Username = (string)env('SMTP_USER', '');
$mail->Password = (string)env('SMTP_PASS', '');
$secure = strtolower((string)env('SMTP_SECURE', 'tls'));
if ($secure === 'ssl') {
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
} elseif ($secure === 'tls') {
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
}
$mail->SMTPKeepAlive = true; // réutilise la connexion
$mail->Timeout = 30; // évite les blocages longs
$mail->SMTPOptions = ['ssl' => ['verify_peer' => true,'verify_peer_name' => true,'allow_self_signed' => false]];
$mail->CharSet = 'UTF-8';
$mail->isHTML(true);
// Expéditeur
$from = (string)env('SMTP_FROM', 'no-reply@mug.a5l.fr');
$fromName = (string)env('SMTP_FROM_NAME', 'MUG');
$mail->setFrom($from, $fromName);
// Reply-To
if (!empty($opts['reply_to']) && is_array($opts['reply_to']) && filter_var($opts['reply_to'][0] ?? '', FILTER_VALIDATE_EMAIL)) {
$mail->addReplyTo($opts['reply_to'][0], $opts['reply_to'][1] ?? '');
} elseif ($rt = env('SMTP_REPLY_TO')) {
$mail->addReplyTo($rt, (string)env('SMTP_REPLY_TO_NAME', 'Support'));
}
// DKIM optionnel
if ($d = env('DKIM_DOMAIN')) {
$mail->DKIM_domain = $d;
$mail->DKIM_selector = (string)env('DKIM_SELECTOR', 'default');
$mail->DKIM_private = (string)env('DKIM_PRIVATE_KEY_PATH', '');
$mail->DKIM_passphrase = (string)env('DKIM_PASSPHRASE', '');
$mail->DKIM_identity = $from;
}
$mail->addAddress($to);
$mail->Subject = $subject;
$mail->Body = $html;
$mail->AltBody = $text ?? trim(html_entity_decode(strip_tags($html), ENT_QUOTES));
$mail->send();
$pdo->prepare("UPDATE journal_smtp SET status='sent', sent_at=NOW() WHERE id=:id")->execute([':id' => $rowId]);
return true;
} catch (Exception $e) {
$pdo->prepare("UPDATE journal_smtp SET status='error', error_message=:err, sent_at=NOW() WHERE id=:id")
->execute([':id' => $rowId, ':err' => substr($e->getMessage(), 0, 1000)]);
throw new RuntimeException('Envoi email impossible: '.$e->getMessage());
}
}