fix #29 : envoyer le lien magique par email (envoyer_mail_smtp)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class CommentManager
|
||||
{
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un commentaire non vérifié.
|
||||
* Retourne ['token' => UUID (dans l'URL), 'code' => 6 chiffres (saisi par le visiteur)].
|
||||
*/
|
||||
public function submit(
|
||||
string $articleUuid,
|
||||
string $name,
|
||||
string $email,
|
||||
string $content,
|
||||
string $ip,
|
||||
string $ua
|
||||
): array {
|
||||
$bytes = random_bytes(16);
|
||||
$bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40);
|
||||
$bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80);
|
||||
$token = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
|
||||
$code = sprintf('%06d', random_int(100000, 999999));
|
||||
$this->pdo->prepare(
|
||||
'INSERT INTO comments
|
||||
(article_uuid, author_name, author_email, content, verify_token, verification_code, ip_address, user_agent)
|
||||
VALUES (:uuid, :name, :email, :content, :token, :code, :ip, :ua)'
|
||||
)->execute([
|
||||
':uuid' => $articleUuid,
|
||||
':name' => $name,
|
||||
':email' => $email,
|
||||
':content' => $content,
|
||||
':token' => $token,
|
||||
':code' => $code,
|
||||
':ip' => $ip,
|
||||
':ua' => substr($ua, 0, 512),
|
||||
]);
|
||||
return ['token' => $token, 'code' => $code];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie le code PIN pour un token donné.
|
||||
* Retourne l'article_uuid en cas de succès.
|
||||
* Retourne int > 0 : tentatives restantes (code incorrect).
|
||||
* Retourne 0 : commentaire supprimé après 3 tentatives échouées.
|
||||
* Retourne null : token introuvable ou expiré.
|
||||
*/
|
||||
public function verify(string $token, string $code): string|int|null
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
"SELECT id, verification_code, verify_attempts, article_uuid
|
||||
FROM comments
|
||||
WHERE verify_token = :token
|
||||
AND verified = FALSE
|
||||
AND created_at >= NOW() - INTERVAL '24 hours'
|
||||
LIMIT 1"
|
||||
);
|
||||
$st->execute([':token' => $token]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($row['verification_code'] !== $code) {
|
||||
$newAttempts = (int)$row['verify_attempts'] + 1;
|
||||
if ($newAttempts >= 3) {
|
||||
$this->pdo->prepare('DELETE FROM comments WHERE id = :id')
|
||||
->execute([':id' => $row['id']]);
|
||||
return 0;
|
||||
}
|
||||
$this->pdo->prepare('UPDATE comments SET verify_attempts = :a WHERE id = :id')
|
||||
->execute([':a' => $newAttempts, ':id' => $row['id']]);
|
||||
return 3 - $newAttempts;
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
'UPDATE comments
|
||||
SET verified = TRUE, published = TRUE, verification_code = NULL, verify_token = NULL
|
||||
WHERE id = :id'
|
||||
)->execute([':id' => $row['id']]);
|
||||
|
||||
return (string)$row['article_uuid'];
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
public function forArticle(string $uuid): array
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'SELECT id, author_name, content, created_at
|
||||
FROM comments
|
||||
WHERE article_uuid = :uuid AND verified = TRUE AND published = TRUE
|
||||
ORDER BY created_at ASC'
|
||||
);
|
||||
$st->execute([':uuid' => $uuid]);
|
||||
return $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function setPublished(int $id, bool $published): void
|
||||
{
|
||||
$this->pdo->prepare('UPDATE comments SET published = :pub WHERE id = :id')
|
||||
->execute([':pub' => $published, ':id' => $id]);
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->pdo->prepare('DELETE FROM comments WHERE id = :id')
|
||||
->execute([':id' => $id]);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
public function getById(int $id): ?array
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'SELECT id, article_uuid, author_name, author_email, content,
|
||||
verify_token, verification_code, verify_attempts, verified, published, created_at, ip_address
|
||||
FROM comments WHERE id = :id LIMIT 1'
|
||||
);
|
||||
$st->execute([':id' => $id]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/** @return array{all:int,pending:int,verified:int,hidden:int} */
|
||||
public function countsByStatus(): array
|
||||
{
|
||||
try {
|
||||
$row = $this->pdo->query(
|
||||
'SELECT
|
||||
COUNT(*) AS all,
|
||||
COUNT(*) FILTER (WHERE verified = FALSE) AS pending,
|
||||
COUNT(*) FILTER (WHERE verified = TRUE AND published = TRUE) AS verified,
|
||||
COUNT(*) FILTER (WHERE verified = TRUE AND published = FALSE) AS hidden
|
||||
FROM comments'
|
||||
)->fetch(PDO::FETCH_ASSOC);
|
||||
return [
|
||||
'all' => (int)($row['all'] ?? 0),
|
||||
'pending' => (int)($row['pending'] ?? 0),
|
||||
'verified' => (int)($row['verified'] ?? 0),
|
||||
'hidden' => (int)($row['hidden'] ?? 0),
|
||||
];
|
||||
} catch (\Throwable) {
|
||||
return ['all' => 0, 'pending' => 0, 'verified' => 0, 'hidden' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne tous les commentaires pour l'admin, avec statut email depuis journal_smtp.
|
||||
*
|
||||
* @param string $filterStatus '' = tous, 'pending' = non vérifié,
|
||||
* 'verified' = vérifié+publié, 'hidden' = vérifié+non publié
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allForAdmin(string $filterStatus = ''): array
|
||||
{
|
||||
$where = match($filterStatus) {
|
||||
'pending' => 'WHERE c.verified = FALSE',
|
||||
'verified' => 'WHERE c.verified = TRUE AND c.published = TRUE',
|
||||
'hidden' => 'WHERE c.verified = TRUE AND c.published = FALSE',
|
||||
default => '',
|
||||
};
|
||||
|
||||
$sqlWithJoin = "
|
||||
SELECT c.id, c.article_uuid, c.author_name, c.author_email, c.content,
|
||||
c.verification_code, c.verified, c.published, c.created_at, c.ip_address,
|
||||
j.status AS mail_status,
|
||||
j.error_message AS mail_error,
|
||||
j.sent_at AS mail_sent_at
|
||||
FROM comments c
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT status, error_message, sent_at
|
||||
FROM journal_smtp
|
||||
WHERE to_email = c.author_email
|
||||
AND created_at BETWEEN c.created_at - INTERVAL '1 minute'
|
||||
AND c.created_at + INTERVAL '10 minutes'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
) j ON TRUE
|
||||
$where
|
||||
ORDER BY c.created_at DESC
|
||||
";
|
||||
|
||||
try {
|
||||
return $this->pdo->query($sqlWithJoin)->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (\Throwable) {
|
||||
// journal_smtp absent ou jointure échouée : requête de secours sans jointure
|
||||
$sqlFallback = "
|
||||
SELECT c.id, c.article_uuid, c.author_name, c.author_email, c.content,
|
||||
c.verification_code, c.verified, c.published, c.created_at, c.ip_address,
|
||||
NULL AS mail_status, NULL AS mail_error, NULL AS mail_sent_at
|
||||
FROM comments c
|
||||
$where
|
||||
ORDER BY c.created_at DESC
|
||||
";
|
||||
return $this->pdo->query($sqlFallback)->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?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,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class FeedFetcher
|
||||
{
|
||||
private const MIN_TTL = 900; // 15 min
|
||||
private const MAX_TTL = 86400; // 24 h
|
||||
|
||||
public function __construct(private string $cacheDir)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les items du feed (depuis le cache si valide, sinon refetch).
|
||||
* @return array{items: array, feed_title: string, fetched_at: int, ttl: int}|null
|
||||
*/
|
||||
public function get(string $url): ?array
|
||||
{
|
||||
$cached = $this->cacheRead($url);
|
||||
if ($cached !== null && time() < (int)$cached['fetched_at'] + (int)$cached['ttl']) {
|
||||
return $cached;
|
||||
}
|
||||
return $this->fetch($url);
|
||||
}
|
||||
|
||||
/** Force le refetch et met le cache à jour. */
|
||||
public function fetch(string $url): ?array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 5,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_USERAGENT => 'varlog/1.0 FeedFetcher (+' . (defined('APP_URL') ? APP_URL : '') . ')',
|
||||
CURLOPT_HEADER => true,
|
||||
]);
|
||||
$raw = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$hSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($raw === false || !is_int($httpCode) || $httpCode < 200 || $httpCode >= 400) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$headers = substr((string)$raw, 0, $hSize);
|
||||
$body = substr((string)$raw, $hSize);
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$xml = simplexml_load_string($body);
|
||||
libxml_clear_errors();
|
||||
if ($xml === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$isAtom = ($xml->getName() === 'feed');
|
||||
$items = $isAtom ? $this->parseAtom($xml) : $this->parseRss($xml);
|
||||
$feedTitle = $isAtom
|
||||
? (string)($xml->title ?? '')
|
||||
: (string)($xml->channel->title ?? '');
|
||||
|
||||
$ttl = $this->resolveTtl($xml, $isAtom, $headers);
|
||||
|
||||
$data = [
|
||||
'feed_title' => $feedTitle,
|
||||
'fetched_at' => time(),
|
||||
'ttl' => $ttl,
|
||||
'items' => $items,
|
||||
];
|
||||
$this->cacheWrite($url, $data);
|
||||
return $data;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
private function parseRss(\SimpleXMLElement $xml): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($xml->channel->item ?? [] as $item) {
|
||||
$date = (string)($item->pubDate ?? '');
|
||||
$items[] = [
|
||||
'title' => trim((string)($item->title ?? '')),
|
||||
'url' => trim((string)($item->link ?? '')),
|
||||
'summary' => $this->cleanSummary((string)($item->description ?? '')),
|
||||
'date' => $date !== '' ? (int)strtotime($date) : 0,
|
||||
'author' => trim((string)($item->author ?? '')),
|
||||
];
|
||||
}
|
||||
return $this->sortItems($items);
|
||||
}
|
||||
|
||||
private function parseAtom(\SimpleXMLElement $xml): array
|
||||
{
|
||||
$ns = $xml->getNamespaces(true);
|
||||
$items = [];
|
||||
foreach ($xml->entry ?? [] as $entry) {
|
||||
$url = '';
|
||||
foreach ($entry->link ?? [] as $link) {
|
||||
$rel = (string)($link['rel'] ?? 'alternate');
|
||||
if ($rel === 'alternate' || $rel === '') {
|
||||
$url = (string)($link['href'] ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
$date = (string)($entry->published ?? $entry->updated ?? '');
|
||||
$author = (string)($entry->author->name ?? '');
|
||||
$summary = (string)($entry->summary ?? $entry->content ?? '');
|
||||
$items[] = [
|
||||
'title' => trim((string)($entry->title ?? '')),
|
||||
'url' => trim($url),
|
||||
'summary' => $this->cleanSummary($summary),
|
||||
'date' => $date !== '' ? (int)strtotime($date) : 0,
|
||||
'author' => trim($author),
|
||||
];
|
||||
}
|
||||
return $this->sortItems($items);
|
||||
}
|
||||
|
||||
private function cleanSummary(string $html): string
|
||||
{
|
||||
$text = strip_tags($html);
|
||||
$text = preg_replace('/\s+/', ' ', $text) ?? $text;
|
||||
return mb_strimwidth(trim($text), 0, 200, '…');
|
||||
}
|
||||
|
||||
private function sortItems(array $items): array
|
||||
{
|
||||
usort($items, static fn ($a, $b) => $b['date'] <=> $a['date']);
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function resolveTtl(\SimpleXMLElement $xml, bool $isAtom, string $headers): int
|
||||
{
|
||||
// 1. TTL déclaré dans le flux RSS (<ttl> en minutes)
|
||||
if (!$isAtom) {
|
||||
$rssttl = (int)($xml->channel->ttl ?? 0);
|
||||
if ($rssttl > 0) {
|
||||
return $this->clampTtl($rssttl * 60);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Cache-Control: max-age depuis les headers HTTP
|
||||
if (preg_match('/max-age=(\d+)/i', $headers, $m)) {
|
||||
return $this->clampTtl((int)$m[1]);
|
||||
}
|
||||
|
||||
// 3. Valeur par défaut : 1 heure
|
||||
return 3600;
|
||||
}
|
||||
|
||||
private function clampTtl(int $seconds): int
|
||||
{
|
||||
return max(self::MIN_TTL, min(self::MAX_TTL, $seconds));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
private function cachePath(string $url): string
|
||||
{
|
||||
return $this->cacheDir . '/' . md5($url) . '.json';
|
||||
}
|
||||
|
||||
private function cacheRead(string $url): ?array
|
||||
{
|
||||
$path = $this->cachePath($url);
|
||||
if (!file_exists($path)) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode((string)file_get_contents($path), true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
private function cacheWrite(string $url, array $data): void
|
||||
{
|
||||
if (!is_dir($this->cacheDir)) {
|
||||
mkdir($this->cacheDir, 0755, true);
|
||||
}
|
||||
file_put_contents(
|
||||
$this->cachePath($url),
|
||||
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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,24 @@
|
||||
<?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,94 @@
|
||||
<?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,36 @@
|
||||
<?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,42 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
+1828
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class RatingManager
|
||||
{
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
public function rate(string $uuid, string $email, int $rating): void
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'INSERT INTO article_ratings (article_uuid, user_email, rating)
|
||||
VALUES (:uuid, :email, :r)
|
||||
ON CONFLICT (article_uuid, user_email)
|
||||
DO UPDATE SET rating = :r, rated_at = NOW()'
|
||||
);
|
||||
$st->execute([':uuid' => $uuid, ':email' => strtolower($email), ':r' => $rating]);
|
||||
}
|
||||
|
||||
/** @return array{avg: float|null, count: int} */
|
||||
public function statsForArticle(string $uuid): array
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'SELECT ROUND(AVG(rating)::numeric, 1) as avg, COUNT(*) as count
|
||||
FROM article_ratings WHERE article_uuid = :uuid'
|
||||
);
|
||||
$st->execute([':uuid' => $uuid]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
return [
|
||||
'avg' => $row && $row['avg'] !== null ? (float)$row['avg'] : null,
|
||||
'count' => $row ? (int)$row['count'] : 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function userRating(string $uuid, string $email): ?int
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'SELECT rating FROM article_ratings WHERE article_uuid = :uuid AND user_email = :email'
|
||||
);
|
||||
$st->execute([':uuid' => $uuid, ':email' => strtolower($email)]);
|
||||
$v = $st->fetchColumn();
|
||||
return $v !== false ? (int)$v : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class ReactionManager
|
||||
{
|
||||
public const TYPES = ['useful', 'important', 'interesting'];
|
||||
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/** Ajoute ou retire une réaction. Retourne true si ajoutée, false si retirée. */
|
||||
public function toggle(string $uuid, string $type, string $visitorHash): bool
|
||||
{
|
||||
if (!in_array($type, self::TYPES, true)) {
|
||||
return false;
|
||||
}
|
||||
$st = $this->pdo->prepare(
|
||||
'SELECT id FROM article_reactions
|
||||
WHERE article_uuid = :uuid AND reaction_type = :type AND visitor_hash = :hash'
|
||||
);
|
||||
$st->execute([':uuid' => $uuid, ':type' => $type, ':hash' => $visitorHash]);
|
||||
if ($st->fetchColumn() !== false) {
|
||||
$this->pdo->prepare(
|
||||
'DELETE FROM article_reactions
|
||||
WHERE article_uuid = :uuid AND reaction_type = :type AND visitor_hash = :hash'
|
||||
)->execute([':uuid' => $uuid, ':type' => $type, ':hash' => $visitorHash]);
|
||||
return false;
|
||||
}
|
||||
$this->pdo->prepare(
|
||||
'INSERT INTO article_reactions (article_uuid, reaction_type, visitor_hash)
|
||||
VALUES (:uuid, :type, :hash) ON CONFLICT DO NOTHING'
|
||||
)->execute([':uuid' => $uuid, ':type' => $type, ':hash' => $visitorHash]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, int> */
|
||||
public function statsForArticle(string $uuid): array
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'SELECT reaction_type, COUNT(*) AS cnt
|
||||
FROM article_reactions WHERE article_uuid = :uuid GROUP BY reaction_type'
|
||||
);
|
||||
$st->execute([':uuid' => $uuid]);
|
||||
$stats = array_fill_keys(self::TYPES, 0);
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$stats[$row['reaction_type']] = (int) $row['cnt'];
|
||||
}
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public function visitorReactions(string $uuid, string $visitorHash): array
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'SELECT reaction_type FROM article_reactions
|
||||
WHERE article_uuid = :uuid AND visitor_hash = :hash'
|
||||
);
|
||||
$st->execute([':uuid' => $uuid, ':hash' => $visitorHash]);
|
||||
return array_column($st->fetchAll(PDO::FETCH_ASSOC), 'reaction_type');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?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,158 @@
|
||||
<?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,129 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
|
||||
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,276 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Moteur de recherche plein-texte en mémoire.
|
||||
*
|
||||
* Algorithme : scoring multi-champ avec correspondance exacte, sous-chaîne et
|
||||
* similarité trigramme. Logique AND : tous les tokens de la requête doivent
|
||||
* matcher quelque part pour qu'un article soit retourné.
|
||||
*
|
||||
* Score par token :
|
||||
* 1.0 → mot identique (ex. "Linky" = "Linky")
|
||||
* 0.75 → sous-chaîne (ex. "voiture" ⊂ "voitures")
|
||||
* 0–0.5 → similarité trigramme (ex. "linki" ≈ "linky")
|
||||
*
|
||||
* Poids par champ : titre × 6, catégorie × 3, contenu × 1.
|
||||
*/
|
||||
class SearchEngine
|
||||
{
|
||||
private const TITLE_WEIGHT = 6.0;
|
||||
private const CAT_WEIGHT = 3.0;
|
||||
private const CONTENT_WEIGHT = 1.0;
|
||||
private const FUZZY_FLOOR = 0.55; // seuil min. de similarité trigramme
|
||||
private const SNIPPET_LEN = 220;
|
||||
|
||||
/**
|
||||
* @param array<array> $articles Liste brute d'articles (depuis ArticleManager)
|
||||
* @return array<array{article: array, score: float, snippet: string}>
|
||||
*/
|
||||
public function search(string $query, array $articles): array
|
||||
{
|
||||
$tokens = $this->tokenize($query);
|
||||
if (empty($tokens)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
foreach ($articles as $article) {
|
||||
// 'plain' est pré-calculé dans search_index.json, sinon on stripe à la volée
|
||||
$plain = $article['plain'] ?? $this->stripMarkdown($article['content'] ?? '');
|
||||
$tWords = $this->tokenize($article['title'] ?? '');
|
||||
$cWords = $this->tokenize($article['category'] ?? '');
|
||||
$pWords = $this->tokenize($plain);
|
||||
|
||||
$score = $this->scoreArticle($tokens, $tWords, $cWords, $pWords);
|
||||
if ($score > 0.0) {
|
||||
$results[] = [
|
||||
'article' => $article,
|
||||
'score' => $score,
|
||||
'snippet' => $this->buildSnippet($plain, $tokens),
|
||||
'tier' => $this->determineTier($tokens, $tWords, $cWords, $pWords),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($results, static function (array $a, array $b): int {
|
||||
if ($a['tier'] !== $b['tier']) {
|
||||
return $a['tier'] <=> $b['tier'];
|
||||
}
|
||||
return $b['score'] <=> $a['score'];
|
||||
});
|
||||
return $results;
|
||||
}
|
||||
|
||||
// ─── Scoring ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function scoreArticle(array $tokens, array $tWords, array $cWords, array $pWords): float
|
||||
{
|
||||
$total = 0.0;
|
||||
foreach ($tokens as $token) {
|
||||
$ts = $this->tokenScore($token, $tWords) * self::TITLE_WEIGHT
|
||||
+ $this->tokenScore($token, $cWords) * self::CAT_WEIGHT
|
||||
+ $this->tokenScore($token, $pWords) * self::CONTENT_WEIGHT;
|
||||
|
||||
if ($ts <= 0.0) {
|
||||
return 0.0; // AND strict : token introuvable → article exclu
|
||||
}
|
||||
$total += $ts;
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classe un résultat en tier :
|
||||
* 1 → tous les tokens trouvés exactement dans le titre
|
||||
* 2 → tous les tokens trouvés exactement dans titre, catégorie ou contenu
|
||||
* 3 → au moins un token uniquement en correspondance floue
|
||||
*/
|
||||
private function determineTier(array $tokens, array $tWords, array $cWords, array $pWords): int
|
||||
{
|
||||
$inTitle = true;
|
||||
foreach ($tokens as $token) {
|
||||
if ($this->tokenScore($token, $tWords, false) < 0.75) {
|
||||
$inTitle = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($inTitle) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$allWords = array_merge($tWords, $cWords, $pWords);
|
||||
foreach ($tokens as $token) {
|
||||
if ($this->tokenScore($token, $allWords, false) < 0.75) {
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne un score 0–1 mesurant à quel point $token correspond
|
||||
* au meilleur mot de la liste $words.
|
||||
*/
|
||||
private function tokenScore(string $token, array $words, bool $fuzzy = true): float
|
||||
{
|
||||
$best = 0.0;
|
||||
$tLen = mb_strlen($token);
|
||||
foreach ($words as $w) {
|
||||
if ($w === $token) {
|
||||
return 1.0; // exact
|
||||
}
|
||||
if ($tLen >= 3 && (str_contains($w, $token) || str_contains($token, $w))) {
|
||||
$best = max($best, 0.75); // sous-chaîne (pluriels, conjugaisons)
|
||||
}
|
||||
if ($fuzzy && $tLen >= 4) {
|
||||
$sim = $this->trigramSimilarity($token, $w);
|
||||
if ($sim >= self::FUZZY_FLOOR) {
|
||||
$best = max($best, $sim * 0.55); // fuzzy (fautes de frappe)
|
||||
}
|
||||
}
|
||||
}
|
||||
return $best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule un score cumulé (OR) pour plusieurs tokens sur un ensemble d'articles.
|
||||
* Tokenise chaque article une seule fois — évite N tokenisations avec N appels à search().
|
||||
* Le fuzzy (trigramme) est désactivé sur le contenu (poids 1.0) pour des raisons de perf.
|
||||
*
|
||||
* @param string[] $tokens Mots normalisés (lowercase, sans accents)
|
||||
* @param array[] $articles Articles (doivent avoir uuid, title, category, plain|content)
|
||||
* @return array{0: array<string, float>, 1: array<string, array>}
|
||||
*/
|
||||
public function scorePool(array $tokens, array $articles): array
|
||||
{
|
||||
if (empty($tokens) || empty($articles)) {
|
||||
return [[], []];
|
||||
}
|
||||
|
||||
$scoreMap = [];
|
||||
$articleMap = [];
|
||||
|
||||
foreach ($articles as $article) {
|
||||
$plain = $article['plain'] ?? $this->stripMarkdown($article['content'] ?? '');
|
||||
$tWords = $this->tokenize($article['title'] ?? '');
|
||||
$cWords = $this->tokenize($article['category'] ?? '');
|
||||
$pWords = $this->tokenize($plain);
|
||||
|
||||
$total = 0.0;
|
||||
foreach ($tokens as $token) {
|
||||
$ts = $this->tokenScore($token, $tWords, true) * self::TITLE_WEIGHT
|
||||
+ $this->tokenScore($token, $cWords, true) * self::CAT_WEIGHT
|
||||
+ $this->tokenScore($token, $pWords, false) * self::CONTENT_WEIGHT;
|
||||
$total += $ts;
|
||||
}
|
||||
|
||||
if ($total > 0.0) {
|
||||
$uuid = $article['uuid'];
|
||||
$scoreMap[$uuid] = $total;
|
||||
$articleMap[$uuid] = $article;
|
||||
}
|
||||
}
|
||||
|
||||
return [$scoreMap, $articleMap];
|
||||
}
|
||||
|
||||
// ─── Trigramme ───────────────────────────────────────────────────────────
|
||||
|
||||
private function trigramSimilarity(string $a, string $b): float
|
||||
{
|
||||
$tA = $this->trigrams($a);
|
||||
$tB = $this->trigrams($b);
|
||||
if (empty($tA) || empty($tB)) {
|
||||
return 0.0;
|
||||
}
|
||||
$common = count(array_intersect($tA, $tB));
|
||||
return $common / max(count($tA), count($tB));
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
private function trigrams(string $s): array
|
||||
{
|
||||
$out = [];
|
||||
$len = mb_strlen($s);
|
||||
for ($i = 0; $i + 2 < $len; $i++) {
|
||||
$out[] = mb_substr($s, $i, 3);
|
||||
}
|
||||
return array_unique($out);
|
||||
}
|
||||
|
||||
// ─── Snippet avec surbrillance ────────────────────────────────────────────
|
||||
|
||||
private function buildSnippet(string $text, array $tokens): string
|
||||
{
|
||||
$norm = $this->normalize($text);
|
||||
$pos = 0;
|
||||
foreach ($tokens as $token) {
|
||||
$p = mb_strpos($norm, $token);
|
||||
if ($p !== false) {
|
||||
$pos = max(0, $p - 60);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$raw = mb_substr($text, $pos, self::SNIPPET_LEN);
|
||||
if ($pos > 0) {
|
||||
$raw = '…' . ltrim($raw);
|
||||
}
|
||||
if ($pos + self::SNIPPET_LEN < mb_strlen($text)) {
|
||||
$raw .= '…';
|
||||
}
|
||||
|
||||
$escaped = htmlspecialchars($raw, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
|
||||
// Surbrillance : on cherche les tokens dans le texte HTML-échappé
|
||||
foreach ($tokens as $token) {
|
||||
$escaped = (string) preg_replace(
|
||||
'/(' . preg_quote(htmlspecialchars($token, ENT_QUOTES, 'UTF-8'), '/') . ')/iu',
|
||||
'<mark>$1</mark>',
|
||||
$escaped
|
||||
);
|
||||
}
|
||||
|
||||
return $escaped;
|
||||
}
|
||||
|
||||
// ─── Helpers texte ────────────────────────────────────────────────────────
|
||||
|
||||
/** Découpe en mots normalisés (min. 2 caractères). */
|
||||
private function tokenize(string $text): array
|
||||
{
|
||||
$norm = $this->normalize($text);
|
||||
$words = preg_split('/\W+/u', $norm, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
return array_values(array_filter($words, fn ($w) => mb_strlen($w) >= 2));
|
||||
}
|
||||
|
||||
/** Minuscule + translittération des accents français. */
|
||||
private function normalize(string $text): string
|
||||
{
|
||||
$text = mb_strtolower($text, 'UTF-8');
|
||||
return strtr($text, [
|
||||
'à' => 'a', 'â' => 'a', 'ä' => 'a',
|
||||
'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
|
||||
'î' => 'i', 'ï' => 'i',
|
||||
'ô' => 'o', 'ö' => 'o',
|
||||
'ù' => 'u', 'û' => 'u', 'ü' => 'u',
|
||||
'ç' => 'c', 'æ' => 'ae', 'œ' => 'oe', 'ñ' => 'n',
|
||||
]);
|
||||
}
|
||||
|
||||
/** Retire la syntaxe Markdown pour extraire le texte brut. */
|
||||
private function stripMarkdown(string $md): string
|
||||
{
|
||||
$t = preg_replace('/!\[[^\]]*\]\([^)]+\)/', '', $md) ?? $md; // images
|
||||
$t = preg_replace('/\[([^\]]+)\]\([^)]+\)/', '$1', $t) ?? $t; // liens
|
||||
$t = preg_replace('/```[\s\S]*?```/', '', $t) ?? $t; // blocs code
|
||||
$t = preg_replace('/`[^`]+`/', '', $t) ?? $t; // code inline
|
||||
$t = preg_replace('/^#{1,6}\s*/m', '', $t) ?? $t; // titres
|
||||
$t = preg_replace('/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/', '$1', $t) ?? $t; // gras/italique
|
||||
$t = preg_replace('/^\s*[-*+|>]\s*/m', '', $t) ?? $t; // listes, citations, tableaux
|
||||
$t = preg_replace('/\n{2,}/', ' ', $t) ?? $t;
|
||||
return trim($t);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class SearchLogParser
|
||||
{
|
||||
private string $logDir;
|
||||
private string $vhostBase;
|
||||
private string $cacheFile;
|
||||
private int $cacheTtl;
|
||||
|
||||
public function __construct(
|
||||
string $logDir = '/var/log/apache2',
|
||||
string $vhostBase = 'lan.acegrp.varlog-access.log',
|
||||
string $cacheFile = '',
|
||||
int $cacheTtl = 600
|
||||
) {
|
||||
$this->logDir = rtrim($logDir, '/');
|
||||
$this->vhostBase = $vhostBase;
|
||||
$this->cacheFile = $cacheFile !== ''
|
||||
? $cacheFile
|
||||
: dirname(__DIR__) . '/_cache/search_terms.json';
|
||||
$this->cacheTtl = $cacheTtl;
|
||||
}
|
||||
|
||||
/** @return array<string,int> terme => nombre d'occurrences, trié desc */
|
||||
public function topTerms(int $limit = 100): array
|
||||
{
|
||||
if ($this->cacheValid()) {
|
||||
$data = json_decode((string) file_get_contents($this->cacheFile), true);
|
||||
if (is_array($data)) {
|
||||
return array_slice($data, 0, $limit, true);
|
||||
}
|
||||
}
|
||||
|
||||
$counts = [];
|
||||
foreach ($this->logFiles() as $file) {
|
||||
$this->parseFile($file, $counts);
|
||||
}
|
||||
arsort($counts);
|
||||
|
||||
@mkdir(dirname($this->cacheFile), 0755, true);
|
||||
file_put_contents($this->cacheFile, json_encode($counts, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return array_slice($counts, 0, $limit, true);
|
||||
}
|
||||
|
||||
public function isReadable(): bool
|
||||
{
|
||||
$f = $this->logDir . '/' . $this->vhostBase;
|
||||
return file_exists($f) && is_readable($f);
|
||||
}
|
||||
|
||||
private function cacheValid(): bool
|
||||
{
|
||||
return file_exists($this->cacheFile)
|
||||
&& (time() - filemtime($this->cacheFile)) < $this->cacheTtl;
|
||||
}
|
||||
|
||||
/** @return list<array{path:string,gz:bool}> */
|
||||
private function logFiles(): array
|
||||
{
|
||||
$base = $this->logDir . '/' . $this->vhostBase;
|
||||
$files = [];
|
||||
|
||||
if (file_exists($base) && is_readable($base)) {
|
||||
$files[] = ['path' => $base, 'gz' => false];
|
||||
}
|
||||
|
||||
for ($i = 1; $i <= 14; $i++) {
|
||||
$plain = $base . '.' . $i;
|
||||
$gz = $plain . '.gz';
|
||||
if (file_exists($plain) && is_readable($plain)) {
|
||||
$files[] = ['path' => $plain, 'gz' => false];
|
||||
} elseif (file_exists($gz) && is_readable($gz)) {
|
||||
$files[] = ['path' => $gz, 'gz' => true];
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
private function parseFile(array $file, array &$counts): void
|
||||
{
|
||||
if ($file['gz']) {
|
||||
$h = @gzopen($file['path'], 'rb');
|
||||
if (!$h) {
|
||||
return;
|
||||
}
|
||||
while (!gzeof($h)) {
|
||||
$line = gzgets($h, 8192);
|
||||
if ($line !== false) {
|
||||
$this->parseLine($line, $counts);
|
||||
}
|
||||
}
|
||||
gzclose($h);
|
||||
} else {
|
||||
$h = @fopen($file['path'], 'rb');
|
||||
if (!$h) {
|
||||
return;
|
||||
}
|
||||
while (($line = fgets($h)) !== false) {
|
||||
$this->parseLine($line, $counts);
|
||||
}
|
||||
fclose($h);
|
||||
}
|
||||
}
|
||||
|
||||
private function parseLine(string $line, array &$counts): void
|
||||
{
|
||||
if (!str_contains($line, 'GET /search?')) {
|
||||
return;
|
||||
}
|
||||
if (!preg_match('/"GET \/search\?([^"]*) HTTP\//', $line, $m)) {
|
||||
return;
|
||||
}
|
||||
|
||||
parse_str($m[1], $params);
|
||||
$q = trim(urldecode($params['q'] ?? ''));
|
||||
|
||||
if ($q === '' || mb_strlen($q) > 200) {
|
||||
return;
|
||||
}
|
||||
$q = mb_strtolower($q);
|
||||
$counts[$q] = ($counts[$q] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?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(\App\Infrastructure\Database::get()))->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,218 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
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(\PDO $pdo, MailService $mailService)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$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,287 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception as MailException;
|
||||
use PDO;
|
||||
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 \PDO $pdo 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(\PDO $pdo, array $smtpConfig)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$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
|
||||
$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,76 @@
|
||||
<?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,79 @@
|
||||
<?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,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function siteSettingsPath(): string
|
||||
{
|
||||
return BASE_PATH . '/data/site_settings.json';
|
||||
}
|
||||
|
||||
function siteSettings(): array
|
||||
{
|
||||
static $settings = null;
|
||||
if ($settings !== null) {
|
||||
return $settings;
|
||||
}
|
||||
$settings = [];
|
||||
$path = siteSettingsPath();
|
||||
if (is_file($path)) {
|
||||
$data = @json_decode((string)file_get_contents($path), true);
|
||||
if (is_array($data)) {
|
||||
$settings = $data;
|
||||
}
|
||||
}
|
||||
return $settings;
|
||||
}
|
||||
|
||||
function siteTitle(): string
|
||||
{
|
||||
return siteSettings()['site_title'] ?? 'varlog';
|
||||
}
|
||||
|
||||
function siteClaim(): string
|
||||
{
|
||||
return siteSettings()['site_claim'] ?? 'journal de Cédrix · informatique, hack & loisirs';
|
||||
}
|
||||
|
||||
function siteLang(): string
|
||||
{
|
||||
return siteSettings()['site_lang'] ?? 'fr-FR';
|
||||
}
|
||||
|
||||
function siteLangOgLocale(): string
|
||||
{
|
||||
return str_replace('-', '_', siteLang());
|
||||
}
|
||||
|
||||
function postsPerPage(): int
|
||||
{
|
||||
return max(1, (int)(siteSettings()['posts_per_page'] ?? 12));
|
||||
}
|
||||
|
||||
function siteLicenseLabel(): string
|
||||
{
|
||||
return siteSettings()['site_license_label'] ?? 'CC BY 4.0';
|
||||
}
|
||||
|
||||
function siteLicenseUrl(): string
|
||||
{
|
||||
return siteSettings()['site_license_url'] ?? 'https://creativecommons.org/licenses/by/4.0/';
|
||||
}
|
||||
|
||||
function saveSiteSettings(array $data): void
|
||||
{
|
||||
$current = siteSettings();
|
||||
$stringKeys = ['site_title', 'site_claim', 'site_lang', 'site_license_label', 'site_license_url'];
|
||||
foreach ($stringKeys as $key) {
|
||||
if (array_key_exists($key, $data)) {
|
||||
$val = trim((string)$data[$key]);
|
||||
if ($val !== '') {
|
||||
$current[$key] = $val;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (array_key_exists('posts_per_page', $data)) {
|
||||
$val = (int)$data['posts_per_page'];
|
||||
if ($val > 0) {
|
||||
$current['posts_per_page'] = $val;
|
||||
}
|
||||
}
|
||||
file_put_contents(
|
||||
siteSettingsPath(),
|
||||
json_encode($current, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function smtpSettingsPath(): string
|
||||
{
|
||||
return BASE_PATH . '/data/smtp_settings.json';
|
||||
}
|
||||
|
||||
function smtpSettings(): array
|
||||
{
|
||||
static $s = null;
|
||||
if ($s !== null) {
|
||||
return $s;
|
||||
}
|
||||
$path = smtpSettingsPath();
|
||||
if (is_file($path)) {
|
||||
$data = @json_decode((string)file_get_contents($path), true);
|
||||
if (is_array($data)) {
|
||||
$s = $data;
|
||||
return $s;
|
||||
}
|
||||
}
|
||||
$s = [];
|
||||
return $s;
|
||||
}
|
||||
|
||||
function smtpCfg(string $key, string $envKey, string $default = ''): string
|
||||
{
|
||||
$s = smtpSettings();
|
||||
if (isset($s[$key]) && (string)$s[$key] !== '') {
|
||||
return (string)$s[$key];
|
||||
}
|
||||
$v = $_ENV[$envKey] ?? getenv($envKey);
|
||||
return ($v !== false && $v !== '') ? (string)$v : $default;
|
||||
}
|
||||
|
||||
function saveSmtpSettings(array $data): void
|
||||
{
|
||||
$current = smtpSettings();
|
||||
foreach (['host', 'port', 'secure', 'user', 'from', 'from_name'] as $key) {
|
||||
if (array_key_exists($key, $data)) {
|
||||
$current[$key] = trim((string)$data[$key]);
|
||||
}
|
||||
}
|
||||
if (!empty($data['pass']) && trim((string)$data['pass']) !== '') {
|
||||
$current['pass'] = trim((string)$data['pass']);
|
||||
}
|
||||
file_put_contents(
|
||||
smtpSettingsPath(),
|
||||
json_encode($current, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class TagSuggester
|
||||
{
|
||||
// Mots courants capitalisés en début de phrase française — ignorés sauf si connus
|
||||
private const STOP = [
|
||||
'Le','La','Les','Un','Une','Des','Du','De','Au','Aux',
|
||||
'En','Et','Ou','Si','Car','Mais','Donc','Or','Ni','Que','Qui','Quoi','Dont','Où',
|
||||
'Ce','Cette','Ces','Mon','Ma','Mes','Son','Sa','Ses','Notre','Votre','Leur','Leurs',
|
||||
'Il','Elle','Ils','Elles','Je','Tu','Nous','Vous','On','Se','Lui','Eux',
|
||||
'Tout','Tous','Toute','Toutes','Très','Plus','Moins','Aussi','Même','Bien',
|
||||
'Dans','Sur','Sous','Avec','Sans','Pour','Par','Vers','Depuis','Pendant',
|
||||
'Comme','Puis','Après','Avant','Quand','Alors','Ainsi','Ici','Là',
|
||||
'Voici','Voilà','Ceci','Cela','Ça',
|
||||
'Une','Deux','Trois','Quatre','Cinq','Six','Sept','Huit','Neuf','Dix',
|
||||
'Enfin','Ensuite','Sinon','Donc','Cependant','Toutefois','Néanmoins',
|
||||
'Cette','Chaque','Aucun','Aucune','Plusieurs',
|
||||
];
|
||||
|
||||
/**
|
||||
* Analyse le contenu markdown et retourne des candidats pour un type de tag.
|
||||
*
|
||||
* @param string $markdown Contenu brut de l'article
|
||||
* @param string[] $existingValues Valeurs déjà utilisées dans d'autres articles (pour ce type)
|
||||
* @param string[] $currentTags Tags déjà assignés à CET article pour ce type
|
||||
* @return array<string, array{count:int, known:bool, current:bool, groups:list<string>}>
|
||||
*/
|
||||
public function suggest(
|
||||
string $markdown,
|
||||
array $existingValues = [],
|
||||
array $currentTags = []
|
||||
): array {
|
||||
$plain = $this->stripMarkdown($markdown);
|
||||
|
||||
$candidates = [];
|
||||
|
||||
// ── 1. Valeurs connues dans le système ──────────────────────────────
|
||||
foreach ($existingValues as $val) {
|
||||
$cnt = $this->countOccurrences($plain, $val);
|
||||
if ($cnt > 0) {
|
||||
$this->add($candidates, $val, $cnt, true, in_array($val, $currentTags, true), 'known');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Abréviations : 2-7 majuscules consécutives (avec chiffres/tirets) ──
|
||||
preg_match_all('/\b([A-Z][A-Z0-9]{1,6}(?:-[A-Z0-9]+)?)\b/', $plain, $m);
|
||||
foreach (array_count_values($m[1]) as $abbr => $cnt) {
|
||||
if (!isset($candidates[$abbr])) {
|
||||
$this->add($candidates, $abbr, $cnt, false, in_array($abbr, $currentTags, true), 'abbrev');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. CamelCase / PascalCase (ex: Zigbee2MQTT, OpenWrt, HomeAssistant) ──
|
||||
preg_match_all('/\b([A-Z][a-z]+(?:[A-Z0-9][a-z0-9]*)+)\b/', $plain, $m);
|
||||
foreach (array_count_values($m[1]) as $word => $cnt) {
|
||||
if (!isset($candidates[$word])) {
|
||||
$this->add($candidates, $word, $cnt, false, in_array($word, $currentTags, true), 'camel');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Noms propres multi-mots (ex: "Home Assistant", "Raspberry Pi") ──
|
||||
preg_match_all(
|
||||
'/\b([A-ZÀÂÄÉÈÊËÎÏÔÙÛÜŸÇ][a-zàâäéèêëîïôùûüÿç]{1,}(?:\s+[A-ZÀÂÄÉÈÊËÎÏÔÙÛÜŸÇ][a-zàâäéèêëîïôùûüÿç]{1,})+)\b/u',
|
||||
$plain,
|
||||
$m
|
||||
);
|
||||
foreach (array_count_values($m[1]) as $phrase => $cnt) {
|
||||
if (!isset($candidates[$phrase]) && !$this->isStop($phrase)) {
|
||||
$this->add($candidates, $phrase, $cnt, false, in_array($phrase, $currentTags, true), 'proper');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Mots capitalisés simples présents ≥ 2 fois ───────────────────
|
||||
preg_match_all('/\b([A-ZÀÂÄÉÈÊËÎÏÔÙÛÜŸÇ][a-zàâäéèêëîïôùûüÿç]{2,})\b/u', $plain, $m);
|
||||
foreach (array_count_values($m[1]) as $word => $cnt) {
|
||||
if ($cnt >= 2 && !isset($candidates[$word]) && !in_array($word, self::STOP, true)) {
|
||||
$this->add($candidates, $word, $cnt, false, in_array($word, $currentTags, true), 'proper');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filtrage ─────────────────────────────────────────────────────────
|
||||
foreach (array_keys($candidates) as $key) {
|
||||
if (mb_strlen($key) < 2 || is_numeric($key)) {
|
||||
unset($candidates[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tri : actuels > connus > fréquence ───────────────────────────────
|
||||
uasort(
|
||||
$candidates,
|
||||
fn ($a, $b) =>
|
||||
((int)$b['current'] <=> (int)$a['current'])
|
||||
?: ((int)$b['known'] <=> (int)$a['known'])
|
||||
?: ($b['count'] <=> $a['count'])
|
||||
);
|
||||
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggestions spéciales pour le champ « catégorie » :
|
||||
* retourne les catégories existantes classées par fréquence dans le texte.
|
||||
*
|
||||
* @param string[] $allCats Clés = nom de catégorie, valeurs = nb articles
|
||||
* @return array<string, int> catégorie => nb occurrences dans le texte
|
||||
*/
|
||||
public function suggestCategory(string $markdown, array $allCats, string $currentCat = ''): array
|
||||
{
|
||||
$plain = $this->stripMarkdown($markdown);
|
||||
$result = [];
|
||||
foreach (array_keys($allCats) as $cat) {
|
||||
$cnt = $this->countOccurrences($plain, $cat);
|
||||
$result[$cat] = $cnt;
|
||||
}
|
||||
// Trier : catégorie courante en premier, puis par occurrence décroissante, puis alphabétique
|
||||
uksort($result, function ($a, $b) use ($result, $currentCat) {
|
||||
if ($a === $currentCat) {
|
||||
return -1;
|
||||
}
|
||||
if ($b === $currentCat) {
|
||||
return 1;
|
||||
}
|
||||
return ($result[$b] <=> $result[$a]) ?: strcmp($a, $b);
|
||||
});
|
||||
return $result;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function add(array &$c, string $key, int $cnt, bool $known, bool $current, string $group): void
|
||||
{
|
||||
$c[$key] = [
|
||||
'count' => $cnt,
|
||||
'known' => $known,
|
||||
'current' => $current,
|
||||
'group' => $group,
|
||||
];
|
||||
}
|
||||
|
||||
private function countOccurrences(string $haystack, string $needle): int
|
||||
{
|
||||
if ($needle === '') {
|
||||
return 0;
|
||||
}
|
||||
return substr_count(mb_strtolower($haystack), mb_strtolower($needle));
|
||||
}
|
||||
|
||||
private function isStop(string $phrase): bool
|
||||
{
|
||||
$words = explode(' ', $phrase);
|
||||
// Phrase stop si tous les mots sont des stop words
|
||||
foreach ($words as $w) {
|
||||
if (!in_array($w, self::STOP, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function stripMarkdown(string $md): string
|
||||
{
|
||||
// Blocs de code → on retire pour éviter les faux positifs de variables/commandes
|
||||
$md = preg_replace('/```[\s\S]*?```/m', ' ', $md);
|
||||
$md = preg_replace('/`[^`\n]+`/', ' ', $md);
|
||||
// En-têtes
|
||||
$md = preg_replace('/^#{1,6}\s+/m', '', $md);
|
||||
// Gras/italique
|
||||
$md = preg_replace('/\*{1,3}([^*]+)\*{1,3}/', '$1', $md);
|
||||
$md = preg_replace('/_{1,3}([^_]+)_{1,3}/', '$1', $md);
|
||||
// Liens et images
|
||||
$md = preg_replace('/!\[[^\]]*\]\([^\)]*\)/', ' ', $md);
|
||||
$md = preg_replace('/\[([^\]]+)\]\([^\)]+\)/', '$1', $md);
|
||||
// URLs brutes
|
||||
$md = preg_replace('/https?:\/\/\S+/', ' ', $md);
|
||||
// Balises HTML
|
||||
$md = strip_tags($md);
|
||||
// Marqueurs de liste
|
||||
$md = preg_replace('/^[\*\-\+]\s+/m', '', $md);
|
||||
// Lignes horizontales
|
||||
$md = preg_replace('/^[-*_]{3,}\s*$/m', '', $md);
|
||||
// Espaces multiples
|
||||
return trim((string)preg_replace('/\s+/', ' ', $md));
|
||||
}
|
||||
}
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function isLoggedIn(): bool
|
||||
{
|
||||
return !empty($_SESSION['user_email']);
|
||||
}
|
||||
|
||||
function requireAuth(): void
|
||||
{
|
||||
if (!isLoggedIn()) {
|
||||
$return = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
header('Location: /login' . ($return !== '/' ? '?return_to=' . urlencode($return) : ''), true, 302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
function currentUserEmail(): ?string
|
||||
{
|
||||
return $_SESSION['user_email'] ?? null;
|
||||
}
|
||||
|
||||
function currentUserName(): string
|
||||
{
|
||||
if (!isLoggedIn()) {
|
||||
return '';
|
||||
}
|
||||
if (isset($_SESSION['user_display_name']) && $_SESSION['user_display_name'] !== '') {
|
||||
return $_SESSION['user_display_name'];
|
||||
}
|
||||
$name = authorDisplayName(currentUserEmail() ?? '');
|
||||
$_SESSION['user_display_name'] = $name;
|
||||
return $name;
|
||||
}
|
||||
|
||||
function authorDisplayName(string $email): string
|
||||
{
|
||||
return authorProfile($email)['name'];
|
||||
}
|
||||
|
||||
function authorProfileUrl(string $email): string
|
||||
{
|
||||
return authorProfile($email)['url'];
|
||||
}
|
||||
|
||||
function authorProfile(string $email): array
|
||||
{
|
||||
static $cache = [];
|
||||
$key = strtolower(trim($email));
|
||||
if ($key === '') {
|
||||
return ['name' => '', 'url' => '', 'slug' => ''];
|
||||
}
|
||||
if (array_key_exists($key, $cache)) {
|
||||
return $cache[$key];
|
||||
}
|
||||
$pdo = dbPdo();
|
||||
if ($pdo) {
|
||||
try {
|
||||
$st = $pdo->prepare('SELECT display_name, profile_url, profile_slug, bio FROM user_profiles WHERE email = :e');
|
||||
$st->execute([':e' => $key]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if ($row) {
|
||||
$cache[$key] = [
|
||||
'name' => ($row['display_name'] !== '') ? $row['display_name'] : explode('@', $key)[0],
|
||||
'url' => $row['profile_url'] ?? '',
|
||||
'slug' => $row['profile_slug'] ?? '',
|
||||
'bio' => $row['bio'] ?? '',
|
||||
];
|
||||
return $cache[$key];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
$cache[$key] = ['name' => explode('@', $key)[0], 'url' => '', 'slug' => ''];
|
||||
return $cache[$key];
|
||||
}
|
||||
|
||||
function authorSlug(string $email): string
|
||||
{
|
||||
return authorProfile($email)['slug'];
|
||||
}
|
||||
|
||||
function profileBySlug(string $slug): ?array
|
||||
{
|
||||
if ($slug === '') {
|
||||
return null;
|
||||
}
|
||||
$pdo = dbPdo();
|
||||
if (!$pdo) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$st = $pdo->prepare('SELECT email, display_name, profile_url, profile_slug, bio FROM user_profiles WHERE profile_slug = :s');
|
||||
$st->execute([':s' => $slug]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function dbPdo(): ?PDO
|
||||
{
|
||||
static $pdo = null;
|
||||
static $failed = false;
|
||||
if ($failed) {
|
||||
return null;
|
||||
}
|
||||
if ($pdo !== null) {
|
||||
return $pdo;
|
||||
}
|
||||
$dsn = $_ENV['DB_DSN'] ?? (getenv('DB_DSN') ?: '');
|
||||
$user = $_ENV['DB_USER'] ?? (getenv('DB_USER') ?: '');
|
||||
$pass = $_ENV['DB_PASS'] ?? (getenv('DB_PASS') ?: '');
|
||||
if (!$dsn) {
|
||||
$failed = true;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$pdo = new PDO($dsn, $user ?: null, $pass ?: null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||
} catch (\Throwable) {
|
||||
$failed = true;
|
||||
return null;
|
||||
}
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
function currentUserRoles(): array
|
||||
{
|
||||
if (!isLoggedIn()) {
|
||||
return [];
|
||||
}
|
||||
if (isset($_SESSION['user_roles'])) {
|
||||
return $_SESSION['user_roles'];
|
||||
}
|
||||
$pdo = dbPdo();
|
||||
if (!$pdo) {
|
||||
$_SESSION['user_roles'] = [];
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$st = $pdo->prepare(
|
||||
'SELECT r.name FROM roles r
|
||||
JOIN user_roles ur ON ur.role_id = r.id
|
||||
WHERE ur.user_email = :e'
|
||||
);
|
||||
$st->execute([':e' => strtolower(currentUserEmail() ?? '')]);
|
||||
$_SESSION['user_roles'] = $st->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
||||
} catch (\Throwable) {
|
||||
$_SESSION['user_roles'] = [];
|
||||
}
|
||||
return $_SESSION['user_roles'];
|
||||
}
|
||||
|
||||
function hasRole(string $role): bool
|
||||
{
|
||||
return in_array($role, currentUserRoles(), true);
|
||||
}
|
||||
|
||||
// Capacités connues — clé => label affiché dans l'admin
|
||||
const KNOWN_CAPABILITIES = [
|
||||
'propose_articles' => 'Proposer des articles',
|
||||
'validate_articles_all' => 'Valider des articles',
|
||||
'validate_articles_own' => 'Valider ses articles uniquement',
|
||||
'publish_articles_all' => 'Publier des articles',
|
||||
'publish_articles_own' => 'Publier ses articles uniquement',
|
||||
'edit_articles_all' => 'Modifier des articles',
|
||||
'edit_articles_own' => 'Modifier ses articles uniquement',
|
||||
'rate_articles' => 'Noter des articles',
|
||||
'view_previews' => 'Lire des avant-premières',
|
||||
'view_drafts_all' => 'Voir tous les brouillons',
|
||||
'view_drafts_own' => 'Voir ses brouillons',
|
||||
'view_sources_all' => 'Voir les sources (tous les articles)',
|
||||
'view_sources_own' => 'Voir les sources de ses articles',
|
||||
];
|
||||
|
||||
// Groupes pour l'interface d'administration
|
||||
const CAPABILITY_GROUPS = [
|
||||
'Articles' => [
|
||||
'propose_articles',
|
||||
'validate_articles_all',
|
||||
'validate_articles_own',
|
||||
'publish_articles_all',
|
||||
'publish_articles_own',
|
||||
'edit_articles_all',
|
||||
'edit_articles_own',
|
||||
],
|
||||
'Accès & lecture' => [
|
||||
'rate_articles',
|
||||
'view_previews',
|
||||
'view_drafts_all',
|
||||
'view_drafts_own',
|
||||
'view_sources_all',
|
||||
'view_sources_own',
|
||||
],
|
||||
];
|
||||
|
||||
function currentUserCapabilities(): array
|
||||
{
|
||||
if (!isLoggedIn()) {
|
||||
return [];
|
||||
}
|
||||
if (isset($_SESSION['user_capabilities'])) {
|
||||
return $_SESSION['user_capabilities'];
|
||||
}
|
||||
$pdo = dbPdo();
|
||||
if (!$pdo) {
|
||||
$_SESSION['user_capabilities'] = [];
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$st = $pdo->prepare(
|
||||
'SELECT DISTINCT rc.capability
|
||||
FROM role_capabilities rc
|
||||
JOIN user_roles ur ON ur.role_id = rc.role_id
|
||||
WHERE ur.user_email = :e'
|
||||
);
|
||||
$st->execute([':e' => strtolower(currentUserEmail() ?? '')]);
|
||||
$_SESSION['user_capabilities'] = $st->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
||||
} catch (\Throwable) {
|
||||
$_SESSION['user_capabilities'] = [];
|
||||
}
|
||||
return $_SESSION['user_capabilities'];
|
||||
}
|
||||
|
||||
function hasCapability(string $cap): bool
|
||||
{
|
||||
if (isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
return in_array($cap, currentUserCapabilities(), true);
|
||||
}
|
||||
|
||||
function canDoOnArticle(string $baseCap, array $article): bool
|
||||
{
|
||||
if (isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
if (hasCapability($baseCap . '_all')) {
|
||||
return true;
|
||||
}
|
||||
if (hasCapability($baseCap . '_own')) {
|
||||
$owner = strtolower($article['author'] ?? '');
|
||||
return $owner !== '' && $owner === strtolower(currentUserEmail() ?? '');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAdmin(): bool
|
||||
{
|
||||
$email = currentUserEmail();
|
||||
if (!$email) {
|
||||
return false;
|
||||
}
|
||||
// Fallback bootstrap : var d'env
|
||||
$rawAdmin = $_ENV['ADMIN_EMAIL'] ?? (getenv('ADMIN_EMAIL') ?: '');
|
||||
$allowed = array_filter(array_map('trim', explode(',', (string)$rawAdmin)));
|
||||
if (in_array(strtolower($email), array_map('strtolower', $allowed), true)) {
|
||||
return true;
|
||||
}
|
||||
return hasRole('admin');
|
||||
}
|
||||
|
||||
function ssoLogoutUrl(): string
|
||||
{
|
||||
$issuer = rtrim((string)($_ENV['OIDC_ISSUER'] ?? (getenv('OIDC_ISSUER') ?: '')), '/');
|
||||
$clientId = (string)($_ENV['OIDC_CLIENT_ID'] ?? (getenv('OIDC_CLIENT_ID') ?: ''));
|
||||
$baseUrl = rtrim((string)($_ENV['APP_URL'] ?? (getenv('APP_URL') ?: '/')), '/');
|
||||
|
||||
$params = [
|
||||
'client_id' => $clientId,
|
||||
'post_logout_redirect_uri' => $baseUrl . '/',
|
||||
];
|
||||
if (!empty($_SESSION['oidc']['id_token'])) {
|
||||
$params['id_token_hint'] = $_SESSION['oidc']['id_token'];
|
||||
}
|
||||
|
||||
if (!$issuer) {
|
||||
return $baseUrl . '/';
|
||||
}
|
||||
|
||||
return $issuer . '/protocol/openid-connect/logout?' . http_build_query($params);
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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());
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function vd($var, ...$moreVars)
|
||||
{
|
||||
ob_start();
|
||||
var_dump($var, ...$moreVars);
|
||||
$output = ob_get_clean();
|
||||
echo "<pre>$output</pre>";
|
||||
}
|
||||
|
||||
function slugify(string $s): string
|
||||
{
|
||||
$map = ['à' => 'a','â' => 'a','ä' => 'a','é' => 'e','è' => 'e','ê' => 'e','ë' => 'e','î' => 'i','ï' => 'i','ô' => 'o','ö' => 'o','ù' => 'u','û' => 'u','ü' => 'u','ç' => 'c','æ' => 'ae','œ' => 'oe'];
|
||||
$s = mb_strtolower($s);
|
||||
$s = strtr($s, $map);
|
||||
$s = (string)preg_replace('/[^a-z0-9]+/', '-', $s);
|
||||
return trim($s, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff ligne-à-ligne via LCS. Retourne un tableau de [op, line] où
|
||||
* op est '=' (inchangé), '-' (supprimé), '+' (ajouté).
|
||||
*/
|
||||
function lineDiff(string $old, string $new): array
|
||||
{
|
||||
$a = explode("\n", $old);
|
||||
$b = explode("\n", $new);
|
||||
$n = count($a);
|
||||
$m = count($b);
|
||||
|
||||
if ($n * $m > 300000) {
|
||||
return [['!', "Diff trop grand ({$n}×{$m} lignes), affichage brut."], ['-', $old], ['+', $new]];
|
||||
}
|
||||
|
||||
$dp = array_fill(0, $n + 1, array_fill(0, $m + 1, 0));
|
||||
for ($i = $n - 1; $i >= 0; $i--) {
|
||||
for ($j = $m - 1; $j >= 0; $j--) {
|
||||
$dp[$i][$j] = $a[$i] === $b[$j]
|
||||
? 1 + $dp[$i + 1][$j + 1]
|
||||
: max($dp[$i + 1][$j], $dp[$i][$j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
$diff = [];
|
||||
$i = 0;
|
||||
$j = 0;
|
||||
while ($i < $n || $j < $m) {
|
||||
if ($i < $n && $j < $m && $a[$i] === $b[$j]) {
|
||||
$diff[] = ['=', $a[$i]];
|
||||
$i++;
|
||||
$j++;
|
||||
} elseif ($j < $m && ($i >= $n || $dp[$i][$j + 1] >= $dp[$i + 1][$j])) {
|
||||
$diff[] = ['+', $b[$j++]];
|
||||
} else {
|
||||
$diff[] = ['-', $a[$i++]];
|
||||
}
|
||||
}
|
||||
return $diff;
|
||||
}
|
||||
|
||||
// 16 couleurs RGB de base — distribuées sur le spectre, visuellement distinctes
|
||||
const COLOR_PALETTE_16 = [
|
||||
[220, 38, 38], // rouge
|
||||
[234, 88, 12], // orange
|
||||
[217, 119, 6], // ambre
|
||||
[161, 142, 14], // jaune-olive
|
||||
[77, 124, 15], // citron
|
||||
[22, 163, 74], // vert
|
||||
[4, 120, 87], // émeraude
|
||||
[15, 118, 110], // sarcelle
|
||||
[8, 145, 178], // cyan
|
||||
[3, 105, 161], // ciel
|
||||
[37, 99, 235], // bleu
|
||||
[79, 70, 229], // indigo
|
||||
[109, 40, 217], // violet
|
||||
[147, 51, 234], // pourpre
|
||||
[192, 38, 211], // fuchsia
|
||||
[219, 39, 119], // rose
|
||||
];
|
||||
|
||||
/**
|
||||
* Génère un dégradé CSS pour une catégorie.
|
||||
* Avec $allCats, l'assignation est séquentielle (par ordre alpha) ;
|
||||
* au-delà de 16, un décalage de teinte et d'angle différencie les palettes.
|
||||
* Sans $allCats, fallback par hachage sur la palette.
|
||||
*/
|
||||
function coverGradient(string $seed, array $allCats = []): string
|
||||
{
|
||||
$key = strtolower(trim($seed));
|
||||
|
||||
if (!empty($allCats)) {
|
||||
$keys = array_map(fn ($k) => strtolower(trim((string)$k)), array_keys($allCats));
|
||||
$pos = array_search($key, $keys, true);
|
||||
if ($pos !== false) {
|
||||
$idx = (int) $pos;
|
||||
$tier = (int) floor($idx / 16);
|
||||
$ci = $idx % 16;
|
||||
return _paletteGradient(COLOR_PALETTE_16[$ci], $tier);
|
||||
}
|
||||
}
|
||||
|
||||
// Hachage déterministe en l'absence de liste
|
||||
$ci = abs(crc32($key)) % 16;
|
||||
return _paletteGradient(COLOR_PALETTE_16[$ci], 0);
|
||||
}
|
||||
|
||||
function _paletteGradient(array $rgb, int $tier): string
|
||||
{
|
||||
[$r, $g, $b] = $rgb;
|
||||
|
||||
// Tier 0 : dégradé standard clair → foncé, 135°
|
||||
// Tier 1 : plus saturé, angle inversé, 315°
|
||||
// Tier 2+ : plus sombre encore, 225°
|
||||
$tintMix = match ($tier) {
|
||||
0 => 0.65, 1 => 0.48, default => 0.35
|
||||
};
|
||||
$shadeK = match ($tier) {
|
||||
0 => 0.35, 1 => 0.25, default => 0.18
|
||||
};
|
||||
$angle = match ($tier) {
|
||||
0 => 135, 1 => 315, default => 225
|
||||
};
|
||||
|
||||
$tr = (int) round($r * (1 - $tintMix) + 255 * $tintMix);
|
||||
$tg = (int) round($g * (1 - $tintMix) + 255 * $tintMix);
|
||||
$tb = (int) round($b * (1 - $tintMix) + 255 * $tintMix);
|
||||
|
||||
$sr = (int) round($r * $shadeK);
|
||||
$sg = (int) round($g * $shadeK);
|
||||
$sb = (int) round($b * $shadeK);
|
||||
|
||||
return "linear-gradient({$angle}deg,rgb($tr,$tg,$tb) 0%,rgb($sr,$sg,$sb) 100%)";
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
<?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 (defined('BASE_PATH') && !function_exists('smtpCfg')) {
|
||||
require_once dirname(__DIR__) . '/src/SmtpSettings.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
|
||||
{
|
||||
if (!($opts['bypass_rate_limit'] ?? false)) {
|
||||
[$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();
|
||||
$_smtpRead = function_exists('smtpCfg');
|
||||
$mail->Host = $_smtpRead ? smtpCfg('host', 'SMTP_HOST', 'localhost') : (string)env('SMTP_HOST', 'localhost');
|
||||
$mail->Port = (int)($_smtpRead ? smtpCfg('port', 'SMTP_PORT', '587') : env('SMTP_PORT', '587'));
|
||||
$_smtpUser = $_smtpRead ? smtpCfg('user', 'SMTP_USER') : (string)env('SMTP_USER', '');
|
||||
$_smtpPass = $_smtpRead ? smtpCfg('pass', 'SMTP_PASS') : (string)env('SMTP_PASS', '');
|
||||
$mail->SMTPAuth = ($_smtpUser !== '' || $_smtpPass !== '');
|
||||
$mail->Username = $_smtpUser;
|
||||
$mail->Password = $_smtpPass;
|
||||
$secure = strtolower($_smtpRead ? smtpCfg('secure', 'SMTP_SECURE', 'tls') : (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 = $_smtpRead ? smtpCfg('from', 'SMTP_FROM', 'no-reply@varlog.a5l.fr') : (string)env('SMTP_FROM', 'no-reply@varlog.a5l.fr');
|
||||
$fromName = $_smtpRead ? smtpCfg('from_name', 'SMTP_FROM_NAME', 'varlog') : (string)env('SMTP_FROM_NAME', 'varlog');
|
||||
$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