golang

Headless CMS на Go — самая минималистичная система управления сайтом

  • суббота, 28 марта 2026 г. в 00:00:11
https://habr.com/ru/articles/1015710/

Когда очередной лендинг требует «просто принимать заявки и показывать новости», разработчик оказывается перед выбором: поднять Laravel/Django с кучей зависимостей, купить SaaS-подписку, или написать что-то своё. Я выбрал третий путь — и это оказалось интереснее, чем я ожидал.

В этой статье разбираю архитектурные решения, которые принял при написании LightHeadless — минималистичного headless CMS на Go. Ни одного внешнего веб-фреймворка, никакого ORM, никакого CGO, никакого Redis. Только стандартная библиотека, три зависимости и 125 тестов с детектором гонок.

Постановка задачи

Типичный лендинг для малого бизнеса решает две задачи: собирает заявки и показывает актуальные новости или акции. Требования к бэкенду при этом скромные:

  • REST API для публичных запросов (запись заявки, получение новостей)

  • Административная панель для менеджеров

  • Интеграция с CRM (Bitrix24 как самый распространённый вариант в СНГ)

  • Простое развёртывание на дешёвом VPS

Ни один из готовых инструментов не закрывал этот набор без существенного оверхеда. WordPress с плагинами — это PHP и MySQL. Strapi — это Node.js и сложная конфигурация. Ghost — блог-движок, не CRM-интегратор. Directus — избыточен для одной страницы.

Решение: написать специализированный инструмент на Go, который влезает в один бинарник и разворачивается командой ./cms.

Никакого CGO — pure Go SQLite через modernc

Первая нетривиальная задача: как использовать SQLite без CGO?

Классический драйвер mattn/go-sqlite3 требует cgo и компилятора C. Это означает сложную кросс-компиляцию и невозможность собрать бинарник командой CGO_ENABLED=0 go build. Для проекта, который должен легко собираться под Linux с Windows-машины, это неприемлемо.

Решение — modernc.org/sqlite. Это полноценный порт SQLite на Go, транслированный из C автоматическими инструментами. API совместимо с database/sql, CGO не нужен.

import ( “database/sql” _ “modernc.org/sqlite” )

func Open(path string) (*sql.DB, error) { db, err := sql.Open(“sqlite”, path) if err != nil { return nil, err }

import (
    "database/sql"
    _ "modernc.org/sqlite"
)

func Open(path string) (*sql.DB, error) {
    db, err := sql.Open("sqlite", path)
    if err != nil {
        return nil, err
    }

    // WAL-режим: конкурентные читатели + один писатель без блокировок
    pragmas := []string{
        "PRAGMA journal_mode=WAL",
        "PRAGMA busy_timeout=5000",
        "PRAGMA foreign_keys=ON",
        "PRAGMA synchronous=NORMAL",
    }
    for _, p := range pragmas {
        if _, err := db.Exec(p); err != nil {
            return nil, fmt.Errorf("pragma %q: %w", p, err)
        }
    }
    return db, nil
}

WAL` (Write-Ahead Logging) здесь принципиален: он позволяет параллельно читать БД пока идёт запись, что важно при одновременных запросах к публичному API и работе в административной панели.

Итог: CGO_ENABLED=0 go build -o cms ./cmd/server — и бинарник готов. Никаких зависимостей на хосте.

Стандартная библиотека вместо веб-фреймворка

В Go-сообществе не утихает дискуссия: Gin, Echo, Chi или net/http? Для этого проекта ответ однозначный — net/http.

Причины:

  1. Размер: добавление Gin увеличивает дерево зависимостей на десятки пакетов.

  2. Полноты stdlib достаточно: маршрутизация по HTTP-методам и путям нужна простая, без path parameters {id} в сложном стиле.

  3. Прозрачность: каждый слой middleware виден и понятен без погружения в чужой фреймворк.

Роутер — стандартный http.ServeMux с явной проверкой метода там, где нужно:

mux := http.NewServeMux()

// Публичный API
mux.Handle("/api/leads", rateLimiter(http.HandlerFunc(h.CreateLead)))
mux.Handle("/api/news", http.HandlerFunc(h.ListNews))
mux.Handle("/api/news/", http.HandlerFunc(h.GetNews))

// Статика и загрузки
mux.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadPath))))

// Административная панель
mux.Handle("/admin/", authMiddleware(sessionStore, adminHandler))

Middleware оборачивают http.Handler в классическом Go-стиле:

func rateLimiter(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ip := realIP(r)
        if !limiter.Allow(ip) {
            http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
            return
        }
        next.ServeHTTP(w, r)
    })
}

Цепочка выглядит так: logger → rateLimiter → auth → csrfCheck → handler. Каждый слой — отдельная функция, тестируется независимо.

Асинхронная очередь для Bitrix24 на горутинах

Отправка заявки в CRM — операция с непредсказуемым временем ответа. Делать её синхронно в обработчике — значит увеличивать время ответа API и зависеть от доступности внешнего сервиса.

Классическое решение — очередь + воркеры. В мире Python это Celery + Redis, в мире Node.js — Bull + Redis. В Go можно обойтись каналами:

type Worker struct {
    db      *sql.DB
    queue   chan Job
    webhook string
    wg      sync.WaitGroup
}

func NewWorker(db *sql.DB, webhook string, poolSize int) *Worker {
    w := &Worker{
        db:      db,
        queue:   make(chan Job, 100), // буфер 100 задач
        webhook: webhook,
    }
    for i := 0; i < poolSize; i++ {
        w.wg.Add(1)
        go w.process()
    }
    return w
}

func (w *Worker) process() {
    defer w.wg.Done()
    for job := range w.queue {
        w.sendToBitrix(job)
    }
}

func (w *Worker) Enqueue(job Job) {
    select {
    case w.queue <- job:
        // задача принята
    default:
        // очередь полна — логируем, не теряем заявку
        log.Printf("bitrix queue full, lead %d will be retried manually", job.LeadID)
    }
}

Контекст с таймаутом 30 секунд даёт воркерам шанс завершить текущие задачи перед завершением процесса. После kill или Ctrl+C сервер дожидается окончания HTTP-запросов (http.Server.Shutdown) и завершения очереди.

CSRF-защита на HMAC-SHA256

Административная панель использует HTML-формы с HTMX. Без CSRF-защиты любая страница в браузере менеджера могла бы отправить форму от его имени.

Классический подход — синхронизирующий токен (Synchronizer Token Pattern):

func generateCSRFToken(sessionID, secret string) string {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(sessionID))
    return hex.EncodeToString(mac.Sum(nil))
}

func validateCSRFToken(r *http.Request, sessionID, secret string) bool {
    token := r.FormValue("csrf_token")
    if token == "" {
        token = r.Header.Get("X-CSRF-Token") // для HTMX-запросов
    }
    expected := generateCSRFToken(sessionID, secret)
    return hmac.Equal([]byte(token), []byte(expected))
}

Токен привязан к сессии через HMAC: без знания секрета подделать его невозможно. hmac.Equal использует константное время сравнения, исключая timing-атаки. Токен встраивается в каждую форму через шаблон:

<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">

Для HTMX-запросов (которые отправляются через hx-post) токен добавляется в заголовок через глобальный конфиг:

document.body.addEventListener('htmx:configRequest', (event) => {
    event.detail.headers['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').content;
});

Rate limiting без внешних зависимостей

Публичный API для записи заявок открыт без аутентификации — типичная мишень для спам-ботов. Ограничение: не более 10 запросов в минуту с одного IP.

type IPLimiter struct {
    visitors sync.Map // map[string]*rate.Entry
    mu       sync.Mutex
}

type entry struct {
    count    int
    resetAt  time.Time
}

func (l *IPLimiter) Allow(ip string) bool {
    now := time.Now()
    val, _ := l.visitors.LoadOrStore(ip, &entry{resetAt: now.Add(time.Minute)})
    e := val.(*entry)

    l.mu.Lock()
    defer l.mu.Unlock()

    if now.After(e.resetAt) {
        e.count = 0
        e.resetAt = now.Add(time.Minute)
    }
    if e.count >= 10 {
        return false
    }
    e.count++
    return true
}

sync.Map оптимизирована для случая «много читателей, мало писателей» — именно наш сценарий. Раз в 5 минут фоновая горутина чистит устаревшие записи:

func (l *IPLimiter) cleanup() {
    for range time.Tick(5 * time.Minute) {
        l.visitors.Range(func(key, val interface{}) bool {
            e := val.(*entry)
            if time.Now().After(e.resetAt) {
                l.visitors.Delete(key)
            }
            return true
        })
    }
}
func (l *IPLimiter) Allow(ip string) bool {
    now := time.Now()
    val, _ := l.visitors.LoadOrStore(ip, &entry{resetAt: now.Add(time.Minute)})
    e := val.(*entry)

    l.mu.Lock()
    defer l.mu.Unlock()

    if now.After(e.resetAt) {
        e.count = 0
        e.resetAt = now.Add(time.Minute)
    }
    if e.count >= 10 {
        return false
    }
    e.count++
    return true
}

sync.Map оптимизирована для случая «много читателей, мало писателей» — именно наш сценарий. Раз в 5 минут фоновая горутина чистит устаревшие записи:

func (l *IPLimiter) cleanup() {
    for range time.Tick(5 * time.Minute) {
        l.visitors.Range(func(key, val interface{}) bool {
            e := val.(*entry)
            if time.Now().After(e.resetAt) {
                l.visitors.Delete(key)
            }
            return true
        })
    }
}

Никакого Redis, никакого Memcached — всё в памяти процесса. Для нагрузок лендинга это достаточно.

Первый запуск: UX без конфиг-файлов

Один из принципов проекта — нулевая конфигурация для старта. При первом запуске сервер сам создаёт схему, генерирует пароль администратора и выводит его в консоль:

func firstRun(db *sql.DB) error {
    var count int
    db.QueryRow("SELECT COUNT(*) FROM settings").Scan(&count)
    if count > 0 {
        return nil // уже инициализировано
    }

    password := generateRandomPassword(12)
    hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)

    _, err := db.Exec(`INSERT INTO settings
        (site_name, admin_email, admin_password)
        VALUES ('My Site', 'admin@example.com', ?)`, string(hash))
    if err != nil {
        return err
    }

    fmt.Printf(`
╔══════════════════════════════════════╗
║         FIRST RUN SETUP              ║
║                                      ║
║  Admin email:    admin@example.com   ║
║  Admin password: %-20s  ║
║                                      ║
║  Change password after first login!  ║
╚══════════════════════════════════════╝
`, password)
    return nil
}

Параметры запуска — флаги командной строки с приоритетом у переменных окружения:

# Все три варианта эквивалентны
./cms -port 8080 -db ./data.db
CMS_PORT=8080 CMS_DB_PATH=./data.db ./cms
# или просто ./cms (дефолты: port=8080, db=./cms.db)

Безопасность: базовый OWASP-чеклист

Проект небольшой, но безопасность — не опция:

Угроза

Защита

SQL injection

Параметризованные запросы везде, никакой конкатенации строк

XSS

html/template экранирует всё по умолчанию

CSRF

HMAC-SHA256 токены, привязанные к сессии

Session hijacking

Криптографически случайные 32-байтовые ID

Brute force

Rate limit 10 req/min на IP для публичного API

Password storage

bcrypt с cost=10

Path traversal

http.FileServer не выходит за пределы директории

Особый акцент на html/template вместо text/template: первый контекстно-осведомлён и автоматически экранирует данные в зависимости от того, куда они вставляются — в HTML-атрибут, JavaScript-блок или URL. Это исключает целый класс XSS-уязвимостей.

Результаты и что получилось

Итоговые характеристики:

  • Бинарник: ~20 МБ (включает все шаблоны и статику через embed.FS)

  • Зависимости runtime: ноль (SQLite внутри бинарника)

  • Время ответа /api/leads: < 100 мс

  • Тестов: 125, все проходят с -race

  • Строк кода: ~5 900

Три внешних зависимости:

golang.org/x/crypto    — bcrypt
modernc.org/sqlite     — pure Go SQLite
github.com/yuin/goldmark — рендеринг Markdown в HTML

Это сознательное ограничение: каждая зависимость — это потенциальный supply chain риск, сложность обновлений и увеличение времени сборки.

Что можно было сделать иначе:

  • Использовать chi вместо голого ServeMux — немного удобнее path parameters, цена минимальная

  • Добавить sqlx для сканирования строк в структуры — убрало бы часть бойлерплейта

  • Рассмотреть bbolt вместо SQLite для хранения сессий — меньше блокировок

Главный вывод

Go-стандартная библиотека закрывает 90% потребностей типичного веб-бэкенда. Добавление фреймворков оправдано, когда задача сложнее: группировка маршрутов, middleware-дерево, кодогенерация. Для специализированного сервиса с ограниченным набором эндпоинтов — stdlib достаточна и прозрачна.

Отказ от CGO через modernc.org/sqlite — самое нетривиальное решение в проекте, и оно полностью себя оправдало: кросс-компиляция работает из коробки, производительности хватает для целевой нагрузки.

Детектор гонок (go test -race) нашёл реальный баг в rate limiter во время разработки — это именно тот инструмент, который нужен при любой работе с горутинами.

Без цифровых помощников я бы конечно не справился (без этого теперь никуда))

Пощупать проект и доработать под себя можно здесь https://github.com/dev993848/lightheadless-cms