2026-09-19 18:20:56 +03:00
|
|
|
package server
|
|
|
|
|
|
2026-09-19 21:36:00 +03:00
|
|
|
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" } } } }
|
2026-09-19 18:20:56 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-09-19 21:36:00 +03:00
|
|
|
"/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" } } }
|
2026-09-19 18:20:56 +03:00
|
|
|
},
|
2026-09-19 21:36:00 +03:00
|
|
|
"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",
|
2026-09-19 22:03:46 +03:00
|
|
|
"required": ["secret", "otpauth_url"],
|
2026-09-19 21:36:00 +03:00
|
|
|
"properties": {
|
|
|
|
|
"secret": { "type": "string" },
|
2026-09-19 22:03:46 +03:00
|
|
|
"otpauth_url": { "type": "string" },
|
|
|
|
|
"qr_png": {
|
|
|
|
|
"type": "string",
|
|
|
|
|
"description": "QR-код otpauth-ссылки как data:image/png;base64 (рисуется локально)"
|
|
|
|
|
},
|
2026-09-19 21:36:00 +03:00
|
|
|
"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": {
|
2026-09-19 18:20:56 +03:00
|
|
|
"type": "object",
|
2026-09-19 21:36:00 +03:00
|
|
|
"required": ["code", "message"],
|
2026-09-19 18:20:56 +03:00
|
|
|
"properties": {
|
2026-09-19 21:36:00 +03:00
|
|
|
"code": { "type": "string" },
|
|
|
|
|
"message": { "type": "string" },
|
|
|
|
|
"details": { "type": "object", "additionalProperties": true }
|
2026-09-19 18:20:56 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-19 21:36:00 +03:00
|
|
|
}`
|