361 lines
14 KiB
PHP
361 lines
14 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace NeonBlog;
|
|
|
|
use Throwable;
|
|
|
|
final class App
|
|
{
|
|
private Config $config;
|
|
private ContentRepository $repository;
|
|
private CommentService $comments;
|
|
private Stats $stats;
|
|
|
|
public function __construct(Config $config, ContentRepository $repository, CommentService $comments, Stats $stats)
|
|
{
|
|
$this->config = $config;
|
|
$this->repository = $repository;
|
|
$this->comments = $comments;
|
|
$this->stats = $stats;
|
|
}
|
|
|
|
public function handle(): void
|
|
{
|
|
try {
|
|
$path = $this->path();
|
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && preg_match('#^comment/([a-z0-9-]+)$#', $path, $matches)) {
|
|
$this->handleComment($matches[1]);
|
|
return;
|
|
}
|
|
|
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
|
|
$this->render('error', ['title' => 'Method not allowed', 'message' => 'This route only accepts GET requests.'], 405, 'error');
|
|
return;
|
|
}
|
|
|
|
$this->route($path);
|
|
} catch (Throwable $error) {
|
|
$this->render('error', [
|
|
'title' => 'Server error',
|
|
'message' => 'Something went wrong while rendering this page.',
|
|
'debug' => $error->getMessage(),
|
|
], 500, 'error');
|
|
}
|
|
}
|
|
|
|
public function repository(): ContentRepository
|
|
{
|
|
return $this->repository;
|
|
}
|
|
|
|
public function comments(): CommentService
|
|
{
|
|
return $this->comments;
|
|
}
|
|
|
|
public function stats(): Stats
|
|
{
|
|
return $this->stats;
|
|
}
|
|
|
|
public function config(): Config
|
|
{
|
|
return $this->config;
|
|
}
|
|
|
|
private function route(string $path): void
|
|
{
|
|
if ($path === '' || $path === 'home') {
|
|
$this->home(1);
|
|
return;
|
|
}
|
|
if (preg_match('#^page/(\d+)$#', $path, $matches)) {
|
|
$this->home((int) $matches[1]);
|
|
return;
|
|
}
|
|
if ($path === 'search') {
|
|
$this->search();
|
|
return;
|
|
}
|
|
if ($path === 'feed.xml') {
|
|
$this->feed();
|
|
return;
|
|
}
|
|
if ($path === 'sitemap.xml') {
|
|
$this->sitemap();
|
|
return;
|
|
}
|
|
if (preg_match('#^post/([a-z0-9-]+)$#', $path, $matches)) {
|
|
$this->single($matches[1], 'post');
|
|
return;
|
|
}
|
|
if (preg_match('#^category/([a-z0-9-]+)(?:/page/(\d+))?$#', $path, $matches)) {
|
|
$this->taxonomy('category', $matches[1], isset($matches[2]) ? (int) $matches[2] : 1);
|
|
return;
|
|
}
|
|
if (preg_match('#^tag/([a-z0-9-]+)(?:/page/(\d+))?$#', $path, $matches)) {
|
|
$this->taxonomy('tag', $matches[1], isset($matches[2]) ? (int) $matches[2] : 1);
|
|
return;
|
|
}
|
|
if (preg_match('#^archive/(\d{4})(?:/(\d{2}))?(?:/page/(\d+))?$#', $path, $matches)) {
|
|
$this->archive((int) $matches[1], isset($matches[2]) ? (int) $matches[2] : null, isset($matches[3]) ? (int) $matches[3] : 1);
|
|
return;
|
|
}
|
|
|
|
$item = $this->repository->find($path);
|
|
if ($item !== null) {
|
|
$this->single($path, (string) $item['type']);
|
|
return;
|
|
}
|
|
|
|
$this->render('error', ['title' => 'Not found', 'message' => 'That page does not exist.'], 404, 'not_found');
|
|
}
|
|
|
|
private function home(int $page): void
|
|
{
|
|
$perPage = (int) $this->config->get('posts_per_page', 6);
|
|
$result = $this->repository->paginate($this->repository->posts(false), $page, $perPage);
|
|
$this->render('listing', [
|
|
'title' => (string) $this->config->get('site.title'),
|
|
'heading' => (string) $this->config->get('site.tagline'),
|
|
'items' => $result['items'],
|
|
'pagination' => $result['pagination'],
|
|
'pagination_url' => static fn (int $target): string => $target === 1 ? View::url('') : View::url('page/' . $target),
|
|
], 200, 'home');
|
|
}
|
|
|
|
private function search(): void
|
|
{
|
|
$query = trim((string) ($_GET['q'] ?? ''));
|
|
$page = max(1, (int) ($_GET['page'] ?? 1));
|
|
$perPage = (int) $this->config->get('posts_per_page', 6);
|
|
$result = $this->repository->search($query, $page, $perPage);
|
|
$this->render('listing', [
|
|
'title' => $query === '' ? 'Search' : 'Search: ' . $query,
|
|
'heading' => $query === '' ? 'Search the archive' : 'Search results for "' . $query . '"',
|
|
'items' => $result['items'],
|
|
'pagination' => $result['pagination'],
|
|
'search_query' => $query,
|
|
'empty_message' => $query === '' ? 'Enter a word or phrase to search posts and pages.' : 'No matching posts were found.',
|
|
'pagination_url' => static fn (int $target): string => View::url('search', ['q' => $query, 'page' => $target]),
|
|
], 200, 'search');
|
|
}
|
|
|
|
private function taxonomy(string $type, string $slug, int $page): void
|
|
{
|
|
$items = $type === 'category' ? $this->repository->byCategory($slug) : $this->repository->byTag($slug);
|
|
$label = $this->termLabel($items, $type, $slug);
|
|
$perPage = (int) $this->config->get('posts_per_page', 6);
|
|
$result = $this->repository->paginate($items, $page, $perPage);
|
|
|
|
$this->render('listing', [
|
|
'title' => ucfirst($type) . ': ' . $label,
|
|
'heading' => ucfirst($type) . ': ' . $label,
|
|
'items' => $result['items'],
|
|
'pagination' => $result['pagination'],
|
|
'empty_message' => 'No posts live here yet.',
|
|
'pagination_url' => static fn (int $target): string => $target === 1
|
|
? View::url($type . '/' . $slug)
|
|
: View::url($type . '/' . $slug . '/page/' . $target),
|
|
], 200, $type);
|
|
}
|
|
|
|
private function archive(int $year, ?int $month, int $page): void
|
|
{
|
|
$items = $this->repository->byArchive($year, $month);
|
|
$perPage = (int) $this->config->get('posts_per_page', 6);
|
|
$result = $this->repository->paginate($items, $page, $perPage);
|
|
$label = $month === null ? (string) $year : date('F Y', strtotime(sprintf('%04d-%02d-01', $year, $month)));
|
|
$base = $month === null ? 'archive/' . $year : sprintf('archive/%04d/%02d', $year, $month);
|
|
|
|
$this->render('listing', [
|
|
'title' => 'Archive: ' . $label,
|
|
'heading' => 'Archive: ' . $label,
|
|
'items' => $result['items'],
|
|
'pagination' => $result['pagination'],
|
|
'empty_message' => 'No posts were published in this window.',
|
|
'pagination_url' => static fn (int $target): string => $target === 1 ? View::url($base) : View::url($base . '/page/' . $target),
|
|
], 200, 'archive');
|
|
}
|
|
|
|
private function single(string $slug, string $type): void
|
|
{
|
|
$item = $this->repository->find($slug);
|
|
if ($item === null || $item['type'] !== $type) {
|
|
$this->render('error', ['title' => 'Not found', 'message' => 'That content is not published.'], 404, 'not_found');
|
|
return;
|
|
}
|
|
|
|
$approved = $this->comments->approvedFor((string) $item['slug']);
|
|
$captcha = $this->config->get('comments.captcha', true) ? Captcha::challenge() : ['token' => '', 'question' => ''];
|
|
$this->render('single', [
|
|
'title' => $item['title'],
|
|
'item' => $item,
|
|
'approved_comments' => $approved,
|
|
'captcha' => $captcha,
|
|
], 200, (string) $item['type'], (string) $item['slug']);
|
|
}
|
|
|
|
private function feed(): void
|
|
{
|
|
$base = $this->config->baseUrl();
|
|
$items = array_slice($this->repository->posts(false), 0, 20);
|
|
$xml = [
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
'<rss version="2.0">',
|
|
'<channel>',
|
|
'<title>' . $this->xml((string) $this->config->get('site.title')) . '</title>',
|
|
'<link>' . $this->xml($base . View::url('')) . '</link>',
|
|
'<description>' . $this->xml((string) $this->config->get('site.description')) . '</description>',
|
|
];
|
|
foreach ($items as $item) {
|
|
$xml[] = '<item>';
|
|
$xml[] = '<title>' . $this->xml((string) $item['title']) . '</title>';
|
|
$xml[] = '<link>' . $this->xml($base . View::itemUrl($item)) . '</link>';
|
|
$xml[] = '<guid>' . $this->xml($base . View::itemUrl($item)) . '</guid>';
|
|
$xml[] = '<pubDate>' . gmdate(DATE_RSS, strtotime((string) $item['date']) ?: time()) . '</pubDate>';
|
|
$xml[] = '<description>' . $this->xml((string) $item['summary']) . '</description>';
|
|
$xml[] = '</item>';
|
|
}
|
|
$xml[] = '</channel>';
|
|
$xml[] = '</rss>';
|
|
|
|
$this->respond(implode("\n", $xml), 'application/rss+xml; charset=utf-8', 200, 'feed');
|
|
}
|
|
|
|
private function sitemap(): void
|
|
{
|
|
$base = $this->config->baseUrl();
|
|
$xml = [
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
'<url><loc>' . $this->xml($base . View::url('')) . '</loc></url>',
|
|
];
|
|
foreach ($this->repository->all(false) as $item) {
|
|
$xml[] = '<url>';
|
|
$xml[] = '<loc>' . $this->xml($base . View::itemUrl($item)) . '</loc>';
|
|
$xml[] = '<lastmod>' . $this->xml(date('Y-m-d', strtotime((string) $item['modified']) ?: time())) . '</lastmod>';
|
|
$xml[] = '</url>';
|
|
}
|
|
$xml[] = '</urlset>';
|
|
|
|
$this->respond(implode("\n", $xml), 'application/xml; charset=utf-8', 200, 'sitemap');
|
|
}
|
|
|
|
private function handleComment(string $slug): void
|
|
{
|
|
$item = $this->repository->find($slug);
|
|
if ($item === null || !$item['allow_comments'] || !$this->config->get('comments.enabled', true)) {
|
|
$this->flash('error', 'Comments are closed for that post.');
|
|
$this->redirect(View::url(''));
|
|
return;
|
|
}
|
|
|
|
if ($this->config->get('comments.captcha', true)) {
|
|
$valid = Captcha::verify((string) ($_POST['captcha_token'] ?? ''), (string) ($_POST['captcha_answer'] ?? ''));
|
|
if (!$valid) {
|
|
$this->flash('error', 'The captcha answer was not correct.');
|
|
$this->redirect(View::itemUrl($item) . '#comments');
|
|
return;
|
|
}
|
|
}
|
|
|
|
$result = $this->comments->add((string) $item['slug'], $_POST);
|
|
$this->flash($result['ok'] ? 'success' : 'error', $result['message']);
|
|
$this->redirect(View::itemUrl($item) . '#comments');
|
|
}
|
|
|
|
/** @param array<string, mixed> $data */
|
|
private function render(string $template, array $data, int $statusCode, string $routeType, string $slug = ''): void
|
|
{
|
|
http_response_code($statusCode);
|
|
header('Content-Type: text/html; charset=utf-8');
|
|
|
|
$theme = preg_replace('/[^a-z0-9_-]/', '', (string) $this->config->get('theme', 'neon')) ?: 'neon';
|
|
$layout = BLOG_ROOT . '/themes/' . $theme . '/layout.php';
|
|
$config = $this->config;
|
|
$repository = $this->repository;
|
|
$facets = $this->repository->facets();
|
|
$navPages = $this->repository->pages(false);
|
|
usort($navPages, static fn (array $a, array $b): int => ((int) $a['menu_order']) <=> ((int) $b['menu_order']));
|
|
$flash = $this->consumeFlash();
|
|
|
|
extract($data, EXTR_SKIP);
|
|
ob_start();
|
|
require $layout;
|
|
$body = (string) ob_get_clean();
|
|
$this->stats->record($routeType, $slug, $statusCode);
|
|
echo $body;
|
|
}
|
|
|
|
private function respond(string $body, string $contentType, int $statusCode, string $routeType): void
|
|
{
|
|
http_response_code($statusCode);
|
|
header('Content-Type: ' . $contentType);
|
|
$this->stats->record($routeType, '', $statusCode);
|
|
echo $body;
|
|
}
|
|
|
|
private function path(): string
|
|
{
|
|
$route = (string) ($_GET['route'] ?? '');
|
|
if ($route === '') {
|
|
$route = (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH);
|
|
}
|
|
|
|
$scriptDir = str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? '')));
|
|
$route = '/' . ltrim($route, '/');
|
|
if ($scriptDir !== '/' && str_starts_with($route, $scriptDir . '/')) {
|
|
$route = substr($route, strlen($scriptDir));
|
|
}
|
|
|
|
return trim($route, '/');
|
|
}
|
|
|
|
private function termLabel(array $items, string $type, string $slug): string
|
|
{
|
|
foreach ($items as $item) {
|
|
if ($type === 'category') {
|
|
return (string) $item['category'];
|
|
}
|
|
foreach ($item['tags'] as $tag) {
|
|
if (ContentRepository::slugify((string) $tag) === $slug) {
|
|
return (string) $tag;
|
|
}
|
|
}
|
|
}
|
|
|
|
return ucwords(str_replace('-', ' ', $slug));
|
|
}
|
|
|
|
private function flash(string $type, string $message): void
|
|
{
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
|
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
|
|
}
|
|
}
|
|
|
|
/** @return array{type: string, message: string}|null */
|
|
private function consumeFlash(): ?array
|
|
{
|
|
if (session_status() !== PHP_SESSION_ACTIVE || !isset($_SESSION['flash'])) {
|
|
return null;
|
|
}
|
|
$flash = $_SESSION['flash'];
|
|
unset($_SESSION['flash']);
|
|
|
|
return is_array($flash) ? $flash : null;
|
|
}
|
|
|
|
private function redirect(string $url): void
|
|
{
|
|
header('Location: ' . $url, true, 303);
|
|
}
|
|
|
|
private function xml(string $value): string
|
|
{
|
|
return htmlspecialchars($value, ENT_XML1 | ENT_COMPAT, 'UTF-8');
|
|
}
|
|
}
|