Files
varlog/public/index.php
T

2495 lines
102 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', 'add_feed', 'delete_feed', 'add_link', 'delete_link', 'reorder_links', 'react', 'comment', 'verify_comment', 'comment_moderate'];
$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 utilisateurs avec view_previews
if ($article['published'] && strtotime((string)($article['published_at'] ?? '')) > time()) {
if (!hasCapability('view_previews')) {
http_response_code(404);
echo 'Article introuvable.';
exit;
}
}
// Catégorie privée → réservé aux connectés
$allCats = $articles->getCategories();
$privateCats = $articles->getPrivateCategories();
$articleCat = trim($article['category'] ?? '');
$isPrivateCat = $articleCat !== '' && in_array($articleCat, $privateCats, true);
if ($isPrivateCat && !isLoggedIn()) {
http_response_code(404);
echo 'Article introuvable.';
exit;
}
$files = $articles->getFiles($article['uuid']);
// Résout les chemins de fichiers relatifs dans le contenu
$rawContent = $articles->resolveFileUrls($article['uuid'], $article['content']);
// Ratings
$ratingStats = ['avg' => null, 'count' => 0];
$userRating = null;
$pdo = dbPdo();
if ($pdo) {
require_once BASE_PATH . '/src/RatingManager.php';
$ratingMgr = new RatingManager($pdo);
$ratingStats = $ratingMgr->statsForArticle($article['uuid']);
if (isLoggedIn()) {
$userRating = $ratingMgr->userRating($article['uuid'], currentUserEmail() ?? '');
}
}
// Tous les articles publiés (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() && !hasCapability('view_previews')) {
continue;
}
$relatedArticles[] = $a;
if (count($relatedArticles) >= 5) {
break;
}
}
}
// Articles proches par titre → OR implicite, cumul des scores
require_once BASE_PATH . '/src/SearchEngine.php';
$_simEngine = new SearchEngine();
$_stopWords = ['avec', 'dans', 'pour', 'une', 'les', 'des', 'sur', 'par', 'qui', 'que',
'tout', 'mais', 'donc', 'comment', 'quand', 'plus', 'cette', 'cet', 'ces',
'mon', 'ton', 'son', 'notre', 'votre', 'leur', 'tres', 'bien', 'fait',
'aussi', 'comme', 'sans', 'sous', 'entre', 'vers', 'chez'];
$_simPool = array_values(array_filter(
$_allPublished,
static function (array $a) use ($article, $privateCats): bool {
if ($a['uuid'] === $article['uuid']) {
return false;
}
if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) {
return false;
}
$cat = trim($a['category'] ?? '');
return $cat === '' || !in_array($cat, $privateCats, true) || isLoggedIn();
}
));
$_titleWords = array_unique(array_values(array_filter(
preg_split('/\W+/u', mb_strtolower($article['title']), -1, PREG_SPLIT_NO_EMPTY) ?: [],
fn ($w) => mb_strlen($w) >= 4 && !in_array($w, $_stopWords, true)
)));
$_scoreMap = [];
$_articleMap = [];
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);
// "À lire aussi" : similaires en premier, même catégorie pour compléter jusqu'à 5
$alsoReadArticles = $_similarArticles;
if (count($alsoReadArticles) < 5) {
$_used = array_column($alsoReadArticles, 'uuid');
foreach ($relatedArticles as $_ra) {
if (count($alsoReadArticles) >= 5) {
break;
}
if (!in_array($_ra['uuid'], $_used, true)) {
$alsoReadArticles[] = $_ra;
}
}
unset($_used);
}
unset($_similarArticles);
unset($_allPublished);
$backlinks = $articles->getBacklinks($article['slug'] ?? '', $article['uuid']);
// Réactions et commentaires
require_once BASE_PATH . '/src/ReactionManager.php';
require_once BASE_PATH . '/src/CommentManager.php';
$reactionStats = array_fill_keys(ReactionManager::TYPES, 0);
$visitorReactions = [];
$comments = [];
$commentFlash = isset($_GET['commented']);
$commentVerified = isset($_GET['verified']);
$commentError = null;
if ($pdo) {
$reactionMgr = new ReactionManager($pdo);
$commentMgr = new CommentManager($pdo);
// Cookie visiteur (fingerprint anti-doublon)
if (empty($_COOKIE['vl_vid'])) {
$vid = bin2hex(random_bytes(16));
setcookie('vl_vid', $vid, [
'expires' => time() + 365 * 86400,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
} else {
$vid = $_COOKIE['vl_vid'];
}
$reactionStats = $reactionMgr->statsForArticle($article['uuid']);
$visitorReactions = $reactionMgr->visitorReactions($article['uuid'], $vid);
$comments = $commentMgr->forArticle($article['uuid']);
}
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 'author':
$authorSlug = trim($_GET['slug'] ?? '');
$authorRow = profileBySlug($authorSlug);
if (!$authorRow) {
http_response_code(404);
$content = '<div class="container py-5"><p class="text-muted">Profil introuvable.</p></div>';
$title = 'Profil introuvable';
include BASE_PATH . '/templates/layout.php';
break;
}
$privateCats = $articles->getPrivateCategories();
$authorArticles = array_values(array_filter(
$articles->getAll(publishedOnly: true),
static function (array $a) use ($authorRow, $privateCats): bool {
if (($a['author'] ?? '') !== $authorRow['email']) {
return false;
}
$cat = trim($a['category'] ?? '');
if ($cat !== '' && in_array($cat, $privateCats, true) && !isLoggedIn()) {
return false;
}
if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) {
return false;
}
return true;
}
));
include BASE_PATH . '/templates/author_profile.php';
break;
case 'author_articles':
$authorSlug = trim($_GET['slug'] ?? '');
$authorRow = profileBySlug($authorSlug);
if (!$authorRow) {
http_response_code(404);
$content = '<div class="container py-5"><p class="text-muted">Profil introuvable.</p></div>';
$title = 'Profil introuvable';
include BASE_PATH . '/templates/layout.php';
break;
}
$privateCats = $articles->getPrivateCategories();
$allCats = $articles->getCategories();
$authorArticles = array_values(array_filter(
$articles->getAll(publishedOnly: true),
static function (array $a) use ($authorRow, $privateCats): bool {
if (($a['author'] ?? '') !== $authorRow['email']) {
return false;
}
$cat = trim($a['category'] ?? '');
if ($cat !== '' && in_array($cat, $privateCats, true) && !isLoggedIn()) {
return false;
}
if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) {
return false;
}
return true;
}
));
$perPage = postsPerPage();
$cursor = trim($_GET['cursor'] ?? '');
$offset = 0;
if ($cursor !== '') {
foreach ($authorArticles as $i => $a) {
if ($a['uuid'] === $cursor) {
$offset = $i + 1;
break;
}
}
}
$posts = array_slice($authorArticles, $offset, $perPage);
$nextCursor = count($posts) === $perPage ? end($posts)['uuid'] : null;
$prevCursor = null;
if ($offset > 0) {
$prevOffset = max(0, $offset - $perPage);
$prevCursor = $prevOffset > 0 ? $authorArticles[$prevOffset - 1]['uuid'] : '';
}
include BASE_PATH . '/templates/author_articles.php';
break;
case 'liens':
$liensSlug = trim($_GET['slug'] ?? '');
$liensRow = profileBySlug($liensSlug);
if (!$liensRow) {
http_response_code(404);
$content = '<div class="container py-5"><p class="text-muted">Page introuvable.</p></div>';
$title = 'Page introuvable';
include BASE_PATH . '/templates/layout.php';
break;
}
$_lName = $liensRow['display_name'] ?? '';
$_lBio = $liensRow['bio'] ?? '';
$_lSlug = $liensRow['profile_slug'] ?? '';
$_lInitials = mb_strtoupper(mb_substr($_lName, 0, 1, 'UTF-8'), 'UTF-8');
$profileLinks = [];
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->prepare(
'SELECT id, url, title, description FROM profile_links
WHERE user_email = :e ORDER BY position, id'
);
$st->execute([':e' => $liensRow['email']]);
$profileLinks = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable) {
}
}
include BASE_PATH . '/templates/liens.php';
break;
case 'add_link':
requireAuth();
$linkUrl = filter_var(trim($_POST['link_url'] ?? ''), FILTER_VALIDATE_URL) ?: '';
$linkTitle = trim($_POST['link_title'] ?? '');
$linkDesc = trim($_POST['link_desc'] ?? '');
if ($linkUrl !== '') {
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->prepare(
'INSERT INTO profile_links (user_email, url, title, description, position)
VALUES (:e, :u, :t, :d,
COALESCE((SELECT MAX(position)+1 FROM profile_links WHERE user_email = :e), 0))'
);
$st->execute([':e' => currentUserEmail(), ':u' => $linkUrl, ':t' => $linkTitle, ':d' => $linkDesc]);
} catch (\Throwable) {
}
}
}
header('Location: /profile#links');
exit;
case 'delete_link':
requireAuth();
$linkId = (int)($_POST['link_id'] ?? 0);
if ($linkId > 0) {
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->prepare('DELETE FROM profile_links WHERE id = :id AND user_email = :e');
$st->execute([':id' => $linkId, ':e' => currentUserEmail()]);
} catch (\Throwable) {
}
}
}
header('Location: /profile#links');
exit;
case 'reorder_links':
requireAuth();
$order = $_POST['order'] ?? [];
if (is_array($order)) {
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->prepare('UPDATE profile_links SET position = :p WHERE id = :id AND user_email = :e');
foreach (array_values($order) as $pos => $id) {
$st->execute([':p' => $pos, ':id' => (int)$id, ':e' => currentUserEmail()]);
}
} catch (\Throwable) {
}
}
}
header('Location: /profile#links');
exit;
case 'flux':
require_once BASE_PATH . '/src/FeedFetcher.php';
$fetcher = new FeedFetcher(BASE_PATH . '/data/_cache/feeds');
$fluxItems = [];
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->query(
'SELECT f.user_email, f.feed_url, f.label,
p.display_name, p.profile_slug
FROM rss_feeds f
LEFT JOIN user_profiles p ON p.email = f.user_email
ORDER BY f.created_at'
);
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $_row) {
$data = $fetcher->get($_row['feed_url']);
if (!$data) {
continue;
}
$feedTitle = $_row['label'] !== '' ? $_row['label'] : $data['feed_title'];
$authorName = $_row['display_name'] ?? '';
$authorSlug = $_row['profile_slug'] ?? '';
foreach ($data['items'] as $_item) {
$fluxItems[] = array_merge($_item, [
'feed_title' => $feedTitle,
'feed_url' => $_row['feed_url'],
'author_name' => $authorName,
'author_slug' => $authorSlug,
]);
}
}
} catch (\Throwable) {
}
}
usort($fluxItems, static fn ($a, $b) => $b['date'] <=> $a['date']);
include BASE_PATH . '/templates/flux.php';
break;
case 'about':
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;
}
// Détection URL interne (même hostname que APP_URL → lecture directe sans cURL)
$step2Meta = null;
$step2IsInternal = false;
$_iHost = parse_url(APP_URL, PHP_URL_HOST) ?? '';
$_uHost = parse_url($step2Url, PHP_URL_HOST) ?? '';
$_uPath = parse_url($step2Url, PHP_URL_PATH) ?? '';
if ($_iHost !== '' && $_uHost === $_iHost && preg_match('#^/post/([a-z0-9][a-z0-9-]*)/?$#', $_uPath, $_sm)) {
$_ia = $articles->getBySlug($_sm[1]);
if ($_ia) {
$step2IsInternal = true;
$step2Meta = ['ok' => true, 'title' => $_ia['title'] ?? '', 'mime' => 'text/html'];
if (!empty($_ia['seo_description'])) {
$step2Meta['description'] = $_ia['seo_description'];
} elseif (!empty($_ia['content'])) {
require_once BASE_PATH . '/src/Parsedown.php';
$_plain = strip_tags((new Parsedown())->text($_ia['content']));
$step2Meta['description'] = mb_strimwidth(trim(preg_replace('/\s+/', ' ', $_plain)), 0, 155, '…');
unset($_plain);
}
if (!empty($_ia['cover'])) {
$step2Meta['og_image'] = '/file?uuid=' . rawurlencode($_ia['uuid']) . '&name=' . rawurlencode($_ia['cover']);
}
}
unset($_ia);
}
unset($_iHost, $_uHost, $_uPath, $_sm);
if ($step2Meta === null) {
$step2Meta = fetchUrlMeta($step2Url);
}
if (!($step2Meta['ok'] ?? false)) {
header('Location: /import/' . rawurlencode($uuid) . '?error=1');
exit;
}
// Capture d'écran pour prévisualisation (pages HTML uniquement, URL externes uniquement)
$step2Screenshot = null;
if (!$step2IsInternal && str_starts_with($step2Meta['mime'] ?? '', 'text/html')) {
$filesDir = 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 'react':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /');
exit;
}
$reactUuid = trim($_POST['uuid'] ?? '');
$reactType = trim($_POST['type'] ?? '');
$isAjax = ($_POST['_ajax'] ?? '') === '1';
// Cookie visiteur
if (empty($_COOKIE['vl_vid'])) {
$vid = bin2hex(random_bytes(16));
setcookie('vl_vid', $vid, [
'expires' => time() + 365 * 86400,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
} else {
$vid = $_COOKIE['vl_vid'];
}
$pdo = dbPdo();
if ($pdo && $reactUuid !== '') {
require_once BASE_PATH . '/src/ReactionManager.php';
$rm = new ReactionManager($pdo);
$added = $rm->toggle($reactUuid, $reactType, $vid);
$count = $rm->statsForArticle($reactUuid)[$reactType] ?? 0;
if ($isAjax) {
header('Content-Type: application/json');
echo json_encode(['ok' => true, 'active' => $added, 'count' => $count]);
exit;
}
}
$reactBack = $_POST['_back'] ?? '/';
header('Location: ' . $reactBack);
exit;
case 'comment':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /');
exit;
}
// Honeypot
if (($_POST['website'] ?? '') !== '') {
header('Location: /');
exit;
}
// CSRF
$csrfOk = isset($_POST['_token'], $_SESSION['comment_csrf'])
&& hash_equals($_SESSION['comment_csrf'], $_POST['_token']);
unset($_SESSION['comment_csrf']);
if (!$csrfOk) {
header('Location: /');
exit;
}
$cmtUuid = trim($_POST['uuid'] ?? '');
$cmtName = trim($_POST['author_name'] ?? '');
$cmtEmail = trim($_POST['author_email'] ?? '');
$cmtContent = trim($_POST['content'] ?? '');
$cmtArticle = $cmtUuid !== '' ? $articles->getByUuid($cmtUuid) : null;
$cmtBack = $cmtArticle ? '/post/' . rawurlencode($cmtArticle['slug'] ?? $cmtUuid) : '/';
$pdo = dbPdo();
if (!$pdo || !$cmtArticle || $cmtName === '' || !filter_var($cmtEmail, FILTER_VALIDATE_EMAIL) || $cmtContent === '') {
header('Location: ' . $cmtBack . '#comment-form-card');
exit;
}
if (mb_strlen($cmtContent) > 2000) {
header('Location: ' . $cmtBack . '#comment-form-card');
exit;
}
require_once BASE_PATH . '/src/CommentManager.php';
require_once BASE_PATH . '/src/mailer.php';
$cm = new CommentManager($pdo);
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$code = $cm->submit($cmtUuid, $cmtName, $cmtEmail, $cmtContent, $ip, $ua);
$verifyUrl = rtrim(APP_URL, '/') . '/verify-comment/' . $code;
$articleUrl = rtrim(APP_URL, '/') . $cmtBack;
$subject = '[' . siteTitle() . '] Confirmez votre commentaire';
$html = '<!DOCTYPE html><html><body style="font-family:sans-serif;max-width:560px;margin:0 auto">'
. '<p>Bonjour ' . htmlspecialchars($cmtName) . ',</p>'
. '<p>Cliquez sur le bouton ci-dessous pour confirmer votre commentaire sur <em>' . htmlspecialchars($cmtArticle['title']) . '</em> :</p>'
. '<p><a href="' . htmlspecialchars($verifyUrl) . '" style="display:inline-block;padding:10px 20px;background:#0d6efd;color:#fff;text-decoration:none;border-radius:4px">Confirmer mon commentaire</a></p>'
. '<p style="color:#888;font-size:.875em">Ou copiez ce lien dans votre navigateur :<br>' . htmlspecialchars($verifyUrl) . '</p>'
. '<p style="color:#888;font-size:.875em">Ce lien expire dans 24 heures. Si vous n\'êtes pas à l\'origine de ce message, ignorez-le.</p>'
. '</body></html>';
try {
envoyer_mail_smtp($cmtEmail, $subject, $html);
} catch (\RuntimeException) {
// Taux limité ou erreur SMTP : on continue sans planter le visiteur
}
header('Location: ' . $cmtBack . '?commented=1#comments');
exit;
case 'verify_comment':
$vcCode = trim($_GET['code'] ?? '');
$pdo = dbPdo();
if ($pdo && preg_match('/^[0-9]{6}$/', $vcCode)) {
require_once BASE_PATH . '/src/CommentManager.php';
$cm = new CommentManager($pdo);
$vcUuid = $cm->verify($vcCode);
if ($vcUuid !== null) {
$vcArticle = $articles->getByUuid($vcUuid);
$vcSlug = $vcArticle ? ($vcArticle['slug'] ?? $vcUuid) : $vcUuid;
header('Location: /post/' . rawurlencode($vcSlug) . '?verified=1#comments');
exit;
}
}
// Code invalide ou expiré
http_response_code(404);
ob_start();
?>
<div class="container py-5 text-center">
<h1 class="h2 mb-3">Lien invalide ou expiré</h1>
<p class="text-muted mb-4">Ce lien de confirmation n'est plus valide (expiré après 24 h) ou a déjà été utilisé.</p>
<a href="/" class="btn btn-primary">← Retour à l'accueil</a>
</div>
<?php
$content = ob_get_clean();
$title = 'Lien invalide — ' . siteTitle();
$metaRobots = 'noindex, nofollow';
include BASE_PATH . '/templates/layout.php';
break;
case 'comment_moderate':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$modId = (int)($_POST['id'] ?? 0);
$modPub = (int)($_POST['pub'] ?? 1);
$pdo = dbPdo();
if ($pdo && $modId > 0) {
require_once BASE_PATH . '/src/CommentManager.php';
(new CommentManager($pdo))->setPublished($modId, $modPub === 1);
}
header('Location: /admin/comments');
exit;
case '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['filter_authors'] = array_values(array_unique(array_filter(array_column($allArticles, 'author'))));
$adminData['filter_categories'] = array_values(array_unique(array_filter(array_column($allArticles, 'category'))));
sort($adminData['filter_authors']);
sort($adminData['filter_categories']);
$filterAuthor = trim($_GET['filter_author'] ?? '');
$filterCategory = trim($_GET['filter_category'] ?? '');
$filterStatus = trim($_GET['filter_status'] ?? '');
$adminData['filter_author'] = $filterAuthor;
$adminData['filter_category'] = $filterCategory;
$adminData['filter_status'] = $filterStatus;
$nowTs = time();
if ($filterAuthor !== '') {
$allArticles = array_values(array_filter($allArticles, fn ($a) => ($a['author'] ?? '') === $filterAuthor));
}
if ($filterCategory !== '') {
$allArticles = array_values(array_filter($allArticles, fn ($a) => trim($a['category'] ?? '') === $filterCategory));
}
if ($filterStatus === 'published') {
$allArticles = array_values(array_filter($allArticles, fn ($a) => $a['published'] && strtotime((string)($a['published_at'] ?? '')) <= $nowTs));
} elseif ($filterStatus === 'draft') {
$allArticles = array_values(array_filter($allArticles, fn ($a) => !$a['published']));
} elseif ($filterStatus === 'preview') {
$allArticles = array_values(array_filter($allArticles, fn ($a) => $a['published'] && strtotime((string)($a['published_at'] ?? '')) > $nowTs));
}
$adminData['articles'] = $allArticles;
}
if ($tab === 'roles') {
if (!isAdmin()) {
http_response_code(403);
exit;
}
$pdo = dbPdo();
if ($pdo) {
$st = $pdo->query(
'SELECT r.id, r.name, r.label, COUNT(ur.user_email) AS user_count
FROM roles r
LEFT JOIN user_roles ur ON ur.role_id = r.id
GROUP BY r.id, r.name, r.label
ORDER BY r.name'
);
$roles = $st->fetchAll(PDO::FETCH_ASSOC);
try {
$capRows = $pdo->query('SELECT role_id, capability FROM role_capabilities')->fetchAll(PDO::FETCH_ASSOC);
$capsMap = [];
foreach ($capRows as $cr) {
$capsMap[(int)$cr['role_id']][] = $cr['capability'];
}
} catch (\Throwable) {
$capsMap = [];
}
foreach ($roles as &$r) {
$r['capabilities'] = $capsMap[(int)$r['id']] ?? [];
}
unset($r);
$adminData['roles'] = $roles;
} else {
$adminData['roles'] = [];
}
}
if ($tab === 'users') {
if (!isAdmin()) {
http_response_code(403);
exit;
}
$pdo = dbPdo();
if ($pdo) {
// users table may not exist yet — degrade gracefully
$usersFromDb = [];
try {
$st = $pdo->query('SELECT email, is_active FROM users ORDER BY email');
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
$v = $row['is_active'];
$usersFromDb[$row['email']] = is_bool($v) ? $v : in_array(strtolower((string)$v), ['t', '1', 'true', 'yes'], true);
}
} catch (\PDOException) {
// table absente, on continue avec la liste user_roles seulement
}
$st = $pdo->query('SELECT ur.user_email, r.name, r.label FROM user_roles ur JOIN roles r ON r.id = ur.role_id ORDER BY ur.user_email');
$rolesMap = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
$rolesMap[$row['user_email']][] = ['name' => $row['name'], 'label' => $row['label']];
}
$merged = [];
foreach (array_unique(array_merge(array_keys($usersFromDb), array_keys($rolesMap))) as $email) {
$merged[$email] = [
'email' => $email,
'is_active' => $usersFromDb[$email] ?? null,
'roles' => $rolesMap[$email] ?? [], // [['name'=>..., 'label'=>...], ...]
];
}
ksort($merged);
$adminData['users'] = array_values($merged);
$st = $pdo->query('SELECT id, name, label FROM roles ORDER BY name');
$adminData['roles'] = $st->fetchAll(PDO::FETCH_ASSOC);
} else {
$adminData['users'] = [];
$adminData['roles'] = [];
}
}
if ($tab === 'comments') {
if (!isAdmin()) {
http_response_code(403);
exit;
}
$pdo = dbPdo();
if ($pdo) {
require_once BASE_PATH . '/src/CommentManager.php';
$adminData['comments'] = (new CommentManager($pdo))->allForAdmin();
// Enrichit avec le slug de chaque article pour les liens
$adminData['articleSlugs'] = [];
foreach ($adminData['comments'] as $cmtRow) {
$uuid = $cmtRow['article_uuid'];
if (!isset($adminData['articleSlugs'][$uuid])) {
$a = $articles->getByUuid($uuid);
$adminData['articleSlugs'][$uuid] = $a ? ($a['slug'] ?? null) : null;
}
}
} else {
$adminData['comments'] = [];
$adminData['articleSlugs'] = [];
}
}
include BASE_PATH . '/templates/admin.php';
break;
case 'admin_bulk_delete':
requireAuth();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$uuids = $_POST['uuids'] ?? [];
if (is_array($uuids)) {
$me = currentUserEmail() ?? '';
foreach ($uuids as $uid) {
$uid = trim((string)$uid);
if ($uid === '') {
continue;
}
$art = $articles->getByUuid($uid);
if (!$art) {
continue;
}
if (isAdmin() || ($art['author'] ?? '') === $me) {
$articles->delete($uid);
}
}
}
}
header('Location: /admin/articles');
exit;
case 'admin_grant_role':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$targetEmail = strtolower(trim($_POST['email'] ?? ''));
$roleName = trim($_POST['role'] ?? '');
if ($targetEmail && $roleName && filter_var($targetEmail, FILTER_VALIDATE_EMAIL)) {
$pdo = dbPdo();
if ($pdo) {
$st = $pdo->prepare(
'INSERT INTO user_roles (user_email, role_id, granted_by)
SELECT :email, id, :by FROM roles WHERE name = :role
ON CONFLICT DO NOTHING'
);
$st->execute([':email' => $targetEmail, ':role' => $roleName, ':by' => currentUserEmail()]);
}
}
header('Location: /admin/users');
exit;
case 'admin_revoke_role':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$targetEmail = strtolower(trim($_POST['email'] ?? ''));
$roleName = trim($_POST['role'] ?? '');
if ($targetEmail && $roleName) {
$pdo = dbPdo();
if ($pdo) {
// Bloquer si c'est le dernier admin (en DB — hors ADMIN_EMAIL env)
if ($roleName === 'admin') {
$st = $pdo->prepare(
'SELECT COUNT(*) FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE r.name = :role AND ur.user_email != :email'
);
$st->execute([':role' => 'admin', ':email' => $targetEmail]);
if ((int)$st->fetchColumn() === 0) {
header('Location: /admin/users?error=last_admin');
exit;
}
}
$st = $pdo->prepare(
'DELETE FROM user_roles
WHERE user_email = :email
AND role_id = (SELECT id FROM roles WHERE name = :role)'
);
$st->execute([':email' => $targetEmail, ':role' => $roleName]);
}
}
header('Location: /admin/users');
exit;
case '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'] ?? '',
'site_lang' => $_POST['site_lang'] ?? '',
'posts_per_page' => $_POST['posts_per_page'] ?? '',
'site_license_label' => $_POST['site_license_label'] ?? '',
'site_license_url' => $_POST['site_license_url'] ?? '',
]);
header('Location: /admin/site?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'] ?? '');
$newUrl = trim($_POST['profile_url'] ?? '');
if ($newUrl !== '' && !filter_var($newUrl, FILTER_VALIDATE_URL)) {
$newUrl = '';
}
$newBio = trim($_POST['bio'] ?? '');
if ($newName === '') {
$profileError = 'Le nom ne peut pas être vide.';
} else {
$pdo = dbPdo();
if ($pdo) {
try {
$newSlug = slugify($newName);
$st = $pdo->prepare(
'INSERT INTO user_profiles (email, display_name, profile_url, profile_slug, bio, updated_at)
VALUES (:e, :n, :u, :s, :b, now())
ON CONFLICT (email) DO UPDATE SET display_name = :n, profile_url = :u, profile_slug = :s, bio = :b, updated_at = now()'
);
$st->execute([':e' => currentUserEmail(), ':n' => $newName, ':u' => $newUrl, ':s' => $newSlug, ':b' => $newBio]);
$_SESSION['user_display_name'] = $newName;
$profileSuccess = true;
} catch (\Throwable $ex) {
$profileError = 'Erreur lors de la sauvegarde.';
}
}
}
}
$profileCurrentName = currentUserName();
$_profileData = authorProfile(currentUserEmail() ?? '');
$profileCurrentUrl = $_profileData['url'];
$profileCurrentBio = $_profileData['bio'];
$profileCurrentSlug = $_profileData['slug'];
// Pré-remplir l'URL avec l'URL de profil public si vide
if ($profileCurrentUrl === '' && $profileCurrentSlug !== '') {
$profileCurrentUrl = rtrim(APP_URL, '/') . '/profil/' . rawurlencode($profileCurrentSlug);
}
// Liens de la page "Mes liens"
$profileLinks = [];
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->prepare('SELECT id, url, title, description FROM profile_links WHERE user_email = :e ORDER BY position, id');
$st->execute([':e' => currentUserEmail()]);
$profileLinks = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable) {
}
}
// Feeds RSS de l'utilisateur
$profileFeeds = [];
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->prepare('SELECT id, feed_url, label FROM rss_feeds WHERE user_email = :e ORDER BY created_at');
$st->execute([':e' => currentUserEmail()]);
$profileFeeds = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable) {
}
}
include BASE_PATH . '/templates/profile.php';
break;
case 'add_feed':
requireAuth();
$feedUrl = filter_var(trim($_POST['feed_url'] ?? ''), FILTER_VALIDATE_URL) ?: '';
$feedLabel = trim($_POST['feed_label'] ?? '');
if ($feedUrl !== '') {
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->prepare(
'INSERT INTO rss_feeds (user_email, feed_url, label) VALUES (:e, :u, :l)
ON CONFLICT (user_email, feed_url) DO UPDATE SET label = :l'
);
$st->execute([':e' => currentUserEmail(), ':u' => $feedUrl, ':l' => $feedLabel]);
} catch (\Throwable) {
}
}
}
header('Location: /profile#feeds');
exit;
case 'delete_feed':
requireAuth();
$feedId = (int)($_POST['feed_id'] ?? 0);
if ($feedId > 0) {
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->prepare('DELETE FROM rss_feeds WHERE id = :id AND user_email = :e');
$st->execute([':id' => $feedId, ':e' => currentUserEmail()]);
} catch (\Throwable) {
}
}
}
header('Location: /profile#feeds');
exit;
case 'search_files':
requireAuth();
header('Content-Type: application/json');
$q = trim($_GET['q'] ?? '');
$sfExclude = trim($_GET['exclude'] ?? '');
if ($q === '') {
echo json_encode([]);
exit;
}
require_once BASE_PATH . '/src/SearchEngine.php';
$sfPool = $articles->getSearchIndex() ?? $articles->getAll();
$sfResults = (new SearchEngine())->search($q, $sfPool);
$sfOut = [];
foreach ($sfResults as $r) {
$a = $r['article'];
$aId = $a['uuid'] ?? '';
if ($aId === '' || $aId === $sfExclude) {
continue;
}
$aFiles = $articles->getFiles($aId);
if (empty($aFiles)) {
continue;
}
$sfFiles = [];
foreach ($aFiles as $f) {
if (str_starts_with($f['name'], '_thumb_')) {
continue;
}
$sfFiles[] = [
'url' => '/file?uuid=' . rawurlencode($aId) . '&name=' . rawurlencode($f['name']),
'name' => $f['name'],
'mime' => $f['mime'],
'is_image' => $f['is_image'],
'size' => $f['size'],
];
}
if (empty($sfFiles)) {
continue;
}
$sfOut[] = [
'article' => ['uuid' => $aId, 'title' => $a['title'] ?? '', 'slug' => $a['slug'] ?? ''],
'files' => $sfFiles,
];
if (count($sfOut) >= 20) {
break;
}
}
echo json_encode($sfOut);
exit;
case 'search':
require_once BASE_PATH . '/src/SearchEngine.php';
$searchQuery = trim($_GET['q'] ?? '');
$searchResults = [];
if ($searchQuery !== '') {
$isAnonSearch = !isLoggedIn();
// Lecture du cache pour les visiteurs anonymes
if ($isAnonSearch) {
$searchResults = $articles->getSearchCache($searchQuery) ?? [];
}
if (empty($searchResults)) {
$privateCats = $articles->getPrivateCategories();
$rawPool = $articles->getSearchIndex() ?? $articles->getAll(true);
$searchPool = array_values(array_filter($rawPool, static function (array $a) use ($privateCats): bool {
if (!($a['published'] ?? false)) {
return false;
}
$cat = trim($a['category'] ?? '');
if ($cat !== '' && in_array($cat, $privateCats, true) && !isLoggedIn()) {
return false;
}
if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) {
return false;
}
return true;
}));
$searchResults = (new SearchEngine())->search($searchQuery, $searchPool);
if ($isAnonSearch && !empty($searchResults)) {
$articles->setSearchCache($searchQuery, $searchResults);
}
}
}
include BASE_PATH . '/templates/search.php';
break;
case 'not_found':
$notFoundPath = trim(
(string)(parse_url($_SERVER['REDIRECT_URL'] ?? $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? ''),
'/'
);
if ($notFoundPath !== '') {
searchAndRedirect(basename($notFoundPath), $articles);
}
http_response_code(404);
ob_start();
?>
<div class="container py-5 text-center">
<h1 class="h2 mb-3">Page introuvable</h1>
<p class="text-muted mb-4">Cette adresse ne correspond à aucun article.<br>Vous avez peut-être suivi un ancien lien.</p>
<a href="/" class="btn btn-primary">← Retour à l'accueil</a>
</div>
<?php
$content = ob_get_clean();
$title = '404 — ' . siteTitle();
$metaRobots = 'noindex, nofollow';
include BASE_PATH . '/templates/layout.php';
break;
case 'list':
default:
$privateCats = $articles->getPrivateCategories();
$allCats = $articles->getCategories();
$filterCat = array_key_exists('cat', $_GET) ? trim($_GET['cat']) : '';
$allPosts = array_values(array_filter($articles->getAll(), static function (array $a) use ($privateCats, $filterCat): bool {
if (!$a['published']) {
return canDoOnArticle('view_drafts', $a);
}
$cat = trim($a['category'] ?? '');
if ($cat !== '' && in_array($cat, $privateCats, true) && !isLoggedIn()) {
return false;
}
if ($filterCat !== '' && $cat !== $filterCat) {
return false;
}
return true;
}));
$perPage = postsPerPage();
$cursor = trim($_GET['cursor'] ?? '');
// Trouve la position du curseur dans la liste triée
$offset = 0;
if ($cursor !== '') {
foreach ($allPosts as $i => $a) {
if ($a['uuid'] === $cursor) {
$offset = $i + 1;
break;
}
}
}
$posts = array_slice($allPosts, $offset, $perPage);
$nextCursor = count($posts) === $perPage ? end($posts)['uuid'] : null;
$prevCursor = null;
if ($offset > 0) {
$prevOffset = max(0, $offset - $perPage);
$prevCursor = $prevOffset > 0 ? $allPosts[$prevOffset - 1]['uuid'] : '';
}
include BASE_PATH . '/templates/post_list.php';
break;
}