3dc200c196
Статистика, карточка пользователя и работа с журналом для администратора
инстанса (AGENT.md 3.2, 7.18):
- GET /instance/stats: рост пользователей и сообщений по дням, активность,
размеры базы и файлов, топы серверов по участникам и сообщениям;
- GET /instance/users/{id}: профиль, активные устройства, серверы с ролями,
события безопасности и аудит по пользователю;
- DELETE /instance/users/{id}/sessions/{sid} и POST .../reset-2fa: отзыв
одного устройства и сброс второго фактора со step-up, аудитом и записью
в события безопасности; чужой ключ администратора не сбрасывается;
- журнал инстанса: фильтры по действию, актору, цели, серверу и датам,
пагинация с общим числом, список действий и выгрузка CSV (лимит 5/мин);
- список серверов: поиск по названию, владелец, главный сервер, пагинация;
- миграция 00025: нормализованное название сервера `name_lower` — SQLite
lower() не знает кириллицу, поэтому регистр приводит приложение (как для
текста сообщений), старые записи дополняются backfill'ом при старте.
1527 lines
55 KiB
Go
1527 lines
55 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"glchat/internal/bootstrap"
|
|
"glchat/internal/store"
|
|
"glchat/internal/sysinfo"
|
|
)
|
|
|
|
// 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"`
|
|
// Total — сколько серверов попадает в фильтр: панель листает страницы
|
|
// (AGENT.md 7.18).
|
|
Total int `json:"total"`
|
|
}
|
|
}
|
|
|
|
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"`
|
|
// Глобальный бан инстанса (AGENT.md 7.18).
|
|
Banned bool `json:"banned"`
|
|
BannedAt string `json:"banned_at,omitempty"`
|
|
BanReason string `json:"ban_reason,omitempty"`
|
|
}
|
|
|
|
type instanceUserListOutput struct {
|
|
Body struct {
|
|
Users []instanceUserPayload `json:"users"`
|
|
Total int `json:"total"`
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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"`
|
|
// Лимиты медиа в байтах (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"`
|
|
}
|
|
}
|
|
|
|
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 = instanceSettingsFromStore(settings)
|
|
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"`
|
|
// Лимиты медиа: 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"`
|
|
}
|
|
},
|
|
) (*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 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)
|
|
}
|
|
}
|
|
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 = instanceSettingsFromStore(settings)
|
|
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, 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) {
|
|
if _, err := requireInstanceAdmin(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
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,
|
|
})
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
output := &instanceGuildListOutput{}
|
|
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),
|
|
})
|
|
}
|
|
output.Body.Total = total
|
|
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 {
|
|
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"`
|
|
},
|
|
) (*instanceUserListOutput, error) {
|
|
if _, err := requireInstanceAdmin(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
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)
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
output := &instanceUserListOutput{}
|
|
output.Body.Users = make([]instanceUserPayload, 0, len(users))
|
|
for _, user := range users {
|
|
output.Body.Users = append(output.Body.Users, instanceUserPayloadFrom(user))
|
|
}
|
|
output.Body.Total = total
|
|
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: "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)
|
|
}
|
|
if s.gateway != nil {
|
|
s.gateway.InvalidateUser(target.ID, "password_reset")
|
|
}
|
|
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)
|
|
}
|
|
if s.gateway != nil {
|
|
s.gateway.InvalidateUser(targetID, "logout_all")
|
|
}
|
|
s.recordAudit(ctx, admin, 0, "instance.user_logout", "user", &targetID, "")
|
|
return newOKOutput(), nil
|
|
})
|
|
|
|
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 {
|
|
s.gateway.InvalidateUser(target.ID, "user_banned")
|
|
}
|
|
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
|
|
})
|
|
|
|
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 {
|
|
s.gateway.InvalidateUser(targetID, "user_deleted")
|
|
}
|
|
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
|
|
})
|
|
|
|
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 {
|
|
// Фильтры журнала (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"`
|
|
},
|
|
) (*instanceAuditOutput, error) {
|
|
if _, err := requireInstanceAdmin(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
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)
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
output := &instanceAuditOutput{}
|
|
output.Body.Entries = auditPayloads(entries)
|
|
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
|
|
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,
|
|
// Признак «голос настроен»: клиент берёт его из /meta, но контракт
|
|
// публичной информации об инстансе (AGENT.md 6.5) обещает и здесь.
|
|
VoiceEnabled: s.cfg.VoiceEnabled(),
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|