feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса
REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):
- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
не участник не получает прав роли @user; калькулятор прав общий для API и
Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.
Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// sessionContextValue — сессия, восстановленная из cookie или Bearer-токена.
|
||||
type sessionContextValue struct {
|
||||
User *store.User
|
||||
Session *store.Session
|
||||
}
|
||||
|
||||
type sessionContextKey struct{}
|
||||
|
||||
// sessionContext резолвит сессию один раз на запрос и кладёт её в контекст.
|
||||
// Отсутствие сессии не ошибка: часть ручек публичная (мета, OpenAPI).
|
||||
func (s *Server) sessionContext(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.auth == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
token := sessionToken(r)
|
||||
if token == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
user, session, err := s.auth.ResolveSession(r.Context(), token)
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), sessionContextKey{}, &sessionContextValue{User: user, Session: session})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// sessionToken читает токен сессии из cookie (браузер) или Bearer (desktop).
|
||||
func sessionToken(r *http.Request) string {
|
||||
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
|
||||
return cookie.Value
|
||||
}
|
||||
return normalizeBearer(r.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func sessionFromContext(ctx context.Context) (*store.User, *store.Session, bool) {
|
||||
value, ok := ctx.Value(sessionContextKey{}).(*sessionContextValue)
|
||||
if !ok || value.User == nil || value.Session == nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
return value.User, value.Session, true
|
||||
}
|
||||
|
||||
// requireUser достаёт текущего пользователя или возвращает 401 в конверте API.
|
||||
func requireUser(ctx context.Context) (*store.User, *store.Session, error) {
|
||||
user, session, ok := sessionFromContext(ctx)
|
||||
if !ok {
|
||||
return nil, nil, humaErrorStatus(http.StatusUnauthorized, "auth.session_expired", "authentication required")
|
||||
}
|
||||
return user, session, nil
|
||||
}
|
||||
|
||||
// requireInstanceAdmin проверяет права администратора инстанса (AGENT.md 6.5).
|
||||
func requireInstanceAdmin(ctx context.Context) (*store.User, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !user.IsInstanceAdmin {
|
||||
return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// humaAPIError — ошибка huma в едином конверте {"error":{"code","message"}}
|
||||
// (AGENT.md 8.5): клиент разбирает код, а не текст.
|
||||
type humaAPIError struct {
|
||||
status int
|
||||
code string
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *humaAPIError) Error() string { return e.code + ": " + e.message }
|
||||
func (e *humaAPIError) GetStatus() int { return e.status }
|
||||
|
||||
func (e *humaAPIError) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(map[string]any{
|
||||
"error": map[string]any{"code": e.code, "message": e.message},
|
||||
})
|
||||
}
|
||||
|
||||
// humaErrorStatus создаёт ошибку с явным кодом.
|
||||
func humaErrorStatus(status int, code, message string) huma.StatusError {
|
||||
return &humaAPIError{status: status, code: code, message: message}
|
||||
}
|
||||
|
||||
// humaError переводит доменную ошибку в ответ API с тем же кодом, что и
|
||||
// chi-ручки (единая таблица в errors.go).
|
||||
func humaError(err error) huma.StatusError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
converted := newAPIError(err)
|
||||
return &humaAPIError{status: converted.Status, code: converted.Code, message: converted.Message}
|
||||
}
|
||||
|
||||
// parseID разбирает Snowflake из пути: идентификаторы в API — строки (AGENT.md 6.4).
|
||||
func parseID(name, value string) (uint64, error) {
|
||||
if value == "" {
|
||||
return 0, humaErrorStatus(http.StatusBadRequest, "request.bad", name+" is required")
|
||||
}
|
||||
parsed, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil {
|
||||
return 0, humaErrorStatus(http.StatusBadRequest, "request.bad", name+" must be a snowflake id")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// guildPermissions считает права пользователя на сервере.
|
||||
func (s *Server) guildPermissions(ctx context.Context, guildID uint64, user *store.User) (permissions.Resolved, error) {
|
||||
resolved, err := s.perms.Guild(ctx, guildID, user.ID, user.IsInstanceAdmin)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return permissions.Resolved{}, humaErrorStatus(http.StatusNotFound, "not_found", "guild not found")
|
||||
}
|
||||
return permissions.Resolved{}, humaError(err)
|
||||
}
|
||||
if !resolved.Has(permissions.ViewGuild) {
|
||||
// Существование сервера не подтверждаем: 404 вместо 403 (AGENT.md 9.7).
|
||||
return permissions.Resolved{}, humaErrorStatus(http.StatusNotFound, "not_found", "guild not found")
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// requireGuildPermission считает права и требует конкретное разрешение.
|
||||
func (s *Server) requireGuildPermission(ctx context.Context, guildID string, user *store.User, permission permissions.Permission) (uint64, permissions.Resolved, error) {
|
||||
id, err := parseID("guild_id", guildID)
|
||||
if err != nil {
|
||||
return 0, permissions.Resolved{}, err
|
||||
}
|
||||
resolved, err := s.guildPermissions(ctx, id, user)
|
||||
if err != nil {
|
||||
return 0, permissions.Resolved{}, err
|
||||
}
|
||||
if !resolved.Has(permission) {
|
||||
return 0, permissions.Resolved{}, humaErrorStatus(http.StatusForbidden, "perm.denied", "permission denied")
|
||||
}
|
||||
return id, resolved, nil
|
||||
}
|
||||
Reference in New Issue
Block a user