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:
@@ -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
@@ -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
@@ -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,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+11
-9
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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
@@ -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 l’utilisateur (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 l’ancien mot de passe
|
||||
if (!password_verify($currentPassword, (string)$row['password_hash'])) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -8,7 +8,6 @@ use App\Infrastructure\Database;
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception as MailException;
|
||||
use PDO;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user