From 0b23946116e2a361170e6eb0e90224adeeaf00bb Mon Sep 17 00:00:00 2001 From: Sebastian Molenda Date: Wed, 13 May 2026 22:40:06 +0200 Subject: [PATCH] =?UTF-8?q?Pierdu=20pierdu=20vibe=20coding=20na=20pe=C5=82?= =?UTF-8?q?nej=20kurwie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- molenda.net/.gitea/workflows/deploy.yml | 74 +++++++++ molenda.net/.gitignore | 1 + molenda.net/Dockerfile | 27 ++++ molenda.net/app/routes.php | 1 + molenda.net/data.sqlite3 | Bin 28672 -> 36864 bytes .../20260513000000_create_blog_visits.php | 23 +++ molenda.net/docker-compose.dev.yml | 36 +++++ molenda.net/docker/nginx/default.conf | 25 +++ molenda.net/docker/php/memory.ini | 1 + molenda.net/public/info.php | 2 + .../Actions/Admin/BlogVisitsAction.php | 50 ++++++ .../Application/Actions/BlogIndexAction.php | 56 +++++++ .../Application/Actions/BlogPostAction.php | 83 ++++++++++ .../src/Application/Services/BlogProvider.php | 147 +++++++++++++----- molenda.net/templates/admin/blog_visits.twig | 44 ++++++ 15 files changed, 532 insertions(+), 38 deletions(-) create mode 100644 molenda.net/.gitea/workflows/deploy.yml create mode 100644 molenda.net/Dockerfile create mode 100644 molenda.net/db/migrations/20260513000000_create_blog_visits.php create mode 100644 molenda.net/docker-compose.dev.yml create mode 100644 molenda.net/docker/nginx/default.conf create mode 100644 molenda.net/docker/php/memory.ini create mode 100644 molenda.net/public/info.php create mode 100644 molenda.net/src/Application/Actions/Admin/BlogVisitsAction.php create mode 100644 molenda.net/templates/admin/blog_visits.twig diff --git a/molenda.net/.gitea/workflows/deploy.yml b/molenda.net/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..7fcdb06 --- /dev/null +++ b/molenda.net/.gitea/workflows/deploy.yml @@ -0,0 +1,74 @@ +name: Deploy on merge to main + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + # ── 1. Checkout ────────────────────────────────────────────────────────── + - name: Checkout repository + uses: actions/checkout@v4 + + # ── 2. PHP + Composer ──────────────────────────────────────────────────── + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: pdo, pdo_sqlite, sqlite3 + + - name: Install Composer dependencies (production only) + run: composer install --no-dev --optimize-autoloader + + # ── 3. Pobierz aktualną bazę SQLite z serwera ──────────────────────────── + # data.sqlite3 z repo NIE trafia na serwer – ściągamy produkcyjną wersję, + # uruchamiamy na niej migracje i wgrywamy z powrotem. + - name: Install lftp + run: sudo apt-get install -y lftp + + - name: Download production data.sqlite3 via FTP + run: | + lftp -u "${{ secrets.FTP_USER }}","${{ secrets.FTP_PASS }}" \ + -e "set ftp:ssl-allow no; \ + set ssl:verify-certificate no; \ + get ${{ secrets.FTP_REMOTE_DIR }}/data.sqlite3 -o ./data.sqlite3; \ + quit" \ + ${{ secrets.FTP_HOST }} + + # ── 4. Uruchom migracje Phinx na pobranej bazie ────────────────────────── + # phinx.php: 'name' => __DIR__ . '/data' → plik data.sqlite3 + - name: Run Phinx migrations + run: ./vendor/bin/phinx migrate -e main + + # ── 5. Wgraj pliki na serwer (z zaktualizowaną bazą) ──────────────────── + - name: Deploy files to server via FTP + uses: SamKirkland/FTP-Deploy-Action@v4.3.5 + with: + server: ${{ secrets.FTP_HOST }} + username: ${{ secrets.FTP_USER }} + password: ${{ secrets.FTP_PASS }} + local-dir: ./ + server-dir: ${{ secrets.FTP_REMOTE_DIR }}/ + exclude: | + **/.git/** + **/.gitea/** + **/tests/** + **/docker/** + **/html_template/** + **/lib/** + **/var/cache/** + **/logs/** + docker-compose*.yml + Dockerfile + phpunit.xml + phpcs.xml + phpstan.neon.dist + README.md + CONTRIBUTING.md + .env* + local.data.sqlite3 + server.data.sqlite3 diff --git a/molenda.net/.gitignore b/molenda.net/.gitignore index 63066a6..991a4bb 100644 --- a/molenda.net/.gitignore +++ b/molenda.net/.gitignore @@ -5,3 +5,4 @@ /logs/* !/logs/README.md .phpunit.result.cache +data.sqlite3 diff --git a/molenda.net/Dockerfile b/molenda.net/Dockerfile new file mode 100644 index 0000000..bf6a5da --- /dev/null +++ b/molenda.net/Dockerfile @@ -0,0 +1,27 @@ +FROM php:8.3-fpm + +WORKDIR /var/www + +# Install system dependencies and PHP extensions +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + unzip \ + zip \ + libzip-dev \ + sqlite3 \ + libsqlite3-dev \ + libonig-dev \ + && docker-php-ext-configure zip \ + && docker-php-ext-install -j$(nproc) pdo pdo_sqlite zip \ + && rm -rf /var/lib/apt/lists/* + +# Install Composer from the official image +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +# Ensure www-data owns the working dir +RUN chown -R www-data:www-data /var/www || true + +EXPOSE 9000 + +CMD ["php-fpm"] diff --git a/molenda.net/app/routes.php b/molenda.net/app/routes.php index 53ff97d..0a94962 100644 --- a/molenda.net/app/routes.php +++ b/molenda.net/app/routes.php @@ -35,6 +35,7 @@ return function (App $app) { // Protected admin area $app->group('/admin', function (Group $group) { $group->get('', \App\Application\Actions\Admin\DashboardAction::class); + $group->get('/blog-visits', \App\Application\Actions\Admin\BlogVisitsAction::class); $group->get('/contents', \App\Application\Actions\Content\ContentCrudAction::class . ':list'); $group->get('/access-logs', \App\Application\Actions\Admin\AccessLogsAction::class); $group->map(['GET', 'POST'], '/contents/create', \App\Application\Actions\Content\ContentCrudAction::class . ':create'); diff --git a/molenda.net/data.sqlite3 b/molenda.net/data.sqlite3 index e8dd3e0c8ff14d034f438aec2acf4cc1d2d7e6d1..f93f83d7662b6354f0ac6d7659805655513bc613 100644 GIT binary patch delta 679 zcmZp8z}T>WX@az%ECT}rClJE`*F+s-Sy=|XszP4=9}FB^Cm8q+@E_;X!ghB$kh6DwFKZOJ&Z3zk*37G{63XZ|fItg%d!F5 zab;!3X3dhsq?}YFZAeTGsKp_UPCl+kW>2o;HLnL59p)J1?BN)sVPtBmso)pt_CdF^5pD%Q`Iw=c3fal2j*PXoi6U z)5yTcOxM6v*U(tO$iT|b(8|aRi_m04`O7>k{8t(Hzwy83f53lrv!KFJ{>cyY*;qk_ zvTS~!uOa|ca2%xI1^;#a<3I)5`33k`n3Xw^LU8kSe@z80USP^#e= zW#nLxZY*bHVJXea&dV>)n|wMiLBm|n00b3+6VnolGWAW3&Gn4Un6p@J3jnb#7ub+m Y2L4*0A;tWBjf|{7BRG)^5XEW$0O#w;j{pDw delta 137 zcmZozz|`=7ae}mc4H@!sL};<4B) zD6p5Cxk-p^vjxu?W-exaLk9jm{H6Scn*|k=`6nC7Uj~XjVBr78|C;{+Q0xf*!~nj{ Z5A;<8fa2Fd;xG8GL&aG)U-#Ek000=IBiR4| diff --git a/molenda.net/db/migrations/20260513000000_create_blog_visits.php b/molenda.net/db/migrations/20260513000000_create_blog_visits.php new file mode 100644 index 0000000..f01d8a3 --- /dev/null +++ b/molenda.net/db/migrations/20260513000000_create_blog_visits.php @@ -0,0 +1,23 @@ +table('blog_visits'); + if (!$table->exists()) { + $table->addColumn('ip', 'string', ['null' => true, 'limit' => 255]) + ->addColumn('useragent', 'text', ['null' => true]) + ->addColumn('cnt', 'integer', ['default' => 0]) + ->addColumn('first_seen', 'integer', ['null' => true]) + ->addColumn('last_seen', 'integer', ['null' => true]) + ->addIndex(['ip', 'useragent'], ['unique' => true, 'name' => 'idx_ip_useragent']) + ->create(); + } + } +} diff --git a/molenda.net/docker-compose.dev.yml b/molenda.net/docker-compose.dev.yml new file mode 100644 index 0000000..0bf33d8 --- /dev/null +++ b/molenda.net/docker-compose.dev.yml @@ -0,0 +1,36 @@ +version: '3.8' + +services: + php: + build: + context: . + dockerfile: Dockerfile + working_dir: /var/www + volumes: + - ./:/var/www:cached + - ./docker/php/memory.ini:/usr/local/etc/php/conf.d/memory.ini:ro + environment: + COMPOSER_ALLOW_SUPERUSER: "1" + mem_limit: 1G + deploy: + resources: + limits: + memory: 1G + networks: + - appnet + + nginx: + image: nginx:1.25-alpine + ports: + - "8080:80" + volumes: + - ./:/var/www:ro + - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - php + networks: + - appnet + +networks: + appnet: + driver: bridge diff --git a/molenda.net/docker/nginx/default.conf b/molenda.net/docker/nginx/default.conf new file mode 100644 index 0000000..415833b --- /dev/null +++ b/molenda.net/docker/nginx/default.conf @@ -0,0 +1,25 @@ +server { + listen 80; + server_name _; + root /var/www/public; + index index.php index.html; + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + include fastcgi_params; + fastcgi_pass php:9000; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_index index.php; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires max; + log_not_found off; + } +} diff --git a/molenda.net/docker/php/memory.ini b/molenda.net/docker/php/memory.ini new file mode 100644 index 0000000..a6a203a --- /dev/null +++ b/molenda.net/docker/php/memory.ini @@ -0,0 +1 @@ +memory_limit = 1G diff --git a/molenda.net/public/info.php b/molenda.net/public/info.php new file mode 100644 index 0000000..61ace19 --- /dev/null +++ b/molenda.net/public/info.php @@ -0,0 +1,2 @@ +pdo = $pdo; + $this->twig = $twig; + } + + public function __invoke(Request $request, Response $response, array $args): Response + { + // get top aggregates + $stmt = $this->pdo->query('SELECT ip, useragent, cnt, datetime(first_seen, "unixepoch") AS first_seen, datetime(last_seen, "unixepoch") AS last_seen FROM blog_visits ORDER BY cnt DESC LIMIT 100'); + $visitors = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // read last 200 lines from detail log (if exists) + $detailPath = dirname(__DIR__, 3) . '/logs/blog_detail.log'; + $recentDetails = []; + if (is_readable($detailPath)) { + $lines = array_filter(array_map('rtrim', file($detailPath))); + $lines = array_slice($lines, -200); + foreach ($lines as $line) { + // format: timestamp \t ip \t url + $parts = preg_split('/\t+/', $line); + $recentDetails[] = [ + 'timestamp' => $parts[0] ?? '', + 'ip' => $parts[1] ?? '', + 'url' => $parts[2] ?? '' + ]; + } + } + + $html = $this->twig->render('admin/blog_visits.twig', ['visitors' => $visitors, 'details' => $recentDetails]); + $response->getBody()->write($html); + return $response->withHeader('Content-Type', 'text/html'); + } +} diff --git a/molenda.net/src/Application/Actions/BlogIndexAction.php b/molenda.net/src/Application/Actions/BlogIndexAction.php index 1a0c364..9efb1d0 100644 --- a/molenda.net/src/Application/Actions/BlogIndexAction.php +++ b/molenda.net/src/Application/Actions/BlogIndexAction.php @@ -32,8 +32,64 @@ class BlogIndexAction $row = $stmt->fetch(PDO::FETCH_ASSOC); $links = $row ? json_decode($row['content'], true) : []; + // log this blog page visit (aggregate + detailed) + try { + $this->logBlogVisit($request); + } catch (\Throwable $e) { + // ignore logging errors + } + $html = $this->twig->render('blog/index.twig', ['articles' => $articles, 'links' => $links]); $response->getBody()->write($html); return $response->withHeader('Content-Type', 'text/html'); } + + private function logBlogVisit(Request $request): void + { + // detailed file log: timestamp, ip, url + $server = $request->getServerParams(); + $xff = $request->getHeaderLine('X-Forwarded-For'); + $ip = trim(explode(',', $xff)[0] ?? ($server['REMOTE_ADDR'] ?? '')) ?: 'unknown'; + $userAgent = $request->getHeaderLine('User-Agent') ?: ''; + $url = (string)$request->getUri(); + + $logDir = dirname(__DIR__, 3) . '/logs'; + $detailPath = $logDir . '/blog_detail.log'; + + // append detail line + @file_put_contents($detailPath, sprintf("%s\t%s\t%s\n", date('Y-m-d H:i:s'), $ip, $url), FILE_APPEND | LOCK_EX); + + // update aggregate counts in SQLite table + $now = time(); + // create table if needed and safely increment count + $this->pdo->beginTransaction(); + try { + $this->pdo->exec("CREATE TABLE IF NOT EXISTS blog_visits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT, + useragent TEXT, + cnt INTEGER DEFAULT 0, + first_seen INTEGER, + last_seen INTEGER, + UNIQUE(ip, useragent) + )"); + + $select = $this->pdo->prepare('SELECT id, cnt FROM blog_visits WHERE ip = :ip AND useragent = :ua'); + $select->execute([':ip' => $ip, ':ua' => $userAgent]); + $row = $select->fetch(PDO::FETCH_ASSOC); + + if ($row) { + $update = $this->pdo->prepare('UPDATE blog_visits SET cnt = cnt + 1, last_seen = :last WHERE id = :id'); + $update->execute([':last' => $now, ':id' => $row['id']]); + } else { + $insert = $this->pdo->prepare('INSERT INTO blog_visits (ip, useragent, cnt, first_seen, last_seen) VALUES (:ip, :ua, 1, :t, :t)'); + $insert->execute([':ip' => $ip, ':ua' => $userAgent, ':t' => $now]); + } + + $this->pdo->commit(); + } catch (\Throwable $e) { + $this->pdo->rollBack(); + throw $e; + } + } } diff --git a/molenda.net/src/Application/Actions/BlogPostAction.php b/molenda.net/src/Application/Actions/BlogPostAction.php index 2c3d5dd..e19f4c1 100644 --- a/molenda.net/src/Application/Actions/BlogPostAction.php +++ b/molenda.net/src/Application/Actions/BlogPostAction.php @@ -25,12 +25,32 @@ class BlogPostAction public function __invoke(Request $request, Response $response, array $args): Response { + $start = microtime(true); + $stub = $args['stub']; $article = $this->blogProvider->getByStub($stub); if (!$article) { $html = $this->twig->render('404.twig'); $response->getBody()->write($html); + + // log timing for failed request as well + try { + $end = microtime(true); + $us = (int)(($end - $start) * 1_000_000); + $url = (string)$request->getUri(); + $logPath = dirname(__DIR__, 3) . '/logs/times.log'; + @file_put_contents($logPath, sprintf("%d %s\n", $us, $url), FILE_APPEND | LOCK_EX); + // blog visit logging for not found post + try { + $this->logBlogVisit($request); + } catch (\Throwable $e) { + // ignore logging errors + } + } catch (\Throwable $e) { + // ignore logging errors + } + return $response->withStatus(404)->withHeader('Content-Type', 'text/html'); } @@ -43,6 +63,69 @@ class BlogPostAction $html = $this->twig->render('blog/post.twig', ['article' => $article, 'links' => $links, 'alsoReading' => $alsoReading]); $response->getBody()->write($html); + + // timing end and log + try { + $end = microtime(true); + $us = (int)(($end - $start) * 1_000_000); + $url = (string)$request->getUri(); + $logPath = dirname(__DIR__, 3) . '/logs/times.log'; + @file_put_contents($logPath, sprintf("%d %s\n", $us, $url), FILE_APPEND | LOCK_EX); + // blog visit logging for successful post + try { + $this->logBlogVisit($request); + } catch (\Throwable $e) { + // ignore logging errors + } + } catch (\Throwable $e) { + // ignore logging errors + } + return $response->withHeader('Content-Type', 'text/html'); } + + private function logBlogVisit(Request $request): void + { + $server = $request->getServerParams(); + $xff = $request->getHeaderLine('X-Forwarded-For'); + $ip = trim(explode(',', $xff)[0] ?? ($server['REMOTE_ADDR'] ?? '')) ?: 'unknown'; + $userAgent = $request->getHeaderLine('User-Agent') ?: ''; + $url = (string)$request->getUri(); + + $logDir = dirname(__DIR__, 3) . '/logs'; + $detailPath = $logDir . '/blog_detail.log'; + + @file_put_contents($detailPath, sprintf("%s\t%s\t%s\n", date('Y-m-d H:i:s'), $ip, $url), FILE_APPEND | LOCK_EX); + + $now = time(); + $this->pdo->beginTransaction(); + try { + $this->pdo->exec("CREATE TABLE IF NOT EXISTS blog_visits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT, + useragent TEXT, + cnt INTEGER DEFAULT 0, + first_seen INTEGER, + last_seen INTEGER, + UNIQUE(ip, useragent) + )"); + + $select = $this->pdo->prepare('SELECT id, cnt FROM blog_visits WHERE ip = :ip AND useragent = :ua'); + $select->execute([':ip' => $ip, ':ua' => $userAgent]); + $row = $select->fetch(PDO::FETCH_ASSOC); + + if ($row) { + $update = $this->pdo->prepare('UPDATE blog_visits SET cnt = cnt + 1, last_seen = :last WHERE id = :id'); + $update->execute([':last' => $now, ':id' => $row['id']]); + } else { + $insert = $this->pdo->prepare('INSERT INTO blog_visits (ip, useragent, cnt, first_seen, last_seen) VALUES (:ip, :ua, 1, :t, :t)'); + $insert->execute([':ip' => $ip, ':ua' => $userAgent, ':t' => $now]); + } + + $this->pdo->commit(); + } catch (\Throwable $e) { + $this->pdo->rollBack(); + throw $e; + } + } } diff --git a/molenda.net/src/Application/Services/BlogProvider.php b/molenda.net/src/Application/Services/BlogProvider.php index c5f90fb..e539c04 100644 --- a/molenda.net/src/Application/Services/BlogProvider.php +++ b/molenda.net/src/Application/Services/BlogProvider.php @@ -6,61 +6,104 @@ namespace App\Application\Services; class BlogProvider { + private array $tableCache = []; + private string $cacheDir; + + public function __construct() + { + $this->cacheDir = dirname(__DIR__, 3) . '/var/cache/markov'; + if (!is_dir($this->cacheDir)) { + @mkdir($this->cacheDir, 0775, true); + } + } + private function generate_markov_table($text, $look_forward = 4) { - $table = array(); + // build counts in a single pass and compute cumulative weights for fast selection + $len = strlen($text); + $table = []; - // now walk through the text and make the index table - for ($i = 0; $i < strlen($text); $i++) { - $char = substr($text, $i, $look_forward); - if (!isset($table[$char])) $table[$char] = array(); - } - - // walk the array again and count the numbers - for ($i = 0; $i < (strlen($text) - $look_forward); $i++) { - $char_index = substr($text, $i, $look_forward); - $char_count = substr($text, $i+$look_forward, $look_forward); - - if (isset($table[$char_index][$char_count])) { - $table[$char_index][$char_count]++; + // single pass to build counts + $max = $len - $look_forward; + for ($i = 0; $i < $max; $i++) { + $key = substr($text, $i, $look_forward); + $next = substr($text, $i + $look_forward, $look_forward); + if (!isset($table[$key])) $table[$key] = []; + if (isset($table[$key][$next])) { + $table[$key][$next]++; } else { - $table[$char_index][$char_count] = 1; + $table[$key][$next] = 1; } } + // convert counts into cumulative arrays with totals for fast selection + // to save memory we map next-token strings to integer ids and keep a shared token list + $tokenIndex = []; + $tokenList = []; + $nextId = 0; + + foreach ($table as $key => $counts) { + $cum = []; + $sum = 0; + foreach ($counts as $item => $weight) { + if (!isset($tokenIndex[$item])) { + $tokenIndex[$item] = $nextId; + $tokenList[$nextId] = $item; + $nextId++; + } + $id = $tokenIndex[$item]; + $sum += $weight; + $cum[] = ['id' => $id, 'cum' => $sum]; + } + $table[$key] = ['total' => $sum, 'cum' => $cum]; + } + + // attach token list for this table so picker can map ids back to strings + $table['_tokens'] = $tokenList; + return $table; } private function generate_markov_text($length, $table, $look_forward = 4) { - // get first character - $char = array_rand($table); + // pick a random starting state + $states = array_keys($table); + if (!$states) return ''; + $char = $states[array_rand($states)]; $o = $char; - for ($i = 0; $i < ($length / $look_forward); $i++) { - $newchar = $this->return_weighted_char($table[$char]); - - if ($newchar) { - $char = $newchar; - $o .= $newchar; - } else { - $char = array_rand($table); + $iterations = (int)ceil($length / max(1, $look_forward)); + for ($i = 0; $i < $iterations; $i++) { + $entry = $table[$char] ?? null; + if ($entry && !empty($entry['cum'])) { + $newId = $this->pick_weighted($entry); + if ($newId !== false) { + $tokenList = $table['_tokens'] ?? []; + $newchar = $tokenList[$newId] ?? false; + if ($newchar !== false) { + $char = $newchar; + $o .= $newchar; + continue; + } + } } + // fallback: pick another random state + $char = $states[array_rand($states)]; + $o .= $char; } return $o; } - private function return_weighted_char($array) + private function pick_weighted(array $entry) { - if (!$array) return false; - - $total = array_sum($array); - $rand = mt_rand(1, $total); - foreach ($array as $item => $weight) { - if ($rand <= $weight) return $item; - $rand -= $weight; + // entry contains 'total' and 'cum' keys + if (empty($entry['cum']) || empty($entry['total'])) return false; + $rand = mt_rand(1, $entry['total']); + foreach ($entry['cum'] as $pair) { + if ($rand <= $pair['cum']) return $pair['id']; } + return false; } private function generate($length) @@ -69,9 +112,8 @@ class BlogProvider if (!is_readable($path)) { throw new \RuntimeException("Missing data file: {$path}"); } - $text = file_get_contents($path); - $table = $this->generate_markov_table($text); + $table = $this->getTableForFile($path); return $this->generate_markov_text($length, $table); } @@ -81,10 +123,39 @@ class BlogProvider if (!is_readable($path)) { throw new \RuntimeException("Missing data file: {$path}"); } - $text = file_get_contents($path); - $table = $this->generate_markov_table($text); - return $this->generate_markov_text($length, $table); + $table = $this->getTableForFile($path); + return $this->generate_markov_text($length, $table, 4); + } + + private function getTableForFile(string $path, int $look_forward = 4): array + { + $cacheKey = md5($path . '|' . $look_forward); + if (isset($this->tableCache[$cacheKey])) return $this->tableCache[$cacheKey]; + + $cacheFile = $this->cacheDir . '/markov_' . $cacheKey . '.ser'; + $srcMTime = filemtime($path) ?: 0; + + if (is_readable($cacheFile)) { + $data = @unserialize(@file_get_contents($cacheFile)); + if (is_array($data) && isset($data['mtime']) && $data['mtime'] === $srcMTime && isset($data['table'])) { + $this->tableCache[$cacheKey] = $data['table']; + return $data['table']; + } + } + + $text = file_get_contents($path); + $table = $this->generate_markov_table($text, $look_forward); + + // write cache best-effort + try { + @file_put_contents($cacheFile, serialize(['mtime' => $srcMTime, 'table' => $table]), LOCK_EX); + } catch (\Throwable $e) { + // ignore + } + + $this->tableCache[$cacheKey] = $table; + return $table; } private function stubify(string $title): string diff --git a/molenda.net/templates/admin/blog_visits.twig b/molenda.net/templates/admin/blog_visits.twig new file mode 100644 index 0000000..1fc9f00 --- /dev/null +++ b/molenda.net/templates/admin/blog_visits.twig @@ -0,0 +1,44 @@ +{% extends 'layout.twig' %} + +{% block title %}Blog Visits - Admin{% endblock %} + +{% block content %} +

Blog Visits

+ +

Top visitors (by hits)

+ + + + + + {% for v in visitors %} + + + + + + + + {% else %} + + {% endfor %} + +
IPUser-AgentCountFirst seenLast seen
{{ v.ip }}{{ v.useragent }}{{ v.cnt }}{{ v.first_seen }}{{ v.last_seen }}
No visitors yet
+ +

Recent detail log

+ + + + {% for d in details %} + + + + + + {% else %} + + {% endfor %} + +
TimestampIPURL
{{ d.timestamp }}{{ d.ip }}{{ d.url }}
No details
+ +{% endblock %}