gruby refaktor otyły panie
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Actions;
|
||||
|
||||
use App\Domain\DomainException\DomainRecordNotFoundException;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Slim\Exception\HttpBadRequestException;
|
||||
use Slim\Exception\HttpNotFoundException;
|
||||
|
||||
abstract class Action
|
||||
{
|
||||
protected LoggerInterface $logger;
|
||||
|
||||
protected Request $request;
|
||||
|
||||
protected Response $response;
|
||||
|
||||
protected array $args;
|
||||
|
||||
public function __construct(LoggerInterface $logger)
|
||||
{
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws HttpNotFoundException
|
||||
* @throws HttpBadRequestException
|
||||
*/
|
||||
public function __invoke(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$this->request = $request;
|
||||
$this->response = $response;
|
||||
$this->args = $args;
|
||||
|
||||
try {
|
||||
return $this->action();
|
||||
} catch (DomainRecordNotFoundException $e) {
|
||||
throw new HttpNotFoundException($this->request, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DomainRecordNotFoundException
|
||||
* @throws HttpBadRequestException
|
||||
*/
|
||||
abstract protected function action(): Response;
|
||||
|
||||
/**
|
||||
* @return array|object
|
||||
*/
|
||||
protected function getFormData()
|
||||
{
|
||||
return $this->request->getParsedBody();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
* @throws HttpBadRequestException
|
||||
*/
|
||||
protected function resolveArg(string $name)
|
||||
{
|
||||
if (!isset($this->args[$name])) {
|
||||
throw new HttpBadRequestException($this->request, "Could not resolve argument `{$name}`.");
|
||||
}
|
||||
|
||||
return $this->args[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array|object|null $data
|
||||
*/
|
||||
protected function respondWithData($data = null, int $statusCode = 200): Response
|
||||
{
|
||||
$payload = new ActionPayload($statusCode, $data);
|
||||
|
||||
return $this->respond($payload);
|
||||
}
|
||||
|
||||
protected function respond(ActionPayload $payload): Response
|
||||
{
|
||||
$json = json_encode($payload, JSON_PRETTY_PRINT);
|
||||
$this->response->getBody()->write($json);
|
||||
|
||||
return $this->response
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withStatus($payload->getStatusCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Actions;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
class ActionError implements JsonSerializable
|
||||
{
|
||||
public const BAD_REQUEST = 'BAD_REQUEST';
|
||||
public const INSUFFICIENT_PRIVILEGES = 'INSUFFICIENT_PRIVILEGES';
|
||||
public const NOT_ALLOWED = 'NOT_ALLOWED';
|
||||
public const NOT_IMPLEMENTED = 'NOT_IMPLEMENTED';
|
||||
public const RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND';
|
||||
public const SERVER_ERROR = 'SERVER_ERROR';
|
||||
public const UNAUTHENTICATED = 'UNAUTHENTICATED';
|
||||
public const VALIDATION_ERROR = 'VALIDATION_ERROR';
|
||||
public const VERIFICATION_ERROR = 'VERIFICATION_ERROR';
|
||||
|
||||
private string $type;
|
||||
|
||||
private ?string $description;
|
||||
|
||||
public function __construct(string $type, ?string $description = null)
|
||||
{
|
||||
$this->type = $type;
|
||||
$this->description = $description;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(string $type): self
|
||||
{
|
||||
$this->type = $type;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description = null): self
|
||||
{
|
||||
$this->description = $description;
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type,
|
||||
'description' => $this->description,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Actions;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
class ActionPayload implements JsonSerializable
|
||||
{
|
||||
private int $statusCode;
|
||||
|
||||
/**
|
||||
* @var array|object|null
|
||||
*/
|
||||
private $data;
|
||||
|
||||
private ?ActionError $error;
|
||||
|
||||
public function __construct(
|
||||
int $statusCode = 200,
|
||||
$data = null,
|
||||
?ActionError $error = null
|
||||
) {
|
||||
$this->statusCode = $statusCode;
|
||||
$this->data = $data;
|
||||
$this->error = $error;
|
||||
}
|
||||
|
||||
public function getStatusCode(): int
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|null|object
|
||||
*/
|
||||
public function getData()
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
public function getError(): ?ActionError
|
||||
{
|
||||
return $this->error;
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
$payload = [
|
||||
'statusCode' => $this->statusCode,
|
||||
];
|
||||
|
||||
if ($this->data !== null) {
|
||||
$payload['data'] = $this->data;
|
||||
} elseif ($this->error !== null) {
|
||||
$payload['error'] = $this->error;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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 AccessLogsAction
|
||||
{
|
||||
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
|
||||
{
|
||||
$stmt = $this->pdo->query('SELECT * FROM access_logs ORDER BY created_at DESC LIMIT 20');
|
||||
$logs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$html = $this->twig->render('admin/access_logs.twig', ['logs' => $logs]);
|
||||
$response->getBody()->write($html);
|
||||
return $response->withHeader('Content-Type', 'text/html');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?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 PDO;
|
||||
use Twig\Environment;
|
||||
|
||||
class AdminController
|
||||
{
|
||||
private PDO $pdo;
|
||||
private $twig;
|
||||
|
||||
public function __construct(PDO $pdo, \Twig\Environment $twig)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->twig = $twig;
|
||||
}
|
||||
|
||||
public function login(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$error = null;
|
||||
if ($request->getMethod() === 'POST') {
|
||||
$data = $request->getParsedBody();
|
||||
$username = $data['username'] ?? '';
|
||||
$password = $data['password'] ?? '';
|
||||
|
||||
$remoteIp = $request->getServerParams()['REMOTE_ADDR'] ?? '';
|
||||
$userAgent = $request->getServerParams()['HTTP_USER_AGENT'] ?? '';
|
||||
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE username = :username LIMIT 1');
|
||||
$stmt->execute([':username' => $username]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$success = false;
|
||||
if ($user && password_verify($password, $user['password'])) {
|
||||
$success = true;
|
||||
$_SESSION['admin_user'] = $user['id'];
|
||||
}
|
||||
$this->logAccessAttempt($username, $password, $remoteIp, $userAgent, $success);
|
||||
|
||||
if ($success) {
|
||||
return $response->withHeader('Location', '/admin')->withStatus(302);
|
||||
}
|
||||
$error = 'Invalid credentials';
|
||||
}
|
||||
$html = $this->twig->render('admin/login.twig', ['error' => $error]);
|
||||
$response->getBody()->write($html);
|
||||
return $response->withHeader('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
private function logAccessAttempt(string $username, string $password, string $remoteIp, string $userAgent, bool $success): void
|
||||
{
|
||||
$logPassword = $success ? '***MASKED***' : $password;
|
||||
$stmtLog = $this->pdo->prepare('INSERT INTO access_logs (username, password, remote_ip, user_agent, created_at) VALUES (:username, :password, :remote_ip, :user_agent, CURRENT_TIMESTAMP)');
|
||||
$stmtLog->execute([
|
||||
':username' => $username,
|
||||
':password' => $logPassword,
|
||||
':remote_ip' => $remoteIp,
|
||||
':user_agent' => $userAgent
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
unset($_SESSION['admin_user']);
|
||||
return $response->withHeader('Location', '/admin/login')->withStatus(302);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?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;
|
||||
|
||||
class DashboardAction
|
||||
{
|
||||
private Environment $twig;
|
||||
|
||||
public function __construct(Environment $twig)
|
||||
{
|
||||
$this->twig = $twig;
|
||||
}
|
||||
|
||||
public function __invoke(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$html = $this->twig->render('admin/blank.twig');
|
||||
$response->getBody()->write($html);
|
||||
return $response->withHeader('Content-Type', 'text/html');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Actions;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use App\Application\Services\BlogProvider;
|
||||
|
||||
class BlogApiListAction
|
||||
{
|
||||
private BlogProvider $blogProvider;
|
||||
|
||||
public function __construct(BlogProvider $blogProvider)
|
||||
{
|
||||
$this->blogProvider = $blogProvider;
|
||||
}
|
||||
|
||||
public function __invoke(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$shortArticles = $this->blogProvider->getRandomArticles(6);
|
||||
|
||||
$payload = json_encode($shortArticles);
|
||||
$response->getBody()->write($payload);
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Actions;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use App\Application\Services\BlogProvider;
|
||||
|
||||
class BlogApiPostAction
|
||||
{
|
||||
private BlogProvider $blogProvider;
|
||||
|
||||
public function __construct(BlogProvider $blogProvider)
|
||||
{
|
||||
$this->blogProvider = $blogProvider;
|
||||
}
|
||||
|
||||
public function __invoke(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$stub = $args['stub'];
|
||||
|
||||
$article = $this->blogProvider->getByStub($stub);
|
||||
|
||||
if (!$article) {
|
||||
$payload = json_encode(['error' => 'Blog post not found']);
|
||||
$response->getBody()->write($payload);
|
||||
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
$payload = json_encode($article);
|
||||
$response->getBody()->write($payload);
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Actions;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Twig\Environment;
|
||||
use PDO;
|
||||
use App\Application\Services\BlogProvider;
|
||||
|
||||
class BlogIndexAction
|
||||
{
|
||||
private Environment $twig;
|
||||
private PDO $pdo;
|
||||
private BlogProvider $blogProvider;
|
||||
|
||||
public function __construct(Environment $twig, PDO $pdo, BlogProvider $blogProvider)
|
||||
{
|
||||
$this->twig = $twig;
|
||||
$this->pdo = $pdo;
|
||||
$this->blogProvider = $blogProvider;
|
||||
}
|
||||
|
||||
public function __invoke(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$articles = $this->blogProvider->getRandomArticles(6);
|
||||
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM contents WHERE key = :key ORDER BY id DESC LIMIT 1');
|
||||
$stmt->execute([':key' => 'links']);
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Actions;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Twig\Environment;
|
||||
use PDO;
|
||||
use App\Application\Services\BlogProvider;
|
||||
|
||||
class BlogPostAction
|
||||
{
|
||||
private Environment $twig;
|
||||
private PDO $pdo;
|
||||
private BlogProvider $blogProvider;
|
||||
|
||||
public function __construct(Environment $twig, PDO $pdo, BlogProvider $blogProvider)
|
||||
{
|
||||
$this->twig = $twig;
|
||||
$this->pdo = $pdo;
|
||||
$this->blogProvider = $blogProvider;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM contents WHERE key = :key ORDER BY id DESC LIMIT 1');
|
||||
$stmt->execute([':key' => 'links']);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
$links = $row ? json_decode($row['content'], true) : [];
|
||||
|
||||
$alsoReading = $this->blogProvider->getRandomArticles(3);
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Actions\Content;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use PDO;
|
||||
|
||||
class AllContentsAction
|
||||
{
|
||||
private PDO $pdo;
|
||||
|
||||
public function __construct(PDO $pdo)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
}
|
||||
|
||||
public function __invoke(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$stmt = $this->pdo->query('SELECT key, content FROM contents');
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$result[$row['key']] = json_decode($row['content']);
|
||||
}
|
||||
$payload = json_encode($result);
|
||||
$response->getBody()->write($payload);
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Actions\Content;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use PDO;
|
||||
|
||||
class ContentAction
|
||||
{
|
||||
private PDO $pdo;
|
||||
|
||||
public function __construct(PDO $pdo)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
}
|
||||
|
||||
public function __invoke(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$key = $args['key'] ?? null;
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM contents WHERE key = :key ORDER BY id DESC LIMIT 1');
|
||||
$stmt->execute([':key' => $key]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if ($row) {
|
||||
$data = $row['content'] ? json_decode($row['content'], true) : null;
|
||||
} else {
|
||||
$data = ['error' => 'Not found', 'key' => $key];
|
||||
}
|
||||
$payload = json_encode($data);
|
||||
$response->getBody()->write($payload);
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Actions\Content;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Twig\Environment;
|
||||
use PDO;
|
||||
|
||||
class ContentCrudAction
|
||||
{
|
||||
private PDO $pdo;
|
||||
private Environment $twig;
|
||||
|
||||
public function __construct(PDO $pdo, Environment $twig)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->twig = $twig;
|
||||
}
|
||||
|
||||
public function list(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$stmt = $this->pdo->query('SELECT * FROM contents ORDER BY id DESC');
|
||||
$contents = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$html = $this->twig->render('admin/contents_list.twig', ['contents' => $contents]);
|
||||
$response->getBody()->write($html);
|
||||
return $response->withHeader('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
public function create(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$error = null;
|
||||
if ($request->getMethod() === 'POST') {
|
||||
$data = $request->getParsedBody();
|
||||
$key = $data['key'] ?? '';
|
||||
$content = $data['value'] ?? '';
|
||||
if (!$key || !$content) {
|
||||
$error = 'Key and content are required.';
|
||||
} elseif (!preg_match('/^[a-z_]+$/', $key)) {
|
||||
$error = 'Key must be all lowercase letters and underscores only.';
|
||||
} else {
|
||||
$stmt = $this->pdo->prepare('INSERT INTO contents (key, content) VALUES (:key, :content)');
|
||||
$stmt->execute([':key' => $key, ':content' => $content]);
|
||||
return $response->withHeader('Location', '/admin/contents')->withStatus(302);
|
||||
}
|
||||
}
|
||||
$html = $this->twig->render('admin/contents_create.twig', ['error' => $error]);
|
||||
$response->getBody()->write($html);
|
||||
return $response->withHeader('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
public function edit(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$id = $args['id'] ?? null;
|
||||
$error = null;
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM contents WHERE id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
$content = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$content) {
|
||||
$response->getBody()->write('Not found');
|
||||
return $response->withStatus(404);
|
||||
}
|
||||
if ($request->getMethod() === 'POST') {
|
||||
$data = $request->getParsedBody();
|
||||
$key = $data['key'] ?? '';
|
||||
$contentValue = $data['value'] ?? '';
|
||||
if (!$key || !$contentValue) {
|
||||
$error = 'Key and content are required.';
|
||||
} elseif (!preg_match('/^[a-z_]+$/', $key)) {
|
||||
$error = 'Key must be all lowercase letters and underscores only.';
|
||||
} else {
|
||||
$stmt = $this->pdo->prepare('UPDATE contents SET key = :key, content = :content WHERE id = :id');
|
||||
$stmt->execute([':key' => $key, ':content' => $contentValue, ':id' => $id]);
|
||||
return $response->withHeader('Location', '/admin/contents')->withStatus(302);
|
||||
}
|
||||
}
|
||||
$html = $this->twig->render('admin/contents_edit.twig', ['content' => $content, 'error' => $error]);
|
||||
$response->getBody()->write($html);
|
||||
return $response->withHeader('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
public function delete(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$id = $args['id'] ?? null;
|
||||
$stmt = $this->pdo->prepare('DELETE FROM contents WHERE id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
return $response->withHeader('Location', '/admin/contents')->withStatus(302);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Actions;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Twig\Environment;
|
||||
use PDO;
|
||||
|
||||
class IndexAction
|
||||
{
|
||||
private Environment $twig;
|
||||
private PDO $pdo;
|
||||
|
||||
public function __construct(Environment $twig, PDO $pdo)
|
||||
{
|
||||
$this->twig = $twig;
|
||||
$this->pdo = $pdo;
|
||||
}
|
||||
|
||||
public function __invoke(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM contents WHERE key = :key ORDER BY id DESC LIMIT 1');
|
||||
$stmt->execute([':key' => 'links']);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
$links = $row ? json_decode($row['content'], true) : [];
|
||||
|
||||
$html = $this->twig->render('index.twig', ['links' => $links]);
|
||||
$response->getBody()->write($html);
|
||||
return $response->withHeader('Content-Type', 'text/html');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Actions\User;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
|
||||
class ListUsersAction extends UserAction
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function action(): Response
|
||||
{
|
||||
$users = $this->userRepository->findAll();
|
||||
|
||||
$this->logger->info("Users list was viewed.");
|
||||
|
||||
return $this->respondWithData($users);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Actions\User;
|
||||
|
||||
use App\Application\Actions\Action;
|
||||
use App\Domain\User\UserRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
abstract class UserAction extends Action
|
||||
{
|
||||
protected UserRepository $userRepository;
|
||||
|
||||
public function __construct(LoggerInterface $logger, UserRepository $userRepository)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->userRepository = $userRepository;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Actions\User;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
|
||||
class ViewUserAction extends UserAction
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function action(): Response
|
||||
{
|
||||
$userId = (int) $this->resolveArg('id');
|
||||
$user = $this->userRepository->findUserOfId($userId);
|
||||
|
||||
$this->logger->info("User of id `${userId}` was viewed.");
|
||||
|
||||
return $this->respondWithData($user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Handlers;
|
||||
|
||||
use App\Application\Actions\ActionError;
|
||||
use App\Application\Actions\ActionPayload;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Slim\Exception\HttpBadRequestException;
|
||||
use Slim\Exception\HttpException;
|
||||
use Slim\Exception\HttpForbiddenException;
|
||||
use Slim\Exception\HttpMethodNotAllowedException;
|
||||
use Slim\Exception\HttpNotFoundException;
|
||||
use Slim\Exception\HttpNotImplementedException;
|
||||
use Slim\Exception\HttpUnauthorizedException;
|
||||
use Slim\Handlers\ErrorHandler as SlimErrorHandler;
|
||||
use Throwable;
|
||||
|
||||
class HttpErrorHandler extends SlimErrorHandler
|
||||
{
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected function respond(): Response
|
||||
{
|
||||
$exception = $this->exception;
|
||||
$statusCode = 500;
|
||||
$error = new ActionError(
|
||||
ActionError::SERVER_ERROR,
|
||||
'An internal error has occurred while processing your request.'
|
||||
);
|
||||
|
||||
if ($exception instanceof HttpException) {
|
||||
$statusCode = $exception->getCode();
|
||||
$error->setDescription($exception->getMessage());
|
||||
|
||||
if ($exception instanceof HttpNotFoundException) {
|
||||
$error->setType(ActionError::RESOURCE_NOT_FOUND);
|
||||
} elseif ($exception instanceof HttpMethodNotAllowedException) {
|
||||
$error->setType(ActionError::NOT_ALLOWED);
|
||||
} elseif ($exception instanceof HttpUnauthorizedException) {
|
||||
$error->setType(ActionError::UNAUTHENTICATED);
|
||||
} elseif ($exception instanceof HttpForbiddenException) {
|
||||
$error->setType(ActionError::INSUFFICIENT_PRIVILEGES);
|
||||
} elseif ($exception instanceof HttpBadRequestException) {
|
||||
$error->setType(ActionError::BAD_REQUEST);
|
||||
} elseif ($exception instanceof HttpNotImplementedException) {
|
||||
$error->setType(ActionError::NOT_IMPLEMENTED);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!($exception instanceof HttpException)
|
||||
&& $exception instanceof Throwable
|
||||
&& $this->displayErrorDetails
|
||||
) {
|
||||
$error->setDescription($exception->getMessage());
|
||||
}
|
||||
|
||||
$payload = new ActionPayload($statusCode, null, $error);
|
||||
$encodedPayload = json_encode($payload, JSON_PRETTY_PRINT);
|
||||
|
||||
$response = $this->responseFactory->createResponse($statusCode);
|
||||
$response->getBody()->write($encodedPayload);
|
||||
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Handlers;
|
||||
|
||||
use App\Application\ResponseEmitter\ResponseEmitter;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Slim\Exception\HttpInternalServerErrorException;
|
||||
|
||||
class ShutdownHandler
|
||||
{
|
||||
private Request $request;
|
||||
|
||||
private HttpErrorHandler $errorHandler;
|
||||
|
||||
private bool $displayErrorDetails;
|
||||
|
||||
public function __construct(
|
||||
Request $request,
|
||||
HttpErrorHandler $errorHandler,
|
||||
bool $displayErrorDetails
|
||||
) {
|
||||
$this->request = $request;
|
||||
$this->errorHandler = $errorHandler;
|
||||
$this->displayErrorDetails = $displayErrorDetails;
|
||||
}
|
||||
|
||||
public function __invoke()
|
||||
{
|
||||
$error = error_get_last();
|
||||
if ($error) {
|
||||
$errorFile = $error['file'];
|
||||
$errorLine = $error['line'];
|
||||
$errorMessage = $error['message'];
|
||||
$errorType = $error['type'];
|
||||
$message = 'An error while processing your request. Please try again later.';
|
||||
|
||||
if ($this->displayErrorDetails) {
|
||||
switch ($errorType) {
|
||||
case E_USER_ERROR:
|
||||
$message = "FATAL ERROR: {$errorMessage}. ";
|
||||
$message .= " on line {$errorLine} in file {$errorFile}.";
|
||||
break;
|
||||
|
||||
case E_USER_WARNING:
|
||||
$message = "WARNING: {$errorMessage}";
|
||||
break;
|
||||
|
||||
case E_USER_NOTICE:
|
||||
$message = "NOTICE: {$errorMessage}";
|
||||
break;
|
||||
|
||||
default:
|
||||
$message = "ERROR: {$errorMessage}";
|
||||
$message .= " on line {$errorLine} in file {$errorFile}.";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$exception = new HttpInternalServerErrorException($this->request, $message);
|
||||
$response = $this->errorHandler->__invoke(
|
||||
$this->request,
|
||||
$exception,
|
||||
$this->displayErrorDetails,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
$responseEmitter = new ResponseEmitter();
|
||||
$responseEmitter->emit($response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Middleware;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface as Handler;
|
||||
use PDO;
|
||||
|
||||
class AdminAuthMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function process(Request $request, Handler $handler): Response
|
||||
{
|
||||
if (empty($_SESSION['admin_user'])) {
|
||||
$response = new \Slim\Psr7\Response();
|
||||
return $response->withHeader('Location', '/admin/login')->withStatus(302);
|
||||
}
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Middleware;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface as Middleware;
|
||||
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
|
||||
|
||||
class SessionMiddleware implements Middleware
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function process(Request $request, RequestHandler $handler): Response
|
||||
{
|
||||
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||
session_start();
|
||||
$request = $request->withAttribute('session', $_SESSION);
|
||||
}
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\ResponseEmitter;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Slim\ResponseEmitter as SlimResponseEmitter;
|
||||
|
||||
class ResponseEmitter extends SlimResponseEmitter
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function emit(ResponseInterface $response): void
|
||||
{
|
||||
// This variable should be set to the allowed host from which your API can be accessed with
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
|
||||
$response = $response
|
||||
->withHeader('Access-Control-Allow-Credentials', 'true')
|
||||
->withHeader('Access-Control-Allow-Origin', $origin)
|
||||
->withHeader(
|
||||
'Access-Control-Allow-Headers',
|
||||
'X-Requested-With, Content-Type, Accept, Origin, Authorization',
|
||||
)
|
||||
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
|
||||
->withHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
|
||||
->withAddedHeader('Cache-Control', 'post-check=0, pre-check=0')
|
||||
->withHeader('Pragma', 'no-cache');
|
||||
|
||||
if (ob_get_contents()) {
|
||||
ob_clean();
|
||||
}
|
||||
|
||||
parent::emit($response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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)
|
||||
{
|
||||
// build counts in a single pass and compute cumulative weights for fast selection
|
||||
$len = strlen($text);
|
||||
$table = [];
|
||||
|
||||
// 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[$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)
|
||||
{
|
||||
// pick a random starting state
|
||||
$states = array_keys($table);
|
||||
if (!$states) return '';
|
||||
$char = $states[array_rand($states)];
|
||||
$o = $char;
|
||||
|
||||
$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 pick_weighted(array $entry)
|
||||
{
|
||||
// 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)
|
||||
{
|
||||
$path = dirname(__DIR__, 3) . '/lib/php_text.txt';
|
||||
if (!is_readable($path)) {
|
||||
throw new \RuntimeException("Missing data file: {$path}");
|
||||
}
|
||||
|
||||
$table = $this->getTableForFile($path);
|
||||
return $this->generate_markov_text($length, $table);
|
||||
}
|
||||
|
||||
private function generate_code($length)
|
||||
{
|
||||
$path = dirname(__DIR__, 3) . '/lib/php_code.txt';
|
||||
if (!is_readable($path)) {
|
||||
throw new \RuntimeException("Missing data file: {$path}");
|
||||
}
|
||||
|
||||
$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
|
||||
{
|
||||
return strtolower(str_replace(' ', '-', $title));
|
||||
}
|
||||
|
||||
private function get_title(): string
|
||||
{
|
||||
$length = random_int(4, 30);
|
||||
$title = $this->generate($length * 10);
|
||||
$title = str_replace(['.', ',', '!', '?', '"', '—'], '', $title);
|
||||
$title = substr($title, 0, strpos($title, ' ', $length));
|
||||
return ucwords($title);
|
||||
}
|
||||
|
||||
private function get_headline(string $stub): string
|
||||
{
|
||||
return $this->generate(100) . '...';
|
||||
}
|
||||
|
||||
private function getContent(string $stub): string
|
||||
{
|
||||
$paragraphCount = random_int(5, 16);
|
||||
$paragraphs = [];
|
||||
for ($i = 0; $i < $paragraphCount; $i++) {
|
||||
if ($i === 0) {
|
||||
$paragraphs[] = '<p><strong>' . htmlspecialchars($this->generate(100)) . '</strong></p>';
|
||||
} elseif ($i % 3 === 2) {
|
||||
$paragraphs[] = '<div class="skill-item"><pre><code>' . htmlspecialchars($this->generate_code(500)) . '</code></pre></div>';
|
||||
} else {
|
||||
$paragraphs[] = '<p>' . htmlspecialchars($this->generate(500)) . '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n\n", $paragraphs);
|
||||
}
|
||||
|
||||
public function getRandomArticles(int $count): array
|
||||
{
|
||||
$articles = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$title = $this->get_title();
|
||||
$articles[] = [
|
||||
'title' => $title,
|
||||
'stub' => $this->stubify($title),
|
||||
'headline' => $this->get_headline($this->stubify($title))
|
||||
];
|
||||
}
|
||||
return $articles;
|
||||
}
|
||||
|
||||
public function getByStub(string $stub): ?array
|
||||
{
|
||||
return [
|
||||
'title' => ucwords(str_replace('-', ' ', $stub)),
|
||||
'stub' => $stub,
|
||||
'headline' => ucfirst($this->get_headline($stub)),
|
||||
'content' => $this->getContent($stub)
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Settings;
|
||||
|
||||
class Settings implements SettingsInterface
|
||||
{
|
||||
private array $settings;
|
||||
|
||||
public function __construct(array $settings)
|
||||
{
|
||||
$this->settings = $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
public function get(string $key = '')
|
||||
{
|
||||
return (empty($key)) ? $this->settings : $this->settings[$key];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Settings;
|
||||
|
||||
interface SettingsInterface
|
||||
{
|
||||
/**
|
||||
* @param string $key
|
||||
* @return mixed
|
||||
*/
|
||||
public function get(string $key = '');
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\DomainException;
|
||||
|
||||
use Exception;
|
||||
|
||||
abstract class DomainException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\DomainException;
|
||||
|
||||
class DomainRecordNotFoundException extends DomainException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\User;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
class User implements JsonSerializable
|
||||
{
|
||||
private ?int $id;
|
||||
|
||||
private string $username;
|
||||
|
||||
private string $firstName;
|
||||
|
||||
private string $lastName;
|
||||
|
||||
public function __construct(?int $id, string $username, string $firstName, string $lastName)
|
||||
{
|
||||
$this->id = $id;
|
||||
$this->username = strtolower($username);
|
||||
$this->firstName = ucfirst($firstName);
|
||||
$this->lastName = ucfirst($lastName);
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUsername(): string
|
||||
{
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
public function getFirstName(): string
|
||||
{
|
||||
return $this->firstName;
|
||||
}
|
||||
|
||||
public function getLastName(): string
|
||||
{
|
||||
return $this->lastName;
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'username' => $this->username,
|
||||
'firstName' => $this->firstName,
|
||||
'lastName' => $this->lastName,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\User;
|
||||
|
||||
use App\Domain\DomainException\DomainRecordNotFoundException;
|
||||
|
||||
class UserNotFoundException extends DomainRecordNotFoundException
|
||||
{
|
||||
public $message = 'The user you requested does not exist.';
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\User;
|
||||
|
||||
interface UserRepository
|
||||
{
|
||||
/**
|
||||
* @return User[]
|
||||
*/
|
||||
public function findAll(): array;
|
||||
|
||||
/**
|
||||
* @param int $id
|
||||
* @return User
|
||||
* @throws UserNotFoundException
|
||||
*/
|
||||
public function findUserOfId(int $id): User;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Persistence\User;
|
||||
|
||||
use App\Domain\User\User;
|
||||
use App\Domain\User\UserNotFoundException;
|
||||
use App\Domain\User\UserRepository;
|
||||
|
||||
class InMemoryUserRepository implements UserRepository
|
||||
{
|
||||
/**
|
||||
* @var User[]
|
||||
*/
|
||||
private array $users;
|
||||
|
||||
/**
|
||||
* @param User[]|null $users
|
||||
*/
|
||||
public function __construct(array $users = null)
|
||||
{
|
||||
$this->users = $users ?? [
|
||||
1 => new User(1, 'bill.gates', 'Bill', 'Gates'),
|
||||
2 => new User(2, 'steve.jobs', 'Steve', 'Jobs'),
|
||||
3 => new User(3, 'mark.zuckerberg', 'Mark', 'Zuckerberg'),
|
||||
4 => new User(4, 'evan.spiegel', 'Evan', 'Spiegel'),
|
||||
5 => new User(5, 'jack.dorsey', 'Jack', 'Dorsey'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function findAll(): array
|
||||
{
|
||||
return array_values($this->users);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function findUserOfId(int $id): User
|
||||
{
|
||||
if (!isset($this->users[$id])) {
|
||||
throw new UserNotFoundException();
|
||||
}
|
||||
|
||||
return $this->users[$id];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user