From fd3fced0d8f6656c9b50db7bdf33652dd2361a05 Mon Sep 17 00:00:00 2001
From: Cedric Abonnel
Date: Fri, 8 May 2026 22:36:04 +0200
Subject: [PATCH] =?UTF-8?q?feat:=20stockage=20articles=20en=20fichiers=20M?=
=?UTF-8?q?arkdown,=20SSO=20int=C3=A9gr=C3=A9,=20URLs=20propres?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.php-cs-fixer.cache | 2 +-
bootstrap.php | 19 +++
data/.gitkeep | 0
public/.htaccess | 16 ++
public/file.php | 35 ++++
public/index.php | 158 +++++++++++++++++-
public/login/index.php | 2 +-
public/login/magic.php | 11 +-
public/logout.php | 32 ++++
public/oidc/callback.php | 103 ++++--------
public/oidc/me.php | 2 +-
public/oidc/start.php | 2 +-
public/route.php | 161 +-----------------
src/ArticleManager.php | 347 +++++++++++++++++++++++++++++++++++++++
src/auth.php | 60 +++++--
templates/about.php | 2 +-
templates/contact.php | 2 +-
templates/layout.php | 24 ++-
templates/legal.php | 6 +-
templates/post_form.php | 113 +++++++++----
templates/post_list.php | 19 ++-
templates/post_view.php | 99 +++++------
22 files changed, 863 insertions(+), 352 deletions(-)
create mode 100644 bootstrap.php
create mode 100644 data/.gitkeep
create mode 100644 public/.htaccess
create mode 100644 public/file.php
create mode 100644 public/logout.php
create mode 100644 src/ArticleManager.php
diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache
index 2bf317e..08840a1 100644
--- a/.php-cs-fixer.cache
+++ b/.php-cs-fixer.cache
@@ -1 +1 @@
-{"php":"8.3.6","version":"3.89.1:v3.89.1#f34967da2866ace090a2b447de1f357356474573","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"imports_order":["class","function","const"],"sort_algorithm":"none"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"strict_param":true,"declare_strict_types":true,"no_unused_imports":true,"single_quote":true},"hashes":{"templates\/header.php":"f4b64c4ecb4dadec166cb7935309096a","templates\/footer.php":"3111b4701ea698ba11c3423260657e28","public\/login\/oidc.php":"8ec86d6f3af33f64d586109ec17f817d","public\/login\/config.php":"5b7b3e2937b349c76a2fd239c3ae06f8","public\/login\/magic.php":"54ef6b7ef80e608905e64e4fa8539846","public\/login\/index.php":"063d7b997bf8292d2b3f8c34dae3252f","public\/oidc\/callback.php":"793ff84451299c9984ac4742f02ca842","public\/oidc\/start.php":"87ddb61a0ef796d7303709ffa741c9c7","public\/oidc\/me.php":"d0439342011bb0e58ef8738b3b81cc2f","public\/index.php":"73a917520ea547ae8a122bd90098bf46","src\/Infrastructure\/DbAdapter.php":"3899a835130c146e2d30dbcca88d8f33","src\/Infrastructure\/Database.php":"6f2848ed70b29d9c2e2d259be611b9b0","src\/Infrastructure\/Session.php":"3538a1147cc81678c470d45ea8574a95","src\/Domain\/User.php":"02213454f7edf43f4afae3f2f81aaf01","src\/Http\/Csrf.php":"55631812cab4b1192f8e30c5d35fd5eb","src\/FileManager.php":"a51dda44f293f238aea295fd56b2fa99","src\/PostManager.php":"25f0179c4d96e9aa04218d54bf45a029","src\/helpers.php":"3a83a4872b1e3e3c58898b54f51e72b4","src\/db.php":"8888b7fbc9740eb3c60dd2374d0cb5d6","src\/ConfigRepo.php":"c2dcee160a272d27725d480a90e76dcf","src\/Parsedown.php":"85da2b47eca1a703fdfe44753bf912df","src\/Service\/Validator.php":"7c267b8b9f3f1bac0f2520dd10364831","src\/Service\/UiFormRenderer.php":"065617191c6d680ce97588f4fa159688","src\/mailer.php":"17e6b19103c880cc9a6c6634486506c2","src\/Repository\/DictionnaryRepository.php":"f937e98cf0f27b59ae00e430b52a586d","src\/Repository\/ProfileRepository.php":"b1cd483652500ee4e2aaaa9e0330ff1d","versions.php":"51a72261e1a507d3435b4a24e5f5fc09","config\/config.php":"a8b7698b01ab9b40eea655e8fcc194fc","templates\/post_form.php":"32765f286dc2fc2f9d9790fa3a94bef5","templates\/post_view.php":"64094ca90fd3d03bb9615bb3772bc189","templates\/post_list.php":"864963b27f0361ad40a9f0f3245fa897","phpstan-bootstrap.php":"d74864c2f107b740523f070d077d715e","src\/Service\/MailQueue.php":"20db418b83dcf426b7c6ad6787644cde","src\/Service\/AuthService.php":"f95a9ab097dcfc4ac6cbcf908cf4cd90","src\/Repository\/UserRepository.php":"d0ccc80374b54a5c4f20cb04c00fb083","src\/auth.php":"c3dc272b004694a6783c90fd7e31c962","src\/Service\/MailService.php":"7bff5df8cac3274a1a4ab8fe137514f2","templates\/contact.php":"b8e2a7b64b75dfffaf96105f305b1b8e","templates\/legal.php":"03baf5956adc4e76227b1794a0034a18","templates\/about.php":"9152e95b79e8ff96f45cf1d8421d0f2f","public\/route.php":"48e1c739d65f1c3ac7acc0114f9a45f1","templates\/licenses.php":"c1b8db32773de42bb4392063c50155e2","templates\/layout.php":"9cb990fca2d78d6da716420ad5da98e7"}}
\ No newline at end of file
+{"php":"8.3.6","version":"3.89.1:v3.89.1#f34967da2866ace090a2b447de1f357356474573","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"imports_order":["class","function","const"],"sort_algorithm":"none"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"strict_param":true,"declare_strict_types":true,"no_unused_imports":true,"single_quote":true},"hashes":{"templates\/header.php":"f4b64c4ecb4dadec166cb7935309096a","templates\/footer.php":"3111b4701ea698ba11c3423260657e28","public\/login\/oidc.php":"8ec86d6f3af33f64d586109ec17f817d","public\/login\/config.php":"5b7b3e2937b349c76a2fd239c3ae06f8","src\/Infrastructure\/DbAdapter.php":"3899a835130c146e2d30dbcca88d8f33","src\/Infrastructure\/Database.php":"6f2848ed70b29d9c2e2d259be611b9b0","src\/Infrastructure\/Session.php":"3538a1147cc81678c470d45ea8574a95","src\/Domain\/User.php":"02213454f7edf43f4afae3f2f81aaf01","src\/Http\/Csrf.php":"55631812cab4b1192f8e30c5d35fd5eb","src\/FileManager.php":"a51dda44f293f238aea295fd56b2fa99","src\/PostManager.php":"25f0179c4d96e9aa04218d54bf45a029","src\/helpers.php":"3a83a4872b1e3e3c58898b54f51e72b4","src\/db.php":"8888b7fbc9740eb3c60dd2374d0cb5d6","src\/ConfigRepo.php":"c2dcee160a272d27725d480a90e76dcf","src\/Parsedown.php":"85da2b47eca1a703fdfe44753bf912df","src\/Service\/Validator.php":"7c267b8b9f3f1bac0f2520dd10364831","src\/Service\/UiFormRenderer.php":"065617191c6d680ce97588f4fa159688","src\/mailer.php":"17e6b19103c880cc9a6c6634486506c2","src\/Repository\/DictionnaryRepository.php":"f937e98cf0f27b59ae00e430b52a586d","src\/Repository\/ProfileRepository.php":"b1cd483652500ee4e2aaaa9e0330ff1d","versions.php":"51a72261e1a507d3435b4a24e5f5fc09","config\/config.php":"a8b7698b01ab9b40eea655e8fcc194fc","phpstan-bootstrap.php":"d74864c2f107b740523f070d077d715e","src\/Service\/MailQueue.php":"20db418b83dcf426b7c6ad6787644cde","src\/Service\/AuthService.php":"f95a9ab097dcfc4ac6cbcf908cf4cd90","src\/Repository\/UserRepository.php":"d0ccc80374b54a5c4f20cb04c00fb083","src\/Service\/MailService.php":"7bff5df8cac3274a1a4ab8fe137514f2","templates\/licenses.php":"c1b8db32773de42bb4392063c50155e2","bootstrap.php":"23e657accae3b860a9ffc227daa30d27","public\/file.php":"4e9de9cbe565e895e7bd809028754cb9","public\/logout.php":"68bc31b06a9e23aa7a43ca7642365bcd","src\/ArticleManager.php":"43e0061b20b22037596aeac8c96de3ae","templates\/post_view.php":"73d3588e0b4c3470e4ce5b6182dfb2fa","templates\/post_list.php":"5ad535f5bdb6de8a6cf9630cefc48e58","templates\/layout.php":"5a9c8a96723eabafc186d6c23dd5cfa3","templates\/contact.php":"9d2026fd4250e0cef3ff736812cd2b12","templates\/post_form.php":"b444d7937eaa5d3b068e25d5d14bc2f7","templates\/legal.php":"b8eaf0c118bb06c92171aaecca091550","templates\/about.php":"022d078aaa40d2067352c81509144182","public\/login\/magic.php":"3d9447ed551e6401c3d43429b1600247","public\/login\/index.php":"7a27b066b232e943e0a98850f0993c52","public\/route.php":"8a478892d21a95352cae93b85169f424","public\/oidc\/callback.php":"be3e7a4e00b40d956c6a54de38fcfe50","public\/oidc\/start.php":"0ecd2496ed9b591e177abe7354a7809f","public\/oidc\/me.php":"d979853cfab834a645ed0c6df5297ed7","public\/index.php":"d48d76494172e7f172ef82010ea8ce93","src\/auth.php":"73d056745086cd0e63053eab808d2b5e"}}
\ No newline at end of file
diff --git a/bootstrap.php b/bootstrap.php
new file mode 100644
index 0000000..c123030
--- /dev/null
+++ b/bootstrap.php
@@ -0,0 +1,19 @@
+ 0,
+ 'path' => '/',
+ 'secure' => $isHttps,
+ 'httponly' => true,
+ 'samesite' => 'Lax',
+ ]);
+ session_start();
+}
diff --git a/data/.gitkeep b/data/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/public/.htaccess b/public/.htaccess
new file mode 100644
index 0000000..9f55202
--- /dev/null
+++ b/public/.htaccess
@@ -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/
+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]
diff --git a/public/file.php b/public/file.php
new file mode 100644
index 0000000..7dbb4ff
--- /dev/null
+++ b/public/file.php
@@ -0,0 +1,35 @@
+ 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;
+}
diff --git a/public/login/index.php b/public/login/index.php
index 534e6b6..0cdc2c4 100644
--- a/public/login/index.php
+++ b/public/login/index.php
@@ -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)
diff --git a/public/login/magic.php b/public/login/magic.php
index f23b0b4..cc07de7 100644
--- a/public/login/magic.php
+++ b/public/login/magic.php
@@ -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
diff --git a/public/logout.php b/public/logout.php
new file mode 100644
index 0000000..94f2166
--- /dev/null
+++ b/public/logout.php
@@ -0,0 +1,32 @@
+ 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 d’authentification.';
+ 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 d’authentification.';
+ 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 d’authentification.';
+ 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 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,
+// 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;
diff --git a/public/oidc/me.php b/public/oidc/me.php
index ad8a791..b9620cc 100644
--- a/public/oidc/me.php
+++ b/public/oidc/me.php
@@ -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
diff --git a/public/oidc/start.php b/public/oidc/start.php
index 7256736..46418b6 100644
--- a/public/oidc/start.php
+++ b/public/oidc/start.php
@@ -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')) {
diff --git a/public/route.php b/public/route.php
index e4d58c4..9919296 100644
--- a/public/route.php
+++ b/public/route.php
@@ -1,160 +1,7 @@
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;
diff --git a/src/ArticleManager.php b/src/ArticleManager.php
new file mode 100644
index 0000000..780a832
--- /dev/null
+++ b/src/ArticleManager.php
@@ -0,0 +1,347 @@
+dataDir)) {
+ return $articles;
+ }
+
+ foreach (scandir($this->dataDir) as $entry) {
+ if ($entry === '.' || $entry === '..') {
+ continue;
+ }
+ $dir = $this->dataDir . '/' . $entry;
+ $file = $dir . '/index.md';
+ if (!is_dir($dir) || !file_exists($file)) {
+ continue;
+ }
+
+ $article = $this->parseFile($file);
+ if (!$article) {
+ continue;
+ }
+ if ($publishedOnly && !$article['published']) {
+ continue;
+ }
+ $articles[] = $article;
+ }
+
+ usort($articles, static fn ($a, $b) => strcmp($b['created_at'] ?? '', $a['created_at'] ?? ''));
+
+ return $articles;
+ }
+
+ public function getBySlug(string $slug): ?array
+ {
+ foreach ($this->getAll() as $article) {
+ if (($article['slug'] ?? '') === $slug) {
+ return $article;
+ }
+ }
+ return null;
+ }
+
+ public function getByUuid(string $uuid): ?array
+ {
+ if (!$this->isValidUuid($uuid)) {
+ return null;
+ }
+ $file = $this->dataDir . '/' . $uuid . '/index.md';
+ if (!file_exists($file)) {
+ return null;
+ }
+ return $this->parseFile($file);
+ }
+
+ // ------------------------------------------------------------------ //
+ // Écriture
+ // ------------------------------------------------------------------ //
+
+ public function create(string $title, string $content, bool $published, string $slug = '', string $publishedAt = ''): string
+ {
+ $uuid = $this->generateUuid();
+ $slug = $slug !== '' ? $this->sanitizeSlug($slug) : $this->generateSlug($title);
+ $slug = $this->uniqueSlug($slug, $uuid);
+ $now = date('Y-m-d H:i:s');
+ $publishedAt = $publishedAt !== '' ? $publishedAt : $now;
+
+ $dir = $this->dataDir . '/' . $uuid;
+ mkdir($dir, 0755, true);
+ mkdir($dir . '/files', 0755, true);
+
+ $meta = [
+ 'uuid' => $uuid,
+ 'slug' => $slug,
+ 'title' => $title,
+ 'published' => $published,
+ 'published_at' => $publishedAt,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ];
+ file_put_contents($dir . '/index.md', $this->writeFrontmatter($meta, $content));
+
+ return $uuid;
+ }
+
+ public function update(string $uuid, string $title, string $content, bool $published, string $slug, string $publishedAt): void
+ {
+ $article = $this->getByUuid($uuid);
+ if (!$article) {
+ return;
+ }
+
+ $slug = $slug !== '' ? $this->sanitizeSlug($slug) : $this->generateSlug($title);
+ $slug = $this->uniqueSlug($slug, $uuid);
+
+ $meta = [
+ 'uuid' => $uuid,
+ 'slug' => $slug,
+ 'title' => $title,
+ 'published' => $published,
+ 'published_at' => $publishedAt !== '' ? $publishedAt : ($article['published_at'] ?? date('Y-m-d H:i:s')),
+ 'created_at' => $article['created_at'] ?? date('Y-m-d H:i:s'),
+ 'updated_at' => date('Y-m-d H:i:s'),
+ ];
+ file_put_contents($this->dataDir . '/' . $uuid . '/index.md', $this->writeFrontmatter($meta, $content));
+ }
+
+ public function delete(string $uuid): void
+ {
+ if (!$this->isValidUuid($uuid)) {
+ return;
+ }
+ $dir = $this->dataDir . '/' . $uuid;
+ if (is_dir($dir)) {
+ $this->removeDir($dir);
+ }
+ }
+
+ // ------------------------------------------------------------------ //
+ // Fichiers associés
+ // ------------------------------------------------------------------ //
+
+ public function getFiles(string $uuid): array
+ {
+ $dir = $this->dataDir . '/' . $uuid . '/files';
+ if (!is_dir($dir)) {
+ return [];
+ }
+
+ $files = [];
+ foreach (scandir($dir) as $name) {
+ if ($name === '.' || $name === '..') {
+ continue;
+ }
+ $path = $dir . '/' . $name;
+ if (!is_file($path)) {
+ continue;
+ }
+ $mime = mime_content_type($path) ?: 'application/octet-stream';
+ $files[] = [
+ 'name' => $name,
+ 'size' => filesize($path),
+ 'mime' => $mime,
+ 'is_image' => str_starts_with($mime, 'image/'),
+ 'is_video' => str_starts_with($mime, 'video/'),
+ 'is_audio' => str_starts_with($mime, 'audio/'),
+ 'uploaded_at' => date('Y-m-d H:i:s', (int)filemtime($path)),
+ ];
+ }
+ return $files;
+ }
+
+ public function addFile(string $uuid, array $uploadedFile): ?string
+ {
+ if (!$this->isValidUuid($uuid)) {
+ return null;
+ }
+ $dir = $this->dataDir . '/' . $uuid . '/files';
+ if (!is_dir($dir)) {
+ mkdir($dir, 0755, true);
+ }
+
+ $name = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($uploadedFile['name']));
+ $dest = $dir . '/' . $name;
+ $i = 1;
+ $info = pathinfo($name);
+ while (file_exists($dest)) {
+ $dest = $dir . '/' . $info['filename'] . '_' . $i . (isset($info['extension']) ? '.' . $info['extension'] : '');
+ $i++;
+ }
+
+ if (!move_uploaded_file($uploadedFile['tmp_name'], $dest)) {
+ return null;
+ }
+ return basename($dest);
+ }
+
+ // ------------------------------------------------------------------ //
+ // Rendu : résout les chemins relatifs dans le contenu Markdown
+ // ------------------------------------------------------------------ //
+
+ public function resolveFileUrls(string $uuid, string $markdown): string
+ {
+ $base = '/file?uuid=' . rawurlencode($uuid) . '&name=';
+
+ //  et [texte](fichier.ext) sans http/https ni /
+ return preg_replace_callback(
+ '/(!?\[([^\]]*)\])\((?!https?:\/\/)(?!\/)([^)]+)\)/',
+ static function (array $m) use ($base): string {
+ return $m[1] . '(' . $base . rawurlencode($m[3]) . ')';
+ },
+ $markdown
+ ) ?? $markdown;
+ }
+
+ // ------------------------------------------------------------------ //
+ // Helpers privés
+ // ------------------------------------------------------------------ //
+
+ private function parseFile(string $path): ?array
+ {
+ $raw = file_get_contents($path);
+ if ($raw === false) {
+ return null;
+ }
+
+ ['meta' => $meta, 'content' => $content] = $this->parseFrontmatter($raw);
+ if (empty($meta['uuid'])) {
+ return null;
+ }
+
+ $meta['content'] = $content;
+ $meta['published'] = filter_var($meta['published'] ?? false, FILTER_VALIDATE_BOOLEAN);
+
+ return $meta;
+ }
+
+ private function parseFrontmatter(string $raw): array
+ {
+ if (!str_starts_with($raw, '---')) {
+ return ['meta' => [], 'content' => $raw];
+ }
+ $end = strpos($raw, "\n---", 3);
+ if ($end === false) {
+ return ['meta' => [], 'content' => $raw];
+ }
+
+ $yaml = substr($raw, 4, $end - 4);
+ $content = ltrim(substr($raw, $end + 4));
+ $meta = [];
+
+ foreach (explode("\n", $yaml) as $line) {
+ $line = trim($line);
+ if ($line === '' || $line[0] === '#') {
+ continue;
+ }
+ $colon = strpos($line, ':');
+ if ($colon === false) {
+ continue;
+ }
+ $key = trim(substr($line, 0, $colon));
+ $val = trim(substr($line, $colon + 1));
+ if ($val === 'true') {
+ $val = true;
+ } elseif ($val === 'false') {
+ $val = false;
+ }
+ $meta[$key] = $val;
+ }
+
+ return ['meta' => $meta, 'content' => $content];
+ }
+
+ private function writeFrontmatter(array $meta, string $content): string
+ {
+ $yaml = '';
+ foreach ($meta as $key => $val) {
+ if (is_bool($val)) {
+ $val = $val ? 'true' : 'false';
+ }
+ $yaml .= $key . ': ' . $val . "\n";
+ }
+ return "---\n" . $yaml . "---\n\n" . ltrim($content);
+ }
+
+ private function generateSlug(string $title): string
+ {
+ $map = [
+ 'à' => 'a', 'â' => 'a', 'ä' => 'a',
+ 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
+ 'î' => 'i', 'ï' => 'i',
+ 'ô' => 'o', 'ö' => 'o',
+ 'ù' => 'u', 'û' => 'u', 'ü' => 'u',
+ 'ç' => 'c', 'æ' => 'ae', 'œ' => 'oe',
+ ];
+ $slug = mb_strtolower($title, 'UTF-8');
+ $slug = strtr($slug, $map);
+ $slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
+ return trim((string)$slug, '-');
+ }
+
+ private function sanitizeSlug(string $slug): string
+ {
+ $slug = mb_strtolower(trim($slug), 'UTF-8');
+ $slug = preg_replace('/[^a-z0-9-]/', '-', $slug);
+ $slug = preg_replace('/-+/', '-', $slug);
+ return trim((string)$slug, '-') ?: 'article';
+ }
+
+ private function uniqueSlug(string $slug, string $excludeUuid): string
+ {
+ $taken = array_column(
+ array_filter($this->getAll(), static fn ($a) => $a['uuid'] !== $excludeUuid),
+ 'slug'
+ );
+
+ if (!in_array($slug, $taken, true)) {
+ return $slug;
+ }
+ $i = 2;
+ while (in_array($slug . '-' . $i, $taken, true)) {
+ $i++;
+ }
+ return $slug . '-' . $i;
+ }
+
+ private function generateUuid(): string
+ {
+ $bytes = random_bytes(16);
+ $bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40);
+ $bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80);
+ return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
+ }
+
+ private function isValidUuid(string $uuid): bool
+ {
+ return (bool)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
+ );
+ }
+
+ private function removeDir(string $dir): void
+ {
+ foreach (scandir($dir) as $entry) {
+ if ($entry === '.' || $entry === '..') {
+ continue;
+ }
+ $path = $dir . '/' . $entry;
+ is_dir($path) ? $this->removeDir($path) : unlink($path);
+ }
+ rmdir($dir);
+ }
+}
diff --git a/src/auth.php b/src/auth.php
index bd8f633..7ddc607 100644
--- a/src/auth.php
+++ b/src/auth.php
@@ -1,28 +1,54 @@
setRedirectURL('http://varlog.acegrp.lan/auth/callback.php');
- $oidc->addScope(['openid', 'email', 'profile']);
- return $oidc;
+ return $_SESSION['user_email'] ?? null;
+}
+
+function isAdmin(): bool
+{
+ $email = currentUserEmail();
+ if (!$email) {
+ return false;
+ }
+ $rawAdmin = $_ENV['ADMIN_EMAIL'] ?? (getenv('ADMIN_EMAIL') ?: '');
+ $allowed = array_filter(array_map('trim', explode(',', (string)$rawAdmin)));
+ return in_array(strtolower($email), array_map('strtolower', $allowed), true);
+}
+
+function ssoLogoutUrl(): string
+{
+ $issuer = rtrim((string)($_ENV['OIDC_ISSUER'] ?? (getenv('OIDC_ISSUER') ?: '')), '/');
+ $clientId = (string)($_ENV['OIDC_CLIENT_ID'] ?? (getenv('OIDC_CLIENT_ID') ?: ''));
+ $baseUrl = rtrim((string)($_ENV['APP_URL'] ?? (getenv('APP_URL') ?: '/')), '/');
+
+ $params = [
+ 'client_id' => $clientId,
+ 'post_logout_redirect_uri' => $baseUrl . '/',
+ ];
+ if (!empty($_SESSION['oidc']['id_token'])) {
+ $params['id_token_hint'] = $_SESSION['oidc']['id_token'];
+ }
+
+ if (!$issuer) {
+ return $baseUrl . '/';
+ }
+
+ return $issuer . '/protocol/openid-connect/logout?' . http_build_query($params);
}
diff --git a/templates/about.php b/templates/about.php
index 5a9d8a8..7d9dc34 100644
--- a/templates/about.php
+++ b/templates/about.php
@@ -85,7 +85,7 @@ ob_start();
- Vous pouvez me joindre via le formulaire de contact.
+ Vous pouvez me joindre via le formulaire de contact.
Je lis tous les messages, même si je ne réponds pas toujours vite.
diff --git a/templates/contact.php b/templates/contact.php
index fcf6758..ae832ba 100644
--- a/templates/contact.php
+++ b/templates/contact.php
@@ -96,7 +96,7 @@ ob_start();
diff --git a/templates/legal.php b/templates/legal.php
index a425df7..ebe0b9c 100644
--- a/templates/legal.php
+++ b/templates/legal.php
@@ -35,7 +35,7 @@ ob_start();
Responsable de publication : Cédric Abonnel
Qualité : Particulier — site personnel non commercial
-
Contact : formulaire de contact
+
Contact : formulaire de contact
@@ -74,7 +74,7 @@ ob_start();
Les composants tiers (Bootstrap, PHPMailer, police Inter…) sont soumis à leurs licences respectives,
- détaillées sur la page des licences.
+ détaillées sur la page des licences.
@@ -99,7 +99,7 @@ ob_start();
Conformément au RGPD (règlement UE 2016/679), vous disposez d'un droit d'accès, de rectification
et de suppression des données vous concernant. Pour exercer ces droits :
- formulaire de contact.
+ formulaire de contact.
diff --git a/templates/post_form.php b/templates/post_form.php
index d86a0e4..43024cc 100644
--- a/templates/post_form.php
+++ b/templates/post_form.php
@@ -1,64 +1,121 @@
-= $action === 'edit' ? 'Modifier le post' : 'Créer un nouveau post' ?>
+= $action === 'edit' ? 'Modifier l\'article' : 'Nouvel article' ?>
-
-
-
- - = htmlspecialchars($error) ?>
-
-
-
+
+
+
+ - = htmlspecialchars($error) ?>
+
+
+