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', 'run_engine_update']; $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) , 3) $charset = null; if (preg_match('/charset=([^\s;]+)/i', $mimeRaw, $cm)) { $charset = trim($cm[1], '"\''); } if (!$charset && preg_match('/]+charset=["\']?\s*([^"\'\s;>]+)/i', $html, $cm)) { $charset = trim($cm[1]); } if (!$charset && preg_match('/]+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>/si', $html, $headMatch); $headHtml = $headMatch[1] ?? $html; if (preg_match('/]*>\s*([^<]+)\s*<\/title>/i', $headHtml, $m)) { $result['title'] = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'); } $metaMap = []; preg_match_all('/]+)>/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('/]+rel=["\']canonical["\'][^>]+href=["\']([^"\']+)["\'][^>]*>/i', $headHtml, $m) || preg_match('/]+href=["\']([^"\']+)["\'][^>]+rel=["\']canonical["\'][^>]*>/i', $headHtml, $m)) { $result['canonical'] = $m[1]; } preg_match_all('/]+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('/]+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 = '

Profil introuvable.

'; $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 = '

Profil introuvable.

'; $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 = '

Page introuvable.

'; $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(); ?>

Génération des aperçus de liens

Supprime et régénère les miniatures locales déjà enregistrées.
← Retour Génération des aperçus '; $heading = $force ? 'Régénération de tous les aperçus' : 'Génération des aperçus manquants'; echo '

' . $heading . '

    '; @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 '
  • '; echo '' . htmlspecialchars($article['title']) . ''; 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 '' . $method . ''; $done++; } else { echo '✗ échec'; $fail++; } echo '
  • '; @ob_flush(); flush(); } } echo '
'; echo '

Terminé — '; echo $done . ' capturé' . ($done > 1 ? 's' : '') . ', '; echo $fail . ' échec' . ($fail > 1 ? 's' : '') . ', '; echo $skip . ' ignoré' . ($skip > 1 ? 's' : '') . '.

'; echo '← Retour'; echo ''; 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 = '' . '

Bonjour ' . htmlspecialchars($cmtName) . ',

' . '

Votre commentaire sur ' . htmlspecialchars($cmtArticle['title']) . ' a bien été reçu.

' . '

Cliquez sur le lien ci-dessous, puis saisissez le code à 6 chiffres :

' . '

Confirmer mon commentaire

' . '

Votre code : ' . htmlspecialchars($cmtCode) . '

' . '

Ce lien et ce code expirent dans 24 heures. Si vous n\'êtes pas à l\'origine de ce message, ignorez-le.

' . ''; 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(); ?>

Confirmer mon commentaire

Trop de tentatives incorrectes. Votre commentaire a été annulé.
← Retour à l'accueil
Code incorrect. Il vous reste essai 1 ? 's' : '' ?>.

Saisissez le code à 6 chiffres reçu par email.

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 = '' . '

Bonjour ' . htmlspecialchars($resendName) . ',

' . '

Votre commentaire sur ' . htmlspecialchars($resendArticle['title']) . ' a bien été reçu.

' . '

Cliquez sur le lien ci-dessous, puis saisissez le code à 6 chiffres :

' . '

Confirmer mon commentaire

' . '

Votre code : ' . htmlspecialchars($resendCode) . '

' . '

Ce lien et ce code expirent dans 24 heures. Si vous n\'êtes pas à l\'origine de ce message, ignorez-le.

' . ''; 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 = '' . '' . '' . '

Bonjour,

' . '

Cet email confirme que la configuration SMTP de ' . htmlspecialchars($_siteName) . ' fonctionne correctement.

' . '

Envoyé le ' . $_sentAt . ' depuis ' . htmlspecialchars($_siteUrl) . '.

' . '
' . '

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.

' . ''; $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¬ice=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¬ice=' . ($_cmErrors ? 'migration_error' : 'migrated')); exit; case 'run_engine_update': requireAuth(); if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(403); exit; } // 1. git pull — vérifier que origin pointe vers le dépôt folio configuré $_folioRepo = rtrim(folioRepoUrl(), '/'); exec('git -C ' . escapeshellarg(BASE_PATH) . ' remote get-url origin 2>&1', $_originOut, $_originCode); $_originUrl = rtrim(trim(implode('', $_originOut)), '/'); // Normaliser : supprimer les credentials éventuels de l'URL (token@host → host) $_originNorm = preg_replace('#https?://[^@]+@#', 'https://', $_originUrl); $_repoNorm = preg_replace('#https?://[^@]+@#', 'https://', $_folioRepo); if ($_originCode !== 0 || $_originNorm !== $_repoNorm) { $_SESSION['_update_log'] = "Le remote git 'origin' (" . $_originUrl . ") ne correspond pas à FOLIO_REPO_URL (" . $_folioRepo . "). git pull annulé."; header('Location: /admin?tab=dashboard¬ice=update_git_error'); exit; } exec('cd ' . escapeshellarg(BASE_PATH) . ' && git pull origin main 2>&1', $_gitOut, $_gitCode); if ($_gitCode !== 0) { $_SESSION['_update_log'] = implode("\n", $_gitOut); header('Location: /admin?tab=dashboard¬ice=update_git_error'); exit; } // 2. composer install (non-bloquant si absent) exec('which composer 2>/dev/null', $_composerPath); if (!empty($_composerPath)) { exec('cd ' . escapeshellarg(BASE_PATH) . ' && composer install --no-dev --optimize-autoloader -q 2>&1'); } // 3. Migrations SQL $pdo->exec('CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT NOT NULL PRIMARY KEY, applied_at TIMESTAMP NOT NULL DEFAULT NOW())'); $_sqlApplied = array_flip($pdo->query('SELECT name FROM schema_migrations ORDER BY name')->fetchAll(PDO::FETCH_COLUMN)); $_sqlFiles = glob(BASE_PATH . '/database/migration_*.sql') ?: []; sort($_sqlFiles); foreach ($_sqlFiles as $_sqlFile) { $_sqlName = basename($_sqlFile); if (isset($_sqlApplied[$_sqlName])) { continue; } $pdo->exec((string) file_get_contents($_sqlFile)); $pdo->prepare('INSERT INTO schema_migrations (name) VALUES (:n)')->execute([':n' => $_sqlName]); } // 4. Migrations de contenu $_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)]))); $_cmErrors = 0; if (!empty($_cmPending)) { file_put_contents($_cmFlag, date('Y-m-d H:i:s')); $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); } } $_updateChecker->clearCache(); header('Location: /admin?tab=dashboard¬ice=' . ($_cmErrors ? 'update_content_error' : 'engine_updated')); 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_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(); ?>

Livre introuvable

Ce livre n'existe pas ou a été supprimé.

← Retour à l'accueil
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(); ?>

Page introuvable

Cette adresse ne correspond à aucun article.
Vous avez peut-être suivi un ancien lien.

← Retour à l'accueil
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; }