Initial commit

This commit is contained in:
Cedric Abonnel
2026-05-08 12:55:46 +02:00
commit 700329f156
46 changed files with 8495 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Domain;
final class User
{
public function __construct(
public string $id,
public string $email,
public string $passwordHash,
public bool $isActive = true,
) {}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
class FileManager
{
private PDO $db;
private string $uploadDir;
public function __construct(PDO $db, string $uploadDir)
{
$this->db = $db;
$this->uploadDir = rtrim($uploadDir, '/');
}
public function upload(int $postId, array $file): ?int
{
if ($file['error'] !== UPLOAD_ERR_OK) {
return null;
}
$type = $this->guessType($file['type']);
$originalName = basename($file['name']);
$ext = pathinfo($originalName, PATHINFO_EXTENSION);
$filename = uniqid('file_') . '.' . $ext;
$destination = $this->uploadDir . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $destination)) {
return null;
}
$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,
'file_path' => $filename,
'original_name' => $originalName
]);
return (int)$this->db->lastInsertId();
}
public function getFilesForPost(int $postId): array
{
$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->execute(['id' => $fileId]);
$file = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$file) {
return false;
}
$fullPath = $this->uploadDir . '/' . $file['file_path'];
if (file_exists($fullPath)) {
unlink($fullPath);
}
$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';
return 'file';
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http;
final class Csrf
{
private const KEY = '_csrf';
public static function token(): string
{
$t = bin2hex(random_bytes(32));
$_SESSION[self::KEY] = $t;
return $t;
}
public static function validate(?string $token): bool
{
$ok = is_string($token) && isset($_SESSION[self::KEY]) && hash_equals($_SESSION[self::KEY], $token);
unset($_SESSION[self::KEY]); // onetime token
return $ok;
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure;
use PDO;
use PDOException;
use RuntimeException;
final class Database
{
private static ?PDO $pdo = null;
public static function get(): PDO
{
if (self::$pdo instanceof PDO) {
return self::$pdo;
}
$get = static function (string $k, ?string $default=null): ?string {
$v = getenv($k);
if ($v !== false && $v !== '') return (string)$v;
return $_ENV[$k] ?? $default;
};
$dsn = $get('DB_DSN');
$user = $get('DB_USER');
$pass = $get('DB_PASS');
if (!$dsn) {
$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 (!$dsn) {
throw new RuntimeException('DB_DSN manquant (ni DB_DSN ni DB_HOST/DB_PORT/DB_NAME).');
}
try {
$pdo = new PDO($dsn, (string)($user ?? ''), (string)($pass ?? ''), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return self::$pdo = $pdo;
} catch (PDOException $e) {
throw new RuntimeException('Connexion BDD échouée.', previous: $e);
}
}
/** @deprecated Utiliser Database::get() */
public static function pdo(): PDO
{
@trigger_error(__METHOD__.' est déprécié. Utiliser Database::get()', E_USER_DEPRECATED);
return self::get();
}
/** @deprecated Utiliser Database::get() */
public static function getPdo(): PDO
{
@trigger_error(__METHOD__.' est déprécié. Utiliser Database::get()', E_USER_DEPRECATED);
return self::get();
}
/** @deprecated Utiliser Database::get() */
public static function getInstance(): PDO
{
@trigger_error(__METHOD__.' est déprécié. Utiliser Database::get()', E_USER_DEPRECATED);
return self::get();
}
public static function transactional(callable $fn)
{
$pdo = self::get();
try {
$pdo->beginTransaction();
$ret = $fn($pdo);
$pdo->commit();
return $ret;
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure;
use PDO;
final class DbAdapter
{
public static function pdo(): PDO
{
if (!empty($GLOBALS['pdo']) && $GLOBALS['pdo'] instanceof PDO) {
return $GLOBALS['pdo'];
}
$dsn = getenv('DB_DSN') ?: ($_ENV['DB_DSN'] ?? null);
$user = getenv('DB_USER') ?: ($_ENV['DB_USER'] ?? null);
$pass = getenv('DB_PASS') ?: ($_ENV['DB_PASS'] ?? null);
if (!$dsn) {
$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 (!$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,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure;
final class Session
{
public static function startSecure(string $name): void
{
if (session_status() === PHP_SESSION_ACTIVE) return;
session_name($name);
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
]);
session_start();
// Verrouillage basique contre session hijacking
$key = '_sess_fingerprint';
$fp = hash('xxh3', ($_SERVER['REMOTE_ADDR'] ?? '') . '|' . ($_SERVER['HTTP_USER_AGENT'] ?? ''));
if (!isset($_SESSION[$key])) {
$_SESSION[$key] = $fp;
} elseif ($_SESSION[$key] !== $fp) {
session_regenerate_id(true);
$_SESSION = [];
$_SESSION[$key] = $fp;
}
}
public static function regenerate(): void
{
session_regenerate_id(true);
}
}
+1994
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
<?php
class PostManager
{
private PDO $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
public function getAll(): array
{
$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->execute(['id' => $id]);
$post = $stmt->fetch(PDO::FETCH_ASSOC);
return $post ?: null;
}
public function create(string $title, string $content, string $published_at): int
{
$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,
'published_at' => $published_at,
]);
return (int)$this->db->lastInsertId();
}
public function update(int $id, string $title, string $content, string $published_at, bool $published): bool
{
$stmt = $this->db->prepare("
UPDATE posts
SET title = :title,
content = :content,
created_at = :published_at,
is_published = :published,
updated_at = NOW()
WHERE id = :id
");
return $stmt->execute([
'id' => $id,
'title' => $title,
'content' => $content,
'published_at' => $published_at,
'published' => $published,
]);
}
public function delete(int $id): bool
{
$stmt = $this->db->prepare("DELETE FROM posts WHERE id = :id");
return $stmt->execute(['id' => $id]);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use PDO;
final class DictionaryRepository
{
public function __construct(private PDO $pdo) {}
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]);
$e = $st->fetch(PDO::FETCH_ASSOC);
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 {
$st = $this->pdo->prepare('SELECT * FROM dd_fields WHERE entity_id = :id ORDER BY ui_order NULLS LAST, id');
$st->execute([':id'=>$entityId]);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
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]);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
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]);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use PDO;
use App\Infrastructure\Database;
final class ProfileRepository
{
private PDO $pdo;
public function __construct(?PDO $pdo = null)
{
// 0) DI directe
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 (method_exists(Database::class, 'getPdo')) {
$try = Database::getPdo();
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 (is_object($db) && method_exists($db, 'pdo')) {
$try = $db->pdo();
if ($try instanceof PDO) { $this->pdo = $try; return; }
}
}
}
// 2) Fonction globale éventuelle
if (function_exists('db')) {
$try = db();
if ($try instanceof PDO) { $this->pdo = $try; return; }
}
// 3) Variable globale éventuelle
if (!empty($GLOBALS['pdo']) && $GLOBALS['pdo'] instanceof PDO) {
$this->pdo = $GLOBALS['pdo'];
return;
}
// 4) Fallback env/const : compose un DSN pgsql si nécessaire
$dsn = getenv('DB_DSN') ?: ($_ENV['DB_DSN'] ?? null);
$user = getenv('DB_USER') ?: ($_ENV['DB_USER'] ?? null);
$pass = getenv('DB_PASS') ?: ($_ENV['DB_PASS'] ?? null);
if (!$dsn) {
$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 ($dsn) {
$pdo = new PDO($dsn, (string)$user, (string)$pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$this->pdo = $pdo;
return;
}
throw new \RuntimeException('Impossible dobtenir un PDO (aucune source valide trouvée).');
}
public function all(?bool $onlyActive = null): array
{
$sql = 'SELECT * FROM profiles';
if ($onlyActive !== null) {
$sql .= ' WHERE is_active = :act';
}
$sql .= ' ORDER BY slug';
$stmt = $this->pdo->prepare($sql);
if ($onlyActive !== null) {
$stmt->bindValue(':act', $onlyActive, PDO::PARAM_BOOL);
}
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
public function findById(int $id): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM profiles WHERE id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public function findBySlug(string $slug): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM profiles WHERE slug = :slug');
$stmt->execute([':slug' => $slug]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public function create(string $slug, string $label, ?string $description, array $permissions, bool $isSystem, bool $isActive): int
{
$stmt = $this->pdo->prepare('INSERT INTO profiles(slug,label,description,permissions,is_system,is_active) VALUES(:slug,:label,:desc,CAST(:perms AS jsonb),:sys,:act) RETURNING id');
$stmt->execute([
':slug' => $slug,
':label' => $label,
':desc' => $description,
':perms' => json_encode($permissions, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES),
':sys' => $isSystem,
':act' => $isActive,
]);
return (int)$stmt->fetchColumn();
}
public function update(int $id, string $slug, string $label, ?string $description, array $permissions, bool $isSystem, bool $isActive): void
{
$stmt = $this->pdo->prepare('UPDATE profiles SET slug=:slug,label=:label,description=:desc,permissions=CAST(:perms AS jsonb),is_system=:sys,is_active=:act WHERE id=:id');
$stmt->execute([
':id' => $id,
':slug' => $slug,
':label' => $label,
':desc' => $description,
':perms' => json_encode($permissions, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES),
':sys' => $isSystem,
':act' => $isActive,
]);
}
public function delete(int $id): void
{
$stmt = $this->pdo->prepare('DELETE FROM profiles WHERE id=:id AND is_system = FALSE');
$stmt->execute([':id' => $id]);
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Domain\User;
use PDO;
final class UserRepository
{
public function __construct(private PDO $pdo) {}
/**
* Crée (si besoin) un utilisateur OIDC.
* - Idempotent par email : si existe, retourne l'id existant.
* - Génère un password_hash aléatoire inutilisable (compte OIDC).
*
* @return string ID (uuid) sous forme de chaîne
*/
public function createFromOidc(string $email): string
{
$email = strtolower(trim($email));
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Email OIDC invalide.');
}
// 1) Existe déjà ?
$st = $this->pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
$st->execute([':email' => $email]);
$id = $st->fetchColumn();
if ($id !== false && $id !== null) {
return (string)$id;
}
// 2) Création
// Génère un hash robuste sur une valeur aléatoire (aucune chance de connexion par mot de passe).
$randomSecret = bin2hex(random_bytes(32));
$randomHash = password_hash($randomSecret, PASSWORD_DEFAULT);
$sql = <<<SQL
INSERT INTO users (email, password_hash)
VALUES (:email, :hash)
RETURNING id
SQL;
try {
$st = $this->pdo->prepare($sql);
$st->execute([
':email' => $email,
':hash' => $randomHash,
]);
return (string)$st->fetchColumn();
} catch (PDOException $e) {
// Unique violation sur email (23505) → on relit lid (race condition)
if ($e->getCode() === '23505') {
$st = $this->pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
$st->execute([':email' => $email]);
$id = $st->fetchColumn();
if ($id !== false && $id !== null) {
return (string)$id;
}
}
throw $e;
}
}
private function nullIfEmpty(?string $v): ?string
{
$v = trim((string)$v);
return $v === '' ? null : $v;
}
public function findByEmail(string $email): ?User
{
$sql = 'SELECT id, email, password_hash, is_active FROM users WHERE email = :email LIMIT 1';
$st = $this->pdo->prepare($sql);
$st->execute([':email' => $email]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
$isActive = $this->toBool($row['is_active']);
return new User(
(string)$row['id'],
(string)$row['email'],
(string)$row['password_hash'],
$isActive
);
}
public function create(string $email, string $passwordHash): string
{
// PostgreSQL
$sql = 'INSERT INTO users (email, password_hash) VALUES (:email, :hash) RETURNING id';
$st = $this->pdo->prepare($sql);
$st->execute([':email' => $email, ':hash' => $passwordHash]);
return (string)$st->fetchColumn();
}
public function updatePassword(string $userId, string $newHash): void
{
$sql = <<<SQL
UPDATE users
SET password_hash = :h,
updated_at = NOW(),
password_changed_at = NOW()
WHERE id = :id
SQL;
$st = $this->pdo->prepare($sql);
$st->execute([':h' => $newHash, ':id' => $userId]);
}
/**
* Normalise un bool venant de PDO/pgsql ('t','f',1,0,true,false,'1','0','true','false')
*/
private function toBool(mixed $v): bool
{
if (is_bool($v)) {
return $v;
}
if (is_int($v)) {
return $v === 1;
}
if (is_string($v)) {
$v = strtolower($v);
return in_array($v, ['t', '1', 'true', 'on', 'yes'], true);
}
return (bool)$v;
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Repository\UserRepository;
final class AuthService
{
public function __construct(private UserRepository $users) {}
public function canAttempt(string $email, string $ip): bool
{
// backoff: 5 dernières tentatives/5 min
$sql = "select count(*)
from login_attempts
where ip = :ip
and attempted_at > now() - interval '5 minutes'
and success = false";
$st = \App\Infrastructure\Database::pdo()->prepare($sql);
$st->execute([':ip' => $ip]);
$fails = (int)$st->fetchColumn();
return $fails < 10; // à ajuster
}
public function login(string $email, string $password, string $ip): bool
{
$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();
if ($ok) {
\App\Infrastructure\Session::regenerate();
$_SESSION['uid'] = $user->id;
$_SESSION['email'] = $user->email;
}
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)
$pdo = \App\Infrastructure\Database::pdo();
$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;
// Vérifier lancien mot de passe
if (!password_verify($currentPassword, (string)$row['password_hash'])) {
return false;
}
// Politique minimale : longueur uniquement (espaces autorisés)
if (mb_strlen($newPassword) < 7) {
return false;
}
// (optionnel) interdire seulement le caractère NUL
if (strpos($newPassword, "\0") !== false) {
return false;
}
// Mettre à jour le hash
$newHash = password_hash($newPassword, PASSWORD_ARGON2ID);
(new \App\Repository\UserRepository())->updatePassword($row['id'], $newHash);
// (Optionnel) rotation session
\App\Infrastructure\Session::regenerate();
return true;
}
public function register(string $email, string $password): string
{
$hash = password_hash($password, PASSWORD_ARGON2ID);
return $this->users->create($email, $hash);
}
public static function requireAuth(): void
{
if (!isset($_SESSION['uid'])) {
header('Location: /login');
exit;
}
}
public static function logout(): void
{
$_SESSION = [];
session_destroy();
}
}
+219
View File
@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Infrastructure\Database;
use PDO;
use Throwable;
use DateTimeImmutable;
/**
* File d'attente SMTP.
*
* Responsabilités :
* - Enregistrer des emails à envoyer (enqueue)
* - Réserver/traiter par batch en évitant les collisions (SKIP LOCKED)
* - Gérer les retries avec backoff exponentiel + plafond
*
* Dépendances :
* - App\Infrastructure\Database (PDO)
* - App\Service\MailService (pour l'envoi réel + journal)
*/
final class MailQueue
{
/** Backoff exponentiel de base (en secondes) : 60s, 120s, 240s, ... */
private const BASE_BACKOFF_SECONDS = 60;
/** Délai max avant retry (plafond) */
private const MAX_BACKOFF_SECONDS = 86400; // 24h
/** Durée de "lease" (verrou doux) pendant le traitement */
private const LEASE_SECONDS = 120;
private PDO $pdo;
private MailService $mailService;
public function __construct(Database $db, MailService $mailService)
{
$this->pdo = $db->getConnection();
$this->mailService = $mailService;
}
/**
* Ajoute un email dans la file.
*
* @param array{delay?:int} $options delay en secondes avant éligibilité (facultatif)
*/
public function enqueue(string $to, string $subject, string $body, array $options = []): int
{
$delay = max(0, (int)($options['delay'] ?? 0));
$sql = <<<SQL
INSERT INTO mail_queue (to_email, subject, body, available_at)
VALUES (:to, :subject, :body, (NOW() AT TIME ZONE 'UTC') + (:delay || ' seconds')::interval)
RETURNING id
SQL;
$stmt = $this->pdo->prepare($sql);
$stmt->execute([
':to' => trim($to),
':subject' => $subject,
':body' => $body,
':delay' => $delay,
]);
/** @var int $id */
$id = (int) $stmt->fetchColumn();
return $id;
}
/**
* Traite la file en batch.
* - Récupère jusqu'à $max jobs "disponibles"
* - Pose un lease (locked_at) pour éviter les doublons de traitement
* - Envoie via MailService
* - Met à jour le statut (sent/failed) + planifie un retry si besoin
*
* @return array{processed:int, sent:int, failed:int, retried:int}
*/
public function process(int $max = 100): array
{
$max = max(1, min(1000, $max));
$jobs = $this->reserveBatch($max);
$processed = $sent = $failed = $retried = 0;
foreach ($jobs as $job) {
$processed++;
$ok = false;
try {
// Anti-abus est appliqué par MailService::send()
$ok = $this->mailService->send($job['to_email'], $job['subject'], $job['body']);
} catch (Throwable $e) {
// On traitera en "retry" sous ce catch
$this->appendError((int)$job['id'], 'unexpected: ' . $e->getMessage());
}
if ($ok) {
$this->markAsSent((int)$job['id']);
$sent++;
} else {
// incrémente attempts et programme retry/backoff
$didRetry = $this->scheduleRetry((int)$job['id'], (int)$job['attempts'] + 1);
if ($didRetry) {
$retried++;
} else {
$this->markAsFailed((int)$job['id']);
$failed++;
}
}
}
return compact('processed', 'sent', 'failed', 'retried');
}
/**
* Réserve jusqu'à $max jobs prêts (pending) en posant locked_at (lease).
* Utilise FOR UPDATE SKIP LOCKED pour le parallélisme côté SQL.
*
* @return array<int, array{id:int,to_email:string,subject:string,body:string,attempts:int}>
*/
private function reserveBatch(int $max): array
{
// 1) Sélection des candidats
$this->pdo->beginTransaction();
try {
$sql = <<<SQL
SELECT id, to_email, subject, body, attempts
FROM mail_queue
WHERE status = 'pending'
AND available_at <= (NOW() AT TIME ZONE 'UTC')
AND (locked_at IS NULL OR locked_at <= (NOW() AT TIME ZONE 'UTC') - INTERVAL ':lease seconds')
ORDER BY available_at ASC, id ASC
FOR UPDATE SKIP LOCKED
LIMIT :max
SQL;
// Interpolation sécurisée du LEASE_SECONDS pour l'INTERVAL
$sql = str_replace(':lease', (string) self::LEASE_SECONDS, $sql);
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':max', $max, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
if ($rows) {
// 2) Marquer locked_at + sending
$ids = array_map(static fn($r) => (int)$r['id'], $rows);
$in = implode(',', array_fill(0, count($ids), '?'));
$up = $this->pdo->prepare(
"UPDATE mail_queue
SET locked_at = (NOW() AT TIME ZONE 'UTC'),
status = 'sending'
WHERE id IN ($in)"
);
$up->execute($ids);
}
$this->pdo->commit();
return $rows;
} catch (Throwable $e) {
$this->pdo->rollBack();
return [];
}
}
private function markAsSent(int $id): void
{
$stmt = $this->pdo->prepare("UPDATE mail_queue SET status='sent', locked_at=NULL WHERE id=:id");
$stmt->execute([':id' => $id]);
}
private function markAsFailed(int $id): void
{
$stmt = $this->pdo->prepare("UPDATE mail_queue SET status='failed', locked_at=NULL WHERE id=:id");
$stmt->execute([':id' => $id]);
}
/**
* Définit la prochaine tentative avec backoff exponentiel plafonné.
* Retourne false si on considère que c'est "trop" et qu'il faut passer en failed.
*/
private function scheduleRetry(int $id, int $nextAttempt): bool
{
// Politique simple : jusqu'à 8 tentatives (≈ ~4h de backoff cumulé, puis plafond 24h)
if ($nextAttempt > 8) {
return false;
}
$delay = min(self::BASE_BACKOFF_SECONDS * (2 ** ($nextAttempt - 1)), self::MAX_BACKOFF_SECONDS);
$sql = <<<SQL
UPDATE mail_queue
SET attempts = :attempts,
status = 'pending',
locked_at = NULL,
available_at = (NOW() AT TIME ZONE 'UTC') + (:delay || ' seconds')::interval
WHERE id = :id
SQL;
$stmt = $this->pdo->prepare($sql);
$stmt->execute([
':attempts' => $nextAttempt,
':delay' => $delay,
':id' => $id,
]);
return true;
}
private function appendError(int $id, string $message): void
{
$sql = "UPDATE mail_queue SET last_error = COALESCE(last_error,'') || :e || E'\n' WHERE id = :id";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([
':e' => '[' . (new DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('c') . '] ' . $message,
':id' => $id,
]);
}
}
+291
View File
@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Infrastructure\Database;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception as MailException;
use PDO;
use DateInterval;
use DateTimeImmutable;
/**
* Service d'envoi d'emails via SMTP avec journalisation en base (journal_smtp)
*
* Dépendances :
* - PHPMailer\PHPMailer (SMTP)
* - App\Infrastructure\Database (retourne un PDO)
*
* Anti-abus (larges) :
* - Min. 5 minutes entre deux envois au même destinataire
* - Max. 5 envois au même destinataire sur 12 heures
* - Garde-fous globaux (désactivables) : max X envois / heure
*/
final class MailService
{
/** Règles anti-abus (ajustables) */
private const MIN_INTERVAL_BETWEEN_SENDS_SECONDS = 300; // 5 minutes
private const MAX_SENDS_PER_12H_PER_RECIPIENT = 5; // 5 en 12h
private const MAX_GLOBAL_PER_HOUR = 200; // global guardrail (0 pour désactiver)
private const MAX_SUBJECT_LEN = 255; // coupe en journal
private const MAX_BODY_LEN_FOR_LOG = 50000; // évite de gaver la base
private PDO $pdo;
private PHPMailer $mailer;
/** @var array<string, mixed> */
private array $smtpConfig;
/**
* @param Database $db Retourne un PDO connecté (PostgreSQL recommandé)
* @param array<string,mixed> $smtpConfig [
* 'host' => 'smtp.example.tld',
* 'port' => 587,
* 'username' => 'user',
* 'password' => 'pass',
* 'encryption' => 'tls'|'ssl'|null,
* 'from' => 'no-reply@example.tld',
* 'from_name' => 'Mon appli',
* 'reply_to' => 'contact@example.tld' (optionnel),
* 'reply_to_name' => 'Support' (optionnel),
* 'smtp_options' => [...] (optionnel, cf. PHPMailer::SMTPOptions)
* ]
*/
public function __construct(Database $db, array $smtpConfig)
{
$this->pdo = $db->getConnection();
$this->smtpConfig = $smtpConfig;
$this->mailer = new PHPMailer(true);
$this->configureMailer($this->mailer, $smtpConfig);
}
/**
* Envoie un mail et journalise l'opération dans journal_smtp
*/
public function send(string $to, string $subject, string $body): bool
{
$to = trim($to);
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
$this->log('blocked', $to, $subject, $body, 'invalid_email');
return false;
}
if (!$this->passesRateLimits($to)) {
$this->log('blocked', $to, $subject, $body, 'rate_limited');
return false;
}
try {
$this->mailer->clearAddresses();
$this->mailer->clearReplyTos();
$this->mailer->addAddress($to);
if (!empty($this->smtpConfig['reply_to'])) {
$this->mailer->addReplyTo(
(string) $this->smtpConfig['reply_to'],
(string)($this->smtpConfig['reply_to_name'] ?? '')
);
}
$this->mailer->Subject = $subject;
$this->mailer->Body = $body;
$this->mailer->AltBody = $this->buildAltBody($body);
$this->mailer->isHTML($this->looksLikeHtml($body));
$sent = $this->mailer->send();
$this->log(
$sent ? 'sent' : 'error',
$to,
$subject,
$body,
$sent ? null : 'phpmailer_send_returned_false',
$this->mailer->getLastMessageID() ?: null
);
return $sent;
} catch (MailException $e) {
$this->log('error', $to, $subject, $body, 'phpmailer_exception: ' . $e->getMessage());
return false;
} catch (\Throwable $e) {
$this->log('error', $to, $subject, $body, 'unexpected: ' . $e->getMessage());
return false;
}
}
/**
* Retourne les derniers envois (journal)
* @return array<int, array<string, mixed>>
*/
public function list(int $limit = 50): array
{
$limit = max(1, min(500, $limit));
$sql = <<<SQL
SELECT id, created_at, script, recipient, subject, status, error, smtp_host, smtp_user, message_id
FROM journal_smtp
ORDER BY created_at DESC, id DESC
LIMIT :limit
SQL;
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
/** @var array<int, array<string,mixed>> $rows */
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return $rows ?: [];
}
// ---------- Internals ----------
/**
* @param array<string,mixed> $cfg
*/
private function configureMailer(PHPMailer $m, array $cfg): void
{
$m->isSMTP();
$m->Host = (string) $cfg['host'];
$m->Port = (int) ($cfg['port'] ?? 587);
$m->SMTPAuth = true;
$m->Username = (string) $cfg['username'];
$m->Password = (string) $cfg['password'];
$m->SMTPSecure = $cfg['encryption'] ?? PHPMailer::ENCRYPTION_STARTTLS;
if (!empty($cfg['smtp_options']) && is_array($cfg['smtp_options'])) {
$m->SMTPOptions = $cfg['smtp_options'];
}
$from = (string) ($cfg['from'] ?? $cfg['username']);
$fromName = (string) ($cfg['from_name'] ?? '');
$m->setFrom($from, $fromName);
// Hygiène SMTP
$m->CharSet = 'UTF-8';
$m->Encoding = 'base64';
$m->Timeout = 15; // secondes
}
/** Règles anti-abus : per-recipient + garde-fous globaux */
private function passesRateLimits(string $recipient): bool
{
// 1) Min interval per destinataire (5 min)
$sql1 = <<<SQL
SELECT created_at
FROM journal_smtp
WHERE recipient = :r
AND status IN ('sent','error','blocked') -- tout envoi/essai compte
ORDER BY created_at DESC
LIMIT 1
SQL;
$stmt1 = $this->pdo->prepare($sql1);
$stmt1->execute([':r' => $recipient]);
$last = $stmt1->fetchColumn();
if ($last) {
$lastTs = (new DateTimeImmutable((string) $last))->getTimestamp();
$delta = time() - $lastTs;
if ($delta < self::MIN_INTERVAL_BETWEEN_SENDS_SECONDS) {
return false;
}
}
// 2) Max 5 en 12h par destinataire
$sql2 = <<<SQL
SELECT COUNT(*)::int
FROM journal_smtp
WHERE recipient = :r
AND created_at >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '12 hours'
AND status = 'sent'
SQL;
$stmt2 = $this->pdo->prepare($sql2);
$stmt2->execute([':r' => $recipient]);
$count12h = (int) $stmt2->fetchColumn();
if ($count12h >= self::MAX_SENDS_PER_12H_PER_RECIPIENT) {
return false;
}
// 3) Garde-fou global / heure (optionnel)
if (self::MAX_GLOBAL_PER_HOUR > 0) {
$sql3 = <<<SQL
SELECT COUNT(*)::int
FROM journal_smtp
WHERE created_at >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '1 hour'
AND status = 'sent'
SQL;
$stmt3 = $this->pdo->query($sql3);
$global1h = (int) $stmt3->fetchColumn();
if ($global1h >= self::MAX_GLOBAL_PER_HOUR) {
return false;
}
}
return true;
}
/**
* Journalise un envoi / tentative
*
* @param 'sent'|'error'|'blocked' $status
*/
private function log(
string $status,
string $recipient,
string $subject,
string $body,
?string $error = null,
?string $messageId = null
): void {
$script = $this->detectScript();
$host = (string) ($this->smtpConfig['host'] ?? '');
$user = (string) ($this->smtpConfig['username'] ?? '');
$subjectDb = mb_strimwidth($subject, 0, self::MAX_SUBJECT_LEN, '…', 'UTF-8');
$bodyDb = mb_strimwidth($body, 0, self::MAX_BODY_LEN_FOR_LOG, '…', 'UTF-8');
$sql = <<<SQL
INSERT INTO journal_smtp
(created_at, script, recipient, subject, body, status, error, smtp_host, smtp_user, message_id)
VALUES
(NOW() AT TIME ZONE 'UTC', :script, :recipient, :subject, :body, :status, :error, :smtp_host, :smtp_user, :message_id)
SQL;
$stmt = $this->pdo->prepare($sql);
$stmt->execute([
':script' => $script,
':recipient' => $recipient,
':subject' => $subjectDb,
':body' => $bodyDb,
':status' => $status,
':error' => $error,
':smtp_host' => $host,
':smtp_user' => $user,
':message_id' => $messageId,
]);
}
private function detectScript(): string
{
// Exemple : /public/pages/notifications/send.php
$script = $_SERVER['SCRIPT_NAME'] ?? ($_SERVER['PHP_SELF'] ?? '');
if ($script === '' && \PHP_SAPI === 'cli') {
$script = $_SERVER['argv'][0] ?? 'cli';
}
return (string) $script;
}
private function looksLikeHtml(string $body): bool
{
return (bool) preg_match('~<(?:html|body|div|p|span|table|br|h[1-6]|a)\b~i', $body);
}
private function buildAltBody(string $body): string
{
if (!$this->looksLikeHtml($body)) {
// Déjà texte brut
return $body;
}
// Version simplifiée : strip tags (on peut faire mieux selon besoins)
$text = trim((string) @html_entity_decode(strip_tags($body), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
return $text !== '' ? $text : '[Voir version HTML]';
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Repository\DictionaryRepository;
final class UiFormRenderer
{
public function __construct(private DictionaryRepository $dict) {}
public function renderControls(string $entityCode, array $values = []): string {
$e = $this->dict->getEntityByCode($entityCode);
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;
$name = $f['code'];
$label = $f['label'];
$help = $f['help_text'] ?? '';
$widget= $f['ui_widget'] ?? 'text';
$val = $values[$name] ?? '';
$html .= '<div class="mb-3">';
$html .= '<label class="form-label" for="'.$name.'">'.htmlspecialchars($label).'</label>';
if ($widget === 'select' && $f['enum_domain']) {
$opts = $this->dict->getEnum($f['enum_domain']);
$html .= '<select id="'.$name.'" name="'.$name.'" class="form-select">';
foreach ($opts as $opt) {
$sel = ($val !== '' && (string)$val === (string)$opt['code']) ? ' selected' : '';
$html .= '<option value="'.htmlspecialchars($opt['code']).'"'.$sel.'>'
. htmlspecialchars($opt['label']).'</option>';
}
$html .= '</select>';
} else {
$type = match ($widget) {
'email' => 'email',
'number' => 'number',
'date' => 'date',
'checkbox'=> 'checkbox',
default => 'text',
};
if ($type === 'checkbox') {
$chk = $val ? ' checked' : '';
$html .= '<input class="form-check-input" type="checkbox" id="'.$name.'" name="'.$name.'" value="1"'.$chk.'>';
} else {
$placeholder = $f['placeholder'] ?? '';
$html .= '<input class="form-control" type="'.$type.'" id="'.$name.'" name="'.$name.'"'
. ' value="'.htmlspecialchars((string)$val, ENT_QUOTES).'"'
. ($placeholder ? ' placeholder="'.htmlspecialchars($placeholder, ENT_QUOTES).'"' : '')
. '>';
}
}
if ($help) {
$html .= '<div class="form-text">'.htmlspecialchars($help).'</div>';
}
$html .= '</div>';
}
return $html;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Repository\DictionaryRepository;
final class Validator
{
public function __construct(private DictionaryRepository $dict) {}
public function validate(string $entityCode, array $payload): array {
$errors = [];
$e = $this->dict->getEntityByCode($entityCode);
if (!$e) return ['_global' => ['Entité inconnue']];
// Index les champs
$fields = [];
foreach ($e['fields'] as $f) {
$fields[$f['code']] = $f;
}
foreach ($e['rules'] as $r) {
$code = $r['field_code'];
$type = $r['rule_type'];
$val = $r['rule_value'];
$msg = $r['message'];
$v = $code ? ($payload[$code] ?? null) : null;
switch ($type) {
case 'required':
if ($code && ($v === null || $v === '')) $errors[$code][] = $msg;
break;
case 'regex':
if ($code && $v !== null && $v !== '' && !preg_match('#'.$val.'#u', (string)$v)) {
$errors[$code][] = $msg;
}
break;
case 'min':
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;
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;
}
break;
case 'unique':
// à implémenter côté repo (SELECT COUNT(*) FROM table WHERE col=:v AND id<>:id)
// Laisse un hook ici.
break;
case 'custom':
// point dextension si tu veux appeler une callable par nom
break;
}
}
return $errors;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
use Jumbojett\OpenIDConnectClient;
require_once BASE_PATH . '/vendor/autoload.php';
session_start();
function require_auth() {
if (!isset($_SESSION['user'])) {
// Redirige vers la page de login
header('Location: /auth/login.php');
exit;
}
}
function get_oidc_client(): OpenIDConnectClient {
$oidc = new OpenIDConnectClient(
'https://idp.a5l.fr/realms/master',
'varlog-client-id',
'varlog-client-secret'
);
$oidc->setRedirectURL('http://varlog.acegrp.lan/auth/callback.php');
$oidc->addScope('openid email profile');
return $oidc;
}
+16
View File
@@ -0,0 +1,16 @@
<?php
require_once BASE_PATH . '/config/config.php';
// Comment récupérer les valeurs de .env
$dsn = $_ENV['DB_DSN'];
$user = $_ENV['DB_USER'];
$pass = $_ENV['DB_PASS'];
// Se connecter
try {
$db = new PDO($dsn, $user, $pass);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
die('Connexion échouée : ' . $e->getMessage());
}
+8
View File
@@ -0,0 +1,8 @@
<?php
function vd($var, ...$moreVars) {
ob_start();
var_dump($var, ...$moreVars);
$output = ob_get_clean();
echo "<pre>$output</pre>";
}