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"` // AdminGuildID — сервер, созданный установщиком (справочно, автовхода нет). AdminGuildID 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 = s.profileFromUser(ctx, 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.AdminGuildID = formatSnowflake(settings.MainGuildID) } return payload, nil } // createGuildAsAdmin создаёт сервер в обход лимитов и фиксирует это в аудите. func (s *Server) createGuildAsAdmin(ctx context.Context, admin, owner *store.User, name string) (*store.Guild, error) { guild, err := s.store.CreateGuild(ctx, store.CreateGuildParams{ Name: name, OwnerID: owner.ID, IsMain: false, 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) } 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 }