1945 lines
78 KiB
PHP
1945 lines
78 KiB
PHP
<?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($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;
|
||
}
|