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