2026-09-19 21:50:06 +03:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-09-21 22:37:47 +03:00
|
|
|
"crypto/rand"
|
2026-09-26 16:44:49 +03:00
|
|
|
"encoding/csv"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"log/slog"
|
2026-09-19 21:50:06 +03:00
|
|
|
"net/http"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
2026-09-26 16:44:49 +03:00
|
|
|
"github.com/go-chi/chi/v5"
|
2026-09-19 21:50:06 +03:00
|
|
|
|
|
|
|
|
"glchat/internal/bootstrap"
|
|
|
|
|
"glchat/internal/store"
|
2026-09-26 16:44:49 +03:00
|
|
|
"glchat/internal/sysinfo"
|
2026-09-19 21:50:06 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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"`
|
2026-09-20 01:12:01 +03:00
|
|
|
// AdminGuildID — сервер, созданный установщиком (справочно, автовхода нет).
|
|
|
|
|
AdminGuildID string `json:"main_guild_id,omitempty"`
|
|
|
|
|
UserCount int `json:"user_count"`
|
|
|
|
|
GuildCount int `json:"guild_count"`
|
2026-09-19 21:50:06 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"`
|
2026-09-26 16:44:49 +03:00
|
|
|
// Total — сколько серверов попадает в фильтр: панель листает страницы
|
|
|
|
|
// (AGENT.md 7.18).
|
|
|
|
|
Total int `json:"total"`
|
2026-09-19 21:50:06 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"`
|
2026-09-22 18:59:53 +03:00
|
|
|
// Глобальный бан инстанса (AGENT.md 7.18).
|
|
|
|
|
Banned bool `json:"banned"`
|
|
|
|
|
BannedAt string `json:"banned_at,omitempty"`
|
|
|
|
|
BanReason string `json:"ban_reason,omitempty"`
|
2026-09-19 21:50:06 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type instanceUserListOutput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
Users []instanceUserPayload `json:"users"`
|
2026-09-22 18:59:53 +03:00
|
|
|
Total int `json:"total"`
|
2026-09-19 21:50:06 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 18:59:53 +03:00
|
|
|
// instanceUserPayloadFrom собирает ответ панели по модели пользователя.
|
|
|
|
|
func instanceUserPayloadFrom(user store.User) instanceUserPayload {
|
|
|
|
|
badges := user.Badges
|
|
|
|
|
if badges == nil {
|
|
|
|
|
badges = []string{}
|
|
|
|
|
}
|
|
|
|
|
payload := instanceUserPayload{
|
|
|
|
|
ID: formatSnowflake(user.ID),
|
|
|
|
|
Username: user.Username,
|
|
|
|
|
DisplayName: user.DisplayName,
|
|
|
|
|
IsInstanceAdmin: user.IsInstanceAdmin,
|
|
|
|
|
Badges: badges,
|
|
|
|
|
CreatedAt: user.CreatedAt.UTC().Format(time.RFC3339),
|
|
|
|
|
Banned: user.BannedAt != nil,
|
|
|
|
|
BanReason: user.BanReason,
|
|
|
|
|
}
|
|
|
|
|
if user.BannedAt != nil {
|
|
|
|
|
payload.BannedAt = user.BannedAt.UTC().Format(time.RFC3339)
|
|
|
|
|
}
|
|
|
|
|
return payload
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 21:50:06 +03:00
|
|
|
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"`
|
2026-09-21 22:37:47 +03:00
|
|
|
// Лимиты медиа в байтах (AGENT.md 7.7): действуют на все сервера.
|
|
|
|
|
MaxAvatarSize int64 `json:"max_avatar_size"`
|
|
|
|
|
MaxGuildImageSize int64 `json:"max_guild_image_size"`
|
|
|
|
|
MaxEmojiSize int64 `json:"max_emoji_size"`
|
|
|
|
|
MaxSoundSize int64 `json:"max_sound_size"`
|
|
|
|
|
MaxCosmeticSize int64 `json:"max_cosmetic_size"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// instanceSettingsFromStore собирает ответ настроек в одном месте.
|
|
|
|
|
func instanceSettingsFromStore(settings *store.InstanceSettings) instanceSettingsPayload {
|
|
|
|
|
return instanceSettingsPayload{
|
|
|
|
|
RegistrationEnabled: settings.RegistrationEnabled,
|
|
|
|
|
AllowGuildCreation: settings.AllowGuildCreation,
|
|
|
|
|
MaxGuildsPerUser: settings.MaxGuildsPerUser,
|
|
|
|
|
MaxMembersPerGuild: settings.MaxMembersPerGuild,
|
|
|
|
|
MaxMessageLength: settings.MaxMessageLength,
|
|
|
|
|
MaxAvatarSize: settings.MaxAvatarSize,
|
|
|
|
|
MaxGuildImageSize: settings.MaxGuildImageSize,
|
|
|
|
|
MaxEmojiSize: settings.MaxEmojiSize,
|
|
|
|
|
MaxSoundSize: settings.MaxSoundSize,
|
|
|
|
|
MaxCosmeticSize: settings.MaxCosmeticSize,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type adminPasswordOutput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
UserID string `json:"user_id"`
|
|
|
|
|
Password string `json:"password"`
|
|
|
|
|
SessionsRevoked int64 `json:"sessions_revoked"`
|
|
|
|
|
}
|
2026-09-19 21:50:06 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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{}
|
2026-09-21 22:37:47 +03:00
|
|
|
output.Body.Settings = instanceSettingsFromStore(settings)
|
2026-09-19 21:50:06 +03:00
|
|
|
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"`
|
2026-09-21 22:37:47 +03:00
|
|
|
// Лимиты медиа: 64 КБ — 512 МБ (AGENT.md 7.7).
|
|
|
|
|
MaxAvatarSize *int64 `json:"max_avatar_size,omitempty" minimum:"65536" maximum:"536870912"`
|
|
|
|
|
MaxGuildImageSize *int64 `json:"max_guild_image_size,omitempty" minimum:"65536" maximum:"536870912"`
|
|
|
|
|
MaxEmojiSize *int64 `json:"max_emoji_size,omitempty" minimum:"65536" maximum:"536870912"`
|
|
|
|
|
MaxSoundSize *int64 `json:"max_sound_size,omitempty" minimum:"65536" maximum:"536870912"`
|
|
|
|
|
MaxCosmeticSize *int64 `json:"max_cosmetic_size,omitempty" minimum:"65536" maximum:"536870912"`
|
2026-09-19 21:50:06 +03:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
) (*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)
|
|
|
|
|
}
|
2026-09-21 22:37:47 +03:00
|
|
|
for key, value := range map[string]*int64{
|
|
|
|
|
"max_avatar_size": input.Body.MaxAvatarSize,
|
|
|
|
|
"max_guild_image_size": input.Body.MaxGuildImageSize,
|
|
|
|
|
"max_emoji_size": input.Body.MaxEmojiSize,
|
|
|
|
|
"max_sound_size": input.Body.MaxSoundSize,
|
|
|
|
|
"max_cosmetic_size": input.Body.MaxCosmeticSize,
|
|
|
|
|
} {
|
|
|
|
|
if value != nil {
|
|
|
|
|
updates[key] = strconv.FormatInt(*value, 10)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-19 21:50:06 +03:00
|
|
|
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{}
|
2026-09-21 22:37:47 +03:00
|
|
|
output.Body.Settings = instanceSettingsFromStore(settings)
|
2026-09-19 21:50:06 +03:00
|
|
|
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": {}}},
|
2026-09-26 16:44:49 +03:00
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
// Фильтры списка серверов (AGENT.md 7.18): поиск по названию, владелец,
|
|
|
|
|
// только главный сервер, плюс страница выдачи.
|
|
|
|
|
Query string `query:"q" maxLength:"64"`
|
|
|
|
|
OwnerID string `query:"owner_id"`
|
|
|
|
|
Main bool `query:"main"`
|
|
|
|
|
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
|
|
|
|
|
Offset int `query:"offset" default:"0" minimum:"0"`
|
|
|
|
|
},
|
|
|
|
|
) (*instanceGuildListOutput, error) {
|
2026-09-19 21:50:06 +03:00
|
|
|
if _, err := requireInstanceAdmin(ctx); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-09-26 16:44:49 +03:00
|
|
|
var ownerID uint64
|
|
|
|
|
if input.OwnerID != "" {
|
|
|
|
|
parsed, err := parseID("owner_id", input.OwnerID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
ownerID = parsed
|
|
|
|
|
}
|
|
|
|
|
rows, total, err := s.store.ListInstanceGuilds(ctx, store.InstanceGuildFilter{
|
|
|
|
|
Query: input.Query,
|
|
|
|
|
OwnerID: ownerID,
|
|
|
|
|
MainOnly: input.Main,
|
|
|
|
|
Limit: input.Limit,
|
|
|
|
|
Offset: input.Offset,
|
|
|
|
|
})
|
2026-09-19 21:50:06 +03:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
output := &instanceGuildListOutput{}
|
2026-09-26 16:44:49 +03:00
|
|
|
output.Body.Guilds = make([]instanceGuildPayload, 0, len(rows))
|
|
|
|
|
for _, row := range rows {
|
|
|
|
|
output.Body.Guilds = append(output.Body.Guilds, instanceGuildPayload{
|
|
|
|
|
ID: formatSnowflake(row.ID),
|
|
|
|
|
Name: row.Name,
|
|
|
|
|
OwnerID: formatSnowflake(row.OwnerID),
|
|
|
|
|
OwnerName: row.OwnerName,
|
|
|
|
|
IsMain: row.IsMain,
|
|
|
|
|
MemberCount: row.MemberCount,
|
|
|
|
|
CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339),
|
|
|
|
|
})
|
2026-09-19 21:50:06 +03:00
|
|
|
}
|
2026-09-26 16:44:49 +03:00
|
|
|
output.Body.Total = total
|
2026-09-19 21:50:06 +03:00
|
|
|
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 {
|
2026-09-22 18:59:53 +03:00
|
|
|
Query string `query:"q" maxLength:"64"`
|
|
|
|
|
// Banned — показать только забаненных (AGENT.md 7.18).
|
|
|
|
|
Banned bool `query:"banned"`
|
|
|
|
|
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
|
|
|
|
|
Offset int `query:"offset" default:"0" minimum:"0"`
|
2026-09-19 21:50:06 +03:00
|
|
|
},
|
|
|
|
|
) (*instanceUserListOutput, error) {
|
|
|
|
|
if _, err := requireInstanceAdmin(ctx); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-09-22 18:59:53 +03:00
|
|
|
users, err := s.store.ListUsers(ctx, input.Query, input.Banned, input.Limit, input.Offset)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
total, err := s.store.CountUsersFiltered(ctx, input.Query, input.Banned)
|
2026-09-19 21:50:06 +03:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
output := &instanceUserListOutput{}
|
|
|
|
|
output.Body.Users = make([]instanceUserPayload, 0, len(users))
|
|
|
|
|
for _, user := range users {
|
2026-09-22 18:59:53 +03:00
|
|
|
output.Body.Users = append(output.Body.Users, instanceUserPayloadFrom(user))
|
2026-09-19 21:50:06 +03:00
|
|
|
}
|
2026-09-22 18:59:53 +03:00
|
|
|
output.Body.Total = total
|
2026-09-19 21:50:06 +03:00
|
|
|
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{}
|
2026-09-19 22:03:46 +03:00
|
|
|
output.Body.User = s.profileFromUser(ctx, updated, false)
|
2026-09-19 21:50:06 +03:00
|
|
|
return output, nil
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-21 22:37:47 +03:00
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "adminResetUserPassword",
|
|
|
|
|
Method: http.MethodPost,
|
|
|
|
|
Path: "/instance/users/{user_id}/reset-password",
|
|
|
|
|
Summary: "Сбросить пароль пользователя (администратор)",
|
|
|
|
|
Tags: []string{"Instance"},
|
|
|
|
|
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
UserID string `path:"user_id"`
|
|
|
|
|
},
|
|
|
|
|
) (*adminPasswordOutput, error) {
|
|
|
|
|
admin, err := requireInstanceAdmin(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
targetID, err := parseID("user_id", input.UserID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
target, err := s.store.GetUser(ctx, targetID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
// Пароль показывается один раз: администратор передаёт его владельцу.
|
|
|
|
|
password, err := newTemporaryPassword()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
// Считаем сессии до смены пароля: SetPassword их уже отзывает.
|
|
|
|
|
revoked, err := s.store.RevokeUserSessions(ctx, target.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
if err := s.auth.SetPassword(ctx, target.ID, password); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
2026-09-22 20:10:53 +03:00
|
|
|
if s.gateway != nil {
|
|
|
|
|
s.gateway.InvalidateUser(target.ID, "password_reset")
|
|
|
|
|
}
|
2026-09-21 22:37:47 +03:00
|
|
|
s.recordAudit(ctx, admin, 0, "instance.user_password_reset", "user", &target.ID, "")
|
|
|
|
|
output := &adminPasswordOutput{}
|
|
|
|
|
output.Body.UserID = formatSnowflake(target.ID)
|
|
|
|
|
output.Body.Password = password
|
|
|
|
|
output.Body.SessionsRevoked = revoked
|
|
|
|
|
return output, nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "adminLogoutUser",
|
|
|
|
|
Method: http.MethodPost,
|
|
|
|
|
Path: "/instance/users/{user_id}/logout",
|
|
|
|
|
Summary: "Выйти со всех устройств пользователя",
|
|
|
|
|
Tags: []string{"Instance"},
|
|
|
|
|
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
UserID string `path:"user_id"`
|
|
|
|
|
},
|
|
|
|
|
) (*okOutput, error) {
|
|
|
|
|
admin, err := requireInstanceAdmin(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
targetID, err := parseID("user_id", input.UserID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if _, err := s.store.GetUser(ctx, targetID); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := s.store.RevokeUserSessions(ctx, targetID); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
2026-09-22 20:10:53 +03:00
|
|
|
if s.gateway != nil {
|
|
|
|
|
s.gateway.InvalidateUser(targetID, "logout_all")
|
|
|
|
|
}
|
2026-09-21 22:37:47 +03:00
|
|
|
s.recordAudit(ctx, admin, 0, "instance.user_logout", "user", &targetID, "")
|
|
|
|
|
return newOKOutput(), nil
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-22 18:59:53 +03:00
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "adminBanUser",
|
|
|
|
|
Method: http.MethodPost,
|
|
|
|
|
Path: "/instance/users/{user_id}/ban",
|
|
|
|
|
Summary: "Забанить пользователя на инстансе",
|
|
|
|
|
Tags: []string{"Instance"},
|
|
|
|
|
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
UserID string `path:"user_id"`
|
|
|
|
|
Body struct {
|
|
|
|
|
Reason string `json:"reason,omitempty" maxLength:"400"`
|
|
|
|
|
// Бан — действие инстанс-админа: нужна свежая проверка (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")
|
|
|
|
|
}
|
|
|
|
|
targetID, err := parseID("user_id", input.UserID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if targetID == admin.ID {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you cannot ban yourself")
|
|
|
|
|
}
|
|
|
|
|
target, err := s.store.GetUser(ctx, targetID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
// Администратора инстанса забанить нельзя (AGENT.md 7.19).
|
|
|
|
|
if target.IsInstanceAdmin {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_protected", "instance administrator cannot be banned")
|
|
|
|
|
}
|
|
|
|
|
if err := s.auth.RequireStepUp(ctx, admin, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
reason := strings.TrimSpace(input.Body.Reason)
|
|
|
|
|
if err := s.store.BanInstanceUser(ctx, target.ID, admin.ID, reason); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := s.store.RevokeUserSessions(ctx, target.ID); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
if s.gateway != nil {
|
2026-09-22 20:10:53 +03:00
|
|
|
s.gateway.InvalidateUser(target.ID, "user_banned")
|
2026-09-22 18:59:53 +03:00
|
|
|
}
|
|
|
|
|
s.recordAudit(ctx, admin, 0, "instance.user_ban", "user", &target.ID, reason)
|
|
|
|
|
updated, err := s.store.GetUser(ctx, target.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
output := &userOutput{}
|
|
|
|
|
output.Body.User = s.profileFromUser(ctx, updated, false)
|
|
|
|
|
return output, nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "adminUnbanUser",
|
|
|
|
|
Method: http.MethodPost,
|
|
|
|
|
Path: "/instance/users/{user_id}/unban",
|
|
|
|
|
Summary: "Снять бан инстанса",
|
|
|
|
|
Tags: []string{"Instance"},
|
|
|
|
|
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
UserID string `path:"user_id"`
|
|
|
|
|
Body struct {
|
|
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
targetID, err := parseID("user_id", input.UserID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if _, err := s.store.GetUser(ctx, targetID); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
if err := s.auth.RequireStepUp(ctx, admin, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := s.store.UnbanInstanceUser(ctx, targetID); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
s.recordAudit(ctx, admin, 0, "instance.user_unban", "user", &targetID, "")
|
|
|
|
|
updated, err := s.store.GetUser(ctx, targetID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
output := &userOutput{}
|
|
|
|
|
output.Body.User = s.profileFromUser(ctx, updated, false)
|
|
|
|
|
return output, nil
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-21 22:37:47 +03:00
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "adminDeleteUser",
|
|
|
|
|
Method: http.MethodDelete,
|
|
|
|
|
Path: "/instance/users/{user_id}",
|
|
|
|
|
Summary: "Удалить пользователя (администратор)",
|
|
|
|
|
Tags: []string{"Instance"},
|
|
|
|
|
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
UserID string `path:"user_id"`
|
|
|
|
|
},
|
|
|
|
|
) (*okOutput, error) {
|
|
|
|
|
admin, err := requireInstanceAdmin(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
targetID, err := parseID("user_id", input.UserID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if targetID == admin.ID {
|
|
|
|
|
// Иначе администратор может удалить себя и потерять доступ.
|
|
|
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you cannot delete yourself")
|
|
|
|
|
}
|
|
|
|
|
if _, err := s.store.GetUser(ctx, targetID); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
// Мягкое удаление: сообщения и аудит остаются (AGENT.md 6.4).
|
|
|
|
|
if err := s.store.SoftDeleteUser(ctx, targetID); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
if s.gateway != nil {
|
2026-09-22 20:10:53 +03:00
|
|
|
s.gateway.InvalidateUser(targetID, "user_deleted")
|
2026-09-21 22:37:47 +03:00
|
|
|
}
|
|
|
|
|
s.recordAudit(ctx, admin, 0, "instance.user_delete", "user", &targetID, "")
|
|
|
|
|
return newOKOutput(), nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "adminUpdateGuild",
|
|
|
|
|
Method: http.MethodPatch,
|
|
|
|
|
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"`
|
|
|
|
|
Body struct {
|
|
|
|
|
Name *string `json:"name,omitempty" maxLength:"64"`
|
|
|
|
|
Description *string `json:"description,omitempty" maxLength:"400"`
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
) (*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
|
|
|
|
|
}
|
|
|
|
|
guild, err := s.store.GetGuild(ctx, guildID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
if input.Body.Name != nil {
|
|
|
|
|
name := strings.TrimSpace(*input.Body.Name)
|
|
|
|
|
if name == "" {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild name must not be empty")
|
|
|
|
|
}
|
|
|
|
|
guild.Name = name
|
|
|
|
|
}
|
|
|
|
|
if input.Body.Description != nil {
|
|
|
|
|
guild.Description = strings.TrimSpace(*input.Body.Description)
|
|
|
|
|
}
|
|
|
|
|
updated, err := s.store.UpdateGuild(ctx, guildID, store.UpdateGuildParams{
|
|
|
|
|
Name: &guild.Name,
|
|
|
|
|
Description: &guild.Description,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
s.invalidateGuild(guildID)
|
|
|
|
|
s.dispatchGuildUpdate(*updated)
|
|
|
|
|
s.recordAudit(ctx, admin, guildID, "instance.guild_update", "guild", &guildID, "")
|
|
|
|
|
return newOKOutput(), nil
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-19 21:50:06 +03:00
|
|
|
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 {
|
2026-09-26 16:44:49 +03:00
|
|
|
// Фильтры журнала (AGENT.md 7.10, 7.18): действие, актор, цель, сервер
|
|
|
|
|
// и период. Даты — в формате YYYY-MM-DD (UTC).
|
|
|
|
|
Action string `query:"action" maxLength:"64"`
|
|
|
|
|
ActorID string `query:"actor_id"`
|
|
|
|
|
TargetID string `query:"target_id"`
|
|
|
|
|
GuildID string `query:"guild_id"`
|
|
|
|
|
Since string `query:"since" maxLength:"10"`
|
|
|
|
|
Until string `query:"until" maxLength:"10"`
|
|
|
|
|
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
|
|
|
|
|
Offset int `query:"offset" default:"0" minimum:"0"`
|
2026-09-19 21:50:06 +03:00
|
|
|
},
|
2026-09-26 16:44:49 +03:00
|
|
|
) (*instanceAuditOutput, error) {
|
2026-09-19 21:50:06 +03:00
|
|
|
if _, err := requireInstanceAdmin(ctx); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-09-26 16:44:49 +03:00
|
|
|
filter, err := auditFilterFromQuery(auditQueryParams{
|
|
|
|
|
Action: input.Action, ActorID: input.ActorID, TargetID: input.TargetID,
|
|
|
|
|
GuildID: input.GuildID, Since: input.Since, Until: input.Until,
|
|
|
|
|
Limit: input.Limit, Offset: input.Offset,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
entries, total, err := s.store.ListInstanceAuditFiltered(ctx, filter)
|
2026-09-19 21:50:06 +03:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
2026-09-26 16:44:49 +03:00
|
|
|
output := &instanceAuditOutput{}
|
2026-09-19 21:50:06 +03:00
|
|
|
output.Body.Entries = auditPayloads(entries)
|
2026-09-26 16:44:49 +03:00
|
|
|
output.Body.Total = total
|
|
|
|
|
return output, nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "listInstanceAuditActions",
|
|
|
|
|
Method: http.MethodGet,
|
|
|
|
|
Path: "/instance/audit/actions",
|
|
|
|
|
Summary: "Действия, встречающиеся в журнале инстанса",
|
|
|
|
|
Tags: []string{"Instance"},
|
|
|
|
|
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
|
|
|
|
}, func(ctx context.Context, _ *struct{}) (*instanceAuditActionsOutput, error) {
|
|
|
|
|
if _, err := requireInstanceAdmin(ctx); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
actions, err := s.store.ListInstanceAuditActions(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
output := &instanceAuditActionsOutput{}
|
|
|
|
|
output.Body.Actions = actions
|
|
|
|
|
return output, nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "getInstanceStats",
|
|
|
|
|
Method: http.MethodGet,
|
|
|
|
|
Path: "/instance/stats",
|
|
|
|
|
Summary: "Статистика инстанса",
|
|
|
|
|
Tags: []string{"Instance"},
|
|
|
|
|
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
|
|
|
|
}, func(ctx context.Context, _ *struct{}) (*instanceStatsOutput, error) {
|
|
|
|
|
if _, err := requireInstanceAdmin(ctx); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
stats, err := s.store.InstanceStats(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
output := &instanceStatsOutput{}
|
|
|
|
|
output.Body.Stats = s.instanceStatsPayload(stats)
|
|
|
|
|
return output, nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "getInstanceUser",
|
|
|
|
|
Method: http.MethodGet,
|
|
|
|
|
Path: "/instance/users/{user_id}",
|
|
|
|
|
Summary: "Карточка пользователя для админ-панели",
|
|
|
|
|
Tags: []string{"Instance"},
|
|
|
|
|
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
UserID string `path:"user_id"`
|
|
|
|
|
},
|
|
|
|
|
) (*instanceUserCardOutput, error) {
|
|
|
|
|
if _, err := requireInstanceAdmin(ctx); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
targetID, err := parseID("user_id", input.UserID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
target, err := s.store.GetUser(ctx, targetID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
card, err := s.instanceUserCard(ctx, target)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return card, nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "adminRevokeUserSession",
|
|
|
|
|
Method: http.MethodDelete,
|
|
|
|
|
Path: "/instance/users/{user_id}/sessions/{session_id}",
|
|
|
|
|
Summary: "Отозвать одно устройство пользователя",
|
|
|
|
|
Tags: []string{"Instance"},
|
|
|
|
|
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
UserID string `path:"user_id"`
|
|
|
|
|
SessionID string `path:"session_id"`
|
|
|
|
|
Body struct {
|
|
|
|
|
// StepUpPassword подтверждает действие: отзыв устройства —
|
|
|
|
|
// чувствительная операция (AGENT.md 7.1, 7.18).
|
|
|
|
|
StepUpPassword string `json:"step_up_password,omitempty"`
|
|
|
|
|
StepUpTOTP string `json:"step_up_totp,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
) (*okOutput, 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)
|
|
|
|
|
}
|
|
|
|
|
targetID, err := parseID("user_id", input.UserID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
sessionID, err := parseID("session_id", input.SessionID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
targetSession, err := s.store.UserSession(ctx, targetID, sessionID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
deleted, err := s.store.DeleteUserSession(ctx, targetID, sessionID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
if !deleted {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "session not found")
|
|
|
|
|
}
|
|
|
|
|
// Закрываем только это соединение: остальные устройства пользователя
|
|
|
|
|
// продолжают работу (AGENT.md 11.6).
|
|
|
|
|
if s.gateway != nil {
|
|
|
|
|
s.gateway.InvalidateSession(targetID, targetSession.TokenHash, "session_revoked")
|
|
|
|
|
}
|
|
|
|
|
s.recordSecurityEvent(ctx, &targetID, "session_revoked", session, map[string]any{
|
|
|
|
|
"by": "instance_admin",
|
|
|
|
|
"session_id": formatSnowflake(sessionID),
|
|
|
|
|
})
|
|
|
|
|
s.recordAudit(ctx, admin, 0, "instance.user_session_revoke", "user", &targetID, "")
|
|
|
|
|
return newOKOutput(), nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "adminResetUser2FA",
|
|
|
|
|
Method: http.MethodPost,
|
|
|
|
|
Path: "/instance/users/{user_id}/reset-2fa",
|
|
|
|
|
Summary: "Сбросить второй фактор пользователя",
|
|
|
|
|
Tags: []string{"Instance"},
|
|
|
|
|
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
UserID string `path:"user_id"`
|
|
|
|
|
Body struct {
|
|
|
|
|
// Сброс второго фактора — восстановление доступа: без step-up
|
|
|
|
|
// администратора это был бы готовый способ захвата аккаунта.
|
|
|
|
|
StepUpPassword string `json:"step_up_password,omitempty"`
|
|
|
|
|
StepUpTOTP string `json:"step_up_totp,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
) (*adminReset2FAOutput, 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)
|
|
|
|
|
}
|
|
|
|
|
targetID, err := parseID("user_id", input.UserID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if targetID == admin.ID {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "use the security settings to reset your own second factor")
|
|
|
|
|
}
|
|
|
|
|
target, err := s.store.GetUser(ctx, targetID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
// У администратора инстанса второй фактор обязателен (AGENT.md 7.19):
|
|
|
|
|
// сброс чужого ключа стал бы обходом этого правила.
|
|
|
|
|
if target.IsInstanceAdmin {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_protected", "instance administrator second factor cannot be reset")
|
|
|
|
|
}
|
|
|
|
|
secret, err := s.store.GetTOTPSecret(ctx, targetID)
|
|
|
|
|
if errors.Is(err, store.ErrNotFound) || (err == nil && !secret.Enabled) {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "two-factor authentication is not enabled")
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
if err := s.store.DeleteTOTPSecret(ctx, targetID); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
// После сброса второго фактора сессии отзываются: владелец входит
|
|
|
|
|
// заново паролем и настраивает новый ключ.
|
|
|
|
|
revoked, err := s.store.RevokeUserSessions(ctx, targetID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
if s.gateway != nil {
|
|
|
|
|
s.gateway.InvalidateUser(targetID, "2fa_reset")
|
|
|
|
|
}
|
|
|
|
|
s.recordSecurityEvent(ctx, &targetID, "2fa_change", session, map[string]any{
|
|
|
|
|
"action": "reset",
|
|
|
|
|
"by": "instance_admin",
|
|
|
|
|
})
|
|
|
|
|
s.recordAudit(ctx, admin, 0, "instance.user_2fa_reset", "user", &targetID, "")
|
|
|
|
|
output := &adminReset2FAOutput{}
|
|
|
|
|
output.Body.UserID = formatSnowflake(targetID)
|
|
|
|
|
output.Body.SessionsRevoked = revoked
|
2026-09-19 21:50:06 +03:00
|
|
|
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,
|
2026-09-22 20:15:12 +03:00
|
|
|
// Признак «голос настроен»: клиент берёт его из /meta, но контракт
|
|
|
|
|
// публичной информации об инстансе (AGENT.md 6.5) обещает и здесь.
|
|
|
|
|
VoiceEnabled: s.cfg.VoiceEnabled(),
|
|
|
|
|
MaxGuildsPerUser: settings.MaxGuildsPerUser,
|
|
|
|
|
MaxMembersPerGuild: settings.MaxMembersPerGuild,
|
|
|
|
|
MaxMessageLength: settings.MaxMessageLength,
|
|
|
|
|
UserCount: users,
|
|
|
|
|
GuildCount: len(guilds),
|
2026-09-19 21:50:06 +03:00
|
|
|
}
|
|
|
|
|
if settings.MainGuildID != 0 {
|
2026-09-20 01:12:01 +03:00
|
|
|
payload.AdminGuildID = formatSnowflake(settings.MainGuildID)
|
2026-09-19 21:50:06 +03:00
|
|
|
}
|
|
|
|
|
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,
|
2026-09-20 01:12:01 +03:00
|
|
|
IsMain: false,
|
2026-09-19 21:50:06 +03:00
|
|
|
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
|
|
|
|
|
}
|
2026-09-21 22:37:47 +03:00
|
|
|
|
|
|
|
|
// temporaryPasswordAlphabet — символы временного пароля: без похожих друг на
|
|
|
|
|
// друга, чтобы его можно было продиктовать.
|
|
|
|
|
const temporaryPasswordAlphabet = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
|
|
|
|
|
|
|
|
// newTemporaryPassword генерирует пароль для выдачи пользователю: 16 символов
|
|
|
|
|
// из криптографического источника (AGENT.md 9.2).
|
|
|
|
|
func newTemporaryPassword() (string, error) {
|
|
|
|
|
const length = 16
|
|
|
|
|
buf := make([]byte, length)
|
|
|
|
|
if _, err := rand.Read(buf); err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
var builder strings.Builder
|
|
|
|
|
builder.Grow(length)
|
|
|
|
|
for _, value := range buf {
|
|
|
|
|
builder.WriteByte(temporaryPasswordAlphabet[int(value)%len(temporaryPasswordAlphabet)])
|
|
|
|
|
}
|
|
|
|
|
return builder.String(), nil
|
|
|
|
|
}
|
2026-09-26 16:44:49 +03:00
|
|
|
|
|
|
|
|
// instanceAuditOutput — страница журнала инстанса с общим числом записей под
|
|
|
|
|
// фильтр: панели нужна пагинация (AGENT.md 7.18).
|
|
|
|
|
type instanceAuditOutput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
Entries []auditEntryPayload `json:"entries"`
|
|
|
|
|
Total int `json:"total"`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type instanceAuditActionsOutput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
Actions []string `json:"actions"`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// dailyCountPayload — точка роста для графика статистики.
|
|
|
|
|
type dailyCountPayload struct {
|
|
|
|
|
Date string `json:"date"`
|
|
|
|
|
Count int `json:"count"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type instanceGuildStatPayload struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Members int `json:"members"`
|
|
|
|
|
Messages int `json:"messages"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type instanceStatsPayload struct {
|
|
|
|
|
Users struct {
|
|
|
|
|
Total int `json:"total"`
|
|
|
|
|
Admins int `json:"admins"`
|
|
|
|
|
Banned int `json:"banned"`
|
|
|
|
|
New7Days int `json:"new_7_days"`
|
|
|
|
|
New30Days int `json:"new_30_days"`
|
|
|
|
|
Active24h int `json:"active_24h"`
|
|
|
|
|
Daily []dailyCountPayload `json:"daily"`
|
|
|
|
|
} `json:"users"`
|
|
|
|
|
Messages struct {
|
|
|
|
|
Total int `json:"total"`
|
|
|
|
|
Today int `json:"today"`
|
|
|
|
|
Week int `json:"week"`
|
|
|
|
|
Month int `json:"month"`
|
|
|
|
|
Daily []dailyCountPayload `json:"daily"`
|
|
|
|
|
} `json:"messages"`
|
|
|
|
|
Files struct {
|
|
|
|
|
Count int `json:"count"`
|
|
|
|
|
Bytes int64 `json:"bytes"`
|
|
|
|
|
} `json:"files"`
|
|
|
|
|
Guilds int `json:"guilds"`
|
|
|
|
|
Invites int `json:"invites"`
|
|
|
|
|
Bans int `json:"bans"`
|
|
|
|
|
DatabaseBytes int64 `json:"database_bytes"`
|
|
|
|
|
StorageBytes int64 `json:"storage_bytes"`
|
|
|
|
|
TopGuilds []instanceGuildStatPayload `json:"top_guilds"`
|
|
|
|
|
BusiestGuilds []instanceGuildStatPayload `json:"busiest_guilds"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type instanceStatsOutput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
Stats instanceStatsPayload `json:"stats"`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type instanceSessionPayload struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
UserAgent string `json:"user_agent,omitempty"`
|
|
|
|
|
IP string `json:"ip,omitempty"`
|
|
|
|
|
CreatedAt string `json:"created_at"`
|
|
|
|
|
LastSeen string `json:"last_seen"`
|
|
|
|
|
ExpiresAt string `json:"expires_at"`
|
|
|
|
|
// SteppedUp — сессия недавно подтверждала пароль: видно, какие устройства
|
|
|
|
|
// администратор может отозвать без последствий для остальных.
|
|
|
|
|
SteppedUp bool `json:"stepped_up"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type instanceMembershipRolePayload struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Color int64 `json:"color"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type instanceMembershipPayload struct {
|
|
|
|
|
GuildID string `json:"guild_id"`
|
|
|
|
|
GuildName string `json:"guild_name"`
|
|
|
|
|
Nickname string `json:"nickname,omitempty"`
|
|
|
|
|
JoinedAt string `json:"joined_at"`
|
|
|
|
|
Roles []instanceMembershipRolePayload `json:"roles"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// instanceUserCardOutput — карточка пользователя: профиль, устройства,
|
|
|
|
|
// серверы с ролями, события безопасности и аудит по нему (AGENT.md 7.18).
|
|
|
|
|
type instanceUserCardOutput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
User instanceUserPayload `json:"user"`
|
|
|
|
|
TOTPEnabled bool `json:"totp_enabled"`
|
|
|
|
|
Passkeys int `json:"passkeys"`
|
|
|
|
|
Sessions []instanceSessionPayload `json:"sessions"`
|
|
|
|
|
Guilds []instanceMembershipPayload `json:"guilds"`
|
|
|
|
|
SecurityEvents []securityEventPayload `json:"security_events"`
|
|
|
|
|
Audit []auditEntryPayload `json:"audit"`
|
|
|
|
|
AuditTotal int `json:"audit_total"`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type adminReset2FAOutput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
UserID string `json:"user_id"`
|
|
|
|
|
SessionsRevoked int64 `json:"sessions_revoked"`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// securityEventsLimit и userAuditLimit — сколько событий и записей аудита
|
|
|
|
|
// показывать в карточке пользователя: остальное доступно в разделе «Аудит».
|
|
|
|
|
const (
|
|
|
|
|
securityEventsLimit = 20
|
|
|
|
|
userAuditLimit = 20
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// auditQueryParams — фильтры журнала, как они приходят из строки запроса.
|
|
|
|
|
// Один разбор используется и JSON-ручкой (huma), и экспортом CSV.
|
|
|
|
|
type auditQueryParams struct {
|
|
|
|
|
Action string
|
|
|
|
|
ActorID string
|
|
|
|
|
TargetID string
|
|
|
|
|
GuildID string
|
|
|
|
|
Since string
|
|
|
|
|
Until string
|
|
|
|
|
Limit int
|
|
|
|
|
Offset int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// auditFilterFromQuery собирает фильтр журнала и разбирает даты (AGENT.md 7.10).
|
|
|
|
|
func auditFilterFromQuery(params auditQueryParams) (store.InstanceAuditFilter, error) {
|
|
|
|
|
filter := store.InstanceAuditFilter{
|
|
|
|
|
Action: strings.TrimSpace(params.Action),
|
|
|
|
|
Limit: params.Limit,
|
|
|
|
|
Offset: params.Offset,
|
|
|
|
|
}
|
|
|
|
|
for _, item := range []struct {
|
|
|
|
|
raw string
|
|
|
|
|
name string
|
|
|
|
|
into *uint64
|
|
|
|
|
}{
|
|
|
|
|
{params.ActorID, "actor_id", &filter.ActorID},
|
|
|
|
|
{params.TargetID, "target_id", &filter.TargetID},
|
|
|
|
|
{params.GuildID, "guild_id", &filter.GuildID},
|
|
|
|
|
} {
|
|
|
|
|
if strings.TrimSpace(item.raw) == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
value, err := parseID(item.name, strings.TrimSpace(item.raw))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return store.InstanceAuditFilter{}, err
|
|
|
|
|
}
|
|
|
|
|
*item.into = value
|
|
|
|
|
}
|
|
|
|
|
since, err := parseAuditDate(params.Since, false)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return store.InstanceAuditFilter{}, err
|
|
|
|
|
}
|
|
|
|
|
until, err := parseAuditDate(params.Until, true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return store.InstanceAuditFilter{}, err
|
|
|
|
|
}
|
|
|
|
|
filter.Since = since
|
|
|
|
|
filter.Until = until
|
|
|
|
|
return filter, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parseAuditDate читает дату фильтра в формате YYYY-MM-DD (UTC). Для «по дату»
|
|
|
|
|
// берётся конец дня: иначе запись за 20 сентября не попала бы в выборку
|
|
|
|
|
// «по 20 сентября» (время в базе хранится с точностью до миллисекунд).
|
|
|
|
|
func parseAuditDate(value string, endOfDay bool) (*time.Time, error) {
|
|
|
|
|
trimmed := strings.TrimSpace(value)
|
|
|
|
|
if trimmed == "" {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
parsed, err := time.Parse("2006-01-02", trimmed)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "date must be YYYY-MM-DD")
|
|
|
|
|
}
|
|
|
|
|
if endOfDay {
|
|
|
|
|
parsed = parsed.Add(24*time.Hour - time.Millisecond)
|
|
|
|
|
}
|
|
|
|
|
return &parsed, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// instanceStatsPayload дополняет выборку из store размерами хранилища: размер
|
|
|
|
|
// базы и файлов виден только серверу (AGENT.md 7.18).
|
|
|
|
|
func (s *Server) instanceStatsPayload(stats *store.InstanceStats) instanceStatsPayload {
|
|
|
|
|
payload := instanceStatsPayload{
|
|
|
|
|
Guilds: stats.Guilds,
|
|
|
|
|
Invites: stats.Invites,
|
|
|
|
|
Bans: stats.Bans,
|
|
|
|
|
}
|
|
|
|
|
payload.Users.Total = stats.Users.Total
|
|
|
|
|
payload.Users.Admins = stats.Users.Admins
|
|
|
|
|
payload.Users.Banned = stats.Users.Banned
|
|
|
|
|
payload.Users.New7Days = stats.Users.New7Days
|
|
|
|
|
payload.Users.New30Days = stats.Users.New30Days
|
|
|
|
|
payload.Users.Active24h = stats.Users.Active24h
|
|
|
|
|
payload.Users.Daily = dailyCountsPayload(stats.Users.Daily)
|
|
|
|
|
payload.Messages.Total = stats.Messages.Total
|
|
|
|
|
payload.Messages.Today = stats.Messages.Today
|
|
|
|
|
payload.Messages.Week = stats.Messages.Week
|
|
|
|
|
payload.Messages.Month = stats.Messages.Month
|
|
|
|
|
payload.Messages.Daily = dailyCountsPayload(stats.Messages.Daily)
|
|
|
|
|
payload.Files.Count = stats.Files.Count
|
|
|
|
|
payload.Files.Bytes = stats.Files.Bytes
|
|
|
|
|
payload.TopGuilds = guildStatsPayload(stats.TopGuilds)
|
|
|
|
|
payload.BusiestGuilds = guildStatsPayload(stats.BusiestGuilds)
|
|
|
|
|
database := sysinfo.FileBytes(s.databasePath())
|
|
|
|
|
database += sysinfo.FileBytes(s.databasePath() + "-wal")
|
|
|
|
|
payload.DatabaseBytes = database
|
|
|
|
|
payload.StorageBytes = database + stats.Files.Bytes
|
|
|
|
|
return payload
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func dailyCountsPayload(counts []store.DailyCount) []dailyCountPayload {
|
|
|
|
|
payload := make([]dailyCountPayload, 0, len(counts))
|
|
|
|
|
for _, item := range counts {
|
|
|
|
|
payload = append(payload, dailyCountPayload{Date: item.Date, Count: item.Count})
|
|
|
|
|
}
|
|
|
|
|
return payload
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func guildStatsPayload(guilds []store.InstanceGuildStat) []instanceGuildStatPayload {
|
|
|
|
|
payload := make([]instanceGuildStatPayload, 0, len(guilds))
|
|
|
|
|
for _, item := range guilds {
|
|
|
|
|
payload = append(payload, instanceGuildStatPayload{
|
|
|
|
|
ID: formatSnowflake(item.ID), Name: item.Name,
|
|
|
|
|
Members: item.Members, Messages: item.Messages,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return payload
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// instanceUserCard собирает карточку пользователя для админ-панели: профиль,
|
|
|
|
|
// активные устройства, серверы с ролями, события безопасности и аудит, где он
|
|
|
|
|
// актор или цель (AGENT.md 7.18, 7.19).
|
|
|
|
|
func (s *Server) instanceUserCard(ctx context.Context, target *store.User) (*instanceUserCardOutput, error) {
|
|
|
|
|
output := &instanceUserCardOutput{}
|
|
|
|
|
output.Body.User = instanceUserPayloadFrom(*target)
|
|
|
|
|
output.Body.Sessions = []instanceSessionPayload{}
|
|
|
|
|
output.Body.Guilds = []instanceMembershipPayload{}
|
|
|
|
|
output.Body.SecurityEvents = []securityEventPayload{}
|
|
|
|
|
|
|
|
|
|
sessions, err := s.store.ListSessions(ctx, target.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
for _, session := range sessions {
|
|
|
|
|
output.Body.Sessions = append(output.Body.Sessions, instanceSessionPayload{
|
|
|
|
|
ID: formatSnowflake(session.ID),
|
|
|
|
|
UserAgent: session.UserAgent,
|
|
|
|
|
IP: session.IP,
|
|
|
|
|
CreatedAt: session.CreatedAt.UTC().Format(time.RFC3339),
|
|
|
|
|
LastSeen: session.LastSeen.UTC().Format(time.RFC3339),
|
|
|
|
|
ExpiresAt: session.ExpiresAt.UTC().Format(time.RFC3339),
|
|
|
|
|
SteppedUp: session.SteppedUp(time.Now().UTC()),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
memberships, err := s.store.ListUserGuildMemberships(ctx, target.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
for _, membership := range memberships {
|
|
|
|
|
item := instanceMembershipPayload{
|
|
|
|
|
GuildID: formatSnowflake(membership.GuildID),
|
|
|
|
|
GuildName: membership.GuildName,
|
|
|
|
|
Nickname: membership.Nickname,
|
|
|
|
|
JoinedAt: membership.JoinedAt.UTC().Format(time.RFC3339),
|
|
|
|
|
Roles: []instanceMembershipRolePayload{},
|
|
|
|
|
}
|
|
|
|
|
for _, role := range membership.Roles {
|
|
|
|
|
item.Roles = append(item.Roles, instanceMembershipRolePayload{
|
|
|
|
|
ID: formatSnowflake(role.ID), Name: role.Name, Color: role.Color,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
output.Body.Guilds = append(output.Body.Guilds, item)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
events, err := s.store.ListSecurityEvents(ctx, target.ID, securityEventsLimit)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
for _, event := range events {
|
|
|
|
|
output.Body.SecurityEvents = append(output.Body.SecurityEvents, securityEventPayload{
|
|
|
|
|
ID: formatSnowflake(event.ID),
|
|
|
|
|
Type: event.Type,
|
|
|
|
|
IP: event.IP,
|
|
|
|
|
UserAgent: event.UserAgent,
|
|
|
|
|
Metadata: event.Metadata,
|
|
|
|
|
CreatedAt: event.CreatedAt.UTC().Format(time.RFC3339),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
entries, total, err := s.store.ListUserAudit(ctx, target.ID, userAuditLimit, 0)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
output.Body.Audit = auditPayloads(entries)
|
|
|
|
|
output.Body.AuditTotal = total
|
|
|
|
|
|
|
|
|
|
passkeys, err := s.store.CountPasskeys(ctx, target.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
output.Body.Passkeys = passkeys
|
|
|
|
|
if secret, err := s.store.GetTOTPSecret(ctx, target.ID); err == nil {
|
|
|
|
|
output.Body.TOTPEnabled = secret.Enabled
|
|
|
|
|
} else if !errors.Is(err, store.ErrNotFound) {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
return output, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// registerInstanceExportRoutes описывает выгрузку журнала инстанса: ответ —
|
|
|
|
|
// CSV, а не JSON, поэтому ручка живёт на роутере chi, а не в huma.
|
|
|
|
|
func (s *Server) registerInstanceExportRoutes(router chi.Router) {
|
|
|
|
|
router.Get("/instance/audit/export", s.handleInstanceAuditExport)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleInstanceAuditExport отдаёт журнал инстанса файлом CSV с теми же
|
|
|
|
|
// фильтрами, что и раздел «Аудит» (AGENT.md 7.18). Предел выгрузки — 5000
|
|
|
|
|
// строк, иначе один запрос вычитывал бы весь журнал в память.
|
|
|
|
|
func (s *Server) handleInstanceAuditExport(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
user, _, ok := s.authenticate(w, r)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !user.IsInstanceAdmin {
|
|
|
|
|
httpxWriteJSONError(w, http.StatusForbidden, "instance.admin_required", "instance administrator rights required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if allowed, retryAfter := s.auditExportLimiter.Allow("audit-export:" + formatSnowflake(user.ID)); !allowed {
|
|
|
|
|
writeHumaAPIError(w, rateLimitedError(retryAfter))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
query := r.URL.Query()
|
|
|
|
|
filter, err := auditFilterFromQuery(auditQueryParams{
|
|
|
|
|
Action: query.Get("action"),
|
|
|
|
|
ActorID: query.Get("actor_id"),
|
|
|
|
|
TargetID: query.Get("target_id"),
|
|
|
|
|
GuildID: query.Get("guild_id"),
|
|
|
|
|
Since: query.Get("since"),
|
|
|
|
|
Until: query.Get("until"),
|
|
|
|
|
Limit: store.MaxInstanceAuditLimit,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeHumaAPIError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
entries, _, err := s.store.ListInstanceAuditFiltered(r.Context(), filter)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeHumaAPIError(w, humaError(err))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
|
|
|
w.Header().Set("Content-Disposition",
|
|
|
|
|
`attachment; filename="audit-`+time.Now().UTC().Format("20060102-150405")+`.csv"`)
|
|
|
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
|
|
|
|
|
|
|
|
writer := csv.NewWriter(w)
|
|
|
|
|
write := func(row []string) {
|
|
|
|
|
safe := make([]string, 0, len(row))
|
|
|
|
|
for _, value := range row {
|
|
|
|
|
safe = append(safe, csvSafe(value))
|
|
|
|
|
}
|
|
|
|
|
_ = writer.Write(safe)
|
|
|
|
|
}
|
|
|
|
|
write([]string{
|
|
|
|
|
"id", "created_at", "action", "actor_id", "actor_instance_admin",
|
|
|
|
|
"target_type", "target_id", "guild_id", "reason", "changes",
|
|
|
|
|
})
|
|
|
|
|
for _, entry := range entries {
|
|
|
|
|
write([]string{
|
|
|
|
|
formatSnowflake(entry.ID),
|
|
|
|
|
entry.CreatedAt.UTC().Format(time.RFC3339),
|
|
|
|
|
entry.Action,
|
|
|
|
|
optionalSnowflake(entry.ActorID),
|
|
|
|
|
strconv.FormatBool(entry.ActorInstanceAdmin),
|
|
|
|
|
entry.TargetType,
|
|
|
|
|
optionalSnowflake(entry.TargetID),
|
|
|
|
|
optionalSnowflake(entry.GuildID),
|
|
|
|
|
entry.Reason,
|
|
|
|
|
string(entry.Changes),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
writer.Flush()
|
|
|
|
|
if err := writer.Error(); err != nil {
|
|
|
|
|
s.logger.WarnContext(r.Context(), "audit export failed", slog.Any("error", err))
|
|
|
|
|
}
|
|
|
|
|
s.recordAudit(r.Context(), user, 0, "instance.audit_export", "instance", nil, "")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// csvSafe защищает выгрузку от формул Excel: значения, начинающиеся с «=»,
|
|
|
|
|
// «+», «-» или «@», таблица воспринимает как формулу (CSV injection).
|
|
|
|
|
func csvSafe(value string) string {
|
|
|
|
|
if value == "" {
|
|
|
|
|
return value
|
|
|
|
|
}
|
|
|
|
|
switch value[0] {
|
|
|
|
|
case '=', '+', '-', '@':
|
|
|
|
|
return "'" + value
|
|
|
|
|
default:
|
|
|
|
|
return value
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// optionalSnowflake печатает необязательный идентификатор.
|
|
|
|
|
func optionalSnowflake(id *uint64) string {
|
|
|
|
|
if id == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return formatSnowflake(*id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// recordSecurityEvent пишет событие безопасности по действию администратора
|
|
|
|
|
// инстанса: владелец аккаунта должен видеть в разделе безопасности, что его
|
|
|
|
|
// устройство отозвали или сбросили второй фактор (AGENT.md 7.1, 7.19). Ошибка
|
|
|
|
|
// записи не ломает основное действие.
|
|
|
|
|
func (s *Server) recordSecurityEvent(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
userID *uint64,
|
|
|
|
|
eventType string,
|
|
|
|
|
session *store.Session,
|
|
|
|
|
metadata map[string]any,
|
|
|
|
|
) {
|
|
|
|
|
encoded := "{}"
|
|
|
|
|
if len(metadata) > 0 {
|
|
|
|
|
if raw, err := json.Marshal(metadata); err == nil {
|
|
|
|
|
encoded = string(raw)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
var ip, userAgent string
|
|
|
|
|
if session != nil {
|
|
|
|
|
ip, userAgent = session.IP, session.UserAgent
|
|
|
|
|
}
|
|
|
|
|
if err := s.store.RecordSecurityEvent(ctx, userID, eventType, ip, userAgent, encoded); err != nil {
|
|
|
|
|
s.logger.WarnContext(ctx, "failed to record security event", slog.Any("error", err))
|
|
|
|
|
}
|
|
|
|
|
}
|