feat: stockage articles en fichiers Markdown, SSO intégré, URLs propres

This commit is contained in:
Cedric Abonnel
2026-05-08 22:36:04 +02:00
parent aa9c04d154
commit fd3fced0d8
22 changed files with 863 additions and 352 deletions
+16
View File
@@ -0,0 +1,16 @@
Options -Indexes
DirectoryIndex index.php
RewriteEngine On
# Fichiers et répertoires réels servis directement
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# URL propre pour les articles : /post/<slug>
RewriteRule ^post/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=view&slug=$1 [L,QSA]
# Ajoute .php si le fichier correspondant existe
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI}.php -f
RewriteRule ^(.+?)/?$ /$1.php [L,QSA]
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
define('BASE_PATH', realpath(__DIR__ . '/../'));
$uuid = $_GET['uuid'] ?? '';
$name = $_GET['name'] ?? '';
// Valide le format UUID v4
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $uuid)) {
http_response_code(400);
exit;
}
// Sécurise le nom de fichier (pas de traversal)
$name = basename($name);
if ($name === '' || $name[0] === '.') {
http_response_code(400);
exit;
}
$path = BASE_PATH . '/data/' . $uuid . '/files/' . $name;
if (!is_file($path)) {
http_response_code(404);
exit;
}
$mime = mime_content_type($path) ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Length: ' . filesize($path));
header('Cache-Control: public, max-age=31536000, immutable');
readfile($path);
exit;
+152 -6
View File
@@ -1,16 +1,162 @@
<?php
declare(strict_types=1);
define('BASE_PATH', realpath(__DIR__ . '/../'));
if (session_status() === PHP_SESSION_NONE) {
$isHttps = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'secure' => $isHttps, 'httponly' => true, 'samesite' => 'Lax']);
session_start();
}
require_once BASE_PATH . '/src/helpers.php';
require_once BASE_PATH . '/src/auth.php';
require_once BASE_PATH . '/config/config.php';
require_once BASE_PATH . '/src/db.php';
require_once BASE_PATH . '/src/PostManager.php';
require_once BASE_PATH . '/src/ArticleManager.php';
$postManager = new PostManager($db);
$articles = new ArticleManager(BASE_PATH . '/data');
ob_start();
$action = $_GET['action'] ?? 'list';
$uuid = $_GET['uuid'] ?? '';
$slug = $_GET['slug'] ?? '';
$posts = $postManager->getAll();
require_once BASE_PATH . '/templates/post_list.php';
switch ($action) {
case 'create':
requireAuth();
$title = $_POST['title'] ?? '';
$content = $_POST['content'] ?? '';
$postSlug = $_POST['slug'] ?? '';
$published = isset($_POST['published']);
$published_at = str_replace('T', ' ', $_POST['published_at'] ?? date('Y-m-d H:i:s'));
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (trim($title) === '') {
$errors[] = 'Le titre est obligatoire.';
}
if (empty($errors)) {
$newUuid = $articles->create($title, $content, $published, $postSlug, $published_at);
foreach ($_FILES['files']['tmp_name'] ?? [] as $i => $tmpName) {
if ($_FILES['files']['error'][$i] === UPLOAD_ERR_OK) {
$articles->addFile($newUuid, [
'name' => $_FILES['files']['name'][$i],
'tmp_name' => $tmpName,
'error' => $_FILES['files']['error'][$i],
]);
}
}
header('Location: /');
exit;
}
}
$formAction = '/?action=create';
$action = 'create';
include BASE_PATH . '/templates/post_form.php';
break;
case 'view':
$article = $slug !== '' ? $articles->getBySlug($slug) : null;
if (!$article) {
http_response_code(404);
echo 'Article introuvable.';
exit;
}
$files = $articles->getFiles($article['uuid']);
// Résout les chemins de fichiers relatifs dans le contenu
$rawContent = $articles->resolveFileUrls($article['uuid'], $article['content']);
include BASE_PATH . '/templates/post_view.php';
break;
case 'edit':
requireAuth();
$article = $articles->getByUuid($uuid);
if (!$article) {
http_response_code(404);
echo 'Article introuvable.';
exit;
}
$title = $_POST['title'] ?? $article['title'];
$content = $_POST['content'] ?? $article['content'];
$postSlug = $_POST['slug'] ?? $article['slug'];
$published = isset($_POST['published']) ? true : $article['published'];
$published_at = $_POST['published_at']
?? date('Y-m-d\TH:i', strtotime((string)($article['published_at'] ?? 'now')));
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (trim($title) === '') {
$errors[] = 'Le titre est obligatoire.';
}
if (empty($errors)) {
$articles->update(
$uuid,
$title,
$content,
$published,
$_POST['slug'] ?? '',
str_replace('T', ' ', $_POST['published_at'] ?? '')
);
foreach ($_FILES['files']['tmp_name'] ?? [] as $i => $tmpName) {
if ($_FILES['files']['error'][$i] === UPLOAD_ERR_OK) {
$articles->addFile($uuid, [
'name' => $_FILES['files']['name'][$i],
'tmp_name' => $tmpName,
'error' => $_FILES['files']['error'][$i],
]);
}
}
$updated = $articles->getByUuid($uuid);
header('Location: /post/' . rawurlencode($updated['slug'] ?? $uuid));
exit;
}
}
$formAction = '/?action=edit&uuid=' . rawurlencode($uuid);
$action = 'edit';
$existingFiles = $articles->getFiles($uuid);
include BASE_PATH . '/templates/post_form.php';
break;
case 'delete':
requireAuth();
if ($uuid !== '') {
$articles->delete($uuid);
}
header('Location: /');
exit;
case 'about':
include BASE_PATH . '/templates/about.php';
break;
case 'legal':
include BASE_PATH . '/templates/legal.php';
break;
case 'contact':
include BASE_PATH . '/templates/contact.php';
break;
case 'licenses':
include BASE_PATH . '/templates/licenses.php';
break;
case 'list':
default:
$posts = $articles->getAll();
include BASE_PATH . '/templates/post_list.php';
break;
}
+1 -1
View File
@@ -36,7 +36,7 @@ if (!function_exists('url')) {
}
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
require_once dirname(__DIR__, 2) . '/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.php';
// Paramètres (env)
+3 -8
View File
@@ -6,7 +6,7 @@
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) . '/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.php';
// si tu as un service pour ouvrir une session
@@ -58,16 +58,11 @@ try {
$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
session_regenerate_id(true);
$_SESSION['user_email'] = strtolower(trim((string)$row['email']));
$dest = $row['return_to'] ?? '/';
// sécurité: ne renvoyer que des chemins relatifs
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
define('BASE_PATH', realpath(__DIR__ . '/../'));
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once BASE_PATH . '/src/auth.php';
require_once BASE_PATH . '/config/config.php';
$logoutUrl = ssoLogoutUrl();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$params['path'],
$params['domain'],
$params['secure'],
$params['httponly']
);
}
session_destroy();
header('Location: ' . $logoutUrl, true, 303);
exit;
+36 -67
View File
@@ -2,14 +2,12 @@
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) . '/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.php';
if (!function_exists('env')) {
@@ -25,13 +23,13 @@ if (!function_exists('env')) {
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'));
$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.php'));
if (!$OIDC_ISSUER || !$OIDC_CLIENT_ID || !$OIDC_REDIRECT_URI) {
http_response_code(500);
@@ -42,7 +40,6 @@ if (!$OIDC_ISSUER || !$OIDC_CLIENT_ID || !$OIDC_REDIRECT_URI) {
$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.';
@@ -58,9 +55,7 @@ if (empty($_GET['code'])) {
$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
unset($_SESSION['oidc_code_verifier'], $_SESSION['oidc_nonce']);
if (!$codeVerifier) {
http_response_code(400);
@@ -68,7 +63,7 @@ if (!$codeVerifier) {
exit;
}
// --- Échange code -> tokens ---
// Échange code tokens
$post = [
'grant_type' => 'authorization_code',
'code' => $code,
@@ -86,15 +81,17 @@ curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($post, '', '&', PHP_QUERY_RFC3986),
CURLOPT_TIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$tokenResponse = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
$curlErr = curl_error($ch);
curl_close($ch);
if ($tokenResponse === false || $httpCode !== 200) {
http_response_code(500);
echo $debug ? 'Échec échange token: ' . htmlspecialchars($err ?: (string)$tokenResponse) : 'Erreur dauthentification.';
echo $debug ? 'Échec échange token : ' . htmlspecialchars($curlErr ?: (string)$tokenResponse) : 'Erreur d\'authentification.';
exit;
}
@@ -104,16 +101,18 @@ $idToken = $tokens['id_token'] ?? null;
if (!$accessToken) {
http_response_code(500);
echo $debug ? 'Access token manquant.' : 'Erreur dauthentification.';
echo $debug ? 'Access token manquant.' : 'Erreur d\'authentification.';
exit;
}
// --- UserInfo ---
// UserInfo
$ch = curl_init($userInfoEndpoint);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $accessToken],
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$userInfoResponse = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
@@ -121,22 +120,17 @@ curl_close($ch);
if ($userInfoResponse === false || $httpCode !== 200) {
http_response_code(500);
echo $debug ? 'Échec UserInfo: ' . htmlspecialchars((string)$userInfoResponse) : 'Erreur dauthentification.';
echo $debug ? 'Échec UserInfo.' : 'Erreur d\'authentification.';
exit;
}
$claims = json_decode((string)$userInfoResponse, true) ?: [];
$email = $claims['email'] ?? null;
// --- Récup info utiles ---
$email = $claims['email'] ?? null;
$username = $claims['preferred_username'] ?? ($email ?: null);
$firstname = $claims['given_name'] ?? null;
$lastname = $claims['family_name'] ?? null;
// Fallback : lire l'email depuis le payload du id_token
if (!$email && $idToken && substr_count($idToken, '.') === 2) {
[, $p, ] = explode('.', $idToken, 3);
$payloadJson = base64_decode(strtr($p, '-_', '+/'), true);
$payload = $payloadJson ? json_decode($payloadJson, true) : null;
$payload = json_decode((string)base64_decode(strtr($p, '-_', '+/'), true), true);
if (is_array($payload) && !empty($payload['email'])) {
$email = $payload['email'];
}
@@ -144,50 +138,25 @@ if (!$email && $idToken && substr_count($idToken, '.') === 2) {
if (!$email) {
http_response_code(400);
echo $debug ? 'Email non fourni par IdP.' : 'Impossible de récupérer votre email.';
echo $debug ? 'Email non fourni par l\'IdP.' : 'Impossible de récupérer votre email.';
exit;
}
// --- Si l'utilisateur existe déjà -> connecter et redirect ---
$flow = $_SESSION['oidc_flow'] ?? 'login';
// Vérifie existence en base
/** @var \PDO $pdo */
$pdo = Database::get();
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
$stmt->execute([':email' => $email]);
$existingId = $stmt->fetchColumn();
// Si flow=login ET utilisateur existe → connexion directe
if ($flow === 'login' && $existingId) {
$_SESSION['user_id'] = (int)$existingId;
$_SESSION['user_email'] = $email;
$_SESSION['oidc'] = [
'issuer' => $OIDC_ISSUER,
'sub' => $claims['sub'] ?? null,
'access_token' => $accessToken,
'id_token' => $idToken,
'expires_at' => time() + (int)($tokens['expires_in'] ?? 3600),
];
$target = $_SESSION['oidc_return_to'] ?? '/';
unset($_SESSION['oidc_return_to'], $_SESSION['oidc_flow']);
if (!is_string($target) || $target === '' || $target[0] !== '/') {
$target = '/';
}
header('Location: ' . $target, true, 303);
exit;
}
// Sinon : go formulaire dinscription (pré-rempli)
$_SESSION['pending_oidc'] = [
'issuer' => $OIDC_ISSUER,
'sub' => $claims['sub'] ?? null,
'email' => $email,
'username' => $claims['preferred_username'] ?? ($email ?: null),
'firstname' => $claims['given_name'] ?? null,
'lastname' => $claims['family_name'] ?? null,
// Ouvre la session authentifiée
session_regenerate_id(true);
$_SESSION['user_email'] = strtolower(trim($email));
$_SESSION['oidc'] = [
'issuer' => $OIDC_ISSUER,
'sub' => $claims['sub'] ?? null,
'access_token' => $accessToken,
'id_token' => $idToken,
'expires_at' => time() + (int)($tokens['expires_in'] ?? 3600),
];
unset($_SESSION['oidc_flow']);
header('Location: ' . url('register/from-oidc'), true, 303);
$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;
+1 -1
View File
@@ -9,7 +9,7 @@ if (session_status() !== PHP_SESSION_ACTIVE) {
}
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
require_once dirname(__DIR__, 2) . '/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.php';
function maskToken(?string $t): string
+1 -1
View File
@@ -7,7 +7,7 @@ if (session_status() !== PHP_SESSION_ACTIVE) {
}
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
require_once dirname(__DIR__, 2) . '/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.php';
if (!function_exists('env')) {
+4 -157
View File
@@ -1,160 +1,7 @@
<?php
declare(strict_types=1);
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 'about':
include BASE_PATH . '/templates/about.php';
break;
case 'legal':
include BASE_PATH . '/templates/legal.php';
break;
case 'contact':
include BASE_PATH . '/templates/contact.php';
break;
case 'licenses':
include BASE_PATH . '/templates/licenses.php';
break;
case 'list':
default:
$posts = $postManager->getAll();
include BASE_PATH . '/templates/post_list.php';
break;
}
// Ce fichier est conservé pour compatibilité ascendante.
// Toute la logique est désormais dans index.php.
header('Location: /' . ($_SERVER['QUERY_STRING'] ? '?' . $_SERVER['QUERY_STRING'] : ''), true, 301);
exit;