Files
glchat/internal/server/openapi.go
T
grendervill 9c001fedfe feat(web): базовый клиент Фазы 1 — вход, серверы, комнаты, настройки
Клиент React 19 + TanStack Query + Zustand:

- вход (поле TOTP появляется на `auth.2fa_required`), регистрация с проверками
  и учётом `registration_enabled`, онбординг первого входа с возможностью
  пропустить, редирект `/` и страница `/status`;
- защита маршрутов: неавторизованных — на `/login`, без онбординга — на
  `/onboarding`;
- оболочка `/app`: рейка серверов, сайдбар категорий и комнат, шапка сервера,
  панель пользователя, модалки создания сервера и комнаты, экран «нет серверов»
  со вступлением в главный сервер, заглушка комнаты до Фазы 2;
- настройки: профиль, безопасность (step-up, сессии, logout-all, 2FA с
  локальным QR и кодами восстановления), внешний вид, админ-раздел инстанса;
- Gateway-клиент: HELLO/IDENTIFY/RESUME, heartbeat с ожиданием ACK,
  экспоненциальное переподключение, разбор `INVALID_SESSION {reason,resumable}`;
- диспетчер событий: READY/RESUMED/USER_UPDATE/GUILD_UPDATE/GUILD_DELETE/
  CHANNEL_* применяются к стору, GUILD_CREATE догружается по REST,
  MEMBER_*/ROLE_* инвалидируют запросы участников, ролей и карточки сервера;
- i18n ru/en, 66 тестов Vitest, `check`/`lint`/`test`/`build` зелёные.

Серверные правки под клиент:

- `totp_enabled` в профиле (`GET /users/@me`, вход, регистрация) — клиенту
  нужно знать, требовать ли код при step-up;
- `POST /auth/2fa/setup` отдаёт `otpauth_url` и `qr_png` (data-URI): QR-код
  рисуется локально, внешние сервисы генерации QR не используются;
- права в ответах — имена (`VIEW_CHANNEL|SEND_MESSAGES`), клиент сверяет имена.
2026-09-19 22:03:46 +03:00

282 lines
11 KiB
Go

package server
import (
"encoding/json"
"log/slog"
"net/http"
)
// handleOpenAPI отдаёт объединённый документ OpenAPI 3.1: пути, описанные
// huma (meta и служебные ручки), плюс контракт auth-ручек, которые живут
// на chi и описаны в authPathsJSON (AGENT.md 8.1: единый источник типов).
func (s *Server) handleOpenAPI(w http.ResponseWriter, r *http.Request) {
document := map[string]any{}
if body, err := json.Marshal(s.api.OpenAPI()); err == nil {
if err := json.Unmarshal(body, &document); err != nil {
s.logger.ErrorContext(r.Context(), "decode generated openapi", slog.Any("error", err))
}
}
if document == nil {
document = map[string]any{}
}
paths, _ := document["paths"].(map[string]any)
if paths == nil {
paths = map[string]any{}
}
var extra map[string]any
if err := json.Unmarshal([]byte(authPathsJSON), &extra); err != nil {
s.logger.ErrorContext(r.Context(), "decode auth openapi paths", slog.Any("error", err))
}
for path, item := range extra {
paths[path] = item
}
document["paths"] = paths
if components, ok := document["components"].(map[string]any); ok {
if schemas, ok := components["schemas"].(map[string]any); ok {
var extraSchemas map[string]any
if err := json.Unmarshal([]byte(authSchemasJSON), &extraSchemas); err == nil {
for name, schema := range extraSchemas {
schemas[name] = schema
}
}
}
}
body, err := json.Marshal(document)
if err != nil {
httpxWriteInternalError(w)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(body); err != nil {
s.logger.ErrorContext(r.Context(), "write openapi document", slog.Any("error", err))
}
}
func httpxWriteInternalError(w http.ResponseWriter) {
http.Error(w, "internal error", http.StatusInternalServerError)
}
// authPathsJSON — контракт ручек аутентификации (chi-обработчики).
const authPathsJSON = `{
"/auth/register": {
"post": {
"operationId": "register",
"summary": "Регистрация по email и паролю",
"tags": ["Auth"],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/RegisterRequest" } } }
},
"responses": {
"200": { "description": "Аккаунт создан, сессия выдана", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AuthResponse" } } } },
"403": { "description": "Регистрация выключена", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"409": { "description": "Email или username заняты", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"422": { "description": "Пароль или username не проходят политику", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/auth/login": {
"post": {
"operationId": "login",
"summary": "Вход по email и паролю (с TOTP при включённой 2FA)",
"tags": ["Auth"],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/LoginRequest" } } }
},
"responses": {
"200": { "description": "Вход выполнен, сессия выдана", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AuthResponse" } } } },
"401": { "description": "Неверные данные или требуется код 2FA (auth.2fa_required)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/auth/logout": {
"post": {
"operationId": "logout",
"summary": "Выход: отзывает текущую сессию",
"tags": ["Auth"],
"responses": { "200": { "description": "Сессия отозвана", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } } }
}
},
"/auth/logout-all": {
"post": {
"operationId": "logoutAll",
"summary": "Выйти везде: отзывает все сессии пользователя",
"tags": ["Auth"],
"responses": { "200": { "description": "Все сессии отозваны", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } } }
}
},
"/auth/sessions": {
"get": {
"operationId": "listSessions",
"summary": "Активные сессии пользователя",
"tags": ["Auth"],
"responses": { "200": { "description": "Список сессий", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionsResponse" } } } } }
}
},
"/auth/2fa/setup": {
"post": {
"operationId": "setupTOTP",
"summary": "Создать секрет TOTP (до подтверждения кодом)",
"tags": ["Auth"],
"responses": { "200": { "description": "Секрет и otpauth-URL", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TOTPSetup" } } } } }
}
},
"/auth/2fa/enable": {
"post": {
"operationId": "enableTOTP",
"summary": "Включить 2FA, подтвердив код; возвращает резервные коды",
"tags": ["Auth"],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/TOTPEnableRequest" } } }
},
"responses": { "200": { "description": "2FA включена", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecoveryCodes" } } } } }
}
},
"/auth/step-up": {
"post": {
"operationId": "stepUp",
"summary": "Подтвердить пароль (и 2FA) для чувствительных действий",
"tags": ["Auth"],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/StepUpRequest" } } }
},
"responses": { "200": { "description": "Аутентификация подтверждена", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } } }
}
},
"/users/@me": {
"get": {
"operationId": "getMe",
"summary": "Текущий пользователь",
"tags": ["Users"],
"responses": { "200": { "description": "Профиль пользователя", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserResponse" } } } } }
}
}
}`
// authSchemasJSON — схемы запросов и ответов auth-ручек.
const authSchemasJSON = `{
"RegisterRequest": {
"type": "object",
"required": ["username", "email", "password"],
"properties": {
"username": { "type": "string", "minLength": 2, "maxLength": 32 },
"display_name": { "type": "string", "maxLength": 64 },
"email": { "type": "string", "format": "email" },
"password": { "type": "string", "minLength": 10 },
"locale": { "type": "string", "enum": ["ru", "en"] }
}
},
"LoginRequest": {
"type": "object",
"required": ["email", "password"],
"properties": {
"email": { "type": "string", "format": "email" },
"password": { "type": "string" },
"totp_code": { "type": "string", "description": "Код TOTP или резервный код" }
}
},
"TOTPEnableRequest": {
"type": "object",
"required": ["code"],
"properties": { "code": { "type": "string", "minLength": 6 } }
},
"StepUpRequest": {
"type": "object",
"required": ["password"],
"properties": {
"password": { "type": "string" },
"totp_code": { "type": "string" }
}
},
"User": {
"type": "object",
"required": ["id", "username", "display_name", "status", "is_instance_admin", "badges", "locale"],
"properties": {
"id": { "type": "string", "description": "Snowflake строкой" },
"username": { "type": "string" },
"display_name": { "type": "string" },
"bio": { "type": "string" },
"status": { "type": "string", "enum": ["online", "idle", "dnd", "invisible"] },
"custom_status": { "type": "string" },
"avatar_file_id": { "type": "string" },
"banner_file_id": { "type": "string" },
"is_instance_admin": { "type": "boolean" },
"badges": { "type": "array", "items": { "type": "string" } },
"locale": { "type": "string", "enum": ["ru", "en"] }
}
},
"AuthResponse": {
"type": "object",
"required": ["user"],
"properties": { "user": { "$ref": "#/components/schemas/User" } }
},
"UserResponse": {
"type": "object",
"required": ["user"],
"properties": { "user": { "$ref": "#/components/schemas/User" } }
},
"Session": {
"type": "object",
"required": ["id", "created_at", "last_seen", "expires_at", "current", "stepped_up"],
"properties": {
"id": { "type": "string" },
"user_agent": { "type": "string" },
"ip": { "type": "string" },
"created_at": { "type": "string", "format": "date-time" },
"last_seen": { "type": "string", "format": "date-time" },
"expires_at": { "type": "string", "format": "date-time" },
"current": { "type": "boolean" },
"stepped_up": { "type": "boolean" }
}
},
"SessionsResponse": {
"type": "object",
"required": ["sessions"],
"properties": { "sessions": { "type": "array", "items": { "$ref": "#/components/schemas/Session" } } }
},
"TOTPSetup": {
"type": "object",
"required": ["secret", "otpauth_url"],
"properties": {
"secret": { "type": "string" },
"otpauth_url": { "type": "string" },
"qr_png": {
"type": "string",
"description": "QR-код otpauth-ссылки как data:image/png;base64 (рисуется локально)"
},
"issuer": { "type": "string" }
}
},
"RecoveryCodes": {
"type": "object",
"required": ["recovery_codes"],
"properties": { "recovery_codes": { "type": "array", "items": { "type": "string" } } }
},
"OkResponse": {
"type": "object",
"required": ["ok"],
"properties": { "ok": { "type": "boolean" } }
},
"Error": {
"type": "object",
"required": ["error"],
"properties": {
"error": {
"type": "object",
"required": ["code", "message"],
"properties": {
"code": { "type": "string" },
"message": { "type": "string" },
"details": { "type": "object", "additionalProperties": true }
}
}
}
}
}`