Initial commit
This commit is contained in:
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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]); // one‑time token
|
||||
return $ok;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 d’obtenir 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]);
|
||||
}
|
||||
}
|
||||
@@ -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 l’id (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;
|
||||
}
|
||||
}
|
||||
@@ -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 l’utilisateur (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 l’ancien 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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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]';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 d’extension si tu veux appeler une callable par nom
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
function vd($var, ...$moreVars) {
|
||||
ob_start();
|
||||
var_dump($var, ...$moreVars);
|
||||
$output = ob_get_clean();
|
||||
echo "<pre>$output</pre>";
|
||||
}
|
||||
Reference in New Issue
Block a user