psj-notes
~/home ~/series ~/archive

Часть 08. Движок блога: статический генератор и админка

01.01.1970 Серия: Руководство по настройке srv02-Helsinki 'blog' 'static-generator' 'admin'

Часть 08. Движок блога: статический генератор и админка

«Дом построен, дверь открыта, табличка блестит. Осталось наполнить комнаты мебелью. Наш блог — это дом, где каждая глава — комната, а каждая серия — этаж.» — Воланд


Глава 1. Что такое статический генератор и зачем он нужен

Наш блог работает по принципу статического генератора. В отличие от WordPress или Grav CMS, где страницы собираются на лету из базы данных, статический генератор создаёт готовые HTML-файлы заранее.

Как это работает:

  1. Вы пишете текст в Markdown-файлах с метаданными (YAML frontmatter)
  2. Запускаете скрипт рендеринга — он читает Markdown, подставляет в шаблоны и создаёт HTML
  3. Nginx отдаёт готовые HTML-файлы — никакой базы данных, никакого PHP на каждом запросе

Почему это хорошо:

  • Скорость — HTML отдаётся напрямую Nginx, без загрузки PHP
  • Надёжность — нет базы данных, которую можно потерять
  • Бэкапы — весь сайт это папка с файлами, скопировал — сохранил
  • Простота — для написания нужен только текстовый редактор и Markdown

Наш стек:

  • league/commonmark — библиотека для парсинга Markdown в HTML
  • PHP 8.3 CLI — скрипты сборки запускаются из командной строки
  • Шаблоны — простые PHP-шаблоны с экранированием (без Twig/Smarty)

Бегемот, раскладывая бумаги:
«Статический генератор — как печатный станок. Написал, нажал кнопку — получил стопку готовых страниц. Не нужно каждый раз переписывать книгу от руки, как в старые времена.»


Глава 2. Структура проекта

Все файлы блога находятся в /home/sergey/projects/www/. Разберём структуру:

www/
├── admin/                    # Административная панель (PHP)
│   ├── index.php             # Главная админки (CRUD)
│   ├── login.php             # Страница входа
│   ├── logout.php            # Выход
│   ├── create.php            # Создание новой главы
│   ├── delete.php            # Удаление главы
│   ├── import.php            # Импорт Markdown-файлов
│   └── lib/                  # PHP-классы админки
│       ├── Auth.php          # Аутентификация (PBKDF2)
│       ├── Config.php        # Константы путей
│       ├── Csrf.php          # CSRF-защита
│       ├── FrontMatter.php   # Парсер YAML-заголовков
│       ├── Render.php        # Запуск рендеринга
│       ├── RenderResult.php  # Результат рендеринга
│       ├── Slug.php          # Генерация URL-имён
│       └── Validator.php     # Валидация ввода
├── config/                   # Конфигурация (пусто, файл credentials.php вне репозитория)
├── data/                     # Исходные файлы контента
│   ├── chapters/             # Markdown-файлы глав
│   └── series/               # YAML-файлы серий
├── public/                   # Сгенерированные HTML-файлы (корень Nginx)
│   ├── index.html
│   ├── archive/
│   ├── series/
│   └── ... (папки серий)
├── scripts/                  # CLI-скрипты сборки
│   ├── render.php            # Точка входа: full | single <slug> | index
│   └── lib/
│       ├── Builder.php       # Генератор HTML
│       ├── Config.php        # Константы для сборщика
│       ├── Index.php         # Чтение и сортировка контента
│       └── Parser.php        # Парсинг Markdown + frontmatter
├── templates/                # PHP-шаблоны
│   ├── layout.php            # Главный шаблон (терминальный стиль)
│   ├── home.php              # Главная страница
│   ├── chapter.php           # Страница главы
│   ├── series.php            # Список серий
│   ├── series-single.php     # Страница одной серии
│   └── archive.php           # Архив всех глав
├── vendor/                   # Composer-зависимости
├── composer.json
├── composer.lock
└── dev-server.php            # Встроенный PHP-сервер для разработки

Коровьев, расставляя папки:
«У каждой вещи — своё место. Исходники — в data, шаблоны — в templates, результат — в public. Админка следит за порядком, скрипты делают работу. Никто не путается.»


Глава 3. Формат контента: YAML + Markdown

Каждая глава — это обычный текстовый файл в data/chapters/. Имя файла формируется по шаблону: slug-серии--slug-главы.md.

Пример: istoriya-unix--glava-i-predystoriya-gibel-multics.md

Файл состоит из двух частей:

YAML Frontmatter (метаданные)

Между --- и --- — метаданные в формате YAML:

---
title: "Глава I. Предыстория: гибель Multics"
date: "2026-07-23T12:00:00Z"
slug: "08-dvizhok-bloga"
series: "istoriya-unix"
order: 1
tags: ["unix", "multics", "bell-labs"]
status: published
---

Поля:

  • title — заголовок главы
  • date — дата публикации (ISO 8601)
  • slug — уникальный идентификатор (используется в URL)
  • series — к какой серии относится глава
  • order — порядковый номер в серии
  • tags — метки
  • statuspublished (опубликована) или draft (черновик)

Markdown (содержимое)

Всё, что после второго ---, — тело главы в Markdown:

## От B к C: рождение нового языка

Если Unix — тело, то язык C — душа. Их история неразрывна. В начале 1970-х Unix был написан на ассемблере PDP-11...

### Заголовок третьего уровня

Обычный текст с **жирным** и *курсивным* выделением.

- Элемент списка
- Ещё элемент

1. Нумерованный список
2. Второй пункт

Парсер конвертирует Markdown в HTML с помощью библиотеки league/commonmark (установлена через Composer).

Файлы серий

В data/series/ лежат YAML-файлы, описывающие серии статей:

title: "История Unix"
slug: "08-dvizhok-bloga"
description: "Полная история Unix — от провала Multics до современности."
published: true
order: 1

Бегемот, заправляя перьевую ручку:
«Пишете в Markdown — получаете красивый HTML. Frontmatter — как паспорт главы: имя, дата рождения, серия, статус. Без паспорта ни одна глава не попадёт на сайт.»


Глава 4. Установка зависимостей (Composer)

Проект использует менеджер пакетов Composer с единственной зависимостью — библиотекой для парсинга Markdown.

Проверяем, что Composer установлен:

composer --version

Ожидаемый вывод:

Composer version 2.7.x ...

Если не установлен — ставим:

sudo apt install -y composer

Устанавливаем зависимости из composer.json:

cd /home/sergey/projects/www && composer install

Ожидаемый вывод:

Installing dependencies from lock file (including require-dev)
Verifying lock file contents can be installed on current platform.
Package operations: 1 install, 0 updates, 0 removals
  - Installing league/commonmark (2.5.x)
Generating autoload files

После установки в проекте появляется:

  • vendor/ — директория с библиотеками
  • vendor/autoload.php — автозагрузчик Composer (подключается во всех PHP-скриптах)

Все PHP-классы проекта используют PSR-4 автозагрузку (прописана в composer.json):

"autoload": {
    "psr-4": {
        "App\\Admin\\": "admin/lib/",
        "App\\Scripts\\": "scripts/lib/"
    }
}

Это значит, что класс App\Admin\Auth лежит в файле admin/lib/Auth.php и подгружается автоматически.

Воланд, принимая ключи:
«Одна зависимость — одна библиотека. Никаких фреймворков на сотню мегабайт. Минимализм — это не скупость, это точность. Каждая строчка кода должна быть оправдана.»


Глава 5. Система аутентификации

Административная панель защищена паролем. Вход через форму логина, аутентификация на основе PBKDF2, защита от CSRF и фиксации сессии.

5.1. Файл с учётными данными

Учётные данные хранятся в /etc/notebook/credentials.phpвне директории сайта. Это гарантирует, что даже при ошибке Nginx файл не будет прочитан через веб.

<?php
return [
    'username' => 'admin',
    'hash' => 'BASE64_SALT$BASE64_HASH',
];

Пароль не хранится в открытом виде. Хранится только PBKDF2-хеш с солью.

5.2. PBKDF2 — проверка пароля

Класс Auth::login() делает следующее:

  1. Загружает файл /etc/notebook/credentials.php
  2. Сравнивает имя пользователя
  3. Разделяет hash на соль и хеш (разделитель $)
  4. Декодирует соль из Base64
  5. Вычисляет PBKDF2-SHA256 с 10 000 итераций
  6. Сравнивает результат с сохранённым хешем через hash_equals() (защита от timing attack)
$salt = base64_decode($parts[0]);
$computed = hash_pbkdf2('sha256', $password, $salt, 10000, 32, true);

if (!hash_equals(base64_decode($hash), $computed)) {
    return false;
}

5.3. Сессия и CSRF

После успешного входа:

  • Сессия сохраняет auth_user, auth_ip, auth_ua
  • Новая сессия генерируется через session_regenerate_id(true) — защита от фиксации сессии
  • Параметры cookie: httponly=true, secure=true, samesite=Strict

CSRF-токен генерируется случайным образом и проверяется в каждой POST-форме:

$token = $_POST['_csrf'] ?? '';
if (!Csrf::validate($token)) {
    $error = 'Неверный CSRF-токен';
}

5.4. Блокировка по IP

Auth::isLoggedIn() проверяет не только наличие сессии, но и совпадение IP-адреса:

if ($_SESSION['auth_ip'] !== $_SERVER['REMOTE_ADDR']) {
    return false;
}

Если злоумышленник украдёт cookie сессии — с его IP войти не получится. Fail2ban (настроенный в части 05) дополнительно блокирует IP после 3 неудачных попыток за 5 минут.

Воланд, запирая сейф:
«Три замка на двери: PBKDF2 — пароль не прочитать даже с суперкомпьютером, привязка к IP — сессия бесполезна с чужого адреса, CSRF — внешняя форма не отправит данные. А снаружи Fail2ban ждёт неудачников.»


Глава 6. Административная панель

Админка доступна по адресу https://psj-notes.ru/admin/.

6.1. Вход (/admin/login.php)

Терминал-стилизованная страница с двумя полями: login и password. После успешного входа — редирект на /admin/index.php.

Стиль страницы соответствует основному сайту: жёлтый терминал на тёмном фоне, зелёный текст, мигающий курсор.

6.2. Главная админки (/admin/index.php)

После входа видно:

  • Список всех глав — таблица с заголовком, серией, статусом (published/draft), датой
  • Действия — создать новую главу, отредактировать, удалить, перерендерить сайт
  • Кнопка перерендеринга — после редактирования контента нужно нажать «Render» чтобы применить изменения

6.3. Создание главы (/admin/create.php)

Форма с полями:

  • Заголовок
  • Slug (генерируется автоматически из заголовка, можно изменить)
  • Серия (выбор из существующих)
  • Номер главы в серии
  • Статус (published/draft)
  • Теги
  • Содержимое в Markdown (textarea)

После сохранения:

  1. Создаётся файл в data/chapters/
  2. Если выбран статус published — запускается рендеринг

6.4. Импорт (/admin/import.php)

Позволяет загрузить готовые Markdown-файлы с YAML-заголовками. Удобно, если вы пишете главы локально в редакторе и потом загружаете на сервер.

6.5. Рендеринг

После изменения контента нужно обновить статические HTML-файлы. Кнопка «Render» в админке вызывает:

Render::trigger($seriesSlug);

Это запускает CLI-скрипт php render.php single <slug> через proc_open():

php /home/sergey/projects/www/scripts/render.php single istoriya-unix

Вывод возвращается на страницу. Если ошибка — текст ошибки показывается администратору.

Коровьев, нажимая красную кнопку:
«Написал — сохранил. Нажал «Render» — получил HTML. Как фотопечать: негатив проявлен, отпечаток готов. Можно вешать на стену (публиковать на сайте).»


Глава 7. Скрипты сборки

Сердце статического генератора — скрипты в scripts/.

7.1. Точка входа: render.php

CLI-скрипт, запускается из командной строки:

# Собрать всё (все серии + главная + архив)
php scripts/render.php full

# Собрать одну серию
php scripts/render.php single istoriya-unix

# Собрать только индексные страницы (главная, архив, список серий)
php scripts/render.php index

7.2. Парсер: Parser.php

Читает Markdown-файл и разделяет на frontmatter и тело:

$parsed = Parser::parse($filePath);
// $parsed->frontMatter = ['title' => '...', 'series' => '...', ...]
// $parsed->bodyHtml = '<h2>От B к C...</h2><p>...'

Frontmatter парсится регулярным выражением, тело конвертируется из Markdown в HTML через league/commonmark.

7.3. Индексатор: Index.php

Собирает все главы из data/chapters/, сортирует по сериям и порядку. Возвращает:

$allSeries = Index::getSeries();     // [['slug'=>'istoriya-unix','title'=>'...'], ...]
$chapters = Index::getChapters('istoriya-unix'); // все главы серии
$allChapters = Index::getAllPublished(); // все опубликованные главы

7.4. Генератор: Builder.php

Создаёт HTML-файлы в public/:

$builder->buildSeries($slug, $series, $chapters);
// → public/istoriya-unix/index.html
// → public/istoriya-unix/glava-i-predystoriya-gibel-multics/index.html
// → ...

$builder->buildIndexPages($allSeries, $allChapters);
// → public/index.html
// → public/archive/index.html
// → public/series/index.html

Каждая страница собирается так:

  1. Парсится содержимое главы (Markdown → HTML)
  2. Выбирается шаблон из templates/ (chapter.php, home.php, ...)
  3. Шаблон рендерится: заголовок, содержимое, меню, футер
  4. Результат оборачивается в layout.php (весь HTML-каркас с терминальным дизайном)
  5. Записывается в public/ в соответствующую директорию

7.5. Шаблоны

Все шаблоны — это PHP-файлы с минимальной логикой (экранирование, условные операторы, циклы).

layout.php — основной каркас:

<body>
<div class="terminal">
  <div class="terminal-header">...</div>
  <div class="terminal-menu">
    <a href="/">~/home</a>
    <a href="/series">~/series</a>
    <a href="/archive">~/archive</a>
  </div>
  <div class="terminal-body">
    <?= $pageContent ?? '' ?>
  </div>
  <div class="terminal-footer">
    <span class="prompt">sergey@srv02-Helsinki:~/<nav> $</span>
    <span class="cursor"></span>
  </div>
</div>
</body>

Шаблоны используют только htmlspecialchars() для экранирования — без Twig-подобных конструкций. Этого достаточно, поскольку логика сборки минимальна.

Бегемот, настраивая конвейер:
«Парсер чистит картошку (Markdown). Индексатор сортирует по размеру. Генератор нарезает и фасует. На выходе — готовые банки (HTML-файлы). Ручной труд не требуется.»


Глава 8. Структура сгенерированного сайта

После выполнения php scripts/render.php full в public/ появляются:

public/
├── index.html                                 # Главная страница
├── archive/                                   # Архив всех глав
│   └── index.html
├── series/                                    # Список серий
│   └── index.html
├── istoriya-unix/                             # Серия "История Unix"
│   ├── index.html                             # Страница серии
│   ├── glava-i-predystoriya-gibel-multics/    # Глава 1
│   │   └── index.html
│   ├── glava-ii-rozhdenie-unix-na-pdp-7/      # Глава 2
│   │   └── index.html
│   ├── ...                                    # Остальные главы
│   └── zaklyuchenie/                          # Заключение
│       └── index.html
├── nastroika-vds-servera-s-vpn/               # Серия "Настройка VDS"
│   ├── index.html
│   ├── ot-vds-do-ssh/
│   │   └── index.html
│   ├── ssh-klyuchi-sudo-i-zsh/
│   │   └── index.html
│   └── ...
└── assets/                                    # CSS, шрифты, изображения

Каждая глава — это папка с index.html. URL выглядят как:

  • https://psj-notes.ru/ — главная
  • https://psj-notes.ru/series/ — список серий
  • https://psj-notes.ru/archive/ — архив всех глав
  • https://psj-notes.ru/istoriya-unix/ — страница серии
  • https://psj-notes.ru/istoriya-unix/glava-i-predystoriya-gibel-multics/ — конкретная глава

Nginx просто отдаёт файл index.html из соответствующей папки.

Коровьев, расставляя книги на полке:
«Каждая глава — отдельная книга с собственным корешком (папкой). Nginx — библиотекарь: гость просит «/istoriya-unix/glava-iii/», библиотекарь идёт на полку istoriya-unix, берёт книгу glava-iii/index.html и отдаёт читателю. Быстро и без суеты.»


Глава 9. Первая публикация

Пошаговый процесс публикации новой главы:

  1. Создаём Markdown-файл локально или через админку:
nano /home/sergey/projects/www/data/chapters/moya-seriya--nazvanie-glavy.md
  1. Пишем frontmatter:
---
title: "Название моей главы"
date: "2026-07-27T12:00:00Z"
slug: "08-dvizhok-bloga"
series: "moya-seriya"
order: 1
tags: ["тег1", "тег2"]
status: published
---
  1. Пишем содержимое в Markdown.

  2. Запускаем сборку:

cd /home/sergey/projects/www && php scripts/render.php full
  1. Проверяем результат:
curl -s https://psj-notes.ru/moya-seriya/nazvanie-glavy/ | head -5
  1. Если через админку: логинимся, нажимаем «Create», заполняем форму, сохраняем. Рендеринг запустится автоматически.

Воланд, перелистывая свежий оттиск:
«Слово стало HTML. Текст обращён в страницу. Ещё одна глава — ещё одна комната в доме. Читатели уже стучатся в дверь.»


Финал части: Резюме Воланда

Параметр Статус
Структура проекта организована
Формат контента: YAML frontmatter + Markdown
Composer установлен, зависимости загружены
Аутентификация: PBKDF2 + CSRF + привязка к IP
Сессии: httponly, secure, samesite=Strict
Файл credentials.php вне корня сайта
Административная панель (CRUD)
Импорт Markdown-файлов
CLI-рендеринг: full / single / index
Статические HTML-файлы в public/
ЧПУ через папки с index.html
График публикаций 📅

Воланд подводит итог всей серии:
«Сервер поднят, дверь заперта, фаервол включён, VPN проложен. Nginx встречает гостей, PHP-FPM готовит страницы, Let's Encrypt ручается за честность. Админка позволяет управлять контентом, Fail2ban отгоняет злоумышленников, Tailscale соединяет с домом. Блог живёт — статические страницы летят к читателям быстрее ветра. Финал.»

Комментарии

Комментарии пока не подключены.