996ab3e508
- mkArticleDir() crée les répertoires avec chmod 0775 explicite (bypass umask) - delete() retourne bool et détecte l'échec sans reconstruire les index - removeDir() supprime les warnings PHP (@unlink, @rmdir, @scandir) - post_view.php affiche un message d'erreur si delete_failed=1 - index.php redirige vers l'article avec ?delete_failed=1 si échec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3606 lines
152 KiB
PHP
3606 lines
152 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
define('BASE_PATH', realpath(__DIR__ . '/../'));
|
||
|
||
// Charger .env avant de lire SESSION_NAME, sinon getenv() retourne '' et le mauvais cookie est chargé
|
||
require_once BASE_PATH . '/vendor/autoload.php';
|
||
require_once BASE_PATH . '/config/config.php';
|
||
|
||
$_sessionName = $_ENV['SESSION_NAME'] ?? (getenv('SESSION_NAME') ?: 'PHPSESSID');
|
||
if (session_status() === PHP_SESSION_NONE
|
||
&& (isset($_COOKIE[$_sessionName]) || $_SERVER['REQUEST_METHOD'] === 'POST')
|
||
) {
|
||
$isHttps = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||
session_name($_sessionName);
|
||
session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'secure' => $isHttps, 'httponly' => true, 'samesite' => 'Lax']);
|
||
session_start();
|
||
}
|
||
unset($_sessionName);
|
||
|
||
require_once BASE_PATH . '/src/helpers.php';
|
||
require_once BASE_PATH . '/src/auth.php';
|
||
require_once BASE_PATH . '/src/SiteSettings.php';
|
||
require_once BASE_PATH . '/src/ArticleManager.php';
|
||
require_once BASE_PATH . '/src/BookManager.php';
|
||
require_once BASE_PATH . '/src/DataGit.php';
|
||
|
||
$_dataGit = new DataGit(DATA_PATH);
|
||
$articles = new ArticleManager(DATA_PATH, $_dataGit);
|
||
$books = new BookManager(DATA_PATH . '/books', $_dataGit);
|
||
|
||
// ─── Mode maintenance ──────────────────────────────────────────────────────
|
||
if (file_exists(DATA_PATH . '/.maintenance')) {
|
||
http_response_code(503);
|
||
header('Retry-After: 60');
|
||
include BASE_PATH . '/templates/maintenance.php';
|
||
exit;
|
||
}
|
||
|
||
require_once BASE_PATH . '/src/UpdateChecker.php';
|
||
$_updateChecker = new UpdateChecker(DATA_PATH, BASE_PATH);
|
||
|
||
$action = $_GET['action'] ?? 'list';
|
||
$uuid = $_GET['uuid'] ?? '';
|
||
$slug = $_GET['slug'] ?? '';
|
||
|
||
$_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category', 'admin_save_site', 'not_found', 'add_feed', 'delete_feed', 'add_link', 'delete_link', 'reorder_links', 'react', 'comment', 'verify_comment', 'comment_moderate', 'comment_delete', 'comment_resend', 'create_tag_type', 'delete_tag_type', 'edit_tags', 'book_save', 'book_delete', 'admin_save_as_groups', 'admin_save_folio_config', 'run_engine_update', 'run_content_migrations'];
|
||
$metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null;
|
||
unset($_noindexActions);
|
||
|
||
// ─── Recherche de l'article le plus proche et redirection 301 ────────────────
|
||
function searchAndRedirect(string $rawPath, ArticleManager $articles): void
|
||
{
|
||
require_once BASE_PATH . '/src/SearchEngine.php';
|
||
$query = (string)preg_replace('/\s{2,}/', ' ', trim(
|
||
(string)preg_replace('/[^a-zA-ZÀ-ÿ0-9\s]/u', ' ', str_replace(['-', '_', '/'], ' ', $rawPath))
|
||
));
|
||
if ($query === '') {
|
||
return;
|
||
}
|
||
$privateCats = $articles->getPrivateCategories();
|
||
$pool = array_values(array_filter(
|
||
$articles->getAll(true),
|
||
static function (array $a) use ($privateCats): bool {
|
||
if (strtotime((string)($a['published_at'] ?? '')) > time()) {
|
||
return false;
|
||
}
|
||
$cat = trim($a['category'] ?? '');
|
||
return $cat === '' || !in_array($cat, $privateCats, true);
|
||
}
|
||
));
|
||
$results = (new SearchEngine())->search($query, $pool);
|
||
if (!empty($results)) {
|
||
header('Location: /search?q=' . urlencode($query), true, 302);
|
||
exit;
|
||
}
|
||
}
|
||
|
||
// ─── Pages statiques depuis data/site/ ──────────────────────────────────────
|
||
function loadSitePageData(string $slug): array
|
||
{
|
||
$base = DATA_PATH . '/site';
|
||
$meta = [];
|
||
$raw = @file_get_contents($base . '/' . $slug . '.json');
|
||
if ($raw !== false) {
|
||
$meta = json_decode($raw, true) ?? [];
|
||
}
|
||
$html = '';
|
||
$mdRaw = @file_get_contents($base . '/' . $slug . '.md');
|
||
if ($mdRaw !== false) {
|
||
require_once BASE_PATH . '/src/Parsedown.php';
|
||
$html = (new Parsedown())->text($mdRaw);
|
||
}
|
||
return ['meta' => $meta, 'html' => $html];
|
||
}
|
||
|
||
// ─── Extraction de métadonnées depuis une URL ────────────────────────────────
|
||
function fetchUrlMeta(string $url): array
|
||
{
|
||
if (!filter_var($url, FILTER_VALIDATE_URL) || !preg_match('#^https?://#i', $url)) {
|
||
return ['ok' => false, 'error' => 'URL invalide'];
|
||
}
|
||
$tmpFile = tempnam(sys_get_temp_dir(), 'vl_meta_');
|
||
$fp = fopen($tmpFile, 'wb');
|
||
$downloaded = 0;
|
||
$limit = 4 * 1024 * 1024;
|
||
$contentLength = null;
|
||
$ch = curl_init($url);
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_FOLLOWLOCATION => true,
|
||
CURLOPT_MAXREDIRS => 3,
|
||
CURLOPT_CONNECTTIMEOUT => 8,
|
||
CURLOPT_TIMEOUT => 20,
|
||
CURLOPT_USERAGENT => 'Mozilla/5.0 varlog-meta/1.0',
|
||
CURLOPT_SSL_VERIFYPEER => true,
|
||
CURLOPT_ENCODING => '', // accepte gzip/deflate/br, décompresse automatiquement
|
||
CURLOPT_HEADERFUNCTION => static function ($curl, $header) use (&$contentLength): int {
|
||
if (preg_match('/^content-length:\s*(\d+)/i', $header, $m)) {
|
||
$contentLength = (int) $m[1];
|
||
}
|
||
return strlen($header);
|
||
},
|
||
CURLOPT_WRITEFUNCTION => static function ($curl, $chunk) use ($fp, &$downloaded, $limit): int {
|
||
$downloaded += strlen($chunk);
|
||
if ($downloaded > $limit) {
|
||
return -1;
|
||
}
|
||
fwrite($fp, $chunk);
|
||
return strlen($chunk);
|
||
},
|
||
]);
|
||
curl_exec($ch);
|
||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
$mimeRaw = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
||
$errno = curl_errno($ch);
|
||
curl_close($ch);
|
||
fclose($fp);
|
||
// 403 = site accessible mais protégé (Cloudflare bot-check, etc.) : on laisse passer
|
||
// avec métadonnées vides pour que l'utilisateur saisisse manuellement le titre.
|
||
if ($httpCode === 403) {
|
||
@unlink($tmpFile);
|
||
return ['ok' => true, 'mime' => 'text/html', 'blocked' => true];
|
||
}
|
||
if ($httpCode < 200 || $httpCode >= 400 || ($errno !== 0 && $errno !== 23)) {
|
||
@unlink($tmpFile);
|
||
return ['ok' => false, 'error' => "Téléchargement impossible (HTTP $httpCode)"];
|
||
}
|
||
$mime = strtok($mimeRaw ?: 'application/octet-stream', '; ');
|
||
$result = ['ok' => true, 'mime' => $mime, 'size' => $contentLength ?? $downloaded];
|
||
|
||
if (!str_starts_with($mime, 'text/html')) {
|
||
$etJson = @shell_exec('exiftool -json -charset utf8 -struct ' . escapeshellarg($tmpFile) . ' 2>/dev/null');
|
||
if ($etJson) {
|
||
$et = json_decode($etJson, true)[0] ?? [];
|
||
$etVal = static function (string ...$keys) use ($et): ?string {
|
||
foreach ($keys as $key) {
|
||
$v = $et[$key] ?? null;
|
||
if (is_array($v)) {
|
||
$v = implode(', ', array_filter(array_map('trim', $v)));
|
||
}
|
||
if (is_string($v) && trim($v) !== '') {
|
||
return trim($v);
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
if ($v = $etVal('Title', 'Headline', 'ObjectName')) {
|
||
$result['title'] = $v;
|
||
}
|
||
if ($v = $etVal('Description', 'Caption-Abstract', 'ImageDescription')) {
|
||
$result['description'] = $v;
|
||
}
|
||
if ($v = $etVal('Keywords')) {
|
||
$result['keywords'] = $v;
|
||
}
|
||
if ($v = $etVal('Copyright', 'CopyrightNotice', 'Rights')) {
|
||
$result['copyright'] = $v;
|
||
}
|
||
if ($v = $etVal('DateTimeOriginal', 'CreateDate', 'ModifyDate')) {
|
||
$result['date'] = preg_replace('/^(\d{4}):(\d{2}):(\d{2})/', '$1-$2-$3', $v);
|
||
}
|
||
if (str_starts_with($mime, 'image/')) {
|
||
if ($v = $etVal('Artist', 'Creator', 'By-line')) {
|
||
$result['author'] = $v;
|
||
}
|
||
$w = $et['ImageWidth'] ?? $et['ExifImageWidth'] ?? null;
|
||
$h = $et['ImageHeight'] ?? $et['ExifImageHeight'] ?? null;
|
||
if ($w !== null && $h !== null) {
|
||
$result['width'] = (int)$w;
|
||
$result['height'] = (int)$h;
|
||
}
|
||
$camera = trim(($et['Make'] ?? '') . ' ' . ($et['Model'] ?? ''));
|
||
if ($camera !== '') {
|
||
$result['camera'] = $camera;
|
||
}
|
||
if ($v = $etVal('Credit')) {
|
||
$result['credit'] = $v;
|
||
}
|
||
if ($v = $etVal('Source')) {
|
||
$result['source'] = $v;
|
||
}
|
||
}
|
||
if ($mime === 'application/pdf') {
|
||
if ($v = $etVal('Author')) {
|
||
$result['author'] = $v;
|
||
}
|
||
if ($v = $etVal('Subject')) {
|
||
$result['subject'] = $v;
|
||
}
|
||
if ($v = $etVal('Creator', 'CreatorTool')) {
|
||
$result['creator'] = $v;
|
||
}
|
||
if ($v = $etVal('Producer')) {
|
||
$result['producer'] = $v;
|
||
}
|
||
if (isset($et['PageCount'])) {
|
||
$result['pages'] = (int) $et['PageCount'];
|
||
}
|
||
if (isset($et['PDFVersion'])) {
|
||
$result['pdf_version'] = 'PDF ' . $et['PDFVersion'];
|
||
}
|
||
$fhPdf = fopen($tmpFile, 'rb');
|
||
$pdfHead = fread($fhPdf, min(filesize($tmpFile), 65536));
|
||
fclose($fhPdf);
|
||
if (preg_match('/\/MediaBox\s*\[\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\]/', $pdfHead, $mb)) {
|
||
$wPt = (float)$mb[3] - (float)$mb[1];
|
||
$hPt = (float)$mb[4] - (float)$mb[2];
|
||
$wMm = (int) round($wPt * 25.4 / 72);
|
||
$hMm = (int) round($hPt * 25.4 / 72);
|
||
$landscape = $wMm > $hMm;
|
||
if ($landscape) {
|
||
[$wMm, $hMm] = [$hMm, $wMm];
|
||
}
|
||
$paperSizes = ['A0' => [841,1189],'A1' => [594,841],'A2' => [420,594],
|
||
'A3' => [297,420],'A4' => [210,297],'A5' => [148,210],
|
||
'Letter' => [216,279],'Legal' => [216,356]];
|
||
$paperName = null;
|
||
foreach ($paperSizes as $pname => [$pw, $ph]) {
|
||
if (abs($wMm - $pw) <= 2 && abs($hMm - $ph) <= 2) {
|
||
$paperName = $pname;
|
||
break;
|
||
}
|
||
}
|
||
$label = $paperName ? $paperName . ($landscape ? ' paysage' : '') : ($landscape ? 'Paysage' : 'Portrait');
|
||
$result['page_size'] = "$label ({$wMm}×{$hMm} mm)";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (str_starts_with($mime, 'text/html')) {
|
||
try {
|
||
$fhHtml = fopen($tmpFile, 'rb');
|
||
$html = fread($fhHtml, min(filesize($tmpFile), 65536));
|
||
fclose($fhHtml);
|
||
|
||
// Détection du charset : 1) en-tête HTTP, 2) <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();
|
||
|
||
$step = max(1, min(5, (int)($_GET['step'] ?? 1)));
|
||
$totalSteps = 5;
|
||
$mode = 'create';
|
||
$errors = [];
|
||
|
||
// UUID depuis l'URL ou la session
|
||
if ($uuid === '') {
|
||
$uuid = $_SESSION['wizard_create'] ?? '';
|
||
}
|
||
$draft = $uuid !== '' ? $articles->getByUuid($uuid) : null;
|
||
|
||
// Si session pointe vers un UUID inexistant, on repart de zéro
|
||
if ($draft === null && $uuid !== '') {
|
||
unset($_SESSION['wizard_create']);
|
||
$uuid = '';
|
||
}
|
||
|
||
switch ($step) {
|
||
|
||
case 1:
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$content = str_replace("\r\n", "\n", $_POST['content'] ?? '');
|
||
$title = extractMarkdownTitle($content) ?: ($draft['title'] ?? 'Sans titre');
|
||
$postSlug = trim($_POST['slug'] ?? '');
|
||
if ($draft === null) {
|
||
$uuid = $articles->create($title, $content, false, $postSlug, date('Y-m-d H:i:s'), currentUserEmail() ?? '', '', '', '', '', []);
|
||
foreach ($_FILES['files']['tmp_name'] ?? [] as $_fi => $_tmpName) {
|
||
if ($_FILES['files']['error'][$_fi] === UPLOAD_ERR_OK) {
|
||
$articles->addFile($uuid, ['name' => $_FILES['files']['name'][$_fi], 'tmp_name' => $_tmpName, 'error' => UPLOAD_ERR_OK]);
|
||
}
|
||
}
|
||
$_SESSION['wizard_create'] = $uuid;
|
||
} else {
|
||
$articles->autosave($uuid, $title, $content, $postSlug);
|
||
}
|
||
header('Location: /new/' . rawurlencode($uuid) . '/2');
|
||
exit;
|
||
}
|
||
$title = $draft['title'] ?? '';
|
||
$content = $draft['content'] ?? '';
|
||
$postSlug = $draft['slug'] ?? '';
|
||
$existingFiles = $uuid !== '' ? $articles->getFiles($uuid) : [];
|
||
$article = $draft;
|
||
$insertUrl = '';
|
||
$formAction = $uuid !== '' ? '/new/' . rawurlencode($uuid) . '/1' : '/new';
|
||
include BASE_PATH . '/templates/wizard/step1.php';
|
||
break;
|
||
|
||
case 2:
|
||
if ($draft === null) {
|
||
header('Location: /new');
|
||
exit;
|
||
}
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$articles->updatePartialMeta($uuid, [
|
||
'published' => isset($_POST['published']) && $_POST['published'] !== '',
|
||
'published_at' => str_replace('T', ' ', $_POST['published_at'] ?? date('Y-m-d H:i:s')),
|
||
]);
|
||
header('Location: /new/' . rawurlencode($uuid) . '/3');
|
||
exit;
|
||
}
|
||
$published = (bool)($draft['published'] ?? false);
|
||
$published_at = $draft['published_at'] ?? date('Y-m-d H:i:s');
|
||
include BASE_PATH . '/templates/wizard/step2.php';
|
||
break;
|
||
|
||
case 3:
|
||
if ($draft === null) {
|
||
header('Location: /new');
|
||
exit;
|
||
}
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$articles->updatePartialMeta($uuid, ['category' => trim($_POST['category'] ?? '')]);
|
||
header('Location: /new/' . rawurlencode($uuid) . '/4');
|
||
exit;
|
||
}
|
||
$category = $draft['category'] ?? '';
|
||
$allCategories = $articles->getCategories();
|
||
$privateCats = $articles->getPrivateCategories();
|
||
include BASE_PATH . '/templates/wizard/step3.php';
|
||
break;
|
||
|
||
case 4:
|
||
if ($draft === null) {
|
||
header('Location: /new');
|
||
exit;
|
||
}
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$_vals = array_values(array_filter(array_map('trim', explode(',', (string)($_POST['tags_flat'] ?? ''))), fn ($_v) => $_v !== ''));
|
||
$articles->updatePartialMeta($uuid, ['tags' => $_vals !== [] ? ['tags' => $_vals] : []]);
|
||
header('Location: /new/' . rawurlencode($uuid) . '/5');
|
||
exit;
|
||
}
|
||
$_tagTypes = $articles->getTagTypes();
|
||
$flatTagValues = [];
|
||
foreach ($_tagTypes as $_tk => $_) {
|
||
foreach ($articles->getAllTagValues($_tk) as $_v) {
|
||
$flatTagValues[$_v] = true;
|
||
}
|
||
}
|
||
foreach ($articles->getAllTagValues('tags') as $_v) {
|
||
$flatTagValues[$_v] = true;
|
||
}
|
||
ksort($flatTagValues);
|
||
$flatTagValues = array_keys($flatTagValues);
|
||
$flatArticleTags = [];
|
||
foreach (($draft['tags'] ?? []) as $_tagVals) {
|
||
foreach ((array)$_tagVals as $_v) {
|
||
if (!in_array($_v, $flatArticleTags, true)) {
|
||
$flatArticleTags[] = $_v;
|
||
}
|
||
}
|
||
}
|
||
$draftContent = (string)($draft['content'] ?? '');
|
||
require_once BASE_PATH . '/src/TagSuggester.php';
|
||
include BASE_PATH . '/templates/wizard/step4.php';
|
||
break;
|
||
|
||
case 5:
|
||
if ($draft === null) {
|
||
header('Location: /new');
|
||
exit;
|
||
}
|
||
require_once BASE_PATH . '/src/Parsedown.php';
|
||
$_pd = new Parsedown();
|
||
$autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draft['content'] ?? ''))))), 0, 155, '…');
|
||
unset($_pd);
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$seoTitle = trim($_POST['seo_title'] ?? '');
|
||
$seoDesc = trim($_POST['seo_description'] ?? '') ?: $autoSeoDesc;
|
||
$coverFile = trim($_POST['cover_file'] ?? '') ?: ($draft['cover'] ?? '');
|
||
$ogImage = $coverFile !== ''
|
||
? rtrim(APP_URL, '/') . '/file?uuid=' . rawurlencode($uuid) . '&name=' . rawurlencode($coverFile)
|
||
: ($draft['og_image'] ?? '');
|
||
$articles->update($uuid, $draft['title'], $draft['content'], $draft['published'], $draft['slug'] ?? '', $draft['published_at'] ?? '', 'Création', $seoTitle, $seoDesc, $ogImage, $draft['category'] ?? '', $draft['tags'] ?? []);
|
||
if ($coverFile !== '' && $coverFile !== ($draft['cover'] ?? '')) {
|
||
$articles->setCover($uuid, $coverFile);
|
||
}
|
||
unset($_SESSION['wizard_create']);
|
||
$final = $articles->getByUuid($uuid);
|
||
header('Location: /post/' . rawurlencode($final['slug'] ?? $uuid));
|
||
exit;
|
||
}
|
||
$title = $draft['title'] ?? '';
|
||
$seoTitle = $draft['seo_title'] ?? '';
|
||
$seoDescription = $draft['seo_description'] ?? '';
|
||
$existingFiles = $articles->getFiles($uuid);
|
||
$category = $draft['category'] ?? '';
|
||
$published = (bool)($draft['published'] ?? false);
|
||
$published_at = $draft['published_at'] ?? '';
|
||
$postSlug = $draft['slug'] ?? '';
|
||
$article = $draft;
|
||
include BASE_PATH . '/templates/wizard/step5.php';
|
||
break;
|
||
}
|
||
break;
|
||
|
||
case 'view':
|
||
$article = $slug !== '' ? $articles->getBySlug($slug) : null;
|
||
if (!$article) {
|
||
searchAndRedirect($slug, $articles);
|
||
http_response_code(404);
|
||
echo 'Article introuvable.';
|
||
exit;
|
||
}
|
||
|
||
if (!$article['published']) {
|
||
if (!canDoOnArticle('view_drafts', $article)) {
|
||
http_response_code(404);
|
||
echo 'Article introuvable.';
|
||
exit;
|
||
}
|
||
}
|
||
|
||
// Avant-première : publié mais date future → réservé aux utilisateurs avec view_previews
|
||
if ($article['published'] && strtotime((string)($article['published_at'] ?? '')) > time()) {
|
||
if (!hasCapability('view_previews')) {
|
||
http_response_code(404);
|
||
echo 'Article introuvable.';
|
||
exit;
|
||
}
|
||
}
|
||
|
||
// Catégorie privée → réservé aux connectés
|
||
$allCats = $articles->getCategories();
|
||
$privateCats = $articles->getPrivateCategories();
|
||
$articleCat = trim($article['category'] ?? '');
|
||
$isPrivateCat = $articleCat !== '' && in_array($articleCat, $privateCats, true);
|
||
if ($isPrivateCat && !isLoggedIn()) {
|
||
http_response_code(404);
|
||
echo 'Article introuvable.';
|
||
exit;
|
||
}
|
||
|
||
$files = $articles->getFiles($article['uuid']);
|
||
|
||
// Résout les chemins de fichiers relatifs dans le contenu
|
||
$rawContent = $articles->resolveFileUrls($article['uuid'], $article['content']);
|
||
|
||
// Ratings
|
||
$ratingStats = ['avg' => null, 'count' => 0];
|
||
$userRating = null;
|
||
$pdo = dbPdo();
|
||
if ($pdo) {
|
||
require_once BASE_PATH . '/src/RatingManager.php';
|
||
$ratingMgr = new RatingManager($pdo);
|
||
$ratingStats = $ratingMgr->statsForArticle($article['uuid']);
|
||
if (isLoggedIn()) {
|
||
$userRating = $ratingMgr->userRating($article['uuid'], currentUserEmail() ?? '');
|
||
}
|
||
}
|
||
|
||
// Tous les articles publiés — depuis le search_index (1 fichier) si dispo, sinon getAll()
|
||
$_si = $articles->getSearchIndex();
|
||
$_allPublished = $_si !== null
|
||
? array_values(array_filter($_si, fn ($a) => (bool)($a['published'] ?? false)))
|
||
: $articles->getAll(true);
|
||
unset($_si);
|
||
|
||
// Articles liés (même catégorie)
|
||
$relatedArticles = [];
|
||
if ($articleCat !== '') {
|
||
foreach ($_allPublished as $a) {
|
||
if ($a['uuid'] === $article['uuid']) {
|
||
continue;
|
||
}
|
||
if (trim($a['category'] ?? '') !== $articleCat) {
|
||
continue;
|
||
}
|
||
if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) {
|
||
continue;
|
||
}
|
||
$relatedArticles[] = $a;
|
||
if (count($relatedArticles) >= 5) {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Articles proches par titre → OR implicite, cumul des scores
|
||
require_once BASE_PATH . '/src/SearchEngine.php';
|
||
$_simEngine = new SearchEngine();
|
||
$_stopWords = ['avec', 'dans', 'pour', 'une', 'les', 'des', 'sur', 'par', 'qui', 'que',
|
||
'tout', 'mais', 'donc', 'comment', 'quand', 'plus', 'cette', 'cet', 'ces',
|
||
'mon', 'ton', 'son', 'notre', 'votre', 'leur', 'tres', 'bien', 'fait',
|
||
'aussi', 'comme', 'sans', 'sous', 'entre', 'vers', 'chez'];
|
||
$_simPool = array_values(array_filter(
|
||
$_allPublished,
|
||
static function (array $a) use ($article, $privateCats): bool {
|
||
if ($a['uuid'] === $article['uuid']) {
|
||
return false;
|
||
}
|
||
if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) {
|
||
return false;
|
||
}
|
||
$cat = trim($a['category'] ?? '');
|
||
return $cat === '' || !in_array($cat, $privateCats, true) || isLoggedIn();
|
||
}
|
||
));
|
||
$_titleWords = array_unique(array_values(array_filter(
|
||
preg_split('/\W+/u', mb_strtolower($article['title']), -1, PREG_SPLIT_NO_EMPTY) ?: [],
|
||
fn ($w) => mb_strlen($w) >= 4 && !in_array($w, $_stopWords, true)
|
||
)));
|
||
[$_scoreMap, $_articleMap] = $_simEngine->scorePool($_titleWords, $_simPool);
|
||
arsort($_scoreMap);
|
||
$_similarArticles = array_map(
|
||
fn ($uuid) => $_articleMap[$uuid],
|
||
array_slice(array_keys($_scoreMap), 0, 5)
|
||
);
|
||
unset($_simEngine, $_simPool, $_titleWords, $_stopWords, $_scoreMap, $_articleMap);
|
||
|
||
// "À lire aussi" : similaires en premier, même catégorie pour compléter jusqu'à 5
|
||
$alsoReadArticles = $_similarArticles;
|
||
if (count($alsoReadArticles) < 5) {
|
||
$_used = array_column($alsoReadArticles, 'uuid');
|
||
foreach ($relatedArticles as $_ra) {
|
||
if (count($alsoReadArticles) >= 5) {
|
||
break;
|
||
}
|
||
if (!in_array($_ra['uuid'], $_used, true)) {
|
||
$alsoReadArticles[] = $_ra;
|
||
}
|
||
}
|
||
unset($_used);
|
||
}
|
||
unset($_similarArticles);
|
||
|
||
unset($_allPublished);
|
||
|
||
$backlinks = $articles->getBacklinks($article['slug'] ?? '', $article['uuid']);
|
||
|
||
// Réactions et commentaires
|
||
require_once BASE_PATH . '/src/ReactionManager.php';
|
||
require_once BASE_PATH . '/src/CommentManager.php';
|
||
$reactionStats = array_fill_keys(ReactionManager::TYPES, 0);
|
||
$visitorReactions = [];
|
||
$comments = [];
|
||
$commentFlash = isset($_GET['commented']);
|
||
$commentVerified = isset($_GET['verified']);
|
||
$commentError = null;
|
||
if ($pdo) {
|
||
$reactionMgr = new ReactionManager($pdo);
|
||
$commentMgr = new CommentManager($pdo);
|
||
|
||
// Cookie visiteur (fingerprint anti-doublon)
|
||
if (empty($_COOKIE['vl_vid'])) {
|
||
$vid = bin2hex(random_bytes(16));
|
||
setcookie('vl_vid', $vid, [
|
||
'expires' => time() + 365 * 86400,
|
||
'path' => '/',
|
||
'secure' => !empty($_SERVER['HTTPS']),
|
||
'httponly' => true,
|
||
'samesite' => 'Lax',
|
||
]);
|
||
} else {
|
||
$vid = $_COOKIE['vl_vid'];
|
||
}
|
||
|
||
$reactionStats = $reactionMgr->statsForArticle($article['uuid']);
|
||
$visitorReactions = $reactionMgr->visitorReactions($article['uuid'], $vid);
|
||
$comments = $commentMgr->forArticle($article['uuid']);
|
||
}
|
||
|
||
// Contexte livre (navigation précédent/suivant si l'article fait partie d'un livre)
|
||
$bookContext = $books->findForArticle($article['slug'] ?? '');
|
||
if ($bookContext !== null) {
|
||
$bookContext['prev_article'] = $bookContext['prev'] !== null ? $articles->getBySlug($bookContext['prev']) : null;
|
||
$bookContext['next_article'] = $bookContext['next'] !== null ? $articles->getBySlug($bookContext['next']) : null;
|
||
}
|
||
|
||
include BASE_PATH . '/templates/post_view.php';
|
||
break;
|
||
|
||
case 'edit':
|
||
requireAuth();
|
||
|
||
$article = $articles->getByUuid($uuid);
|
||
if (!$article) {
|
||
http_response_code(404);
|
||
echo 'Article introuvable.';
|
||
exit;
|
||
}
|
||
if (!canDoOnArticle('edit_articles', $article)) {
|
||
http_response_code(403);
|
||
echo 'Accès refusé.';
|
||
exit;
|
||
}
|
||
|
||
// Toggle featured (admin only) — conservé depuis l'ancienne version
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['_toggle_featured'])) {
|
||
if (!isAdmin()) {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$articles->setFeatured($uuid, !((bool)($article['featured'] ?? false)));
|
||
header('Location: /edit/' . rawurlencode($uuid) . '/1');
|
||
exit;
|
||
}
|
||
|
||
$step = (int)($_GET['step'] ?? 0);
|
||
$totalSteps = 6;
|
||
$mode = 'edit';
|
||
$errors = [];
|
||
|
||
// Sans step : rediriger vers l'étape 1
|
||
if ($step === 0) {
|
||
header('Location: /edit/' . rawurlencode($uuid) . '/1');
|
||
exit;
|
||
}
|
||
|
||
// Base de travail : draft overlay s'il existe, sinon article original
|
||
$draft = $articles->getDraftOverlay($uuid) ?? $article;
|
||
|
||
switch ($step) {
|
||
|
||
case 1:
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$content = str_replace("\r\n", "\n", $_POST['content'] ?? '');
|
||
$title = extractMarkdownTitle($content) ?: ($draft['title'] ?? 'Sans titre');
|
||
$articles->saveDraftOverlay($uuid, ['title' => $title, 'slug' => trim($_POST['slug'] ?? $draft['slug'] ?? '')], $content);
|
||
header('Location: /edit/' . rawurlencode($uuid) . '/2');
|
||
exit;
|
||
}
|
||
$title = $draft['title'];
|
||
$content = $draft['content'];
|
||
$postSlug = $draft['slug'];
|
||
$existingFiles = $articles->getFiles($uuid);
|
||
$insertUrl = '';
|
||
if (isset($_GET['insert_url']) && filter_var($_GET['insert_url'], FILTER_VALIDATE_URL)) {
|
||
$insertUrl = $_GET['insert_url'];
|
||
}
|
||
$formAction = '/edit/' . rawurlencode($uuid) . '/1';
|
||
include BASE_PATH . '/templates/wizard/step1.php';
|
||
break;
|
||
|
||
case 2:
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$articles->saveDraftOverlay($uuid, [
|
||
'published' => isset($_POST['published']) && $_POST['published'] !== '',
|
||
'published_at' => str_replace('T', ' ', $_POST['published_at'] ?? ($draft['published_at'] ?? '')),
|
||
]);
|
||
header('Location: /edit/' . rawurlencode($uuid) . '/3');
|
||
exit;
|
||
}
|
||
$published = (bool)($draft['published'] ?? false);
|
||
$published_at = $draft['published_at'] ?? '';
|
||
include BASE_PATH . '/templates/wizard/step2.php';
|
||
break;
|
||
|
||
case 3:
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$articles->saveDraftOverlay($uuid, ['category' => trim($_POST['category'] ?? '')]);
|
||
header('Location: /edit/' . rawurlencode($uuid) . '/4');
|
||
exit;
|
||
}
|
||
$category = $draft['category'] ?? '';
|
||
$allCategories = $articles->getCategories();
|
||
$privateCats = $articles->getPrivateCategories();
|
||
include BASE_PATH . '/templates/wizard/step3.php';
|
||
break;
|
||
|
||
case 4:
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$_vals = array_values(array_filter(array_map('trim', explode(',', (string)($_POST['tags_flat'] ?? ''))), fn ($_v) => $_v !== ''));
|
||
$articles->saveDraftOverlay($uuid, ['tags' => $_vals !== [] ? ['tags' => $_vals] : []]);
|
||
header('Location: /edit/' . rawurlencode($uuid) . '/5');
|
||
exit;
|
||
}
|
||
$_tagTypes = $articles->getTagTypes();
|
||
$flatTagValues = [];
|
||
foreach ($_tagTypes as $_tk => $_) {
|
||
foreach ($articles->getAllTagValues($_tk) as $_v) {
|
||
$flatTagValues[$_v] = true;
|
||
}
|
||
}
|
||
foreach ($articles->getAllTagValues('tags') as $_v) {
|
||
$flatTagValues[$_v] = true;
|
||
}
|
||
ksort($flatTagValues);
|
||
$flatTagValues = array_keys($flatTagValues);
|
||
$flatArticleTags = [];
|
||
foreach (($draft['tags'] ?? []) as $_tagVals) {
|
||
foreach ((array)$_tagVals as $_v) {
|
||
if (!in_array($_v, $flatArticleTags, true)) {
|
||
$flatArticleTags[] = $_v;
|
||
}
|
||
}
|
||
}
|
||
$draftContent = (string)($draft['content'] ?? '');
|
||
require_once BASE_PATH . '/src/TagSuggester.php';
|
||
include BASE_PATH . '/templates/wizard/step4.php';
|
||
break;
|
||
|
||
case 5:
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$articles->saveDraftOverlay($uuid, [
|
||
'seo_title' => trim($_POST['seo_title'] ?? ''),
|
||
'seo_description' => trim($_POST['seo_description'] ?? ''),
|
||
]);
|
||
header('Location: /edit/' . rawurlencode($uuid) . '/6');
|
||
exit;
|
||
}
|
||
require_once BASE_PATH . '/src/Parsedown.php';
|
||
$_pd = new Parsedown();
|
||
$autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draft['content'] ?? ''))))), 0, 155, '…');
|
||
unset($_pd);
|
||
$title = $draft['title'];
|
||
$seoTitle = $draft['seo_title'] ?? '';
|
||
$seoDescription = $draft['seo_description'] ?? '';
|
||
$postSlug = $draft['slug'];
|
||
$published = (bool)($draft['published'] ?? false);
|
||
$published_at = $draft['published_at'] ?? '';
|
||
$category = $draft['category'] ?? '';
|
||
include BASE_PATH . '/templates/wizard/step5.php';
|
||
break;
|
||
|
||
case 6:
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['_confirm'])) {
|
||
$revisionComment = trim($_POST['revision_comment'] ?? '');
|
||
// Si le slug a été modifié dans le formulaire de confirmation, le propager
|
||
if (!empty($_POST['slug'])) {
|
||
$articles->saveDraftOverlay($uuid, ['slug' => trim($_POST['slug'])]);
|
||
}
|
||
$articles->commitDraftOverlay($uuid, $revisionComment);
|
||
$final = $articles->getByUuid($uuid);
|
||
header('Location: /post/' . rawurlencode($final['slug'] ?? $uuid));
|
||
exit;
|
||
}
|
||
$draftData = $articles->getDraftOverlay($uuid) ?? $article;
|
||
require_once BASE_PATH . '/src/Parsedown.php';
|
||
$_pd = new Parsedown();
|
||
$autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draftData['content'] ?? ''))))), 0, 155, '…');
|
||
unset($_pd);
|
||
$diffLines = lineDiff((string)($article['content'] ?? ''), (string)($draftData['content'] ?? ''));
|
||
$titleChanged = ($draftData['title'] ?? '') !== ($article['title'] ?? '');
|
||
$autoSlug = slugify($draftData['title'] ?? '');
|
||
$postSlug = $draftData['slug'] ?? $article['slug'];
|
||
$changes = [];
|
||
if ($titleChanged) {
|
||
$changes[] = 'titre modifié';
|
||
}
|
||
if (($draftData['category'] ?? '') !== ($article['category'] ?? '')) {
|
||
$changes[] = 'catégorie modifiée';
|
||
}
|
||
if (($draftData['tags'] ?? []) !== ($article['tags'] ?? [])) {
|
||
$changes[] = 'tags modifiés';
|
||
}
|
||
if (($draftData['content'] ?? '') !== ($article['content'] ?? '')) {
|
||
$changes[] = 'contenu modifié';
|
||
}
|
||
if ((bool)($draftData['published'] ?? false) !== (bool)($article['published'] ?? false)) {
|
||
$changes[] = ($draftData['published'] ?? false) ? 'article publié' : 'article dépublié';
|
||
}
|
||
$autoRevisionComment = !empty($changes) ? ucfirst(implode(', ', $changes)) : '';
|
||
$title = $draftData['title'] ?? '';
|
||
$seoTitle = $draftData['seo_title'] ?? '';
|
||
$seoDescription = $draftData['seo_description'] ?? '';
|
||
$published = (bool)($draftData['published'] ?? false);
|
||
$published_at = $draftData['published_at'] ?? '';
|
||
$category = $draftData['category'] ?? '';
|
||
include BASE_PATH . '/templates/wizard/step6.php';
|
||
break;
|
||
}
|
||
break;
|
||
|
||
case 'edit_tags':
|
||
requireAuth();
|
||
$article = $uuid !== '' ? $articles->getByUuid($uuid) : null;
|
||
if (!$article) {
|
||
http_response_code(404);
|
||
echo 'Article introuvable.';
|
||
exit;
|
||
}
|
||
if (!canDoOnArticle('edit_articles', $article)) {
|
||
http_response_code(403);
|
||
echo 'Accès refusé.';
|
||
exit;
|
||
}
|
||
|
||
$tagType = urldecode(trim($_GET['tag_type'] ?? ''));
|
||
$isCatField = ($tagType === 'categorie' || $tagType === 'catégorie');
|
||
$tagTypes = $articles->getTagTypes();
|
||
|
||
require_once BASE_PATH . '/src/TagSuggester.php';
|
||
$suggester = new TagSuggester();
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
if ($isCatField) {
|
||
$newCat = trim($_POST['category'] ?? '');
|
||
// Met à jour uniquement la catégorie via update() en préservant tout le reste
|
||
$articles->update(
|
||
$uuid,
|
||
$article['title'],
|
||
$article['content'],
|
||
$article['published'],
|
||
$article['slug'],
|
||
$article['published_at'] ?? '',
|
||
'',
|
||
$article['seo_title'] ?? '',
|
||
$article['seo_description'] ?? '',
|
||
$article['og_image'] ?? '',
|
||
$newCat,
|
||
$article['tags'] ?? []
|
||
);
|
||
} else {
|
||
// Tags : les valeurs cochées remplacent les tags du type concerné
|
||
$selected = array_values(array_filter(array_map('trim', $_POST['selected'] ?? []), fn ($v) => $v !== ''));
|
||
$allTags = $article['tags'] ?? [];
|
||
if (empty($selected)) {
|
||
unset($allTags[$tagType]);
|
||
} else {
|
||
$allTags[$tagType] = $selected;
|
||
}
|
||
$articles->setTags($uuid, $allTags);
|
||
}
|
||
header('Location: /edit/' . rawurlencode($uuid));
|
||
exit;
|
||
}
|
||
|
||
// GET — calculer les suggestions
|
||
if ($isCatField) {
|
||
$allCats = $articles->getCategories();
|
||
$currentCat = $article['category'] ?? '';
|
||
$suggestions = $suggester->suggestCategory($article['content'], $allCats, $currentCat);
|
||
$currentTags = [];
|
||
} else {
|
||
$existingValues = $articles->getAllTagValues($tagType);
|
||
$currentTags = $article['tags'][$tagType] ?? [];
|
||
$suggestions = $suggester->suggest($article['content'], $existingValues, $currentTags);
|
||
$allCats = null;
|
||
$currentCat = null;
|
||
}
|
||
|
||
include BASE_PATH . '/templates/edit_tags.php';
|
||
break;
|
||
|
||
case 'delete_file':
|
||
requireAuth();
|
||
$fileName = basename($_POST['name'] ?? '');
|
||
if ($uuid !== '' && $fileName !== '' && $fileName[0] !== '.') {
|
||
$articles->deleteFile($uuid, $fileName);
|
||
}
|
||
header('Location: /edit/' . rawurlencode($uuid));
|
||
exit;
|
||
|
||
case 'delete':
|
||
requireAuth();
|
||
if ($uuid !== '') {
|
||
if (!$articles->delete($uuid)) {
|
||
$failedArt = $articles->getByUuid($uuid);
|
||
$failedSlug = $failedArt['slug'] ?? '';
|
||
$back = $failedSlug !== '' ? '/post/' . rawurlencode($failedSlug) : '/';
|
||
header('Location: ' . $back . '?delete_failed=1');
|
||
exit;
|
||
}
|
||
}
|
||
header('Location: /');
|
||
exit;
|
||
|
||
case 'delete_revision':
|
||
requireAuth();
|
||
if (!isAdmin()) {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
if ($uuid !== '' && isset($_POST['rev_n'])) {
|
||
$articles->deleteRevision($uuid, (int)$_POST['rev_n']);
|
||
}
|
||
header('Location: /edit/' . rawurlencode($uuid) . '#historyPanel');
|
||
exit;
|
||
|
||
case 'delete_all_revisions':
|
||
requireAuth();
|
||
if (!isAdmin()) {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
if ($uuid !== '') {
|
||
$articles->deleteAllRevisions($uuid);
|
||
}
|
||
header('Location: /edit/' . rawurlencode($uuid));
|
||
exit;
|
||
|
||
case 'categories':
|
||
header('Location: /admin/categories');
|
||
exit;
|
||
|
||
case 'create_tag_type':
|
||
requireAuth();
|
||
if (!isAdmin()) {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$typeKey = strtolower((string)preg_replace('/[^a-z0-9_]/i', '', trim($_POST['type_key'] ?? '')));
|
||
$typeLabel = trim($_POST['type_label'] ?? '');
|
||
if ($typeKey !== '' && $typeLabel !== '') {
|
||
$types = $articles->getTagTypes();
|
||
if (!isset($types[$typeKey])) {
|
||
$types[$typeKey] = $typeLabel;
|
||
$articles->saveTagTypes($types);
|
||
}
|
||
}
|
||
}
|
||
header('Location: /admin/categories');
|
||
exit;
|
||
|
||
case 'delete_tag_type':
|
||
requireAuth();
|
||
if (!isAdmin()) {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$typeKey = trim($_POST['type_key'] ?? '');
|
||
if ($typeKey !== '') {
|
||
$types = $articles->getTagTypes();
|
||
unset($types[$typeKey]);
|
||
$articles->saveTagTypes($types);
|
||
}
|
||
}
|
||
header('Location: /admin/categories');
|
||
exit;
|
||
|
||
case 'rename_category':
|
||
requireAuth();
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$old = trim($_POST['old'] ?? '');
|
||
$new = trim($_POST['new'] ?? '');
|
||
if ($old !== '' && $new !== '' && $old !== $new) {
|
||
$articles->renameCategory($old, $new);
|
||
}
|
||
}
|
||
header('Location: /admin/categories');
|
||
exit;
|
||
|
||
case 'delete_category':
|
||
requireAuth();
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$cat = trim($_POST['category'] ?? '');
|
||
if ($cat !== '') {
|
||
$articles->deleteCategory($cat);
|
||
}
|
||
}
|
||
header('Location: /admin/categories');
|
||
exit;
|
||
|
||
case 'toggle_private_category':
|
||
requireAuth();
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$cat = trim($_POST['category'] ?? '');
|
||
if ($cat !== '') {
|
||
$articles->togglePrivateCategory($cat);
|
||
}
|
||
}
|
||
header('Location: /admin/categories');
|
||
exit;
|
||
|
||
case 'author':
|
||
$authorSlug = trim($_GET['slug'] ?? '');
|
||
$authorRow = profileBySlug($authorSlug);
|
||
if (!$authorRow) {
|
||
http_response_code(404);
|
||
$content = '<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(DATA_PATH . '/_cache/feeds');
|
||
$fluxItems = [];
|
||
$pdo = dbPdo();
|
||
if ($pdo) {
|
||
try {
|
||
$st = $pdo->query(
|
||
'SELECT f.user_email, f.feed_url, f.label,
|
||
p.display_name, p.profile_slug
|
||
FROM rss_feeds f
|
||
LEFT JOIN user_profiles p ON p.email = f.user_email
|
||
ORDER BY f.created_at'
|
||
);
|
||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $_row) {
|
||
$data = $fetcher->get($_row['feed_url']);
|
||
if (!$data) {
|
||
continue;
|
||
}
|
||
$feedTitle = $_row['label'] !== '' ? $_row['label'] : $data['feed_title'];
|
||
$authorName = $_row['display_name'] ?? '';
|
||
$authorSlug = $_row['profile_slug'] ?? '';
|
||
foreach ($data['items'] as $_item) {
|
||
$fluxItems[] = array_merge($_item, [
|
||
'feed_title' => $feedTitle,
|
||
'feed_url' => $_row['feed_url'],
|
||
'author_name' => $authorName,
|
||
'author_slug' => $authorSlug,
|
||
]);
|
||
}
|
||
}
|
||
} catch (\Throwable) {
|
||
}
|
||
}
|
||
usort($fluxItems, static fn ($a, $b) => $b['date'] <=> $a['date']);
|
||
include BASE_PATH . '/templates/flux.php';
|
||
break;
|
||
|
||
case 'about':
|
||
['meta' => $siteMeta, 'html' => $siteContent] = loadSitePageData('about');
|
||
include BASE_PATH . '/templates/about.php';
|
||
break;
|
||
|
||
case 'legal':
|
||
['meta' => $siteMeta, 'html' => $siteContent] = loadSitePageData('legal');
|
||
include BASE_PATH . '/templates/legal.php';
|
||
break;
|
||
|
||
case 'contact':
|
||
include BASE_PATH . '/templates/contact.php';
|
||
break;
|
||
|
||
case 'licenses':
|
||
['meta' => $siteMeta, 'html' => $siteContent] = loadSitePageData('licenses');
|
||
include BASE_PATH . '/templates/licenses.php';
|
||
break;
|
||
|
||
case 'diff':
|
||
requireAuth();
|
||
$article = $articles->getByUuid($uuid);
|
||
if (!$article) {
|
||
http_response_code(404);
|
||
echo 'Article introuvable.';
|
||
exit;
|
||
}
|
||
$revisions = $article['revisions'] ?? [];
|
||
$revN = (int)($_GET['rev'] ?? 0);
|
||
// Trouver l'index dans le tableau par numéro de révision
|
||
$revIndex = null;
|
||
foreach ($revisions as $ri => $r) {
|
||
if ((int)($r['n'] ?? 0) === $revN) {
|
||
$revIndex = $ri;
|
||
break;
|
||
}
|
||
}
|
||
if ($revIndex === null || $revN < 1) {
|
||
header('Location: /edit/' . rawurlencode($uuid));
|
||
exit;
|
||
}
|
||
$oldContent = $articles->getRevisionContent($uuid, $revN);
|
||
if ($oldContent === null) {
|
||
http_response_code(404);
|
||
echo 'Révision introuvable.';
|
||
exit;
|
||
}
|
||
$diffLines = lineDiff($oldContent, $article['content']);
|
||
include BASE_PATH . '/templates/diff.php';
|
||
break;
|
||
|
||
case 'autosave':
|
||
requireAuth();
|
||
header('Content-Type: application/json');
|
||
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $uuid === '') {
|
||
echo json_encode(['ok' => false]);
|
||
exit;
|
||
}
|
||
$asContent = str_replace("\r\n", "\n", $_POST['content'] ?? '');
|
||
$asSlug = trim($_POST['slug'] ?? '');
|
||
$_asCurrent = $articles->getByUuid($uuid);
|
||
$asTitle = extractMarkdownTitle($asContent) ?: ($_asCurrent['title'] ?? 'Sans titre');
|
||
$ok = $articles->autosave($uuid, $asTitle, $asContent, $asSlug);
|
||
$_asSlugFinal = $ok ? ($articles->getByUuid($uuid)['slug'] ?? '') : '';
|
||
echo json_encode(['ok' => $ok, 'time' => date('H:i:s'), 'title' => $asTitle, 'slug' => $_asSlugFinal]);
|
||
exit;
|
||
|
||
case 'autosave_draft':
|
||
requireAuth();
|
||
header('Content-Type: application/json');
|
||
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $uuid === '') {
|
||
echo json_encode(['ok' => false]);
|
||
exit;
|
||
}
|
||
$_adArticle = $articles->getByUuid($uuid);
|
||
if (!$_adArticle || !canDoOnArticle('edit_articles', $_adArticle)) {
|
||
echo json_encode(['ok' => false]);
|
||
exit;
|
||
}
|
||
$_adContent = isset($_POST['content']) ? str_replace("\r\n", "\n", $_POST['content']) : null;
|
||
$_adTitle = $_adContent !== null
|
||
? (extractMarkdownTitle($_adContent) ?: ($_adArticle['title'] ?? 'Sans titre'))
|
||
: ($_adArticle['title'] ?? 'Sans titre');
|
||
$articles->saveDraftOverlay($uuid, ['title' => $_adTitle], $_adContent);
|
||
echo json_encode(['ok' => true, 'time' => date('H:i:s'), 'title' => $_adTitle]);
|
||
exit;
|
||
|
||
case 'edit_discard_draft':
|
||
requireAuth();
|
||
$_ddArticle = $articles->getByUuid($uuid);
|
||
if (!$_ddArticle || !canDoOnArticle('edit_articles', $_ddArticle)) {
|
||
http_response_code(403);
|
||
echo 'Accès refusé.';
|
||
exit;
|
||
}
|
||
$articles->discardDraftOverlay($uuid);
|
||
header('Location: /post/' . rawurlencode($_ddArticle['slug'] ?? $uuid));
|
||
exit;
|
||
|
||
case 'copy_file':
|
||
requireAuth();
|
||
header('Content-Type: application/json');
|
||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
echo json_encode(['ok' => false]);
|
||
exit;
|
||
}
|
||
$cfFrom = trim($_POST['from_uuid'] ?? '');
|
||
$cfTo = $uuid !== '' ? $uuid : trim($_POST['to_uuid'] ?? '');
|
||
$cfName = basename($_POST['name'] ?? '');
|
||
if (!preg_match('/^[0-9a-f-]{36}$/', $cfFrom)
|
||
|| !preg_match('/^[0-9a-f-]{36}$/', $cfTo)
|
||
|| $cfName === ''
|
||
|| str_starts_with($cfName, '.')) {
|
||
echo json_encode(['ok' => false, 'error' => 'Paramètres invalides']);
|
||
exit;
|
||
}
|
||
$cfSrc = DATA_PATH . '/' . $cfFrom . '/files/' . $cfName;
|
||
$cfDstDir = DATA_PATH . '/' . $cfTo . '/files';
|
||
$cfDst = $cfDstDir . '/' . $cfName;
|
||
if (!file_exists($cfSrc)) {
|
||
echo json_encode(['ok' => false, 'error' => 'Fichier source introuvable']);
|
||
exit;
|
||
}
|
||
if (!is_dir($cfDstDir)) {
|
||
mkdir($cfDstDir, 0775, true);
|
||
}
|
||
echo json_encode(['ok' => copy($cfSrc, $cfDst)]);
|
||
exit;
|
||
|
||
case 'add_files':
|
||
requireAuth();
|
||
$addFilesArticle = $articles->getByUuid($uuid);
|
||
if (!$addFilesArticle) {
|
||
http_response_code(404);
|
||
echo 'Article introuvable.';
|
||
exit;
|
||
}
|
||
$addFilesError = null;
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
// Quand post_max_size est dépassé, PHP vide $_FILES et $_POST silencieusement
|
||
if ((int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0 && empty($_FILES)) {
|
||
$addFilesError = sprintf('Fichier trop lourd — limite serveur : %s.', ini_get('post_max_size'));
|
||
} else {
|
||
foreach ($_FILES['files']['tmp_name'] ?? [] as $i => $tmpName) {
|
||
if ($_FILES['files']['error'][$i] === UPLOAD_ERR_OK) {
|
||
$articles->addFile($uuid, [
|
||
'name' => $_FILES['files']['name'][$i],
|
||
'tmp_name' => $tmpName,
|
||
'error' => $_FILES['files']['error'][$i],
|
||
]);
|
||
}
|
||
}
|
||
header('Location: /edit/' . rawurlencode($uuid));
|
||
exit;
|
||
}
|
||
}
|
||
include BASE_PATH . '/templates/add_files.php';
|
||
break;
|
||
|
||
case 'import_image':
|
||
requireAuth();
|
||
$importArticle = $articles->getByUuid($uuid);
|
||
if (!$importArticle) {
|
||
http_response_code(404);
|
||
echo 'Article introuvable.';
|
||
exit;
|
||
}
|
||
$importError = $_GET['error'] ?? '';
|
||
include BASE_PATH . '/templates/import_image.php';
|
||
break;
|
||
|
||
case 'fetch_file_meta':
|
||
requireAuth();
|
||
header('Content-Type: application/json');
|
||
echo json_encode(fetchUrlMeta(trim($_GET['url'] ?? '')));
|
||
exit;
|
||
|
||
case 'import_image_step2':
|
||
requireAuth();
|
||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
header('Location: /import/' . rawurlencode($uuid));
|
||
exit;
|
||
}
|
||
$step2Article = $articles->getByUuid($uuid);
|
||
if (!$step2Article) {
|
||
http_response_code(404);
|
||
echo 'Article introuvable.';
|
||
exit;
|
||
}
|
||
$step2Url = trim($_POST['image_url'] ?? '');
|
||
if (!filter_var($step2Url, FILTER_VALIDATE_URL) || !preg_match('#^https?://#i', $step2Url)) {
|
||
header('Location: /import/' . rawurlencode($uuid) . '?error=1');
|
||
exit;
|
||
}
|
||
// Détection URL interne (même hostname que APP_URL → lecture directe sans cURL)
|
||
$step2Meta = null;
|
||
$step2IsInternal = false;
|
||
$_iHost = parse_url(APP_URL, PHP_URL_HOST) ?? '';
|
||
$_uHost = parse_url($step2Url, PHP_URL_HOST) ?? '';
|
||
$_uPath = parse_url($step2Url, PHP_URL_PATH) ?? '';
|
||
if ($_iHost !== '' && $_uHost === $_iHost && preg_match('#^/post/([a-z0-9][a-z0-9-]*)/?$#', $_uPath, $_sm)) {
|
||
$_ia = $articles->getBySlug($_sm[1]);
|
||
if ($_ia) {
|
||
$step2IsInternal = true;
|
||
$step2Meta = ['ok' => true, 'title' => $_ia['title'] ?? '', 'mime' => 'text/html'];
|
||
if (!empty($_ia['seo_description'])) {
|
||
$step2Meta['description'] = $_ia['seo_description'];
|
||
} elseif (!empty($_ia['content'])) {
|
||
require_once BASE_PATH . '/src/Parsedown.php';
|
||
$_plain = strip_tags((new Parsedown())->text($_ia['content']));
|
||
$step2Meta['description'] = mb_strimwidth(trim(preg_replace('/\s+/', ' ', $_plain)), 0, 155, '…');
|
||
unset($_plain);
|
||
}
|
||
if (!empty($_ia['cover'])) {
|
||
$step2Meta['og_image'] = '/file?uuid=' . rawurlencode($_ia['uuid']) . '&name=' . rawurlencode($_ia['cover']);
|
||
}
|
||
}
|
||
unset($_ia);
|
||
}
|
||
unset($_iHost, $_uHost, $_uPath, $_sm);
|
||
if ($step2Meta === null) {
|
||
$step2Meta = fetchUrlMeta($step2Url);
|
||
}
|
||
if (!($step2Meta['ok'] ?? false)) {
|
||
header('Location: /import/' . rawurlencode($uuid) . '?error=1');
|
||
exit;
|
||
}
|
||
// Capture d'écran pour prévisualisation (pages HTML uniquement, URL externes uniquement)
|
||
$step2Screenshot = null;
|
||
if (!$step2IsInternal && str_starts_with($step2Meta['mime'] ?? '', 'text/html')) {
|
||
$filesDir = DATA_PATH . '/' . $uuid . '/files';
|
||
if (!is_dir($filesDir)) {
|
||
mkdir($filesDir, 0755, true);
|
||
}
|
||
$previewPath = $filesDir . '/_preview.png';
|
||
@unlink($previewPath); // supprime le résidu d'une analyse précédente
|
||
if (takeScreenshot($step2Url, $previewPath)) {
|
||
$step2Screenshot = '_preview.png';
|
||
}
|
||
}
|
||
include BASE_PATH . '/templates/import_image_step2.php';
|
||
break;
|
||
|
||
case 'copyright_ack':
|
||
requireAuth();
|
||
$ackArticle = $articles->getByUuid($uuid);
|
||
if (!$ackArticle) {
|
||
http_response_code(404);
|
||
echo 'Article introuvable.';
|
||
exit;
|
||
}
|
||
$ackUrl = filter_var($_GET['image_url'] ?? '', FILTER_VALIDATE_URL)
|
||
? $_GET['image_url'] : '';
|
||
if ($ackUrl === '') {
|
||
header('Location: /import/' . rawurlencode($uuid));
|
||
exit;
|
||
}
|
||
$ackTitle = $_GET['img_title'] ?? '';
|
||
$ackAuthor = $_GET['img_author'] ?? '';
|
||
$ackSource = $_GET['img_source'] ?? '';
|
||
$ackIsCover = !empty($_GET['is_cover']);
|
||
$ackMetaJson = $_GET['meta_json'] ?? '';
|
||
include BASE_PATH . '/templates/copyright_ack.php';
|
||
break;
|
||
|
||
case 'add_file_from_url':
|
||
requireAuth();
|
||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
header('Location: /');
|
||
exit;
|
||
}
|
||
$urlUuid = $_GET['uuid'] ?? $_POST['uuid'] ?? '';
|
||
$imageUrl = trim($_POST['image_url'] ?? '');
|
||
$mode = $_POST['mode'] ?? 'link';
|
||
$isCover = isset($_POST['is_cover']);
|
||
$imgTitle = trim($_POST['img_title'] ?? '');
|
||
$imgAuthor = trim($_POST['img_author'] ?? '');
|
||
$imgSource = trim($_POST['img_source'] ?? '') ?: $imageUrl;
|
||
|
||
// Métadonnées supplémentaires (passées depuis l'étape 2)
|
||
$importedMeta = [];
|
||
$rawMetaJson = $_POST['meta_json'] ?? '';
|
||
if ($rawMetaJson !== '') {
|
||
$dec = @json_decode($rawMetaJson, true);
|
||
if (is_array($dec)) {
|
||
foreach ($dec as $k => $v) {
|
||
if (is_string($k) && strlen($k) <= 60 && is_scalar($v)) {
|
||
$importedMeta[$k] = $v;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
$urlArticle = $articles->getByUuid($urlUuid);
|
||
if (!$urlArticle || $imageUrl === '' || !filter_var($imageUrl, FILTER_VALIDATE_URL)) {
|
||
header('Location: /import/' . rawurlencode($urlUuid));
|
||
exit;
|
||
}
|
||
|
||
$screenshotFile = basename(trim($_POST['screenshot_file'] ?? ''));
|
||
|
||
if ($mode === 'screenshot') {
|
||
if ($screenshotFile === '' || $screenshotFile !== '_preview.png') {
|
||
header('Location: /import/' . rawurlencode($urlUuid) . '?error=1');
|
||
exit;
|
||
}
|
||
$filesDir = DATA_PATH . '/' . $urlUuid . '/files';
|
||
$previewPath = $filesDir . '/' . $screenshotFile;
|
||
if (!file_exists($previewPath)) {
|
||
header('Location: /import/' . rawurlencode($urlUuid) . '?error=1');
|
||
exit;
|
||
}
|
||
$hash = substr(hash_file('sha256', $previewPath), 0, 16);
|
||
$size = filesize($previewPath);
|
||
$destName = $hash . '-' . $size . '.png';
|
||
rename($previewPath, $filesDir . '/' . $destName);
|
||
$articles->addFileMeta($urlUuid, $destName, $imgAuthor, $imgSource, $imgTitle, $importedMeta);
|
||
if ($isCover) {
|
||
$articles->setCover($urlUuid, $destName);
|
||
}
|
||
header('Location: /edit/' . rawurlencode($urlUuid));
|
||
exit;
|
||
}
|
||
|
||
if ($mode === 'link') {
|
||
$filesDir = DATA_PATH . '/' . $urlUuid . '/files';
|
||
if (!is_dir($filesDir)) {
|
||
mkdir($filesDir, 0755, true);
|
||
}
|
||
$linkMime = $importedMeta['mime'] ?? '';
|
||
$isHtmlLink = str_starts_with($linkMime, 'text/html') || $linkMime === '';
|
||
if ($isHtmlLink) {
|
||
$thumbSet = false;
|
||
// 1. Télécharge l'og:image distante
|
||
$extOg = $importedMeta['og_image'] ?? '';
|
||
if (!$thumbSet && $extOg !== '' && !str_starts_with($extOg, '/')) {
|
||
$thumbName = downloadImageToThumb($extOg, $filesDir);
|
||
if ($thumbName !== null) {
|
||
$importedMeta['og_image'] = '/file?uuid=' . rawurlencode($urlUuid) . '&name=' . rawurlencode($thumbName);
|
||
$thumbSet = true;
|
||
}
|
||
}
|
||
// 2. Plus grande image trouvée sur la page
|
||
if (!$thumbSet) {
|
||
$bigImg = findLargestPageImage($imageUrl);
|
||
if ($bigImg !== null) {
|
||
$thumbName = downloadImageToThumb($bigImg, $filesDir);
|
||
if ($thumbName !== null) {
|
||
$importedMeta['og_image'] = '/file?uuid=' . rawurlencode($urlUuid) . '&name=' . rawurlencode($thumbName);
|
||
$thumbSet = true;
|
||
}
|
||
}
|
||
}
|
||
// 3. Screenshot pré-généré depuis step2
|
||
if (!$thumbSet && $screenshotFile !== '' && file_exists($filesDir . '/' . $screenshotFile)) {
|
||
$previewPath = $filesDir . '/' . $screenshotFile;
|
||
$hash = substr(hash_file('sha256', $previewPath), 0, 16);
|
||
$size = filesize($previewPath);
|
||
$thumbName = '_thumb_' . $hash . '-' . $size . '.png';
|
||
rename($previewPath, $filesDir . '/' . $thumbName);
|
||
$importedMeta['og_image'] = '/file?uuid=' . rawurlencode($urlUuid) . '&name=' . rawurlencode($thumbName);
|
||
$thumbSet = true;
|
||
}
|
||
// 4. Screenshot à la volée en dernier recours
|
||
if (!$thumbSet) {
|
||
$screenshotTmp = tempnam(sys_get_temp_dir(), 'vl_ss_') . '.png';
|
||
if (takeScreenshot($imageUrl, $screenshotTmp)) {
|
||
$hash = substr(hash_file('sha256', $screenshotTmp), 0, 16);
|
||
$size = filesize($screenshotTmp);
|
||
$thumbName = '_thumb_' . $hash . '-' . $size . '.png';
|
||
rename($screenshotTmp, $filesDir . '/' . $thumbName);
|
||
$importedMeta['og_image'] = '/file?uuid=' . rawurlencode($urlUuid) . '&name=' . rawurlencode($thumbName);
|
||
} else {
|
||
@unlink($screenshotTmp);
|
||
}
|
||
}
|
||
// Supprime le preview inutilisé si toujours présent
|
||
if ($screenshotFile !== '' && file_exists($filesDir . '/' . $screenshotFile)) {
|
||
@unlink($filesDir . '/' . $screenshotFile);
|
||
}
|
||
} elseif ($screenshotFile !== '') {
|
||
// Non-HTML : supprime le preview inutilisé
|
||
@unlink($filesDir . '/' . $screenshotFile);
|
||
}
|
||
$articles->addExternalLink($urlUuid, $imageUrl, $imgTitle, $imgAuthor, $importedMeta);
|
||
header('Location: /edit/' . rawurlencode($urlUuid));
|
||
exit;
|
||
}
|
||
|
||
// Mode téléchargement : accusé de réception obligatoire
|
||
if (empty($_POST['copyright_acked'])) {
|
||
header('Location: /?action=copyright_ack&' . http_build_query([
|
||
'uuid' => $urlUuid,
|
||
'image_url' => $imageUrl,
|
||
'img_title' => $imgTitle,
|
||
'img_author' => $imgAuthor,
|
||
'img_source' => trim($_POST['img_source'] ?? ''),
|
||
'is_cover' => $isCover ? '1' : '',
|
||
'meta_json' => $rawMetaJson,
|
||
]));
|
||
exit;
|
||
}
|
||
|
||
$imported = $articles->addFileFromUrl($urlUuid, $imageUrl, $isCover, $imgAuthor, $imgSource, $imgTitle, $importedMeta);
|
||
if ($imported) {
|
||
header('Location: /edit/' . rawurlencode($urlUuid));
|
||
} else {
|
||
header('Location: /import/' . rawurlencode($urlUuid) . '?error=1&mode=download');
|
||
}
|
||
exit;
|
||
|
||
case 'sources':
|
||
$article = $articles->getByUuid($uuid);
|
||
if (!$article) {
|
||
http_response_code(404);
|
||
echo 'Article introuvable.';
|
||
exit;
|
||
}
|
||
requireAuth();
|
||
if (!canDoOnArticle('view_sources', $article)) {
|
||
http_response_code(403);
|
||
echo 'Accès refusé.';
|
||
exit;
|
||
}
|
||
$sourcesFiles = $articles->getFiles($uuid);
|
||
include BASE_PATH . '/templates/sources.php';
|
||
break;
|
||
|
||
case 'regen_thumbs':
|
||
requireAuth();
|
||
|
||
// Page de confirmation si pas encore lancé
|
||
if (!isset($_GET['run'])) {
|
||
ob_start();
|
||
?>
|
||
<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 = DATA_PATH . '/' . $artUuid . '/files';
|
||
foreach ($article['external_links'] ?? [] as $link) {
|
||
$lMeta = $link['meta'] ?? [];
|
||
$lMime = $lMeta['mime'] ?? 'text/html';
|
||
$lUrl = $link['url'] ?? '';
|
||
|
||
// Ignore si ce n'est pas du HTML
|
||
if ($lMime !== '' && !str_starts_with($lMime, 'text/html')) {
|
||
$skip++;
|
||
continue;
|
||
}
|
||
|
||
$hasLocal = !empty($lMeta['og_image']) && str_starts_with($lMeta['og_image'], '/');
|
||
if ($hasLocal && !$force) {
|
||
$skip++;
|
||
continue;
|
||
}
|
||
|
||
echo '<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 (double-submit cookie — pas de session requise pour les visiteurs)
|
||
$csrfOk = isset($_POST['_token'], $_COOKIE['_csrf_c'])
|
||
&& hash_equals($_COOKIE['_csrf_c'], $_POST['_token']);
|
||
setcookie('_csrf_c', '', ['expires' => time() - 3600, 'path' => '/', 'samesite' => 'Strict', 'httponly' => true]);
|
||
if (!$csrfOk) {
|
||
header('Location: /');
|
||
exit;
|
||
}
|
||
|
||
$cmtUuid = trim($_POST['uuid'] ?? '');
|
||
$cmtName = trim($_POST['author_name'] ?? '');
|
||
$cmtEmail = trim($_POST['author_email'] ?? '');
|
||
$cmtContent = trim($_POST['content'] ?? '');
|
||
|
||
$cmtArticle = $cmtUuid !== '' ? $articles->getByUuid($cmtUuid) : null;
|
||
$cmtBack = $cmtArticle ? '/post/' . rawurlencode($cmtArticle['slug'] ?? $cmtUuid) : '/';
|
||
|
||
$pdo = dbPdo();
|
||
if (!$pdo || !$cmtArticle || $cmtName === '' || !filter_var($cmtEmail, FILTER_VALIDATE_EMAIL) || $cmtContent === '') {
|
||
header('Location: ' . $cmtBack . '#comment-form-card');
|
||
exit;
|
||
}
|
||
|
||
if (mb_strlen($cmtContent) > 2000) {
|
||
header('Location: ' . $cmtBack . '#comment-form-card');
|
||
exit;
|
||
}
|
||
|
||
require_once BASE_PATH . '/src/CommentManager.php';
|
||
require_once BASE_PATH . '/src/mailer.php';
|
||
|
||
$cm = new CommentManager($pdo);
|
||
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
|
||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||
['token' => $cmtToken, 'code' => $cmtCode] = $cm->submit($cmtUuid, $cmtName, $cmtEmail, $cmtContent, $ip, $ua);
|
||
|
||
$verifyUrl = rtrim(APP_URL, '/') . '/verify-comment/' . $cmtToken;
|
||
$subject = '[' . siteTitle() . '] Confirmez votre commentaire';
|
||
$html = '<!DOCTYPE html><html><body style="font-family:sans-serif;max-width:560px;margin:0 auto">'
|
||
. '<p>Bonjour ' . htmlspecialchars($cmtName) . ',</p>'
|
||
. '<p>Votre commentaire sur <em>' . htmlspecialchars($cmtArticle['title']) . '</em> a bien été reçu.</p>'
|
||
. '<p>Cliquez sur le lien ci-dessous, puis saisissez le code à 6 chiffres :</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>Votre code : <strong style="font-family:monospace;font-size:1.25em;letter-spacing:0.1em">' . htmlspecialchars($cmtCode) . '</strong></p>'
|
||
. '<p style="color:#888;font-size:.875em">Ce lien et ce code expirent 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':
|
||
$vcToken = trim($_GET['token'] ?? '');
|
||
$vcCode = trim($_POST['code'] ?? '');
|
||
$vcError = false;
|
||
$vcDeleted = false;
|
||
$vcAttemptsLeft = null;
|
||
|
||
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $vcToken)) {
|
||
header('Location: /');
|
||
exit;
|
||
}
|
||
|
||
if ($vcCode !== '' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$pdo = dbPdo();
|
||
if ($pdo && preg_match('/^[0-9]{6}$/', $vcCode)) {
|
||
require_once BASE_PATH . '/src/CommentManager.php';
|
||
$cm = new CommentManager($pdo);
|
||
$result = $cm->verify($vcToken, $vcCode);
|
||
if (is_string($result)) {
|
||
$vcArticle = $articles->getByUuid($result);
|
||
$vcSlug = $vcArticle ? ($vcArticle['slug'] ?? $result) : $result;
|
||
header('Location: /post/' . rawurlencode($vcSlug) . '?verified=1#comments');
|
||
exit;
|
||
}
|
||
if ($result === 0) {
|
||
$vcDeleted = true;
|
||
} else {
|
||
$vcError = true;
|
||
$vcAttemptsLeft = (int)$result;
|
||
}
|
||
} else {
|
||
$vcError = true;
|
||
$vcAttemptsLeft = null;
|
||
}
|
||
}
|
||
|
||
ob_start();
|
||
?>
|
||
<div class="container py-5" style="max-width:480px">
|
||
<h1 class="h3 mb-4">Confirmer mon commentaire</h1>
|
||
<?php if ($vcDeleted): ?>
|
||
<div class="alert alert-danger mb-3">
|
||
Trop de tentatives incorrectes. Votre commentaire a été annulé.
|
||
</div>
|
||
<a href="/" class="btn btn-primary">← Retour à l'accueil</a>
|
||
<?php else: ?>
|
||
<?php if ($vcError): ?>
|
||
<div class="alert alert-danger py-2 mb-3">
|
||
Code incorrect.
|
||
<?php if ($vcAttemptsLeft !== null): ?>
|
||
Il vous reste <?= (int)$vcAttemptsLeft ?> essai<?= $vcAttemptsLeft > 1 ? 's' : '' ?>.
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php else: ?>
|
||
<p class="text-muted mb-3">Saisissez le code à 6 chiffres reçu par email.</p>
|
||
<?php endif; ?>
|
||
<form method="post" action="/verify-comment/<?= htmlspecialchars($vcToken) ?>">
|
||
<div class="mb-3">
|
||
<label for="vc-code" class="form-label fw-semibold">Code de confirmation</label>
|
||
<input type="text" id="vc-code" name="code"
|
||
class="form-control form-control-lg text-center font-monospace"
|
||
pattern="[0-9]{6}" maxlength="6" inputmode="numeric"
|
||
placeholder="123456" autocomplete="one-time-code" required autofocus>
|
||
</div>
|
||
<button type="submit" class="btn btn-primary w-100">Confirmer</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php
|
||
$content = ob_get_clean();
|
||
$title = ($vcDeleted ? 'Commentaire annulé' : 'Confirmer mon commentaire') . ' — ' . siteTitle();
|
||
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 'comment_delete':
|
||
requireAuth();
|
||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$delId = (int)($_POST['id'] ?? 0);
|
||
$pdo = dbPdo();
|
||
if ($pdo && $delId > 0) {
|
||
require_once BASE_PATH . '/src/CommentManager.php';
|
||
(new CommentManager($pdo))->delete($delId);
|
||
}
|
||
header('Location: /admin/comments');
|
||
exit;
|
||
|
||
case 'comment_resend':
|
||
requireAuth();
|
||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$resendId = (int)($_POST['id'] ?? 0);
|
||
$pdo = dbPdo();
|
||
if ($pdo && $resendId > 0) {
|
||
require_once BASE_PATH . '/src/CommentManager.php';
|
||
require_once BASE_PATH . '/src/mailer.php';
|
||
$cm = new CommentManager($pdo);
|
||
$resendRow = $cm->getById($resendId);
|
||
if ($resendRow && !$resendRow['verified'] && !empty($resendRow['verification_code']) && !empty($resendRow['verify_token'])) {
|
||
$resendArticle = $articles->getByUuid((string)$resendRow['article_uuid']);
|
||
if ($resendArticle) {
|
||
$resendCode = (string)$resendRow['verification_code'];
|
||
$resendToken = (string)$resendRow['verify_token'];
|
||
$resendName = (string)$resendRow['author_name'];
|
||
$resendEmail = (string)$resendRow['author_email'];
|
||
$resendBack = '/post/' . rawurlencode($resendArticle['slug'] ?? $resendRow['article_uuid']);
|
||
$resendVerifyUrl = rtrim(APP_URL, '/') . '/verify-comment/' . $resendToken;
|
||
$resendSubject = '[' . siteTitle() . '] Confirmez votre commentaire';
|
||
$resendHtml = '<!DOCTYPE html><html><body style="font-family:sans-serif;max-width:560px;margin:0 auto">'
|
||
. '<p>Bonjour ' . htmlspecialchars($resendName) . ',</p>'
|
||
. '<p>Votre commentaire sur <em>' . htmlspecialchars($resendArticle['title']) . '</em> a bien été reçu.</p>'
|
||
. '<p>Cliquez sur le lien ci-dessous, puis saisissez le code à 6 chiffres :</p>'
|
||
. '<p><a href="' . htmlspecialchars($resendVerifyUrl) . '" style="display:inline-block;padding:10px 20px;background:#0d6efd;color:#fff;text-decoration:none;border-radius:4px">Confirmer mon commentaire</a></p>'
|
||
. '<p>Votre code : <strong style="font-family:monospace;font-size:1.25em;letter-spacing:0.1em">' . htmlspecialchars($resendCode) . '</strong></p>'
|
||
. '<p style="color:#888;font-size:.875em">Ce lien et ce code expirent dans 24 heures. Si vous n\'êtes pas à l\'origine de ce message, ignorez-le.</p>'
|
||
. '</body></html>';
|
||
try {
|
||
envoyer_mail_smtp($resendEmail, $resendSubject, $resendHtml, null, ['bypass_rate_limit' => true]);
|
||
} catch (\RuntimeException) {
|
||
// Erreur SMTP : on continue sans planter
|
||
}
|
||
}
|
||
}
|
||
}
|
||
header('Location: /admin/comments');
|
||
exit;
|
||
|
||
case 'rate':
|
||
requireAuth();
|
||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
header('Location: /');
|
||
exit;
|
||
}
|
||
$rateUuid = $_POST['uuid'] ?? '';
|
||
$rateValue = (int)($_POST['rating'] ?? 0);
|
||
$rateArticle = $articles->getByUuid($rateUuid);
|
||
if ($rateArticle && $rateValue >= 1 && $rateValue <= 5) {
|
||
$pdo = dbPdo();
|
||
if ($pdo) {
|
||
require_once BASE_PATH . '/src/RatingManager.php';
|
||
(new RatingManager($pdo))->rate($rateUuid, currentUserEmail() ?? '', $rateValue);
|
||
}
|
||
header('Location: /post/' . rawurlencode($rateArticle['slug'] ?? $rateUuid));
|
||
} else {
|
||
header('Location: /');
|
||
}
|
||
exit;
|
||
|
||
case 'admin':
|
||
requireAuth();
|
||
$tab = $_GET['tab'] ?? (isAdmin() ? 'dashboard' : 'articles');
|
||
$adminData = [];
|
||
$siteSettingsSaved = isset($_GET['saved']);
|
||
$siteSettingsError = ($_GET['error'] ?? '') === 'write';
|
||
|
||
if ($tab === 'dashboard') {
|
||
if (!isAdmin()) {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$allArticles = $articles->getAll();
|
||
$now = time();
|
||
$adminData['total'] = count($allArticles);
|
||
$adminData['published'] = count(array_filter($allArticles, fn ($a) => $a['published'] && strtotime((string)($a['published_at'] ?? '')) <= $now));
|
||
$adminData['drafts'] = count(array_filter($allArticles, fn ($a) => !$a['published']));
|
||
$adminData['previews'] = count(array_filter($allArticles, fn ($a) => $a['published'] && strtotime((string)($a['published_at'] ?? '')) > $now));
|
||
$adminData['recent'] = array_slice(
|
||
usort($allArticles, fn ($a, $b) => strcmp($b['updated_at'] ?? '', $a['updated_at'] ?? '')) ? $allArticles : $allArticles,
|
||
0,
|
||
5
|
||
);
|
||
// Trier par updated_at desc
|
||
usort($allArticles, fn ($a, $b) => strcmp($b['updated_at'] ?? '', $a['updated_at'] ?? ''));
|
||
$adminData['recent'] = array_slice($allArticles, 0, 5);
|
||
}
|
||
|
||
if ($tab === 'articles') {
|
||
$allArticles = $articles->getAll();
|
||
if (!isAdmin()) {
|
||
$me = currentUserEmail() ?? '';
|
||
$allArticles = array_values(array_filter($allArticles, fn ($a) => ($a['author'] ?? '') === $me));
|
||
}
|
||
|
||
$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));
|
||
}
|
||
|
||
$sortBy = in_array($_GET['sort'] ?? '', ['title', 'published', 'updated']) ? $_GET['sort'] : 'updated';
|
||
$sortDir = ($_GET['dir'] ?? '') === 'asc' ? 'asc' : 'desc';
|
||
usort($allArticles, function ($a, $b) use ($sortBy, $sortDir) {
|
||
$cmp = match ($sortBy) {
|
||
'title' => strcmp($a['title'] ?? '', $b['title'] ?? ''),
|
||
'published' => strcmp(
|
||
$a['published_at'] ?? $a['created_at'] ?? '',
|
||
$b['published_at'] ?? $b['created_at'] ?? ''
|
||
),
|
||
default => strcmp($a['updated_at'] ?? '', $b['updated_at'] ?? ''),
|
||
};
|
||
return $sortDir === 'asc' ? $cmp : -$cmp;
|
||
});
|
||
|
||
$adminData['articles'] = $allArticles;
|
||
$adminData['sort_by'] = $sortBy;
|
||
$adminData['sort_dir'] = $sortDir;
|
||
}
|
||
|
||
if ($tab === 'roles') {
|
||
if (!isAdmin()) {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$pdo = dbPdo();
|
||
if ($pdo) {
|
||
$st = $pdo->query(
|
||
'SELECT r.id, r.name, r.label, COUNT(ur.user_email) AS user_count
|
||
FROM roles r
|
||
LEFT JOIN user_roles ur ON ur.role_id = r.id
|
||
GROUP BY r.id, r.name, r.label
|
||
ORDER BY r.name'
|
||
);
|
||
$roles = $st->fetchAll(PDO::FETCH_ASSOC);
|
||
try {
|
||
$capRows = $pdo->query('SELECT role_id, capability FROM role_capabilities')->fetchAll(PDO::FETCH_ASSOC);
|
||
$capsMap = [];
|
||
foreach ($capRows as $cr) {
|
||
$capsMap[(int)$cr['role_id']][] = $cr['capability'];
|
||
}
|
||
} catch (\Throwable) {
|
||
$capsMap = [];
|
||
}
|
||
foreach ($roles as &$r) {
|
||
$r['capabilities'] = $capsMap[(int)$r['id']] ?? [];
|
||
}
|
||
unset($r);
|
||
$adminData['roles'] = $roles;
|
||
} else {
|
||
$adminData['roles'] = [];
|
||
}
|
||
}
|
||
|
||
if ($tab === 'users') {
|
||
if (!isAdmin()) {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$pdo = dbPdo();
|
||
if ($pdo) {
|
||
// users table may not exist yet — degrade gracefully
|
||
$usersFromDb = [];
|
||
try {
|
||
$st = $pdo->query('SELECT email, is_active FROM users ORDER BY email');
|
||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||
$v = $row['is_active'];
|
||
$usersFromDb[$row['email']] = is_bool($v) ? $v : in_array(strtolower((string)$v), ['t', '1', 'true', 'yes'], true);
|
||
}
|
||
} catch (\PDOException) {
|
||
// table absente, on continue avec la liste user_roles seulement
|
||
}
|
||
|
||
$st = $pdo->query('SELECT ur.user_email, r.name, r.label FROM user_roles ur JOIN roles r ON r.id = ur.role_id ORDER BY ur.user_email');
|
||
$rolesMap = [];
|
||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||
$rolesMap[$row['user_email']][] = ['name' => $row['name'], 'label' => $row['label']];
|
||
}
|
||
|
||
$merged = [];
|
||
foreach (array_unique(array_merge(array_keys($usersFromDb), array_keys($rolesMap))) as $email) {
|
||
$merged[$email] = [
|
||
'email' => $email,
|
||
'is_active' => $usersFromDb[$email] ?? null,
|
||
'roles' => $rolesMap[$email] ?? [], // [['name'=>..., 'label'=>...], ...]
|
||
];
|
||
}
|
||
ksort($merged);
|
||
$adminData['users'] = array_values($merged);
|
||
|
||
$st = $pdo->query('SELECT id, name, label FROM roles ORDER BY name');
|
||
$adminData['roles'] = $st->fetchAll(PDO::FETCH_ASSOC);
|
||
} else {
|
||
$adminData['users'] = [];
|
||
$adminData['roles'] = [];
|
||
}
|
||
}
|
||
|
||
if ($tab === 'comments') {
|
||
if (!isAdmin()) {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$pdo = dbPdo();
|
||
$cmtFilterStatus = trim($_GET['filter_status'] ?? '');
|
||
$adminData['cmt_filter_status'] = $cmtFilterStatus;
|
||
if ($pdo) {
|
||
require_once BASE_PATH . '/src/CommentManager.php';
|
||
$cm = new CommentManager($pdo);
|
||
$adminData['comments'] = $cm->allForAdmin($cmtFilterStatus);
|
||
$adminData['cmt_counts'] = $cm->countsByStatus();
|
||
// Enrichit avec le slug de chaque article pour les liens
|
||
$adminData['articleSlugs'] = [];
|
||
foreach ($adminData['comments'] as $cmtRow) {
|
||
$uuid = $cmtRow['article_uuid'];
|
||
if (!isset($adminData['articleSlugs'][$uuid])) {
|
||
$a = $articles->getByUuid($uuid);
|
||
$adminData['articleSlugs'][$uuid] = $a ? ($a['slug'] ?? null) : null;
|
||
}
|
||
}
|
||
} else {
|
||
$adminData['comments'] = [];
|
||
$adminData['articleSlugs'] = [];
|
||
$adminData['cmt_counts'] = ['all' => 0, 'pending' => 0, 'verified' => 0, 'hidden' => 0];
|
||
}
|
||
}
|
||
|
||
if ($tab === 'emails') {
|
||
if (!isAdmin()) {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$pdo = dbPdo();
|
||
if ($pdo) {
|
||
$emlFilter = in_array($_GET['filter'] ?? '', ['sent', 'error', 'queued'], true) ? $_GET['filter'] : '';
|
||
$emlPage = max(0, (int)($_GET['page'] ?? 0));
|
||
$emlLimit = 50;
|
||
$emlOffset = $emlPage * $emlLimit;
|
||
|
||
$whereEml = $emlFilter !== '' ? 'WHERE status = ' . $pdo->quote($emlFilter) : '';
|
||
|
||
$row = $pdo->query("SELECT
|
||
COUNT(*) AS all,
|
||
COUNT(*) FILTER (WHERE status = 'sent') AS sent,
|
||
COUNT(*) FILTER (WHERE status = 'error') AS error,
|
||
COUNT(*) FILTER (WHERE status = 'queued') AS queued
|
||
FROM journal_smtp")->fetch(PDO::FETCH_ASSOC);
|
||
$adminData['eml_counts'] = [
|
||
'all' => (int)($row['all'] ?? 0),
|
||
'sent' => (int)($row['sent'] ?? 0),
|
||
'error' => (int)($row['error'] ?? 0),
|
||
'queued' => (int)($row['queued'] ?? 0),
|
||
];
|
||
$adminData['emails'] = $pdo->query(
|
||
"SELECT id, created_at, to_email, subject, status, error_message, content_text, sent_at
|
||
FROM journal_smtp $whereEml
|
||
ORDER BY created_at DESC
|
||
LIMIT $emlLimit OFFSET $emlOffset"
|
||
)->fetchAll(PDO::FETCH_ASSOC);
|
||
$adminData['eml_filter'] = $emlFilter;
|
||
$adminData['eml_page'] = $emlPage;
|
||
} else {
|
||
$adminData['emails'] = [];
|
||
$adminData['eml_counts'] = ['all' => 0, 'sent' => 0, 'error' => 0, 'queued' => 0];
|
||
$adminData['eml_filter'] = '';
|
||
$adminData['eml_page'] = 0;
|
||
}
|
||
}
|
||
|
||
if ($tab === 'smtp') {
|
||
if (!isAdmin()) {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
require_once BASE_PATH . '/src/SmtpSettings.php';
|
||
$adminData['smtp_config'] = [
|
||
'host' => smtpCfg('host', 'SMTP_HOST'),
|
||
'port' => smtpCfg('port', 'SMTP_PORT'),
|
||
'secure' => smtpCfg('secure', 'SMTP_SECURE'),
|
||
'user' => smtpCfg('user', 'SMTP_USER'),
|
||
'has_pass' => smtpCfg('pass', 'SMTP_PASS') !== '',
|
||
'from' => smtpCfg('from', 'SMTP_FROM'),
|
||
'from_name' => smtpCfg('from_name', 'SMTP_FROM_NAME'),
|
||
];
|
||
$adminData['smtp_test'] = $_SESSION['smtp_test_result'] ?? null;
|
||
unset($_SESSION['smtp_test_result']);
|
||
}
|
||
|
||
if ($tab === 'searches') {
|
||
if (!isAdmin()) {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
require_once BASE_PATH . '/src/SearchLogParser.php';
|
||
$parser = new SearchLogParser('/var/log/apache2', apacheAccessLog());
|
||
$adminData['search_terms'] = $parser->topTerms(100);
|
||
$adminData['search_log_readable'] = $parser->isReadable();
|
||
}
|
||
|
||
if ($tab === 'stats') {
|
||
if (!isAdmin()) {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
require_once BASE_PATH . '/src/TrendingParser.php';
|
||
require_once BASE_PATH . '/src/AccessLogParser.php';
|
||
require_once BASE_PATH . '/src/AsnLookup.php';
|
||
|
||
$statsCacheFile = DATA_PATH . '/.stats_cache.json';
|
||
$statsRaw = null;
|
||
if (file_exists($statsCacheFile) && (time() - filemtime($statsCacheFile)) < 60) {
|
||
$statsRaw = json_decode((string) file_get_contents($statsCacheFile), true) ?: null;
|
||
}
|
||
if ($statsRaw === null) {
|
||
$cutoff14 = strtotime('-14 days midnight') ?: (time() - 14 * 86400);
|
||
$tParser = new TrendingParser('/var/log/apache2', apacheAccessLog());
|
||
$accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog());
|
||
$topIps = array_slice($accessParser->stats()['ips'], 0, 200, true);
|
||
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
|
||
|
||
$statsRaw = [
|
||
'readable' => $accessParser->isReadable(),
|
||
'books' => $tParser->top($cutoff14, 20, ['/book/']),
|
||
'as' => AsnLookup::aggregateByAs($topIps, $asnMap),
|
||
];
|
||
@file_put_contents($statsCacheFile, json_encode($statsRaw));
|
||
}
|
||
$adminData['stats_readable'] = $statsRaw['readable'];
|
||
$adminData['stats_books'] = $statsRaw['books'];
|
||
$adminData['stats_as'] = $statsRaw['as'];
|
||
$adminData['stats_as_groups'] = AsnLookup::applyGroups($statsRaw['as'], asGroups());
|
||
$adminData['as_groups'] = asGroups();
|
||
}
|
||
|
||
if ($tab === 'categories') {
|
||
$adminData['cats'] = $articles->getCategories();
|
||
$adminData['privateCats'] = $articles->getPrivateCategories();
|
||
$adminData['tagTypes'] = $articles->getTagTypes();
|
||
}
|
||
|
||
if ($tab === 'books') {
|
||
if (!isAdmin()) {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$adminData['books'] = $books->getAll();
|
||
$adminData['edit_book'] = null;
|
||
$adminData['all_articles'] = $articles->getAll();
|
||
usort($adminData['all_articles'], static fn ($a, $b) => strcmp($a['title'] ?? '', $b['title'] ?? ''));
|
||
$editBookSlug = trim($_GET['edit'] ?? '');
|
||
if ($editBookSlug !== '') {
|
||
$adminData['edit_book'] = $books->getBySlug($editBookSlug);
|
||
}
|
||
}
|
||
|
||
include BASE_PATH . '/templates/admin.php';
|
||
break;
|
||
|
||
case 'admin_smtp_save':
|
||
requireAuth();
|
||
if (!isAdmin()) {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
require_once BASE_PATH . '/src/SmtpSettings.php';
|
||
|
||
$ok = saveSmtpSettings([
|
||
'host' => $_POST['smtp_host'] ?? '',
|
||
'port' => $_POST['smtp_port'] ?? '',
|
||
'secure' => $_POST['smtp_secure'] ?? '',
|
||
'user' => $_POST['smtp_user'] ?? '',
|
||
'pass' => $_POST['smtp_pass'] ?? '',
|
||
'from' => $_POST['smtp_from'] ?? '',
|
||
'from_name' => $_POST['smtp_from_name'] ?? '',
|
||
]);
|
||
header('Location: /admin/smtp?' . ($ok ? 'saved=1' : 'error=write'));
|
||
exit;
|
||
|
||
case 'admin_smtp_test':
|
||
requireAuth();
|
||
if (!isAdmin()) {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
require_once BASE_PATH . '/src/SmtpSettings.php';
|
||
require_once BASE_PATH . '/src/mailer.php';
|
||
|
||
$mode = in_array($_POST['mode'] ?? '', ['connect', 'send'], true) ? $_POST['mode'] : 'connect';
|
||
$testEmail = trim($_POST['test_email'] ?? '');
|
||
if ($testEmail !== '' && !filter_var($testEmail, FILTER_VALIDATE_EMAIL)) {
|
||
$testEmail = '';
|
||
}
|
||
|
||
$smtpLogs = [];
|
||
$smtpOk = false;
|
||
$smtpErrMsg = '';
|
||
|
||
try {
|
||
$mail = new \PHPMailer\PHPMailer\PHPMailer(true);
|
||
$mail->isSMTP();
|
||
$mail->Host = smtpCfg('host', 'SMTP_HOST', 'localhost');
|
||
$mail->Port = (int)smtpCfg('port', 'SMTP_PORT', '587');
|
||
$_stUser = smtpCfg('user', 'SMTP_USER');
|
||
$_stPass = smtpCfg('pass', 'SMTP_PASS');
|
||
$mail->SMTPAuth = ($_stUser !== '' || $_stPass !== '');
|
||
$mail->Username = $_stUser;
|
||
$mail->Password = $_stPass;
|
||
$smtpSecure = strtolower(smtpCfg('secure', 'SMTP_SECURE', 'tls'));
|
||
if ($smtpSecure === 'ssl') {
|
||
$mail->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS;
|
||
} elseif ($smtpSecure === 'tls') {
|
||
$mail->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
|
||
}
|
||
$mail->Timeout = 15;
|
||
$mail->SMTPOptions = ['ssl' => ['verify_peer' => true, 'verify_peer_name' => true, 'allow_self_signed' => false]];
|
||
$mail->SMTPDebug = \PHPMailer\PHPMailer\SMTP::DEBUG_SERVER;
|
||
$mail->Debugoutput = static function (string $str) use (&$smtpLogs): void {
|
||
$smtpLogs[] = rtrim($str);
|
||
};
|
||
|
||
if ($mode === 'send' && $testEmail !== '') {
|
||
$mail->CharSet = 'UTF-8';
|
||
$mail->isHTML(true);
|
||
$_smtpFrom = smtpCfg('from', 'SMTP_FROM', 'no-reply@varlog.a5l.fr');
|
||
$_smtpFromName = smtpCfg('from_name', 'SMTP_FROM_NAME', 'varlog');
|
||
$mail->setFrom($_smtpFrom, $_smtpFromName);
|
||
$mail->addAddress($testEmail);
|
||
$_siteName = siteTitle();
|
||
$_siteUrl = rtrim(APP_URL, '/');
|
||
$_sentAt = date('d/m/Y à H\hi', time());
|
||
$mail->Subject = 'Vérification de la configuration email — ' . $_siteName;
|
||
$mail->Body = '<!DOCTYPE html><html lang="fr"><head><meta charset="UTF-8">'
|
||
. '<meta name="viewport" content="width=device-width,initial-scale=1"></head>'
|
||
. '<body style="font-family:sans-serif;color:#1a1a1a;max-width:520px;margin:0 auto;padding:32px 16px">'
|
||
. '<p>Bonjour,</p>'
|
||
. '<p>Cet email confirme que la configuration SMTP de <strong>' . htmlspecialchars($_siteName) . '</strong> fonctionne correctement.</p>'
|
||
. '<p>Envoyé le ' . $_sentAt . ' depuis <a href="' . htmlspecialchars($_siteUrl) . '">' . htmlspecialchars($_siteUrl) . '</a>.</p>'
|
||
. '<hr style="border:none;border-top:1px solid #e5e7eb;margin:28px 0">'
|
||
. '<p style="color:#6b7280;font-size:0.82em">Vous recevez cet email car un administrateur a effectué un test de configuration depuis l\'interface d\'administration de ' . htmlspecialchars($_siteName) . '.'
|
||
. ' Si vous n\'attendiez pas cet email, vous pouvez l\'ignorer.</p>'
|
||
. '</body></html>';
|
||
$mail->AltBody = "Bonjour,\r\n\r\n"
|
||
. "Cet email confirme que la configuration SMTP de {$_siteName} fonctionne correctement.\r\n\r\n"
|
||
. "Envoyé le {$_sentAt} depuis {$_siteUrl}.\r\n\r\n"
|
||
. "--\r\n"
|
||
. "Vous recevez cet email car un administrateur a effectué un test de configuration depuis l'interface d'administration de {$_siteName}."
|
||
. " Si vous n'attendiez pas cet email, vous pouvez l'ignorer.";
|
||
$mail->send();
|
||
} else {
|
||
$mail->smtpConnect();
|
||
$mail->smtpClose();
|
||
}
|
||
$smtpOk = true;
|
||
} catch (\Exception $e) {
|
||
$smtpErrMsg = $e->getMessage();
|
||
}
|
||
|
||
$_SESSION['smtp_test_result'] = [
|
||
'success' => $smtpOk,
|
||
'error' => $smtpErrMsg,
|
||
'logs' => $smtpLogs,
|
||
'mode' => $mode,
|
||
'email' => $testEmail,
|
||
'ts' => date('d/m/Y H:i:s'),
|
||
];
|
||
header('Location: /admin/smtp');
|
||
exit;
|
||
|
||
case 'admin_bulk_delete':
|
||
requireAuth();
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$uuids = $_POST['uuids'] ?? [];
|
||
if (is_array($uuids)) {
|
||
$me = currentUserEmail() ?? '';
|
||
foreach ($uuids as $uid) {
|
||
$uid = trim((string)$uid);
|
||
if ($uid === '') {
|
||
continue;
|
||
}
|
||
$art = $articles->getByUuid($uid);
|
||
if (!$art) {
|
||
continue;
|
||
}
|
||
if (isAdmin() || ($art['author'] ?? '') === $me) {
|
||
$articles->delete($uid);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
header('Location: /admin/articles');
|
||
exit;
|
||
|
||
case 'admin_grant_role':
|
||
requireAuth();
|
||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$targetEmail = strtolower(trim($_POST['email'] ?? ''));
|
||
$roleName = trim($_POST['role'] ?? '');
|
||
if ($targetEmail && $roleName && filter_var($targetEmail, FILTER_VALIDATE_EMAIL)) {
|
||
$pdo = dbPdo();
|
||
if ($pdo) {
|
||
$st = $pdo->prepare(
|
||
'INSERT INTO user_roles (user_email, role_id, granted_by)
|
||
SELECT :email, id, :by FROM roles WHERE name = :role
|
||
ON CONFLICT DO NOTHING'
|
||
);
|
||
$st->execute([':email' => $targetEmail, ':role' => $roleName, ':by' => currentUserEmail()]);
|
||
}
|
||
}
|
||
header('Location: /admin/users');
|
||
exit;
|
||
|
||
case 'admin_revoke_role':
|
||
requireAuth();
|
||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$targetEmail = strtolower(trim($_POST['email'] ?? ''));
|
||
$roleName = trim($_POST['role'] ?? '');
|
||
if ($targetEmail && $roleName) {
|
||
$pdo = dbPdo();
|
||
if ($pdo) {
|
||
// Bloquer si c'est le dernier admin (en DB — hors ADMIN_EMAIL env)
|
||
if ($roleName === 'admin') {
|
||
$st = $pdo->prepare(
|
||
'SELECT COUNT(*) FROM user_roles ur
|
||
JOIN roles r ON r.id = ur.role_id
|
||
WHERE r.name = :role AND ur.user_email != :email'
|
||
);
|
||
$st->execute([':role' => 'admin', ':email' => $targetEmail]);
|
||
if ((int)$st->fetchColumn() === 0) {
|
||
header('Location: /admin/users?error=last_admin');
|
||
exit;
|
||
}
|
||
}
|
||
$st = $pdo->prepare(
|
||
'DELETE FROM user_roles
|
||
WHERE user_email = :email
|
||
AND role_id = (SELECT id FROM roles WHERE name = :role)'
|
||
);
|
||
$st->execute([':email' => $targetEmail, ':role' => $roleName]);
|
||
}
|
||
}
|
||
header('Location: /admin/users');
|
||
exit;
|
||
|
||
case 'run_content_migrations':
|
||
requireAuth();
|
||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$_cmDataDir = DATA_PATH;
|
||
$_cmTrack = $_cmDataDir . '/.content_migrations.json';
|
||
$_cmFlag = $_cmDataDir . '/.maintenance';
|
||
$_cmApplied = file_exists($_cmTrack) ? (json_decode((string) file_get_contents($_cmTrack), true) ?? []) : [];
|
||
$_cmFiles = glob(BASE_PATH . '/scripts/content/migration_*.php') ?: [];
|
||
sort($_cmFiles);
|
||
$_cmPending = array_values(array_filter($_cmFiles, fn ($f) => !isset($_cmApplied[basename($f)])));
|
||
if (empty($_cmPending)) {
|
||
header('Location: /admin?tab=dashboard¬ice=no_migrations');
|
||
exit;
|
||
}
|
||
file_put_contents($_cmFlag, date('Y-m-d H:i:s'));
|
||
$_cmErrors = 0;
|
||
$dataDir = $_cmDataDir;
|
||
foreach ($_cmPending as $_cmFile) {
|
||
try {
|
||
require $_cmFile;
|
||
$_cmApplied[basename($_cmFile)] = date('Y-m-d H:i:s');
|
||
file_put_contents($_cmTrack, json_encode($_cmApplied, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n");
|
||
} catch (Throwable $_cmEx) {
|
||
$_cmErrors++;
|
||
break;
|
||
}
|
||
}
|
||
if (file_exists($_cmFlag)) {
|
||
unlink($_cmFlag);
|
||
}
|
||
header('Location: /admin?tab=dashboard¬ice=' . ($_cmErrors ? 'migration_error' : 'migrated'));
|
||
exit;
|
||
|
||
case 'run_engine_update':
|
||
requireAuth();
|
||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
|
||
set_time_limit(0);
|
||
ignore_user_abort(true);
|
||
|
||
exec('sudo /usr/local/bin/folio-upgrade.sh ' . escapeshellarg(folioUpdateBranch()) . ' 2>&1', $_upgradeOut, $_upgradeCode);
|
||
|
||
$_updateChecker->clearCache();
|
||
|
||
if ($_upgradeCode !== 0) {
|
||
$_SESSION['_upgrade_log'] = implode("\n", $_upgradeOut);
|
||
header('Location: /admin?tab=dashboard¬ice=upgrade_error');
|
||
exit;
|
||
}
|
||
|
||
header('Location: /admin?tab=dashboard¬ice=engine_updated');
|
||
exit;
|
||
|
||
case 'force_update_check':
|
||
requireAuth();
|
||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$_updateChecker->clearCache();
|
||
header('Location: /admin?tab=dashboard');
|
||
exit;
|
||
|
||
case 'admin_save_folio_config':
|
||
requireAuth();
|
||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$ok = saveSiteSettings([
|
||
'folio_repo_url' => $_POST['folio_repo_url'] ?? '',
|
||
'folio_update_branch' => $_POST['folio_update_branch'] ?? '',
|
||
]);
|
||
$_updateChecker->clearCache();
|
||
header('Location: /admin/site?notice=' . ($ok ? 'folio_saved' : 'folio_error'));
|
||
exit;
|
||
|
||
case 'admin_save_site':
|
||
requireAuth();
|
||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$ok = saveSiteSettings([
|
||
'site_title' => $_POST['site_title'] ?? '',
|
||
'site_claim' => $_POST['site_claim'] ?? '',
|
||
'site_lang' => $_POST['site_lang'] ?? '',
|
||
'posts_per_page' => $_POST['posts_per_page'] ?? '',
|
||
'site_license_label' => $_POST['site_license_label'] ?? '',
|
||
'site_license_url' => $_POST['site_license_url'] ?? '',
|
||
]);
|
||
header('Location: /admin/site?' . ($ok ? 'saved=1' : 'error=write'));
|
||
exit;
|
||
|
||
case 'admin_save_searches_config':
|
||
requireAuth();
|
||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$ok = saveSiteSettings(['apache_access_log' => $_POST['apache_access_log'] ?? '']);
|
||
header('Location: /admin/searches?' . ($ok ? 'saved=1' : 'error=write'));
|
||
exit;
|
||
|
||
case 'admin_save_as_groups':
|
||
requireAuth();
|
||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$rawLabels = $_POST['as_group_label'] ?? [];
|
||
$rawPatterns = $_POST['as_group_patterns'] ?? [];
|
||
$groups = [];
|
||
foreach ((array) $rawLabels as $i => $label) {
|
||
$label = trim((string) $label);
|
||
if ($label === '') {
|
||
continue;
|
||
}
|
||
$patterns = array_values(array_filter(array_map(
|
||
'trim',
|
||
explode("\n", (string) ($rawPatterns[$i] ?? ''))
|
||
)));
|
||
$groups[] = ['label' => $label, 'patterns' => $patterns];
|
||
}
|
||
$ok = saveSiteSettings(['as_groups' => $groups]);
|
||
header('Location: /admin/stats?' . ($ok ? 'saved=1' : 'error=write'));
|
||
exit;
|
||
|
||
case 'admin_create_role':
|
||
requireAuth();
|
||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$roleLabel = trim($_POST['label'] ?? '');
|
||
$roleName = preg_replace('/[^a-z0-9_-]/', '', strtolower(trim($_POST['name'] ?? '')));
|
||
if ($roleName === '' && $roleLabel !== '') {
|
||
$roleName = slugify($roleLabel);
|
||
}
|
||
if ($roleName && $roleLabel) {
|
||
$pdo = dbPdo();
|
||
if ($pdo) {
|
||
try {
|
||
$st = $pdo->prepare('INSERT INTO roles (name, label) VALUES (:n, :l) ON CONFLICT (name) DO NOTHING');
|
||
$st->execute([':n' => $roleName, ':l' => $roleLabel]);
|
||
} catch (\PDOException) {
|
||
}
|
||
}
|
||
}
|
||
header('Location: /admin/roles');
|
||
exit;
|
||
|
||
case 'admin_update_role':
|
||
requireAuth();
|
||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$roleId = (int)($_POST['id'] ?? 0);
|
||
$roleLabel = trim($_POST['label'] ?? '');
|
||
if ($roleId > 0 && $roleLabel) {
|
||
$pdo = dbPdo();
|
||
if ($pdo) {
|
||
$st = $pdo->prepare('UPDATE roles SET label = :l WHERE id = :id');
|
||
$st->execute([':l' => $roleLabel, ':id' => $roleId]);
|
||
}
|
||
}
|
||
header('Location: /admin/roles');
|
||
exit;
|
||
|
||
case 'admin_delete_role':
|
||
requireAuth();
|
||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$roleId = (int)($_POST['id'] ?? 0);
|
||
if ($roleId > 0) {
|
||
$pdo = dbPdo();
|
||
if ($pdo) {
|
||
$st = $pdo->prepare('DELETE FROM roles WHERE id = :id');
|
||
$st->execute([':id' => $roleId]);
|
||
}
|
||
}
|
||
header('Location: /admin/roles');
|
||
exit;
|
||
|
||
case 'admin_update_role_caps':
|
||
requireAuth();
|
||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$roleId = (int)($_POST['role_id'] ?? 0);
|
||
$newCaps = array_filter((array)($_POST['caps'] ?? []), fn ($c) => array_key_exists($c, KNOWN_CAPABILITIES));
|
||
if ($roleId > 0) {
|
||
$pdo = dbPdo();
|
||
if ($pdo) {
|
||
$pdo->prepare('DELETE FROM role_capabilities WHERE role_id = :id')->execute([':id' => $roleId]);
|
||
$ins = $pdo->prepare('INSERT INTO role_capabilities (role_id, capability) VALUES (:id, :cap)');
|
||
foreach ($newCaps as $cap) {
|
||
$ins->execute([':id' => $roleId, ':cap' => $cap]);
|
||
}
|
||
// Invalide le cache de capacités en session (affecte l'utilisateur courant)
|
||
unset($_SESSION['user_capabilities']);
|
||
}
|
||
}
|
||
header('Location: /admin/roles');
|
||
exit;
|
||
|
||
case 'admin_role_edit':
|
||
requireAuth();
|
||
if (!isAdmin()) {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$editRoleName = preg_replace('/[^a-z0-9_-]/', '', strtolower(trim($_GET['role_name'] ?? '')));
|
||
if (!$editRoleName) {
|
||
header('Location: /admin/roles');
|
||
exit;
|
||
}
|
||
$pdo = dbPdo();
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
if ($pdo) {
|
||
$newLabel = trim($_POST['label'] ?? '');
|
||
$newCaps = array_filter(
|
||
(array)($_POST['caps'] ?? []),
|
||
fn ($c) => array_key_exists($c, KNOWN_CAPABILITIES)
|
||
);
|
||
if ($newLabel) {
|
||
$pdo->prepare('UPDATE roles SET label = :l WHERE name = :n')
|
||
->execute([':l' => $newLabel, ':n' => $editRoleName]);
|
||
}
|
||
$st = $pdo->prepare('SELECT id FROM roles WHERE name = :n');
|
||
$st->execute([':n' => $editRoleName]);
|
||
$editRoleId = $st->fetchColumn();
|
||
if ($editRoleId) {
|
||
$pdo->prepare('DELETE FROM role_capabilities WHERE role_id = :id')
|
||
->execute([':id' => $editRoleId]);
|
||
$ins = $pdo->prepare('INSERT INTO role_capabilities (role_id, capability) VALUES (:id, :cap)');
|
||
foreach ($newCaps as $cap) {
|
||
$ins->execute([':id' => $editRoleId, ':cap' => $cap]);
|
||
}
|
||
}
|
||
unset($_SESSION['user_capabilities']);
|
||
}
|
||
header('Location: /admin/roles');
|
||
exit;
|
||
}
|
||
// GET — charge le rôle et ses capacités
|
||
$editRole = null;
|
||
$editRoleCaps = [];
|
||
if ($pdo) {
|
||
try {
|
||
$st = $pdo->prepare('SELECT id, name, label FROM roles WHERE name = :n');
|
||
$st->execute([':n' => $editRoleName]);
|
||
$editRole = $st->fetch(PDO::FETCH_ASSOC) ?: null;
|
||
} catch (\Throwable) {
|
||
}
|
||
if ($editRole) {
|
||
try {
|
||
$st = $pdo->prepare('SELECT capability FROM role_capabilities WHERE role_id = :id');
|
||
$st->execute([':id' => $editRole['id']]);
|
||
$editRoleCaps = $st->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
||
} catch (\Throwable) {
|
||
}
|
||
}
|
||
}
|
||
if (!$editRole) {
|
||
header('Location: /admin/roles');
|
||
exit;
|
||
}
|
||
include BASE_PATH . '/templates/admin_role_edit.php';
|
||
exit;
|
||
|
||
case 'profile':
|
||
requireAuth();
|
||
$profileError = '';
|
||
$profileSuccess = false;
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$newName = trim($_POST['display_name'] ?? '');
|
||
$newUrl = trim($_POST['profile_url'] ?? '');
|
||
if ($newUrl !== '' && !filter_var($newUrl, FILTER_VALIDATE_URL)) {
|
||
$newUrl = '';
|
||
}
|
||
$newBio = trim($_POST['bio'] ?? '');
|
||
if ($newName === '') {
|
||
$profileError = 'Le nom ne peut pas être vide.';
|
||
} else {
|
||
$pdo = dbPdo();
|
||
if ($pdo) {
|
||
try {
|
||
$newSlug = slugify($newName);
|
||
$st = $pdo->prepare(
|
||
'INSERT INTO user_profiles (email, display_name, profile_url, profile_slug, bio, updated_at)
|
||
VALUES (:e, :n, :u, :s, :b, now())
|
||
ON CONFLICT (email) DO UPDATE SET display_name = :n, profile_url = :u, profile_slug = :s, bio = :b, updated_at = now()'
|
||
);
|
||
$st->execute([':e' => currentUserEmail(), ':n' => $newName, ':u' => $newUrl, ':s' => $newSlug, ':b' => $newBio]);
|
||
$_SESSION['user_display_name'] = $newName;
|
||
$profileSuccess = true;
|
||
} catch (\Throwable $ex) {
|
||
$profileError = 'Erreur lors de la sauvegarde.';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
$profileCurrentName = currentUserName();
|
||
$_profileData = authorProfile(currentUserEmail() ?? '');
|
||
$profileCurrentUrl = $_profileData['url'];
|
||
$profileCurrentBio = $_profileData['bio'];
|
||
$profileCurrentSlug = $_profileData['slug'];
|
||
// Pré-remplir l'URL avec l'URL de profil public si vide
|
||
if ($profileCurrentUrl === '' && $profileCurrentSlug !== '') {
|
||
$profileCurrentUrl = rtrim(APP_URL, '/') . '/profil/' . rawurlencode($profileCurrentSlug);
|
||
}
|
||
// Liens de la page "Mes liens"
|
||
$profileLinks = [];
|
||
$pdo = dbPdo();
|
||
if ($pdo) {
|
||
try {
|
||
$st = $pdo->prepare('SELECT id, url, title, description FROM profile_links WHERE user_email = :e ORDER BY position, id');
|
||
$st->execute([':e' => currentUserEmail()]);
|
||
$profileLinks = $st->fetchAll(PDO::FETCH_ASSOC);
|
||
} catch (\Throwable) {
|
||
}
|
||
}
|
||
// Feeds RSS de l'utilisateur
|
||
$profileFeeds = [];
|
||
$pdo = dbPdo();
|
||
if ($pdo) {
|
||
try {
|
||
$st = $pdo->prepare('SELECT id, feed_url, label FROM rss_feeds WHERE user_email = :e ORDER BY created_at');
|
||
$st->execute([':e' => currentUserEmail()]);
|
||
$profileFeeds = $st->fetchAll(PDO::FETCH_ASSOC);
|
||
} catch (\Throwable) {
|
||
}
|
||
}
|
||
include BASE_PATH . '/templates/profile.php';
|
||
break;
|
||
|
||
case 'add_feed':
|
||
requireAuth();
|
||
$feedUrl = filter_var(trim($_POST['feed_url'] ?? ''), FILTER_VALIDATE_URL) ?: '';
|
||
$feedLabel = trim($_POST['feed_label'] ?? '');
|
||
if ($feedUrl !== '') {
|
||
$pdo = dbPdo();
|
||
if ($pdo) {
|
||
try {
|
||
$st = $pdo->prepare(
|
||
'INSERT INTO rss_feeds (user_email, feed_url, label) VALUES (:e, :u, :l)
|
||
ON CONFLICT (user_email, feed_url) DO UPDATE SET label = :l'
|
||
);
|
||
$st->execute([':e' => currentUserEmail(), ':u' => $feedUrl, ':l' => $feedLabel]);
|
||
} catch (\Throwable) {
|
||
}
|
||
}
|
||
}
|
||
header('Location: /profile#feeds');
|
||
exit;
|
||
|
||
case 'delete_feed':
|
||
requireAuth();
|
||
$feedId = (int)($_POST['feed_id'] ?? 0);
|
||
if ($feedId > 0) {
|
||
$pdo = dbPdo();
|
||
if ($pdo) {
|
||
try {
|
||
$st = $pdo->prepare('DELETE FROM rss_feeds WHERE id = :id AND user_email = :e');
|
||
$st->execute([':id' => $feedId, ':e' => currentUserEmail()]);
|
||
} catch (\Throwable) {
|
||
}
|
||
}
|
||
}
|
||
header('Location: /profile#feeds');
|
||
exit;
|
||
|
||
case 'search_files':
|
||
requireAuth();
|
||
header('Content-Type: application/json');
|
||
$q = trim($_GET['q'] ?? '');
|
||
$sfExclude = trim($_GET['exclude'] ?? '');
|
||
if ($q === '') {
|
||
echo json_encode([]);
|
||
exit;
|
||
}
|
||
require_once BASE_PATH . '/src/SearchEngine.php';
|
||
$sfPool = $articles->getSearchIndex() ?? $articles->getAll();
|
||
$sfResults = (new SearchEngine())->search($q, $sfPool);
|
||
$sfOut = [];
|
||
foreach ($sfResults as $r) {
|
||
$a = $r['article'];
|
||
$aId = $a['uuid'] ?? '';
|
||
if ($aId === '' || $aId === $sfExclude) {
|
||
continue;
|
||
}
|
||
$aFiles = $articles->getFiles($aId);
|
||
if (empty($aFiles)) {
|
||
continue;
|
||
}
|
||
$sfFiles = [];
|
||
foreach ($aFiles as $f) {
|
||
if (str_starts_with($f['name'], '_thumb_')) {
|
||
continue;
|
||
}
|
||
$sfFiles[] = [
|
||
'url' => '/file?uuid=' . rawurlencode($aId) . '&name=' . rawurlencode($f['name']),
|
||
'name' => $f['name'],
|
||
'mime' => $f['mime'],
|
||
'is_image' => $f['is_image'],
|
||
'size' => $f['size'],
|
||
];
|
||
}
|
||
if (empty($sfFiles)) {
|
||
continue;
|
||
}
|
||
$sfOut[] = [
|
||
'article' => ['uuid' => $aId, 'title' => $a['title'] ?? '', 'slug' => $a['slug'] ?? ''],
|
||
'files' => $sfFiles,
|
||
];
|
||
if (count($sfOut) >= 20) {
|
||
break;
|
||
}
|
||
}
|
||
echo json_encode($sfOut);
|
||
exit;
|
||
|
||
case 'search':
|
||
require_once BASE_PATH . '/src/SearchEngine.php';
|
||
$searchQuery = trim($_GET['q'] ?? '');
|
||
$searchResults = [];
|
||
if ($searchQuery !== '') {
|
||
$isAnonSearch = !isLoggedIn();
|
||
// Lecture du cache pour les visiteurs anonymes
|
||
if ($isAnonSearch) {
|
||
$searchResults = $articles->getSearchCache($searchQuery) ?? [];
|
||
}
|
||
if (empty($searchResults)) {
|
||
$privateCats = $articles->getPrivateCategories();
|
||
$rawPool = $articles->getSearchIndex() ?? $articles->getAll(true);
|
||
$searchPool = array_values(array_filter($rawPool, static function (array $a) use ($privateCats): bool {
|
||
if (!($a['published'] ?? false)) {
|
||
return false;
|
||
}
|
||
$cat = trim($a['category'] ?? '');
|
||
if ($cat !== '' && in_array($cat, $privateCats, true) && !isLoggedIn()) {
|
||
return false;
|
||
}
|
||
if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}));
|
||
$searchResults = (new SearchEngine())->search($searchQuery, $searchPool);
|
||
if ($isAnonSearch && !empty($searchResults)) {
|
||
$articles->setSearchCache($searchQuery, $searchResults);
|
||
}
|
||
}
|
||
}
|
||
include BASE_PATH . '/templates/search.php';
|
||
break;
|
||
|
||
case 'book':
|
||
$bookSlug = trim($_GET['book_slug'] ?? '');
|
||
$book = $books->getBySlug($bookSlug);
|
||
if (!$book) {
|
||
http_response_code(404);
|
||
ob_start();
|
||
?>
|
||
<div class="container py-5 text-center">
|
||
<h1 class="h2 mb-3">Livre introuvable</h1>
|
||
<p class="text-muted mb-4">Ce livre n'existe pas ou a été supprimé.</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;
|
||
}
|
||
$bookArticles = [];
|
||
foreach ($book['articles'] ?? [] as $aSlug) {
|
||
$a = $articles->getBySlug($aSlug);
|
||
if (!$a) {
|
||
continue;
|
||
}
|
||
if (!$a['published'] && !canDoOnArticle('view_drafts', $a)) {
|
||
continue;
|
||
}
|
||
$bookArticles[] = $a;
|
||
}
|
||
$allCats = $articles->getCategories();
|
||
include BASE_PATH . '/templates/book.php';
|
||
break;
|
||
|
||
case 'book_save':
|
||
requireAuth();
|
||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$bSlug = trim($_POST['slug'] ?? '');
|
||
$bTitle = trim($_POST['title'] ?? '');
|
||
$bDesc = trim($_POST['description'] ?? '');
|
||
$bArts = array_values(array_filter(array_map('trim', preg_split('/[\r\n]+/', $_POST['articles'] ?? ''))));
|
||
if ($bSlug !== '' && $bTitle !== '') {
|
||
$books->save(['slug' => $bSlug, 'title' => $bTitle, 'description' => $bDesc, 'articles' => $bArts]);
|
||
}
|
||
header('Location: /admin/books?saved=1&edit=' . rawurlencode($bSlug));
|
||
exit;
|
||
|
||
case 'book_delete':
|
||
requireAuth();
|
||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
$bSlug = trim($_POST['slug'] ?? '');
|
||
if ($bSlug !== '') {
|
||
$books->delete($bSlug);
|
||
}
|
||
header('Location: /admin/books?deleted=1');
|
||
exit;
|
||
|
||
case 'not_found':
|
||
$notFoundPath = trim(
|
||
(string)(parse_url($_SERVER['REDIRECT_URL'] ?? $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? ''),
|
||
'/'
|
||
);
|
||
if ($notFoundPath !== '') {
|
||
searchAndRedirect(basename($notFoundPath), $articles);
|
||
}
|
||
http_response_code(404);
|
||
ob_start();
|
||
?>
|
||
<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 (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) {
|
||
return false;
|
||
}
|
||
if ($filterCat !== '' && $cat !== $filterCat) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}));
|
||
$perPage = postsPerPage();
|
||
$cursor = trim($_GET['cursor'] ?? '');
|
||
|
||
// Trouve la position du curseur dans la liste triée
|
||
$offset = 0;
|
||
if ($cursor !== '') {
|
||
foreach ($allPosts as $i => $a) {
|
||
if ($a['uuid'] === $cursor) {
|
||
$offset = $i + 1;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
$posts = array_slice($allPosts, $offset, $perPage);
|
||
$nextCursor = count($posts) === $perPage ? end($posts)['uuid'] : null;
|
||
$prevCursor = null;
|
||
if ($offset > 0) {
|
||
$prevOffset = max(0, $offset - $perPage);
|
||
$prevCursor = $prevOffset > 0 ? $allPosts[$prevOffset - 1]['uuid'] : '';
|
||
}
|
||
|
||
// Compteurs pour le hero de la page d'accueil
|
||
$totalPublished = 0;
|
||
$totalUpcoming = 0;
|
||
$_now = time();
|
||
foreach ($articles->getSearchIndex() ?? [] as $_a) {
|
||
if (!($_a['published'] ?? false)) {
|
||
continue;
|
||
}
|
||
$_cat = trim($_a['category'] ?? '');
|
||
if ($_cat !== '' && in_array($_cat, $privateCats, true) && !isLoggedIn()) {
|
||
continue;
|
||
}
|
||
if (strtotime((string)($_a['published_at'] ?? '')) > $_now) {
|
||
$totalUpcoming++;
|
||
} else {
|
||
$totalPublished++;
|
||
}
|
||
}
|
||
unset($_now, $_a, $_cat);
|
||
|
||
// ─── Sections spécifiques à la page d'accueil ─────────────────────
|
||
$isHomepage = ($cursor === '' && $filterCat === '');
|
||
$heroPost = null;
|
||
$latestPosts = [];
|
||
$popularPosts = [];
|
||
$recentlyUpdated = [];
|
||
$redecouvertes = [];
|
||
$featuredArticle = null;
|
||
|
||
if ($isHomepage) {
|
||
// Article mis en avant
|
||
foreach ($allPosts as $_hp) {
|
||
if (!empty($_hp['featured'])) {
|
||
$featuredArticle = $_hp;
|
||
break;
|
||
}
|
||
}
|
||
$heroPost = $featuredArticle ?? ($allPosts[0] ?? null);
|
||
|
||
// 5 articles les plus récents (hors hero)
|
||
$_heroUuid = $heroPost['uuid'] ?? '';
|
||
$_count = 0;
|
||
foreach ($allPosts as $_hp) {
|
||
if ($_hp['uuid'] === $_heroUuid) {
|
||
continue;
|
||
}
|
||
$latestPosts[] = $_hp;
|
||
if (++$_count >= 5) {
|
||
break;
|
||
}
|
||
}
|
||
unset($_heroUuid, $_count, $_hp);
|
||
|
||
$allPostsMap = array_column($allPosts, null, 'uuid');
|
||
$_slugMap = array_column($allPosts, null, 'slug');
|
||
|
||
// Tendances 1 h — lecture seule du cache généré par /trending?period=1h
|
||
$_trendCache = DATA_PATH . '/_cache/trending_1h.json';
|
||
$_trendPaths = null;
|
||
if (file_exists($_trendCache) && (time() - filemtime($_trendCache)) < 720) {
|
||
$_trendPaths = json_decode((string) file_get_contents($_trendCache), true) ?: null;
|
||
}
|
||
if (!empty($_trendPaths)) {
|
||
foreach ($_trendPaths as $_path => $_cnt) {
|
||
if (count($popularPosts) >= 6) {
|
||
break;
|
||
}
|
||
if (!preg_match('#^/post/([^/]+)$#', $_path, $_m)) {
|
||
continue;
|
||
}
|
||
$_a = $_slugMap[rawurldecode($_m[1])] ?? null;
|
||
if ($_a !== null) {
|
||
$popularPosts[] = $_a;
|
||
}
|
||
}
|
||
unset($_path, $_cnt, $_m, $_a);
|
||
}
|
||
unset($_trendCache, $_trendPaths, $_slugMap);
|
||
|
||
// Fallback : score pondéré DB (réactions, notes, commentaires sur 10 j)
|
||
$_pdo = dbPdo();
|
||
if ($_pdo) {
|
||
if (empty($popularPosts)) {
|
||
try {
|
||
$_stmt = $_pdo->query("
|
||
SELECT article_uuid, SUM(score) AS total
|
||
FROM (
|
||
SELECT article_uuid, 1 AS score FROM article_reactions
|
||
WHERE created_at >= NOW() - INTERVAL '10 days'
|
||
UNION ALL
|
||
SELECT article_uuid, 2 AS score FROM article_ratings
|
||
WHERE rated_at >= NOW() - INTERVAL '10 days'
|
||
UNION ALL
|
||
SELECT article_uuid, 3 AS score FROM comments
|
||
WHERE created_at >= NOW() - INTERVAL '10 days' AND published = TRUE
|
||
) ev
|
||
GROUP BY article_uuid
|
||
ORDER BY total DESC
|
||
LIMIT 20
|
||
");
|
||
foreach ($_stmt->fetchAll(PDO::FETCH_ASSOC) as $_row) {
|
||
if (count($popularPosts) >= 6) {
|
||
break;
|
||
}
|
||
$_uuid = $_row['article_uuid'];
|
||
if (!isset($allPostsMap[$_uuid])) {
|
||
continue;
|
||
}
|
||
$popularPosts[] = $allPostsMap[$_uuid];
|
||
}
|
||
} catch (Throwable) {
|
||
}
|
||
}
|
||
|
||
// Redécouvertes : anciens articles (> 30 j) avec activité récente
|
||
try {
|
||
$_stmt = $_pdo->query("
|
||
SELECT DISTINCT article_uuid FROM (
|
||
SELECT article_uuid FROM article_reactions
|
||
WHERE created_at >= NOW() - INTERVAL '10 days'
|
||
UNION
|
||
SELECT article_uuid FROM comments
|
||
WHERE created_at >= NOW() - INTERVAL '10 days' AND published = TRUE
|
||
) ev
|
||
");
|
||
$_thirtyDaysAgo = date('Y-m-d H:i:s', time() - 30 * 86400);
|
||
$_latestUuids = array_column($latestPosts, 'uuid');
|
||
$_popularUuids = array_column($popularPosts, 'uuid');
|
||
$_heroUuid = $heroPost['uuid'] ?? '';
|
||
foreach ($_stmt->fetchAll(PDO::FETCH_COLUMN, 0) as $_uuid) {
|
||
if (count($redecouvertes) >= 4) {
|
||
break;
|
||
}
|
||
if (!isset($allPostsMap[$_uuid])) {
|
||
continue;
|
||
}
|
||
$_a = $allPostsMap[$_uuid];
|
||
if (($_a['published_at'] ?? '') >= $_thirtyDaysAgo) {
|
||
continue;
|
||
}
|
||
if ($_a['uuid'] === $_heroUuid) {
|
||
continue;
|
||
}
|
||
if (in_array($_uuid, $_latestUuids, true)) {
|
||
continue;
|
||
}
|
||
if (in_array($_uuid, $_popularUuids, true)) {
|
||
continue;
|
||
}
|
||
$redecouvertes[] = $_a;
|
||
}
|
||
unset($_thirtyDaysAgo, $_latestUuids, $_popularUuids, $_heroUuid, $_uuid, $_a);
|
||
} catch (Throwable) {
|
||
}
|
||
}
|
||
unset($_pdo, $_stmt, $_row);
|
||
|
||
// Récemment mis à jour (7 derniers jours, pas dans les autres sections)
|
||
$_sevenDaysAgo = date('Y-m-d H:i:s', time() - 7 * 86400);
|
||
$_latestUuids = array_column($latestPosts, 'uuid');
|
||
$_popularUuids = array_column($popularPosts, 'uuid');
|
||
$_heroUuid = $heroPost['uuid'] ?? '';
|
||
foreach ($allPosts as $_a) {
|
||
if (count($recentlyUpdated) >= 4) {
|
||
break;
|
||
}
|
||
if ($_a['uuid'] === $_heroUuid) {
|
||
continue;
|
||
}
|
||
if (in_array($_a['uuid'], $_latestUuids, true)) {
|
||
continue;
|
||
}
|
||
if (in_array($_a['uuid'], $_popularUuids, true)) {
|
||
continue;
|
||
}
|
||
if (($_a['updated_at'] ?? '') < $_sevenDaysAgo) {
|
||
continue;
|
||
}
|
||
if (($_a['published_at'] ?? '') >= $_sevenDaysAgo) {
|
||
continue;
|
||
} // déjà récent dans latest
|
||
$recentlyUpdated[] = $_a;
|
||
}
|
||
unset($_sevenDaysAgo, $_latestUuids, $_popularUuids, $_heroUuid, $_a, $allPostsMap);
|
||
}
|
||
// ──────────────────────────────────────────────────────────────────
|
||
|
||
include BASE_PATH . '/templates/post_list.php';
|
||
break;
|
||
}
|