Сайт для фактов и задач ко Дню программиста https://event.csit.sgu.ru
  • Rust 51.8%
  • Lua 17.8%
  • HTML 17.2%
  • Shell 13.2%
Find a file
Данила Григорьев 7fcf48d7a0
All checks were successful
Развёртывание сайта / deploy (push) Successful in 30s
fix: поменял палитру code, pre
2026-09-14 03:21:10 +04:00
.forgejo/workflows Взлетаем 2026-09-14 02:33:25 +04:00
content Взлетаем 2026-09-14 02:33:25 +04:00
lua_modules Взлетаем 2026-09-14 02:33:25 +04:00
scripts fix: поменял имя remote 2026-09-14 03:18:13 +04:00
src Взлетаем 2026-09-14 02:33:25 +04:00
templates fix: поменял палитру code, pre 2026-09-14 03:21:10 +04:00
.gitignore Взлетаем 2026-09-14 02:33:25 +04:00
Cargo.lock Взлетаем 2026-09-14 02:33:25 +04:00
Cargo.toml Взлетаем 2026-09-14 02:33:25 +04:00
README.md Взлетаем 2026-09-14 02:33:25 +04:00

Как определяется маршрут

Для запроса /a/b движок ищет (в этом порядке):

  1. content/a/b.lua — динамическая страница
  2. content/a/b.md — статическая страница
  3. content/a/b/index.lua — динамическая страница (папка-раздел)
  4. content/a/b/index.md — статическая страница (папка-раздел)
  5. 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 &mdash; 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  = "Задание &mdash; загадка",
    prompt = "![Картинка](/task/riddle.png)\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-ключа с разным направлением — не путайте их и не выдавайте один за другой:

  1. В настройках репозитория должен быть добавлен ключ для стягивания данных из репозитория сервером.
  2. В секрете 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

Как пользоваться скриптами

  1. Первичный деплой (один раз, вручную, от обычного пользователя с 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
    
  2. Обновление вручную (если нужно обновить в обход CI, тем же пользователем, из того же чекаута):

    bash scripts/redeploy.sh
    
  3. Автоматическое обновление: .forgejo/workflows/deploy.yml при каждом пуше в master заходит по SSH на DEPLOY_HOST под DEPLOY_USER (ключ №2) и выполняет bash "$DEPLOY_PATH/scripts/redeploy.sh" — то есть просто вызывает тот же redeploy.sh, что и при ручном обновлении.

  4. Поставьте перед сервисом обратный прокси (nginx/caddy) для TLS и домена, проксируя на 127.0.0.1:3000 (или другой PORT, если переопределяли).

  5. Обновление контента (.md/.lua/картинки) само по себе не требует пересборки и рестарта — можно просто заменить файлы в content/ / lua_modules/ на сервере (redeploy.sh для этого не нужен). Пересборка (а значит и redeploy.sh) нужна только при изменении Rust-кода движка или шаблонов оформления (templates/).