- Rust 51.8%
- Lua 17.8%
- HTML 17.2%
- Shell 13.2%
|
All checks were successful
Развёртывание сайта / deploy (push) Successful in 30s
|
||
|---|---|---|
| .forgejo/workflows | ||
| content | ||
| lua_modules | ||
| scripts | ||
| src | ||
| templates | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| README.md | ||
Как определяется маршрут
Для запроса /a/b движок ищет (в этом порядке):
content/a/b.lua— динамическая страницаcontent/a/b.md— статическая страницаcontent/a/b/index.lua— динамическая страница (папка-раздел)content/a/b/index.md— статическая страница (папка-раздел)content/a/bкак обычный файл — отдаётся как статика (картинка, видео, css, что угодно)
Для / проверяются только content/index.lua и content/index.md.
Так можно свободно мешать стили: класть картинку/видео рядом с index.md
в отдельной папке (как pages/picture/), либо держать страницу плоским
файлом text.md без своей папки.
Попытки выйти за пределы content/ (.., симлинки наружу) отклоняются —
вернётся 404.
Статические страницы (.md)
Файл — обычный Markdown с необязательным front matter в начале:
---
title: Заголовок страницы
---
# Текст страницы
Обычный **Markdown**, а также **любой сырой HTML** — он не экранируется:
<div style="color:red">такой HTML тоже отработает</div>
Если title не указан, движок подставит заголовок по последнему сегменту
URL (например, /pages/my-page → «my page»).
Отвечают только на GET/HEAD; на прочие методы — 405.
Статика (картинки, видео, что угодно)
Просто положите файл рядом со страницей внутри content/ — он станет
доступен по тому же пути. Например:
content/pages/picture/index.md
content/pages/picture/hero.png → GET /pages/picture/hero.png
Тип содержимого (Content-Type) определяется по расширению файла.
Динамические страницы (.lua)
.lua-файл в content/ — это страница, обрабатывающая любой HTTP-метод
(GET, POST, PUT, DELETE, ...). Скрипт должен определить глобальную функцию
handle(request):
function handle(request)
-- request.method -- строка: "GET", "POST", ...
-- request.path -- строка: путь запроса, например "/task/riddle"
-- request.query -- таблица string -> string (из query-строки)
-- request.headers -- таблица string -> string (имена в нижнем регистре)
-- request.cookies -- таблица string -> string, распарсенная из
-- заголовка Cookie
-- request.form -- таблица string -> string, распарсенная из тела
-- запроса, если Content-Type — form-urlencoded
-- request.body -- строка: сырое тело запроса
return {
status = 200, -- необязательно, по умолчанию 200
body = "# Markdown", -- обязательно: Markdown-код ответа
-- (тоже поддерживает front matter и сырой HTML)
headers = { -- необязательно: доп. заголовки ответа,
["Set-Cookie"] = "name=value; Path=/; Max-Age=3600",
},
}
end
Возвращённый body проходит через тот же Markdown-рендерер и тот же общий
шаблон оформления, что и статические страницы — так что страница-ответ
выглядит так же, как и всё остальное на сайте. Поле headers — самый
общий примитив движка для состояния между запросами: он не знает о cookie
ничего специально, просто выставляет то, что вернул скрипт (см. модуль
заданий ниже, который как раз строит на этом счётчик попыток).
Переиспользуемый модуль заданий (lua_modules/task.lua)
Для страниц-заданий не нужно писать
handle() вручную — есть готовый каркас lua_modules/task.lua. Автор
задания описывает декларативно:
title— заголовок страницы;prompt— условие (Markdown, без front matter);answer— правильный ответ (строка). Показывается пользователю кнопкой «Показать ответ» после нескольких неудачных попыток;validate(необязательно) — своя функция проверкиfunction(answer, request) -> { ok = bool, message = string }. По умолчанию — сравнение сanswerбез учёта регистра и пробелов.message— единственный источник текста и для провала, и для успеха: ровно то, что попросили в контракте — задание, валидатор и выводимый текст как часть результата валидатора.
-- content/task/riddle.lua
require("task").define({
title = "Задание — загадка",
prompt = "\n\nСколько будет **6 × 7**?",
answer = "42",
})
Своя функция validate нужна, например, чтобы принимать несколько
вариантов написания ответа, при этом answer остаётся одним каноничным
значением, которое покажет кнопка «Показать ответ»:
require("task").define({
title = "Столица Франции",
prompt = "Назови столицу Франции.",
answer = "Париж",
validate = function(answer, _request)
local a = answer:lower()
if a == "париж" or a == "paris" then
return { ok = true, message = "Верно!" }
end
return { ok = false, message = "Неверно, попробуй ещё раз." }
end,
})
Подключение вспомогательных Lua-модулей
Модули, которые не должны быть маршрутами, кладите в lua_modules/
(рядом с content/, но не внутри неё) и подключайте через require.
Каждый запрос выполняется в свежем изолированном экземпляре Lua — состояние между запросами не сохраняется (глобальные переменные одного запроса не видны следующему).
Оформление
Вся вёрстка — общий шаблон templates/page.html, в который подставляется отрендеренный HTML страницы.
Чтобы поменять оформление сайта целиком, правьте только этот файл (и
templates/error.html для страниц ошибок) — содержимое страниц он не
касается.
Локальный запуск
cargo run
# сайт слушает http://0.0.0.0:3000
Переменные окружения (все необязательны):
| Переменная | По умолчанию | Назначение |
|---|---|---|
CONTENT_DIR |
content |
каталог с маршрутизируемым контентом |
LUA_MODULES_DIR |
lua_modules |
каталог вспомогательных Lua-модулей |
PORT |
3000 |
порт, который слушает сервер |
Добавление контента
- Новая статическая страница: создайте
content/путь/страница.md(илиcontent/путь/index.md, если рядом будет своя статика) — маршрут появится сам, перезапуск сервера не нужен для контента (файлы читаются на каждый запрос), кроме случаев, когда меняются шаблоны/CSS — они вшиваются в бинарник на этапеcargo build. - Новая динамическая страница: создайте
content/путь/страница.luaс функциейhandle(request). - Загрузка статики (картинки, видео): просто скопируйте файл в нужное
место внутри
content/— он тут же станет доступен по соответствующему URL. Никакой отдельной загрузки/индексации не требуется.
Деплой
Деплой рассчитан на ALT Linux и состоит из двух скриптов в scripts/
плюс CI на Forgejo Actions, который вызывает второй скрипт по SSH при
каждом пуше в master.
scripts/first-deploy.sh— разовая настройка сервера: ставит пакеты, собирает релизный бинарник и создаёт, включает в автозагрузку и запускает systemd-юнитwebsite.scripts/redeploy.sh— обновление:git pull --ff-onlyизssh://git@git.csit.sgu.ru/soviet/programmers-day.git, пересборка релиза,systemctl restart sovet-site.
Два разных SSH-ключа
Деплой использует два независимых SSH-ключа с разным направлением — не путайте их и не выдавайте один за другой:
- В настройках репозитория должен быть добавлен ключ для стягивания данных из репозитория сервером.
- В секрете
DEPLOY_SSH_KEYдолжен лежать приватный ключ для доступа CI к серверу.
Переменные и секреты Forgejo Actions
Настраиваются в репозитории: Settings -> Actions -> Secrets/Variables.
| Имя | Тип | Значение |
|---|---|---|
DEPLOY_SSH_KEY |
Secret | приватный ключ CI целиком, вместе с заголовками |
DEPLOY_KNOWN_HOSTS |
Secret | вывод ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST" |
DEPLOY_HOST |
Variable | адрес/домен сервера деплоя |
DEPLOY_PORT |
Variable | SSH-порт сервера (обычно 22) |
DEPLOY_USER |
Variable | пользователь на сервере, от которого сделан чекаут и работает сервис |
DEPLOY_PATH |
Variable | абсолютный путь к чекауту на сервере, например /home/deploy/programmers-day |
DEPLOY_USER должен иметь passwordless sudo хотя бы на перезапуск и
статус сервиса, иначе redeploy.sh не сможет выполнить свой
sudo systemctl restart website. Пример строки для visudo
(замените deploy на реального пользователя):
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart website, /usr/bin/systemctl status website
Как пользоваться скриптами
-
Первичный деплой (один раз, вручную, от обычного пользователя с sudo). Сначала вручную клонируйте репозиторий ключом №1 (сам скрипт этого не делает):
git clone ssh://git@git.csit.sgu.ru/soviet/programmers-day.git cd programmers-day bash scripts/first-deploy.sh # свой порт вместо 3000 по умолчанию: # PORT=8080 bash scripts/first-deploy.sh -
Обновление вручную (если нужно обновить в обход CI, тем же пользователем, из того же чекаута):
bash scripts/redeploy.sh -
Автоматическое обновление:
.forgejo/workflows/deploy.ymlпри каждом пуше вmasterзаходит по SSH наDEPLOY_HOSTподDEPLOY_USER(ключ №2) и выполняетbash "$DEPLOY_PATH/scripts/redeploy.sh"— то есть просто вызывает тот жеredeploy.sh, что и при ручном обновлении. -
Поставьте перед сервисом обратный прокси (nginx/caddy) для TLS и домена, проксируя на
127.0.0.1:3000(или другойPORT, если переопределяли). -
Обновление контента (
.md/.lua/картинки) само по себе не требует пересборки и рестарта — можно просто заменить файлы вcontent//lua_modules/на сервере (redeploy.shдля этого не нужен). Пересборка (а значит иredeploy.sh) нужна только при изменении Rust-кода движка или шаблонов оформления (templates/).