Веб-платформа LectureLog: загрузка лекций, отслеживание обработки, чтение конспектов и публичная витрина. Один из трёх репозиториев проекта:
- core (
lecturelog-core) — ядро обработки: принимает задачи, обрабатывает медиа, владеет своим MinIO и таблицей задач. Источник правды по контракту. - web (
lecturelog-web, этот репозиторий) — платформа: пользователи, права, загрузка, читалка, витрина. Ходит в ядро только по HTTP, в БД ядра не лезет. - docs — общая документация процесса.
Платформа общается с ядром через типизированный Go-клиент, сгенерированный из OpenAPI-контракта ядра.
cp .env.example .env
# Заполните GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, WEB_POSTGRES_PASSWORD,
# PLATFORM_DB_DSN, LECTURELOG_WEBHOOK_SECRET и CORE_MINIO_*.
make up-stub
make watchmake watch следит за изменениями Go-кода, пересобирает и перезапускает сервер.
Если air не установлен, Makefile запускает его через go run; make dev
остаётся запасным запуском без hot-reload. Изменения templ и CSS генерируются
вручную командами make templ и make tailwind.
make up-stub поднимает Postgres и MinIO-заглушку ядра для изолированной
разработки. Если запущено реальное ядро, используйте make up: он поднимает
только Postgres. Укажите адрес ядра в CORE_API_BASE_URL без /api/v1 и его
MinIO в CORE_MINIO_*. После запуска откройте http://localhost:8080.
Заполните .env: задайте Google OAuth, общий с ядром
LECTURELOG_WEBHOOK_SECRET и значения CORE_* реального ядра. Затем выполните:
make prodКоманда собирает web-контейнер и поднимает его вместе с Postgres; адрес —
http://localhost:8080. Для остановки используйте make prod-down.
Для доступа web-контейнера к ядру, работающему в Docker на хосте, в .env
используйте host.docker.internal:<порт> (либо подключите оба сервиса к общей
Docker-сети). Есть важное различие: CORE_MINIO_ENDPOINT попадает в presigned
URL и должен быть доступен браузеру пользователя. Поэтому внутренний адрес для
web и внешний адрес для браузера могут конфликтовать; в локальном e2e обычно
подходит общий localhost:<порт>.
Короткий e2e-сценарий: войдите через Google, загрузите лекцию, дождитесь
обработки ядром и вебхука ready, затем откройте /hub и читалку
/read/{id}. Реальная читалка требует, чтобы ядро записало
results/<core_task_id>/structure.json в свой MinIO.
Миграции БД применяются автоматически при старте сервера через db.Migrate,
отдельная команда не нужна.
Основной prod-like сценарий для быстрой проверки — готовый Docker image из GHCR, без сборки исходников на сервере. Каналы образов:
| Docker tag | Откуда берётся | Назначение |
|---|---|---|
dev |
каждый push в git-ветку dev |
быстрый прод-чек текущей разработки |
latest |
git tag v* |
стабильный релиз |
vX.Y.Z |
git tag vX.Y.Z |
воспроизводимый релиз |
latest — это Docker-тег, не git-ветка. Стабильный код живёт в main, активная
разработка — в dev.
Минимальное развёртывание web на VPS:
mkdir -p /opt/lecturelog-web
cd /opt/lecturelog-web
curl -fsSLo docker-compose.yml https://raw.githubusercontent.com/LectureLog/lecturelog-web/refs/heads/dev/deploy/compose.vps.yml
curl -fsSLo .env https://raw.githubusercontent.com/LectureLog/lecturelog-web/refs/heads/dev/deploy/env.web.example
docker network create lecturelog-shared || trueОтредактируйте .env: задайте Google OAuth, WEB_POSTGRES_PASSWORD, общий с
core LECTURELOG_WEBHOOK_SECRET и публичный HTTPS endpoint MinIO ядра в
CORE_MINIO_ENDPOINT без схемы, например files.example.com. Затем:
docker compose pull
docker compose up -d
docker compose logs -f webДля dev-проверки оставьте LECTURELOG_WEB_IMAGE_TAG=dev. Для стабильного канала
используйте latest, для воспроизводимого деплоя — конкретный тег, например
v0.2.0.
Core должен быть поднят в общей Docker-сети lecturelog-shared: web обращается к
нему по http://lecturelog-core-api:8000. Наружу web-порт публикуется только на
127.0.0.1, поэтому публичный HTTPS-доступ должен идти через nginx/caddy.
- Проверьте, что
devзелёный и его образ:devпроверен на VPS. - Перенесите проверенный код в
main. - Поставьте semver-тег и отправьте его в GitHub:
git checkout main
git merge --ff-only dev
git tag v0.2.0
git push origin main v0.2.0GitHub Actions соберёт ghcr.io/lecturelog/lecturelog-web:v0.2.0,
обновит ghcr.io/lecturelog/lecturelog-web:latest и создаст GitHub Release.
Ядро скачивает приватные, возрастные и региональные видео с YouTube только
если у него есть авторизационные cookies аккаунта YouTube. Cookies — общий
секрет на всё ядро (не на пользователя), поэтому загрузить и обновить их
может только администратор платформы (см. ADMIN_EMAILS в таблице
env-ключей выше — именно этот список определяет, кто видит /settings).
Как попасть в настройки. Администратор видит иконку-шестерёнку в шапке
рядом с переключателем темы; она ведёт на /settings. У остальных
пользователей иконка не отображается, а прямой заход на /settings
отклоняется.
Как получить cookies.txt. Зайдите в браузере на youtube.com под тем
аккаунтом, cookies которого нужно передать ядру, и экспортируйте их
расширением браузера в формате Netscape (например, «Get cookies.txt
LOCALLY»). Получится файл cookies.txt.
Загрузка и статус. На /settings в секции «YouTube cookies» видно,
загружены ли cookies сейчас (дата обновления и размер файла) или что их нет.
Чтобы загрузить новые, выберите файл cookies.txt (до 1 МБ) и нажмите
«Загрузить» — файл целиком уходит в ядро, платформа его не хранит. Кнопка
«Удалить cookies» (с подтверждением) стирает их из ядра.
Безопасность. Cookies дают полный доступ к YouTube-аккаунту — по ним можно войти в аккаунт, смотреть историю и менять настройки. Не используйте для этого свой основной личный аккаунт: заведите отдельный, «технический».
Когда cookies устарели. Если ядро отклоняет cookies при обработке
YouTube-лекции, карточка лекции покажет ошибку «Cookies YouTube устарели —
обратитесь к администратору» — администратору нужно зайти на /settings и
загрузить свежий cookies.txt.
Главный инвариант (см. docs/WORKFLOW.md): openapi.json ядра — производный
от кода ядра (FastAPI), а не пишется руками. Платформа генерирует клиент из
этого файла через oapi-codegen. Отсюда жёсткий порядок изменений:
код ядра → регенерация openapi.json → coreclient платформы (oapi-codegen) → код платформы
Поэтому ядро патчится первым, платформа — после стабилизации контракта. Если ядро отдало несовместимый контракт, клиент платформы перестанет компилироваться до написания доменных модулей — это главный автоловец рассинхрона (GATE B).
Типизированный HTTP-клиент к ядру: доменная обёртка CoreClient поверх
машинно-сгенерированного ClientWithResponses. Пакет лежит в internal/, т.к.
это деталь реализации платформы, а не публичный API.
Доменные методы (internal/coreclient/client.go):
CreateUpload(ctx, filename)— запрос presigned-PUT URL (POST /uploads, телоapplication/json{filename}); возвращает{Key, URL, ExpiresIn}.CreateTask(ctx, params)— создание задачи (POST /tasks, multipart/form-data); ровно один источник —S3KeyилиVideoURL, плюс опциональныеMedia,NoSlidesи презентацияSlidesName/SlidesContent(поле APIslides, PDF/PPTX). multipart-тело собирается вручную, т.к. oapi-codegen для multipart даёт только сырой…WithBodyWithResponse.GetTaskStatus(ctx, taskID)— статус задачи (GET /tasks/{id}); на 404 возвращаетErrTaskNotFound. Nullable-поля (Stage,Error,ErrorCode,ResultPath) отражены указателями.DeleteTask(ctx, taskID)— удаление задачи (DELETE /tasks/{id}, идемпотентно: 204 →nil, в т.ч. при повторном удалении).
Верификатор входящего вебхука (internal/coreclient/webhook.go):
VerifyWebhookSignature(body, signatureHex, secret) bool— constant-time проверка подписи черезhmac.Equal.WebhookPayload— тип тела вебхука{task_id, status, error, error_code}(error/error_code— указатели:nil= JSONnull).
Подпись HMAC существует только на исходящем вебхуке ядра (направление ядро → платформа):
- Ядро само подписывает свой callback: заголовок
X-Webhook-Signature= HMAC-SHA256 (hex) от байтов тела запроса с ключомLECTURELOG_WEBHOOK_SECRET. - Платформа верифицирует эту подпись через
VerifyWebhookSignature(constant-time, от тех же байтов, что пришли по HTTP).
Исходящие запросы платформа → ядро (POST /uploads, POST /tasks,
GET/DELETE /tasks/{id}) НЕ подписываются: ядро их подпись не проверяет.
Защита исходящего направления — сетевой контур и общие секреты, а не подпись
каждого запроса.
HTTP-приём вебхука (endpoint, чтение тела, матч лекции по
core_task_id) — вне B1; это задача C1-sync. B1 даёт только чистый верификатор и тип тела.
Общий слой подключения к Postgres платформы и применения миграций схемы.
Подключение. Точка входа — db.New(ctx, dsn) (*pgxpool.Pool, error): создаёт
pgxpool, проверяет доступность базы через Ping, возвращает пул готовым к
использованию. DSN передаётся строкой (из config.Config.PlatformDBDSN); пакет
db не импортирует internal/config.
Миграции. Движок — jackc/tern/v2 (pgx-родной, без сторонних CLI-зависимостей).
Файлы *.sql встроены в бинарь через //go:embed migrations/*.sql. Применение:
db.Migrate(ctx, pool) errorВызов идемпотентен: повторный запуск при уже применённых миграциях ничего не меняет.
Таблица версий (schema_version) управляется tern автоматически.
Схема (таблицы):
| Таблица | Роль |
|---|---|
users |
Пользователи платформы; email UNIQUE NOT NULL; PK — UUID |
identities |
OAuth-аккаунты; составной PK (provider, provider_sub), FK→users; email не хранится |
sessions |
Сессии; session_id UUID PK, FK→users, expires_at |
lectures |
Лекции; статус/видимость/тип источника — нативные PostgreSQL enum; поля core_task_id, s3_key, video_url, published_at и др. |
UUID генерится базой (gen_random_uuid(), расширение pgcrypto).
Частичный индекс idx_lectures_core_task_id на lectures.core_task_id (WHERE NOT
NULL) — обеспечивает быстрый матч входящего вебхука по идентификатору задачи ядра.
Частичный индекс idx_lectures_public_published_at на lectures.published_at DESC
(WHERE visibility='public', миграция 003_lectures_hub_index.sql) — ускоряет выдачу
публичной витрины /hub.
Data-access лекций (db.LectureDB). Тип LectureDB предоставляет операции
над таблицей lectures; не содержит доменной логики (порядок вызовов ядро→БД —
в пакете lecture).
Тип строки — db.LectureRow; nullable текстовые поля (core_task_id, s3_key,
video_url, error_code) возвращаются через COALESCE как пустая строка;
published_at — *time.Time.
| Метод | Сигнатура | Описание |
|---|---|---|
ListByOwner |
(ctx, ownerID) ([]LectureRow, error) |
Лекции владельца, updated_at DESC; возвращает пустой срез (не nil) |
FindByID |
(ctx, lectureID) (*LectureRow, error) |
По PK; (nil, nil) если не найдена |
Rename |
(ctx, lectureID, ownerID, title) (int64, error) |
Обновляет title + updated_at; фильтр owner_id; возвращает affected |
SetVisibility |
(ctx, lectureID, ownerID, visibility) (int64, error) |
public: WHERE status='ready', устанавливает published_at; private: published_at не обнуляется |
Delete |
(ctx, lectureID, ownerID) (int64, error) |
Hard-delete; вызывается только после core.DeleteTask (гарантия домена) |
SetCoreTaskProcessing |
(ctx, lectureID, ownerID, coreTaskID) (int64, error) |
Переводит failed→processing, обновляет core_task_id, очищает error_code; WHERE status='failed' |
UpdateStatusConditional |
(ctx, coreTaskID, status, errorCode) (int64, error) |
Анти-гонка: обновляет статус только из processing (WHERE status='processing'); потребитель — C1-sync |
ListPublic |
(ctx, limit) ([]PublicLectureRow, error) |
Витрина: публичные лекции (lectures⋈users) с автором, WHERE visibility='public', ORDER BY published_at DESC; потребитель — hub |
UpdateStatusConditional — единственный метод, который потребляет C1-sync
(вебхук / поллинг), а не пакет lecture; его назначение — атомарно принять
результат ядра без гонки с параллельным retry.
Тесты. Дефолтный go test ./... не требует Postgres: проверяет встроенность
embed-файлов, синтаксическую корректность SQL и отказ New на заведомо битом DSN.
Интеграционный тест (тег integration) поднимает Postgres через testcontainers,
применяет миграции и проверяет идемпотентность. Команда:
make migrate-test # go test -tags=integration ./internal/db/... (требует Docker)Единая точка чтения и валидации конфигурации приложения из окружения. Пакет
используется при старте сервера; ни один компонент платформы не обращается к
os.Getenv напрямую.
Сигнатура точки входа:
func Load(getenv func(string) string) (*Config, error)Геттер окружения инъектируется, а не захватывается из os — это делает функцию
детерминированной и тривиально тестируемой без манипуляций с реальным окружением
процесса. Новых зависимостей пакет не вносит (stdlib-only).
Fail-fast с агрегацией. При отсутствии любого обязательного ключа Load
возвращает одну ошибку со списком всех недостающих ключей — не падает на
первом. Заданное, но непарсируемое значение опционального ключа тоже является
ошибкой (не молчаливый дефолт).
Валидация LECTURELOG_WEBHOOK_SECRET вынесена сюда (закрытие долга B1):
пустой или незаданный секрет — обязательная ошибка при загрузке конфигурации.
Прежде эта проверка отсутствовала, что позволяло принять поддельную подпись при
пустом секрете.
Фабрика (*Config).CoreClient() возвращает готовую coreclient.Config без
дублирования полей. CORE_API_BASE_URL передаётся без суффикса /api/v1 —
пути, сгенерированные oapi-codegen, уже несут этот префикс.
| Ключ | Назначение | Обяз. | Дефолт |
|---|---|---|---|
GOOGLE_CLIENT_ID |
Google OAuth client id | да | — |
GOOGLE_CLIENT_SECRET |
Google OAuth client secret | да | — |
PLATFORM_CALLBACK_URL |
OAuth callback URL | да | — |
ADMIN_EMAILS |
Allowlist админов для /settings*, email через запятую; пусто = fail-closed |
нет | пустой список |
LECTURELOG_WEBHOOK_SECRET |
HMAC-секрет вебхука (общий с ядром) | да | — |
PLATFORM_DB_DSN |
DSN Postgres платформы (pgx) | да | — |
CORE_API_BASE_URL |
Базовый URL ядра (без /api/v1) |
да | — |
CORE_MINIO_ENDPOINT |
MinIO ядра endpoint | да | — |
CORE_MINIO_ACCESS_KEY |
MinIO ядра access key | да | — |
CORE_MINIO_SECRET_KEY |
MinIO ядра secret key | да | — |
CORE_MINIO_BUCKET |
MinIO ядра bucket | да | — |
CORE_MINIO_USE_SSL |
MinIO use SSL | нет | false |
PRESIGNED_TTL |
TTL presigned-пачки | нет | 24h |
SESSION_TTL |
TTL сессии | нет | 720h |
PLATFORM_SECURE |
Флаг Secure для кук (true в prod/HTTPS) |
нет | false |
PLATFORM_ADDR |
Адрес и порт HTTP-сервера | нет | :8080 |
Каркас SSR-презентации «Читальный зал» — общий слой рендеринга и роутинга для всех будущих страниц платформы. Доменные страницы (читалка, хаб, загрузка) — C1.
Стек. Роутер — chi; HTML-компоненты —
templ (Go-шаблоны с типизацией, генерация *_templ.go);
интерактивность — htmx v1.9.12; стили — Tailwind CSS v4
(CSS-first, standalone CLI без node).
Toolchain.
- templ прописан как tool-директива
go.mod(как oapi-codegen): никакихtools.go, никакого глобального бинаря. Генерация —go generate ./...(директива вinternal/web/generate.go). Сгенерированные*_templ.goкоммитятся в репозиторий. - Tailwind — standalone CLI-бинарь (без Node.js), скачивается целью
make tailwind-binв./bin/(gitignored). Собранныйinternal/web/static/css/app.cssкоммитится в репозиторий —go testне требует наличия Tailwind-бинаря. - htmx вендорён:
internal/web/static/vendor/htmx.min.js, отдаётся через//go:embed(не CDN).
Layout «Читальный зал». templ-компонент Layout(data, actions):
<html data-theme>— атрибут определяет тему (дефолтlightв разметке, не завязан на JS).- Sticky-шапка 58px: brand-mark, название, слот
actions, тумблер темы. - Иконка настроек в шапке появляется только для администратора и только когда маршруты настроек включены через settings-флаг в контексте запроса.
<head>: Google Fonts (Source Serif 4 + Onest),/static/css/app.css, htmx.- Инлайн-скрипт (до first paint) читает
localStorageи проставляетdata-themeна<html>— анти-FOUC без вспышки дефолтной темы.
Внешние ссылки — только через обёртку. Ссылка за пределы платформы рисуется
компонентом-обёрткой (первая такая — footerExtLink в layout.templ), а не
сырым <a target="_blank">: rel="noopener noreferrer" задаётся в одном месте,
иначе новая вкладка получает доступ к window.opener. href передаётся
выражением, а не строковым литералом, — только тогда templ прогоняет его через
SafeURL-санитайзер. Сами адреса — константы в internal/web/links.go (не в
internal/config: значения статические, а web осознанно не зависит от
config). Правило под тестом: TestFooter_ExternalLinksSecurity проверяет rel
у любой ссылки документа с target="_blank", поэтому внешняя ссылка в обход
обёртки уронит тесты.
Дизайн-токены. Источник — design/tokens.css (пакет design/, style-guide
платформы). Токены скопированы в internal/web/assets/tokens.css и подключены
к Tailwind через директиву @theme (var(--token)); тёмная тема —
переопределение переменных под [data-theme="dark"].
Страница «Мои лекции» (page_lectures.templ). Добавлена в C1-lecture.
LectureCardVM— view-модель карточки (ID, Title, Status, StatusLabel, Visibility, SourceKind, CanPublish, CanRetry, ErrorText, UpdatedAt); маппингlecture.Lecture → LectureCardVMвыполняетlecture/handlers.go.LecturesPage(data LayoutData, vms []LectureCardVM)— полная страница в базовом Layout; пустое состояние —LecturesEmpty.LectureCard(vm LectureCardVM)— карточка; используется и в полных ответах (GET /lectures), и в htmx-партиалах (rename / visibility / retry).LectureTitleInline(vm)— inline-форма переименования (POST на rename).LectureVisibilityToggle(vm)— тумблер видимости (POST на visibility).
Страница читалки (page_reader.templ). ReaderPage получает ReaderVM и
показывает конспект с оглавлением, прогрессом чтения, плеерами, слайдами и
поиском. reader.js реализует scroll-spy оглавления, прогресс, lightbox слайдов
с клавиатурной навигацией и поиск. HTML подтем передаётся через templ.Raw:
он заранее санитизирован goldmark в пакете internal/reader. Стили читалки
собраны на дизайн-токенах. Для визуальной проверки без ядра тест за тегом
preview генерирует самодостаточный reader-preview.html из фикстуры.
CSRF в hx-headers (закрытие долга C0-web). Добавлен internal/web/csrf.go:
func WithCSRFToken(ctx context.Context, token string) context.Context
func CSRFTokenFromContext(ctx context.Context) stringLayoutData.CSRFToken передаётся в Layout и выводится в hx-headers шапки
({"X-CSRF-Token": "<токен>"}), отчего все htmx-запросы страницы автоматически
несут CSRF-токен. cmd/server инжектирует токен в контекст через
csrfInjector-middleware после gorilla/csrf.
Роутер. web.NewRouter() (chi) монтирует:
/static/*— embed-статика (CSS, htmx, vendor-файлы);/— демо-страница (проверка layout).
cmd/server смонтирован в C0-auth — см. раздел ниже.
Тесты. Рендер Layout в bytes.Buffer + httptest-проверка роутера.
Ни браузера, ни Node.js, ни Tailwind-бинаря не требуется — артефакты
(*_templ.go, app.css) коммитятся.
Доменный модуль аутентификации платформы: Google OAuth 2.0, серверные сессии в Postgres, CSRF-защита и middleware прав.
Ключевые типы:
| Тип | Роль |
|---|---|
Profile |
Данные пользователя от OAuth-провайдера (sub, email, email_verified, name, picture) |
User |
Доменный пользователь платформы (UUID, email, имя, аватар) |
Session |
Серверная сессия (UUID, UserID, ExpiresAt) |
Service |
Центральный сервис — содержит бизнес-логику входа, сессий и middleware |
Repository |
Интерфейс доступа к данным (мокируется в тестах) |
OAuthProvider |
Интерфейс OAuth-провайдера (мокируется через httptest) |
Создание сервиса:
auth.NewService(repo Repository, provider OAuthProvider, sessionTTL time.Duration, secure bool, opts ...Option) *Servicesecure=true выставляет флаг Secure на всех куках — использовать в prod (HTTPS).
Allowlist администраторов подключается опцией:
auth.NewService(repo, provider, cfg.SessionTTL, secure, auth.WithAdminEmails(cfg.AdminEmails))cfg.AdminEmails читается из ADMIN_EMAILS: email канонизируются, пустое
значение допустимо и работает fail-closed — админские маршруты, включая
/settings*, будут недоступны всем пользователям.
OAuth flow (Google OAuth 2.0). Профиль берётся из userinfo endpoint (не из
id_token). State-параметр генерируется через crypto/rand безусловно; сверка —
constant-time (hmac.Equal-эквивалент) — защищает OAuth round-trip от CSRF.
NewGoogleProvider(cfg *oauth2.Config, userinfoURL string) OAuthProvider позволяет
подменять endpoint в тестах на httptest-сервер.
Куки:
| Кука | TTL | Назначение |
|---|---|---|
ll_session |
из config.SessionTTL |
HttpOnly, Secure, SameSite=Lax; session_id сессии |
ll_oauth_state |
600 секунд | Одноразовая; OAuth state (анти-CSRF при login) |
Логика входа (resolveUser). Алгоритм «email = личность»:
email_verified=false→ErrEmailNotVerified, ничего не создаётся (guard против account takeover).- Матч по
users.email— найден:UpsertIdentity, возвращается существующий. - Не найден:
CreateUser+UpsertIdentity, возвращается новый.
HTTP-хендлеры и роутинг:
HandleLogin— генерирует state, устанавливаетll_oauth_state, редиректит наAuthCodeURLпровайдера.HandleCallback— сверяет state, обменивает код на профиль черезOAuthProvider.Exchange, вызываетresolveUser, создаёт сессию, выдаётll_session.HandleLogout— удаляет сессию из БД, сбрасываетll_session.Mount(r chi.Router)— монтирует все три маршрута в chi-роутер (GET /auth/login,GET /auth/callback,POST /auth/logout).
Middleware:
LoadSession— читаетll_session, валидирует черезRepository.GetSession(фильтрацияexpires_at > now()на стороне Postgres), кладёт*Userв контекст черезUserFromContext. Анонимные запросы пропускает.LoadAdmin— послеLoadSessionсравнивает email текущего пользователя с allowlist изADMIN_EMAILSи кладёт admin-флаг в контекст для layout и защищённых хендлеров.RequireAuth— блокирует анонимов: обычный запрос →302 /auth/login, htmx (HX-Request: true) →401(htmx не обрабатывает редирект как навигацию).RequireAdmin— блокирует не-админов для admin-only маршрутов: обычный запрос →302 /lectures, htmx →200сHX-Redirect: /lectures. Проверка fail-closed, поэтому пустойADMIN_EMAILSне даёт доступа к/settings*.
CSRF. gorilla/csrf монтируется в cmd/server глобально (ключ 32 байта,
csrf.RequestHeader("X-CSRF-Token")). Под htmx токен передаётся через
hx-headers={"X-CSRF-Token": "..."} — место в layout заложено в C0-web.
Тестируемость. Интерфейсы Repository и OAuthProvider позволяют тестировать
без Postgres и реальных запросов к Google. Дефолтный go test ./... проходит
герметично: OAuth-тест через httptest, resolveUser — на репозиторий-моке.
Доменный модуль «Мои лекции»: хранит бизнес-логику управления лекциями пользователя и монтирует HTTP-хендлеры ЛК.
Ключевые типы:
| Тип | Роль |
|---|---|
Lecture |
Доменная лекция (ID, OwnerID, CoreTaskID, Status, Visibility, SourceKind, S3Key, VideoURL, Title, …) |
Status |
Enum статуса обработки: processing / ready / failed |
Visibility |
Enum видимости: private / public |
Repository |
Интерфейс data-access (владеет пакет lecture → адаптер живёт в cmd/server) |
CoreTasks |
Интерфейс к ядру: DeleteTask + CreateTask (мокабельность без coreclient) |
CreateTaskParams |
Параметры создания задачи: S3Key, VideoURL, Media |
Service |
Центральный сервис; содержит repo и core |
Конструктор:
lecture.NewService(repo Repository, core CoreTasks) *ServiceМетоды Service:
| Метод | Сигнатура | Описание |
|---|---|---|
List |
(ctx, ownerID) ([]Lecture, error) |
Список лекций владельца (updated_at DESC) |
Rename |
(ctx, lectureID, ownerID, title) (Lecture, error) |
Trim + валидация ≤200 симв.; возвращает свежую запись |
SetVisibility |
(ctx, lectureID, ownerID, vis Visibility) (Lecture, error) |
Публикация только готовых (ready); снятие — в любой момент |
Delete |
(ctx, lectureID, ownerID) error |
Сначала core.DeleteTask, затем удаление строки; ошибка ядра прерывает операцию |
Retry |
(ctx, lectureID, ownerID) (Lecture, error) |
Повторная обработка только failed-лекций с источником |
Бизнес-правила:
- Публикация (
SetVisibility → public): разрешена только приstatus=ready; иначеErrNotReady. - Retry: разрешён только при
status=failedи наличииS3KeyилиVideoURL; иначеErrNotFailed/ErrNoRetrySource. Алгоритм:CreateTaskв ядре →SetCoreTaskProcessingв БД (failed→processing). - Delete: owner-проверка выполняется до обращения к ядру — нельзя удалить чужую задачу. Если ядро вернуло ошибку — строка в БД не трогается.
- Анти-перебор ID: во всех мутациях (Rename/SetVisibility/Delete/Retry) несуществующая и чужая лекция возвращают одинаковый
ErrNotFound— исключает оракул чужих UUID.
Доменные ошибки:
| Константа | Значение |
|---|---|
ErrNotFound |
Лекция не найдена или нет прав |
ErrNotReady |
Публикация доступна только для ready |
ErrNotFailed |
Retry доступен только для failed |
ErrNoRetrySource |
Нет источника для повтора |
HTTP-хендлеры (Service.Mount):
GET /lectures — страница «Мои лекции» (полный рендер)
POST /lectures/{id}/rename — переименование (htmx partial: карточка)
POST /lectures/{id}/visibility — переключение видимости (htmx partial: карточка)
POST /lectures/{id}/retry — повторная обработка (htmx partial: карточка)
DELETE /lectures/{id} — удаление (пустой 200, htmx удаляет DOM-узел)
Все маршруты работают под auth.RequireAuth (монтируется в cmd/server).
CSRF-токен передаётся через hx-headers={"X-CSRF-Token": "..."} — Layout
берёт значение из web.LayoutData.CSRFToken, которое хендлер заполняет через
web.CSRFTokenFromContext.
Доменный модуль загрузки лекций: валидация файла, получение presigned-URL у ядра, HMAC-токен незавершённой загрузки, создание задачи и строки лекции.
Ключевые типы:
| Тип | Роль |
|---|---|
Service |
Центральный сервис; содержит Core, Repository, Signer |
Core |
Интерфейс к ядру: CreateUpload + CreateTask |
Repository |
Интерфейс БД: CreateLecture |
Signer |
HMAC-подписчик/верификатор stateless-токена незавершённой загрузки |
PrepareResult |
Ответ presign: Token, PutURL, S3Key, Media, Title, ExpiresIn |
ConfirmInput |
Форма подтверждения: Token, S3Key, Title, HasPDF, ExtractSlides |
YouTubeInput |
Форма YouTube: URL, Title, HasPDF, ExtractSlides |
CreateLectureParams |
Параметры новой строки лекции в БД |
Конструктор:
upload.NewService(core Core, repo Repository, signer *Signer, uploadTTL time.Duration) *ServiceМетоды Service:
| Метод | Описание |
|---|---|
PrepareFileUpload(ctx, userID, filename, size, mime) |
Валидирует мета-данные файла, запрашивает у ядра presigned-PUT URL, возвращает HMAC-токен |
ConfirmFileUpload(ctx, userID, ConfirmInput) |
Верифицирует токен, создаёт задачу в ядре, сохраняет лекцию в БД |
CreateYouTube(ctx, userID, YouTubeInput) |
Валидирует YouTube-URL, создаёт задачу в ядре, сохраняет лекцию в БД |
HTTP-хендлеры (Service.Mount):
GET /upload — страница формы загрузки (templ, режим Файл/Ссылка,
drop-зона, опции has_pdf / extract_slides, заголовок)
POST /upload/presign — JSON {filename, size, mime} → {token, put_url, s3_key,
media, title, expires_in}
POST /upload/confirm — form {token, s3_key, title, has_pdf, extract_slides}
→ задача в ядре + строка лекции; HX-Redirect: /lectures
POST /upload/youtube — form {url, title, has_pdf, extract_slides}
→ то же для YouTube-URL; HX-Redirect: /lectures
GET /upload смонтирован напрямую в cmd/server под RequireAuth; три мутирующих
маршрута — через uploadSvc.Mount.
Клиентский поток загрузки файла (internal/web/static/js/upload.js):
- JS отправляет
POST /upload/presignс{filename, size, mime}. - Получает
{token, put_url, s3_key, …}, выполняет прямойPUTфайла в MinIO поput_url(минуя сервер платформы). - Отправляет
POST /upload/confirmс{token, s3_key, title, …}. - Сервер отвечает
HX-Redirect: /lectures— htmx выполняет полную навигацию.
Guard от двойного сабмита блокирует повторную отправку до завершения потока.
CSRF-токен передаётся через data-атрибут формы.
HMAC-токен (Signer). Stateless-токен связывает userID + s3_key + media +
срок действия. Кодируется как base64url(payload).base64url(hmac-sha256).
Верификация: constant-time hmac.Equal, проверка владельца, проверка срока.
Валидация (validate.go):
- Поддерживаемые расширения:
.mp4,.mov,.mkv,.webm,.avi(video);.mp3,.wav,.m4a,.aac,.ogg,.flac(audio). - MIME от клиента — недоверенный; пустой MIME допускается.
- Лимит размера файла: 5 ГБ (бета-потолок).
- YouTube-URL: только
youtube.com,www.youtube.com,youtu.be,m.youtube.com.
Доменные ошибки:
| Константа | Значение |
|---|---|
ErrUnsupportedMedia |
Неподдерживаемый тип медиа |
ErrEmptyFile |
Пустой файл (size ≤ 0) |
ErrTooLarge |
Файл превышает лимит |
ErrEmptyFilename |
Пустое имя файла |
ErrMediaMismatch |
MIME-тип не совпадает с расширением файла |
ErrInvalidURL |
Некорректная или не-YouTube ссылка |
ErrForbidden |
Токен не принадлежит пользователю или истёк |
Известные ограничения (технический долг):
- PDF-слайды (
has_pdf) пока не передаются в ядро — проброс файла отдельный атом.has_pdf=trueвключаетno_slides=trueв задаче ядра; без PDF тумблерextract_slidesуправляет извлечением кадров из видео. - Прямые (не-YouTube) медиа-URL не поддержаны.
uploadSignKeyгенерируется при каждом старте — рестарт сервера инвалидирует все незавершённые presign-токены (приемлемо для беты).
Доменный модуль синхронизации статуса лекции с ядром: приём подписанного вебхука и поллинг-прокси статуса задачи.
Ключевые типы:
| Тип | Роль |
|---|---|
Service |
Центральный сервис; содержит Repository, CoreStatus, webhookSecret |
Repository |
Узкий порт БД: UpdateStatusConditional + FindByID |
CoreStatus |
Узкий порт ядра: GetTaskStatus |
TaskProgress |
Статус задачи ядра: Stage, ProgressPct, Status, ErrorCode |
LectureView |
Минимальный срез лекции для карточки и проверки владельца |
Конструктор:
syncsvc.NewService(repo Repository, core CoreStatus, webhookSecret string) *ServiceHTTP-хендлеры:
POST /webhooks/core — приём вебхука ядра (ПУБЛИЧНЫЙ, без сессии;
защищён HMAC X-Webhook-Signature, CSRF-exempt)
GET /lectures/{id}/status — поллинг-прокси (под RequireAuth, htmx ~10с):
возвращает htmx-фрагмент карточки лекции
HandleWebhook (POST /webhooks/core):
- Тело ограничено
MaxBytesReader1 МБ. - Подпись:
coreclient.VerifyWebhookSignature(body, X-Webhook-Signature, secret)— constant-time HMAC-SHA256. Неверная подпись →401 Unauthorized. - Тело: JSON
{task_id, status, error, error_code}(coreclient.WebhookPayload). - Допустимые статусы:
processing,ready,failed; иное →400 Bad Request. - Обновление в БД:
UpdateStatusConditional(анти-гонка — только изprocessing), идемпотентно. - CSRF-exempt: маршрут
POST /webhooks/coreвыведен из-подgorilla/csrfчерез обёрткуcsrfExemptвcmd/server.
HandlePollStatus (GET /lectures/{id}/status):
- Требует авторизации (
RequireAuth); проверяет владельца лекции. - Если статус нетерминальный и задача есть в ядре — запрашивает
GetTaskStatus. - Fallback-запись: при переходе задачи в терминальный статус (по ответу
ядра) выполняет
UpdateStatusConditionalв БД (resilience к потере вебхука). - При недоступности ядра — мягкая деградация: возвращает карточку с последним известным статусом из БД.
- Ответ:
text/html; charset=utf-8— htmx-фрагмент (web.LectureCard).
Маппинг error_code → русская метка реализован в lectureToVM:
| Код ядра | Отображение |
|---|---|
rate_limit |
Превышен лимит обработки |
bad_input |
Некорректный источник |
internal |
Внутренняя ошибка обработки |
processing_error |
Ошибка обработки |
download_error |
Ошибка загрузки |
transcription_error |
Ошибка распознавания речи |
Доменный модуль публичной витрины: список опубликованных лекций, открытый
анонимным посетителям. Зеркалит структуру internal/lecture (Service + Repository
- handlers).
Ключевые типы:
| Тип | Роль |
|---|---|
PublicLecture |
Публичная лекция витрины с автором (ID, Title, SourceKind, PublishedAt, AuthorName, AuthorAvatarURL) |
Repository |
Узкий порт БД: ListPublic(ctx, limit) — адаптер поверх db.LectureDB живёт в cmd/server |
Service |
Центральный сервис; хранит repo и потолок выдачи |
Конструктор:
hub.NewService(repo Repository, limit int) *Servicelimit <= 0 заменяется значением по умолчанию (defaultLimit = 200).
HTTP-хендлер (Service.Mount):
GET /hub — публичная витрина лекций (visibility='public', ORDER BY
published_at DESC); ОТКРЫТА анонимам, без RequireAuth
Хендлер рендерит web.HubPage (карточки web.HubCardVM: автор, тип источника,
дата публикации, ссылка в читалку /read/{id}).
Известные ограничения (технический долг):
/hubпока отдельная страница, а не лендинг/.- Серверной пагинации нет — выдача ограничена потолком
limit=200.
Доменный модуль «Читальный зал»: загружает структуру конспекта structure.json,
проверяет доступ к лекции, рендерит Markdown через goldmark без unsafe-HTML и
выдаёт presigned-ссылки на медиа и слайды.
GET /read/{id} — страница чтения; public-лекция доступна анонимно,
private — только владельцу
GET /read/{id}/export — экспорт; 302 на presigned-архив результата ядра
Маршруты монтируются под глобальным LoadSession, вне RequireAuth: доступ
проверяет сервис. Чужая private-лекция и отсутствующая лекция возвращают один
404. Не готовая лекция возвращает 202 с экраном обработки; недоступность ядра —
мягкий 502 без стектрейса.
Единственный бинарь платформы. Цепочка инициализации:
config.Load → coreclient.New → db.New + db.Migrate → dbAdapter → auth.NewService
→ lecture.NewService (coreTasksAdapter) → upload.NewService (uploadRepo, Signer)
→ syncsvc.NewService (syncRepo, coreStatusAdapter) → reader.NewService (readerRepo, s3)
→ reader.NewHandlers → web.NewRouter → ListenAndServe
dbAdapter — адаптер из cmd/server, реализует auth.Repository поверх
db.UserDB и db.SessionDB. Связка намеренно живёт здесь: internal/db не
импортирует internal/auth, internal/auth не знает про pgx. Корректность
проверяется compile-time: var _ auth.Repository = (*dbAdapter)(nil).
coreTasksAdapter — реализует lecture.CoreTasks поверх *coreclient.CoreClient
(DeleteTask, CreateTask). Проверяется compile-time.
uploadRepo — реализует upload.Repository поверх db.LectureDB
(CreateLecture). Проверяется compile-time.
syncRepo — реализует syncsvc.Repository поверх db.LectureDB
(UpdateStatusConditional, FindByID). Проверяется compile-time.
coreStatusAdapter — реализует syncsvc.CoreStatus поверх
*coreclient.CoreClient (GetTaskStatus). Проверяется compile-time.
hubRepo — реализует hub.Repository поверх db.LectureDB (ListPublic).
Проверяется compile-time. Витрина /hub монтируется вне RequireAuth-группы.
readerRepo — реализует reader.LectureRepo поверх db.LectureDB
(FindByID). reader.Service использует s3.Client для чтения и presign,
а *coreclient.CoreClient — для ссылки экспорта.
Единственный экземпляр *coreclient.CoreClient (создаётся через coreclient.New)
и единственный экземпляр *db.LectureDB разделяются между всеми адаптерами.
web.NewRouter принимает вариативные опции — web.WithGlobalMiddleware и
web.WithMount. Пакет internal/web остаётся presentation/router layer:
layout импортирует internal/auth только для auth.IsAdminFromContext в
web.NewLayoutData, а доменные сервисы по-прежнему инъектируются через опции:
web.NewRouter(
web.WithGlobalMiddleware(
authSvc.LoadSession,
authSvc.LoadAdmin,
csrfExempt("/webhooks/core", csrfMiddleware), // вебхук ядра выведен из-под CSRF
csrfInjector,
),
web.WithMount(func(r chi.Router) { r.Post("/webhooks/core", syncSvc.HandleWebhook) }),
web.WithMount(func(r chi.Router) { authSvc.Mount(r) }),
web.WithMount(func(r chi.Router) { hubSvc.Mount(r) }), // GET /hub — анонимам
web.WithMount(func(r chi.Router) { readerHandlers.Mount(r) }), // GET /read/{id} — доступ проверяет сервис
web.WithMount(func(r chi.Router) {
r.Group(func(pr chi.Router) {
pr.Use(authSvc.RequireAuth)
lectureSvc.Mount(pr) // GET /lectures, POST /lectures/{id}/*
pr.Get("/upload", ...) // страница формы загрузки
uploadSvc.Mount(pr) // POST /upload/presign|confirm|youtube
pr.Get("/lectures/{id}/status", syncSvc.HandlePollStatus)
})
}),
)Когда в ветке присутствует сервис настроек, маршруты /settings* должны
монтироваться отдельной защищённой группой под RequireAuth -> RequireAdmin;
под bare RequireAuth их монтировать нельзя. Иконка настроек в layout
показывается только при IsAdmin и settings-флаге, который сервер выставляет
через web.WithSettingsAvailable(ctx) после подключения этих маршрутов.
Адрес задаётся через PLATFORM_ADDR (дефолт :8080).
Известные ограничения (технический долг).
- CSRF-ключ и ключ подписи presign-токенов (
uploadSignKey) генерируются черезcrypto/randпри каждом старте сервера. При рестарте все ранее выданные CSRF-токены и незавершённые presign-токены инвалидируются. Для прод-стабильности необходимо вынести ключи в env-переменные (PLATFORM_CSRF_KEY,PLATFORM_UPLOAD_SIGN_KEY). - Мягкий экран 502/503 при недоступности ядра (для
confirmиyoutube) — не реализован (долг §8).
go generate ./...Шаги генерации (internal/coreclient/generate.go):
- Нормализация спеки
scripts/normalize_openapi.py— схлопывает 3.1.0-конструкцию nullable (anyOf:[{...},{type:null}]) в 3.0-совместимыйnullable: true, т.к. oapi-codegen v2.7.x не понимает 3.1.0-nullable напрямую. Промежуточныйopenapi.normalized.jsonв репозиторий не коммитится. - oapi-codegen (режим
types + client) →internal/coreclient/gen.go(коммитится в репозиторий).
Вендоренная копия спеки ядра — internal/coreclient/openapi.json. Обновление из
соседнего репозитория ядра:
make sync-spec # cp ../lecturelog-core/docs/openapi.json -> internal/coreclient/openapi.jsonПосле make sync-spec обязательны make generate и ревью diff в gen.go —
чтобы поймать рассинхрон контракта.
Версии прибиты: Go 1.25, oapi-codegen v2.7.1 (через tool-директиву
go.mod, без tools.go).
Цели Makefile:
| Цель | Действие |
|---|---|
make generate |
нормализация спеки + oapi-codegen |
make web-gen |
go generate ./... для пакета internal/web (templ) |
make tailwind-bin |
скачать Tailwind standalone CLI в ./bin/ |
make build |
generate → go build ./... |
make vet |
go vet ./... |
make test |
go test ./... (юнит + контрактный smoke к замоканному ядру) |
make gate |
полные ворота: templ-генерация + Tailwind-сборка + build + vet + test |
make gen-check |
проверка детерминизма генерации (git diff --exit-code) |
make sync-spec |
обновить вендоренную спеку из репозитория ядра |
make migrate-test |
интеграционная проверка миграций (требует Docker) |
Ворота GATE B (слой 1 приёмки платформы, coreclient/config/db):
go generate ./... && go build ./... && go vet ./... && go test ./...go generate обязателен перед сборкой — он создаёт/обновляет gen.go из
вендоренной спеки. Контрактный smoke поднимает httptest-мок ядра и проверяет
пути, методы, Content-Type, сериализацию тел и маппинг кодов (200/204/400/404/409)
в доменные ошибки — герметично, без сети и соседнего репозитория.
Ворота C0-web (make gate): templ-генерация + сборка Tailwind + go build/vet/test.
go test проходит без Node.js, Tailwind-бинаря и браузера — все артефакты
(*_templ.go, app.css) коммитятся в репозиторий.
make gen-check — CI-цель, проверяет детерминизм генерации: запускает go generate
и падает, если git diff --exit-code обнаруживает изменения.
Дефолтные ворота не требуют ни Docker, ни Postgres — все интеграционные тесты
изолированы тегом integration и запускаются только через make migrate-test.
- B1 —
internal/coreclient: типизированный клиент ядра, HMAC-верификатор вебхука, GATE B зелёный. - C0-config —
internal/config: единый конфиг-слой, fail-fast агрегация ошибок окружения, валидацияLECTURELOG_WEBHOOK_SECRET(долг B1 закрыт). - C0-db —
internal/db: схема и миграции Postgres платформы (tern + embed), таблицы users/identities/sessions/lectures, GATE B зелёный, интеграционный тест заintegration-тегом. - C0-web —
internal/web: каркас «Читальный зал» завершён. chi-роутер, templ-layout, htmx, Tailwind v4, дизайн-токены изdesign/, анти-FOUC, тумблер темы, web.NewRouter со статикой и демо-страницей,make gateзелёный. - C0-auth —
internal/auth+cmd/server: волна C0 (фундамент) завершена. Google OAuth 2.0, серверные сессии в Postgres, CSRF (gorilla/csrf), middlewareLoadSession/RequireAuth, точка входаcmd/serverс полной цепочкой инициализации, compile-time проверкаdbAdapter.make gateзелёный.
Волна C1 — активна. Завершённые атомы:
-
C1-lecture —
internal/lecture+ расширениеinternal/dbиinternal/web: доменный модуль «Мои лекции» готов. ТипыLecture/Status/Visibility, интерфейсыRepositoryиCoreTasks,Serviceс методами List/Rename/SetVisibility/Delete/Retry, все бизнес-правила (owner-проверка до ядра, порядок delete, условия публикации/retry), HTTP-хендлеры ЛК подauth.RequireAuth, страницаLecturesPage+LectureCard, CSRF через hx-headers.make gateзелёный. -
C1-upload —
internal/upload+ проводка вcmd/server: загрузка лекций полностью готова. Страница формы (GET /upload), presign-поток (POST /upload/presign), подтверждение (POST /upload/confirm), YouTube (POST /upload/youtube), клиентский JS-поток (presign → прямой PUT в MinIO → confirm), HMAC stateless-токенSigner, валидация медиа.cmd/serverподключает реальный*coreclient.CoreClientчерезcoreTasksAdapterиcoreStatusAdapter— задачи реально уходят в ядро.make gateзелёный. -
C1-sync —
internal/syncsvc+ проводка вcmd/server: синхронизация статуса лекции с ядром готова. ВебхукPOST /webhooks/core(HMAC-подпись, CSRF-exempt, conditional update, идемпотентность), поллинг-проксиGET /lectures/{id}/status(htmx ~10с, fallback-запись терминального статуса, мягкая деградация при недоступности ядра).make gateзелёный.Известные ограничения (технический долг C1):
- PDF-слайды (
has_pdf) не передаются в ядро — проброс файла отдельный атом. Без PDF тумблерextract_slidesвключает извлечение кадров из видео. - Прямые (не-YouTube) медиа-URL не поддержаны.
uploadSignKeyи CSRF-ключ генерируются на старте — рестарт инвалидирует незавершённые presign-токены (приемлемо для беты).- Мягкий экран 502/503 при недоступности ядра — долг (§8).
- PDF-слайды (
-
C1-devstack — dev-experience: локальный запуск в одну команду. godotenv подгружает
.envвcmd/serverпередconfig.Load;.env.example,docker-compose.yml(Postgres 16 + MinIO + minio-init создаёт bucket), Makefile-целиup/down/dev. См. раздел «Локальный запуск». -
C1-hub —
internal/hub+ индекс003+ страница: публичная витринаGET /hub(visibility='public' ORDER BY published_at DESC с автором), открыта анонимам.db.LectureDB.ListPublic(lectures⋈users), частичный индексidx_lectures_public_published_at.make gateзелёный. -
C1-reader —
internal/reader+internal/s3+ UI и проводка: страница чтения конспектаGET /read/{id}, доступная анонимам для public-лекций и владельцу для private; экспортGET /read/{id}/exportперенаправляет на presigned-архив ядра. Карточки/hubведут на/read/{id}. Реальный сквозной e2e остаётся заблокированным ядром: эндпоинтstructure.jsonещё не доступен.
Подробности — в docs/WORKFLOW.md и docs/TASKS.md.