Pierdu pierdu vibe coding na pełnej kurwie
This commit is contained in:
@@ -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/*
|
||||||
!/logs/README.md
|
!/logs/README.md
|
||||||
.phpunit.result.cache
|
.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
|
// Protected admin area
|
||||||
$app->group('/admin', function (Group $group) {
|
$app->group('/admin', function (Group $group) {
|
||||||
$group->get('', \App\Application\Actions\Admin\DashboardAction::class);
|
$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('/contents', \App\Application\Actions\Content\ContentCrudAction::class . ':list');
|
||||||
$group->get('/access-logs', \App\Application\Actions\Admin\AccessLogsAction::class);
|
$group->get('/access-logs', \App\Application\Actions\Admin\AccessLogsAction::class);
|
||||||
$group->map(['GET', 'POST'], '/contents/create', \App\Application\Actions\Content\ContentCrudAction::class . ':create');
|
$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);
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
$links = $row ? json_decode($row['content'], true) : [];
|
$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]);
|
$html = $this->twig->render('blog/index.twig', ['articles' => $articles, 'links' => $links]);
|
||||||
$response->getBody()->write($html);
|
$response->getBody()->write($html);
|
||||||
return $response->withHeader('Content-Type', 'text/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
|
public function __invoke(Request $request, Response $response, array $args): Response
|
||||||
{
|
{
|
||||||
|
$start = microtime(true);
|
||||||
|
|
||||||
$stub = $args['stub'];
|
$stub = $args['stub'];
|
||||||
$article = $this->blogProvider->getByStub($stub);
|
$article = $this->blogProvider->getByStub($stub);
|
||||||
|
|
||||||
if (!$article) {
|
if (!$article) {
|
||||||
$html = $this->twig->render('404.twig');
|
$html = $this->twig->render('404.twig');
|
||||||
$response->getBody()->write($html);
|
$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');
|
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]);
|
$html = $this->twig->render('blog/post.twig', ['article' => $article, 'links' => $links, 'alsoReading' => $alsoReading]);
|
||||||
$response->getBody()->write($html);
|
$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');
|
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
|
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)
|
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
|
// single pass to build counts
|
||||||
for ($i = 0; $i < strlen($text); $i++) {
|
$max = $len - $look_forward;
|
||||||
$char = substr($text, $i, $look_forward);
|
for ($i = 0; $i < $max; $i++) {
|
||||||
if (!isset($table[$char])) $table[$char] = array();
|
$key = substr($text, $i, $look_forward);
|
||||||
}
|
$next = substr($text, $i + $look_forward, $look_forward);
|
||||||
|
if (!isset($table[$key])) $table[$key] = [];
|
||||||
// walk the array again and count the numbers
|
if (isset($table[$key][$next])) {
|
||||||
for ($i = 0; $i < (strlen($text) - $look_forward); $i++) {
|
$table[$key][$next]++;
|
||||||
$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]++;
|
|
||||||
} else {
|
} 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;
|
return $table;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generate_markov_text($length, $table, $look_forward = 4)
|
private function generate_markov_text($length, $table, $look_forward = 4)
|
||||||
{
|
{
|
||||||
// get first character
|
// pick a random starting state
|
||||||
$char = array_rand($table);
|
$states = array_keys($table);
|
||||||
|
if (!$states) return '';
|
||||||
|
$char = $states[array_rand($states)];
|
||||||
$o = $char;
|
$o = $char;
|
||||||
|
|
||||||
for ($i = 0; $i < ($length / $look_forward); $i++) {
|
$iterations = (int)ceil($length / max(1, $look_forward));
|
||||||
$newchar = $this->return_weighted_char($table[$char]);
|
for ($i = 0; $i < $iterations; $i++) {
|
||||||
|
$entry = $table[$char] ?? null;
|
||||||
if ($newchar) {
|
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;
|
$char = $newchar;
|
||||||
$o .= $newchar;
|
$o .= $newchar;
|
||||||
} else {
|
continue;
|
||||||
$char = array_rand($table);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// fallback: pick another random state
|
||||||
|
$char = $states[array_rand($states)];
|
||||||
|
$o .= $char;
|
||||||
|
}
|
||||||
|
|
||||||
return $o;
|
return $o;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function return_weighted_char($array)
|
private function pick_weighted(array $entry)
|
||||||
{
|
{
|
||||||
if (!$array) return false;
|
// entry contains 'total' and 'cum' keys
|
||||||
|
if (empty($entry['cum']) || empty($entry['total'])) return false;
|
||||||
$total = array_sum($array);
|
$rand = mt_rand(1, $entry['total']);
|
||||||
$rand = mt_rand(1, $total);
|
foreach ($entry['cum'] as $pair) {
|
||||||
foreach ($array as $item => $weight) {
|
if ($rand <= $pair['cum']) return $pair['id'];
|
||||||
if ($rand <= $weight) return $item;
|
|
||||||
$rand -= $weight;
|
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generate($length)
|
private function generate($length)
|
||||||
@@ -69,9 +112,8 @@ class BlogProvider
|
|||||||
if (!is_readable($path)) {
|
if (!is_readable($path)) {
|
||||||
throw new \RuntimeException("Missing data file: {$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);
|
return $this->generate_markov_text($length, $table);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,10 +123,39 @@ class BlogProvider
|
|||||||
if (!is_readable($path)) {
|
if (!is_readable($path)) {
|
||||||
throw new \RuntimeException("Missing data file: {$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);
|
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
|
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