Files
varlog/public/index.php
T

1945 lines
78 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
define('BASE_PATH', realpath(__DIR__ . '/../'));
if (session_status() === PHP_SESSION_NONE) {
$isHttps = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'secure' => $isHttps, 'httponly' => true, 'samesite' => 'Lax']);
session_start();
}
require_once BASE_PATH . '/src/helpers.php';
require_once BASE_PATH . '/src/auth.php';
require_once BASE_PATH . '/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', 'not_found'];
$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: /post/' . rawurlencode($results[0]['article']['slug'] ?? ''), true, 301);
exit;
}
}
// ─── 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) <meta charset>, 3) <meta http-equiv>
$charset = null;
if (preg_match('/charset=([^\s;]+)/i', $mimeRaw, $cm)) {
$charset = trim($cm[1], '"\'');
}
if (!$charset && preg_match('/<meta[^>]+charset=["\']?\s*([^"\'\s;>]+)/i', $html, $cm)) {
$charset = trim($cm[1]);
}
if (!$charset && preg_match('/<meta[^>]+content=["\'][^"\']*charset=([^"\'\s;]+)/i', $html, $cm)) {
$charset = trim($cm[1]);
}
if ($charset && strtolower(str_replace(['-','_'], '', $charset)) !== 'utf8') {
$converted = @mb_convert_encoding($html, 'UTF-8', $charset);
if ($converted !== false && $converted !== '') {
$html = $converted;
}
}
preg_match('/<head[^>]*>(.*?)<\/head>/si', $html, $headMatch);
$headHtml = $headMatch[1] ?? $html;
if (preg_match('/<title[^>]*>\s*([^<]+)\s*<\/title>/i', $headHtml, $m)) {
$result['title'] = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
$metaMap = [];
preg_match_all('/<meta\s+([^>]+)>/i', $headHtml, $metaTags);
foreach ($metaTags[1] as $attrs) {
$key = $val = null;
if (preg_match('/(?:name|property)\s*=\s*["\']([^"\']+)["\']/', $attrs, $m)) {
$key = strtolower($m[1]);
}
if (preg_match('/content\s*=\s*["\']([^"\']*)["\']/', $attrs, $m)) {
$val = html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
if ($key !== null && $val !== null && $val !== '') {
$metaMap[$key] = $val;
}
}
$result['title'] ??= $metaMap['og:title'] ?? $metaMap['twitter:title'] ?? $metaMap['title'] ?? null;
$result['description'] ??= $metaMap['og:description'] ?? $metaMap['twitter:description'] ?? $metaMap['description'] ?? null;
$result['author'] ??= $metaMap['author'] ?? $metaMap['article:author'] ?? $metaMap['dc.creator'] ?? null;
$result['keywords'] ??= $metaMap['keywords'] ?? $metaMap['news_keywords'] ?? null;
$result['og_image'] ??= $metaMap['og:image'] ?? $metaMap['twitter:image'] ?? null;
$result['site_name'] ??= $metaMap['og:site_name'] ?? null;
$result['og_type'] ??= $metaMap['og:type'] ?? null;
$result['language'] ??= $metaMap['og:locale'] ?? $metaMap['dc.language'] ?? null;
$result['date'] ??= $metaMap['article:published_time'] ?? $metaMap['dc.date'] ?? null;
if (preg_match('/<link[^>]+rel=["\']canonical["\'][^>]+href=["\']([^"\']+)["\'][^>]*>/i', $headHtml, $m)
|| preg_match('/<link[^>]+href=["\']([^"\']+)["\'][^>]+rel=["\']canonical["\'][^>]*>/i', $headHtml, $m)) {
$result['canonical'] = $m[1];
}
preg_match_all('/<script[^>]+type=["\']application\/ld\+json["\'][^>]*>(.*?)<\/script>/si', $headHtml, $ldTags);
foreach ($ldTags[1] as $jsonStr) {
$ld = @json_decode(trim($jsonStr), true);
if (!is_array($ld)) {
continue;
}
foreach (isset($ld[0]) ? $ld : [$ld] as $item) {
if (!is_array($item)) {
continue;
}
if (empty($result['title']) && !empty($item['headline'])) {
$result['title'] = $item['headline'];
}
if (empty($result['description']) && !empty($item['description'])) {
$result['description'] = $item['description'];
}
if (empty($result['date'])) {
$d = $item['datePublished'] ?? $item['dateCreated'] ?? null;
if ($d) {
$result['date'] = $d;
}
}
if (empty($result['author'])) {
$au = $item['author'] ?? null;
if (is_array($au)) {
$au = $au['name'] ?? ($au[0]['name'] ?? null);
}
if (is_string($au) && $au !== '') {
$result['author'] = $au;
}
}
}
break;
}
$result = array_filter($result, static fn ($v) => $v !== null && $v !== '');
$result['ok'] = true;
} catch (\Throwable) {
}
}
@unlink($tmpFile);
return $result;
}
// ─── Télécharge une image distante → _thumb_ local, retourne le nom du fichier ─
function downloadImageToThumb(string $imageUrl, string $filesDir): ?string
{
if (!filter_var($imageUrl, FILTER_VALIDATE_URL) || !preg_match('#^https?://#i', $imageUrl)) {
return null;
}
$ch = curl_init($imageUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_USERAGENT => 'Mozilla/5.0 varlog/1.0',
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $code !== 200 || strlen($body) < 512) {
return null;
}
$urlPath = parse_url($imageUrl, PHP_URL_PATH) ?? '';
$ext = strtolower(pathinfo($urlPath, PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif'], true)) {
$ext = 'jpg';
}
if ($ext === 'jpeg') {
$ext = 'jpg';
}
if (!is_dir($filesDir)) {
mkdir($filesDir, 0755, true);
}
$hash = substr(hash('sha256', $body), 0, 16);
$size = strlen($body);
$name = '_thumb_' . $hash . '-' . $size . '.' . $ext;
file_put_contents($filesDir . '/' . $name, $body);
return $name;
}
// ─── Trouve l'URL de la plus grande image d'une page HTML ────────────────────
function findLargestPageImage(string $pageUrl): ?string
{
if (!filter_var($pageUrl, FILTER_VALIDATE_URL)) {
return null;
}
$ch = curl_init($pageUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_USERAGENT => 'Mozilla/5.0 varlog/1.0',
CURLOPT_ENCODING => '',
CURLOPT_WRITEFUNCTION => static function ($curl, $chunk) use (&$htmlBuf, &$htmlLen): int {
$htmlLen = ($htmlLen ?? 0) + strlen($chunk);
if ($htmlLen <= 131072) {
$htmlBuf = ($htmlBuf ?? '') . $chunk;
}
return strlen($chunk);
},
]);
$htmlBuf = '';
$htmlLen = 0;
curl_exec($ch);
curl_close($ch);
if (!$htmlBuf) {
return null;
}
$scheme = parse_url($pageUrl, PHP_URL_SCHEME) ?? 'https';
$host = $scheme . '://' . (parse_url($pageUrl, PHP_URL_HOST) ?? '');
preg_match_all('/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $htmlBuf, $m);
$candidates = [];
foreach ($m[1] as $src) {
if (preg_match('#^https?://#i', $src)) {
$candidates[] = $src;
} elseif (str_starts_with($src, '//')) {
$candidates[] = $scheme . ':' . $src;
} elseif (str_starts_with($src, '/')) {
$candidates[] = $host . $src;
}
}
// Filtre les icônes/avatars courants par leur chemin
$candidates = array_filter(
$candidates,
fn ($u) =>
!preg_match('#/(icon|logo|avatar|favicon|sprite|pixel|spacer|blank|1x1|tracking)#i', $u)
&& preg_match('#\.(jpe?g|png|webp|gif|avif)(\?.*)?$#i', $u)
);
$candidates = array_slice(array_values($candidates), 0, 10);
if (empty($candidates)) {
return null;
}
// HEAD requests pour comparer Content-Length
$best = null;
$bestSize = 0;
foreach ($candidates as $imgUrl) {
$ch = curl_init($imgUrl);
curl_setopt_array($ch, [
CURLOPT_NOBODY => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_USERAGENT => 'Mozilla/5.0 varlog/1.0',
]);
curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$len = (int) curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);
curl_close($ch);
if ($code === 200 && $len > $bestSize) {
$bestSize = $len;
$best = $imgUrl;
}
}
// Si aucun Content-Length retourné, prend le premier candidat
return $best ?? $candidates[0];
}
// ─── Capture d'écran via Chromium headless ──────────────────────────────────
function takeScreenshot(string $url, string $outputPath): bool
{
$bin = '';
foreach (['chromium-headless-shell', 'chromium', 'chromium-browser', 'google-chrome'] as $name) {
$found = trim((string) shell_exec('which ' . escapeshellarg($name) . ' 2>/dev/null'));
if ($found !== '') {
$bin = $found;
break;
}
}
if ($bin === '') {
return false;
}
$cmd = 'timeout 20 ' . escapeshellarg($bin)
. ' --headless=new'
. ' --disable-gpu'
. ' --no-sandbox'
. ' --disable-setuid-sandbox'
. ' --hide-scrollbars'
. ' --window-size=1200,630'
. ' --screenshot=' . escapeshellarg($outputPath)
. ' --virtual-time-budget=6000'
. ' ' . escapeshellarg($url)
. ' 2>/dev/null';
shell_exec($cmd);
return file_exists($outputPath) && filesize($outputPath) > 0;
}
switch ($action) {
case 'create':
requireAuth();
$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) {
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 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;
}
}
// Articles proches : un search par mot du titre → OR implicite, cumul des scores
$similarArticles = [];
require_once BASE_PATH . '/src/SearchEngine.php';
$_simEngine = new SearchEngine();
$_relatedUuids = array_column($relatedArticles, 'uuid');
$_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', 'plus', 'sans', 'sous', 'entre', 'vers', 'chez'];
$_simPool = array_values(array_filter(
$_allPublished,
static function (array $a) use ($article, $privateCats, $_relatedUuids): bool {
if ($a['uuid'] === $article['uuid']) {
return false;
}
if (in_array($a['uuid'], $_relatedUuids, true)) {
return false;
}
if (strtotime((string)($a['published_at'] ?? '')) > time() && !isLoggedIn()) {
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 = [];
foreach ($_titleWords as $_word) {
foreach ($_simEngine->search($_word, $_simPool) as $_r) {
$_uuid = $_r['article']['uuid'];
$_scoreMap[$_uuid] = ($_scoreMap[$_uuid] ?? 0.0) + $_r['score'];
$_articleMap[$_uuid] = $_r['article'];
}
}
arsort($_scoreMap);
$similarArticles = array_map(
fn ($uuid) => $_articleMap[$uuid],
array_slice(array_keys($_scoreMap), 0, 5)
);
unset($_simEngine, $_simPool, $_titleWords, $_stopWords, $_scoreMap, $_articleMap, $_relatedUuids);
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();
?>
<h1 class="h4 mb-4">Génération des aperçus de liens</h1>
<form method="get" action="/admin/regen-thumbs">
<input type="hidden" name="run" value="1">
<div class="card p-4 mb-4" style="max-width:480px">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="force" value="1" id="forceCheck">
<label class="form-check-label" for="forceCheck">
Refaire les captures déjà existantes
</label>
<div class="text-muted small mt-1">
Supprime et régénère les miniatures locales déjà enregistrées.
</div>
</div>
<button type="submit" class="btn btn-primary">Lancer</button>
</div>
</form>
<a href="/" class="btn btn-secondary btn-sm">← Retour</a>
<?php
$content = ob_get_clean();
$title = 'Génération des aperçus';
include BASE_PATH . '/templates/layout.php';
break;
}
$force = !empty($_GET['force']);
set_time_limit(600);
// Streaming : on affiche au fil de l'eau
ob_end_clean();
header('Content-Type: text/html; charset=UTF-8');
echo '<!doctype html><html><head><meta charset="utf-8">
<title>Génération des aperçus</title>
<link rel="stylesheet" href="/assets/css/app.css">
</head><body class="container py-4">';
$heading = $force ? 'Régénération de tous les aperçus' : 'Génération des aperçus manquants';
echo '<h1 class="h4 mb-4">' . $heading . '</h1><ul class="list-group mb-4">';
@ob_flush();
flush();
$done = $fail = $skip = 0;
foreach ($articles->getAll() as $article) {
$artUuid = $article['uuid'];
$filesDir = 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 '<li class="list-group-item py-2 small">';
echo '<span class="text-muted me-2">' . htmlspecialchars($article['title']) . '</span>';
echo htmlspecialchars($lUrl) . ' … ';
@ob_flush();
flush();
// Supprime l'ancienne miniature locale si on force
if ($force && $hasLocal) {
$oldMeta = $lMeta['og_image'];
$oldName = rawurldecode(parse_url($oldMeta, PHP_URL_QUERY) ? (explode('name=', $oldMeta)[1] ?? '') : '');
// Extrait le paramètre name= de l'URL /file?uuid=...&name=...
parse_str(parse_url($oldMeta, PHP_URL_QUERY) ?? '', $oldQs);
$oldFile = $oldQs['name'] ?? '';
if ($oldFile !== '' && file_exists($filesDir . '/' . $oldFile)) {
@unlink($filesDir . '/' . $oldFile);
}
}
$thumbName = null;
$method = '';
// 1. og_image → téléchargement direct
// Non-force : utilise l'og_image stockée si externe
// Force : refetch la page pour récupérer l'URL d'origine
$ogToDownload = '';
if (!$force) {
$stored = $lMeta['og_image'] ?? '';
if ($stored !== '' && filter_var($stored, FILTER_VALIDATE_URL)) {
$ogToDownload = $stored;
}
} else {
$freshMeta = fetchUrlMeta($lUrl);
$ogToDownload = $freshMeta['og_image'] ?? '';
if (!filter_var($ogToDownload, FILTER_VALIDATE_URL)) {
$ogToDownload = '';
}
}
if ($ogToDownload !== '') {
$thumbName = downloadImageToThumb($ogToDownload, $filesDir);
if ($thumbName) {
$method = '✓ og:image';
}
}
// 2. Plus grande image de la page
if ($thumbName === null) {
$largestUrl = findLargestPageImage($lUrl);
if ($largestUrl) {
$thumbName = downloadImageToThumb($largestUrl, $filesDir);
if ($thumbName) {
$method = '✓ plus grande image';
}
}
}
// 3. Screenshot Chromium en dernier recours
if ($thumbName === null) {
$screenshotTmp = tempnam(sys_get_temp_dir(), 'vl_ss_') . '.png';
if (takeScreenshot($lUrl, $screenshotTmp)) {
if (!is_dir($filesDir)) {
mkdir($filesDir, 0755, true);
}
$hash = substr(hash_file('sha256', $screenshotTmp), 0, 16);
$size = filesize($screenshotTmp);
$thumbName = '_thumb_' . $hash . '-' . $size . '.png';
rename($screenshotTmp, $filesDir . '/' . $thumbName);
$method = '✓ screenshot';
} else {
@unlink($screenshotTmp);
}
}
if ($thumbName !== null) {
$ogUrl = '/file?uuid=' . rawurlencode($artUuid) . '&name=' . rawurlencode($thumbName);
$articles->updateExternalLinkMeta($artUuid, $lUrl, ['og_image' => $ogUrl]);
echo '<span class="text-success">' . $method . '</span>';
$done++;
} else {
echo '<span class="text-danger">✗ échec</span>';
$fail++;
}
echo '</li>';
@ob_flush();
flush();
}
}
echo '</ul>';
echo '<p class="fw-semibold">Terminé — ';
echo $done . ' capturé' . ($done > 1 ? 's' : '') . ', ';
echo $fail . ' échec' . ($fail > 1 ? 's' : '') . ', ';
echo $skip . ' ignoré' . ($skip > 1 ? 's' : '') . '.</p>';
echo '<a href="/admin/regen-thumbs" class="btn btn-secondary btn-sm">← Retour</a>';
echo '</body></html>';
exit;
case 'delete_external_link':
requireAuth();
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $uuid !== '') {
$linkUrl = $_POST['url'] ?? '';
if ($linkUrl !== '' && filter_var($linkUrl, FILTER_VALIDATE_URL)) {
$articles->removeExternalLink($uuid, $linkUrl);
}
}
header('Location: /edit/' . rawurlencode($uuid));
exit;
case '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 'not_found':
$notFoundPath = trim(
(string)(parse_url($_SERVER['REDIRECT_URL'] ?? $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? ''),
'/'
);
if ($notFoundPath !== '') {
searchAndRedirect(basename($notFoundPath), $articles);
}
http_response_code(404);
ob_start();
?>
<div class="container py-5 text-center">
<h1 class="h2 mb-3">Page introuvable</h1>
<p class="text-muted mb-4">Cette adresse ne correspond à aucun article.<br>Vous avez peut-être suivi un ancien lien.</p>
<a href="/" class="btn btn-primary">← Retour à l'accueil</a>
</div>
<?php
$content = ob_get_clean();
$title = '404 — ' . siteTitle();
$metaRobots = 'noindex, nofollow';
include BASE_PATH . '/templates/layout.php';
break;
case 'list':
default:
$privateCats = $articles->getPrivateCategories();
$allCats = $articles->getCategories();
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;
}