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,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
|
||||
}
|
||||
Reference in New Issue
Block a user