Pierdu pierdu vibe coding na pełnej kurwie

This commit is contained in:
Sebastian Molenda
2026-05-13 22:40:06 +02:00
parent ab96d82fcf
commit 0b23946116
15 changed files with 532 additions and 38 deletions
+74
View File
@@ -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
+1
View File
@@ -5,3 +5,4 @@
/logs/*
!/logs/README.md
.phpunit.result.cache
data.sqlite3
+27
View File
@@ -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"]
+1
View File
@@ -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();
}
}
}
+36
View File
@@ -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
+25
View File
@@ -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;
}
}
+1
View File
@@ -0,0 +1 @@
memory_limit = 1G
+2
View File
@@ -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 %}