Initial commit

This commit is contained in:
Cedric Abonnel
2026-05-08 12:55:46 +02:00
commit 700329f156
46 changed files with 8495 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
# Credentials & config sensible
.env
# Composer dependencies
vendor/
# OS
.DS_Store
Thumbs.db
+12
View File
@@ -0,0 +1,12 @@
<?php
$finder = PhpCsFixer\Finder::create()->in(__DIR__);
return (new PhpCsFixer\Config())
->setRiskyAllowed(true)
->setRules([
'@PSR12' => true,
'strict_param' => true,
'declare_strict_types' => true,
'no_unused_imports' => true,
'single_quote' => true,
])
->setFinder($finder);
+24
View File
@@ -0,0 +1,24 @@
{
"name": "varlog/microblog",
"type": "project",
"require": {
"ext-pdo": "*",
"php": ">=8.2",
"vlucas/phpdotenv": "^5.6",
"phpmailer/phpmailer": "^6.11",
"jumbojett/openid-connect-php": "^1.0"
},
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
"require-dev": {
"phpstan/phpstan": "^1.11",
"friendsofphp/php-cs-fixer": "^3.64"
},
"scripts": {
"fix": "php-cs-fixer fix --config=.php-cs-fixer.dist.php",
"stan": "phpstan analyse"
}
}
Generated
+3304
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
<?php
// config/config.php
require_once BASE_PATH . '/vendor/autoload.php';
use Dotenv\Dotenv;
// Load .env file
$dotenv = Dotenv::createImmutable(BASE_PATH);
$dotenv->load();
if (!$_ENV['APP_URL']) {
http_response_code(500);
echo "Configuration manquante : définis APP_URL ou APP_URL dans le .env";
exit;
}
// Normalise: toujours un trailing slash unique
define('APP_URL', rtrim($_ENV['APP_URL'], '/') . '/');
// (Optionnel) Expose dans $_ENV si besoin
$_ENV['APP_URL'] = APP_URL;
/**
* Helper pour construire des liens absolus propres.
* url('ressources/user/login.php')
* url('api/items', ['page'=>2])
*/
function url(string $path = '', array $qs = []): string {
$u = APP_URL . ltrim($path, '/');
if ($qs) {
$u .= (str_contains($u, '?') ? '&' : '?') . http_build_query($qs);
}
return $u;
}
+49
View File
@@ -0,0 +1,49 @@
logique complète en PHP framework maison), avec un système de routing clair, base postgres et extensible pour gérer :
* les posts (CRUD + publication + masquage (au lieu de suppression)),
* les commentaires (publier, masquer, privé),
* les pièces jointes (upload, masquage (au lieu de supprimer), inutile de réuploadé si le fichier à déjà été poussé dans un autre poste par exemple.).
architecture MVC, avec un routeur maison et des contrôleurs structurés.
Tout sera modulaire et facile à maintenir.
---
## 🏗️ Structure du projet
```
project/
├─ public/
│ ├─ index.php # Point d'entrée (router)
│ └─ uploads/ # Dossier des fichiers uploadés
├─ app/
│ ├─ Core/
│ │ ├─ Router.php # Routeur maison
| │ ├─ Model.php
| │ ├─ View.php
│ │ └─ Controller.php # Classe de base pour les contrôleurs
│ │
│ ├─ Controllers/
│ │ ├─ PostController.php
│ │ ├─ CommentController.php
│ │ └─ AttachmentController.php
│ │
│ ├─ Models/
│ │ ├─ Post.php
│ │ ├─ Comment.php
│ │ └─ Attachment.php
│ │
│ ├── Views/
│ │ ├── posts/
│ │ │ ├── index.php
│ │ │ ├── show.php
│ │ │ └── form.php
│ │ ├── comments/
│ │ └── attachments/
│ │
│ └─ config.php # Configuration (DB, etc.)
└─ composer.json
```
+85
View File
@@ -0,0 +1,85 @@
<?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');
}
}
+10
View File
@@ -0,0 +1,10 @@
<div class="container">
<footer class="py-3 my-4">
<ul class="nav justify-content-center border-bottom pb-3 mb-3">
<li class="nav-item"><a href="https://alpinux.org/mentions-legales" class="nav-link px-2 text-body-secondary">Mentions légales</a></li>
<li class="nav-item"><a href="/index/a-propos" class="nav-link px-2 text-body-secondary">A propos</a></li>
</ul>
<p class="text-center text-body-secondary">Association 1901 - <a href="https://alpinux.org/">Alpinux, le LUG de Savoie</a></p>
</footer>
</div>
+49
View File
@@ -0,0 +1,49 @@
<div class="container">
<header class="d-flex flex-wrap align-items-center justify-content-center justify-content-md-between py-3 mb-4 border-bottom">
<a href="/" class="d-flex align-items-center text-body-emphasis text-decoration-none">
<img width="32" src="/img/logo-mail.svg" class="bi me-2" >
<span class="fs-4">Mug ALPINUX</span>
</a>
<ul class="nav col-12 col-md-auto mb-2 justify-content-center mb-md-0">
</ul>
<?php
// Créer une instance de MessageManager avec le fichier de base de données SQLite
$messageManager = new ace\MessageManager('database.db');
if ($messageManager->sessionAlready()) {
?>
<div class="dropdown text-end">
<a href="#" class="d-block link-dark text-decoration-none dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<?php echo $messageManager->getUsername($_SESSION['user_id']); ?>
</a>
<ul class="dropdown-menu text-small">
<li><a class="dropdown-item" href="/user/parametres">Paramètres <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-sliders" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M11.5 2a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM9.05 3a2.5 2.5 0 0 1 4.9 0H16v1h-2.05a2.5 2.5 0 0 1-4.9 0H0V3h9.05zM4.5 7a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM2.05 8a2.5 2.5 0 0 1 4.9 0H16v1H6.95a2.5 2.5 0 0 1-4.9 0H0V8h2.05zm9.45 4a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm-2.45 1a2.5 2.5 0 0 1 4.9 0H16v1h-2.05a2.5 2.5 0 0 1-4.9 0H0v-1h9.05z"/>
</svg></a></li>
<li><a class="dropdown-item" href="/user/profil">Profil <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-person-vcard" viewBox="0 0 16 16">
<path d="M5 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm4-2.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5ZM9 8a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4A.5.5 0 0 1 9 8Zm1 2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5Z"/>
<path d="M2 2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H2ZM1 4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H8.96c.026-.163.04-.33.04-.5C9 10.567 7.21 9 5 9c-2.086 0-3.8 1.398-3.984 3.181A1.006 1.006 0 0 1 1 12V4Z"/>
</svg></a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/user/disconnect">Déconnexion <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-box-arrow-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0v2z"/>
<path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z"/>
</svg></a></li>
</ul>
</div>
<?php
}
?>
</header>
</div>
+149
View File
@@ -0,0 +1,149 @@
<?php
// projet : mug.a5l.fr
// fichier : includes/mailer.php
// version : 20251011
declare(strict_types=1);
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
require_once dirname(__DIR__) . '/vendor/autoload.php';
if (!function_exists('env')) {
function env(string $key, ?string $default = null): ?string {
if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') return (string)$_ENV[$key];
$v = getenv($key);
if ($v !== false && $v !== '') return (string)$v;
return $default;
}
}
if (!function_exists('db')) {
function db(): \PDO { return \App\Infrastructure\Database::get(); }
}
/**
* Anti-abus simple : 1 envoi / 5 min et 5 en 12 h par destinataire (status in ('sent','queued')).
*/
function mailer_can_send(string $email, int $coolMin = 5, int $maxPer12h = 5): array {
// bypass complet si désactivé
$enabled = (int) (env('SMTP_RATE_LIMIT_ENABLE', '1'));
if ($enabled === 0) return [true, ''];
$pdo = db();
// Cooldown (actif seulement si >0)
if ($coolMin > 0) {
$q1 = "SELECT 1 FROM journal_smtp
WHERE to_email = :e AND created_at >= NOW() - INTERVAL :cool
AND status IN ('sent','queued')
LIMIT 1";
$stmt = $pdo->prepare($q1);
$stmt->execute([':e'=>$email, ':cool'=>sprintf('%d minutes', $coolMin)]);
if ($stmt->fetchColumn()) return [false, "Un email vient d’être envoyé. Réessayez dans {$coolMin} min."];
}
// Plafond 12h (actif seulement si >0)
if ($maxPer12h > 0) {
$q2 = "SELECT COUNT(*) FROM journal_smtp
WHERE to_email = :e AND created_at >= NOW() - INTERVAL '12 hours'
AND status IN ('sent','queued')";
$stmt = $pdo->prepare($q2);
$stmt->execute([':e'=>$email]);
if ((int)$stmt->fetchColumn() >= $maxPer12h) return [false, 'Quota atteint. Réessayez plus tard.'];
}
return [true, ''];
}
/**
* Envoi immédiat SMTP avec PHPMailer + journalisation.
* @param string $to destinataire
* @param string $subject objet
* @param string $html corps HTML
* @param string|null $text corps texte brut (optionnel, auto-généré si null)
* @param array $opts ['reply_to'=>['email','name']]
*/
function envoyer_mail_smtp(string $to, string $subject, string $html, ?string $text = null, array $opts = []): bool {
[$ok, $msg] = mailer_can_send($to, (int)env('SMTP_COOLDOWN_MINUTES', '5'), (int)env('SMTP_MAX_PER_12H', '5'));
if (!$ok) throw new RuntimeException($msg);
$pdo = db();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare("INSERT INTO journal_smtp
(created_at, script_path, to_email, subject, content_html, content_text, status, ip, user_agent)
VALUES (NOW(), :script, :to, :subj, :html, :text, 'queued', :ip, :ua)
RETURNING id");
$stmt->execute([
':script' => ($_SERVER['SCRIPT_NAME'] ?? ''),
':to' => $to,
':subj' => $subject,
':html' => $html,
':text' => $text ?? trim(html_entity_decode(strip_tags($html), ENT_QUOTES)),
':ip' => ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? ''),
':ua' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512),
]);
$rowId = (int)$stmt->fetchColumn();
$pdo->commit();
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
$mail = new PHPMailer(true);
try {
$mail->isSMTP();
$mail->Host = (string)env('SMTP_HOST', 'localhost');
$mail->Port = (int)env('SMTP_PORT', '587');
$mail->SMTPAuth = (env('SMTP_USER') || env('SMTP_PASS')) ? true : false;
$mail->Username = (string)env('SMTP_USER', '');
$mail->Password = (string)env('SMTP_PASS', '');
$secure = strtolower((string)env('SMTP_SECURE', 'tls'));
if ($secure === 'ssl') $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
elseif ($secure === 'tls') $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->SMTPKeepAlive = true; // réutilise la connexion
$mail->Timeout = 30; // évite les blocages longs
$mail->SMTPOptions = ['ssl'=>['verify_peer'=>true,'verify_peer_name'=>true,'allow_self_signed'=>false]];
$mail->CharSet = 'UTF-8';
$mail->isHTML(true);
// Expéditeur
$from = (string)env('SMTP_FROM', 'no-reply@mug.a5l.fr');
$fromName = (string)env('SMTP_FROM_NAME', 'MUG');
$mail->setFrom($from, $fromName);
// Reply-To
if (!empty($opts['reply_to']) && is_array($opts['reply_to']) && filter_var($opts['reply_to'][0] ?? '', FILTER_VALIDATE_EMAIL)) {
$mail->addReplyTo($opts['reply_to'][0], $opts['reply_to'][1] ?? '');
} elseif ($rt = env('SMTP_REPLY_TO')) {
$mail->addReplyTo($rt, (string)env('SMTP_REPLY_TO_NAME', 'Support'));
}
// DKIM optionnel
if ($d = env('DKIM_DOMAIN')) {
$mail->DKIM_domain = $d;
$mail->DKIM_selector = (string)env('DKIM_SELECTOR', 'default');
$mail->DKIM_private = (string)env('DKIM_PRIVATE_KEY_PATH', '');
$mail->DKIM_passphrase = (string)env('DKIM_PASSPHRASE', '');
$mail->DKIM_identity = $from;
}
$mail->addAddress($to);
$mail->Subject = $subject;
$mail->Body = $html;
$mail->AltBody = $text ?? trim(html_entity_decode(strip_tags($html), ENT_QUOTES));
$mail->send();
$pdo->prepare("UPDATE journal_smtp SET status='sent', sent_at=NOW() WHERE id=:id")->execute([':id'=>$rowId]);
return true;
} catch (Exception $e) {
$pdo->prepare("UPDATE journal_smtp SET status='error', error_message=:err, sent_at=NOW() WHERE id=:id")
->execute([':id'=>$rowId, ':err'=>substr($e->getMessage(),0,1000)]);
throw new RuntimeException('Envoi email impossible: '.$e->getMessage());
}
}
+32
View File
@@ -0,0 +1,32 @@
/journal/
├── public/ # Fichiers accessibles publiquement
│ ├── index.php
│ ├── assets/ # CSS, JS, fichiers uploadés
│ │ ├── uploads/
│ │ ├── css/
│ │ └── js/
├── templates/ # Templates HTML Bootstrap
│ ├── layout.php
│ ├── post_form.php
│ └── post_view.php
├── src/ # Classes et fonctions PHP
│ ├── db.php # Connexion PDO à PostgreSQL
│ ├── PostManager.php # Gestion des posts
│ └── FileManager.php # Gestion des fichiers
├── config/
│ └── config.php # Données de connexion PostgreSQL
└── route.php # Routage de base
Un exemple complet de formulaire dajout de post avec upload de fichiers ?
Le code PHP pour la gestion des posts et des fichiers ?
Ou encore une page HTML daffichage dun post avec tous les médias intégrés ?
+71
View File
@@ -0,0 +1,71 @@
<?php
require_once BASE_PATH . '/src/db.php';
require_once BASE_PATH . '/src/PostManager.php';
$postManager = new PostManager($db);
$errors = [];
$title = '';
$content = '';
$published = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title'] ?? '');
$content = trim($_POST['content'] ?? '');
$published = isset($_POST['published']);
if ($title === '') {
$errors[] = 'Le titre est obligatoire.';
}
if (empty($errors)) {
$postId = $postManager->create($title, $content, $published);
header("Location: index.php");
exit;
}
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Nouveau post</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container py-4">
<h1 class="mb-4">Ajouter un nouveau post</h1>
<?php if (!empty($errors)): ?>
<div class="alert alert-danger">
<ul class="mb-0">
<?php foreach ($errors as $error): ?>
<li><?= htmlspecialchars($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form method="POST">
<div class="mb-3">
<label for="title" class="form-label">Titre</label>
<input type="text" class="form-control" id="title" name="title" value="<?= htmlspecialchars($title) ?>" required>
</div>
<div class="mb-3">
<label for="content" class="form-label">Contenu</label>
<textarea class="form-control" id="content" name="content" rows="6"><?= htmlspecialchars($content) ?></textarea>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="published" name="published" <?= $published ? 'checked' : '' ?>>
<label class="form-check-label" for="published">Publier immédiatement</label>
</div>
<button type="submit" class="btn btn-primary">Enregistrer</button>
<a href="index.php" class="btn btn-secondary">Annuler</a>
</form>
</div>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
<?php
define('BASE_PATH', realpath(__DIR__ . '/../'));
require_once BASE_PATH . '/src/helpers.php';
require_once BASE_PATH . '/config/config.php';
require_once BASE_PATH . '/src/db.php';
require_once BASE_PATH . '/src/PostManager.php';
$postManager = new PostManager($db);
ob_start();
$posts = $postManager->getAll();
require_once BASE_PATH . '/templates/post_list.php';
+130
View File
@@ -0,0 +1,130 @@
<?php
// public/login/config.php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
if (!defined('BASE_PATH')) { require_once dirname(__DIR__, 2) . '/config/config.php'; }
require_once BASE_PATH . '/includes/db.php';
require_once BASE_PATH . '/includes/csrf.php';
require_once BASE_PATH . '/includes/ConfigRepo.php';
Session::startSecure(getenv('SESSION_NAME') ?: 'SID_IDENT');
ensure_admin();
csrf_start();
$cfg = config_repo_get();
$msg = null; $err = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_check($_POST['csrf'] ?? '')) { http_response_code(403); exit('CSRF'); }
$in = [
'oidc_issuer' => trim((string)($_POST['oidc_issuer'] ?? '')),
'oidc_name' => trim((string)($_POST['oidc_name'] ?? '')),
'oidc_client_id' => trim((string)($_POST['oidc_client_id'] ?? '')),
'oidc_client_secret'=> trim((string)($_POST['oidc_client_secret'] ?? '')),
'oidc_redirect_uri' => trim((string)($_POST['oidc_redirect_uri'] ?? '')),
];
// validations simples
if ($in['allow_oidc']) {
if ($in['oidc_issuer'] === '' || $in['oidc_client_id'] === '' || $in['oidc_client_secret'] === '' || $in['oidc_redirect_uri'] === '') {
$err = "OIDC activé mais champs incomplets.";
}
}
if (!$err) {
config_repo_save($in);
// Mise à jour du .env
$envPairs = [
'OIDC_ISSUER' => $in['oidc_issuer'] !== '' ? $in['oidc_issuer'] : null,
'OIDC_NAME' => $in['oidc_name'] !== '' ? $in['oidc_name'] : null,
'OIDC_CLIENT_ID' => $in['oidc_client_id'] !== '' ? $in['oidc_client_id'] : null,
'OIDC_CLIENT_SECRET' => $in['oidc_client_secret'] !== '' ? $in['oidc_client_secret'] : null,
'OIDC_REDIRECT_URI' => $in['oidc_redirect_uri'] !== '' ? $in['oidc_redirect_uri'] : null,
];
env_set_pairs(BASE_PATH.'/.env', $envPairs);
$cfg = config_repo_get();
$msg = "Configuration enregistrée.";
}
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Configuration authentification</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="/assets/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container py-4">
<h1 class="h3 mb-3">Configuration authentification</h1>
<?php if ($msg): ?><div class="alert alert-success"><?=htmlspecialchars($msg)?></div><?php endif; ?>
<?php if ($err): ?><div class="alert alert-danger"><?=htmlspecialchars($err)?></div><?php endif; ?>
<form method="post" class="card p-3">
<input type="hidden" name="csrf" value="<?=htmlspecialchars(csrf_token())?>">
<fieldset class="mb-3">
<legend class="h5">Modes de connexion</legend>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="allow_password" name="allow_password" <?= $cfg['allow_password'] ? 'checked' : '' ?>>
<label class="form-check-label" for="allow_password">Login + mot de passe autorisé</label>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="allow_oidc" name="allow_oidc" <?= $cfg['allow_oidc'] ? 'checked' : '' ?>>
<label class="form-check-label" for="allow_oidc">Connexion OIDC autorisée</label>
</div>
</fieldset>
<fieldset class="mb-3">
<legend class="h5">Inscriptions</legend>
<div class="form-check">
<input class="form-check-input" type="radio" id="reg_open" name="registrations_open" value="open" <?= $cfg['registrations_open'] ? 'checked' : '' ?>>
<label class="form-check-label" for="reg_open">Ouvertes à tous</label>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="radio" id="reg_closed" name="registrations_open" value="closed" <?= !$cfg['registrations_open'] ? 'checked' : '' ?>>
<label class="form-check-label" for="reg_closed">Fermées</label>
</div>
</fieldset>
<fieldset class="mb-3">
<legend class="h5">Paramètres OIDC</legend>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Issuer URL</label>
<input type="url" name="oidc_issuer" class="form-control" value="<?=htmlspecialchars((string)$cfg['oidc_issuer'])?>" placeholder="https://idp.example.com/realms/xxx">
</div>
<div class="col-md-6">
<label class="form-label">Nom affiché</label>
<input type="text" name="oidc_name" class="form-control" value="<?=htmlspecialchars((string)$cfg['oidc_name'])?>" placeholder="Keycloak, Azure AD…">
</div>
<div class="col-md-6">
<label class="form-label">Client ID</label>
<input type="text" name="oidc_client_id" class="form-control" value="<?=htmlspecialchars((string)$cfg['oidc_client_id'])?>">
</div>
<div class="col-md-6">
<label class="form-label">Client Secret</label>
<input type="password" name="oidc_client_secret" class="form-control" value="<?=htmlspecialchars((string)$cfg['oidc_client_secret'])?>">
</div>
<div class="col-12">
<label class="form-label">Redirect URI</label>
<input type="url" name="oidc_redirect_uri" class="form-control" value="<?=htmlspecialchars((string)$cfg['oidc_redirect_uri'])?>" placeholder="<?=htmlspecialchars(rtrim(getenv('APP_URL') ?: '', '/').'/oidc/callback')?>">
</div>
</div>
<p class="form-text mt-2">Ces champs alimentent le fichier <code>.env</code>.</p>
</fieldset>
<div class="mt-3">
<button class="btn btn-primary" type="submit">Enregistrer</button>
</div>
</form>
</div>
</body>
</html>
+188
View File
@@ -0,0 +1,188 @@
<?php
// projet : mug.a5l.fr
// fichier : pages/login/index.php
// version : 20251011
declare(strict_types=1);
use App\Http\Csrf;
// --- Helpers AVANT tout usage ---
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(); }
}
if (!function_exists('url')) {
function url(string $path = '/'): string {
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
return $scheme . '://' . $host . $path;
}
}
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.php';
// Paramètres (env)
$ttlMin = (int) env('MAGIC_LINK_TTL_MINUTES', '30');
$coolMin = (int) env('MAGIC_COOLDOWN_MINUTES', '5');
$winHours = (int) env('MAGIC_WINDOW_HOURS', '12');
$maxPerWin = (int) env('MAGIC_MAX_PER_WINDOW', '5');
// --- return_to ---
$defaultReturn = '/';
$sanitize = static function (string $url) use ($defaultReturn): string {
$url = trim($url);
if ($url === '' || !str_starts_with($url, '/')) return $defaultReturn;
return $url;
};
$returnTo = $sanitize((string)($_GET['return_to'] ?? ($_SERVER['HTTP_REFERER'] ?? $defaultReturn)));
// --- OIDC ---
$oidcEnabled = (bool) (env('OIDC_ISSUER') && env('OIDC_CLIENT_ID'));
$oidcLoginUrl = '/login/oidc' . ($returnTo ? ('?return_to=' . urlencode($returnTo)) : '');
$oidcAuto = (isset($_GET['sso']) && $_GET['sso'] === '1') || (env('OIDC_AUTO', '0') === '1');
if ($oidcEnabled && $oidcAuto) { header('Location: ' . $oidcLoginUrl, true, 302); exit; }
// --- form: demande de lien magique ---
$errors = [];
$okMsg = '';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
if (!Csrf::validate($_POST['_csrf'] ?? null)) {
http_response_code(400);
$errors[] = 'Jeton CSRF invalide.';
} else {
$email = strtolower(trim((string)($_POST['email'] ?? '')));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Adresse email invalide.';
} else {
// rate limit simple par email et IP
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
if (strpos($ip, ',') !== false) $ip = trim(explode(',', $ip, 2)[0]);
$pdo = db();
$pdo->beginTransaction();
try {
// purge expirés / consommés
$pdo->prepare("DELETE FROM auth_magic_links WHERE email = :e AND (expires_at < NOW() OR consumed_at IS NOT NULL)")
->execute([':e' => $email]);
// 1) cooldown: refuser si un envoi récent < coolMin
$sql = sprintf(
"SELECT 1 FROM auth_magic_links
WHERE email = :e AND created_at >= NOW() - INTERVAL '%d minutes'
LIMIT 1",
max(0, $coolMin)
);
$stmt = $pdo->prepare($sql);
$stmt->execute([':e' => $email]);
if ($stmt->fetchColumn()) {
throw new RuntimeException(sprintf('Un lien vient d’être envoyé. Réessayez dans %d min.
Si vous ne recevez toujours rien, envisagez d\'utiliser un fournisseur de messagerie respectueux de la vie privée,
comme Proton Mail, Tuta, Posteo, Mailfence ou Infomaniak, qui garantissent un hébergement européen
et ne revendent pas vos données. -- Cédrix, le 11/10/2025', $coolMin));
}
// 2) plafond: maxPerWin liens sur winHours
$sql = sprintf(
"SELECT COUNT(*) FROM auth_magic_links
WHERE email = :e AND created_at >= NOW() - INTERVAL '%d hours'",
max(0, $winHours)
);
$stmt = $pdo->prepare($sql);
$stmt->execute([':e' => $email]);
if ((int)$stmt->fetchColumn() >= $maxPerWin) {
throw new RuntimeException('Quota atteint. Réessayez plus tard.');
}
// Génère et enregistre le lien avec TTL ttlMin
$raw = random_bytes(32);
$token = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
$sql = sprintf(
"INSERT INTO auth_magic_links (id,email,token,created_at,expires_at,ip,user_agent,return_to)
VALUES (gen_random_uuid(), :email, :token, NOW(), NOW() + INTERVAL '%d minutes', :ip, :ua, :rt)
RETURNING token",
max(1, $ttlMin)
);
$stmt = $pdo->prepare($sql);
$stmt->execute([
':email' => $email,
':token' => $token,
':ip' => $ip,
':ua' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512),
':rt' => ($returnTo !== '/' ? $returnTo : null),
]);
$pdo->commit();
// construit lURL et ENVOIE le mail ici...
$magicUrl = url('/login/magic.php') . '?token=' . urlencode($token);
/* envoyer_mail_smtp(...) ou mail(...) */
// message utilisateur
$okMsg = "Un lien vient d'être envoyé. Vérifiez votre boîte de réception et le dossier spam/indésirables.";
} catch (\Throwable $ex) {
if ($pdo->inTransaction()) $pdo->rollBack();
$errors[] = $ex->getMessage();
}
}
}
}
$csrf = Csrf::token();
?>
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Connexion</title>
<link rel="stylesheet" href="/assets/bootstrap/bootstrap.min.css">
<style>.or-sep{display:flex;align-items:center;gap:.75rem;margin:1.25rem 0}.or-sep::before,.or-sep::after{content:"";flex:1;height:1px;background:#ddd}</style>
</head>
<body class="container py-5">
<h1 class="mb-3">Bienvenue 👋</h1>
<p class="text-muted">Vous n’êtes pas connecté. Accédez auxfonctionnalités en vous identifiant.</p>
<?php foreach ($errors as $e): ?>
<div class="alert alert-danger"><?= htmlspecialchars($e, ENT_QUOTES) ?></div>
<?php endforeach; ?>
<?php if ($okMsg): ?>
<div class="alert alert-success"><?= htmlspecialchars($okMsg, ENT_QUOTES) ?></div>
<?php endif; ?>
<?php if ($oidcEnabled): ?>
<div class="mb-3">
<a class="btn btn-primary w-100" href="<?= htmlspecialchars($oidcLoginUrl, ENT_QUOTES) ?>">Se connecter avec A5L</a>
</div>
<div class="or-sep"><span>ou</span></div>
<?php else: ?>
<div class="alert alert-warning">A5L indisponible : configurez <code>OIDC_ISSUER</code> et <code>OIDC_CLIENT_ID</code> dans <code>.env</code>.</div>
<div class="or-sep"><span>ou</span></div>
<?php endif; ?>
<form method="post" action="/login<?= $returnTo ? ('?return_to=' . urlencode($returnTo)) : '' ?>" novalidate>
<input type="hidden" name="_csrf" value="<?= htmlspecialchars($csrf, ENT_QUOTES) ?>">
<div class="mb-3">
<label class="form-label" for="email">Adresse email</label>
<input class="form-control" id="email" type="email" name="email" required autocomplete="email" inputmode="email" placeholder="vous@domaine.tld">
</div>
<button class="btn btn-primary" type="submit">Recevoir un lien magique</button>
</form>
</body>
</html>
+68
View File
@@ -0,0 +1,68 @@
<?php
// projet : mug.a5l.fr
// fichier : pages/login/magic.php
// version : 20251011
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.php';
use App\Service\AuthService; // si tu as un service pour ouvrir une session
if (!function_exists('db')) {
function db(): PDO { return \App\Infrastructure\Database::get(); }
}
if (!function_exists('url')) {
function url(string $path = '/'): string {
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
return $scheme . '://' . $host . $path;
}
}
$token = (string)($_GET['token'] ?? '');
if ($token === '' || preg_match('/[^A-Za-z0-9\-\_]/', $token)) {
http_response_code(400);
exit('Lien invalide.');
}
$pdo = db();
$pdo->beginTransaction();
try {
// récupère lien non consommé et non expiré
$sql = "SELECT id, email, token, created_at, expires_at, consumed_at, return_to
FROM auth_magic_links
WHERE token = :t
FOR UPDATE";
$stmt = $pdo->prepare($sql);
$stmt->execute([':t' => $token]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) throw new RuntimeException('Lien inconnu.');
if ($row['consumed_at'] !== null) throw new RuntimeException('Lien déjà utilisé.');
if (strtotime((string)$row['expires_at']) < time()) throw new RuntimeException('Lien expiré.');
// consomme le lien
$pdo->prepare("UPDATE auth_magic_links SET consumed_at = NOW() WHERE id = :id")->execute([':id' => $row['id']]);
$pdo->commit();
// ouvre une session applicative « anonyme authentifiée par email »
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
$_SESSION['auth'] = [
'method' => 'magic',
'email' => (string)$row['email'],
'ts' => time(),
];
// Aucun create user ici, conforme à la demande
$dest = $row['return_to'] ?? '/';
// sécurité: ne renvoyer que des chemins relatifs
if (!is_string($dest) || !str_starts_with($dest, '/')) $dest = '/';
header('Location: ' . $dest, true, 303);
exit;
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
http_response_code(400);
echo htmlspecialchars($e->getMessage(), ENT_QUOTES);
}
+4
View File
@@ -0,0 +1,4 @@
<?php
// proxy vers pages/oidc/start.php avec flow=login
$_GET['flow'] = 'login';
require_once dirname(__DIR__) . '/oidc/start.php';
+179
View File
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
use App\Infrastructure\Database;
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.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;
}
}
$debug = (env('APP_DEBUG', '0') === '1');
// --- OIDC config ---
$OIDC_ISSUER = rtrim((string)env('OIDC_ISSUER', ''), '/');
$OIDC_CLIENT_ID = (string)env('OIDC_CLIENT_ID', '');
$OIDC_CLIENT_SECRET = (string)env('OIDC_CLIENT_SECRET', '');
$OIDC_REDIRECT_URI = (string)(env('OIDC_REDIRECT_URI') ?: url('oidc/callback'));
if (!$OIDC_ISSUER || !$OIDC_CLIENT_ID || !$OIDC_REDIRECT_URI) {
http_response_code(500);
echo $debug ? 'OIDC config manquante.' : 'Erreur.';
exit;
}
$tokenEndpoint = $OIDC_ISSUER . '/protocol/openid-connect/token';
$userInfoEndpoint = $OIDC_ISSUER . '/protocol/openid-connect/userinfo';
// --- Checks retour ---
if (!isset($_GET['state'], $_SESSION['oidc_state']) || !hash_equals((string)$_SESSION['oidc_state'], (string)$_GET['state'])) {
http_response_code(400);
echo $debug ? 'State invalide.' : 'Requête invalide.';
exit;
}
unset($_SESSION['oidc_state']);
if (empty($_GET['code'])) {
http_response_code(400);
echo $debug ? 'Code manquant.' : 'Requête invalide.';
exit;
}
$code = (string)$_GET['code'];
$codeVerifier = $_SESSION['oidc_code_verifier'] ?? null;
unset($_SESSION['oidc_code_verifier']); // anti-replay
$expectedNonce = $_SESSION['oidc_nonce'] ?? null;
unset($_SESSION['oidc_nonce']); // anti-replay
if (!$codeVerifier) {
http_response_code(400);
echo $debug ? 'PKCE code_verifier manquant.' : 'Requête invalide.';
exit;
}
// --- Échange code -> tokens ---
$post = [
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $OIDC_REDIRECT_URI,
'client_id' => $OIDC_CLIENT_ID,
'code_verifier' => $codeVerifier,
];
if ($OIDC_CLIENT_SECRET !== '') $post['client_secret'] = $OIDC_CLIENT_SECRET;
$ch = curl_init($tokenEndpoint);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($post, '', '&', PHP_QUERY_RFC3986),
CURLOPT_TIMEOUT => 15,
]);
$tokenResponse = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($tokenResponse === false || $httpCode !== 200) {
http_response_code(500);
echo $debug ? 'Échec échange token: ' . htmlspecialchars($err ?: (string)$tokenResponse) : 'Erreur dauthentification.';
exit;
}
$tokens = json_decode((string)$tokenResponse, true) ?: [];
$accessToken = $tokens['access_token'] ?? null;
$idToken = $tokens['id_token'] ?? null;
if (!$accessToken) {
http_response_code(500);
echo $debug ? 'Access token manquant.' : 'Erreur dauthentification.';
exit;
}
// --- UserInfo ---
$ch = curl_init($userInfoEndpoint);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $accessToken],
CURLOPT_TIMEOUT => 10,
]);
$userInfoResponse = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($userInfoResponse === false || $httpCode !== 200) {
http_response_code(500);
echo $debug ? 'Échec UserInfo: ' . htmlspecialchars((string)$userInfoResponse) : 'Erreur dauthentification.';
exit;
}
$claims = json_decode((string)$userInfoResponse, true) ?: [];
// --- Récup info utiles ---
$email = $claims['email'] ?? null;
$username = $claims['preferred_username'] ?? ($email ?: null);
$firstname = $claims['given_name'] ?? null;
$lastname = $claims['family_name'] ?? null;
if (!$email && $idToken && substr_count($idToken, '.') === 2) {
[, $p, ] = explode('.', $idToken, 3);
$payloadJson = base64_decode(strtr($p, '-_', '+/'), true);
$payload = $payloadJson ? json_decode($payloadJson, true) : null;
if (is_array($payload) && !empty($payload['email'])) $email = $payload['email'];
}
if (!$email) {
http_response_code(400);
echo $debug ? 'Email non fourni par IdP.' : 'Impossible de récupérer votre email.';
exit;
}
// --- Si l'utilisateur existe déjà -> connecter et redirect ---
$flow = $_SESSION['oidc_flow'] ?? 'login';
// Vérifie existence en base
/** @var \PDO $pdo */
$pdo = Database::get();
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
$stmt->execute([':email' => $email]);
$existingId = $stmt->fetchColumn();
// Si flow=login ET utilisateur existe → connexion directe
if ($flow === 'login' && $existingId) {
$_SESSION['user_id'] = (int)$existingId;
$_SESSION['user_email'] = $email;
$_SESSION['oidc'] = [
'issuer' => $OIDC_ISSUER,
'sub' => $claims['sub'] ?? null,
'access_token' => $accessToken,
'id_token' => $idToken,
'expires_at' => time() + (int)($tokens['expires_in'] ?? 3600),
];
$target = $_SESSION['oidc_return_to'] ?? '/';
unset($_SESSION['oidc_return_to'], $_SESSION['oidc_flow']);
if (!is_string($target) || $target === '' || $target[0] !== '/') $target = '/';
header('Location: ' . $target, true, 303);
exit;
}
// Sinon : go formulaire dinscription (pré-rempli)
$_SESSION['pending_oidc'] = [
'issuer' => $OIDC_ISSUER,
'sub' => $claims['sub'] ?? null,
'email' => $email,
'username' => $claims['preferred_username'] ?? ($email ?: null),
'firstname' => $claims['given_name'] ?? null,
'lastname' => $claims['family_name'] ?? null,
];
unset($_SESSION['oidc_flow']);
header('Location: ' . url('register/from-oidc'), true, 303);
exit;
+179
View File
@@ -0,0 +1,179 @@
<?php
// projet : mug.a5l.fr
// fichier : pages/oidc/me.php
// version : 20251005
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.php';
function maskToken(?string $t): string {
if (!$t) return '';
$len = strlen($t);
if ($len <= 12) return str_repeat('•', $len);
return substr($t, 0, 6) . str_repeat('•', max(0, $len - 12)) . substr($t, -6);
}
function b64url_decode_str(string $s): string|false {
$s = strtr($s, '-_', '+/');
$pad = strlen($s) % 4;
if ($pad) $s .= str_repeat('=', 4 - $pad);
return base64_decode($s, true);
}
function decode_jwt(string $jwt): array {
if (substr_count($jwt, '.') !== 2) return [];
[, $payload, ] = explode('.', $jwt, 3);
$json = b64url_decode_str($payload);
if ($json === false) return [];
$arr = json_decode($json, true);
return is_array($arr) ? $arr : [];
}
$env = static function(string $k, ?string $d = null): ?string {
if (array_key_exists($k, $_ENV) && $_ENV[$k] !== '') return (string)$_ENV[$k];
$v = getenv($k);
if ($v !== false && $v !== '') return (string)$v;
return $d;
};
$debugEnabled = ($env('DEBUG_OIDC') === 'true') || (isset($_GET['debug']) && $_GET['debug'] === '1');
$oidc = $_SESSION['oidc'] ?? [];
$claims = $_SESSION['oidc_userinfo'] ?? [];
$issuer = (string)($oidc['issuer'] ?? '');
$sub = (string)($oidc['sub'] ?? '');
$idToken = (string)($oidc['id_token'] ?? '');
$accTok = (string)($oidc['access_token'] ?? '');
$expAt = (int) ($oidc['expires_at'] ?? 0);
$now = time();
$left = $expAt ? max(0, $expAt - $now) : null;
// Fallback 1 : si pas de claims userinfo, essayer de les lire dans l'id_token
if (!$claims && $idToken) {
$claims = decode_jwt($idToken);
}
// Fallback 2 (debug) : tenter un appel live au UserInfo si access_token présent
if ($debugEnabled && $claims === [] && $accTok && $issuer) {
$userinfoEndpoint = rtrim($issuer, '/') . '/protocol/openid-connect/userinfo';
$ch = curl_init($userinfoEndpoint);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $accTok],
CURLOPT_TIMEOUT => 6,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($resp !== false && $code === 200) {
$tmp = json_decode((string)$resp, true);
if (is_array($tmp)) $claims = $tmp;
}
}
// Extraire rôles groupés (Keycloak)
$roles = [];
if (isset($claims['realm_access']['roles']) && is_array($claims['realm_access']['roles'])) {
$roles = array_merge($roles, $claims['realm_access']['roles']);
}
if (isset($claims['resource_access']) && is_array($claims['resource_access'])) {
foreach ($claims['resource_access'] as $clientId => $data) {
if (!empty($data['roles']) && is_array($data['roles'])) {
foreach ($data['roles'] as $r) {
$roles[] = $clientId . ':' . $r;
}
}
}
}
$roles = array_values(array_unique($roles));
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>OIDC • Profil</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/assets/bootstrap/bootstrap.min.css" rel="stylesheet">
<style>
.kv { display:grid; grid-template-columns: 220px 1fr; gap:.5rem 1rem; }
.kv dt { font-weight: 600; color: #555; }
pre { background: #f8f9fa; padding: .75rem; border-radius: .5rem; overflow:auto; }
</style>
</head>
<body class="container py-4">
<h1 class="mb-4">Profil A5L</h1>
<?php if (!$oidc): ?>
<div class="alert alert-warning">Aucune session A5L. Connecte-toi via A5L d'abord.</div>
<?php else: ?>
<div class="card mb-4">
<div class="card-header">Session / Jetons</div>
<div class="card-body">
<dl class="kv">
<dt>Issuer</dt><dd><?= htmlspecialchars($issuer) ?></dd>
<dt>Subject (sub)</dt><dd><?= htmlspecialchars($sub) ?></dd>
<dt>ID Token</dt><dd><code><?= htmlspecialchars(maskToken($idToken)) ?></code></dd>
<dt>Access Token</dt><dd><code><?= htmlspecialchars(maskToken($accTok)) ?></code></dd>
<dt>Expire à</dt><dd><?= $expAt ? date('Y-m-d H:i:s', $expAt) : '—' ?></dd>
<dt>Temps restant</dt><dd><?= $left !== null ? ($left . ' s') : '—' ?></dd>
</dl>
<?php if ($debugEnabled): ?>
<details class="mt-3">
<summary>Voir jetons non masqués (danger)</summary>
<div class="mt-2">
<div><strong>ID Token</strong></div>
<pre><?= htmlspecialchars($idToken) ?></pre>
<div><strong>Access Token</strong></div>
<pre><?= htmlspecialchars($accTok) ?></pre>
</div>
</details>
<?php endif; ?>
</div>
</div>
<div class="card mb-4">
<div class="card-header">Claims</div>
<div class="card-body">
<dl class="kv">
<dt>Email</dt><dd><?= htmlspecialchars((string)($claims['email'] ?? '')) ?></dd>
<dt>Preferred username</dt><dd><?= htmlspecialchars((string)($claims['preferred_username'] ?? '')) ?></dd>
<dt>Given name</dt><dd><?= htmlspecialchars((string)($claims['given_name'] ?? '')) ?></dd>
<dt>Family name</dt><dd><?= htmlspecialchars((string)($claims['family_name'] ?? '')) ?></dd>
<dt>Name</dt><dd><?= htmlspecialchars((string)($claims['name'] ?? '')) ?></dd>
<dt>Locale</dt><dd><?= htmlspecialchars((string)($claims['locale'] ?? '')) ?></dd>
<dt>Rôles</dt>
<dd>
<?php if ($roles): ?>
<ul class="mb-0">
<?php foreach ($roles as $r): ?>
<li><?= htmlspecialchars((string)$r) ?></li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<?php endif; ?>
</dd>
</dl>
<?php if ($debugEnabled): ?>
<h6 class="mt-3">Claims (JSON complet)</h6>
<pre><?= htmlspecialchars(json_encode($claims, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) ?></pre>
<?php endif; ?>
<?php if (!$claims): ?>
<div class="alert alert-info mt-3">
Aucun claim reçu. Vérifie que ton <code>callback</code> remplit bien <code>$_SESSION['oidc_userinfo']</code> ou que l<code>ID Token</code> contient les champs.
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<a class="btn btn-secondary" href="<?= htmlspecialchars(url('')) ?>">Retour</a>
</body>
</html>
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.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;
}
}
$flow = $_GET['flow'] ?? 'login'; // 'login' ou 'register'
if (!in_array($flow, ['login','register'], true)) $flow = 'login';
// return_to (URL relative uniquement)
$defaultReturn = '/';
$rawReturn = $_GET['return_to'] ?? ($_SERVER['HTTP_REFERER'] ?? $defaultReturn);
$returnTo = (is_string($rawReturn) && str_starts_with($rawReturn, '/')) ? $rawReturn : $defaultReturn;
// Mémorise flow + cible
$_SESSION['oidc_flow'] = $flow;
$_SESSION['oidc_return_to'] = $returnTo;
// --- OIDC conf ---
$issuer = rtrim((string)env('OIDC_ISSUER',''), '/');
$clientId = (string)env('OIDC_CLIENT_ID','');
$redirectUri = (string)(env('OIDC_REDIRECT_URI') ?: url('oidc/callback'));
if (!$issuer || !$clientId || !$redirectUri) {
http_response_code(500);
echo 'OIDC non configuré (OIDC_ISSUER / OIDC_CLIENT_ID / OIDC_REDIRECT_URI).';
exit;
}
// --- Endpoints & PKCE ---
$authEndpoint = $issuer . '/protocol/openid-connect/auth';
$state = bin2hex(random_bytes(16));
$nonce = bin2hex(random_bytes(16));
$codeVerifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
$_SESSION['oidc_state'] = $state;
$_SESSION['oidc_nonce'] = $nonce;
$_SESSION['oidc_code_verifier'] = $codeVerifier;
// --- URL dauth ---
$params = [
'response_type' => 'code',
'client_id' => $clientId,
'redirect_uri' => $redirectUri,
'scope' => 'openid email profile',
'state' => $state,
'nonce' => $nonce,
'code_challenge' => $codeChallenge,
'code_challenge_method' => 'S256',
'ui_locales' => 'fr',
];
header('Location: ' . $authEndpoint . '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986), true, 302);
exit;
+142
View File
@@ -0,0 +1,142 @@
<?php
define('BASE_PATH', realpath(__DIR__ . '/../'));
require_once BASE_PATH . '/src/db.php';
require_once BASE_PATH . '/src/PostManager.php';
require_once BASE_PATH . '/src/FileManager.php';
$action = $_GET['action'] ?? 'list';
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
$postManager = new PostManager($db);
$fileManager = new FileManager($db, __DIR__ . '/assets/uploads');
// Gérer les accès
// les fonctions create, delete, edit doit être autorisée aux personnes dont les roles leur permette
// Afficher la bonne page
switch ($action) {
case 'create':
$title = $_POST['title'] ?? '';
$content = $_POST['content'] ?? '';
$published_at = $_POST['published_at'] ?? date('Y-m-d H:i:s');
$published_at = str_replace('T', ' ', $published_at); // conversion HTML -> SQL
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (trim($title) === '') {
$errors[] = 'Le titre est obligatoire.';
}
if (empty($errors)) {
$postId = $postManager->create($title, $content, $published_at);
if (!empty($_FILES['files']['name'][0])) {
foreach ($_FILES['files']['tmp_name'] as $i => $tmpName) {
if ($_FILES['files']['error'][$i] === UPLOAD_ERR_OK) {
$file = [
'name' => $_FILES['files']['name'][$i],
'type' => $_FILES['files']['type'][$i],
'tmp_name' => $_FILES['files']['tmp_name'][$i],
'error' => $_FILES['files']['error'][$i],
'size' => $_FILES['files']['size'][$i],
];
$fileManager->upload($postId, $file);
}
}
}
header("Location: route.php");
exit;
}
}
$formAction = 'route.php?action=create';
$action = 'create';
include BASE_PATH . '/templates/post_form.php';
break;
case 'view':
if (!$id) {
echo "ID manquant.";
exit;
}
$post = $postManager->get($id);
if (!$post) {
echo "Post introuvable.";
exit;
}
include __DIR__ . '/../templates/post_view.php';
break;
case 'delete':
if ($id) {
$postManager->delete($id);
}
header("Location: route.php");
exit;
case 'edit':
if (!$id) {
echo "ID manquant.";
exit;
}
$post = $postManager->get($id);
if (!$post) {
echo "Post introuvable.";
exit;
}
$title = $_POST['title'] ?? $post['title'];
$content = $_POST['content'] ?? $post['content'];
$published_at = $_POST['published_at'] ?? date('Y-m-d\TH:i', strtotime($post['created_at']));
$published = isset($_POST['published']) ? true : $post['is_published'];
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (trim($title) === '') {
$errors[] = 'Le titre est obligatoire.';
}
if (empty($errors)) {
$published_at_sql = str_replace('T', ' ', $_POST['published_at']);
$postManager->update($id, $title, $content, $published_at_sql, $published);
if (!empty($_FILES['files']['name'][0])) {
foreach ($_FILES['files']['tmp_name'] as $i => $tmpName) {
if ($_FILES['files']['error'][$i] === UPLOAD_ERR_OK) {
$file = [
'name' => $_FILES['files']['name'][$i],
'type' => $_FILES['files']['type'][$i],
'tmp_name' => $_FILES['files']['tmp_name'][$i],
'error' => $_FILES['files']['error'][$i],
'size' => $_FILES['files']['size'][$i],
];
$fileManager->upload($id, $file);
}
}
}
header("Location: route.php?action=view&id=$id");
exit;
}
}
$formAction = "route.php?action=edit&id=$id";
$action = 'edit';
include BASE_PATH . '/templates/post_form.php';
break;
case 'list':
default:
$posts = $postManager->getAll();
include BASE_PATH . '/templates/post_list.php';
break;
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Domain;
final class User
{
public function __construct(
public string $id,
public string $email,
public string $passwordHash,
public bool $isActive = true,
) {}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
class FileManager
{
private PDO $db;
private string $uploadDir;
public function __construct(PDO $db, string $uploadDir)
{
$this->db = $db;
$this->uploadDir = rtrim($uploadDir, '/');
}
public function upload(int $postId, array $file): ?int
{
if ($file['error'] !== UPLOAD_ERR_OK) {
return null;
}
$type = $this->guessType($file['type']);
$originalName = basename($file['name']);
$ext = pathinfo($originalName, PATHINFO_EXTENSION);
$filename = uniqid('file_') . '.' . $ext;
$destination = $this->uploadDir . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $destination)) {
return null;
}
$stmt = $this->db->prepare("
INSERT INTO post_files (post_id, file_type, file_path, original_name)
VALUES (:post_id, :file_type, :file_path, :original_name)
");
$stmt->execute([
'post_id' => $postId,
'file_type' => $type,
'file_path' => $filename,
'original_name' => $originalName
]);
return (int)$this->db->lastInsertId();
}
public function getFilesForPost(int $postId): array
{
$stmt = $this->db->prepare("SELECT * FROM post_files WHERE post_id = :post_id ORDER BY uploaded_at");
$stmt->execute(['post_id' => $postId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function delete(int $fileId): bool
{
$stmt = $this->db->prepare("SELECT file_path FROM post_files WHERE id = :id");
$stmt->execute(['id' => $fileId]);
$file = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$file) {
return false;
}
$fullPath = $this->uploadDir . '/' . $file['file_path'];
if (file_exists($fullPath)) {
unlink($fullPath);
}
$stmt = $this->db->prepare("DELETE FROM post_files WHERE id = :id");
return $stmt->execute(['id' => $fileId]);
}
private function guessType(string $mime): string
{
if (str_starts_with($mime, 'image/')) return 'image';
if (str_starts_with($mime, 'video/')) return 'video';
if (str_starts_with($mime, 'audio/')) return 'audio';
return 'file';
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http;
final class Csrf
{
private const KEY = '_csrf';
public static function token(): string
{
$t = bin2hex(random_bytes(32));
$_SESSION[self::KEY] = $t;
return $t;
}
public static function validate(?string $token): bool
{
$ok = is_string($token) && isset($_SESSION[self::KEY]) && hash_equals($_SESSION[self::KEY], $token);
unset($_SESSION[self::KEY]); // onetime token
return $ok;
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure;
use PDO;
use PDOException;
use RuntimeException;
final class Database
{
private static ?PDO $pdo = null;
public static function get(): PDO
{
if (self::$pdo instanceof PDO) {
return self::$pdo;
}
$get = static function (string $k, ?string $default=null): ?string {
$v = getenv($k);
if ($v !== false && $v !== '') return (string)$v;
return $_ENV[$k] ?? $default;
};
$dsn = $get('DB_DSN');
$user = $get('DB_USER');
$pass = $get('DB_PASS');
if (!$dsn) {
$host = $get('DB_HOST', 'localhost');
$port = $get('DB_PORT', '5432');
$name = $get('DB_NAME');
if ($name) $dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', $host, $port, $name);
}
if (!$dsn) {
throw new RuntimeException('DB_DSN manquant (ni DB_DSN ni DB_HOST/DB_PORT/DB_NAME).');
}
try {
$pdo = new PDO($dsn, (string)($user ?? ''), (string)($pass ?? ''), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return self::$pdo = $pdo;
} catch (PDOException $e) {
throw new RuntimeException('Connexion BDD échouée.', previous: $e);
}
}
/** @deprecated Utiliser Database::get() */
public static function pdo(): PDO
{
@trigger_error(__METHOD__.' est déprécié. Utiliser Database::get()', E_USER_DEPRECATED);
return self::get();
}
/** @deprecated Utiliser Database::get() */
public static function getPdo(): PDO
{
@trigger_error(__METHOD__.' est déprécié. Utiliser Database::get()', E_USER_DEPRECATED);
return self::get();
}
/** @deprecated Utiliser Database::get() */
public static function getInstance(): PDO
{
@trigger_error(__METHOD__.' est déprécié. Utiliser Database::get()', E_USER_DEPRECATED);
return self::get();
}
public static function transactional(callable $fn)
{
$pdo = self::get();
try {
$pdo->beginTransaction();
$ret = $fn($pdo);
$pdo->commit();
return $ret;
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure;
use PDO;
final class DbAdapter
{
public static function pdo(): PDO
{
if (!empty($GLOBALS['pdo']) && $GLOBALS['pdo'] instanceof PDO) {
return $GLOBALS['pdo'];
}
$dsn = getenv('DB_DSN') ?: ($_ENV['DB_DSN'] ?? null);
$user = getenv('DB_USER') ?: ($_ENV['DB_USER'] ?? null);
$pass = getenv('DB_PASS') ?: ($_ENV['DB_PASS'] ?? null);
if (!$dsn) {
$host = getenv('DB_HOST') ?: ($_ENV['DB_HOST'] ?? 'localhost');
$port = getenv('DB_PORT') ?: ($_ENV['DB_PORT'] ?? '5432');
$name = getenv('DB_NAME') ?: ($_ENV['DB_NAME'] ?? null);
if ($name) $dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', $host, $port, $name);
}
if (!$dsn) throw new \RuntimeException('Aucun DSN pour initialiser PDO');
return new PDO($dsn, (string)$user, (string)$pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure;
final class Session
{
public static function startSecure(string $name): void
{
if (session_status() === PHP_SESSION_ACTIVE) return;
session_name($name);
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
]);
session_start();
// Verrouillage basique contre session hijacking
$key = '_sess_fingerprint';
$fp = hash('xxh3', ($_SERVER['REMOTE_ADDR'] ?? '') . '|' . ($_SERVER['HTTP_USER_AGENT'] ?? ''));
if (!isset($_SESSION[$key])) {
$_SESSION[$key] = $fp;
} elseif ($_SESSION[$key] !== $fp) {
session_regenerate_id(true);
$_SESSION = [];
$_SESSION[$key] = $fp;
}
}
public static function regenerate(): void
{
session_regenerate_id(true);
}
}
+1994
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
<?php
class PostManager
{
private PDO $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
public function getAll(): array
{
$stmt = $this->db->query("SELECT * FROM posts ORDER BY created_at DESC");
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function get(int $id): ?array
{
$stmt = $this->db->prepare("SELECT * FROM posts WHERE id = :id");
$stmt->execute(['id' => $id]);
$post = $stmt->fetch(PDO::FETCH_ASSOC);
return $post ?: null;
}
public function create(string $title, string $content, string $published_at): int
{
$stmt = $this->db->prepare("
INSERT INTO posts (title, content, created_at, is_published)
VALUES (:title, :content, :published_at, true)
");
$stmt->execute([
'title' => $title,
'content' => $content,
'published_at' => $published_at,
]);
return (int)$this->db->lastInsertId();
}
public function update(int $id, string $title, string $content, string $published_at, bool $published): bool
{
$stmt = $this->db->prepare("
UPDATE posts
SET title = :title,
content = :content,
created_at = :published_at,
is_published = :published,
updated_at = NOW()
WHERE id = :id
");
return $stmt->execute([
'id' => $id,
'title' => $title,
'content' => $content,
'published_at' => $published_at,
'published' => $published,
]);
}
public function delete(int $id): bool
{
$stmt = $this->db->prepare("DELETE FROM posts WHERE id = :id");
return $stmt->execute(['id' => $id]);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use PDO;
final class DictionaryRepository
{
public function __construct(private PDO $pdo) {}
public function getEntityByCode(string $code): ?array {
$st = $this->pdo->prepare('SELECT * FROM dd_entities WHERE code = :c AND is_active IS TRUE');
$st->execute([':c'=>$code]);
$e = $st->fetch(PDO::FETCH_ASSOC);
if (!$e) return null;
$e['fields'] = $this->getFields((int)$e['id']);
$e['rules'] = $this->getRules((int)$e['id']);
return $e;
}
public function getFields(int $entityId): array {
$st = $this->pdo->prepare('SELECT * FROM dd_fields WHERE entity_id = :id ORDER BY ui_order NULLS LAST, id');
$st->execute([':id'=>$entityId]);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
public function getRules(int $entityId): array {
$st = $this->pdo->prepare('SELECT * FROM dd_rules WHERE entity_id = :id AND active IS TRUE');
$st->execute([':id'=>$entityId]);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
public function getEnum(string $name): array {
$st = $this->pdo->prepare('
SELECT ev.code, ev.label
FROM dd_enums e JOIN dd_enum_values ev ON ev.enum_id = e.id
WHERE e.name = :n AND ev.active IS TRUE
ORDER BY ev.sort_order, ev.id
');
$st->execute([':n'=>$name]);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use PDO;
use App\Infrastructure\Database;
final class ProfileRepository
{
private PDO $pdo;
public function __construct(?PDO $pdo = null)
{
// 0) DI directe
if ($pdo instanceof PDO) { $this->pdo = $pdo; return; }
// 1) App\Infrastructure\Database (si elle expose quelque chose)
if (class_exists(Database::class)) {
if (method_exists(Database::class, 'pdo')) {
$try = Database::pdo();
if ($try instanceof PDO) { $this->pdo = $try; return; }
}
if (method_exists(Database::class, 'getPdo')) {
$try = Database::getPdo();
if ($try instanceof PDO) { $this->pdo = $try; return; }
}
if (method_exists(Database::class, 'getInstance')) {
$db = Database::getInstance();
if ($db instanceof PDO) { $this->pdo = $db; return; }
if (is_object($db) && method_exists($db, 'pdo')) {
$try = $db->pdo();
if ($try instanceof PDO) { $this->pdo = $try; return; }
}
}
}
// 2) Fonction globale éventuelle
if (function_exists('db')) {
$try = db();
if ($try instanceof PDO) { $this->pdo = $try; return; }
}
// 3) Variable globale éventuelle
if (!empty($GLOBALS['pdo']) && $GLOBALS['pdo'] instanceof PDO) {
$this->pdo = $GLOBALS['pdo'];
return;
}
// 4) Fallback env/const : compose un DSN pgsql si nécessaire
$dsn = getenv('DB_DSN') ?: ($_ENV['DB_DSN'] ?? null);
$user = getenv('DB_USER') ?: ($_ENV['DB_USER'] ?? null);
$pass = getenv('DB_PASS') ?: ($_ENV['DB_PASS'] ?? null);
if (!$dsn) {
$host = getenv('DB_HOST') ?: ($_ENV['DB_HOST'] ?? 'localhost');
$port = getenv('DB_PORT') ?: ($_ENV['DB_PORT'] ?? '5432');
$name = getenv('DB_NAME') ?: ($_ENV['DB_NAME'] ?? null);
if ($name) {
$dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', $host, $port, $name);
}
}
if ($dsn) {
$pdo = new PDO($dsn, (string)$user, (string)$pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$this->pdo = $pdo;
return;
}
throw new \RuntimeException('Impossible dobtenir un PDO (aucune source valide trouvée).');
}
public function all(?bool $onlyActive = null): array
{
$sql = 'SELECT * FROM profiles';
if ($onlyActive !== null) {
$sql .= ' WHERE is_active = :act';
}
$sql .= ' ORDER BY slug';
$stmt = $this->pdo->prepare($sql);
if ($onlyActive !== null) {
$stmt->bindValue(':act', $onlyActive, PDO::PARAM_BOOL);
}
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
public function findById(int $id): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM profiles WHERE id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public function findBySlug(string $slug): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM profiles WHERE slug = :slug');
$stmt->execute([':slug' => $slug]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public function create(string $slug, string $label, ?string $description, array $permissions, bool $isSystem, bool $isActive): int
{
$stmt = $this->pdo->prepare('INSERT INTO profiles(slug,label,description,permissions,is_system,is_active) VALUES(:slug,:label,:desc,CAST(:perms AS jsonb),:sys,:act) RETURNING id');
$stmt->execute([
':slug' => $slug,
':label' => $label,
':desc' => $description,
':perms' => json_encode($permissions, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES),
':sys' => $isSystem,
':act' => $isActive,
]);
return (int)$stmt->fetchColumn();
}
public function update(int $id, string $slug, string $label, ?string $description, array $permissions, bool $isSystem, bool $isActive): void
{
$stmt = $this->pdo->prepare('UPDATE profiles SET slug=:slug,label=:label,description=:desc,permissions=CAST(:perms AS jsonb),is_system=:sys,is_active=:act WHERE id=:id');
$stmt->execute([
':id' => $id,
':slug' => $slug,
':label' => $label,
':desc' => $description,
':perms' => json_encode($permissions, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES),
':sys' => $isSystem,
':act' => $isActive,
]);
}
public function delete(int $id): void
{
$stmt = $this->pdo->prepare('DELETE FROM profiles WHERE id=:id AND is_system = FALSE');
$stmt->execute([':id' => $id]);
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Domain\User;
use PDO;
final class UserRepository
{
public function __construct(private PDO $pdo) {}
/**
* Crée (si besoin) un utilisateur OIDC.
* - Idempotent par email : si existe, retourne l'id existant.
* - Génère un password_hash aléatoire inutilisable (compte OIDC).
*
* @return string ID (uuid) sous forme de chaîne
*/
public function createFromOidc(string $email): string
{
$email = strtolower(trim($email));
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Email OIDC invalide.');
}
// 1) Existe déjà ?
$st = $this->pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
$st->execute([':email' => $email]);
$id = $st->fetchColumn();
if ($id !== false && $id !== null) {
return (string)$id;
}
// 2) Création
// Génère un hash robuste sur une valeur aléatoire (aucune chance de connexion par mot de passe).
$randomSecret = bin2hex(random_bytes(32));
$randomHash = password_hash($randomSecret, PASSWORD_DEFAULT);
$sql = <<<SQL
INSERT INTO users (email, password_hash)
VALUES (:email, :hash)
RETURNING id
SQL;
try {
$st = $this->pdo->prepare($sql);
$st->execute([
':email' => $email,
':hash' => $randomHash,
]);
return (string)$st->fetchColumn();
} catch (PDOException $e) {
// Unique violation sur email (23505) → on relit lid (race condition)
if ($e->getCode() === '23505') {
$st = $this->pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
$st->execute([':email' => $email]);
$id = $st->fetchColumn();
if ($id !== false && $id !== null) {
return (string)$id;
}
}
throw $e;
}
}
private function nullIfEmpty(?string $v): ?string
{
$v = trim((string)$v);
return $v === '' ? null : $v;
}
public function findByEmail(string $email): ?User
{
$sql = 'SELECT id, email, password_hash, is_active FROM users WHERE email = :email LIMIT 1';
$st = $this->pdo->prepare($sql);
$st->execute([':email' => $email]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
$isActive = $this->toBool($row['is_active']);
return new User(
(string)$row['id'],
(string)$row['email'],
(string)$row['password_hash'],
$isActive
);
}
public function create(string $email, string $passwordHash): string
{
// PostgreSQL
$sql = 'INSERT INTO users (email, password_hash) VALUES (:email, :hash) RETURNING id';
$st = $this->pdo->prepare($sql);
$st->execute([':email' => $email, ':hash' => $passwordHash]);
return (string)$st->fetchColumn();
}
public function updatePassword(string $userId, string $newHash): void
{
$sql = <<<SQL
UPDATE users
SET password_hash = :h,
updated_at = NOW(),
password_changed_at = NOW()
WHERE id = :id
SQL;
$st = $this->pdo->prepare($sql);
$st->execute([':h' => $newHash, ':id' => $userId]);
}
/**
* Normalise un bool venant de PDO/pgsql ('t','f',1,0,true,false,'1','0','true','false')
*/
private function toBool(mixed $v): bool
{
if (is_bool($v)) {
return $v;
}
if (is_int($v)) {
return $v === 1;
}
if (is_string($v)) {
$v = strtolower($v);
return in_array($v, ['t', '1', 'true', 'on', 'yes'], true);
}
return (bool)$v;
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Repository\UserRepository;
final class AuthService
{
public function __construct(private UserRepository $users) {}
public function canAttempt(string $email, string $ip): bool
{
// backoff: 5 dernières tentatives/5 min
$sql = "select count(*)
from login_attempts
where ip = :ip
and attempted_at > now() - interval '5 minutes'
and success = false";
$st = \App\Infrastructure\Database::pdo()->prepare($sql);
$st->execute([':ip' => $ip]);
$fails = (int)$st->fetchColumn();
return $fails < 10; // à ajuster
}
public function login(string $email, string $password, string $ip): bool
{
$user = $this->users->findByEmail($email);
$ok = $user && $user->isActive && password_verify($password, $user->passwordHash);
$pdo = \App\Infrastructure\Database::pdo();
$st = $pdo->prepare('insert into login_attempts(email, ip, success) values(:e, :ip, :s)');
$st->bindValue(':e', $email, \PDO::PARAM_STR);
$st->bindValue(':ip', $ip, \PDO::PARAM_STR);
$st->bindValue(':s', $ok, \PDO::PARAM_BOOL);
$st->execute();
if ($ok) {
\App\Infrastructure\Session::regenerate();
$_SESSION['uid'] = $user->id;
$_SESSION['email'] = $user->email;
}
return $ok;
}
public function changePassword(string $userId, string $currentPassword, string $newPassword): bool
{
// Récupération de lutilisateur (rapide : requête directe ; tu peux créer findById() si tu préfères)
$pdo = \App\Infrastructure\Database::pdo();
$st = $pdo->prepare('select id, email, password_hash, is_active from users where id = :id');
$st->execute([':id' => $userId]);
$row = $st->fetch(\PDO::FETCH_ASSOC);
if (!$row || !(bool)$row['is_active']) return false;
// Vérifier lancien mot de passe
if (!password_verify($currentPassword, (string)$row['password_hash'])) {
return false;
}
// Politique minimale : longueur uniquement (espaces autorisés)
if (mb_strlen($newPassword) < 7) {
return false;
}
// (optionnel) interdire seulement le caractère NUL
if (strpos($newPassword, "\0") !== false) {
return false;
}
// Mettre à jour le hash
$newHash = password_hash($newPassword, PASSWORD_ARGON2ID);
(new \App\Repository\UserRepository())->updatePassword($row['id'], $newHash);
// (Optionnel) rotation session
\App\Infrastructure\Session::regenerate();
return true;
}
public function register(string $email, string $password): string
{
$hash = password_hash($password, PASSWORD_ARGON2ID);
return $this->users->create($email, $hash);
}
public static function requireAuth(): void
{
if (!isset($_SESSION['uid'])) {
header('Location: /login');
exit;
}
}
public static function logout(): void
{
$_SESSION = [];
session_destroy();
}
}
+219
View File
@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Infrastructure\Database;
use PDO;
use Throwable;
use DateTimeImmutable;
/**
* File d'attente SMTP.
*
* Responsabilités :
* - Enregistrer des emails à envoyer (enqueue)
* - Réserver/traiter par batch en évitant les collisions (SKIP LOCKED)
* - Gérer les retries avec backoff exponentiel + plafond
*
* Dépendances :
* - App\Infrastructure\Database (PDO)
* - App\Service\MailService (pour l'envoi réel + journal)
*/
final class MailQueue
{
/** Backoff exponentiel de base (en secondes) : 60s, 120s, 240s, ... */
private const BASE_BACKOFF_SECONDS = 60;
/** Délai max avant retry (plafond) */
private const MAX_BACKOFF_SECONDS = 86400; // 24h
/** Durée de "lease" (verrou doux) pendant le traitement */
private const LEASE_SECONDS = 120;
private PDO $pdo;
private MailService $mailService;
public function __construct(Database $db, MailService $mailService)
{
$this->pdo = $db->getConnection();
$this->mailService = $mailService;
}
/**
* Ajoute un email dans la file.
*
* @param array{delay?:int} $options delay en secondes avant éligibilité (facultatif)
*/
public function enqueue(string $to, string $subject, string $body, array $options = []): int
{
$delay = max(0, (int)($options['delay'] ?? 0));
$sql = <<<SQL
INSERT INTO mail_queue (to_email, subject, body, available_at)
VALUES (:to, :subject, :body, (NOW() AT TIME ZONE 'UTC') + (:delay || ' seconds')::interval)
RETURNING id
SQL;
$stmt = $this->pdo->prepare($sql);
$stmt->execute([
':to' => trim($to),
':subject' => $subject,
':body' => $body,
':delay' => $delay,
]);
/** @var int $id */
$id = (int) $stmt->fetchColumn();
return $id;
}
/**
* Traite la file en batch.
* - Récupère jusqu'à $max jobs "disponibles"
* - Pose un lease (locked_at) pour éviter les doublons de traitement
* - Envoie via MailService
* - Met à jour le statut (sent/failed) + planifie un retry si besoin
*
* @return array{processed:int, sent:int, failed:int, retried:int}
*/
public function process(int $max = 100): array
{
$max = max(1, min(1000, $max));
$jobs = $this->reserveBatch($max);
$processed = $sent = $failed = $retried = 0;
foreach ($jobs as $job) {
$processed++;
$ok = false;
try {
// Anti-abus est appliqué par MailService::send()
$ok = $this->mailService->send($job['to_email'], $job['subject'], $job['body']);
} catch (Throwable $e) {
// On traitera en "retry" sous ce catch
$this->appendError((int)$job['id'], 'unexpected: ' . $e->getMessage());
}
if ($ok) {
$this->markAsSent((int)$job['id']);
$sent++;
} else {
// incrémente attempts et programme retry/backoff
$didRetry = $this->scheduleRetry((int)$job['id'], (int)$job['attempts'] + 1);
if ($didRetry) {
$retried++;
} else {
$this->markAsFailed((int)$job['id']);
$failed++;
}
}
}
return compact('processed', 'sent', 'failed', 'retried');
}
/**
* Réserve jusqu'à $max jobs prêts (pending) en posant locked_at (lease).
* Utilise FOR UPDATE SKIP LOCKED pour le parallélisme côté SQL.
*
* @return array<int, array{id:int,to_email:string,subject:string,body:string,attempts:int}>
*/
private function reserveBatch(int $max): array
{
// 1) Sélection des candidats
$this->pdo->beginTransaction();
try {
$sql = <<<SQL
SELECT id, to_email, subject, body, attempts
FROM mail_queue
WHERE status = 'pending'
AND available_at <= (NOW() AT TIME ZONE 'UTC')
AND (locked_at IS NULL OR locked_at <= (NOW() AT TIME ZONE 'UTC') - INTERVAL ':lease seconds')
ORDER BY available_at ASC, id ASC
FOR UPDATE SKIP LOCKED
LIMIT :max
SQL;
// Interpolation sécurisée du LEASE_SECONDS pour l'INTERVAL
$sql = str_replace(':lease', (string) self::LEASE_SECONDS, $sql);
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':max', $max, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
if ($rows) {
// 2) Marquer locked_at + sending
$ids = array_map(static fn($r) => (int)$r['id'], $rows);
$in = implode(',', array_fill(0, count($ids), '?'));
$up = $this->pdo->prepare(
"UPDATE mail_queue
SET locked_at = (NOW() AT TIME ZONE 'UTC'),
status = 'sending'
WHERE id IN ($in)"
);
$up->execute($ids);
}
$this->pdo->commit();
return $rows;
} catch (Throwable $e) {
$this->pdo->rollBack();
return [];
}
}
private function markAsSent(int $id): void
{
$stmt = $this->pdo->prepare("UPDATE mail_queue SET status='sent', locked_at=NULL WHERE id=:id");
$stmt->execute([':id' => $id]);
}
private function markAsFailed(int $id): void
{
$stmt = $this->pdo->prepare("UPDATE mail_queue SET status='failed', locked_at=NULL WHERE id=:id");
$stmt->execute([':id' => $id]);
}
/**
* Définit la prochaine tentative avec backoff exponentiel plafonné.
* Retourne false si on considère que c'est "trop" et qu'il faut passer en failed.
*/
private function scheduleRetry(int $id, int $nextAttempt): bool
{
// Politique simple : jusqu'à 8 tentatives (≈ ~4h de backoff cumulé, puis plafond 24h)
if ($nextAttempt > 8) {
return false;
}
$delay = min(self::BASE_BACKOFF_SECONDS * (2 ** ($nextAttempt - 1)), self::MAX_BACKOFF_SECONDS);
$sql = <<<SQL
UPDATE mail_queue
SET attempts = :attempts,
status = 'pending',
locked_at = NULL,
available_at = (NOW() AT TIME ZONE 'UTC') + (:delay || ' seconds')::interval
WHERE id = :id
SQL;
$stmt = $this->pdo->prepare($sql);
$stmt->execute([
':attempts' => $nextAttempt,
':delay' => $delay,
':id' => $id,
]);
return true;
}
private function appendError(int $id, string $message): void
{
$sql = "UPDATE mail_queue SET last_error = COALESCE(last_error,'') || :e || E'\n' WHERE id = :id";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([
':e' => '[' . (new DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('c') . '] ' . $message,
':id' => $id,
]);
}
}
+291
View File
@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Infrastructure\Database;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception as MailException;
use PDO;
use DateInterval;
use DateTimeImmutable;
/**
* Service d'envoi d'emails via SMTP avec journalisation en base (journal_smtp)
*
* Dépendances :
* - PHPMailer\PHPMailer (SMTP)
* - App\Infrastructure\Database (retourne un PDO)
*
* Anti-abus (larges) :
* - Min. 5 minutes entre deux envois au même destinataire
* - Max. 5 envois au même destinataire sur 12 heures
* - Garde-fous globaux (désactivables) : max X envois / heure
*/
final class MailService
{
/** Règles anti-abus (ajustables) */
private const MIN_INTERVAL_BETWEEN_SENDS_SECONDS = 300; // 5 minutes
private const MAX_SENDS_PER_12H_PER_RECIPIENT = 5; // 5 en 12h
private const MAX_GLOBAL_PER_HOUR = 200; // global guardrail (0 pour désactiver)
private const MAX_SUBJECT_LEN = 255; // coupe en journal
private const MAX_BODY_LEN_FOR_LOG = 50000; // évite de gaver la base
private PDO $pdo;
private PHPMailer $mailer;
/** @var array<string, mixed> */
private array $smtpConfig;
/**
* @param Database $db Retourne un PDO connecté (PostgreSQL recommandé)
* @param array<string,mixed> $smtpConfig [
* 'host' => 'smtp.example.tld',
* 'port' => 587,
* 'username' => 'user',
* 'password' => 'pass',
* 'encryption' => 'tls'|'ssl'|null,
* 'from' => 'no-reply@example.tld',
* 'from_name' => 'Mon appli',
* 'reply_to' => 'contact@example.tld' (optionnel),
* 'reply_to_name' => 'Support' (optionnel),
* 'smtp_options' => [...] (optionnel, cf. PHPMailer::SMTPOptions)
* ]
*/
public function __construct(Database $db, array $smtpConfig)
{
$this->pdo = $db->getConnection();
$this->smtpConfig = $smtpConfig;
$this->mailer = new PHPMailer(true);
$this->configureMailer($this->mailer, $smtpConfig);
}
/**
* Envoie un mail et journalise l'opération dans journal_smtp
*/
public function send(string $to, string $subject, string $body): bool
{
$to = trim($to);
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
$this->log('blocked', $to, $subject, $body, 'invalid_email');
return false;
}
if (!$this->passesRateLimits($to)) {
$this->log('blocked', $to, $subject, $body, 'rate_limited');
return false;
}
try {
$this->mailer->clearAddresses();
$this->mailer->clearReplyTos();
$this->mailer->addAddress($to);
if (!empty($this->smtpConfig['reply_to'])) {
$this->mailer->addReplyTo(
(string) $this->smtpConfig['reply_to'],
(string)($this->smtpConfig['reply_to_name'] ?? '')
);
}
$this->mailer->Subject = $subject;
$this->mailer->Body = $body;
$this->mailer->AltBody = $this->buildAltBody($body);
$this->mailer->isHTML($this->looksLikeHtml($body));
$sent = $this->mailer->send();
$this->log(
$sent ? 'sent' : 'error',
$to,
$subject,
$body,
$sent ? null : 'phpmailer_send_returned_false',
$this->mailer->getLastMessageID() ?: null
);
return $sent;
} catch (MailException $e) {
$this->log('error', $to, $subject, $body, 'phpmailer_exception: ' . $e->getMessage());
return false;
} catch (\Throwable $e) {
$this->log('error', $to, $subject, $body, 'unexpected: ' . $e->getMessage());
return false;
}
}
/**
* Retourne les derniers envois (journal)
* @return array<int, array<string, mixed>>
*/
public function list(int $limit = 50): array
{
$limit = max(1, min(500, $limit));
$sql = <<<SQL
SELECT id, created_at, script, recipient, subject, status, error, smtp_host, smtp_user, message_id
FROM journal_smtp
ORDER BY created_at DESC, id DESC
LIMIT :limit
SQL;
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
/** @var array<int, array<string,mixed>> $rows */
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return $rows ?: [];
}
// ---------- Internals ----------
/**
* @param array<string,mixed> $cfg
*/
private function configureMailer(PHPMailer $m, array $cfg): void
{
$m->isSMTP();
$m->Host = (string) $cfg['host'];
$m->Port = (int) ($cfg['port'] ?? 587);
$m->SMTPAuth = true;
$m->Username = (string) $cfg['username'];
$m->Password = (string) $cfg['password'];
$m->SMTPSecure = $cfg['encryption'] ?? PHPMailer::ENCRYPTION_STARTTLS;
if (!empty($cfg['smtp_options']) && is_array($cfg['smtp_options'])) {
$m->SMTPOptions = $cfg['smtp_options'];
}
$from = (string) ($cfg['from'] ?? $cfg['username']);
$fromName = (string) ($cfg['from_name'] ?? '');
$m->setFrom($from, $fromName);
// Hygiène SMTP
$m->CharSet = 'UTF-8';
$m->Encoding = 'base64';
$m->Timeout = 15; // secondes
}
/** Règles anti-abus : per-recipient + garde-fous globaux */
private function passesRateLimits(string $recipient): bool
{
// 1) Min interval per destinataire (5 min)
$sql1 = <<<SQL
SELECT created_at
FROM journal_smtp
WHERE recipient = :r
AND status IN ('sent','error','blocked') -- tout envoi/essai compte
ORDER BY created_at DESC
LIMIT 1
SQL;
$stmt1 = $this->pdo->prepare($sql1);
$stmt1->execute([':r' => $recipient]);
$last = $stmt1->fetchColumn();
if ($last) {
$lastTs = (new DateTimeImmutable((string) $last))->getTimestamp();
$delta = time() - $lastTs;
if ($delta < self::MIN_INTERVAL_BETWEEN_SENDS_SECONDS) {
return false;
}
}
// 2) Max 5 en 12h par destinataire
$sql2 = <<<SQL
SELECT COUNT(*)::int
FROM journal_smtp
WHERE recipient = :r
AND created_at >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '12 hours'
AND status = 'sent'
SQL;
$stmt2 = $this->pdo->prepare($sql2);
$stmt2->execute([':r' => $recipient]);
$count12h = (int) $stmt2->fetchColumn();
if ($count12h >= self::MAX_SENDS_PER_12H_PER_RECIPIENT) {
return false;
}
// 3) Garde-fou global / heure (optionnel)
if (self::MAX_GLOBAL_PER_HOUR > 0) {
$sql3 = <<<SQL
SELECT COUNT(*)::int
FROM journal_smtp
WHERE created_at >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '1 hour'
AND status = 'sent'
SQL;
$stmt3 = $this->pdo->query($sql3);
$global1h = (int) $stmt3->fetchColumn();
if ($global1h >= self::MAX_GLOBAL_PER_HOUR) {
return false;
}
}
return true;
}
/**
* Journalise un envoi / tentative
*
* @param 'sent'|'error'|'blocked' $status
*/
private function log(
string $status,
string $recipient,
string $subject,
string $body,
?string $error = null,
?string $messageId = null
): void {
$script = $this->detectScript();
$host = (string) ($this->smtpConfig['host'] ?? '');
$user = (string) ($this->smtpConfig['username'] ?? '');
$subjectDb = mb_strimwidth($subject, 0, self::MAX_SUBJECT_LEN, '…', 'UTF-8');
$bodyDb = mb_strimwidth($body, 0, self::MAX_BODY_LEN_FOR_LOG, '…', 'UTF-8');
$sql = <<<SQL
INSERT INTO journal_smtp
(created_at, script, recipient, subject, body, status, error, smtp_host, smtp_user, message_id)
VALUES
(NOW() AT TIME ZONE 'UTC', :script, :recipient, :subject, :body, :status, :error, :smtp_host, :smtp_user, :message_id)
SQL;
$stmt = $this->pdo->prepare($sql);
$stmt->execute([
':script' => $script,
':recipient' => $recipient,
':subject' => $subjectDb,
':body' => $bodyDb,
':status' => $status,
':error' => $error,
':smtp_host' => $host,
':smtp_user' => $user,
':message_id' => $messageId,
]);
}
private function detectScript(): string
{
// Exemple : /public/pages/notifications/send.php
$script = $_SERVER['SCRIPT_NAME'] ?? ($_SERVER['PHP_SELF'] ?? '');
if ($script === '' && \PHP_SAPI === 'cli') {
$script = $_SERVER['argv'][0] ?? 'cli';
}
return (string) $script;
}
private function looksLikeHtml(string $body): bool
{
return (bool) preg_match('~<(?:html|body|div|p|span|table|br|h[1-6]|a)\b~i', $body);
}
private function buildAltBody(string $body): string
{
if (!$this->looksLikeHtml($body)) {
// Déjà texte brut
return $body;
}
// Version simplifiée : strip tags (on peut faire mieux selon besoins)
$text = trim((string) @html_entity_decode(strip_tags($body), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
return $text !== '' ? $text : '[Voir version HTML]';
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Repository\DictionaryRepository;
final class UiFormRenderer
{
public function __construct(private DictionaryRepository $dict) {}
public function renderControls(string $entityCode, array $values = []): string {
$e = $this->dict->getEntityByCode($entityCode);
if (!$e) return '<div class="alert alert-danger">Entité inconnue</div>';
$html = '';
foreach ($e['fields'] as $f) {
if (!$f['form_visible']) continue;
if ($f['read_only']) continue;
$name = $f['code'];
$label = $f['label'];
$help = $f['help_text'] ?? '';
$widget= $f['ui_widget'] ?? 'text';
$val = $values[$name] ?? '';
$html .= '<div class="mb-3">';
$html .= '<label class="form-label" for="'.$name.'">'.htmlspecialchars($label).'</label>';
if ($widget === 'select' && $f['enum_domain']) {
$opts = $this->dict->getEnum($f['enum_domain']);
$html .= '<select id="'.$name.'" name="'.$name.'" class="form-select">';
foreach ($opts as $opt) {
$sel = ($val !== '' && (string)$val === (string)$opt['code']) ? ' selected' : '';
$html .= '<option value="'.htmlspecialchars($opt['code']).'"'.$sel.'>'
. htmlspecialchars($opt['label']).'</option>';
}
$html .= '</select>';
} else {
$type = match ($widget) {
'email' => 'email',
'number' => 'number',
'date' => 'date',
'checkbox'=> 'checkbox',
default => 'text',
};
if ($type === 'checkbox') {
$chk = $val ? ' checked' : '';
$html .= '<input class="form-check-input" type="checkbox" id="'.$name.'" name="'.$name.'" value="1"'.$chk.'>';
} else {
$placeholder = $f['placeholder'] ?? '';
$html .= '<input class="form-control" type="'.$type.'" id="'.$name.'" name="'.$name.'"'
. ' value="'.htmlspecialchars((string)$val, ENT_QUOTES).'"'
. ($placeholder ? ' placeholder="'.htmlspecialchars($placeholder, ENT_QUOTES).'"' : '')
. '>';
}
}
if ($help) {
$html .= '<div class="form-text">'.htmlspecialchars($help).'</div>';
}
$html .= '</div>';
}
return $html;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Repository\DictionaryRepository;
final class Validator
{
public function __construct(private DictionaryRepository $dict) {}
public function validate(string $entityCode, array $payload): array {
$errors = [];
$e = $this->dict->getEntityByCode($entityCode);
if (!$e) return ['_global' => ['Entité inconnue']];
// Index les champs
$fields = [];
foreach ($e['fields'] as $f) {
$fields[$f['code']] = $f;
}
foreach ($e['rules'] as $r) {
$code = $r['field_code'];
$type = $r['rule_type'];
$val = $r['rule_value'];
$msg = $r['message'];
$v = $code ? ($payload[$code] ?? null) : null;
switch ($type) {
case 'required':
if ($code && ($v === null || $v === '')) $errors[$code][] = $msg;
break;
case 'regex':
if ($code && $v !== null && $v !== '' && !preg_match('#'.$val.'#u', (string)$v)) {
$errors[$code][] = $msg;
}
break;
case 'min':
if ($code && is_numeric($v) && (float)$v < (float)$val) $errors[$code][] = $msg;
break;
case 'max':
if ($code && is_numeric($v) && (float)$v > (float)$val) $errors[$code][] = $msg;
break;
case 'between':
if ($code && is_numeric($v)) {
[$a,$b] = array_map('floatval', explode(',', $val));
$fv = (float)$v;
if ($fv < $a || $fv > $b) $errors[$code][] = $msg;
}
break;
case 'unique':
// à implémenter côté repo (SELECT COUNT(*) FROM table WHERE col=:v AND id<>:id)
// Laisse un hook ici.
break;
case 'custom':
// point dextension si tu veux appeler une callable par nom
break;
}
}
return $errors;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
use Jumbojett\OpenIDConnectClient;
require_once BASE_PATH . '/vendor/autoload.php';
session_start();
function require_auth() {
if (!isset($_SESSION['user'])) {
// Redirige vers la page de login
header('Location: /auth/login.php');
exit;
}
}
function get_oidc_client(): OpenIDConnectClient {
$oidc = new OpenIDConnectClient(
'https://idp.a5l.fr/realms/master',
'varlog-client-id',
'varlog-client-secret'
);
$oidc->setRedirectURL('http://varlog.acegrp.lan/auth/callback.php');
$oidc->addScope('openid email profile');
return $oidc;
}
+16
View File
@@ -0,0 +1,16 @@
<?php
require_once BASE_PATH . '/config/config.php';
// Comment récupérer les valeurs de .env
$dsn = $_ENV['DB_DSN'];
$user = $_ENV['DB_USER'];
$pass = $_ENV['DB_PASS'];
// Se connecter
try {
$db = new PDO($dsn, $user, $pass);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
die('Connexion échouée : ' . $e->getMessage());
}
+8
View File
@@ -0,0 +1,8 @@
<?php
function vd($var, ...$moreVars) {
ob_start();
var_dump($var, ...$moreVars);
$output = ob_get_clean();
echo "<pre>$output</pre>";
}
+17
View File
@@ -0,0 +1,17 @@
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
is_published BOOLEAN DEFAULT FALSE
);
CREATE TABLE post_files (
id SERIAL PRIMARY KEY,
post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,
file_type TEXT,
file_path TEXT,
original_name TEXT,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
+58
View File
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title><?= htmlspecialchars($title ?? 'varlog') ?></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- SEO -->
<meta name="description" content="Varlog est un journal personnel en ligne de Cédrix développé par ces soins. Informatique, hack et loisirs techniques.">
<meta name="robots" content="index, follow">
<!-- Open Graph -->
<meta property="og:title" content="<?= htmlspecialchars($title ?? 'varlog') ?>">
<meta property="og:description" content="Découvrez les derniers articles publiés sur le journal personnel varlog.">
<meta property="og:type" content="website">
<meta property="og:locale" content="fr_FR">
<meta property="og:url" content="https://varlog.a5l.fr/">
<meta property="og:site_name" content="varlog">
<!-- Favicon (si dispo) -->
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
<!-- CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body class="bg-light text-dark">
<header>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4" role="navigation" aria-label="Navigation principale">
<div class="container-fluid">
<a class="navbar-brand" href="route.php">📝 varlog</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent" aria-controls="navbarContent" aria-expanded="false" aria-label="Basculer la navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav ms-auto">
<li class="nav-item"><a class="nav-link" href="route.php?action=create">Nouveau post</a></li>
</ul>
</div>
</div>
</nav>
</header>
<main class="container" role="main">
<?= $content ?>
</main>
<footer class="text-center text-muted py-4 mt-5 small" role="contentinfo">
&copy; <?= date('Y') ?> — <strong>varlog</strong> est un journal personnel développé par Cédrix
</footer>
<!-- JS Bootstrap (optionnel) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
<?php
ob_start();
// Valeur par défaut pour le champ datetime-local
$dateValue = $published_at ?? date('Y-m-d\TH:i');
?>
<h1 class="mb-4"><?= $action === 'edit' ? 'Modifier le post' : 'Créer un nouveau post' ?></h1>
<?php if (!empty($errors)): ?>
<div class="alert alert-danger">
<ul class="mb-0">
<?php foreach ($errors as $error): ?>
<li><?= htmlspecialchars($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form method="POST" action="<?= htmlspecialchars($formAction) ?>" enctype="multipart/form-data">
<div class="mb-3">
<label for="title" class="form-label">Titre</label>
<input type="text" class="form-control" id="title" name="title" required value="<?= htmlspecialchars($title) ?>">
</div>
<div class="mb-2">
<small class="text-muted">
Écris en <strong>Markdown</strong> <a href="https://www.markdownguide.org/cheat-sheet/" target="_blank">guide rapide</a>
</small>
</div>
<div class="mb-3">
<label for="content" class="form-label">Contenu</label>
<textarea class="form-control" id="content" name="content" rows="6"><?= htmlspecialchars($content) ?></textarea>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="published_at" class="form-label">Date de publication</label>
<input type="datetime-local" class="form-control" id="published_at" name="published_at" value="<?= $dateValue ?>">
</div>
<div class="col-md-6 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="published" name="published" <?= $published ? 'checked' : '' ?>>
<label class="form-check-label" for="published">Publié</label>
</div>
</div>
</div>
<div class="mb-3">
<label for="files" class="form-label">Fichiers</label>
<input type="file" class="form-control" id="files" name="files[]" multiple>
</div>
<button type="submit" class="btn btn-success">Enregistrer</button>
<a href="route.php" class="btn btn-secondary">Annuler</a>
</form>
<?php
$content = ob_get_clean();
$title = $action === 'edit' ? 'Modifier le post' : 'Nouveau post';
include __DIR__ . '/layout.php';
+46
View File
@@ -0,0 +1,46 @@
<?php
require_once BASE_PATH . '/src/Parsedown.php';
$Parsedown = new Parsedown();
ob_start();
?>
<h1 class="mb-4 text-center">📝 Tous les posts</h1>
<div class="row row-cols-1 row-cols-md-2 g-4">
<?php foreach ($posts as $post): ?>
<div class="col">
<div class="card shadow-sm h-100 border-<?php echo $post['is_published'] ? 'primary' : 'warning'; ?>">
<div class="card-body d-flex flex-column">
<h5 class="card-title text-primary">
<?= htmlspecialchars($post['title']) ?>
<?php if (!$post['is_published']): ?>
<span class="badge bg-warning text-dark ms-2">⏳ Brouillon</span>
<?php endif; ?>
</h5>
<div class="card-text text-body">
<?php
$html = $Parsedown->text($post['content']);
$preview = mb_strimwidth(strip_tags($html), 0, 300, '…');
echo '<p>' . $preview . '</p>';
?>
</div>
<p class="text-muted small mt-auto mb-2">📅 Publié le <?= date('d/m/Y', strtotime($post['created_at'])) ?></p>
<div class="d-flex justify-content-end gap-2">
<a href="route.php?action=view&id=<?= $post['id'] ?>" class="btn btn-sm btn-outline-primary">🔍 Voir</a>
<a href="route.php?action=edit&id=<?= $post['id'] ?>" class="btn btn-sm btn-outline-secondary">✏️ Modifier</a>
</div>
<a href="route.php?action=view&id=<?= $post['id'] ?>" class="stretched-link"></a>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php
$content = ob_get_clean();
$title = "Liste des posts";
include __DIR__ . '/layout.php';
+70
View File
@@ -0,0 +1,70 @@
<?php
require_once __DIR__ . '/../src/Parsedown.php';
$Parsedown = new Parsedown();
ob_start();
?>
<a href="route.php" class="btn btn-secondary mb-3">← Retour</a>
<div class="card mb-4">
<div class="card-body">
<h2 class="card-title"><?= htmlspecialchars($post['title']) ?></h2>
<div class="card-text">
<?= $Parsedown->text($post['content']) ?>
</div>
<p class="text-muted small mt-2">Publié le <?= $post['created_at'] ?></p>
</div>
</div>
<?php
require_once __DIR__ . '/../src/FileManager.php';
$uploadDir = __DIR__ . '/../public/assets/uploads';
$publicDir = 'assets/uploads';
$fileManager = new FileManager($db, $uploadDir);
$files = $fileManager->getFilesForPost($post['id']);
?>
<?php if ($files): ?>
<h5>Fichiers attachés</h5>
<div class="row">
<?php foreach ($files as $file): ?>
<div class="col-md-4 mb-3">
<div class="card">
<div class="card-body">
<?php
$fileUrl = $publicDir . '/' . $file['file_path'];
$type = $file['file_type'];
?>
<?php if ($type === 'image'): ?>
<img src="<?= $fileUrl ?>" class="img-fluid" alt="<?= htmlspecialchars($file['original_name']) ?>">
<?php elseif ($type === 'video'): ?>
<video controls class="w-100">
<source src="<?= $fileUrl ?>" type="video/mp4">
</video>
<?php elseif ($type === 'audio'): ?>
<audio controls class="w-100">
<source src="<?= $fileUrl ?>" type="audio/mpeg">
</audio>
<?php else: ?>
<p><a href="<?= $fileUrl ?>" target="_blank">📎 <?= htmlspecialchars($file['original_name']) ?></a></p>
<?php endif; ?>
</div>
<div class="card-footer text-end">
<small class="text-muted">Ajouté le <?= $file['uploaded_at'] ?></small>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<a href="route.php?action=delete&id=<?= $post['id'] ?>" class="btn btn-danger mt-3" onclick="return confirm('Supprimer ce post ?')">Supprimer ce post</a>
<?php
$content = ob_get_clean();
$title = htmlspecialchars($post['title']);
include __DIR__ . '/layout.php';
+2
View File
@@ -0,0 +1,2 @@
## 21/10/2025
Introduction de l'authentification par OIDC