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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,483 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"glchat/internal/bootstrap"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// instancePayload — публичная информация об инстансе (AGENT.md 6.5).
|
||||
type instancePayload struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
AllowGuildCreation bool `json:"allow_guild_creation"`
|
||||
VoiceEnabled bool `json:"voice_enabled"`
|
||||
MaxGuildsPerUser int `json:"max_guilds_per_user"`
|
||||
MaxMembersPerGuild int `json:"max_members_per_guild"`
|
||||
MaxMessageLength int `json:"max_message_length"`
|
||||
MainGuildID string `json:"main_guild_id,omitempty"`
|
||||
UserCount int `json:"user_count"`
|
||||
GuildCount int `json:"guild_count"`
|
||||
}
|
||||
|
||||
type instanceOutput struct {
|
||||
Body struct {
|
||||
Instance instancePayload `json:"instance"`
|
||||
}
|
||||
}
|
||||
|
||||
type instanceGuildPayload struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
OwnerName string `json:"owner_name,omitempty"`
|
||||
IsMain bool `json:"is_main"`
|
||||
MemberCount int `json:"member_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type instanceGuildListOutput struct {
|
||||
Body struct {
|
||||
Guilds []instanceGuildPayload `json:"guilds"`
|
||||
}
|
||||
}
|
||||
|
||||
type instanceUserPayload struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IsInstanceAdmin bool `json:"is_instance_admin"`
|
||||
Badges []string `json:"badges"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type instanceUserListOutput struct {
|
||||
Body struct {
|
||||
Users []instanceUserPayload `json:"users"`
|
||||
}
|
||||
}
|
||||
|
||||
type instanceSettingsPayload struct {
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
AllowGuildCreation bool `json:"allow_guild_creation"`
|
||||
MaxGuildsPerUser int `json:"max_guilds_per_user"`
|
||||
MaxMembersPerGuild int `json:"max_members_per_guild"`
|
||||
MaxMessageLength int `json:"max_message_length"`
|
||||
}
|
||||
|
||||
type instanceSettingsOutput struct {
|
||||
Body struct {
|
||||
Settings instanceSettingsPayload `json:"settings"`
|
||||
}
|
||||
}
|
||||
|
||||
// registerInstanceRoutes описывает публичную информацию об инстансе и
|
||||
// админ-панель администратора инстанса (AGENT.md 6.5, 7.19).
|
||||
func (s *Server) registerInstanceRoutes(api huma.API) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getInstance",
|
||||
Method: http.MethodGet,
|
||||
Path: "/instance",
|
||||
Summary: "Публичная информация об инстансе",
|
||||
Tags: []string{"Instance"},
|
||||
}, func(ctx context.Context, _ *struct{}) (*instanceOutput, error) {
|
||||
payload, err := s.instancePayload(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Лимиты и число пользователей видны только администратору инстанса.
|
||||
if user, _, ok := sessionFromContext(ctx); !ok || !user.IsInstanceAdmin {
|
||||
payload.MaxGuildsPerUser = 0
|
||||
payload.MaxMembersPerGuild = 0
|
||||
payload.UserCount = 0
|
||||
payload.GuildCount = 0
|
||||
}
|
||||
output := &instanceOutput{}
|
||||
output.Body.Instance = payload
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getInstanceSettings",
|
||||
Method: http.MethodGet,
|
||||
Path: "/instance/settings",
|
||||
Summary: "Настройки инстанса (только администратор)",
|
||||
Tags: []string{"Instance"},
|
||||
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||
}, func(ctx context.Context, _ *struct{}) (*instanceSettingsOutput, error) {
|
||||
if _, err := requireInstanceAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings, err := s.store.InstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
output := &instanceSettingsOutput{}
|
||||
output.Body.Settings = instanceSettingsPayload{
|
||||
RegistrationEnabled: settings.RegistrationEnabled,
|
||||
AllowGuildCreation: settings.AllowGuildCreation,
|
||||
MaxGuildsPerUser: settings.MaxGuildsPerUser,
|
||||
MaxMembersPerGuild: settings.MaxMembersPerGuild,
|
||||
MaxMessageLength: settings.MaxMessageLength,
|
||||
}
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "updateInstanceSettings",
|
||||
Method: http.MethodPatch,
|
||||
Path: "/instance/settings",
|
||||
Summary: "Изменить настройки инстанса",
|
||||
Tags: []string{"Instance"},
|
||||
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||
}, func(ctx context.Context, input *struct {
|
||||
Body struct {
|
||||
RegistrationEnabled *bool `json:"registration_enabled,omitempty"`
|
||||
AllowGuildCreation *bool `json:"allow_guild_creation,omitempty"`
|
||||
MaxGuildsPerUser *int `json:"max_guilds_per_user,omitempty" minimum:"1" maximum:"10000"`
|
||||
MaxMembersPerGuild *int `json:"max_members_per_guild,omitempty" minimum:"1" maximum:"1000000"`
|
||||
MaxMessageLength *int `json:"max_message_length,omitempty" minimum:"1" maximum:"100000"`
|
||||
}
|
||||
},
|
||||
) (*instanceSettingsOutput, error) {
|
||||
user, err := requireInstanceAdmin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updates := map[string]string{}
|
||||
if input.Body.RegistrationEnabled != nil {
|
||||
updates["registration_enabled"] = strconv.FormatBool(*input.Body.RegistrationEnabled)
|
||||
}
|
||||
if input.Body.AllowGuildCreation != nil {
|
||||
updates["allow_guild_creation"] = strconv.FormatBool(*input.Body.AllowGuildCreation)
|
||||
}
|
||||
if input.Body.MaxGuildsPerUser != nil {
|
||||
updates["max_guilds_per_user"] = strconv.Itoa(*input.Body.MaxGuildsPerUser)
|
||||
}
|
||||
if input.Body.MaxMembersPerGuild != nil {
|
||||
updates["max_members_per_guild"] = strconv.Itoa(*input.Body.MaxMembersPerGuild)
|
||||
}
|
||||
if input.Body.MaxMessageLength != nil {
|
||||
updates["max_message_length"] = strconv.Itoa(*input.Body.MaxMessageLength)
|
||||
}
|
||||
for key, value := range updates {
|
||||
if err := s.store.SetInstanceSetting(ctx, key, value); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
}
|
||||
settings, err := s.store.InstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
s.recordAudit(ctx, user, 0, "instance.settings_update", "instance", nil, "")
|
||||
output := &instanceSettingsOutput{}
|
||||
output.Body.Settings = instanceSettingsPayload{
|
||||
RegistrationEnabled: settings.RegistrationEnabled,
|
||||
AllowGuildCreation: settings.AllowGuildCreation,
|
||||
MaxGuildsPerUser: settings.MaxGuildsPerUser,
|
||||
MaxMembersPerGuild: settings.MaxMembersPerGuild,
|
||||
MaxMessageLength: settings.MaxMessageLength,
|
||||
}
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listInstanceGuilds",
|
||||
Method: http.MethodGet,
|
||||
Path: "/instance/guilds",
|
||||
Summary: "Все серверы инстанса",
|
||||
Tags: []string{"Instance"},
|
||||
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||
}, func(ctx context.Context, _ *struct{}) (*instanceGuildListOutput, error) {
|
||||
if _, err := requireInstanceAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
guilds, err := s.store.ListAllGuilds(ctx)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
output := &instanceGuildListOutput{}
|
||||
output.Body.Guilds = make([]instanceGuildPayload, 0, len(guilds))
|
||||
for _, guild := range guilds {
|
||||
payload := instanceGuildPayload{
|
||||
ID: formatSnowflake(guild.ID),
|
||||
Name: guild.Name,
|
||||
OwnerID: formatSnowflake(guild.OwnerID),
|
||||
IsMain: guild.IsMain,
|
||||
CreatedAt: guild.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if owner, err := s.store.GetUser(ctx, guild.OwnerID); err == nil {
|
||||
payload.OwnerName = owner.Username
|
||||
}
|
||||
if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil {
|
||||
payload.MemberCount = count
|
||||
}
|
||||
output.Body.Guilds = append(output.Body.Guilds, payload)
|
||||
}
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "adminCreateGuild",
|
||||
Method: http.MethodPost,
|
||||
Path: "/instance/guilds",
|
||||
Summary: "Создать сервер от имени администратора (лимиты обходятся)",
|
||||
Tags: []string{"Instance"},
|
||||
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||
}, func(ctx context.Context, input *struct {
|
||||
Body struct {
|
||||
Name string `json:"name" minLength:"1" maxLength:"64"`
|
||||
OwnerID string `json:"owner_id,omitempty"`
|
||||
}
|
||||
},
|
||||
) (*instanceGuildListOutput, error) {
|
||||
admin, err := requireInstanceAdmin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owner := admin
|
||||
if input.Body.OwnerID != "" {
|
||||
ownerID, err := parseID("owner_id", input.Body.OwnerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owner, err = s.store.GetUser(ctx, ownerID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
}
|
||||
name := strings.TrimSpace(input.Body.Name)
|
||||
if name == "" {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild name must not be empty")
|
||||
}
|
||||
// Администратор инстанса создаёт сервер в обход лимитов: причина
|
||||
// фиксируется в аудите отдельной записью (AGENT.md 6.5).
|
||||
guild, err := s.createGuildAsAdmin(ctx, admin, owner, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output := &instanceGuildListOutput{}
|
||||
output.Body.Guilds = []instanceGuildPayload{{
|
||||
ID: formatSnowflake(guild.ID),
|
||||
Name: guild.Name,
|
||||
OwnerID: formatSnowflake(guild.OwnerID),
|
||||
IsMain: guild.IsMain,
|
||||
CreatedAt: guild.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}}
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "adminDeleteGuild",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/instance/guilds/{guild_id}",
|
||||
Summary: "Удалить сервер (администратор инстанса)",
|
||||
Tags: []string{"Instance"},
|
||||
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||
}, func(ctx context.Context, input *struct {
|
||||
GuildID string `path:"guild_id"`
|
||||
},
|
||||
) (*okOutput, error) {
|
||||
admin, err := requireInstanceAdmin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
guildID, err := parseID("guild_id", input.GuildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.deleteGuild(ctx, admin, guildID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newOKOutput(), nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listInstanceUsers",
|
||||
Method: http.MethodGet,
|
||||
Path: "/instance/users",
|
||||
Summary: "Пользователи инстанса",
|
||||
Tags: []string{"Instance"},
|
||||
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||
}, func(ctx context.Context, input *struct {
|
||||
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
|
||||
Offset int `query:"offset" default:"0" minimum:"0"`
|
||||
},
|
||||
) (*instanceUserListOutput, error) {
|
||||
if _, err := requireInstanceAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users, err := s.store.ListUsers(ctx, input.Limit, input.Offset)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
output := &instanceUserListOutput{}
|
||||
output.Body.Users = make([]instanceUserPayload, 0, len(users))
|
||||
for _, user := range users {
|
||||
badges := user.Badges
|
||||
if badges == nil {
|
||||
badges = []string{}
|
||||
}
|
||||
output.Body.Users = append(output.Body.Users, instanceUserPayload{
|
||||
ID: formatSnowflake(user.ID),
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
IsInstanceAdmin: user.IsInstanceAdmin,
|
||||
Badges: badges,
|
||||
CreatedAt: user.CreatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "setInstanceAdmin",
|
||||
Method: http.MethodPost,
|
||||
Path: "/instance/users/{user_id}/admin",
|
||||
Summary: "Выдать или снять права администратора инстанса",
|
||||
Tags: []string{"Instance"},
|
||||
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||
}, func(ctx context.Context, input *struct {
|
||||
UserID string `path:"user_id"`
|
||||
Body struct {
|
||||
Admin bool `json:"admin"`
|
||||
// StepUpPassword подтверждает действие: смена администраторов —
|
||||
// чувствительная операция (AGENT.md 7.1).
|
||||
StepUpPassword string `json:"step_up_password,omitempty"`
|
||||
StepUpTOTP string `json:"step_up_totp,omitempty"`
|
||||
}
|
||||
},
|
||||
) (*userOutput, error) {
|
||||
admin, session, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !admin.IsInstanceAdmin {
|
||||
return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required")
|
||||
}
|
||||
if err := s.auth.RequireStepUp(ctx, admin, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
userID, err := parseID("user_id", input.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID == admin.ID && !input.Body.Admin {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you cannot revoke your own administrator rights")
|
||||
}
|
||||
if err := s.store.SetInstanceAdmin(ctx, userID, input.Body.Admin); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
updated, err := s.store.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
action := "instance.admin_grant"
|
||||
if !input.Body.Admin {
|
||||
action = "instance.admin_revoke"
|
||||
}
|
||||
s.recordAudit(ctx, admin, 0, action, "user", &userID, "")
|
||||
s.dispatchUserUpdate(updated)
|
||||
output := &userOutput{}
|
||||
output.Body.User = profileFromUser(updated, false)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listInstanceAudit",
|
||||
Method: http.MethodGet,
|
||||
Path: "/instance/audit",
|
||||
Summary: "Журнал действий администраторов инстанса",
|
||||
Tags: []string{"Instance"},
|
||||
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||||
}, func(ctx context.Context, input *struct {
|
||||
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
|
||||
},
|
||||
) (*auditListOutput, error) {
|
||||
if _, err := requireInstanceAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := s.store.ListInstanceAudit(ctx, input.Limit)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
output := &auditListOutput{}
|
||||
output.Body.Entries = auditPayloads(entries)
|
||||
return output, nil
|
||||
})
|
||||
}
|
||||
|
||||
// instancePayload собирает публичные сведения об инстансе.
|
||||
func (s *Server) instancePayload(ctx context.Context) (instancePayload, error) {
|
||||
settings, err := s.store.InstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return instancePayload{}, humaError(err)
|
||||
}
|
||||
users, err := s.store.CountUsers(ctx)
|
||||
if err != nil {
|
||||
return instancePayload{}, humaError(err)
|
||||
}
|
||||
guilds, err := s.store.ListAllGuilds(ctx)
|
||||
if err != nil {
|
||||
return instancePayload{}, humaError(err)
|
||||
}
|
||||
payload := instancePayload{
|
||||
Name: s.cfg.InstanceName,
|
||||
Version: s.cfg.Version,
|
||||
RegistrationEnabled: settings.RegistrationEnabled,
|
||||
AllowGuildCreation: settings.AllowGuildCreation,
|
||||
MaxGuildsPerUser: settings.MaxGuildsPerUser,
|
||||
MaxMembersPerGuild: settings.MaxMembersPerGuild,
|
||||
MaxMessageLength: settings.MaxMessageLength,
|
||||
UserCount: users,
|
||||
GuildCount: len(guilds),
|
||||
}
|
||||
if settings.MainGuildID != 0 {
|
||||
payload.MainGuildID = formatSnowflake(settings.MainGuildID)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// createGuildAsAdmin создаёт сервер в обход лимитов и фиксирует это в аудите.
|
||||
func (s *Server) createGuildAsAdmin(ctx context.Context, admin, owner *store.User, name string) (*store.Guild, error) {
|
||||
settings, err := s.store.InstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
guild, err := s.store.CreateGuild(ctx, store.CreateGuildParams{
|
||||
Name: name,
|
||||
OwnerID: owner.ID,
|
||||
IsMain: settings.MainGuildID == 0,
|
||||
IsDiscoverable: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if _, err := bootstrap.SeedGuildDefaults(ctx, s.store, guild, owner); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if _, err := s.store.CreateChannel(ctx, store.CreateChannelParams{
|
||||
GuildID: &guild.ID, Type: store.ChannelText, Name: "общий", Position: 0,
|
||||
}); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if guild.IsMain {
|
||||
if err := s.store.SetInstanceSetting(ctx, "main_guild_id", strconv.FormatUint(guild.ID, 10)); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
}
|
||||
s.recordAudit(ctx, admin, guild.ID, "guild.create", "guild", &guild.ID, "created by instance admin")
|
||||
s.recordAudit(ctx, admin, guild.ID, "limits.bypass", "guild", &guild.ID, "instance admin bypassed guild limits")
|
||||
if s.gateway != nil {
|
||||
s.gateway.SendToUser(owner.ID, "GUILD_CREATE", map[string]any{"guild_id": formatSnowflake(guild.ID)})
|
||||
}
|
||||
return guild, nil
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// permissionViewChannel — сокращение для оверрайдов в тестах.
|
||||
const permissionViewChannel = permissions.ViewChannel
|
||||
|
||||
// registerAndLogin регистрирует пользователя и возвращает cookie сессии.
|
||||
func registerAndLogin(t *testing.T, srv *Server, username, email string) *http.Cookie {
|
||||
t.Helper()
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
|
||||
`{"username":"`+username+`","email":"`+email+`","password":"correct-horse-battery"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("register %s = %d, body = %s", username, rec.Code, rec.Body.String())
|
||||
}
|
||||
cookies := rec.Result().Cookies()
|
||||
if len(cookies) == 0 {
|
||||
t.Fatalf("register %s did not return a session cookie", username)
|
||||
}
|
||||
return cookies[0]
|
||||
}
|
||||
|
||||
func decodeResponse[T any](t *testing.T, rec *httptest.ResponseRecorder) T {
|
||||
t.Helper()
|
||||
var payload T
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode body: %v (raw: %s)", err, rec.Body.String())
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func errorCodeOf(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||||
t.Helper()
|
||||
payload := decodeResponse[struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}](t, rec)
|
||||
if payload.Error.Code == "" {
|
||||
t.Fatalf("response has no error code: %s", rec.Body.String())
|
||||
}
|
||||
return payload.Error.Code
|
||||
}
|
||||
|
||||
// promoteAdmin делает пользователя администратором инстанса напрямую в БД.
|
||||
func promoteAdmin(t *testing.T, srv *Server, email string) {
|
||||
t.Helper()
|
||||
user, err := srv.auth.UserByEmail(t.Context(), email)
|
||||
if err != nil {
|
||||
t.Fatalf("find user %s: %v", email, err)
|
||||
}
|
||||
if err := srv.store.SetInstanceAdmin(t.Context(), user.ID, true); err != nil {
|
||||
t.Fatalf("promote %s: %v", email, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumaErrorsUseAPIEnvelope(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
|
||||
rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
if code := errorCodeOf(t, rec); code != "auth.session_expired" {
|
||||
t.Fatalf("error code = %q, want auth.session_expired", code)
|
||||
}
|
||||
|
||||
rec = doJSON(t, srv, http.MethodGet, "/api/v1/instance/settings", "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("instance settings without session = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUpdateFlow(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
cookie := registerAndLogin(t, srv, "profile_user", "profile@example.com")
|
||||
|
||||
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie)
|
||||
if me.Code != http.StatusOK {
|
||||
t.Fatalf("GET /users/@me = %d, body = %s", me.Code, me.Body.String())
|
||||
}
|
||||
profile := decodeResponse[struct {
|
||||
User struct {
|
||||
Username string `json:"username"`
|
||||
OnboardingCompleted bool `json:"onboarding_completed"`
|
||||
Locale string `json:"locale"`
|
||||
} `json:"user"`
|
||||
}](t, me)
|
||||
if profile.User.Username != "profile_user" {
|
||||
t.Fatalf("username = %q", profile.User.Username)
|
||||
}
|
||||
if profile.User.OnboardingCompleted {
|
||||
t.Fatal("new user must not have onboarding completed")
|
||||
}
|
||||
if profile.User.Locale != "ru" {
|
||||
t.Fatalf("default locale = %q, want ru", profile.User.Locale)
|
||||
}
|
||||
|
||||
updated := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me",
|
||||
`{"display_name":"Профиль","bio":"о себе","status":"idle","locale":"en"}`, cookie)
|
||||
if updated.Code != http.StatusOK {
|
||||
t.Fatalf("PATCH /users/@me = %d, body = %s", updated.Code, updated.Body.String())
|
||||
}
|
||||
payload := decodeResponse[struct {
|
||||
User struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
Bio string `json:"bio"`
|
||||
Status string `json:"status"`
|
||||
Locale string `json:"locale"`
|
||||
} `json:"user"`
|
||||
}](t, updated)
|
||||
if payload.User.DisplayName != "Профиль" || payload.User.Bio != "о себе" || payload.User.Status != "idle" {
|
||||
t.Fatalf("unexpected profile: %+v", payload.User)
|
||||
}
|
||||
|
||||
// Онбординг завершается отдельной ручкой и выставляет флаг.
|
||||
onboarding := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/onboarding/complete",
|
||||
`{"display_name":"Новый ник","bio":"привет"}`, cookie)
|
||||
if onboarding.Code != http.StatusOK {
|
||||
t.Fatalf("onboarding = %d, body = %s", onboarding.Code, onboarding.Body.String())
|
||||
}
|
||||
after := decodeResponse[struct {
|
||||
User struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
OnboardingCompleted bool `json:"onboarding_completed"`
|
||||
} `json:"user"`
|
||||
}](t, onboarding)
|
||||
if !after.User.OnboardingCompleted || after.User.DisplayName != "Новый ник" {
|
||||
t.Fatalf("unexpected onboarding result: %+v", after.User)
|
||||
}
|
||||
|
||||
// Публичный профиль доступен другому пользователю и не содержит приватных полей.
|
||||
otherCookie := registerAndLogin(t, srv, "other_user", "other@example.com")
|
||||
user, err := srv.auth.UserByEmail(t.Context(), "profile@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
public := doJSON(t, srv, http.MethodGet, "/api/v1/users/"+formatSnowflake(user.ID), "", otherCookie)
|
||||
if public.Code != http.StatusOK {
|
||||
t.Fatalf("public profile = %d, body = %s", public.Code, public.Body.String())
|
||||
}
|
||||
if strings.Contains(public.Body.String(), "@example.com") {
|
||||
t.Fatalf("public profile must not leak email: %s", public.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuildLifecycleAndPermissions(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
ownerCookie := registerAndLogin(t, srv, "guild_owner", "owner@example.com")
|
||||
memberCookie := registerAndLogin(t, srv, "guild_member", "member@example.com")
|
||||
|
||||
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Тестовый сервер"}`, ownerCookie)
|
||||
if created.Code != http.StatusOK {
|
||||
t.Fatalf("create guild = %d, body = %s", created.Code, created.Body.String())
|
||||
}
|
||||
guild := decodeResponse[struct {
|
||||
Guild struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsMain bool `json:"is_main"`
|
||||
Roles []struct {
|
||||
ID string `json:"id"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
} `json:"roles"`
|
||||
Channels []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"channels"`
|
||||
MyPermissions []string `json:"my_permissions"`
|
||||
} `json:"guild"`
|
||||
}](t, created)
|
||||
if guild.Guild.Name != "Тестовый сервер" {
|
||||
t.Fatalf("guild name = %q", guild.Guild.Name)
|
||||
}
|
||||
if len(guild.Guild.Roles) != 2 {
|
||||
t.Fatalf("default roles = %d, want 2", len(guild.Guild.Roles))
|
||||
}
|
||||
if len(guild.Guild.Channels) != 1 || guild.Guild.Channels[0].Name != "общий" {
|
||||
t.Fatalf("default channels = %+v", guild.Guild.Channels)
|
||||
}
|
||||
if !containsString(guild.Guild.MyPermissions, "ADMINISTRATOR") {
|
||||
t.Fatalf("owner must have ADMINISTRATOR, got %v", guild.Guild.MyPermissions)
|
||||
}
|
||||
|
||||
// Посторонний не видит сервер: 404 вместо 403 (AGENT.md 9.7).
|
||||
stranger := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie)
|
||||
if stranger.Code != http.StatusNotFound {
|
||||
t.Fatalf("stranger channels = %d, want 404", stranger.Code)
|
||||
}
|
||||
|
||||
// Главный сервер открыт для присоединения.
|
||||
join := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie)
|
||||
if join.Code != http.StatusOK {
|
||||
t.Fatalf("join main guild = %d, body = %s", join.Code, join.Body.String())
|
||||
}
|
||||
|
||||
// Участник видит комнату, но не может её создать: нет MANAGE_CHANNELS.
|
||||
channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie)
|
||||
if channels.Code != http.StatusOK {
|
||||
t.Fatalf("member channels = %d", channels.Code)
|
||||
}
|
||||
list := decodeResponse[struct {
|
||||
Channels []struct {
|
||||
Name string `json:"name"`
|
||||
CanView bool `json:"can_view"`
|
||||
CanSend bool `json:"can_send"`
|
||||
} `json:"channels"`
|
||||
}](t, channels)
|
||||
if len(list.Channels) != 1 || !list.Channels[0].CanView || !list.Channels[0].CanSend {
|
||||
t.Fatalf("unexpected member channels: %+v", list.Channels)
|
||||
}
|
||||
denied := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels",
|
||||
`{"name":"секретная","type":"text"}`, memberCookie)
|
||||
if denied.Code != http.StatusForbidden {
|
||||
t.Fatalf("member create channel = %d, want 403", denied.Code)
|
||||
}
|
||||
if code := errorCodeOf(t, denied); code != "perm.denied" {
|
||||
t.Fatalf("error code = %q, want perm.denied", code)
|
||||
}
|
||||
|
||||
// Владелец создаёт комнату и роль.
|
||||
newChannel := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels",
|
||||
`{"name":"флудилка","type":"voice"}`, ownerCookie)
|
||||
if newChannel.Code != http.StatusOK {
|
||||
t.Fatalf("owner create channel = %d, body = %s", newChannel.Code, newChannel.Body.String())
|
||||
}
|
||||
|
||||
roleRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/roles",
|
||||
`{"name":"Модератор","permissions":"VIEW_CHANNEL|KICK_MEMBERS","color":16711680}`, ownerCookie)
|
||||
if roleRec.Code != http.StatusOK {
|
||||
t.Fatalf("create role = %d, body = %s", roleRec.Code, roleRec.Body.String())
|
||||
}
|
||||
role := decodeResponse[struct {
|
||||
Role struct {
|
||||
ID string `json:"id"`
|
||||
Permissions string `json:"permissions"`
|
||||
} `json:"role"`
|
||||
}](t, roleRec)
|
||||
if !strings.Contains(role.Role.Permissions, "KICK_MEMBERS") {
|
||||
t.Fatalf("role permissions = %q", role.Role.Permissions)
|
||||
}
|
||||
|
||||
// Роль можно выдать участнику.
|
||||
member, err := srv.auth.UserByEmail(t.Context(), "member@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
assign := doJSON(t, srv, http.MethodPut,
|
||||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID)+"/roles/"+role.Role.ID, "", ownerCookie)
|
||||
if assign.Code != http.StatusOK {
|
||||
t.Fatalf("assign role = %d, body = %s", assign.Code, assign.Body.String())
|
||||
}
|
||||
members := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/members", "", ownerCookie)
|
||||
roster := decodeResponse[struct {
|
||||
Members []struct {
|
||||
UserID string `json:"user_id"`
|
||||
RoleIDs []string `json:"role_ids"`
|
||||
} `json:"members"`
|
||||
}](t, members)
|
||||
if len(roster.Members) != 2 {
|
||||
t.Fatalf("members = %d, want 2", len(roster.Members))
|
||||
}
|
||||
found := false
|
||||
for _, entry := range roster.Members {
|
||||
if entry.UserID == formatSnowflake(member.ID) && containsString(entry.RoleIDs, role.Role.ID) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("role was not assigned: %+v", roster.Members)
|
||||
}
|
||||
|
||||
// Участник не может исключить владельца даже с правом KICK_MEMBERS.
|
||||
owner, err := srv.auth.UserByEmail(t.Context(), "owner@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
kickOwner := doJSON(t, srv, http.MethodDelete,
|
||||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(owner.ID), "", memberCookie)
|
||||
if kickOwner.Code != http.StatusForbidden {
|
||||
t.Fatalf("kick owner = %d, want 403", kickOwner.Code)
|
||||
}
|
||||
|
||||
// Владелец исключает участника.
|
||||
kick := doJSON(t, srv, http.MethodDelete,
|
||||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID), "", ownerCookie)
|
||||
if kick.Code != http.StatusOK {
|
||||
t.Fatalf("kick member = %d, body = %s", kick.Code, kick.Body.String())
|
||||
}
|
||||
|
||||
// Журнал аудита содержит действие исключения.
|
||||
audit := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/audit-log", "", ownerCookie)
|
||||
entries := decodeResponse[struct {
|
||||
Entries []struct {
|
||||
Action string `json:"action"`
|
||||
} `json:"entries"`
|
||||
}](t, audit)
|
||||
if !hasAction(entries.Entries, "member.kick") {
|
||||
t.Fatalf("audit log has no member.kick: %+v", entries.Entries)
|
||||
}
|
||||
|
||||
// Главный сервер удалить нельзя.
|
||||
deleteMain := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guild.Guild.ID, "", ownerCookie)
|
||||
if deleteMain.Code != http.StatusForbidden {
|
||||
t.Fatalf("delete main guild = %d, want 403", deleteMain.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceAdminEndpointsAndLimits(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
adminCookie := registerAndLogin(t, srv, "instance_admin", "admin@example.com")
|
||||
promoteAdmin(t, srv, "admin@example.com")
|
||||
userCookie := registerAndLogin(t, srv, "plain_user", "plain@example.com")
|
||||
|
||||
// Публичная информация об инстансе доступна без сессии.
|
||||
public := doJSON(t, srv, http.MethodGet, "/api/v1/instance", "")
|
||||
if public.Code != http.StatusOK {
|
||||
t.Fatalf("GET /instance = %d", public.Code)
|
||||
}
|
||||
publicPayload := decodeResponse[struct {
|
||||
Instance struct {
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
UserCount int `json:"user_count"`
|
||||
} `json:"instance"`
|
||||
}](t, public)
|
||||
if !publicPayload.Instance.RegistrationEnabled {
|
||||
t.Fatal("registration must be enabled by default")
|
||||
}
|
||||
if publicPayload.Instance.UserCount != 0 {
|
||||
t.Fatalf("public payload must not expose user count, got %d", publicPayload.Instance.UserCount)
|
||||
}
|
||||
|
||||
// Обычный пользователь не имеет доступа к админ-ручкам.
|
||||
for _, path := range []string{"/api/v1/instance/settings", "/api/v1/instance/guilds", "/api/v1/instance/users", "/api/v1/instance/audit"} {
|
||||
rec := doJSON(t, srv, http.MethodGet, path, "", userCookie)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("GET %s as user = %d, want 403", path, rec.Code)
|
||||
}
|
||||
if code := errorCodeOf(t, rec); code != "instance.admin_required" {
|
||||
t.Fatalf("GET %s code = %q", path, code)
|
||||
}
|
||||
}
|
||||
|
||||
// Лимит в один сервер на пользователя.
|
||||
patch := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings",
|
||||
`{"max_guilds_per_user":1}`, adminCookie)
|
||||
if patch.Code != http.StatusOK {
|
||||
t.Fatalf("patch settings = %d, body = %s", patch.Code, patch.Body.String())
|
||||
}
|
||||
|
||||
first := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Первый"}`, userCookie)
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("first guild = %d, body = %s", first.Code, first.Body.String())
|
||||
}
|
||||
second := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Второй"}`, userCookie)
|
||||
if second.Code != http.StatusForbidden {
|
||||
t.Fatalf("second guild = %d, want 403", second.Code)
|
||||
}
|
||||
if code := errorCodeOf(t, second); code != "limits.guilds_reached" {
|
||||
t.Fatalf("limit error code = %q, want limits.guilds_reached", code)
|
||||
}
|
||||
|
||||
// Администратор инстанса обходит лимит, и это попадает в аудит.
|
||||
adminGuild := doJSON(t, srv, http.MethodPost, "/api/v1/instance/guilds", `{"name":"Админский"}`, adminCookie)
|
||||
if adminGuild.Code != http.StatusOK {
|
||||
t.Fatalf("admin guild = %d, body = %s", adminGuild.Code, adminGuild.Body.String())
|
||||
}
|
||||
|
||||
guilds := doJSON(t, srv, http.MethodGet, "/api/v1/instance/guilds", "", adminCookie)
|
||||
guildList := decodeResponse[struct {
|
||||
Guilds []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"guilds"`
|
||||
}](t, guilds)
|
||||
if len(guildList.Guilds) != 2 {
|
||||
t.Fatalf("instance guilds = %d, want 2", len(guildList.Guilds))
|
||||
}
|
||||
|
||||
audit := doJSON(t, srv, http.MethodGet, "/api/v1/instance/audit", "", adminCookie)
|
||||
entries := decodeResponse[struct {
|
||||
Entries []struct {
|
||||
Action string `json:"action"`
|
||||
ActorInstanceAdmin bool `json:"actor_instance_admin"`
|
||||
} `json:"entries"`
|
||||
}](t, audit)
|
||||
if !hasAction(entries.Entries, "limits.bypass") {
|
||||
t.Fatalf("audit has no limits.bypass: %+v", entries.Entries)
|
||||
}
|
||||
for _, entry := range entries.Entries {
|
||||
if !entry.ActorInstanceAdmin {
|
||||
t.Fatalf("instance audit entry without admin flag: %+v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Права администратора выдаются только со step-up.
|
||||
user, err := srv.auth.UserByEmail(t.Context(), "plain@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
noStepUp := doJSON(t, srv, http.MethodPost,
|
||||
"/api/v1/instance/users/"+formatSnowflake(user.ID)+"/admin", `{"admin":true}`, adminCookie)
|
||||
if noStepUp.Code != http.StatusForbidden {
|
||||
t.Fatalf("admin grant without step-up = %d, want 403", noStepUp.Code)
|
||||
}
|
||||
grant := doJSON(t, srv, http.MethodPost,
|
||||
"/api/v1/instance/users/"+formatSnowflake(user.ID)+"/admin",
|
||||
`{"admin":true,"step_up_password":"correct-horse-battery"}`, adminCookie)
|
||||
if grant.Code != http.StatusOK {
|
||||
t.Fatalf("admin grant = %d, body = %s", grant.Code, grant.Body.String())
|
||||
}
|
||||
|
||||
// Смена настроек регистрации закрывает регистрацию.
|
||||
off := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings", `{"registration_enabled":false}`, adminCookie)
|
||||
if off.Code != http.StatusOK {
|
||||
t.Fatalf("disable registration = %d", off.Code)
|
||||
}
|
||||
blocked := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
|
||||
`{"username":"blocked_user","email":"blocked@example.com","password":"correct-horse-battery"}`)
|
||||
if blocked.Code == http.StatusOK {
|
||||
t.Fatal("registration must be rejected when disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberTimeoutRequiresPermission(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
ownerCookie := registerAndLogin(t, srv, "timeout_owner", "timeout-owner@example.com")
|
||||
memberCookie := registerAndLogin(t, srv, "timeout_member", "timeout-member@example.com")
|
||||
|
||||
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Модерация"}`, ownerCookie)
|
||||
guild := decodeResponse[struct {
|
||||
Guild struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"guild"`
|
||||
}](t, created)
|
||||
doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie)
|
||||
|
||||
member, err := srv.auth.UserByEmail(t.Context(), "timeout-member@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
// У участника нет TIMEOUT_MEMBERS: тайм-аут запрещён.
|
||||
denied := doJSON(t, srv, http.MethodPatch,
|
||||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID),
|
||||
`{"timeout_until":"2030-01-01T00:00:00Z"}`, memberCookie)
|
||||
if denied.Code != http.StatusForbidden {
|
||||
t.Fatalf("member self timeout = %d, want 403", denied.Code)
|
||||
}
|
||||
|
||||
// Владелец выдаёт тайм-аут: права SEND_MESSAGES отключаются.
|
||||
ok := doJSON(t, srv, http.MethodPatch,
|
||||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID),
|
||||
`{"timeout_until":"2030-01-01T00:00:00Z"}`, ownerCookie)
|
||||
if ok.Code != http.StatusOK {
|
||||
t.Fatalf("owner timeout = %d, body = %s", ok.Code, ok.Body.String())
|
||||
}
|
||||
channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie)
|
||||
list := decodeResponse[struct {
|
||||
Channels []struct {
|
||||
CanSend bool `json:"can_send"`
|
||||
} `json:"channels"`
|
||||
}](t, channels)
|
||||
if len(list.Channels) == 0 || list.Channels[0].CanSend {
|
||||
t.Fatalf("timed out member must not be able to send: %+v", list.Channels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleHierarchyProtectsHigherRoles(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
ownerCookie := registerAndLogin(t, srv, "hier_owner", "hier-owner@example.com")
|
||||
modCookie := registerAndLogin(t, srv, "hier_mod", "hier-mod@example.com")
|
||||
|
||||
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Иерархия"}`, ownerCookie)
|
||||
guild := decodeResponse[struct {
|
||||
Guild struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"guild"`
|
||||
}](t, created)
|
||||
doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", modCookie)
|
||||
|
||||
mod, err := srv.auth.UserByEmail(t.Context(), "hier-mod@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
// Роль модератора с правом управления ролями.
|
||||
roleRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/roles",
|
||||
`{"name":"Модератор","permissions":"VIEW_CHANNEL|MANAGE_ROLES|KICK_MEMBERS"}`, ownerCookie)
|
||||
role := decodeResponse[struct {
|
||||
Role struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"role"`
|
||||
}](t, roleRec)
|
||||
assign := doJSON(t, srv, http.MethodPut,
|
||||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(mod.ID)+"/roles/"+role.Role.ID, "", ownerCookie)
|
||||
if assign.Code != http.StatusOK {
|
||||
t.Fatalf("assign moderator role = %d", assign.Code)
|
||||
}
|
||||
|
||||
// Модератор пытается снять собственную роль: себе модерировать нельзя.
|
||||
self := doJSON(t, srv, http.MethodDelete,
|
||||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(mod.ID)+"/roles/"+role.Role.ID, "", modCookie)
|
||||
if self.Code != http.StatusForbidden {
|
||||
t.Fatalf("self moderation = %d, want 403", self.Code)
|
||||
}
|
||||
|
||||
// Модератор пытается выдать себе роль администратора: она выше его роли.
|
||||
roles := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/roles", "", ownerCookie)
|
||||
roleList := decodeResponse[struct {
|
||||
Roles []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"roles"`
|
||||
}](t, roles)
|
||||
var adminRoleID string
|
||||
for _, candidate := range roleList.Roles {
|
||||
if candidate.Name == "Администратор" {
|
||||
adminRoleID = candidate.ID
|
||||
}
|
||||
}
|
||||
if adminRoleID == "" {
|
||||
t.Fatal("administrator role is missing")
|
||||
}
|
||||
escalate := doJSON(t, srv, http.MethodPut,
|
||||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(mod.ID)+"/roles/"+adminRoleID, "", modCookie)
|
||||
if escalate.Code != http.StatusForbidden {
|
||||
t.Fatalf("privilege escalation = %d, want 403", escalate.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelOverridesHideChannel(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
ownerCookie := registerAndLogin(t, srv, "override_owner", "override-owner@example.com")
|
||||
memberCookie := registerAndLogin(t, srv, "override_member", "override-member@example.com")
|
||||
|
||||
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Оверрайды"}`, ownerCookie)
|
||||
guild := decodeResponse[struct {
|
||||
Guild struct {
|
||||
ID string `json:"id"`
|
||||
Roles []struct {
|
||||
ID string `json:"id"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
} `json:"roles"`
|
||||
} `json:"guild"`
|
||||
}](t, created)
|
||||
doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie)
|
||||
|
||||
secret := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels",
|
||||
`{"name":"тайная","type":"text"}`, ownerCookie)
|
||||
channel := decodeResponse[struct {
|
||||
Channel struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"channel"`
|
||||
}](t, secret)
|
||||
|
||||
// Оверрайд для роли по умолчанию: VIEW_CHANNEL запрещён.
|
||||
channelID, err := parseID("channel_id", channel.Channel.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("parse channel id: %v", err)
|
||||
}
|
||||
var defaultRoleID uint64
|
||||
for _, role := range guild.Guild.Roles {
|
||||
if role.IsDefault {
|
||||
defaultRoleID, err = parseID("role_id", role.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("parse role id: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if defaultRoleID == 0 {
|
||||
t.Fatal("default role not found")
|
||||
}
|
||||
if err := srv.store.SetChannelOverride(t.Context(), store.ChannelOverride{
|
||||
ChannelID: channelID, TargetType: "role", TargetID: defaultRoleID,
|
||||
Deny: uint64(permissionViewChannel),
|
||||
}); err != nil {
|
||||
t.Fatalf("SetChannelOverride: %v", err)
|
||||
}
|
||||
srv.perms.InvalidateGuild(guildIDOf(t, guild.Guild.ID))
|
||||
|
||||
channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie)
|
||||
list := decodeResponse[struct {
|
||||
Channels []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"channels"`
|
||||
}](t, channels)
|
||||
for _, entry := range list.Channels {
|
||||
if entry.Name == "тайная" {
|
||||
t.Fatalf("channel with denied VIEW_CHANNEL must be hidden: %+v", list.Channels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func guildIDOf(t *testing.T, raw string) uint64 {
|
||||
t.Helper()
|
||||
id, err := parseID("guild_id", raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse guild id: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func containsString(values []string, wanted string) bool {
|
||||
for _, value := range values {
|
||||
if value == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasAction[T any](entries []T, action string) bool {
|
||||
for _, entry := range entries {
|
||||
encoded, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(string(encoded), `"`+action+`"`) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// profilePayload — публичный профиль пользователя (AGENT.md 8.2).
|
||||
type profilePayload struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Bio string `json:"bio"`
|
||||
Status string `json:"status"`
|
||||
CustomStatus string `json:"custom_status"`
|
||||
AvatarFileID string `json:"avatar_file_id,omitempty"`
|
||||
BannerFileID string `json:"banner_file_id,omitempty"`
|
||||
IsInstanceAdmin bool `json:"is_instance_admin"`
|
||||
Badges []string `json:"badges"`
|
||||
Locale string `json:"locale"`
|
||||
// OnboardingCompleted — признак пройденной первичной настройки (AGENT.md 7.2).
|
||||
OnboardingCompleted bool `json:"onboarding_completed"`
|
||||
}
|
||||
|
||||
func profileFromUser(user *store.User, includePrivate bool) profilePayload {
|
||||
payload := profilePayload{
|
||||
ID: formatSnowflake(user.ID),
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
Bio: user.Bio,
|
||||
Status: user.Status,
|
||||
CustomStatus: user.CustomStatus,
|
||||
IsInstanceAdmin: user.IsInstanceAdmin,
|
||||
Badges: user.Badges,
|
||||
OnboardingCompleted: user.OnboardingCompletedAt != nil,
|
||||
}
|
||||
if payload.Badges == nil {
|
||||
payload.Badges = []string{}
|
||||
}
|
||||
if user.AvatarFileID != nil {
|
||||
payload.AvatarFileID = formatSnowflake(*user.AvatarFileID)
|
||||
}
|
||||
if user.BannerFileID != nil {
|
||||
payload.BannerFileID = formatSnowflake(*user.BannerFileID)
|
||||
}
|
||||
if includePrivate {
|
||||
payload.Locale = user.Locale
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
type meOutput struct {
|
||||
Body struct {
|
||||
User profilePayload `json:"user"`
|
||||
}
|
||||
}
|
||||
|
||||
type userOutput struct {
|
||||
Body struct {
|
||||
User profilePayload `json:"user"`
|
||||
}
|
||||
}
|
||||
|
||||
type okOutput struct {
|
||||
Body struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
}
|
||||
|
||||
func newOKOutput() *okOutput {
|
||||
output := &okOutput{}
|
||||
output.Body.OK = true
|
||||
return output
|
||||
}
|
||||
|
||||
type updateProfileInput struct {
|
||||
Body struct {
|
||||
DisplayName *string `json:"display_name,omitempty" maxLength:"32"`
|
||||
Bio *string `json:"bio,omitempty" maxLength:"500"`
|
||||
Status *string `json:"status,omitempty" enum:"online,idle,dnd,invisible"`
|
||||
CustomStatus *string `json:"custom_status,omitempty" maxLength:"128"`
|
||||
CustomStatusEmoji *string `json:"custom_status_emoji,omitempty" maxLength:"32"`
|
||||
Locale *string `json:"locale,omitempty" enum:"ru,en"`
|
||||
}
|
||||
}
|
||||
|
||||
type changePasswordInput struct {
|
||||
Body struct {
|
||||
CurrentPassword string `json:"current_password" minLength:"1"`
|
||||
NewPassword string `json:"new_password" minLength:"1"`
|
||||
}
|
||||
}
|
||||
|
||||
type onboardingInput struct {
|
||||
Body struct {
|
||||
DisplayName *string `json:"display_name,omitempty" maxLength:"32"`
|
||||
Bio *string `json:"bio,omitempty" maxLength:"500"`
|
||||
Locale *string `json:"locale,omitempty" enum:"ru,en"`
|
||||
}
|
||||
}
|
||||
|
||||
type guildSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IconFileID string `json:"icon_file_id,omitempty"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
IsMain bool `json:"is_main"`
|
||||
MemberCount int `json:"member_count"`
|
||||
MyRoleIDs []string `json:"my_role_ids"`
|
||||
MyPermissions []string `json:"my_permissions"`
|
||||
}
|
||||
|
||||
type guildListOutput struct {
|
||||
Body struct {
|
||||
Guilds []guildSummary `json:"guilds"`
|
||||
}
|
||||
}
|
||||
|
||||
// registerUserRoutes описывает ручки профиля, онбординга и списка серверов.
|
||||
func (s *Server) registerUserRoutes(api huma.API) {
|
||||
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getCurrentUser",
|
||||
Method: http.MethodGet,
|
||||
Path: "/users/@me",
|
||||
Summary: "Текущий пользователь",
|
||||
Tags: []string{"Users"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, _ *struct{}) (*meOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output := &meOutput{}
|
||||
output.Body.User = profileFromUser(user, true)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "updateCurrentUser",
|
||||
Method: http.MethodPatch,
|
||||
Path: "/users/@me",
|
||||
Summary: "Изменить профиль",
|
||||
Tags: []string{"Users"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *updateProfileInput) (*meOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params := store.UpdateUserParams{
|
||||
DisplayName: input.Body.DisplayName,
|
||||
Bio: input.Body.Bio,
|
||||
Status: input.Body.Status,
|
||||
CustomStatus: input.Body.CustomStatus,
|
||||
CustomStatusEmoji: input.Body.CustomStatusEmoji,
|
||||
Locale: input.Body.Locale,
|
||||
}
|
||||
if input.Body.DisplayName != nil {
|
||||
trimmed := strings.TrimSpace(*input.Body.DisplayName)
|
||||
if trimmed == "" {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "display name must not be empty")
|
||||
}
|
||||
params.DisplayName = &trimmed
|
||||
}
|
||||
updated, err := s.store.UpdateUser(ctx, user.ID, params)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
// Профиль изменился — остальные клиенты получают событие (AGENT.md 8.3).
|
||||
s.dispatchUserUpdate(updated)
|
||||
output := &meOutput{}
|
||||
output.Body.User = profileFromUser(updated, true)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "changePassword",
|
||||
Method: http.MethodPost,
|
||||
Path: "/users/@me/password",
|
||||
Summary: "Сменить пароль (требует step-up)",
|
||||
Tags: []string{"Users"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *changePasswordInput) (*okOutput, error) {
|
||||
user, session, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.auth.ChangePassword(ctx, user.ID, session.ID, input.Body.CurrentPassword, input.Body.NewPassword); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
return newOKOutput(), nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "completeOnboarding",
|
||||
Method: http.MethodPost,
|
||||
Path: "/users/@me/onboarding/complete",
|
||||
Summary: "Завершить первичную настройку",
|
||||
Tags: []string{"Users"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *onboardingInput) (*meOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params := store.UpdateUserParams{
|
||||
Bio: input.Body.Bio,
|
||||
Locale: input.Body.Locale,
|
||||
}
|
||||
if input.Body.DisplayName != nil {
|
||||
if trimmed := strings.TrimSpace(*input.Body.DisplayName); trimmed != "" {
|
||||
params.DisplayName = &trimmed
|
||||
}
|
||||
}
|
||||
if _, err := s.store.UpdateUser(ctx, user.ID, params); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if err := s.store.MarkOnboardingCompleted(ctx, user.ID); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
updated, err := s.store.GetUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
s.dispatchUserUpdate(updated)
|
||||
output := &meOutput{}
|
||||
output.Body.User = profileFromUser(updated, true)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getUser",
|
||||
Method: http.MethodGet,
|
||||
Path: "/users/{user_id}",
|
||||
Summary: "Публичный профиль пользователя",
|
||||
Tags: []string{"Users"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *struct {
|
||||
UserID string `path:"user_id"`
|
||||
},
|
||||
) (*userOutput, error) {
|
||||
if _, _, err := requireUser(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := parseID("user_id", input.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err := s.store.GetUser(ctx, id)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
output := &userOutput{}
|
||||
output.Body.User = profileFromUser(user, false)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listMyGuilds",
|
||||
Method: http.MethodGet,
|
||||
Path: "/users/@me/guilds",
|
||||
Summary: "Серверы текущего пользователя",
|
||||
Tags: []string{"Users"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, _ *struct{}) (*guildListOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
guilds, err := s.store.ListGuildsForUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
summaries := make([]guildSummary, 0, len(guilds))
|
||||
for _, guild := range guilds {
|
||||
resolved, err := s.guildPermissions(ctx, guild.ID, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !resolved.Has(permissions.ViewGuild) {
|
||||
continue
|
||||
}
|
||||
summary, err := s.guildSummary(ctx, guild, user.ID, resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summaries = append(summaries, summary)
|
||||
}
|
||||
output := &guildListOutput{}
|
||||
output.Body.Guilds = summaries
|
||||
return output, nil
|
||||
})
|
||||
}
|
||||
|
||||
// guildSummary собирает краткую карточку сервера для списка.
|
||||
func (s *Server) guildSummary(ctx context.Context, guild store.Guild, userID uint64, resolved permissions.Resolved) (guildSummary, error) {
|
||||
summary := guildSummary{
|
||||
ID: formatSnowflake(guild.ID),
|
||||
Name: guild.Name,
|
||||
OwnerID: formatSnowflake(guild.OwnerID),
|
||||
IsMain: guild.IsMain,
|
||||
MyRoleIDs: []string{},
|
||||
MyPermissions: permissions.Names(resolved.Guild),
|
||||
}
|
||||
if guild.IconFileID != nil {
|
||||
summary.IconFileID = formatSnowflake(*guild.IconFileID)
|
||||
}
|
||||
roleIDs, err := s.store.MemberRoleIDs(ctx, guild.ID, userID)
|
||||
if err != nil {
|
||||
return guildSummary{}, humaError(err)
|
||||
}
|
||||
for _, roleID := range roleIDs {
|
||||
summary.MyRoleIDs = append(summary.MyRoleIDs, formatSnowflake(roleID))
|
||||
}
|
||||
count, err := s.store.CountGuildMembers(ctx, guild.ID)
|
||||
if err != nil {
|
||||
return guildSummary{}, humaError(err)
|
||||
}
|
||||
summary.MemberCount = count
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// dispatchUserUpdate рассылает обновление профиля во все сессии пользователя.
|
||||
func (s *Server) dispatchUserUpdate(user *store.User) {
|
||||
if s.gateway == nil {
|
||||
return
|
||||
}
|
||||
s.gateway.SendToUser(user.ID, "USER_UPDATE", map[string]any{
|
||||
"user": profileFromUser(user, true),
|
||||
})
|
||||
}
|
||||
+15
-19
@@ -7,12 +7,14 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"glchat/internal/auth"
|
||||
"glchat/internal/gateway"
|
||||
"glchat/internal/httpx"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// sessionCookieName — имя cookie сессии (AGENT.md 8.1: префикс __Host-).
|
||||
const sessionCookieName = "__Host-session"
|
||||
// Значение общее с Gateway: браузерный клиент аутентифицируется этой cookie.
|
||||
const sessionCookieName = gateway.SessionCookieName
|
||||
|
||||
const sessionCookiePath = "/"
|
||||
|
||||
@@ -54,7 +56,6 @@ func (s *Server) registerAuthRoutes(router chi.Router) {
|
||||
router.Post("/auth/step-up", s.handleStepUp)
|
||||
router.Post("/auth/2fa/setup", s.handleSetupTOTP)
|
||||
router.Post("/auth/2fa/enable", s.handleEnableTOTP)
|
||||
router.Get("/users/@me", s.handleGetMe)
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
@@ -77,19 +78,22 @@ type currentUserPayload struct {
|
||||
IsInstanceAdmin bool `json:"is_instance_admin"`
|
||||
Badges []string `json:"badges"`
|
||||
Locale string `json:"locale"`
|
||||
// OnboardingCompleted — пройдена ли первичная настройка (AGENT.md 7.2).
|
||||
OnboardingCompleted bool `json:"onboarding_completed"`
|
||||
}
|
||||
|
||||
func userPayload(user *store.User) currentUserPayload {
|
||||
payload := currentUserPayload{
|
||||
ID: formatSnowflake(user.ID),
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
Bio: user.Bio,
|
||||
Status: user.Status,
|
||||
CustomStatus: user.CustomStatus,
|
||||
IsInstanceAdmin: user.IsInstanceAdmin,
|
||||
Badges: user.Badges,
|
||||
Locale: user.Locale,
|
||||
ID: formatSnowflake(user.ID),
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
Bio: user.Bio,
|
||||
Status: user.Status,
|
||||
CustomStatus: user.CustomStatus,
|
||||
IsInstanceAdmin: user.IsInstanceAdmin,
|
||||
Badges: user.Badges,
|
||||
Locale: user.Locale,
|
||||
OnboardingCompleted: user.OnboardingCompletedAt != nil,
|
||||
}
|
||||
if payload.Badges == nil {
|
||||
payload.Badges = []string{}
|
||||
@@ -251,14 +255,6 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]any{"sessions": payload})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) {
|
||||
user, _, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"user": userPayload(user)})
|
||||
}
|
||||
|
||||
type totpEnableRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
@@ -110,7 +110,15 @@ func TestOpenAPIDocumentsAuthEndpoints(t *testing.T) {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("decode openapi: %v", err)
|
||||
}
|
||||
for _, path := range []string{"/auth/register", "/auth/login", "/auth/logout", "/auth/sessions", "/users/@me", "/auth/2fa/setup", "/meta"} {
|
||||
paths := []string{
|
||||
"/auth/register", "/auth/login", "/auth/logout", "/auth/sessions",
|
||||
"/users/@me", "/auth/2fa/setup", "/meta",
|
||||
// Ручки Фазы 1, сгенерированные huma.
|
||||
"/users/@me/guilds", "/guilds", "/guilds/{guild_id}", "/guilds/{guild_id}/channels",
|
||||
"/guilds/{guild_id}/members", "/guilds/{guild_id}/roles", "/instance",
|
||||
"/instance/settings", "/instance/guilds", "/instance/users", "/instance/audit",
|
||||
}
|
||||
for _, path := range paths {
|
||||
if _, ok := doc.Paths[path]; !ok {
|
||||
t.Errorf("openapi is missing %s", path)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"glchat/internal/gateway"
|
||||
"glchat/internal/httpx"
|
||||
"glchat/internal/meta"
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/source"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
@@ -26,6 +28,9 @@ type Deps struct {
|
||||
Store *store.Store
|
||||
Auth *auth.Service
|
||||
Gateway *gateway.Service
|
||||
// Permissions — общий с Gateway калькулятор прав (необязателен: если не
|
||||
// передан, сервер создаёт собственный).
|
||||
Permissions *permissions.Calculator
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -34,10 +39,12 @@ type Server struct {
|
||||
store *store.Store
|
||||
auth *auth.Service
|
||||
gateway *gateway.Service
|
||||
logger *slog.Logger
|
||||
http *http.Server
|
||||
static *staticHandler
|
||||
api huma.API
|
||||
// perms — движок прав: ручки проверяют права теми же правилами, что Gateway.
|
||||
perms *permissions.Calculator
|
||||
logger *slog.Logger
|
||||
http *http.Server
|
||||
static *staticHandler
|
||||
api huma.API
|
||||
}
|
||||
|
||||
func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Server {
|
||||
@@ -50,12 +57,25 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
||||
logger: logger,
|
||||
static: newStaticHandler(cfg.WebRoot),
|
||||
}
|
||||
switch {
|
||||
case deps.Permissions != nil:
|
||||
s.perms = deps.Permissions
|
||||
case deps.Store != nil:
|
||||
s.perms = permissions.NewCalculator(source.New(deps.Store))
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Route("/api/v1", func(apiRouter chi.Router) {
|
||||
// Сессия резолвится один раз на запрос: huma-ручки читают её из контекста.
|
||||
apiRouter.Use(s.sessionContext)
|
||||
s.api = s.registerAPI(apiRouter)
|
||||
s.registerMetaRoutes(s.api)
|
||||
s.registerAuthRoutes(apiRouter)
|
||||
if deps.Store != nil {
|
||||
s.registerUserRoutes(s.api)
|
||||
s.registerGuildRoutes(s.api)
|
||||
s.registerInstanceRoutes(s.api)
|
||||
}
|
||||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
||||
})
|
||||
s.registerRoutes(router)
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/database"
|
||||
"glchat/internal/gateway"
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/source"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
@@ -62,8 +64,11 @@ func newTestServer(t *testing.T) (*Server, *database.DB) {
|
||||
if err != nil {
|
||||
t.Fatalf("initialize authentication: %v", err)
|
||||
}
|
||||
gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st), logger, cfg.AllowedOrigins())
|
||||
return New(cfg, db, logger, Deps{Store: st, Auth: authService, Gateway: gatewayService}), db
|
||||
calculator := permissions.NewCalculator(source.New(st))
|
||||
gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st, calculator), logger, cfg.AllowedOrigins())
|
||||
return New(cfg, db, logger, Deps{
|
||||
Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator,
|
||||
}), db
|
||||
}
|
||||
|
||||
// TestGatewayRouteUpgrades проверяет связку: маршрут /gateway доступен через
|
||||
|
||||
Reference in New Issue
Block a user