feat: stockage articles en fichiers Markdown, SSO intégré, URLs propres
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if (!defined('BASE_PATH')) {
|
||||
define('BASE_PATH', __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();
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 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;
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class ArticleManager
|
||||
{
|
||||
public function __construct(private string $dataDir)
|
||||
{
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Lecture
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
public function getAll(bool $publishedOnly = false): array
|
||||
{
|
||||
$articles = [];
|
||||
if (!is_dir($this->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);
|
||||
}
|
||||
}
|
||||
+43
-17
@@ -1,28 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
use Jumbojett\OpenIDConnectClient;
|
||||
|
||||
require_once BASE_PATH . '/vendor/autoload.php';
|
||||
session_start();
|
||||
|
||||
function require_auth()
|
||||
function isLoggedIn(): bool
|
||||
{
|
||||
if (!isset($_SESSION['user'])) {
|
||||
// Redirige vers la page de login
|
||||
header('Location: /auth/login.php');
|
||||
return !empty($_SESSION['user_email']);
|
||||
}
|
||||
|
||||
function requireAuth(): void
|
||||
{
|
||||
if (!isLoggedIn()) {
|
||||
$return = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
header('Location: /login' . ($return !== '/' ? '?return_to=' . urlencode($return) : ''), true, 302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
function get_oidc_client(): OpenIDConnectClient
|
||||
function currentUserEmail(): ?string
|
||||
{
|
||||
$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;
|
||||
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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ ob_start();
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<p class="mb-0">
|
||||
Vous pouvez me joindre via le <a href="route.php?action=contact">formulaire de contact</a>.
|
||||
Vous pouvez me joindre via le <a href="/?action=contact">formulaire de contact</a>.
|
||||
Je lis tous les messages, même si je ne réponds pas toujours vite.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,7 @@ ob_start();
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="route.php?action=contact" novalidate>
|
||||
<form method="POST" action="/?action=contact" novalidate>
|
||||
<input type="hidden" name="_token" value="<?= htmlspecialchars($_SESSION['contact_csrf']) ?>">
|
||||
<!-- Honeypot -->
|
||||
<div style="display:none" aria-hidden="true">
|
||||
|
||||
+18
-6
@@ -30,7 +30,7 @@
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-lg navbar-light mb-0" role="navigation" aria-label="Navigation principale">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand d-flex flex-column lh-1" href="route.php">
|
||||
<a class="navbar-brand d-flex flex-column lh-1" href="/">
|
||||
<span>varlog</span>
|
||||
<small class="navbar-tagline">journal de Cédrix · informatique, hack & loisirs</small>
|
||||
</a>
|
||||
@@ -39,7 +39,19 @@
|
||||
</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>
|
||||
<?php if (function_exists('isAdmin') && isAdmin()): ?>
|
||||
<li class="nav-item"><a class="nav-link" href="/?action=create">Nouveau post</a></li>
|
||||
<?php endif; ?>
|
||||
<?php if (function_exists('isLoggedIn') && isLoggedIn()): ?>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/logout.php" title="Déconnexion">
|
||||
<?= htmlspecialchars(currentUserEmail() ?? '') ?>
|
||||
<small class="text-muted">(déconnexion)</small>
|
||||
</a>
|
||||
</li>
|
||||
<?php else: ?>
|
||||
<li class="nav-item"><a class="nav-link" href="/login">Connexion</a></li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,10 +71,10 @@
|
||||
<small>© <?= date('Y') ?> — <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a></small>
|
||||
</div>
|
||||
<nav class="footer-nav" aria-label="Liens du site">
|
||||
<a href="route.php?action=about">À propos</a>
|
||||
<a href="route.php?action=contact">Contact</a>
|
||||
<a href="route.php?action=legal">Mentions légales</a>
|
||||
<a href="route.php?action=licenses">Licences</a>
|
||||
<a href="/?action=about">À propos</a>
|
||||
<a href="/?action=contact">Contact</a>
|
||||
<a href="/?action=legal">Mentions légales</a>
|
||||
<a href="/?action=licenses">Licences</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -35,7 +35,7 @@ ob_start();
|
||||
<div class="card-body">
|
||||
<p class="mb-1"><strong>Responsable de publication :</strong> Cédric Abonnel</p>
|
||||
<p class="mb-1"><strong>Qualité :</strong> Particulier — site personnel non commercial</p>
|
||||
<p class="mb-0"><strong>Contact :</strong> <a href="route.php?action=contact">formulaire de contact</a></p>
|
||||
<p class="mb-0"><strong>Contact :</strong> <a href="/?action=contact">formulaire de contact</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -74,7 +74,7 @@ ob_start();
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
Les composants tiers (Bootstrap, PHPMailer, police Inter…) sont soumis à leurs licences respectives,
|
||||
détaillées sur la <a href="route.php?action=licenses">page des licences</a>.
|
||||
détaillées sur la <a href="/?action=licenses">page des licences</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,7 +99,7 @@ ob_start();
|
||||
<p class="mb-0">
|
||||
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 :
|
||||
<a href="route.php?action=contact">formulaire de contact</a>.
|
||||
<a href="/?action=contact">formulaire de contact</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+85
-28
@@ -1,64 +1,121 @@
|
||||
<?php
|
||||
ob_start();
|
||||
|
||||
// Valeur par défaut pour le champ datetime-local
|
||||
$dateValue = $published_at ?? date('Y-m-d\TH:i');
|
||||
$dateValue = isset($published_at)
|
||||
? (str_contains($published_at, ' ')
|
||||
? date('Y-m-d\TH:i', strtotime($published_at))
|
||||
: $published_at)
|
||||
: date('Y-m-d\TH:i');
|
||||
?>
|
||||
|
||||
<h1 class="mb-4"><?= $action === 'edit' ? 'Modifier le post' : 'Créer un nouveau post' ?></h1>
|
||||
<h1 class="mb-4"><?= $action === 'edit' ? 'Modifier l\'article' : 'Nouvel article' ?></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>
|
||||
<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) ?>">
|
||||
<input type="text" class="form-control" id="title" name="title" required
|
||||
value="<?= htmlspecialchars($title ?? '') ?>"
|
||||
oninput="autoSlug(this.value)">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="slug" class="form-label">
|
||||
Slug <small class="text-muted">(URL : /post/<span id="slug-preview"><?= htmlspecialchars($postSlug ?? '') ?></span>)</small>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm font-monospace" id="slug" name="slug"
|
||||
value="<?= htmlspecialchars($postSlug ?? '') ?>"
|
||||
pattern="[a-z0-9][a-z0-9-]*"
|
||||
placeholder="généré automatiquement depuis le titre">
|
||||
</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>
|
||||
Écris en <strong>Markdown</strong> — les fichiers uploadés sont référençables dans le contenu :
|
||||
<code></code>
|
||||
</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>
|
||||
<textarea class="form-control font-monospace" id="content" name="content" rows="12"><?= 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 ?? false) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="published">Publié</label>
|
||||
<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 ?? false) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="published">Publié</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="files" class="form-label">Fichiers</label>
|
||||
<label for="files" class="form-label">Ajouter des fichiers</label>
|
||||
<input type="file" class="form-control" id="files" name="files[]" multiple>
|
||||
<div class="form-text">Images, vidéos, PDF… — intègre-les dans le contenu ou laisse-les en pièces jointes.</div>
|
||||
</div>
|
||||
|
||||
<?php if ($action === 'edit' && !empty($existingFiles)): ?>
|
||||
<div class="mb-3">
|
||||
<p class="form-label">Fichiers existants</p>
|
||||
<ul class="list-unstyled">
|
||||
<?php foreach ($existingFiles as $f): ?>
|
||||
<li>
|
||||
<code><?= htmlspecialchars($f['name']) ?></code>
|
||||
<small class="text-muted ms-2"><?= htmlspecialchars(number_format($f['size'] / 1024, 1)) ?> Ko</small>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<button type="submit" class="btn btn-success">Enregistrer</button>
|
||||
<a href="route.php" class="btn btn-secondary">Annuler</a>
|
||||
<a href="/" class="btn btn-secondary">Annuler</a>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function slugify(s) {
|
||||
const map = {'à':'a','â':'a','ä':'a','é':'e','è':'e','ê':'e','ë':'e','î':'i','ï':'i','ô':'o','ö':'o','ù':'u','û':'u','ü':'u','ç':'c','æ':'ae','œ':'oe'};
|
||||
return s.toLowerCase().replace(/[àâäéèêëîïôöùûüçæœ]/g, c => map[c] || c)
|
||||
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
}
|
||||
function autoSlug(title) {
|
||||
const slugField = document.getElementById('slug');
|
||||
const preview = document.getElementById('slug-preview');
|
||||
// N'écrase le slug que s'il est vide ou s'il correspond à la génération automatique
|
||||
if (slugField._auto !== false) {
|
||||
const generated = slugify(title);
|
||||
slugField.value = generated;
|
||||
preview.textContent = generated;
|
||||
}
|
||||
}
|
||||
document.getElementById('slug').addEventListener('input', function() {
|
||||
this._auto = (this.value === '');
|
||||
document.getElementById('slug-preview').textContent = this.value;
|
||||
});
|
||||
// En mode édition le champ est pré-rempli : désactive l'auto-génération
|
||||
(function() {
|
||||
const s = document.getElementById('slug');
|
||||
if (s.value !== '') s._auto = false;
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
$title = $action === 'edit' ? 'Modifier le post' : 'Nouveau post';
|
||||
$title = $action === 'edit' ? 'Modifier l\'article' : 'Nouvel article';
|
||||
include __DIR__ . '/layout.php';
|
||||
|
||||
+11
-8
@@ -15,32 +15,35 @@ ob_start();
|
||||
?>
|
||||
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
|
||||
<?php foreach ($posts as $post): ?>
|
||||
<?php foreach ($posts as $i => $post): ?>
|
||||
<?php
|
||||
$html = $Parsedown->text($post['content']);
|
||||
$preview = mb_strimwidth(strip_tags($html), 0, 120, '…');
|
||||
$gradient = $coverGradients[$post['id'] % count($coverGradients)];
|
||||
$gradient = $coverGradients[$i % count($coverGradients)];
|
||||
$postUrl = '/post/' . rawurlencode($post['slug']);
|
||||
?>
|
||||
<div class="col">
|
||||
<article class="card h-100">
|
||||
<div class="card-cover" style="background: <?= $gradient ?>"></div>
|
||||
<div class="card-body d-flex flex-column">
|
||||
<h2 class="card-title">
|
||||
<a href="route.php?action=view&id=<?= $post['id'] ?>">
|
||||
<a href="<?= htmlspecialchars($postUrl) ?>">
|
||||
<?= htmlspecialchars($post['title']) ?>
|
||||
</a>
|
||||
<?php if (!$post['is_published']): ?>
|
||||
<?php if (!$post['published']): ?>
|
||||
<span class="badge bg-warning ms-1">Brouillon</span>
|
||||
<?php endif; ?>
|
||||
</h2>
|
||||
<p class="card-text flex-grow-1"><?= htmlspecialchars($preview) ?></p>
|
||||
<div class="post-entry-meta mt-auto">
|
||||
<span><?= date('d/m/Y', strtotime($post['created_at'])) ?></span>
|
||||
<a href="route.php?action=edit&id=<?= $post['id'] ?>" class="post-entry-edit">modifier</a>
|
||||
<a href="route.php?action=view&id=<?= $post['id'] ?>" class="post-entry-read">→ lire</a>
|
||||
<span><?= htmlspecialchars(date('d/m/Y', strtotime((string)($post['created_at'] ?? '')))) ?></span>
|
||||
<?php if (function_exists('isAdmin') && isAdmin()): ?>
|
||||
<a href="/?action=edit&uuid=<?= htmlspecialchars($post['uuid']) ?>" class="post-entry-edit">modifier</a>
|
||||
<?php endif; ?>
|
||||
<a href="<?= htmlspecialchars($postUrl) ?>" class="post-entry-read">→ lire</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href="route.php?action=view&id=<?= $post['id'] ?>" class="stretched-link"></a>
|
||||
<a href="<?= htmlspecialchars($postUrl) ?>" class="stretched-link"></a>
|
||||
</article>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
+53
-46
@@ -5,66 +5,73 @@ $Parsedown = new Parsedown();
|
||||
ob_start();
|
||||
?>
|
||||
|
||||
<a href="route.php" class="btn btn-secondary mb-3">← Retour</a>
|
||||
<a href="/" 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>
|
||||
<h2 class="card-title"><?= htmlspecialchars($article['title']) ?></h2>
|
||||
|
||||
<div class="card-text post-content">
|
||||
<?= $Parsedown->text($post['content']) ?>
|
||||
<?= $Parsedown->text($rawContent) ?>
|
||||
</div>
|
||||
|
||||
<p class="text-muted small mt-2">Publié le <?= $post['created_at'] ?></p>
|
||||
<p class="text-muted small mt-2">
|
||||
Publié le <?= htmlspecialchars(date('d/m/Y H:i', strtotime((string)($article['published_at'] ?? $article['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
|
||||
// Sépare les fichiers intégrés (référencés dans le contenu) des pièces jointes
|
||||
$referenced = [];
|
||||
preg_match_all('/\(\/file\?uuid=[^&]+&name=([^)]+)\)/', $rawContent, $m);
|
||||
foreach ($m[1] as $encodedName) {
|
||||
$referenced[rawurldecode($encodedName)] = true;
|
||||
}
|
||||
$attachments = array_filter($files, static fn ($f) => !isset($referenced[$f['name']]));
|
||||
?>
|
||||
<?php if ($attachments): ?>
|
||||
<section class="mb-4">
|
||||
<h5>Pièces jointes</h5>
|
||||
<div class="row g-3">
|
||||
<?php foreach ($attachments as $file): ?>
|
||||
<div class="col-sm-6 col-md-4">
|
||||
<div class="card">
|
||||
<?php
|
||||
$fileUrl = '/file?uuid=' . rawurlencode($article['uuid']) . '&name=' . rawurlencode($file['name']);
|
||||
?>
|
||||
<?php if ($file['is_image']): ?>
|
||||
<img src="<?= htmlspecialchars($fileUrl) ?>" class="card-img-top" alt="<?= htmlspecialchars($file['name']) ?>" style="max-height:200px;object-fit:cover">
|
||||
<?php elseif ($file['is_video']): ?>
|
||||
<video controls class="w-100" style="max-height:200px"><source src="<?= htmlspecialchars($fileUrl) ?>"></video>
|
||||
<?php elseif ($file['is_audio']): ?>
|
||||
<audio controls class="w-100"><source src="<?= htmlspecialchars($fileUrl) ?>"></audio>
|
||||
<?php endif; ?>
|
||||
<div class="card-body p-2">
|
||||
<a href="<?= htmlspecialchars($fileUrl) ?>" class="card-title small d-block text-truncate" target="_blank">
|
||||
<?= htmlspecialchars($file['name']) ?>
|
||||
</a>
|
||||
<small class="text-muted"><?= htmlspecialchars(number_format($file['size'] / 1024, 1)) ?> Ko</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
<?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 if (function_exists('isAdmin') && isAdmin()): ?>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<a href="/?action=edit&uuid=<?= htmlspecialchars($article['uuid']) ?>" class="btn btn-primary">Modifier</a>
|
||||
<a href="/?action=delete&uuid=<?= htmlspecialchars($article['uuid']) ?>"
|
||||
class="btn btn-danger"
|
||||
onclick="return confirm('Supprimer cet article définitivement ?')">Supprimer</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
$title = htmlspecialchars($post['title']);
|
||||
$title = htmlspecialchars($article['title']);
|
||||
include __DIR__ . '/layout.php';
|
||||
|
||||
Reference in New Issue
Block a user