0, 'path' => '/', 'secure' => $isHttps, 'httponly' => true, 'samesite' => 'Lax']); session_start(); } require_once BASE_PATH . '/src/helpers.php'; require_once BASE_PATH . '/src/auth.php'; require_once BASE_PATH . '/src/SiteSettings.php'; require_once BASE_PATH . '/config/config.php'; require_once BASE_PATH . '/src/ArticleManager.php'; $articles = new ArticleManager(BASE_PATH . '/data'); $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']; $metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null; unset($_noindexActions); // ─── 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); 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(); $title = $_POST['title'] ?? ''; $content = $_POST['content'] ?? ''; $postSlug = $_POST['slug'] ?? ''; $published = isset($_POST['published']); $published_at = str_replace('T', ' ', $_POST['published_at'] ?? date('Y-m-d H:i:s')); $seoTitle = $_POST['seo_title'] ?? ''; $seoDescription = $_POST['seo_description'] ?? ''; $ogImage = $_POST['og_image'] ?? ''; $category = $_POST['category'] ?? ''; $errors = []; if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (trim($title) === '') { $errors[] = 'Le titre est obligatoire.'; } if (empty($errors)) { $newUuid = $articles->create($title, $content, $published, $postSlug, $published_at, currentUserEmail() ?? '', $seoTitle, $seoDescription, $ogImage, $category); foreach ($_FILES['files']['tmp_name'] ?? [] as $i => $tmpName) { if ($_FILES['files']['error'][$i] === UPLOAD_ERR_OK) { $articles->addFile($newUuid, [ 'name' => $_FILES['files']['name'][$i], 'tmp_name' => $tmpName, 'error' => $_FILES['files']['error'][$i], ]); } } header('Location: /'); exit; } } $formAction = '/new'; $action = 'create'; include BASE_PATH . '/templates/post_form.php'; break; case 'view': $article = $slug !== '' ? $articles->getBySlug($slug) : null; if (!$article) { http_response_code(404); echo 'Article introuvable.'; exit; } 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 connectés if ($article['published'] && strtotime((string)($article['published_at'] ?? '')) > time()) { if (!isLoggedIn()) { 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 (une seule passe pour related + sidebar) $_allPublished = $articles->getAll(true); // 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() && !isLoggedIn()) { continue; } $relatedArticles[] = $a; if (count($relatedArticles) >= 5) { break; } } } // Sidebar gauche : autres catégories avec leurs 5 derniers articles $categorySidebar = []; foreach ($_allPublished as $a) { $aCat = trim($a['category'] ?? ''); if ($aCat === '' || $aCat === $articleCat) { continue; } if (in_array($aCat, $privateCats, true) && !isLoggedIn()) { continue; } if (strtotime((string)($a['published_at'] ?? '')) > time() && !isLoggedIn()) { continue; } if (!isset($categorySidebar[$aCat])) { $categorySidebar[$aCat] = []; } if (count($categorySidebar[$aCat]) < 5) { $categorySidebar[$aCat][] = $a; } } unset($_allPublished); 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; } $title = $_POST['title'] ?? $article['title']; $content = $_POST['content'] ?? $article['content']; $postSlug = $_POST['slug'] ?? $article['slug']; $published = isset($_POST['published']) ? true : $article['published']; $published_at = $_POST['published_at'] ?? date('Y-m-d\TH:i', strtotime((string)($article['published_at'] ?? 'now'))); $seoTitle = $_POST['seo_title'] ?? ($article['seo_title'] ?? ''); $seoDescription = $_POST['seo_description'] ?? ($article['seo_description'] ?? ''); $ogImage = $_POST['og_image'] ?? ($article['og_image'] ?? ''); $category = $_POST['category'] ?? ($article['category'] ?? ''); $errors = []; if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (trim($title) === '') { $errors[] = 'Le titre est obligatoire.'; } if (empty($errors)) { if (!empty($_POST['_confirm'])) { $coverFile = trim($_POST['cover_file'] ?? '') ?: ($article['cover'] ?? ''); $ogImageFromCover = $coverFile !== '' ? rtrim(APP_URL, '/') . '/file?uuid=' . rawurlencode($uuid) . '&name=' . rawurlencode($coverFile) : ''; $articles->update( $uuid, $title, $content, $published, $_POST['slug'] ?? '', str_replace('T', ' ', $_POST['published_at'] ?? ''), $_POST['revision_comment'] ?? '', $_POST['seo_title'] ?? '', $_POST['seo_description'] ?? '', $ogImageFromCover, $_POST['category'] ?? '' ); $fmetaNames = $_POST['fmeta_name'] ?? []; $fmetaAuthors = $_POST['fmeta_author'] ?? []; $fmetaSources = $_POST['fmeta_source'] ?? []; foreach ($fmetaNames as $fi => $fname) { $articles->addFileMeta($uuid, $fname, trim($fmetaAuthors[$fi] ?? ''), trim($fmetaSources[$fi] ?? '')); } $coverFile = trim($_POST['cover_file'] ?? ''); if ($coverFile !== '') { $articles->setCover($uuid, $coverFile); } $updated = $articles->getByUuid($uuid); header('Location: /post/' . rawurlencode($updated['slug'] ?? $uuid)); exit; } // ─── Page de confirmation ──────────────────────────────────── $diffLines = lineDiff((string)($article['content'] ?? ''), $content); $titleChanged = ($title !== ($article['title'] ?? '')); $autoSlug = slugify($title); $changes = []; if ($titleChanged) { $changes[] = 'titre modifié'; } if (($category ?? '') !== ($article['category'] ?? '')) { $changes[] = 'catégorie modifiée'; } if ($content !== ($article['content'] ?? '')) { $changes[] = 'contenu modifié'; } $oldPublished = (bool)($article['published'] ?? false); if ($published !== $oldPublished) { $changes[] = $published ? 'article publié' : 'article dépublié'; } $newCover = trim($_POST['cover_file'] ?? ''); if ($newCover !== '' && $newCover !== ($article['cover'] ?? '')) { $changes[] = 'couverture modifiée'; } $fmetaNames = $_POST['fmeta_name'] ?? []; $fmetaAuthors = $_POST['fmeta_author'] ?? []; $fmetaSources = $_POST['fmeta_source'] ?? []; foreach ($fmetaNames as $fi => $fname) { $savedMeta = ($article['files_meta'][$fname] ?? []); if (trim($fmetaAuthors[$fi] ?? '') !== ($savedMeta['author'] ?? '') || trim($fmetaSources[$fi] ?? '') !== ($savedMeta['source_url'] ?? '')) { $changes[] = 'métadonnées fichiers modifiées'; break; } } $autoRevisionComment = !empty($changes) ? ucfirst(implode(', ', $changes)) : ''; require_once BASE_PATH . '/src/Parsedown.php'; $_pd = new Parsedown(); $autoSeoDesc = mb_strimwidth( trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text($content)))), 0, 155, '…' ); unset($_pd); include BASE_PATH . '/templates/post_confirm.php'; exit; } } $formAction = '/edit/' . rawurlencode($uuid); $action = 'edit'; $existingFiles = $articles->getFiles($uuid); $insertUrl = ''; if (isset($_GET['insert_url']) && filter_var($_GET['insert_url'], FILTER_VALIDATE_URL)) { $insertUrl = $_GET['insert_url']; } include BASE_PATH . '/templates/post_form.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': requireAuth(); $cats = $articles->getCategories(); $privateCats = $articles->getPrivateCategories(); include BASE_PATH . '/templates/categories.php'; break; 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: /categories'); exit; case 'delete_category': requireAuth(); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $cat = trim($_POST['category'] ?? ''); if ($cat !== '') { $articles->deleteCategory($cat); } } header('Location: /categories'); exit; case 'toggle_private_category': requireAuth(); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $cat = trim($_POST['category'] ?? ''); if ($cat !== '') { $articles->togglePrivateCategory($cat); } } header('Location: /categories'); exit; case 'about': include BASE_PATH . '/templates/about.php'; break; case 'legal': include BASE_PATH . '/templates/legal.php'; break; case 'contact': include BASE_PATH . '/templates/contact.php'; break; case 'licenses': include BASE_PATH . '/templates/licenses.php'; break; case '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; } $asTitle = trim($_POST['title'] ?? ''); $asContent = $_POST['content'] ?? ''; $asSlug = trim($_POST['slug'] ?? ''); if ($asTitle === '') { echo json_encode(['ok' => false]); exit; } $ok = $articles->autosave($uuid, $asTitle, $asContent, $asSlug); echo json_encode(['ok' => $ok, 'time' => date('H:i:s')]); 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 = BASE_PATH . '/data/' . $cfFrom . '/files/' . $cfName; $cfDstDir = BASE_PATH . '/data/' . $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; } if ($_SERVER['REQUEST_METHOD'] === 'POST') { 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; } $step2Meta = fetchUrlMeta($step2Url); if (!($step2Meta['ok'] ?? false)) { header('Location: /import/' . rawurlencode($uuid) . '?error=1'); exit; } // Capture d'écran pour prévisualisation (pages HTML uniquement) $step2Screenshot = null; if (str_starts_with($step2Meta['mime'] ?? '', 'text/html')) { $filesDir = BASE_PATH . '/data/' . $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 = BASE_PATH . '/data/' . $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 = BASE_PATH . '/data/' . $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 = BASE_PATH . '/data/' . $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 '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']); 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['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'] = []; } } include BASE_PATH . '/templates/admin.php'; break; 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 'admin_save_site': requireAuth(); if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(403); exit; } saveSiteSettings([ 'site_title' => $_POST['site_title'] ?? '', 'site_claim' => $_POST['site_claim'] ?? '', ]); header('Location: /admin/site?saved=1'); 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'] ?? ''); if ($newName === '') { $profileError = 'Le nom ne peut pas être vide.'; } else { $pdo = dbPdo(); if ($pdo) { try { $st = $pdo->prepare( 'INSERT INTO user_profiles (email, display_name, updated_at) VALUES (:e, :n, now()) ON CONFLICT (email) DO UPDATE SET display_name = :n, updated_at = now()' ); $st->execute([':e' => currentUserEmail(), ':n' => $newName]); $_SESSION['user_display_name'] = $newName; $profileSuccess = true; } catch (\Throwable $ex) { $profileError = 'Erreur lors de la sauvegarde.'; } } } } $profileCurrentName = currentUserName(); include BASE_PATH . '/templates/profile.php'; break; 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 !== '') { $privateCats = $articles->getPrivateCategories(); // Utilise l'index pré-construit si disponible (lecture d'un seul fichier JSON) // Sinon fallback sur getAll() qui scanne tous les répertoires $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() && !isLoggedIn()) { return false; } return true; })); $searchResults = (new SearchEngine())->search($searchQuery, $searchPool); } include BASE_PATH . '/templates/search.php'; break; case 'list': default: $privateCats = $articles->getPrivateCategories(); $allCats = $articles->getCategories(); if (array_key_exists('cat', $_GET)) { $filterCat = trim($_GET['cat']); if ($filterCat === '') { // Réinitialisation explicite → effacer le cookie et rediriger setcookie('varlog_cat', '', time() - 3600, '/', '', $isHttps, true); header('Location: /', true, 302); exit; } // Sauvegarder la catégorie choisie (1 an) setcookie('varlog_cat', $filterCat, time() + 365 * 24 * 3600, '/', '', $isHttps, true); } else { // Pas de paramètre → appliquer le cookie si la catégorie existe toujours $savedCat = trim($_COOKIE['varlog_cat'] ?? ''); if ($savedCat !== '' && isset($allCats[$savedCat])) { header('Location: /categorie/' . rawurlencode($savedCat), true, 302); exit; } if ($savedCat !== '' && !isset($allCats[$savedCat])) { setcookie('varlog_cat', '', time() - 3600, '/', '', $isHttps, true); } $filterCat = ''; } $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 ($filterCat !== '' && $cat !== $filterCat) { return false; } return true; })); $perPage = 12; $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'] : ''; } include BASE_PATH . '/templates/post_list.php'; break; }