master #1
@@ -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
|
||||
@@ -5,3 +5,4 @@
|
||||
/logs/*
|
||||
!/logs/README.md
|
||||
.phpunit.result.cache
|
||||
data.sqlite3
|
||||
|
||||
@@ -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"]
|
||||
@@ -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');
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CreateBlogVisits extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
// create table if it does not exist
|
||||
$table = $this->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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
memory_limit = 1G
|
||||
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
phpinfo();
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Actions\Admin;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Twig\Environment;
|
||||
use PDO;
|
||||
|
||||
class BlogVisitsAction
|
||||
{
|
||||
private PDO $pdo;
|
||||
private Environment $twig;
|
||||
|
||||
public function __construct(PDO $pdo, Environment $twig)
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends 'layout.twig' %}
|
||||
|
||||
{% block title %}Blog Visits - Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Blog Visits</h1>
|
||||
|
||||
<h2>Top visitors (by hits)</h2>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>IP</th><th>User-Agent</th><th>Count</th><th>First seen</th><th>Last seen</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for v in visitors %}
|
||||
<tr>
|
||||
<td>{{ v.ip }}</td>
|
||||
<td style="max-width:40ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">{{ v.useragent }}</td>
|
||||
<td>{{ v.cnt }}</td>
|
||||
<td>{{ v.first_seen }}</td>
|
||||
<td>{{ v.last_seen }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5">No visitors yet</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Recent detail log</h2>
|
||||
<table class="table">
|
||||
<thead><tr><th>Timestamp</th><th>IP</th><th>URL</th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in details %}
|
||||
<tr>
|
||||
<td>{{ d.timestamp }}</td>
|
||||
<td>{{ d.ip }}</td>
|
||||
<td style="max-width:60ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">{{ d.url }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3">No details</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user