Fix newest homepage detection, file creation dates, real IP, and request log visitor type

- Fix detectNewestPage() to search subdirectories recursively and handle language prefixes
- Fix getFileInfo() to preserve frontmatter created date or ctime instead of overwriting with mtime
- Automatically store created date in frontmatter when creating/editing files
- Add RequestLogger::getClientIp() with proxy and Cloudflare header support
- Replace domain column in request log with visitor/bot type badges (Human, AI, Search, Scraper)
- Update 'Activiteitenlog' to 'Activiteiten log' in UI and guide
This commit is contained in:
2026-07-28 16:56:51 +02:00
parent bcbb297116
commit 0626e8c6cc
9 changed files with 256 additions and 44 deletions
+99 -6
View File
@@ -90,7 +90,11 @@ class CodePressCMS {
{
if ($this->effectiveDefaultPage === null) {
$page = $this->config['default_page'] ?? 'auto';
$this->effectiveDefaultPage = ($page === 'auto') ? $this->detectDefaultPage() : $page;
$this->effectiveDefaultPage = match ($page) {
'auto' => $this->detectDefaultPage(),
'newest' => $this->detectNewestPage(),
default => $page,
};
}
return $this->effectiveDefaultPage;
}
@@ -438,7 +442,7 @@ class CodePressCMS {
}
if (isset($result) && $actualFilePath) {
$result['file_info'] = $this->getFileInfo($actualFilePath);
$result['file_info'] = $this->getFileInfo($actualFilePath, $result['metadata'] ?? []);
return $result;
}
@@ -449,19 +453,31 @@ class CodePressCMS {
* Get file information including creation and modification dates
*
* @param string $filePath Path to the file
* @param array $metadata Optional frontmatter metadata
* @return array|null File information or null if file doesn't exist
*/
private function getFileInfo($filePath) {
private function getFileInfo($filePath, array $metadata = []) {
if (!file_exists($filePath)) {
return null;
}
$stats = stat($filePath);
// Use birthtime if available (macOS/BSD), fall back to mtime on Linux where ctime is inode change time
$createdTimestamp = $stats['birthtime'] ?? $stats['mtime'];
$created = date('d-m-Y H:i', $createdTimestamp);
$modified = date('d-m-Y H:i', $stats['mtime']);
// 1. Check if created date is present in frontmatter metadata
$metaCreated = $metadata['created'] ?? $metadata['date'] ?? $metadata['date_created'] ?? $metadata['created_at'] ?? null;
if ($metaCreated) {
if (is_numeric($metaCreated)) {
$created = date('d-m-Y H:i', (int)$metaCreated);
} else {
$created = (string)$metaCreated;
}
} else {
// 2. Fallback to ctime (inode change time / creation time) or mtime
$createdTimestamp = $stats['ctime'] ?? $stats['mtime'];
$created = date('d-m-Y H:i', $createdTimestamp);
}
return [
'created' => $created,
'modified' => $modified,
@@ -1366,6 +1382,83 @@ class CodePressCMS {
return $candidates[0]['name'];
}
/**
* Detect the newest modified page (last modified timestamp)
*
* @return string Page key that was most recently modified or created
*/
private function detectNewestPage(): string
{
$contentDir = $this->config['content_dir'];
$realContentDir = realpath($contentDir);
if (!$realContentDir || !is_dir($realContentDir)) return 'index';
$availableLangs = array_keys($this->getAvailableLanguages());
if (empty($availableLangs)) {
$availableLangs = ['nl', 'en'];
}
$langRegex = implode('|', array_map('preg_quote', $availableLangs));
$candidates = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($realContentDir, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $fileInfo) {
if (!$fileInfo->isFile()) continue;
$ext = strtolower($fileInfo->getExtension());
if (!in_array($ext, ['md', 'php', 'html'])) continue;
$filePath = $fileInfo->getRealPath();
$relative = substr($filePath, strlen($realContentDir) + 1);
// Skip hidden files/directories (starting with . or -)
$parts = explode('/', str_replace('\\', '/', $relative));
$skip = false;
foreach ($parts as $p) {
if ($p !== '' && ($p[0] === '.' || $p[0] === '-')) {
$skip = true;
break;
}
}
if ($skip) continue;
// Strip extension
$base = preg_replace('/\.(md|php|html)$/i', '', $relative);
// Handle language prefix e.g. nl.test -> test, or dir/en.page -> dir/page
$filename = basename($base);
$dirPrefix = dirname($base);
$dirPrefix = ($dirPrefix === '.' || $dirPrefix === '') ? '' : $dirPrefix . '/';
if (preg_match('/^(' . $langRegex . ')\.(.+)$/i', $filename, $m)) {
$pageKey = $dirPrefix . $m[2];
} else {
$pageKey = $base;
}
// If it ends with /index, e.g. folder/index -> folder
if (str_ends_with($pageKey, '/index')) {
$pageKey = substr($pageKey, 0, -6);
}
$mtime = $fileInfo->getMTime();
$candidates[] = [
'key' => $pageKey ?: 'index',
'mtime' => $mtime
];
}
if (empty($candidates)) return 'index';
usort($candidates, function ($a, $b) {
return $b['mtime'] <=> $a['mtime'];
});
return $candidates[0]['key'];
}
/**
* Get homepage title
*
+116 -1
View File
@@ -23,6 +23,114 @@ class RequestLogger
@file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX);
}
public static function getClientIp(): string
{
$headers = [
'HTTP_CF_CONNECTING_IP',
'HTTP_X_REAL_IP',
'HTTP_CLIENT_IP',
'HTTP_X_FORWARDED_FOR',
'REMOTE_ADDR',
];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
$rawIp = $_SERVER[$header];
if (str_contains($rawIp, ',')) {
$ips = explode(',', $rawIp);
$rawIp = trim($ips[0]);
}
$rawIp = trim($rawIp);
if (filter_var($rawIp, FILTER_VALIDATE_IP) !== false) {
return $rawIp;
}
}
}
return $_SERVER['REMOTE_ADDR'] ?? 'cli';
}
public static function detectVisitorInfo(string $ua, string $user = ''): array
{
if (empty($ua)) {
$userLabel = ($user && $user !== 'Gast') ? $user : 'Onbekend';
return ['type' => 'unknown', 'label' => $userLabel, 'badge' => 'secondary', 'icon' => 'bi-question-circle'];
}
$bots = [
'AI' => [
'GPTBot' => 'AI (GPTBot)',
'ChatGPT-User' => 'AI (ChatGPT)',
'Claude-Web' => 'AI (Claude)',
'ClaudeBot' => 'AI (ClaudeBot)',
'anthropic-ai' => 'AI (Anthropic)',
'Google-Extended' => 'AI (Gemini/Google)',
'CCBot' => 'AI (CommonCrawl)',
'PerplexityBot' => 'AI (Perplexity)',
'Amazonbot' => 'AI (Amazon)',
'cohere-ai' => 'AI (Cohere)',
'OAI-SearchBot' => 'AI (OpenAI)',
'Bytespider' => 'AI (ByteDance)',
'FacebookBot' => 'AI (Meta/FB)',
'Applebot-Extended' => 'AI (Apple)',
],
'Search' => [
'Googlebot' => 'Zoekmachine (Google)',
'Bingbot' => 'Zoekmachine (Bing)',
'BingPreview' => 'Zoekmachine (Bing)',
'Slurp' => 'Zoekmachine (Yahoo)',
'DuckDuckBot' => 'Zoekmachine (DuckDuckGo)',
'Baiduspider' => 'Zoekmachine (Baidu)',
'YandexBot' => 'Zoekmachine (Yandex)',
'Sogou' => 'Zoekmachine (Sogou)',
'Exabot' => 'Zoekmachine (Exabot)',
'facebot' => 'Zoekmachine (Facebook)',
],
'Scraper' => [
'HTTrack' => 'Scraper (HTTrack)',
'Scrapy' => 'Scraper (Scrapy)',
'PhantomJS' => 'Scraper (PhantomJS)',
'HeadlessChrome' => 'Scraper (Headless)',
'curl' => 'Scraper (cURL)',
'wget' => 'Scraper (Wget)',
'python' => 'Scraper (Python)',
'Postman' => 'Scraper (Postman)',
],
];
foreach ($bots['AI'] as $pattern => $label) {
if (stripos($ua, $pattern) !== false) {
return ['type' => 'ai', 'label' => $label, 'badge' => 'danger', 'icon' => 'bi-robot'];
}
}
foreach ($bots['Search'] as $pattern => $label) {
if (stripos($ua, $pattern) !== false) {
return ['type' => 'search', 'label' => $label, 'badge' => 'primary', 'icon' => 'bi-search'];
}
}
foreach ($bots['Scraper'] as $pattern => $label) {
if (stripos($ua, $pattern) !== false) {
return ['type' => 'scraper', 'label' => $label, 'badge' => 'warning text-dark', 'icon' => 'bi-bug'];
}
}
if (preg_match('/(bot|crawler|spider|slurp)/i', $ua)) {
return ['type' => 'bot', 'label' => 'Bot', 'badge' => 'secondary', 'icon' => 'bi-robot'];
}
$userPrefix = ($user && $user !== 'Gast') ? htmlspecialchars($user) . ' (' : '';
$userSuffix = ($user && $user !== 'Gast') ? ')' : '';
return [
'type' => 'human',
'label' => $userPrefix . 'Mens' . $userSuffix,
'badge' => 'success',
'icon' => 'bi-person-check',
];
}
public static function detectBot(): ?string
{
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
@@ -69,10 +177,17 @@ class RequestLogger
foreach ($content as $line) {
if (preg_match('/^\[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] \[([^\]]*)\] \[([^\]]+)\] \[([^\]]*)\] \[([^\]]*)\]$/', trim($line), $m)) {
$user = $m[3];
// Handle backwards compatibility where host (e.g. localhost:8080 or domain.com) was logged
if (str_contains($user, '.') || str_contains($user, ':') || $user === 'cli') {
$user = 'Gast';
}
$visitorInfo = self::detectVisitorInfo($m[6], $user);
$logs[] = [
'time' => $m[1],
'ip' => $m[2],
'host' => $m[3],
'user' => $user,
'visitor_info' => $visitorInfo,
'lang' => $m[4],
'page' => $m[5],
'ua' => $m[6],