Files
folio/public/index.php
T

3522 lines
149 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
define('BASE_PATH', realpath(__DIR__ . '/../'));
// Charger .env avant de lire SESSION_NAME, sinon getenv() retourne '' et le mauvais cookie est chargé
require_once BASE_PATH . '/vendor/autoload.php';
require_once BASE_PATH . '/config/config.php';
$_sessionName = $_ENV['SESSION_NAME'] ?? (getenv('SESSION_NAME') ?: 'PHPSESSID');
if (session_status() === PHP_SESSION_NONE
&& (isset($_COOKIE[$_sessionName]) || $_SERVER['REQUEST_METHOD'] === 'POST')
) {
$isHttps = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
session_name($_sessionName);
session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'secure' => $isHttps, 'httponly' => true, 'samesite' => 'Lax']);
session_start();
}
unset($_sessionName);
require_once BASE_PATH . '/src/helpers.php';
require_once BASE_PATH . '/src/auth.php';
require_once BASE_PATH . '/src/SiteSettings.php';
require_once BASE_PATH . '/src/ArticleManager.php';
require_once BASE_PATH . '/src/BookManager.php';
require_once BASE_PATH . '/src/DataGit.php';
$_dataGit = new DataGit(DATA_PATH);
$articles = new ArticleManager(DATA_PATH, $_dataGit);
$books = new BookManager(DATA_PATH . '/books', $_dataGit);
// ─── Mode maintenance ──────────────────────────────────────────────────────
if (file_exists(DATA_PATH . '/.maintenance')) {
http_response_code(503);
header('Retry-After: 60');
include BASE_PATH . '/templates/maintenance.php';
exit;
}
require_once BASE_PATH . '/src/UpdateChecker.php';
$_updateChecker = new UpdateChecker(DATA_PATH, BASE_PATH);
$action = $_GET['action'] ?? 'list';
$uuid = $_GET['uuid'] ?? '';
$slug = $_GET['slug'] ?? '';
$_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category', 'admin_save_site', 'not_found', 'add_feed', 'delete_feed', 'add_link', 'delete_link', 'reorder_links', 'react', 'comment', 'verify_comment', 'comment_moderate', 'comment_delete', 'comment_resend', 'create_tag_type', 'delete_tag_type', 'edit_tags', 'book_save', 'book_delete', 'admin_save_as_groups', 'admin_save_folio_config'];
$metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null;
unset($_noindexActions);
// ─── Recherche de l'article le plus proche et redirection 301 ────────────────
function searchAndRedirect(string $rawPath, ArticleManager $articles): void
{
require_once BASE_PATH . '/src/SearchEngine.php';
$query = (string)preg_replace('/\s{2,}/', ' ', trim(
(string)preg_replace('/[^a-zA-ZÀ-ÿ0-9\s]/u', ' ', str_replace(['-', '_', '/'], ' ', $rawPath))
));
if ($query === '') {
return;
}
$privateCats = $articles->getPrivateCategories();
$pool = array_values(array_filter(
$articles->getAll(true),
static function (array $a) use ($privateCats): bool {
if (strtotime((string)($a['published_at'] ?? '')) > time()) {
return false;
}
$cat = trim($a['category'] ?? '');
return $cat === '' || !in_array($cat, $privateCats, true);
}
));
$results = (new SearchEngine())->search($query, $pool);
if (!empty($results)) {
header('Location: /search?q=' . urlencode($query), true, 302);
exit;
}
}
// ─── Pages statiques depuis data/site/ ──────────────────────────────────────
function loadSitePageData(string $slug): array
{
$base = DATA_PATH . '/site';
$meta = [];
$raw = @file_get_contents($base . '/' . $slug . '.json');
if ($raw !== false) {
$meta = json_decode($raw, true) ?? [];
}
$html = '';
$mdRaw = @file_get_contents($base . '/' . $slug . '.md');
if ($mdRaw !== false) {
require_once BASE_PATH . '/src/Parsedown.php';
$html = (new Parsedown())->text($mdRaw);
}
return ['meta' => $meta, 'html' => $html];
}
// ─── Extraction de métadonnées depuis une URL ────────────────────────────────
function fetchUrlMeta(string $url): array
{
if (!filter_var($url, FILTER_VALIDATE_URL) || !preg_match('#^https?://#i', $url)) {
return ['ok' => false, 'error' => 'URL invalide'];
}
$tmpFile = tempnam(sys_get_temp_dir(), 'vl_meta_');
$fp = fopen($tmpFile, 'wb');
$downloaded = 0;
$limit = 4 * 1024 * 1024;
$contentLength = null;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_TIMEOUT => 20,
CURLOPT_USERAGENT => 'Mozilla/5.0 varlog-meta/1.0',
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_ENCODING => '', // accepte gzip/deflate/br, décompresse automatiquement
CURLOPT_HEADERFUNCTION => static function ($curl, $header) use (&$contentLength): int {
if (preg_match('/^content-length:\s*(\d+)/i', $header, $m)) {
$contentLength = (int) $m[1];
}
return strlen($header);
},
CURLOPT_WRITEFUNCTION => static function ($curl, $chunk) use ($fp, &$downloaded, $limit): int {
$downloaded += strlen($chunk);
if ($downloaded > $limit) {
return -1;
}
fwrite($fp, $chunk);
return strlen($chunk);
},
]);
curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$mimeRaw = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$errno = curl_errno($ch);
curl_close($ch);
fclose($fp);
// 403 = site accessible mais protégé (Cloudflare bot-check, etc.) : on laisse passer
// avec métadonnées vides pour que l'utilisateur saisisse manuellement le titre.
if ($httpCode === 403) {
@unlink($tmpFile);
return ['ok' => true, 'mime' => 'text/html', 'blocked' => true];
}
if ($httpCode < 200 || $httpCode >= 400 || ($errno !== 0 && $errno !== 23)) {
@unlink($tmpFile);
return ['ok' => false, 'error' => "Téléchargement impossible (HTTP $httpCode)"];
}
$mime = strtok($mimeRaw ?: 'application/octet-stream', '; ');
$result = ['ok' => true, 'mime' => $mime, 'size' => $contentLength ?? $downloaded];
if (!str_starts_with($mime, 'text/html')) {
$etJson = @shell_exec('exiftool -json -charset utf8 -struct ' . escapeshellarg($tmpFile) . ' 2>/dev/null');
if ($etJson) {
$et = json_decode($etJson, true)[0] ?? [];
$etVal = static function (string ...$keys) use ($et): ?string {
foreach ($keys as $key) {
$v = $et[$key] ?? null;
if (is_array($v)) {
$v = implode(', ', array_filter(array_map('trim', $v)));
}
if (is_string($v) && trim($v) !== '') {
return trim($v);
}
}
return null;
};
if ($v = $etVal('Title', 'Headline', 'ObjectName')) {
$result['title'] = $v;
}
if ($v = $etVal('Description', 'Caption-Abstract', 'ImageDescription')) {
$result['description'] = $v;
}
if ($v = $etVal('Keywords')) {
$result['keywords'] = $v;
}
if ($v = $etVal('Copyright', 'CopyrightNotice', 'Rights')) {
$result['copyright'] = $v;
}
if ($v = $etVal('DateTimeOriginal', 'CreateDate', 'ModifyDate')) {
$result['date'] = preg_replace('/^(\d{4}):(\d{2}):(\d{2})/', '$1-$2-$3', $v);
}
if (str_starts_with($mime, 'image/')) {
if ($v = $etVal('Artist', 'Creator', 'By-line')) {
$result['author'] = $v;
}
$w = $et['ImageWidth'] ?? $et['ExifImageWidth'] ?? null;
$h = $et['ImageHeight'] ?? $et['ExifImageHeight'] ?? null;
if ($w !== null && $h !== null) {
$result['width'] = (int)$w;
$result['height'] = (int)$h;
}
$camera = trim(($et['Make'] ?? '') . ' ' . ($et['Model'] ?? ''));
if ($camera !== '') {
$result['camera'] = $camera;
}
if ($v = $etVal('Credit')) {
$result['credit'] = $v;
}
if ($v = $etVal('Source')) {
$result['source'] = $v;
}
}
if ($mime === 'application/pdf') {
if ($v = $etVal('Author')) {
$result['author'] = $v;
}
if ($v = $etVal('Subject')) {
$result['subject'] = $v;
}
if ($v = $etVal('Creator', 'CreatorTool')) {
$result['creator'] = $v;
}
if ($v = $etVal('Producer')) {
$result['producer'] = $v;
}
if (isset($et['PageCount'])) {
$result['pages'] = (int) $et['PageCount'];
}
if (isset($et['PDFVersion'])) {
$result['pdf_version'] = 'PDF ' . $et['PDFVersion'];
}
$fhPdf = fopen($tmpFile, 'rb');
$pdfHead = fread($fhPdf, min(filesize($tmpFile), 65536));
fclose($fhPdf);
if (preg_match('/\/MediaBox\s*\[\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\]/', $pdfHead, $mb)) {
$wPt = (float)$mb[3] - (float)$mb[1];
$hPt = (float)$mb[4] - (float)$mb[2];
$wMm = (int) round($wPt * 25.4 / 72);
$hMm = (int) round($hPt * 25.4 / 72);
$landscape = $wMm > $hMm;
if ($landscape) {
[$wMm, $hMm] = [$hMm, $wMm];
}
$paperSizes = ['A0' => [841,1189],'A1' => [594,841],'A2' => [420,594],
'A3' => [297,420],'A4' => [210,297],'A5' => [148,210],
'Letter' => [216,279],'Legal' => [216,356]];
$paperName = null;
foreach ($paperSizes as $pname => [$pw, $ph]) {
if (abs($wMm - $pw) <= 2 && abs($hMm - $ph) <= 2) {
$paperName = $pname;
break;
}
}
$label = $paperName ? $paperName . ($landscape ? ' paysage' : '') : ($landscape ? 'Paysage' : 'Portrait');
$result['page_size'] = "$label ({$wMm}×{$hMm} mm)";
}
}
}
}
if (str_starts_with($mime, 'text/html')) {
try {
$fhHtml = fopen($tmpFile, 'rb');
$html = fread($fhHtml, min(filesize($tmpFile), 65536));
fclose($fhHtml);
// Détection du charset : 1) en-tête HTTP, 2) <meta charset>, 3) <meta http-equiv>
$charset = null;
if (preg_match('/charset=([^\s;]+)/i', $mimeRaw, $cm)) {
$charset = trim($cm[1], '"\'');
}
if (!$charset && preg_match('/<meta[^>]+charset=["\']?\s*([^"\'\s;>]+)/i', $html, $cm)) {
$charset = trim($cm[1]);
}
if (!$charset && preg_match('/<meta[^>]+content=["\'][^"\']*charset=([^"\'\s;]+)/i', $html, $cm)) {
$charset = trim($cm[1]);
}
if ($charset && strtolower(str_replace(['-','_'], '', $charset)) !== 'utf8') {
$converted = @mb_convert_encoding($html, 'UTF-8', $charset);
if ($converted !== false && $converted !== '') {
$html = $converted;
}
}
preg_match('/<head[^>]*>(.*?)<\/head>/si', $html, $headMatch);
$headHtml = $headMatch[1] ?? $html;
if (preg_match('/<title[^>]*>\s*([^<]+)\s*<\/title>/i', $headHtml, $m)) {
$result['title'] = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
$metaMap = [];
preg_match_all('/<meta\s+([^>]+)>/i', $headHtml, $metaTags);
foreach ($metaTags[1] as $attrs) {
$key = $val = null;
if (preg_match('/(?:name|property)\s*=\s*["\']([^"\']+)["\']/', $attrs, $m)) {
$key = strtolower($m[1]);
}
if (preg_match('/content\s*=\s*["\']([^"\']*)["\']/', $attrs, $m)) {
$val = html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
if ($key !== null && $val !== null && $val !== '') {
$metaMap[$key] = $val;
}
}
$result['title'] ??= $metaMap['og:title'] ?? $metaMap['twitter:title'] ?? $metaMap['title'] ?? null;
$result['description'] ??= $metaMap['og:description'] ?? $metaMap['twitter:description'] ?? $metaMap['description'] ?? null;
$result['author'] ??= $metaMap['author'] ?? $metaMap['article:author'] ?? $metaMap['dc.creator'] ?? null;
$result['keywords'] ??= $metaMap['keywords'] ?? $metaMap['news_keywords'] ?? null;
$result['og_image'] ??= $metaMap['og:image'] ?? $metaMap['twitter:image'] ?? null;
$result['site_name'] ??= $metaMap['og:site_name'] ?? null;
$result['og_type'] ??= $metaMap['og:type'] ?? null;
$result['language'] ??= $metaMap['og:locale'] ?? $metaMap['dc.language'] ?? null;
$result['date'] ??= $metaMap['article:published_time'] ?? $metaMap['dc.date'] ?? null;
if (preg_match('/<link[^>]+rel=["\']canonical["\'][^>]+href=["\']([^"\']+)["\'][^>]*>/i', $headHtml, $m)
|| preg_match('/<link[^>]+href=["\']([^"\']+)["\'][^>]+rel=["\']canonical["\'][^>]*>/i', $headHtml, $m)) {
$result['canonical'] = $m[1];
}
preg_match_all('/<script[^>]+type=["\']application\/ld\+json["\'][^>]*>(.*?)<\/script>/si', $headHtml, $ldTags);
foreach ($ldTags[1] as $jsonStr) {
$ld = @json_decode(trim($jsonStr), true);
if (!is_array($ld)) {
continue;
}
foreach (isset($ld[0]) ? $ld : [$ld] as $item) {
if (!is_array($item)) {
continue;
}
if (empty($result['title']) && !empty($item['headline'])) {
$result['title'] = $item['headline'];
}
if (empty($result['description']) && !empty($item['description'])) {
$result['description'] = $item['description'];
}
if (empty($result['date'])) {
$d = $item['datePublished'] ?? $item['dateCreated'] ?? null;
if ($d) {
$result['date'] = $d;
}
}
if (empty($result['author'])) {
$au = $item['author'] ?? null;
if (is_array($au)) {
$au = $au['name'] ?? ($au[0]['name'] ?? null);
}
if (is_string($au) && $au !== '') {
$result['author'] = $au;
}
}
}
break;
}
$result = array_filter($result, static fn ($v) => $v !== null && $v !== '');
$result['ok'] = true;
} catch (\Throwable) {
}
}
@unlink($tmpFile);
return $result;
}
// ─── Télécharge une image distante → _thumb_ local, retourne le nom du fichier ─
function downloadImageToThumb(string $imageUrl, string $filesDir): ?string
{
if (!filter_var($imageUrl, FILTER_VALIDATE_URL) || !preg_match('#^https?://#i', $imageUrl)) {
return null;
}
$ch = curl_init($imageUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_USERAGENT => 'Mozilla/5.0 varlog/1.0',
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $code !== 200 || strlen($body) < 512) {
return null;
}
$urlPath = parse_url($imageUrl, PHP_URL_PATH) ?? '';
$ext = strtolower(pathinfo($urlPath, PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif'], true)) {
$ext = 'jpg';
}
if ($ext === 'jpeg') {
$ext = 'jpg';
}
if (!is_dir($filesDir)) {
mkdir($filesDir, 0755, true);
}
$hash = substr(hash('sha256', $body), 0, 16);
$size = strlen($body);
$name = '_thumb_' . $hash . '-' . $size . '.' . $ext;
file_put_contents($filesDir . '/' . $name, $body);
return $name;
}
// ─── Trouve l'URL de la plus grande image d'une page HTML ────────────────────
function findLargestPageImage(string $pageUrl): ?string
{
if (!filter_var($pageUrl, FILTER_VALIDATE_URL)) {
return null;
}
$ch = curl_init($pageUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_USERAGENT => 'Mozilla/5.0 varlog/1.0',
CURLOPT_ENCODING => '',
CURLOPT_WRITEFUNCTION => static function ($curl, $chunk) use (&$htmlBuf, &$htmlLen): int {
$htmlLen = ($htmlLen ?? 0) + strlen($chunk);
if ($htmlLen <= 131072) {
$htmlBuf = ($htmlBuf ?? '') . $chunk;
}
return strlen($chunk);
},
]);
$htmlBuf = '';
$htmlLen = 0;
curl_exec($ch);
curl_close($ch);
if (!$htmlBuf) {
return null;
}
$scheme = parse_url($pageUrl, PHP_URL_SCHEME) ?? 'https';
$host = $scheme . '://' . (parse_url($pageUrl, PHP_URL_HOST) ?? '');
preg_match_all('/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $htmlBuf, $m);
$candidates = [];
foreach ($m[1] as $src) {
if (preg_match('#^https?://#i', $src)) {
$candidates[] = $src;
} elseif (str_starts_with($src, '//')) {
$candidates[] = $scheme . ':' . $src;
} elseif (str_starts_with($src, '/')) {
$candidates[] = $host . $src;
}
}
// Filtre les icônes/avatars courants par leur chemin
$candidates = array_filter(
$candidates,
fn ($u) =>
!preg_match('#/(icon|logo|avatar|favicon|sprite|pixel|spacer|blank|1x1|tracking)#i', $u)
&& preg_match('#\.(jpe?g|png|webp|gif|avif)(\?.*)?$#i', $u)
);
$candidates = array_slice(array_values($candidates), 0, 10);
if (empty($candidates)) {
return null;
}
// HEAD requests pour comparer Content-Length
$best = null;
$bestSize = 0;
foreach ($candidates as $imgUrl) {
$ch = curl_init($imgUrl);
curl_setopt_array($ch, [
CURLOPT_NOBODY => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_USERAGENT => 'Mozilla/5.0 varlog/1.0',
]);
curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$len = (int) curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);
curl_close($ch);
if ($code === 200 && $len > $bestSize) {
$bestSize = $len;
$best = $imgUrl;
}
}
// Si aucun Content-Length retourné, prend le premier candidat
return $best ?? $candidates[0];
}
// ─── Capture d'écran via Chromium headless ──────────────────────────────────
function takeScreenshot(string $url, string $outputPath): bool
{
$bin = '';
foreach (['chromium-headless-shell', 'chromium', 'chromium-browser', 'google-chrome'] as $name) {
$found = trim((string) shell_exec('which ' . escapeshellarg($name) . ' 2>/dev/null'));
if ($found !== '') {
$bin = $found;
break;
}
}
if ($bin === '') {
return false;
}
$cmd = 'timeout 20 ' . escapeshellarg($bin)
. ' --headless=new'
. ' --disable-gpu'
. ' --no-sandbox'
. ' --disable-setuid-sandbox'
. ' --hide-scrollbars'
. ' --window-size=1200,630'
. ' --screenshot=' . escapeshellarg($outputPath)
. ' --virtual-time-budget=6000'
. ' ' . escapeshellarg($url)
. ' 2>/dev/null';
shell_exec($cmd);
return file_exists($outputPath) && filesize($outputPath) > 0;
}
switch ($action) {
case 'create':
requireAuth();
$step = max(1, min(5, (int)($_GET['step'] ?? 1)));
$totalSteps = 5;
$mode = 'create';
$errors = [];
// UUID depuis l'URL ou la session
if ($uuid === '') {
$uuid = $_SESSION['wizard_create'] ?? '';
}
$draft = $uuid !== '' ? $articles->getByUuid($uuid) : null;
// Si session pointe vers un UUID inexistant, on repart de zéro
if ($draft === null && $uuid !== '') {
unset($_SESSION['wizard_create']);
$uuid = '';
}
switch ($step) {
case 1:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$content = str_replace("\r\n", "\n", $_POST['content'] ?? '');
$title = extractMarkdownTitle($content) ?: ($draft['title'] ?? 'Sans titre');
$postSlug = trim($_POST['slug'] ?? '');
if ($draft === null) {
$uuid = $articles->create($title, $content, false, $postSlug, date('Y-m-d H:i:s'), currentUserEmail() ?? '', '', '', '', '', []);
foreach ($_FILES['files']['tmp_name'] ?? [] as $_fi => $_tmpName) {
if ($_FILES['files']['error'][$_fi] === UPLOAD_ERR_OK) {
$articles->addFile($uuid, ['name' => $_FILES['files']['name'][$_fi], 'tmp_name' => $_tmpName, 'error' => UPLOAD_ERR_OK]);
}
}
$_SESSION['wizard_create'] = $uuid;
} else {
$articles->autosave($uuid, $title, $content, $postSlug);
}
header('Location: /new/' . rawurlencode($uuid) . '/2');
exit;
}
$title = $draft['title'] ?? '';
$content = $draft['content'] ?? '';
$postSlug = $draft['slug'] ?? '';
$existingFiles = $uuid !== '' ? $articles->getFiles($uuid) : [];
$article = $draft;
$insertUrl = '';
$formAction = $uuid !== '' ? '/new/' . rawurlencode($uuid) . '/1' : '/new';
include BASE_PATH . '/templates/wizard/step1.php';
break;
case 2:
if ($draft === null) {
header('Location: /new');
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$articles->updatePartialMeta($uuid, [
'published' => isset($_POST['published']) && $_POST['published'] !== '',
'published_at' => str_replace('T', ' ', $_POST['published_at'] ?? date('Y-m-d H:i:s')),
]);
header('Location: /new/' . rawurlencode($uuid) . '/3');
exit;
}
$published = (bool)($draft['published'] ?? false);
$published_at = $draft['published_at'] ?? date('Y-m-d H:i:s');
include BASE_PATH . '/templates/wizard/step2.php';
break;
case 3:
if ($draft === null) {
header('Location: /new');
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$articles->updatePartialMeta($uuid, ['category' => trim($_POST['category'] ?? '')]);
header('Location: /new/' . rawurlencode($uuid) . '/4');
exit;
}
$category = $draft['category'] ?? '';
$allCategories = $articles->getCategories();
$privateCats = $articles->getPrivateCategories();
include BASE_PATH . '/templates/wizard/step3.php';
break;
case 4:
if ($draft === null) {
header('Location: /new');
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$_vals = array_values(array_filter(array_map('trim', explode(',', (string)($_POST['tags_flat'] ?? ''))), fn ($_v) => $_v !== ''));
$articles->updatePartialMeta($uuid, ['tags' => $_vals !== [] ? ['tags' => $_vals] : []]);
header('Location: /new/' . rawurlencode($uuid) . '/5');
exit;
}
$_tagTypes = $articles->getTagTypes();
$flatTagValues = [];
foreach ($_tagTypes as $_tk => $_) {
foreach ($articles->getAllTagValues($_tk) as $_v) {
$flatTagValues[$_v] = true;
}
}
foreach ($articles->getAllTagValues('tags') as $_v) {
$flatTagValues[$_v] = true;
}
ksort($flatTagValues);
$flatTagValues = array_keys($flatTagValues);
$flatArticleTags = [];
foreach (($draft['tags'] ?? []) as $_tagVals) {
foreach ((array)$_tagVals as $_v) {
if (!in_array($_v, $flatArticleTags, true)) {
$flatArticleTags[] = $_v;
}
}
}
$draftContent = (string)($draft['content'] ?? '');
require_once BASE_PATH . '/src/TagSuggester.php';
include BASE_PATH . '/templates/wizard/step4.php';
break;
case 5:
if ($draft === null) {
header('Location: /new');
exit;
}
require_once BASE_PATH . '/src/Parsedown.php';
$_pd = new Parsedown();
$autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draft['content'] ?? ''))))), 0, 155, '…');
unset($_pd);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$seoTitle = trim($_POST['seo_title'] ?? '');
$seoDesc = trim($_POST['seo_description'] ?? '') ?: $autoSeoDesc;
$coverFile = trim($_POST['cover_file'] ?? '') ?: ($draft['cover'] ?? '');
$ogImage = $coverFile !== ''
? rtrim(APP_URL, '/') . '/file?uuid=' . rawurlencode($uuid) . '&name=' . rawurlencode($coverFile)
: ($draft['og_image'] ?? '');
$articles->update($uuid, $draft['title'], $draft['content'], $draft['published'], $draft['slug'] ?? '', $draft['published_at'] ?? '', 'Création', $seoTitle, $seoDesc, $ogImage, $draft['category'] ?? '', $draft['tags'] ?? []);
if ($coverFile !== '' && $coverFile !== ($draft['cover'] ?? '')) {
$articles->setCover($uuid, $coverFile);
}
unset($_SESSION['wizard_create']);
$final = $articles->getByUuid($uuid);
header('Location: /post/' . rawurlencode($final['slug'] ?? $uuid));
exit;
}
$title = $draft['title'] ?? '';
$seoTitle = $draft['seo_title'] ?? '';
$seoDescription = $draft['seo_description'] ?? '';
$existingFiles = $articles->getFiles($uuid);
$category = $draft['category'] ?? '';
$published = (bool)($draft['published'] ?? false);
$published_at = $draft['published_at'] ?? '';
$postSlug = $draft['slug'] ?? '';
$article = $draft;
include BASE_PATH . '/templates/wizard/step5.php';
break;
}
break;
case 'view':
$article = $slug !== '' ? $articles->getBySlug($slug) : null;
if (!$article) {
searchAndRedirect($slug, $articles);
http_response_code(404);
echo 'Article introuvable.';
exit;
}
if (!$article['published']) {
if (!canDoOnArticle('view_drafts', $article)) {
http_response_code(404);
echo 'Article introuvable.';
exit;
}
}
// Avant-première : publié mais date future → réservé aux utilisateurs avec view_previews
if ($article['published'] && strtotime((string)($article['published_at'] ?? '')) > time()) {
if (!hasCapability('view_previews')) {
http_response_code(404);
echo 'Article introuvable.';
exit;
}
}
// Catégorie privée → réservé aux connectés
$allCats = $articles->getCategories();
$privateCats = $articles->getPrivateCategories();
$articleCat = trim($article['category'] ?? '');
$isPrivateCat = $articleCat !== '' && in_array($articleCat, $privateCats, true);
if ($isPrivateCat && !isLoggedIn()) {
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']);
// Ratings
$ratingStats = ['avg' => null, 'count' => 0];
$userRating = null;
$pdo = dbPdo();
if ($pdo) {
require_once BASE_PATH . '/src/RatingManager.php';
$ratingMgr = new RatingManager($pdo);
$ratingStats = $ratingMgr->statsForArticle($article['uuid']);
if (isLoggedIn()) {
$userRating = $ratingMgr->userRating($article['uuid'], currentUserEmail() ?? '');
}
}
// Tous les articles publiés — depuis le search_index (1 fichier) si dispo, sinon getAll()
$_si = $articles->getSearchIndex();
$_allPublished = $_si !== null
? array_values(array_filter($_si, fn ($a) => (bool)($a['published'] ?? false)))
: $articles->getAll(true);
unset($_si);
// Articles liés (même catégorie)
$relatedArticles = [];
if ($articleCat !== '') {
foreach ($_allPublished as $a) {
if ($a['uuid'] === $article['uuid']) {
continue;
}
if (trim($a['category'] ?? '') !== $articleCat) {
continue;
}
if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) {
continue;
}
$relatedArticles[] = $a;
if (count($relatedArticles) >= 5) {
break;
}
}
}
// Articles proches par titre → OR implicite, cumul des scores
require_once BASE_PATH . '/src/SearchEngine.php';
$_simEngine = new SearchEngine();
$_stopWords = ['avec', 'dans', 'pour', 'une', 'les', 'des', 'sur', 'par', 'qui', 'que',
'tout', 'mais', 'donc', 'comment', 'quand', 'plus', 'cette', 'cet', 'ces',
'mon', 'ton', 'son', 'notre', 'votre', 'leur', 'tres', 'bien', 'fait',
'aussi', 'comme', 'sans', 'sous', 'entre', 'vers', 'chez'];
$_simPool = array_values(array_filter(
$_allPublished,
static function (array $a) use ($article, $privateCats): bool {
if ($a['uuid'] === $article['uuid']) {
return false;
}
if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) {
return false;
}
$cat = trim($a['category'] ?? '');
return $cat === '' || !in_array($cat, $privateCats, true) || isLoggedIn();
}
));
$_titleWords = array_unique(array_values(array_filter(
preg_split('/\W+/u', mb_strtolower($article['title']), -1, PREG_SPLIT_NO_EMPTY) ?: [],
fn ($w) => mb_strlen($w) >= 4 && !in_array($w, $_stopWords, true)
)));
[$_scoreMap, $_articleMap] = $_simEngine->scorePool($_titleWords, $_simPool);
arsort($_scoreMap);
$_similarArticles = array_map(
fn ($uuid) => $_articleMap[$uuid],
array_slice(array_keys($_scoreMap), 0, 5)
);
unset($_simEngine, $_simPool, $_titleWords, $_stopWords, $_scoreMap, $_articleMap);
// "À lire aussi" : similaires en premier, même catégorie pour compléter jusqu'à 5
$alsoReadArticles = $_similarArticles;
if (count($alsoReadArticles) < 5) {
$_used = array_column($alsoReadArticles, 'uuid');
foreach ($relatedArticles as $_ra) {
if (count($alsoReadArticles) >= 5) {
break;
}
if (!in_array($_ra['uuid'], $_used, true)) {
$alsoReadArticles[] = $_ra;
}
}
unset($_used);
}
unset($_similarArticles);
unset($_allPublished);
$backlinks = $articles->getBacklinks($article['slug'] ?? '', $article['uuid']);
// Réactions et commentaires
require_once BASE_PATH . '/src/ReactionManager.php';
require_once BASE_PATH . '/src/CommentManager.php';
$reactionStats = array_fill_keys(ReactionManager::TYPES, 0);
$visitorReactions = [];
$comments = [];
$commentFlash = isset($_GET['commented']);
$commentVerified = isset($_GET['verified']);
$commentError = null;
if ($pdo) {
$reactionMgr = new ReactionManager($pdo);
$commentMgr = new CommentManager($pdo);
// Cookie visiteur (fingerprint anti-doublon)
if (empty($_COOKIE['vl_vid'])) {
$vid = bin2hex(random_bytes(16));
setcookie('vl_vid', $vid, [
'expires' => time() + 365 * 86400,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
} else {
$vid = $_COOKIE['vl_vid'];
}
$reactionStats = $reactionMgr->statsForArticle($article['uuid']);
$visitorReactions = $reactionMgr->visitorReactions($article['uuid'], $vid);
$comments = $commentMgr->forArticle($article['uuid']);
}
// Contexte livre (navigation précédent/suivant si l'article fait partie d'un livre)
$bookContext = $books->findForArticle($article['slug'] ?? '');
if ($bookContext !== null) {
$bookContext['prev_article'] = $bookContext['prev'] !== null ? $articles->getBySlug($bookContext['prev']) : null;
$bookContext['next_article'] = $bookContext['next'] !== null ? $articles->getBySlug($bookContext['next']) : null;
}
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;
}
if (!canDoOnArticle('edit_articles', $article)) {
http_response_code(403);
echo 'Accès refusé.';
exit;
}
// Toggle featured (admin only) — conservé depuis l'ancienne version
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['_toggle_featured'])) {
if (!isAdmin()) {
http_response_code(403);
exit;
}
$articles->setFeatured($uuid, !((bool)($article['featured'] ?? false)));
header('Location: /edit/' . rawurlencode($uuid) . '/1');
exit;
}
$step = (int)($_GET['step'] ?? 0);
$totalSteps = 6;
$mode = 'edit';
$errors = [];
// Sans step : rediriger vers l'étape 1
if ($step === 0) {
header('Location: /edit/' . rawurlencode($uuid) . '/1');
exit;
}
// Base de travail : draft overlay s'il existe, sinon article original
$draft = $articles->getDraftOverlay($uuid) ?? $article;
switch ($step) {
case 1:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$content = str_replace("\r\n", "\n", $_POST['content'] ?? '');
$title = extractMarkdownTitle($content) ?: ($draft['title'] ?? 'Sans titre');
$articles->saveDraftOverlay($uuid, ['title' => $title, 'slug' => trim($_POST['slug'] ?? $draft['slug'] ?? '')], $content);
header('Location: /edit/' . rawurlencode($uuid) . '/2');
exit;
}
$title = $draft['title'];
$content = $draft['content'];
$postSlug = $draft['slug'];
$existingFiles = $articles->getFiles($uuid);
$insertUrl = '';
if (isset($_GET['insert_url']) && filter_var($_GET['insert_url'], FILTER_VALIDATE_URL)) {
$insertUrl = $_GET['insert_url'];
}
$formAction = '/edit/' . rawurlencode($uuid) . '/1';
include BASE_PATH . '/templates/wizard/step1.php';
break;
case 2:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$articles->saveDraftOverlay($uuid, [
'published' => isset($_POST['published']) && $_POST['published'] !== '',
'published_at' => str_replace('T', ' ', $_POST['published_at'] ?? ($draft['published_at'] ?? '')),
]);
header('Location: /edit/' . rawurlencode($uuid) . '/3');
exit;
}
$published = (bool)($draft['published'] ?? false);
$published_at = $draft['published_at'] ?? '';
include BASE_PATH . '/templates/wizard/step2.php';
break;
case 3:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$articles->saveDraftOverlay($uuid, ['category' => trim($_POST['category'] ?? '')]);
header('Location: /edit/' . rawurlencode($uuid) . '/4');
exit;
}
$category = $draft['category'] ?? '';
$allCategories = $articles->getCategories();
$privateCats = $articles->getPrivateCategories();
include BASE_PATH . '/templates/wizard/step3.php';
break;
case 4:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$_vals = array_values(array_filter(array_map('trim', explode(',', (string)($_POST['tags_flat'] ?? ''))), fn ($_v) => $_v !== ''));
$articles->saveDraftOverlay($uuid, ['tags' => $_vals !== [] ? ['tags' => $_vals] : []]);
header('Location: /edit/' . rawurlencode($uuid) . '/5');
exit;
}
$_tagTypes = $articles->getTagTypes();
$flatTagValues = [];
foreach ($_tagTypes as $_tk => $_) {
foreach ($articles->getAllTagValues($_tk) as $_v) {
$flatTagValues[$_v] = true;
}
}
foreach ($articles->getAllTagValues('tags') as $_v) {
$flatTagValues[$_v] = true;
}
ksort($flatTagValues);
$flatTagValues = array_keys($flatTagValues);
$flatArticleTags = [];
foreach (($draft['tags'] ?? []) as $_tagVals) {
foreach ((array)$_tagVals as $_v) {
if (!in_array($_v, $flatArticleTags, true)) {
$flatArticleTags[] = $_v;
}
}
}
$draftContent = (string)($draft['content'] ?? '');
require_once BASE_PATH . '/src/TagSuggester.php';
include BASE_PATH . '/templates/wizard/step4.php';
break;
case 5:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$articles->saveDraftOverlay($uuid, [
'seo_title' => trim($_POST['seo_title'] ?? ''),
'seo_description' => trim($_POST['seo_description'] ?? ''),
]);
header('Location: /edit/' . rawurlencode($uuid) . '/6');
exit;
}
require_once BASE_PATH . '/src/Parsedown.php';
$_pd = new Parsedown();
$autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draft['content'] ?? ''))))), 0, 155, '…');
unset($_pd);
$title = $draft['title'];
$seoTitle = $draft['seo_title'] ?? '';
$seoDescription = $draft['seo_description'] ?? '';
$postSlug = $draft['slug'];
$published = (bool)($draft['published'] ?? false);
$published_at = $draft['published_at'] ?? '';
$category = $draft['category'] ?? '';
include BASE_PATH . '/templates/wizard/step5.php';
break;
case 6:
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['_confirm'])) {
$revisionComment = trim($_POST['revision_comment'] ?? '');
// Si le slug a été modifié dans le formulaire de confirmation, le propager
if (!empty($_POST['slug'])) {
$articles->saveDraftOverlay($uuid, ['slug' => trim($_POST['slug'])]);
}
$articles->commitDraftOverlay($uuid, $revisionComment);
$final = $articles->getByUuid($uuid);
header('Location: /post/' . rawurlencode($final['slug'] ?? $uuid));
exit;
}
$draftData = $articles->getDraftOverlay($uuid) ?? $article;
require_once BASE_PATH . '/src/Parsedown.php';
$_pd = new Parsedown();
$autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draftData['content'] ?? ''))))), 0, 155, '…');
unset($_pd);
$diffLines = lineDiff((string)($article['content'] ?? ''), (string)($draftData['content'] ?? ''));
$titleChanged = ($draftData['title'] ?? '') !== ($article['title'] ?? '');
$autoSlug = slugify($draftData['title'] ?? '');
$postSlug = $draftData['slug'] ?? $article['slug'];
$changes = [];
if ($titleChanged) {
$changes[] = 'titre modifié';
}
if (($draftData['category'] ?? '') !== ($article['category'] ?? '')) {
$changes[] = 'catégorie modifiée';
}
if (($draftData['tags'] ?? []) !== ($article['tags'] ?? [])) {
$changes[] = 'tags modifiés';
}
if (($draftData['content'] ?? '') !== ($article['content'] ?? '')) {
$changes[] = 'contenu modifié';
}
if ((bool)($draftData['published'] ?? false) !== (bool)($article['published'] ?? false)) {
$changes[] = ($draftData['published'] ?? false) ? 'article publié' : 'article dépublié';
}
$autoRevisionComment = !empty($changes) ? ucfirst(implode(', ', $changes)) : '';
$title = $draftData['title'] ?? '';
$seoTitle = $draftData['seo_title'] ?? '';
$seoDescription = $draftData['seo_description'] ?? '';
$published = (bool)($draftData['published'] ?? false);
$published_at = $draftData['published_at'] ?? '';
$category = $draftData['category'] ?? '';
include BASE_PATH . '/templates/wizard/step6.php';
break;
}
break;
case 'edit_tags':
requireAuth();
$article = $uuid !== '' ? $articles->getByUuid($uuid) : null;
if (!$article) {
http_response_code(404);
echo 'Article introuvable.';
exit;
}
if (!canDoOnArticle('edit_articles', $article)) {
http_response_code(403);
echo 'Accès refusé.';
exit;
}
$tagType = urldecode(trim($_GET['tag_type'] ?? ''));
$isCatField = ($tagType === 'categorie' || $tagType === 'catégorie');
$tagTypes = $articles->getTagTypes();
require_once BASE_PATH . '/src/TagSuggester.php';
$suggester = new TagSuggester();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($isCatField) {
$newCat = trim($_POST['category'] ?? '');
// Met à jour uniquement la catégorie via update() en préservant tout le reste
$articles->update(
$uuid,
$article['title'],
$article['content'],
$article['published'],
$article['slug'],
$article['published_at'] ?? '',
'',
$article['seo_title'] ?? '',
$article['seo_description'] ?? '',
$article['og_image'] ?? '',
$newCat,
$article['tags'] ?? []
);
} else {
// Tags : les valeurs cochées remplacent les tags du type concerné
$selected = array_values(array_filter(array_map('trim', $_POST['selected'] ?? []), fn ($v) => $v !== ''));
$allTags = $article['tags'] ?? [];
if (empty($selected)) {
unset($allTags[$tagType]);
} else {
$allTags[$tagType] = $selected;
}
$articles->setTags($uuid, $allTags);
}
header('Location: /edit/' . rawurlencode($uuid));
exit;
}
// GET — calculer les suggestions
if ($isCatField) {
$allCats = $articles->getCategories();
$currentCat = $article['category'] ?? '';
$suggestions = $suggester->suggestCategory($article['content'], $allCats, $currentCat);
$currentTags = [];
} else {
$existingValues = $articles->getAllTagValues($tagType);
$currentTags = $article['tags'][$tagType] ?? [];
$suggestions = $suggester->suggest($article['content'], $existingValues, $currentTags);
$allCats = null;
$currentCat = null;
}
include BASE_PATH . '/templates/edit_tags.php';
break;
case 'delete_file':
requireAuth();
$fileName = basename($_POST['name'] ?? '');
if ($uuid !== '' && $fileName !== '' && $fileName[0] !== '.') {
$articles->deleteFile($uuid, $fileName);
}
header('Location: /edit/' . rawurlencode($uuid));
exit;
case 'delete':
requireAuth();
if ($uuid !== '') {
$articles->delete($uuid);
}
header('Location: /');
exit;
case 'delete_revision':
requireAuth();
if (!isAdmin()) {
http_response_code(403);
exit;
}
if ($uuid !== '' && isset($_POST['rev_n'])) {
$articles->deleteRevision($uuid, (int)$_POST['rev_n']);
}
header('Location: /edit/' . rawurlencode($uuid) . '#historyPanel');
exit;
case 'delete_all_revisions':
requireAuth();
if (!isAdmin()) {
http_response_code(403);
exit;
}
if ($uuid !== '') {
$articles->deleteAllRevisions($uuid);
}
header('Location: /edit/' . rawurlencode($uuid));
exit;
case 'categories':
header('Location: /admin/categories');
exit;
case 'create_tag_type':
requireAuth();
if (!isAdmin()) {
http_response_code(403);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$typeKey = strtolower((string)preg_replace('/[^a-z0-9_]/i', '', trim($_POST['type_key'] ?? '')));
$typeLabel = trim($_POST['type_label'] ?? '');
if ($typeKey !== '' && $typeLabel !== '') {
$types = $articles->getTagTypes();
if (!isset($types[$typeKey])) {
$types[$typeKey] = $typeLabel;
$articles->saveTagTypes($types);
}
}
}
header('Location: /admin/categories');
exit;
case 'delete_tag_type':
requireAuth();
if (!isAdmin()) {
http_response_code(403);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$typeKey = trim($_POST['type_key'] ?? '');
if ($typeKey !== '') {
$types = $articles->getTagTypes();
unset($types[$typeKey]);
$articles->saveTagTypes($types);
}
}
header('Location: /admin/categories');
exit;
case 'rename_category':
requireAuth();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$old = trim($_POST['old'] ?? '');
$new = trim($_POST['new'] ?? '');
if ($old !== '' && $new !== '' && $old !== $new) {
$articles->renameCategory($old, $new);
}
}
header('Location: /admin/categories');
exit;
case 'delete_category':
requireAuth();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$cat = trim($_POST['category'] ?? '');
if ($cat !== '') {
$articles->deleteCategory($cat);
}
}
header('Location: /admin/categories');
exit;
case 'toggle_private_category':
requireAuth();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$cat = trim($_POST['category'] ?? '');
if ($cat !== '') {
$articles->togglePrivateCategory($cat);
}
}
header('Location: /admin/categories');
exit;
case 'author':
$authorSlug = trim($_GET['slug'] ?? '');
$authorRow = profileBySlug($authorSlug);
if (!$authorRow) {
http_response_code(404);
$content = '<div class="container py-5"><p class="text-muted">Profil introuvable.</p></div>';
$title = 'Profil introuvable';
include BASE_PATH . '/templates/layout.php';
break;
}
$privateCats = $articles->getPrivateCategories();
$authorArticles = array_values(array_filter(
$articles->getAll(publishedOnly: true),
static function (array $a) use ($authorRow, $privateCats): bool {
if (($a['author'] ?? '') !== $authorRow['email']) {
return false;
}
$cat = trim($a['category'] ?? '');
if ($cat !== '' && in_array($cat, $privateCats, true) && !isLoggedIn()) {
return false;
}
if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) {
return false;
}
return true;
}
));
include BASE_PATH . '/templates/author_profile.php';
break;
case 'author_articles':
$authorSlug = trim($_GET['slug'] ?? '');
$authorRow = profileBySlug($authorSlug);
if (!$authorRow) {
http_response_code(404);
$content = '<div class="container py-5"><p class="text-muted">Profil introuvable.</p></div>';
$title = 'Profil introuvable';
include BASE_PATH . '/templates/layout.php';
break;
}
$privateCats = $articles->getPrivateCategories();
$allCats = $articles->getCategories();
$authorArticles = array_values(array_filter(
$articles->getAll(publishedOnly: true),
static function (array $a) use ($authorRow, $privateCats): bool {
if (($a['author'] ?? '') !== $authorRow['email']) {
return false;
}
$cat = trim($a['category'] ?? '');
if ($cat !== '' && in_array($cat, $privateCats, true) && !isLoggedIn()) {
return false;
}
if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) {
return false;
}
return true;
}
));
$perPage = postsPerPage();
$cursor = trim($_GET['cursor'] ?? '');
$offset = 0;
if ($cursor !== '') {
foreach ($authorArticles as $i => $a) {
if ($a['uuid'] === $cursor) {
$offset = $i + 1;
break;
}
}
}
$posts = array_slice($authorArticles, $offset, $perPage);
$nextCursor = count($posts) === $perPage ? end($posts)['uuid'] : null;
$prevCursor = null;
if ($offset > 0) {
$prevOffset = max(0, $offset - $perPage);
$prevCursor = $prevOffset > 0 ? $authorArticles[$prevOffset - 1]['uuid'] : '';
}
include BASE_PATH . '/templates/author_articles.php';
break;
case 'liens':
$liensSlug = trim($_GET['slug'] ?? '');
$liensRow = profileBySlug($liensSlug);
if (!$liensRow) {
http_response_code(404);
$content = '<div class="container py-5"><p class="text-muted">Page introuvable.</p></div>';
$title = 'Page introuvable';
include BASE_PATH . '/templates/layout.php';
break;
}
$_lName = $liensRow['display_name'] ?? '';
$_lBio = $liensRow['bio'] ?? '';
$_lSlug = $liensRow['profile_slug'] ?? '';
$_lInitials = mb_strtoupper(mb_substr($_lName, 0, 1, 'UTF-8'), 'UTF-8');
$profileLinks = [];
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->prepare(
'SELECT id, url, title, description FROM profile_links
WHERE user_email = :e ORDER BY position, id'
);
$st->execute([':e' => $liensRow['email']]);
$profileLinks = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable) {
}
}
include BASE_PATH . '/templates/liens.php';
break;
case 'add_link':
requireAuth();
$linkUrl = filter_var(trim($_POST['link_url'] ?? ''), FILTER_VALIDATE_URL) ?: '';
$linkTitle = trim($_POST['link_title'] ?? '');
$linkDesc = trim($_POST['link_desc'] ?? '');
if ($linkUrl !== '') {
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->prepare(
'INSERT INTO profile_links (user_email, url, title, description, position)
VALUES (:e, :u, :t, :d,
COALESCE((SELECT MAX(position)+1 FROM profile_links WHERE user_email = :e), 0))'
);
$st->execute([':e' => currentUserEmail(), ':u' => $linkUrl, ':t' => $linkTitle, ':d' => $linkDesc]);
} catch (\Throwable) {
}
}
}
header('Location: /profile#links');
exit;
case 'delete_link':
requireAuth();
$linkId = (int)($_POST['link_id'] ?? 0);
if ($linkId > 0) {
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->prepare('DELETE FROM profile_links WHERE id = :id AND user_email = :e');
$st->execute([':id' => $linkId, ':e' => currentUserEmail()]);
} catch (\Throwable) {
}
}
}
header('Location: /profile#links');
exit;
case 'reorder_links':
requireAuth();
$order = $_POST['order'] ?? [];
if (is_array($order)) {
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->prepare('UPDATE profile_links SET position = :p WHERE id = :id AND user_email = :e');
foreach (array_values($order) as $pos => $id) {
$st->execute([':p' => $pos, ':id' => (int)$id, ':e' => currentUserEmail()]);
}
} catch (\Throwable) {
}
}
}
header('Location: /profile#links');
exit;
case 'flux':
require_once BASE_PATH . '/src/FeedFetcher.php';
$fetcher = new FeedFetcher(DATA_PATH . '/_cache/feeds');
$fluxItems = [];
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->query(
'SELECT f.user_email, f.feed_url, f.label,
p.display_name, p.profile_slug
FROM rss_feeds f
LEFT JOIN user_profiles p ON p.email = f.user_email
ORDER BY f.created_at'
);
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $_row) {
$data = $fetcher->get($_row['feed_url']);
if (!$data) {
continue;
}
$feedTitle = $_row['label'] !== '' ? $_row['label'] : $data['feed_title'];
$authorName = $_row['display_name'] ?? '';
$authorSlug = $_row['profile_slug'] ?? '';
foreach ($data['items'] as $_item) {
$fluxItems[] = array_merge($_item, [
'feed_title' => $feedTitle,
'feed_url' => $_row['feed_url'],
'author_name' => $authorName,
'author_slug' => $authorSlug,
]);
}
}
} catch (\Throwable) {
}
}
usort($fluxItems, static fn ($a, $b) => $b['date'] <=> $a['date']);
include BASE_PATH . '/templates/flux.php';
break;
case 'about':
['meta' => $siteMeta, 'html' => $siteContent] = loadSitePageData('about');
include BASE_PATH . '/templates/about.php';
break;
case 'legal':
['meta' => $siteMeta, 'html' => $siteContent] = loadSitePageData('legal');
include BASE_PATH . '/templates/legal.php';
break;
case 'contact':
include BASE_PATH . '/templates/contact.php';
break;
case 'licenses':
['meta' => $siteMeta, 'html' => $siteContent] = loadSitePageData('licenses');
include BASE_PATH . '/templates/licenses.php';
break;
case 'diff':
requireAuth();
$article = $articles->getByUuid($uuid);
if (!$article) {
http_response_code(404);
echo 'Article introuvable.';
exit;
}
$revisions = $article['revisions'] ?? [];
$revN = (int)($_GET['rev'] ?? 0);
// Trouver l'index dans le tableau par numéro de révision
$revIndex = null;
foreach ($revisions as $ri => $r) {
if ((int)($r['n'] ?? 0) === $revN) {
$revIndex = $ri;
break;
}
}
if ($revIndex === null || $revN < 1) {
header('Location: /edit/' . rawurlencode($uuid));
exit;
}
$oldContent = $articles->getRevisionContent($uuid, $revN);
if ($oldContent === null) {
http_response_code(404);
echo 'Révision introuvable.';
exit;
}
$diffLines = lineDiff($oldContent, $article['content']);
include BASE_PATH . '/templates/diff.php';
break;
case 'autosave':
requireAuth();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $uuid === '') {
echo json_encode(['ok' => false]);
exit;
}
$asContent = str_replace("\r\n", "\n", $_POST['content'] ?? '');
$asSlug = trim($_POST['slug'] ?? '');
$_asCurrent = $articles->getByUuid($uuid);
$asTitle = extractMarkdownTitle($asContent) ?: ($_asCurrent['title'] ?? 'Sans titre');
$ok = $articles->autosave($uuid, $asTitle, $asContent, $asSlug);
$_asSlugFinal = $ok ? ($articles->getByUuid($uuid)['slug'] ?? '') : '';
echo json_encode(['ok' => $ok, 'time' => date('H:i:s'), 'title' => $asTitle, 'slug' => $_asSlugFinal]);
exit;
case 'autosave_draft':
requireAuth();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $uuid === '') {
echo json_encode(['ok' => false]);
exit;
}
$_adArticle = $articles->getByUuid($uuid);
if (!$_adArticle || !canDoOnArticle('edit_articles', $_adArticle)) {
echo json_encode(['ok' => false]);
exit;
}
$_adContent = isset($_POST['content']) ? str_replace("\r\n", "\n", $_POST['content']) : null;
$_adTitle = $_adContent !== null
? (extractMarkdownTitle($_adContent) ?: ($_adArticle['title'] ?? 'Sans titre'))
: ($_adArticle['title'] ?? 'Sans titre');
$articles->saveDraftOverlay($uuid, ['title' => $_adTitle], $_adContent);
echo json_encode(['ok' => true, 'time' => date('H:i:s'), 'title' => $_adTitle]);
exit;
case 'edit_discard_draft':
requireAuth();
$_ddArticle = $articles->getByUuid($uuid);
if (!$_ddArticle || !canDoOnArticle('edit_articles', $_ddArticle)) {
http_response_code(403);
echo 'Accès refusé.';
exit;
}
$articles->discardDraftOverlay($uuid);
header('Location: /post/' . rawurlencode($_ddArticle['slug'] ?? $uuid));
exit;
case 'copy_file':
requireAuth();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false]);
exit;
}
$cfFrom = trim($_POST['from_uuid'] ?? '');
$cfTo = $uuid !== '' ? $uuid : trim($_POST['to_uuid'] ?? '');
$cfName = basename($_POST['name'] ?? '');
if (!preg_match('/^[0-9a-f-]{36}$/', $cfFrom)
|| !preg_match('/^[0-9a-f-]{36}$/', $cfTo)
|| $cfName === ''
|| str_starts_with($cfName, '.')) {
echo json_encode(['ok' => false, 'error' => 'Paramètres invalides']);
exit;
}
$cfSrc = DATA_PATH . '/' . $cfFrom . '/files/' . $cfName;
$cfDstDir = DATA_PATH . '/' . $cfTo . '/files';
$cfDst = $cfDstDir . '/' . $cfName;
if (!file_exists($cfSrc)) {
echo json_encode(['ok' => false, 'error' => 'Fichier source introuvable']);
exit;
}
if (!is_dir($cfDstDir)) {
mkdir($cfDstDir, 0775, true);
}
echo json_encode(['ok' => copy($cfSrc, $cfDst)]);
exit;
case 'add_files':
requireAuth();
$addFilesArticle = $articles->getByUuid($uuid);
if (!$addFilesArticle) {
http_response_code(404);
echo 'Article introuvable.';
exit;
}
$addFilesError = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Quand post_max_size est dépassé, PHP vide $_FILES et $_POST silencieusement
if ((int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0 && empty($_FILES)) {
$addFilesError = sprintf('Fichier trop lourd — limite serveur : %s.', ini_get('post_max_size'));
} else {
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],
]);
}
}
header('Location: /edit/' . rawurlencode($uuid));
exit;
}
}
include BASE_PATH . '/templates/add_files.php';
break;
case 'import_image':
requireAuth();
$importArticle = $articles->getByUuid($uuid);
if (!$importArticle) {
http_response_code(404);
echo 'Article introuvable.';
exit;
}
$importError = $_GET['error'] ?? '';
include BASE_PATH . '/templates/import_image.php';
break;
case 'fetch_file_meta':
requireAuth();
header('Content-Type: application/json');
echo json_encode(fetchUrlMeta(trim($_GET['url'] ?? '')));
exit;
case 'import_image_step2':
requireAuth();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /import/' . rawurlencode($uuid));
exit;
}
$step2Article = $articles->getByUuid($uuid);
if (!$step2Article) {
http_response_code(404);
echo 'Article introuvable.';
exit;
}
$step2Url = trim($_POST['image_url'] ?? '');
if (!filter_var($step2Url, FILTER_VALIDATE_URL) || !preg_match('#^https?://#i', $step2Url)) {
header('Location: /import/' . rawurlencode($uuid) . '?error=1');
exit;
}
// Détection URL interne (même hostname que APP_URL → lecture directe sans cURL)
$step2Meta = null;
$step2IsInternal = false;
$_iHost = parse_url(APP_URL, PHP_URL_HOST) ?? '';
$_uHost = parse_url($step2Url, PHP_URL_HOST) ?? '';
$_uPath = parse_url($step2Url, PHP_URL_PATH) ?? '';
if ($_iHost !== '' && $_uHost === $_iHost && preg_match('#^/post/([a-z0-9][a-z0-9-]*)/?$#', $_uPath, $_sm)) {
$_ia = $articles->getBySlug($_sm[1]);
if ($_ia) {
$step2IsInternal = true;
$step2Meta = ['ok' => true, 'title' => $_ia['title'] ?? '', 'mime' => 'text/html'];
if (!empty($_ia['seo_description'])) {
$step2Meta['description'] = $_ia['seo_description'];
} elseif (!empty($_ia['content'])) {
require_once BASE_PATH . '/src/Parsedown.php';
$_plain = strip_tags((new Parsedown())->text($_ia['content']));
$step2Meta['description'] = mb_strimwidth(trim(preg_replace('/\s+/', ' ', $_plain)), 0, 155, '…');
unset($_plain);
}
if (!empty($_ia['cover'])) {
$step2Meta['og_image'] = '/file?uuid=' . rawurlencode($_ia['uuid']) . '&name=' . rawurlencode($_ia['cover']);
}
}
unset($_ia);
}
unset($_iHost, $_uHost, $_uPath, $_sm);
if ($step2Meta === null) {
$step2Meta = fetchUrlMeta($step2Url);
}
if (!($step2Meta['ok'] ?? false)) {
header('Location: /import/' . rawurlencode($uuid) . '?error=1');
exit;
}
// Capture d'écran pour prévisualisation (pages HTML uniquement, URL externes uniquement)
$step2Screenshot = null;
if (!$step2IsInternal && str_starts_with($step2Meta['mime'] ?? '', 'text/html')) {
$filesDir = DATA_PATH . '/' . $uuid . '/files';
if (!is_dir($filesDir)) {
mkdir($filesDir, 0755, true);
}
$previewPath = $filesDir . '/_preview.png';
@unlink($previewPath); // supprime le résidu d'une analyse précédente
if (takeScreenshot($step2Url, $previewPath)) {
$step2Screenshot = '_preview.png';
}
}
include BASE_PATH . '/templates/import_image_step2.php';
break;
case 'copyright_ack':
requireAuth();
$ackArticle = $articles->getByUuid($uuid);
if (!$ackArticle) {
http_response_code(404);
echo 'Article introuvable.';
exit;
}
$ackUrl = filter_var($_GET['image_url'] ?? '', FILTER_VALIDATE_URL)
? $_GET['image_url'] : '';
if ($ackUrl === '') {
header('Location: /import/' . rawurlencode($uuid));
exit;
}
$ackTitle = $_GET['img_title'] ?? '';
$ackAuthor = $_GET['img_author'] ?? '';
$ackSource = $_GET['img_source'] ?? '';
$ackIsCover = !empty($_GET['is_cover']);
$ackMetaJson = $_GET['meta_json'] ?? '';
include BASE_PATH . '/templates/copyright_ack.php';
break;
case 'add_file_from_url':
requireAuth();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /');
exit;
}
$urlUuid = $_GET['uuid'] ?? $_POST['uuid'] ?? '';
$imageUrl = trim($_POST['image_url'] ?? '');
$mode = $_POST['mode'] ?? 'link';
$isCover = isset($_POST['is_cover']);
$imgTitle = trim($_POST['img_title'] ?? '');
$imgAuthor = trim($_POST['img_author'] ?? '');
$imgSource = trim($_POST['img_source'] ?? '') ?: $imageUrl;
// Métadonnées supplémentaires (passées depuis l'étape 2)
$importedMeta = [];
$rawMetaJson = $_POST['meta_json'] ?? '';
if ($rawMetaJson !== '') {
$dec = @json_decode($rawMetaJson, true);
if (is_array($dec)) {
foreach ($dec as $k => $v) {
if (is_string($k) && strlen($k) <= 60 && is_scalar($v)) {
$importedMeta[$k] = $v;
}
}
}
}
$urlArticle = $articles->getByUuid($urlUuid);
if (!$urlArticle || $imageUrl === '' || !filter_var($imageUrl, FILTER_VALIDATE_URL)) {
header('Location: /import/' . rawurlencode($urlUuid));
exit;
}
$screenshotFile = basename(trim($_POST['screenshot_file'] ?? ''));
if ($mode === 'screenshot') {
if ($screenshotFile === '' || $screenshotFile !== '_preview.png') {
header('Location: /import/' . rawurlencode($urlUuid) . '?error=1');
exit;
}
$filesDir = DATA_PATH . '/' . $urlUuid . '/files';
$previewPath = $filesDir . '/' . $screenshotFile;
if (!file_exists($previewPath)) {
header('Location: /import/' . rawurlencode($urlUuid) . '?error=1');
exit;
}
$hash = substr(hash_file('sha256', $previewPath), 0, 16);
$size = filesize($previewPath);
$destName = $hash . '-' . $size . '.png';
rename($previewPath, $filesDir . '/' . $destName);
$articles->addFileMeta($urlUuid, $destName, $imgAuthor, $imgSource, $imgTitle, $importedMeta);
if ($isCover) {
$articles->setCover($urlUuid, $destName);
}
header('Location: /edit/' . rawurlencode($urlUuid));
exit;
}
if ($mode === 'link') {
$filesDir = DATA_PATH . '/' . $urlUuid . '/files';
if (!is_dir($filesDir)) {
mkdir($filesDir, 0755, true);
}
$linkMime = $importedMeta['mime'] ?? '';
$isHtmlLink = str_starts_with($linkMime, 'text/html') || $linkMime === '';
if ($isHtmlLink) {
$thumbSet = false;
// 1. Télécharge l'og:image distante
$extOg = $importedMeta['og_image'] ?? '';
if (!$thumbSet && $extOg !== '' && !str_starts_with($extOg, '/')) {
$thumbName = downloadImageToThumb($extOg, $filesDir);
if ($thumbName !== null) {
$importedMeta['og_image'] = '/file?uuid=' . rawurlencode($urlUuid) . '&name=' . rawurlencode($thumbName);
$thumbSet = true;
}
}
// 2. Plus grande image trouvée sur la page
if (!$thumbSet) {
$bigImg = findLargestPageImage($imageUrl);
if ($bigImg !== null) {
$thumbName = downloadImageToThumb($bigImg, $filesDir);
if ($thumbName !== null) {
$importedMeta['og_image'] = '/file?uuid=' . rawurlencode($urlUuid) . '&name=' . rawurlencode($thumbName);
$thumbSet = true;
}
}
}
// 3. Screenshot pré-généré depuis step2
if (!$thumbSet && $screenshotFile !== '' && file_exists($filesDir . '/' . $screenshotFile)) {
$previewPath = $filesDir . '/' . $screenshotFile;
$hash = substr(hash_file('sha256', $previewPath), 0, 16);
$size = filesize($previewPath);
$thumbName = '_thumb_' . $hash . '-' . $size . '.png';
rename($previewPath, $filesDir . '/' . $thumbName);
$importedMeta['og_image'] = '/file?uuid=' . rawurlencode($urlUuid) . '&name=' . rawurlencode($thumbName);
$thumbSet = true;
}
// 4. Screenshot à la volée en dernier recours
if (!$thumbSet) {
$screenshotTmp = tempnam(sys_get_temp_dir(), 'vl_ss_') . '.png';
if (takeScreenshot($imageUrl, $screenshotTmp)) {
$hash = substr(hash_file('sha256', $screenshotTmp), 0, 16);
$size = filesize($screenshotTmp);
$thumbName = '_thumb_' . $hash . '-' . $size . '.png';
rename($screenshotTmp, $filesDir . '/' . $thumbName);
$importedMeta['og_image'] = '/file?uuid=' . rawurlencode($urlUuid) . '&name=' . rawurlencode($thumbName);
} else {
@unlink($screenshotTmp);
}
}
// Supprime le preview inutilisé si toujours présent
if ($screenshotFile !== '' && file_exists($filesDir . '/' . $screenshotFile)) {
@unlink($filesDir . '/' . $screenshotFile);
}
} elseif ($screenshotFile !== '') {
// Non-HTML : supprime le preview inutilisé
@unlink($filesDir . '/' . $screenshotFile);
}
$articles->addExternalLink($urlUuid, $imageUrl, $imgTitle, $imgAuthor, $importedMeta);
header('Location: /edit/' . rawurlencode($urlUuid));
exit;
}
// Mode téléchargement : accusé de réception obligatoire
if (empty($_POST['copyright_acked'])) {
header('Location: /?action=copyright_ack&' . http_build_query([
'uuid' => $urlUuid,
'image_url' => $imageUrl,
'img_title' => $imgTitle,
'img_author' => $imgAuthor,
'img_source' => trim($_POST['img_source'] ?? ''),
'is_cover' => $isCover ? '1' : '',
'meta_json' => $rawMetaJson,
]));
exit;
}
$imported = $articles->addFileFromUrl($urlUuid, $imageUrl, $isCover, $imgAuthor, $imgSource, $imgTitle, $importedMeta);
if ($imported) {
header('Location: /edit/' . rawurlencode($urlUuid));
} else {
header('Location: /import/' . rawurlencode($urlUuid) . '?error=1&mode=download');
}
exit;
case 'sources':
$article = $articles->getByUuid($uuid);
if (!$article) {
http_response_code(404);
echo 'Article introuvable.';
exit;
}
requireAuth();
if (!canDoOnArticle('view_sources', $article)) {
http_response_code(403);
echo 'Accès refusé.';
exit;
}
$sourcesFiles = $articles->getFiles($uuid);
include BASE_PATH . '/templates/sources.php';
break;
case 'regen_thumbs':
requireAuth();
// Page de confirmation si pas encore lancé
if (!isset($_GET['run'])) {
ob_start();
?>
<h1 class="h4 mb-4">Génération des aperçus de liens</h1>
<form method="get" action="/admin/regen-thumbs">
<input type="hidden" name="run" value="1">
<div class="card p-4 mb-4" style="max-width:480px">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="force" value="1" id="forceCheck">
<label class="form-check-label" for="forceCheck">
Refaire les captures déjà existantes
</label>
<div class="text-muted small mt-1">
Supprime et régénère les miniatures locales déjà enregistrées.
</div>
</div>
<button type="submit" class="btn btn-primary">Lancer</button>
</div>
</form>
<a href="/" class="btn btn-secondary btn-sm">← Retour</a>
<?php
$content = ob_get_clean();
$title = 'Génération des aperçus';
include BASE_PATH . '/templates/layout.php';
break;
}
$force = !empty($_GET['force']);
set_time_limit(600);
// Streaming : on affiche au fil de l'eau
ob_end_clean();
header('Content-Type: text/html; charset=UTF-8');
echo '<!doctype html><html><head><meta charset="utf-8">
<title>Génération des aperçus</title>
<link rel="stylesheet" href="/assets/css/app.css">
</head><body class="container py-4">';
$heading = $force ? 'Régénération de tous les aperçus' : 'Génération des aperçus manquants';
echo '<h1 class="h4 mb-4">' . $heading . '</h1><ul class="list-group mb-4">';
@ob_flush();
flush();
$done = $fail = $skip = 0;
foreach ($articles->getAll() as $article) {
$artUuid = $article['uuid'];
$filesDir = DATA_PATH . '/' . $artUuid . '/files';
foreach ($article['external_links'] ?? [] as $link) {
$lMeta = $link['meta'] ?? [];
$lMime = $lMeta['mime'] ?? 'text/html';
$lUrl = $link['url'] ?? '';
// Ignore si ce n'est pas du HTML
if ($lMime !== '' && !str_starts_with($lMime, 'text/html')) {
$skip++;
continue;
}
$hasLocal = !empty($lMeta['og_image']) && str_starts_with($lMeta['og_image'], '/');
if ($hasLocal && !$force) {
$skip++;
continue;
}
echo '<li class="list-group-item py-2 small">';
echo '<span class="text-muted me-2">' . htmlspecialchars($article['title']) . '</span>';
echo htmlspecialchars($lUrl) . ' … ';
@ob_flush();
flush();
// Supprime l'ancienne miniature locale si on force
if ($force && $hasLocal) {
$oldMeta = $lMeta['og_image'];
$oldName = rawurldecode(parse_url($oldMeta, PHP_URL_QUERY) ? (explode('name=', $oldMeta)[1] ?? '') : '');
// Extrait le paramètre name= de l'URL /file?uuid=...&name=...
parse_str(parse_url($oldMeta, PHP_URL_QUERY) ?? '', $oldQs);
$oldFile = $oldQs['name'] ?? '';
if ($oldFile !== '' && file_exists($filesDir . '/' . $oldFile)) {
@unlink($filesDir . '/' . $oldFile);
}
}
$thumbName = null;
$method = '';
// 1. og_image → téléchargement direct
// Non-force : utilise l'og_image stockée si externe
// Force : refetch la page pour récupérer l'URL d'origine
$ogToDownload = '';
if (!$force) {
$stored = $lMeta['og_image'] ?? '';
if ($stored !== '' && filter_var($stored, FILTER_VALIDATE_URL)) {
$ogToDownload = $stored;
}
} else {
$freshMeta = fetchUrlMeta($lUrl);
$ogToDownload = $freshMeta['og_image'] ?? '';
if (!filter_var($ogToDownload, FILTER_VALIDATE_URL)) {
$ogToDownload = '';
}
}
if ($ogToDownload !== '') {
$thumbName = downloadImageToThumb($ogToDownload, $filesDir);
if ($thumbName) {
$method = '✓ og:image';
}
}
// 2. Plus grande image de la page
if ($thumbName === null) {
$largestUrl = findLargestPageImage($lUrl);
if ($largestUrl) {
$thumbName = downloadImageToThumb($largestUrl, $filesDir);
if ($thumbName) {
$method = '✓ plus grande image';
}
}
}
// 3. Screenshot Chromium en dernier recours
if ($thumbName === null) {
$screenshotTmp = tempnam(sys_get_temp_dir(), 'vl_ss_') . '.png';
if (takeScreenshot($lUrl, $screenshotTmp)) {
if (!is_dir($filesDir)) {
mkdir($filesDir, 0755, true);
}
$hash = substr(hash_file('sha256', $screenshotTmp), 0, 16);
$size = filesize($screenshotTmp);
$thumbName = '_thumb_' . $hash . '-' . $size . '.png';
rename($screenshotTmp, $filesDir . '/' . $thumbName);
$method = '✓ screenshot';
} else {
@unlink($screenshotTmp);
}
}
if ($thumbName !== null) {
$ogUrl = '/file?uuid=' . rawurlencode($artUuid) . '&name=' . rawurlencode($thumbName);
$articles->updateExternalLinkMeta($artUuid, $lUrl, ['og_image' => $ogUrl]);
echo '<span class="text-success">' . $method . '</span>';
$done++;
} else {
echo '<span class="text-danger">✗ échec</span>';
$fail++;
}
echo '</li>';
@ob_flush();
flush();
}
}
echo '</ul>';
echo '<p class="fw-semibold">Terminé — ';
echo $done . ' capturé' . ($done > 1 ? 's' : '') . ', ';
echo $fail . ' échec' . ($fail > 1 ? 's' : '') . ', ';
echo $skip . ' ignoré' . ($skip > 1 ? 's' : '') . '.</p>';
echo '<a href="/admin/regen-thumbs" class="btn btn-secondary btn-sm">← Retour</a>';
echo '</body></html>';
exit;
case 'delete_external_link':
requireAuth();
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $uuid !== '') {
$linkUrl = $_POST['url'] ?? '';
if ($linkUrl !== '' && filter_var($linkUrl, FILTER_VALIDATE_URL)) {
$articles->removeExternalLink($uuid, $linkUrl);
}
}
header('Location: /edit/' . rawurlencode($uuid));
exit;
case 'react':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /');
exit;
}
$reactUuid = trim($_POST['uuid'] ?? '');
$reactType = trim($_POST['type'] ?? '');
$isAjax = ($_POST['_ajax'] ?? '') === '1';
// Cookie visiteur
if (empty($_COOKIE['vl_vid'])) {
$vid = bin2hex(random_bytes(16));
setcookie('vl_vid', $vid, [
'expires' => time() + 365 * 86400,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
} else {
$vid = $_COOKIE['vl_vid'];
}
$pdo = dbPdo();
if ($pdo && $reactUuid !== '') {
require_once BASE_PATH . '/src/ReactionManager.php';
$rm = new ReactionManager($pdo);
$added = $rm->toggle($reactUuid, $reactType, $vid);
$count = $rm->statsForArticle($reactUuid)[$reactType] ?? 0;
if ($isAjax) {
header('Content-Type: application/json');
echo json_encode(['ok' => true, 'active' => $added, 'count' => $count]);
exit;
}
}
$reactBack = $_POST['_back'] ?? '/';
header('Location: ' . $reactBack);
exit;
case 'comment':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /');
exit;
}
// Honeypot
if (($_POST['website'] ?? '') !== '') {
header('Location: /');
exit;
}
// CSRF (double-submit cookie — pas de session requise pour les visiteurs)
$csrfOk = isset($_POST['_token'], $_COOKIE['_csrf_c'])
&& hash_equals($_COOKIE['_csrf_c'], $_POST['_token']);
setcookie('_csrf_c', '', ['expires' => time() - 3600, 'path' => '/', 'samesite' => 'Strict', 'httponly' => true]);
if (!$csrfOk) {
header('Location: /');
exit;
}
$cmtUuid = trim($_POST['uuid'] ?? '');
$cmtName = trim($_POST['author_name'] ?? '');
$cmtEmail = trim($_POST['author_email'] ?? '');
$cmtContent = trim($_POST['content'] ?? '');
$cmtArticle = $cmtUuid !== '' ? $articles->getByUuid($cmtUuid) : null;
$cmtBack = $cmtArticle ? '/post/' . rawurlencode($cmtArticle['slug'] ?? $cmtUuid) : '/';
$pdo = dbPdo();
if (!$pdo || !$cmtArticle || $cmtName === '' || !filter_var($cmtEmail, FILTER_VALIDATE_EMAIL) || $cmtContent === '') {
header('Location: ' . $cmtBack . '#comment-form-card');
exit;
}
if (mb_strlen($cmtContent) > 2000) {
header('Location: ' . $cmtBack . '#comment-form-card');
exit;
}
require_once BASE_PATH . '/src/CommentManager.php';
require_once BASE_PATH . '/src/mailer.php';
$cm = new CommentManager($pdo);
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
['token' => $cmtToken, 'code' => $cmtCode] = $cm->submit($cmtUuid, $cmtName, $cmtEmail, $cmtContent, $ip, $ua);
$verifyUrl = rtrim(APP_URL, '/') . '/verify-comment/' . $cmtToken;
$subject = '[' . siteTitle() . '] Confirmez votre commentaire';
$html = '<!DOCTYPE html><html><body style="font-family:sans-serif;max-width:560px;margin:0 auto">'
. '<p>Bonjour ' . htmlspecialchars($cmtName) . ',</p>'
. '<p>Votre commentaire sur <em>' . htmlspecialchars($cmtArticle['title']) . '</em> a bien été reçu.</p>'
. '<p>Cliquez sur le lien ci-dessous, puis saisissez le code à 6 chiffres :</p>'
. '<p><a href="' . htmlspecialchars($verifyUrl) . '" style="display:inline-block;padding:10px 20px;background:#0d6efd;color:#fff;text-decoration:none;border-radius:4px">Confirmer mon commentaire</a></p>'
. '<p>Votre code : <strong style="font-family:monospace;font-size:1.25em;letter-spacing:0.1em">' . htmlspecialchars($cmtCode) . '</strong></p>'
. '<p style="color:#888;font-size:.875em">Ce lien et ce code expirent dans 24 heures. Si vous n\'êtes pas à l\'origine de ce message, ignorez-le.</p>'
. '</body></html>';
try {
envoyer_mail_smtp($cmtEmail, $subject, $html);
} catch (\RuntimeException) {
// Taux limité ou erreur SMTP : on continue sans planter le visiteur
}
header('Location: ' . $cmtBack . '?commented=1#comments');
exit;
case 'verify_comment':
$vcToken = trim($_GET['token'] ?? '');
$vcCode = trim($_POST['code'] ?? '');
$vcError = false;
$vcDeleted = false;
$vcAttemptsLeft = null;
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $vcToken)) {
header('Location: /');
exit;
}
if ($vcCode !== '' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$pdo = dbPdo();
if ($pdo && preg_match('/^[0-9]{6}$/', $vcCode)) {
require_once BASE_PATH . '/src/CommentManager.php';
$cm = new CommentManager($pdo);
$result = $cm->verify($vcToken, $vcCode);
if (is_string($result)) {
$vcArticle = $articles->getByUuid($result);
$vcSlug = $vcArticle ? ($vcArticle['slug'] ?? $result) : $result;
header('Location: /post/' . rawurlencode($vcSlug) . '?verified=1#comments');
exit;
}
if ($result === 0) {
$vcDeleted = true;
} else {
$vcError = true;
$vcAttemptsLeft = (int)$result;
}
} else {
$vcError = true;
$vcAttemptsLeft = null;
}
}
ob_start();
?>
<div class="container py-5" style="max-width:480px">
<h1 class="h3 mb-4">Confirmer mon commentaire</h1>
<?php if ($vcDeleted): ?>
<div class="alert alert-danger mb-3">
Trop de tentatives incorrectes. Votre commentaire a été annulé.
</div>
<a href="/" class="btn btn-primary">← Retour à l'accueil</a>
<?php else: ?>
<?php if ($vcError): ?>
<div class="alert alert-danger py-2 mb-3">
Code incorrect.
<?php if ($vcAttemptsLeft !== null): ?>
Il vous reste <?= (int)$vcAttemptsLeft ?> essai<?= $vcAttemptsLeft > 1 ? 's' : '' ?>.
<?php endif; ?>
</div>
<?php else: ?>
<p class="text-muted mb-3">Saisissez le code à 6 chiffres reçu par email.</p>
<?php endif; ?>
<form method="post" action="/verify-comment/<?= htmlspecialchars($vcToken) ?>">
<div class="mb-3">
<label for="vc-code" class="form-label fw-semibold">Code de confirmation</label>
<input type="text" id="vc-code" name="code"
class="form-control form-control-lg text-center font-monospace"
pattern="[0-9]{6}" maxlength="6" inputmode="numeric"
placeholder="123456" autocomplete="one-time-code" required autofocus>
</div>
<button type="submit" class="btn btn-primary w-100">Confirmer</button>
</form>
<?php endif; ?>
</div>
<?php
$content = ob_get_clean();
$title = ($vcDeleted ? 'Commentaire annulé' : 'Confirmer mon commentaire') . ' — ' . siteTitle();
include BASE_PATH . '/templates/layout.php';
break;
case 'comment_moderate':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$modId = (int)($_POST['id'] ?? 0);
$modPub = (int)($_POST['pub'] ?? 1);
$pdo = dbPdo();
if ($pdo && $modId > 0) {
require_once BASE_PATH . '/src/CommentManager.php';
(new CommentManager($pdo))->setPublished($modId, $modPub === 1);
}
header('Location: /admin/comments');
exit;
case 'comment_delete':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$delId = (int)($_POST['id'] ?? 0);
$pdo = dbPdo();
if ($pdo && $delId > 0) {
require_once BASE_PATH . '/src/CommentManager.php';
(new CommentManager($pdo))->delete($delId);
}
header('Location: /admin/comments');
exit;
case 'comment_resend':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$resendId = (int)($_POST['id'] ?? 0);
$pdo = dbPdo();
if ($pdo && $resendId > 0) {
require_once BASE_PATH . '/src/CommentManager.php';
require_once BASE_PATH . '/src/mailer.php';
$cm = new CommentManager($pdo);
$resendRow = $cm->getById($resendId);
if ($resendRow && !$resendRow['verified'] && !empty($resendRow['verification_code']) && !empty($resendRow['verify_token'])) {
$resendArticle = $articles->getByUuid((string)$resendRow['article_uuid']);
if ($resendArticle) {
$resendCode = (string)$resendRow['verification_code'];
$resendToken = (string)$resendRow['verify_token'];
$resendName = (string)$resendRow['author_name'];
$resendEmail = (string)$resendRow['author_email'];
$resendBack = '/post/' . rawurlencode($resendArticle['slug'] ?? $resendRow['article_uuid']);
$resendVerifyUrl = rtrim(APP_URL, '/') . '/verify-comment/' . $resendToken;
$resendSubject = '[' . siteTitle() . '] Confirmez votre commentaire';
$resendHtml = '<!DOCTYPE html><html><body style="font-family:sans-serif;max-width:560px;margin:0 auto">'
. '<p>Bonjour ' . htmlspecialchars($resendName) . ',</p>'
. '<p>Votre commentaire sur <em>' . htmlspecialchars($resendArticle['title']) . '</em> a bien été reçu.</p>'
. '<p>Cliquez sur le lien ci-dessous, puis saisissez le code à 6 chiffres :</p>'
. '<p><a href="' . htmlspecialchars($resendVerifyUrl) . '" style="display:inline-block;padding:10px 20px;background:#0d6efd;color:#fff;text-decoration:none;border-radius:4px">Confirmer mon commentaire</a></p>'
. '<p>Votre code : <strong style="font-family:monospace;font-size:1.25em;letter-spacing:0.1em">' . htmlspecialchars($resendCode) . '</strong></p>'
. '<p style="color:#888;font-size:.875em">Ce lien et ce code expirent dans 24 heures. Si vous n\'êtes pas à l\'origine de ce message, ignorez-le.</p>'
. '</body></html>';
try {
envoyer_mail_smtp($resendEmail, $resendSubject, $resendHtml, null, ['bypass_rate_limit' => true]);
} catch (\RuntimeException) {
// Erreur SMTP : on continue sans planter
}
}
}
}
header('Location: /admin/comments');
exit;
case 'rate':
requireAuth();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /');
exit;
}
$rateUuid = $_POST['uuid'] ?? '';
$rateValue = (int)($_POST['rating'] ?? 0);
$rateArticle = $articles->getByUuid($rateUuid);
if ($rateArticle && $rateValue >= 1 && $rateValue <= 5) {
$pdo = dbPdo();
if ($pdo) {
require_once BASE_PATH . '/src/RatingManager.php';
(new RatingManager($pdo))->rate($rateUuid, currentUserEmail() ?? '', $rateValue);
}
header('Location: /post/' . rawurlencode($rateArticle['slug'] ?? $rateUuid));
} else {
header('Location: /');
}
exit;
case 'admin':
requireAuth();
$tab = $_GET['tab'] ?? (isAdmin() ? 'dashboard' : 'articles');
$adminData = [];
$siteSettingsSaved = isset($_GET['saved']);
$siteSettingsError = ($_GET['error'] ?? '') === 'write';
if ($tab === 'dashboard') {
if (!isAdmin()) {
http_response_code(403);
exit;
}
$allArticles = $articles->getAll();
$now = time();
$adminData['total'] = count($allArticles);
$adminData['published'] = count(array_filter($allArticles, fn ($a) => $a['published'] && strtotime((string)($a['published_at'] ?? '')) <= $now));
$adminData['drafts'] = count(array_filter($allArticles, fn ($a) => !$a['published']));
$adminData['previews'] = count(array_filter($allArticles, fn ($a) => $a['published'] && strtotime((string)($a['published_at'] ?? '')) > $now));
$adminData['recent'] = array_slice(
usort($allArticles, fn ($a, $b) => strcmp($b['updated_at'] ?? '', $a['updated_at'] ?? '')) ? $allArticles : $allArticles,
0,
5
);
// Trier par updated_at desc
usort($allArticles, fn ($a, $b) => strcmp($b['updated_at'] ?? '', $a['updated_at'] ?? ''));
$adminData['recent'] = array_slice($allArticles, 0, 5);
}
if ($tab === 'articles') {
$allArticles = $articles->getAll();
if (!isAdmin()) {
$me = currentUserEmail() ?? '';
$allArticles = array_values(array_filter($allArticles, fn ($a) => ($a['author'] ?? '') === $me));
}
usort($allArticles, fn ($a, $b) => strcmp($b['updated_at'] ?? '', $a['updated_at'] ?? ''));
$adminData['filter_authors'] = array_values(array_unique(array_filter(array_column($allArticles, 'author'))));
$adminData['filter_categories'] = array_values(array_unique(array_filter(array_column($allArticles, 'category'))));
sort($adminData['filter_authors']);
sort($adminData['filter_categories']);
$filterAuthor = trim($_GET['filter_author'] ?? '');
$filterCategory = trim($_GET['filter_category'] ?? '');
$filterStatus = trim($_GET['filter_status'] ?? '');
$adminData['filter_author'] = $filterAuthor;
$adminData['filter_category'] = $filterCategory;
$adminData['filter_status'] = $filterStatus;
$nowTs = time();
if ($filterAuthor !== '') {
$allArticles = array_values(array_filter($allArticles, fn ($a) => ($a['author'] ?? '') === $filterAuthor));
}
if ($filterCategory !== '') {
$allArticles = array_values(array_filter($allArticles, fn ($a) => trim($a['category'] ?? '') === $filterCategory));
}
if ($filterStatus === 'published') {
$allArticles = array_values(array_filter($allArticles, fn ($a) => $a['published'] && strtotime((string)($a['published_at'] ?? '')) <= $nowTs));
} elseif ($filterStatus === 'draft') {
$allArticles = array_values(array_filter($allArticles, fn ($a) => !$a['published']));
} elseif ($filterStatus === 'preview') {
$allArticles = array_values(array_filter($allArticles, fn ($a) => $a['published'] && strtotime((string)($a['published_at'] ?? '')) > $nowTs));
}
$adminData['articles'] = $allArticles;
}
if ($tab === 'roles') {
if (!isAdmin()) {
http_response_code(403);
exit;
}
$pdo = dbPdo();
if ($pdo) {
$st = $pdo->query(
'SELECT r.id, r.name, r.label, COUNT(ur.user_email) AS user_count
FROM roles r
LEFT JOIN user_roles ur ON ur.role_id = r.id
GROUP BY r.id, r.name, r.label
ORDER BY r.name'
);
$roles = $st->fetchAll(PDO::FETCH_ASSOC);
try {
$capRows = $pdo->query('SELECT role_id, capability FROM role_capabilities')->fetchAll(PDO::FETCH_ASSOC);
$capsMap = [];
foreach ($capRows as $cr) {
$capsMap[(int)$cr['role_id']][] = $cr['capability'];
}
} catch (\Throwable) {
$capsMap = [];
}
foreach ($roles as &$r) {
$r['capabilities'] = $capsMap[(int)$r['id']] ?? [];
}
unset($r);
$adminData['roles'] = $roles;
} else {
$adminData['roles'] = [];
}
}
if ($tab === 'users') {
if (!isAdmin()) {
http_response_code(403);
exit;
}
$pdo = dbPdo();
if ($pdo) {
// users table may not exist yet — degrade gracefully
$usersFromDb = [];
try {
$st = $pdo->query('SELECT email, is_active FROM users ORDER BY email');
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
$v = $row['is_active'];
$usersFromDb[$row['email']] = is_bool($v) ? $v : in_array(strtolower((string)$v), ['t', '1', 'true', 'yes'], true);
}
} catch (\PDOException) {
// table absente, on continue avec la liste user_roles seulement
}
$st = $pdo->query('SELECT ur.user_email, r.name, r.label FROM user_roles ur JOIN roles r ON r.id = ur.role_id ORDER BY ur.user_email');
$rolesMap = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
$rolesMap[$row['user_email']][] = ['name' => $row['name'], 'label' => $row['label']];
}
$merged = [];
foreach (array_unique(array_merge(array_keys($usersFromDb), array_keys($rolesMap))) as $email) {
$merged[$email] = [
'email' => $email,
'is_active' => $usersFromDb[$email] ?? null,
'roles' => $rolesMap[$email] ?? [], // [['name'=>..., 'label'=>...], ...]
];
}
ksort($merged);
$adminData['users'] = array_values($merged);
$st = $pdo->query('SELECT id, name, label FROM roles ORDER BY name');
$adminData['roles'] = $st->fetchAll(PDO::FETCH_ASSOC);
} else {
$adminData['users'] = [];
$adminData['roles'] = [];
}
}
if ($tab === 'comments') {
if (!isAdmin()) {
http_response_code(403);
exit;
}
$pdo = dbPdo();
$cmtFilterStatus = trim($_GET['filter_status'] ?? '');
$adminData['cmt_filter_status'] = $cmtFilterStatus;
if ($pdo) {
require_once BASE_PATH . '/src/CommentManager.php';
$cm = new CommentManager($pdo);
$adminData['comments'] = $cm->allForAdmin($cmtFilterStatus);
$adminData['cmt_counts'] = $cm->countsByStatus();
// Enrichit avec le slug de chaque article pour les liens
$adminData['articleSlugs'] = [];
foreach ($adminData['comments'] as $cmtRow) {
$uuid = $cmtRow['article_uuid'];
if (!isset($adminData['articleSlugs'][$uuid])) {
$a = $articles->getByUuid($uuid);
$adminData['articleSlugs'][$uuid] = $a ? ($a['slug'] ?? null) : null;
}
}
} else {
$adminData['comments'] = [];
$adminData['articleSlugs'] = [];
$adminData['cmt_counts'] = ['all' => 0, 'pending' => 0, 'verified' => 0, 'hidden' => 0];
}
}
if ($tab === 'emails') {
if (!isAdmin()) {
http_response_code(403);
exit;
}
$pdo = dbPdo();
if ($pdo) {
$emlFilter = in_array($_GET['filter'] ?? '', ['sent', 'error', 'queued'], true) ? $_GET['filter'] : '';
$emlPage = max(0, (int)($_GET['page'] ?? 0));
$emlLimit = 50;
$emlOffset = $emlPage * $emlLimit;
$whereEml = $emlFilter !== '' ? 'WHERE status = ' . $pdo->quote($emlFilter) : '';
$row = $pdo->query("SELECT
COUNT(*) AS all,
COUNT(*) FILTER (WHERE status = 'sent') AS sent,
COUNT(*) FILTER (WHERE status = 'error') AS error,
COUNT(*) FILTER (WHERE status = 'queued') AS queued
FROM journal_smtp")->fetch(PDO::FETCH_ASSOC);
$adminData['eml_counts'] = [
'all' => (int)($row['all'] ?? 0),
'sent' => (int)($row['sent'] ?? 0),
'error' => (int)($row['error'] ?? 0),
'queued' => (int)($row['queued'] ?? 0),
];
$adminData['emails'] = $pdo->query(
"SELECT id, created_at, to_email, subject, status, error_message, content_text, sent_at
FROM journal_smtp $whereEml
ORDER BY created_at DESC
LIMIT $emlLimit OFFSET $emlOffset"
)->fetchAll(PDO::FETCH_ASSOC);
$adminData['eml_filter'] = $emlFilter;
$adminData['eml_page'] = $emlPage;
} else {
$adminData['emails'] = [];
$adminData['eml_counts'] = ['all' => 0, 'sent' => 0, 'error' => 0, 'queued' => 0];
$adminData['eml_filter'] = '';
$adminData['eml_page'] = 0;
}
}
if ($tab === 'smtp') {
if (!isAdmin()) {
http_response_code(403);
exit;
}
require_once BASE_PATH . '/src/SmtpSettings.php';
$adminData['smtp_config'] = [
'host' => smtpCfg('host', 'SMTP_HOST'),
'port' => smtpCfg('port', 'SMTP_PORT'),
'secure' => smtpCfg('secure', 'SMTP_SECURE'),
'user' => smtpCfg('user', 'SMTP_USER'),
'has_pass' => smtpCfg('pass', 'SMTP_PASS') !== '',
'from' => smtpCfg('from', 'SMTP_FROM'),
'from_name' => smtpCfg('from_name', 'SMTP_FROM_NAME'),
];
$adminData['smtp_test'] = $_SESSION['smtp_test_result'] ?? null;
unset($_SESSION['smtp_test_result']);
}
if ($tab === 'searches') {
if (!isAdmin()) {
http_response_code(403);
exit;
}
require_once BASE_PATH . '/src/SearchLogParser.php';
$parser = new SearchLogParser('/var/log/apache2', apacheAccessLog());
$adminData['search_terms'] = $parser->topTerms(100);
$adminData['search_log_readable'] = $parser->isReadable();
}
if ($tab === 'stats') {
if (!isAdmin()) {
http_response_code(403);
exit;
}
require_once BASE_PATH . '/src/AccessLogParser.php';
require_once BASE_PATH . '/src/AsnLookup.php';
$accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog());
$accessStats = $accessParser->stats();
$adminData['stats_readable'] = $accessParser->isReadable();
$adminData['stats_pages'] = array_slice($accessStats['pages'], 0, 30, true);
$adminData['stats_books'] = array_slice($accessStats['books'], 0, 20, true);
// Lookup AS pour les top 200 IPs
$topIps = array_slice($accessStats['ips'], 0, 200, true);
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
$asList = AsnLookup::aggregateByAs($topIps, $asnMap);
$adminData['stats_as'] = $asList;
$adminData['stats_as_groups'] = AsnLookup::applyGroups($asList, asGroups());
$adminData['as_groups'] = asGroups();
}
if ($tab === 'categories') {
$adminData['cats'] = $articles->getCategories();
$adminData['privateCats'] = $articles->getPrivateCategories();
$adminData['tagTypes'] = $articles->getTagTypes();
}
if ($tab === 'books') {
if (!isAdmin()) {
http_response_code(403);
exit;
}
$adminData['books'] = $books->getAll();
$adminData['edit_book'] = null;
$adminData['all_articles'] = $articles->getAll();
usort($adminData['all_articles'], static fn ($a, $b) => strcmp($a['title'] ?? '', $b['title'] ?? ''));
$editBookSlug = trim($_GET['edit'] ?? '');
if ($editBookSlug !== '') {
$adminData['edit_book'] = $books->getBySlug($editBookSlug);
}
}
include BASE_PATH . '/templates/admin.php';
break;
case 'admin_smtp_save':
requireAuth();
if (!isAdmin()) {
http_response_code(403);
exit;
}
require_once BASE_PATH . '/src/SmtpSettings.php';
$ok = saveSmtpSettings([
'host' => $_POST['smtp_host'] ?? '',
'port' => $_POST['smtp_port'] ?? '',
'secure' => $_POST['smtp_secure'] ?? '',
'user' => $_POST['smtp_user'] ?? '',
'pass' => $_POST['smtp_pass'] ?? '',
'from' => $_POST['smtp_from'] ?? '',
'from_name' => $_POST['smtp_from_name'] ?? '',
]);
header('Location: /admin/smtp?' . ($ok ? 'saved=1' : 'error=write'));
exit;
case 'admin_smtp_test':
requireAuth();
if (!isAdmin()) {
http_response_code(403);
exit;
}
require_once BASE_PATH . '/src/SmtpSettings.php';
require_once BASE_PATH . '/src/mailer.php';
$mode = in_array($_POST['mode'] ?? '', ['connect', 'send'], true) ? $_POST['mode'] : 'connect';
$testEmail = trim($_POST['test_email'] ?? '');
if ($testEmail !== '' && !filter_var($testEmail, FILTER_VALIDATE_EMAIL)) {
$testEmail = '';
}
$smtpLogs = [];
$smtpOk = false;
$smtpErrMsg = '';
try {
$mail = new \PHPMailer\PHPMailer\PHPMailer(true);
$mail->isSMTP();
$mail->Host = smtpCfg('host', 'SMTP_HOST', 'localhost');
$mail->Port = (int)smtpCfg('port', 'SMTP_PORT', '587');
$_stUser = smtpCfg('user', 'SMTP_USER');
$_stPass = smtpCfg('pass', 'SMTP_PASS');
$mail->SMTPAuth = ($_stUser !== '' || $_stPass !== '');
$mail->Username = $_stUser;
$mail->Password = $_stPass;
$smtpSecure = strtolower(smtpCfg('secure', 'SMTP_SECURE', 'tls'));
if ($smtpSecure === 'ssl') {
$mail->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS;
} elseif ($smtpSecure === 'tls') {
$mail->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
}
$mail->Timeout = 15;
$mail->SMTPOptions = ['ssl' => ['verify_peer' => true, 'verify_peer_name' => true, 'allow_self_signed' => false]];
$mail->SMTPDebug = \PHPMailer\PHPMailer\SMTP::DEBUG_SERVER;
$mail->Debugoutput = static function (string $str) use (&$smtpLogs): void {
$smtpLogs[] = rtrim($str);
};
if ($mode === 'send' && $testEmail !== '') {
$mail->CharSet = 'UTF-8';
$mail->isHTML(true);
$_smtpFrom = smtpCfg('from', 'SMTP_FROM', 'no-reply@varlog.a5l.fr');
$_smtpFromName = smtpCfg('from_name', 'SMTP_FROM_NAME', 'varlog');
$mail->setFrom($_smtpFrom, $_smtpFromName);
$mail->addAddress($testEmail);
$_siteName = siteTitle();
$_siteUrl = rtrim(APP_URL, '/');
$_sentAt = date('d/m/Y à H\hi', time());
$mail->Subject = 'Vérification de la configuration email — ' . $_siteName;
$mail->Body = '<!DOCTYPE html><html lang="fr"><head><meta charset="UTF-8">'
. '<meta name="viewport" content="width=device-width,initial-scale=1"></head>'
. '<body style="font-family:sans-serif;color:#1a1a1a;max-width:520px;margin:0 auto;padding:32px 16px">'
. '<p>Bonjour,</p>'
. '<p>Cet email confirme que la configuration SMTP de <strong>' . htmlspecialchars($_siteName) . '</strong> fonctionne correctement.</p>'
. '<p>Envoyé le ' . $_sentAt . ' depuis <a href="' . htmlspecialchars($_siteUrl) . '">' . htmlspecialchars($_siteUrl) . '</a>.</p>'
. '<hr style="border:none;border-top:1px solid #e5e7eb;margin:28px 0">'
. '<p style="color:#6b7280;font-size:0.82em">Vous recevez cet email car un administrateur a effectué un test de configuration depuis l\'interface d\'administration de ' . htmlspecialchars($_siteName) . '.'
. ' Si vous n\'attendiez pas cet email, vous pouvez l\'ignorer.</p>'
. '</body></html>';
$mail->AltBody = "Bonjour,\r\n\r\n"
. "Cet email confirme que la configuration SMTP de {$_siteName} fonctionne correctement.\r\n\r\n"
. "Envoyé le {$_sentAt} depuis {$_siteUrl}.\r\n\r\n"
. "--\r\n"
. "Vous recevez cet email car un administrateur a effectué un test de configuration depuis l'interface d'administration de {$_siteName}."
. " Si vous n'attendiez pas cet email, vous pouvez l'ignorer.";
$mail->send();
} else {
$mail->smtpConnect();
$mail->smtpClose();
}
$smtpOk = true;
} catch (\Exception $e) {
$smtpErrMsg = $e->getMessage();
}
$_SESSION['smtp_test_result'] = [
'success' => $smtpOk,
'error' => $smtpErrMsg,
'logs' => $smtpLogs,
'mode' => $mode,
'email' => $testEmail,
'ts' => date('d/m/Y H:i:s'),
];
header('Location: /admin/smtp');
exit;
case 'admin_bulk_delete':
requireAuth();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$uuids = $_POST['uuids'] ?? [];
if (is_array($uuids)) {
$me = currentUserEmail() ?? '';
foreach ($uuids as $uid) {
$uid = trim((string)$uid);
if ($uid === '') {
continue;
}
$art = $articles->getByUuid($uid);
if (!$art) {
continue;
}
if (isAdmin() || ($art['author'] ?? '') === $me) {
$articles->delete($uid);
}
}
}
}
header('Location: /admin/articles');
exit;
case 'admin_grant_role':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$targetEmail = strtolower(trim($_POST['email'] ?? ''));
$roleName = trim($_POST['role'] ?? '');
if ($targetEmail && $roleName && filter_var($targetEmail, FILTER_VALIDATE_EMAIL)) {
$pdo = dbPdo();
if ($pdo) {
$st = $pdo->prepare(
'INSERT INTO user_roles (user_email, role_id, granted_by)
SELECT :email, id, :by FROM roles WHERE name = :role
ON CONFLICT DO NOTHING'
);
$st->execute([':email' => $targetEmail, ':role' => $roleName, ':by' => currentUserEmail()]);
}
}
header('Location: /admin/users');
exit;
case 'admin_revoke_role':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$targetEmail = strtolower(trim($_POST['email'] ?? ''));
$roleName = trim($_POST['role'] ?? '');
if ($targetEmail && $roleName) {
$pdo = dbPdo();
if ($pdo) {
// Bloquer si c'est le dernier admin (en DB — hors ADMIN_EMAIL env)
if ($roleName === 'admin') {
$st = $pdo->prepare(
'SELECT COUNT(*) FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE r.name = :role AND ur.user_email != :email'
);
$st->execute([':role' => 'admin', ':email' => $targetEmail]);
if ((int)$st->fetchColumn() === 0) {
header('Location: /admin/users?error=last_admin');
exit;
}
}
$st = $pdo->prepare(
'DELETE FROM user_roles
WHERE user_email = :email
AND role_id = (SELECT id FROM roles WHERE name = :role)'
);
$st->execute([':email' => $targetEmail, ':role' => $roleName]);
}
}
header('Location: /admin/users');
exit;
case 'run_content_migrations':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$_cmDataDir = DATA_PATH;
$_cmTrack = $_cmDataDir . '/.content_migrations.json';
$_cmFlag = $_cmDataDir . '/.maintenance';
$_cmApplied = file_exists($_cmTrack) ? (json_decode((string) file_get_contents($_cmTrack), true) ?? []) : [];
$_cmFiles = glob(BASE_PATH . '/scripts/content/migration_*.php') ?: [];
sort($_cmFiles);
$_cmPending = array_values(array_filter($_cmFiles, fn ($f) => !isset($_cmApplied[basename($f)])));
if (empty($_cmPending)) {
header('Location: /admin?tab=dashboard&notice=no_migrations');
exit;
}
file_put_contents($_cmFlag, date('Y-m-d H:i:s'));
$_cmErrors = 0;
$dataDir = $_cmDataDir;
foreach ($_cmPending as $_cmFile) {
try {
require $_cmFile;
$_cmApplied[basename($_cmFile)] = date('Y-m-d H:i:s');
file_put_contents($_cmTrack, json_encode($_cmApplied, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n");
} catch (Throwable $_cmEx) {
$_cmErrors++;
break;
}
}
if (file_exists($_cmFlag)) {
unlink($_cmFlag);
}
header('Location: /admin?tab=dashboard&notice=' . ($_cmErrors ? 'migration_error' : 'migrated'));
exit;
case 'force_update_check':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$_updateChecker->clearCache();
header('Location: /admin?tab=dashboard');
exit;
case 'admin_save_folio_config':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$ok = saveSiteSettings([
'folio_repo_url' => $_POST['folio_repo_url'] ?? '',
'folio_update_branch' => $_POST['folio_update_branch'] ?? '',
]);
$_updateChecker->clearCache();
header('Location: /admin?tab=dashboard&notice=' . ($ok ? 'folio_saved' : 'folio_error'));
exit;
case 'admin_save_site':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$ok = saveSiteSettings([
'site_title' => $_POST['site_title'] ?? '',
'site_claim' => $_POST['site_claim'] ?? '',
'site_lang' => $_POST['site_lang'] ?? '',
'posts_per_page' => $_POST['posts_per_page'] ?? '',
'site_license_label' => $_POST['site_license_label'] ?? '',
'site_license_url' => $_POST['site_license_url'] ?? '',
]);
header('Location: /admin/site?' . ($ok ? 'saved=1' : 'error=write'));
exit;
case 'admin_save_searches_config':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$ok = saveSiteSettings(['apache_access_log' => $_POST['apache_access_log'] ?? '']);
header('Location: /admin/searches?' . ($ok ? 'saved=1' : 'error=write'));
exit;
case 'admin_save_as_groups':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$rawLabels = $_POST['as_group_label'] ?? [];
$rawPatterns = $_POST['as_group_patterns'] ?? [];
$groups = [];
foreach ((array) $rawLabels as $i => $label) {
$label = trim((string) $label);
if ($label === '') {
continue;
}
$patterns = array_values(array_filter(array_map(
'trim',
explode("\n", (string) ($rawPatterns[$i] ?? ''))
)));
$groups[] = ['label' => $label, 'patterns' => $patterns];
}
$ok = saveSiteSettings(['as_groups' => $groups]);
header('Location: /admin/stats?' . ($ok ? 'saved=1' : 'error=write'));
exit;
case 'admin_create_role':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$roleLabel = trim($_POST['label'] ?? '');
$roleName = preg_replace('/[^a-z0-9_-]/', '', strtolower(trim($_POST['name'] ?? '')));
if ($roleName === '' && $roleLabel !== '') {
$roleName = slugify($roleLabel);
}
if ($roleName && $roleLabel) {
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->prepare('INSERT INTO roles (name, label) VALUES (:n, :l) ON CONFLICT (name) DO NOTHING');
$st->execute([':n' => $roleName, ':l' => $roleLabel]);
} catch (\PDOException) {
}
}
}
header('Location: /admin/roles');
exit;
case 'admin_update_role':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$roleId = (int)($_POST['id'] ?? 0);
$roleLabel = trim($_POST['label'] ?? '');
if ($roleId > 0 && $roleLabel) {
$pdo = dbPdo();
if ($pdo) {
$st = $pdo->prepare('UPDATE roles SET label = :l WHERE id = :id');
$st->execute([':l' => $roleLabel, ':id' => $roleId]);
}
}
header('Location: /admin/roles');
exit;
case 'admin_delete_role':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$roleId = (int)($_POST['id'] ?? 0);
if ($roleId > 0) {
$pdo = dbPdo();
if ($pdo) {
$st = $pdo->prepare('DELETE FROM roles WHERE id = :id');
$st->execute([':id' => $roleId]);
}
}
header('Location: /admin/roles');
exit;
case 'admin_update_role_caps':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$roleId = (int)($_POST['role_id'] ?? 0);
$newCaps = array_filter((array)($_POST['caps'] ?? []), fn ($c) => array_key_exists($c, KNOWN_CAPABILITIES));
if ($roleId > 0) {
$pdo = dbPdo();
if ($pdo) {
$pdo->prepare('DELETE FROM role_capabilities WHERE role_id = :id')->execute([':id' => $roleId]);
$ins = $pdo->prepare('INSERT INTO role_capabilities (role_id, capability) VALUES (:id, :cap)');
foreach ($newCaps as $cap) {
$ins->execute([':id' => $roleId, ':cap' => $cap]);
}
// Invalide le cache de capacités en session (affecte l'utilisateur courant)
unset($_SESSION['user_capabilities']);
}
}
header('Location: /admin/roles');
exit;
case 'admin_role_edit':
requireAuth();
if (!isAdmin()) {
http_response_code(403);
exit;
}
$editRoleName = preg_replace('/[^a-z0-9_-]/', '', strtolower(trim($_GET['role_name'] ?? '')));
if (!$editRoleName) {
header('Location: /admin/roles');
exit;
}
$pdo = dbPdo();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($pdo) {
$newLabel = trim($_POST['label'] ?? '');
$newCaps = array_filter(
(array)($_POST['caps'] ?? []),
fn ($c) => array_key_exists($c, KNOWN_CAPABILITIES)
);
if ($newLabel) {
$pdo->prepare('UPDATE roles SET label = :l WHERE name = :n')
->execute([':l' => $newLabel, ':n' => $editRoleName]);
}
$st = $pdo->prepare('SELECT id FROM roles WHERE name = :n');
$st->execute([':n' => $editRoleName]);
$editRoleId = $st->fetchColumn();
if ($editRoleId) {
$pdo->prepare('DELETE FROM role_capabilities WHERE role_id = :id')
->execute([':id' => $editRoleId]);
$ins = $pdo->prepare('INSERT INTO role_capabilities (role_id, capability) VALUES (:id, :cap)');
foreach ($newCaps as $cap) {
$ins->execute([':id' => $editRoleId, ':cap' => $cap]);
}
}
unset($_SESSION['user_capabilities']);
}
header('Location: /admin/roles');
exit;
}
// GET — charge le rôle et ses capacités
$editRole = null;
$editRoleCaps = [];
if ($pdo) {
try {
$st = $pdo->prepare('SELECT id, name, label FROM roles WHERE name = :n');
$st->execute([':n' => $editRoleName]);
$editRole = $st->fetch(PDO::FETCH_ASSOC) ?: null;
} catch (\Throwable) {
}
if ($editRole) {
try {
$st = $pdo->prepare('SELECT capability FROM role_capabilities WHERE role_id = :id');
$st->execute([':id' => $editRole['id']]);
$editRoleCaps = $st->fetchAll(PDO::FETCH_COLUMN) ?: [];
} catch (\Throwable) {
}
}
}
if (!$editRole) {
header('Location: /admin/roles');
exit;
}
include BASE_PATH . '/templates/admin_role_edit.php';
exit;
case 'profile':
requireAuth();
$profileError = '';
$profileSuccess = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$newName = trim($_POST['display_name'] ?? '');
$newUrl = trim($_POST['profile_url'] ?? '');
if ($newUrl !== '' && !filter_var($newUrl, FILTER_VALIDATE_URL)) {
$newUrl = '';
}
$newBio = trim($_POST['bio'] ?? '');
if ($newName === '') {
$profileError = 'Le nom ne peut pas être vide.';
} else {
$pdo = dbPdo();
if ($pdo) {
try {
$newSlug = slugify($newName);
$st = $pdo->prepare(
'INSERT INTO user_profiles (email, display_name, profile_url, profile_slug, bio, updated_at)
VALUES (:e, :n, :u, :s, :b, now())
ON CONFLICT (email) DO UPDATE SET display_name = :n, profile_url = :u, profile_slug = :s, bio = :b, updated_at = now()'
);
$st->execute([':e' => currentUserEmail(), ':n' => $newName, ':u' => $newUrl, ':s' => $newSlug, ':b' => $newBio]);
$_SESSION['user_display_name'] = $newName;
$profileSuccess = true;
} catch (\Throwable $ex) {
$profileError = 'Erreur lors de la sauvegarde.';
}
}
}
}
$profileCurrentName = currentUserName();
$_profileData = authorProfile(currentUserEmail() ?? '');
$profileCurrentUrl = $_profileData['url'];
$profileCurrentBio = $_profileData['bio'];
$profileCurrentSlug = $_profileData['slug'];
// Pré-remplir l'URL avec l'URL de profil public si vide
if ($profileCurrentUrl === '' && $profileCurrentSlug !== '') {
$profileCurrentUrl = rtrim(APP_URL, '/') . '/profil/' . rawurlencode($profileCurrentSlug);
}
// Liens de la page "Mes liens"
$profileLinks = [];
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->prepare('SELECT id, url, title, description FROM profile_links WHERE user_email = :e ORDER BY position, id');
$st->execute([':e' => currentUserEmail()]);
$profileLinks = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable) {
}
}
// Feeds RSS de l'utilisateur
$profileFeeds = [];
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->prepare('SELECT id, feed_url, label FROM rss_feeds WHERE user_email = :e ORDER BY created_at');
$st->execute([':e' => currentUserEmail()]);
$profileFeeds = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable) {
}
}
include BASE_PATH . '/templates/profile.php';
break;
case 'add_feed':
requireAuth();
$feedUrl = filter_var(trim($_POST['feed_url'] ?? ''), FILTER_VALIDATE_URL) ?: '';
$feedLabel = trim($_POST['feed_label'] ?? '');
if ($feedUrl !== '') {
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->prepare(
'INSERT INTO rss_feeds (user_email, feed_url, label) VALUES (:e, :u, :l)
ON CONFLICT (user_email, feed_url) DO UPDATE SET label = :l'
);
$st->execute([':e' => currentUserEmail(), ':u' => $feedUrl, ':l' => $feedLabel]);
} catch (\Throwable) {
}
}
}
header('Location: /profile#feeds');
exit;
case 'delete_feed':
requireAuth();
$feedId = (int)($_POST['feed_id'] ?? 0);
if ($feedId > 0) {
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->prepare('DELETE FROM rss_feeds WHERE id = :id AND user_email = :e');
$st->execute([':id' => $feedId, ':e' => currentUserEmail()]);
} catch (\Throwable) {
}
}
}
header('Location: /profile#feeds');
exit;
case 'search_files':
requireAuth();
header('Content-Type: application/json');
$q = trim($_GET['q'] ?? '');
$sfExclude = trim($_GET['exclude'] ?? '');
if ($q === '') {
echo json_encode([]);
exit;
}
require_once BASE_PATH . '/src/SearchEngine.php';
$sfPool = $articles->getSearchIndex() ?? $articles->getAll();
$sfResults = (new SearchEngine())->search($q, $sfPool);
$sfOut = [];
foreach ($sfResults as $r) {
$a = $r['article'];
$aId = $a['uuid'] ?? '';
if ($aId === '' || $aId === $sfExclude) {
continue;
}
$aFiles = $articles->getFiles($aId);
if (empty($aFiles)) {
continue;
}
$sfFiles = [];
foreach ($aFiles as $f) {
if (str_starts_with($f['name'], '_thumb_')) {
continue;
}
$sfFiles[] = [
'url' => '/file?uuid=' . rawurlencode($aId) . '&name=' . rawurlencode($f['name']),
'name' => $f['name'],
'mime' => $f['mime'],
'is_image' => $f['is_image'],
'size' => $f['size'],
];
}
if (empty($sfFiles)) {
continue;
}
$sfOut[] = [
'article' => ['uuid' => $aId, 'title' => $a['title'] ?? '', 'slug' => $a['slug'] ?? ''],
'files' => $sfFiles,
];
if (count($sfOut) >= 20) {
break;
}
}
echo json_encode($sfOut);
exit;
case 'search':
require_once BASE_PATH . '/src/SearchEngine.php';
$searchQuery = trim($_GET['q'] ?? '');
$searchResults = [];
if ($searchQuery !== '') {
$isAnonSearch = !isLoggedIn();
// Lecture du cache pour les visiteurs anonymes
if ($isAnonSearch) {
$searchResults = $articles->getSearchCache($searchQuery) ?? [];
}
if (empty($searchResults)) {
$privateCats = $articles->getPrivateCategories();
$rawPool = $articles->getSearchIndex() ?? $articles->getAll(true);
$searchPool = array_values(array_filter($rawPool, static function (array $a) use ($privateCats): bool {
if (!($a['published'] ?? false)) {
return false;
}
$cat = trim($a['category'] ?? '');
if ($cat !== '' && in_array($cat, $privateCats, true) && !isLoggedIn()) {
return false;
}
if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) {
return false;
}
return true;
}));
$searchResults = (new SearchEngine())->search($searchQuery, $searchPool);
if ($isAnonSearch && !empty($searchResults)) {
$articles->setSearchCache($searchQuery, $searchResults);
}
}
}
include BASE_PATH . '/templates/search.php';
break;
case 'book':
$bookSlug = trim($_GET['book_slug'] ?? '');
$book = $books->getBySlug($bookSlug);
if (!$book) {
http_response_code(404);
ob_start();
?>
<div class="container py-5 text-center">
<h1 class="h2 mb-3">Livre introuvable</h1>
<p class="text-muted mb-4">Ce livre n'existe pas ou a été supprimé.</p>
<a href="/" class="btn btn-primary">← Retour à l'accueil</a>
</div>
<?php
$content = ob_get_clean();
$title = '404 — ' . siteTitle();
$metaRobots = 'noindex, nofollow';
include BASE_PATH . '/templates/layout.php';
break;
}
$bookArticles = [];
foreach ($book['articles'] ?? [] as $aSlug) {
$a = $articles->getBySlug($aSlug);
if (!$a) {
continue;
}
if (!$a['published'] && !canDoOnArticle('view_drafts', $a)) {
continue;
}
$bookArticles[] = $a;
}
$allCats = $articles->getCategories();
include BASE_PATH . '/templates/book.php';
break;
case 'book_save':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$bSlug = trim($_POST['slug'] ?? '');
$bTitle = trim($_POST['title'] ?? '');
$bDesc = trim($_POST['description'] ?? '');
$bArts = array_values(array_filter(array_map('trim', preg_split('/[\r\n]+/', $_POST['articles'] ?? ''))));
if ($bSlug !== '' && $bTitle !== '') {
$books->save(['slug' => $bSlug, 'title' => $bTitle, 'description' => $bDesc, 'articles' => $bArts]);
}
header('Location: /admin/books?saved=1&edit=' . rawurlencode($bSlug));
exit;
case 'book_delete':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$bSlug = trim($_POST['slug'] ?? '');
if ($bSlug !== '') {
$books->delete($bSlug);
}
header('Location: /admin/books?deleted=1');
exit;
case 'not_found':
$notFoundPath = trim(
(string)(parse_url($_SERVER['REDIRECT_URL'] ?? $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? ''),
'/'
);
if ($notFoundPath !== '') {
searchAndRedirect(basename($notFoundPath), $articles);
}
http_response_code(404);
ob_start();
?>
<div class="container py-5 text-center">
<h1 class="h2 mb-3">Page introuvable</h1>
<p class="text-muted mb-4">Cette adresse ne correspond à aucun article.<br>Vous avez peut-être suivi un ancien lien.</p>
<a href="/" class="btn btn-primary">← Retour à l'accueil</a>
</div>
<?php
$content = ob_get_clean();
$title = '404 — ' . siteTitle();
$metaRobots = 'noindex, nofollow';
include BASE_PATH . '/templates/layout.php';
break;
case 'list':
default:
$privateCats = $articles->getPrivateCategories();
$allCats = $articles->getCategories();
$filterCat = array_key_exists('cat', $_GET) ? trim($_GET['cat']) : '';
$allPosts = array_values(array_filter($articles->getAll(), static function (array $a) use ($privateCats, $filterCat): bool {
if (!$a['published']) {
return canDoOnArticle('view_drafts', $a);
}
$cat = trim($a['category'] ?? '');
if ($cat !== '' && in_array($cat, $privateCats, true) && !isLoggedIn()) {
return false;
}
if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) {
return false;
}
if ($filterCat !== '' && $cat !== $filterCat) {
return false;
}
return true;
}));
$perPage = postsPerPage();
$cursor = trim($_GET['cursor'] ?? '');
// Trouve la position du curseur dans la liste triée
$offset = 0;
if ($cursor !== '') {
foreach ($allPosts as $i => $a) {
if ($a['uuid'] === $cursor) {
$offset = $i + 1;
break;
}
}
}
$posts = array_slice($allPosts, $offset, $perPage);
$nextCursor = count($posts) === $perPage ? end($posts)['uuid'] : null;
$prevCursor = null;
if ($offset > 0) {
$prevOffset = max(0, $offset - $perPage);
$prevCursor = $prevOffset > 0 ? $allPosts[$prevOffset - 1]['uuid'] : '';
}
// Compteurs pour le hero de la page d'accueil
$totalPublished = 0;
$totalUpcoming = 0;
$_now = time();
foreach ($articles->getSearchIndex() ?? [] as $_a) {
if (!($_a['published'] ?? false)) {
continue;
}
$_cat = trim($_a['category'] ?? '');
if ($_cat !== '' && in_array($_cat, $privateCats, true) && !isLoggedIn()) {
continue;
}
if (strtotime((string)($_a['published_at'] ?? '')) > $_now) {
$totalUpcoming++;
} else {
$totalPublished++;
}
}
unset($_now, $_a, $_cat);
// ─── Sections spécifiques à la page d'accueil ─────────────────────
$isHomepage = ($cursor === '' && $filterCat === '');
$heroPost = null;
$latestPosts = [];
$popularPosts = [];
$recentlyUpdated = [];
$redecouvertes = [];
$featuredArticle = null;
if ($isHomepage) {
// Article mis en avant
foreach ($allPosts as $_hp) {
if (!empty($_hp['featured'])) {
$featuredArticle = $_hp;
break;
}
}
$heroPost = $featuredArticle ?? ($allPosts[0] ?? null);
// 5 articles les plus récents (hors hero)
$_heroUuid = $heroPost['uuid'] ?? '';
$_count = 0;
foreach ($allPosts as $_hp) {
if ($_hp['uuid'] === $_heroUuid) {
continue;
}
$latestPosts[] = $_hp;
if (++$_count >= 5) {
break;
}
}
unset($_heroUuid, $_count, $_hp);
$allPostsMap = array_column($allPosts, null, 'uuid');
// Articles populaires (10 derniers jours) — score pondéré
$_pdo = dbPdo();
if ($_pdo) {
try {
$_stmt = $_pdo->query("
SELECT article_uuid, SUM(score) AS total
FROM (
SELECT article_uuid, 1 AS score FROM article_reactions
WHERE created_at >= NOW() - INTERVAL '10 days'
UNION ALL
SELECT article_uuid, 2 AS score FROM article_ratings
WHERE rated_at >= NOW() - INTERVAL '10 days'
UNION ALL
SELECT article_uuid, 3 AS score FROM comments
WHERE created_at >= NOW() - INTERVAL '10 days' AND published = TRUE
) ev
GROUP BY article_uuid
ORDER BY total DESC
LIMIT 20
");
foreach ($_stmt->fetchAll(PDO::FETCH_ASSOC) as $_row) {
if (count($popularPosts) >= 6) {
break;
}
$_uuid = $_row['article_uuid'];
if (!isset($allPostsMap[$_uuid])) {
continue;
}
$popularPosts[] = $allPostsMap[$_uuid];
}
} catch (Throwable) {
}
// Redécouvertes : anciens articles (> 30 j) avec activité récente
try {
$_stmt = $_pdo->query("
SELECT DISTINCT article_uuid FROM (
SELECT article_uuid FROM article_reactions
WHERE created_at >= NOW() - INTERVAL '10 days'
UNION
SELECT article_uuid FROM comments
WHERE created_at >= NOW() - INTERVAL '10 days' AND published = TRUE
) ev
");
$_thirtyDaysAgo = date('Y-m-d H:i:s', time() - 30 * 86400);
$_latestUuids = array_column($latestPosts, 'uuid');
$_popularUuids = array_column($popularPosts, 'uuid');
$_heroUuid = $heroPost['uuid'] ?? '';
foreach ($_stmt->fetchAll(PDO::FETCH_COLUMN, 0) as $_uuid) {
if (count($redecouvertes) >= 4) {
break;
}
if (!isset($allPostsMap[$_uuid])) {
continue;
}
$_a = $allPostsMap[$_uuid];
if (($_a['published_at'] ?? '') >= $_thirtyDaysAgo) {
continue;
}
if ($_a['uuid'] === $_heroUuid) {
continue;
}
if (in_array($_uuid, $_latestUuids, true)) {
continue;
}
if (in_array($_uuid, $_popularUuids, true)) {
continue;
}
$redecouvertes[] = $_a;
}
unset($_thirtyDaysAgo, $_latestUuids, $_popularUuids, $_heroUuid, $_uuid, $_a);
} catch (Throwable) {
}
}
unset($_pdo, $_stmt, $_row);
// Récemment mis à jour (7 derniers jours, pas dans les autres sections)
$_sevenDaysAgo = date('Y-m-d H:i:s', time() - 7 * 86400);
$_latestUuids = array_column($latestPosts, 'uuid');
$_popularUuids = array_column($popularPosts, 'uuid');
$_heroUuid = $heroPost['uuid'] ?? '';
foreach ($allPosts as $_a) {
if (count($recentlyUpdated) >= 4) {
break;
}
if ($_a['uuid'] === $_heroUuid) {
continue;
}
if (in_array($_a['uuid'], $_latestUuids, true)) {
continue;
}
if (in_array($_a['uuid'], $_popularUuids, true)) {
continue;
}
if (($_a['updated_at'] ?? '') < $_sevenDaysAgo) {
continue;
}
if (($_a['published_at'] ?? '') >= $_sevenDaysAgo) {
continue;
} // déjà récent dans latest
$recentlyUpdated[] = $_a;
}
unset($_sevenDaysAgo, $_latestUuids, $_popularUuids, $_heroUuid, $_a, $allPostsMap);
}
// ──────────────────────────────────────────────────────────────────
include BASE_PATH . '/templates/post_list.php';
break;
}