feat(instance): админ-панель с лимитами медиа и действиями над пользователями
- лимиты медиа живут в настройках инстанса (миграция 00016): аватары, оформление сервера, эмодзи, звуки и галерея; все загрузки берут предел оттуда, значения по умолчанию — из AGENT.md 7.7 - действия администратора: временный пароль (показывается один раз, сессии отзываются), выход со всех устройств, мягкое удаление пользователя (сообщения и аудит остаются), переименование и удаление любого сервера - админ-панель разбита на вкладки: обзор и здоровье, лимиты, пользователи, серверы, аудит; смена администраторов подтверждается личностью (step-up) - тесты: Go (действия администратора, лимит эмодзи из настроек) и Vitest (вкладки, сброс пароля, выход, удаление, переименование сервера)
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
-- +goose Up
|
||||
-- Глобальные лимиты медиа (AGENT.md 7.7): меняются в админ-панели инстанса и
|
||||
-- действуют на все сервера. Вложения регулируются профилем производительности
|
||||
-- (MAX_UPLOAD_SIZE), поэтому здесь их нет.
|
||||
INSERT INTO instance_settings (key, value) VALUES
|
||||
('max_avatar_size', '5242880'),
|
||||
('max_guild_image_size', '5242880'),
|
||||
('max_emoji_size', '524288'),
|
||||
('max_sound_size', '524288'),
|
||||
('max_cosmetic_size', '5242880')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM instance_settings WHERE key IN
|
||||
('max_avatar_size', 'max_guild_image_size', 'max_emoji_size', 'max_sound_size', 'max_cosmetic_size');
|
||||
@@ -22,8 +22,6 @@ import (
|
||||
const (
|
||||
// maxCosmeticsPerGuild — предел галереи сервера.
|
||||
maxCosmeticsPerGuild = 50
|
||||
// maxCosmeticSize — предел размера картинки оформления.
|
||||
maxCosmeticSize = 5 << 20
|
||||
// maxCosmeticNameLength — длина имени элемента галереи.
|
||||
maxCosmeticNameLength = 40
|
||||
// scopeGlobal — область личного стиля «для друзей».
|
||||
@@ -255,18 +253,19 @@ func (s *Server) handleCosmeticUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
limit := s.mediaLimit(ctx, mediaCosmetic)
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
if header.Size > maxCosmeticSize {
|
||||
if header.Size > limit {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "cosmetic is too large")
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxCosmeticSize+1))
|
||||
if err != nil || int64(len(data)) > maxCosmeticSize {
|
||||
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
||||
if err != nil || int64(len(data)) > limit {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "cosmetic is too large")
|
||||
return
|
||||
}
|
||||
@@ -757,9 +756,6 @@ func optionalString(value *string) string {
|
||||
return *value
|
||||
}
|
||||
|
||||
// maxChannelBackgroundSize — предел размера фона комнаты (AGENT.md 7.7).
|
||||
const maxChannelBackgroundSize = 5 << 20
|
||||
|
||||
// registerChannelBackgroundRoutes описывает фон комнаты (AGENT.md 7.5, 7.7):
|
||||
// картинка за лентой сообщений, право MANAGE_CHANNEL_BACKGROUND.
|
||||
func (s *Server) registerChannelBackgroundRoutes(router chi.Router) {
|
||||
@@ -779,18 +775,19 @@ func (s *Server) handleChannelBackgroundUpload(w http.ResponseWriter, r *http.Re
|
||||
writeHumaAPIError(w, err)
|
||||
return
|
||||
}
|
||||
limit := s.mediaLimit(ctx, mediaGuildImage)
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
if header.Size > maxChannelBackgroundSize {
|
||||
if header.Size > limit {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "background is too large")
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxChannelBackgroundSize+1))
|
||||
if err != nil || int64(len(data)) > maxChannelBackgroundSize {
|
||||
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
||||
if err != nil || int64(len(data)) > limit {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "background is too large")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
|
||||
// Лимиты кастомных эмодзи (AGENT.md 7.12).
|
||||
const (
|
||||
maxEmojiSize = 512 << 10
|
||||
maxEmojisPerGuild = 100
|
||||
)
|
||||
|
||||
@@ -202,7 +201,8 @@ func (s *Server) handleEmojiUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxEmojiSize+maxMultipartOverhead)
|
||||
limit := s.mediaLimit(ctx, mediaEmoji)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit+maxMultipartOverhead)
|
||||
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader
|
||||
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body")
|
||||
return
|
||||
@@ -225,12 +225,12 @@ func (s *Server) handleEmojiUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
if header.Size > maxEmojiSize {
|
||||
if header.Size > limit {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "эмодзи больше 512 КБ")
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxEmojiSize+1))
|
||||
if err != nil || len(data) > maxEmojiSize {
|
||||
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
||||
if err != nil || int64(len(data)) > limit {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "эмодзи больше 512 КБ")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -28,8 +28,6 @@ const (
|
||||
maxMultipartOverhead = 1 << 20
|
||||
// maxMultipartMemory — сколько multipart держим в памяти, остальное — на диске.
|
||||
maxMultipartMemory = 8 << 20
|
||||
// maxAvatarSize — предел размера аватара (AGENT.md 7.2).
|
||||
maxAvatarSize = 8 << 20
|
||||
)
|
||||
|
||||
// uploadPayload — результат загрузки файла: метаданные для вложения.
|
||||
@@ -86,7 +84,8 @@ func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAvatarSize+maxMultipartOverhead)
|
||||
limit := s.mediaLimit(ctx, mediaAvatar)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit+maxMultipartOverhead)
|
||||
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader
|
||||
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body")
|
||||
return
|
||||
@@ -102,14 +101,14 @@ func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
if header.Size > maxAvatarSize {
|
||||
if header.Size > limit {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "avatar is too large")
|
||||
return
|
||||
}
|
||||
// Аватар читаем в память (не больше 8 МБ): нужно проверить, что это
|
||||
// действительно изображение, а не переименованный файл (AGENT.md 9.2).
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxAvatarSize+1))
|
||||
if err != nil || int64(len(data)) > maxAvatarSize {
|
||||
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
||||
if err != nil || int64(len(data)) > limit {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "avatar is too large")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -16,9 +16,6 @@ import (
|
||||
// страницы приглашения и акцентный цвет. Файлы хранятся обычными загрузками с
|
||||
// отдельными purpose, поэтому анимированные GIF/APNG/WebP не теряют анимацию.
|
||||
|
||||
// maxGuildImageSize — предел размера для иконки, баннера и splash.
|
||||
const maxGuildImageSize = 8 << 20
|
||||
|
||||
type appearanceKind struct {
|
||||
// field — колонка гильдии, purpose — назначение файла.
|
||||
field string
|
||||
@@ -65,20 +62,21 @@ func (s *Server) handleGuildAppearanceUpload(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
limit := s.mediaLimit(ctx, mediaGuildImage)
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
if header.Size > maxGuildImageSize {
|
||||
if header.Size > limit {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", kind.label+" is too large")
|
||||
return
|
||||
}
|
||||
// Читаем в память (не больше 8 МБ): нужно убедиться, что это изображение,
|
||||
// а не переименованный файл (AGENT.md 9.2).
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxGuildImageSize+1))
|
||||
if err != nil || int64(len(data)) > maxGuildImageSize {
|
||||
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
||||
if err != nil || int64(len(data)) > limit {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", kind.label+" is too large")
|
||||
return
|
||||
}
|
||||
|
||||
+230
-14
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -72,6 +73,36 @@ type instanceSettingsPayload struct {
|
||||
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 {
|
||||
@@ -122,13 +153,7 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
output := &instanceSettingsOutput{}
|
||||
output.Body.Settings = instanceSettingsPayload{
|
||||
RegistrationEnabled: settings.RegistrationEnabled,
|
||||
AllowGuildCreation: settings.AllowGuildCreation,
|
||||
MaxGuildsPerUser: settings.MaxGuildsPerUser,
|
||||
MaxMembersPerGuild: settings.MaxMembersPerGuild,
|
||||
MaxMessageLength: settings.MaxMessageLength,
|
||||
}
|
||||
output.Body.Settings = instanceSettingsFromStore(settings)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
@@ -146,6 +171,12 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
||||
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) {
|
||||
@@ -169,6 +200,17 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
||||
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)
|
||||
@@ -180,13 +222,7 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
||||
}
|
||||
s.recordAudit(ctx, user, 0, "instance.settings_update", "instance", nil, "")
|
||||
output := &instanceSettingsOutput{}
|
||||
output.Body.Settings = instanceSettingsPayload{
|
||||
RegistrationEnabled: settings.RegistrationEnabled,
|
||||
AllowGuildCreation: settings.AllowGuildCreation,
|
||||
MaxGuildsPerUser: settings.MaxGuildsPerUser,
|
||||
MaxMembersPerGuild: settings.MaxMembersPerGuild,
|
||||
MaxMessageLength: settings.MaxMessageLength,
|
||||
}
|
||||
output.Body.Settings = instanceSettingsFromStore(settings)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
@@ -392,6 +428,166 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
s.recordAudit(ctx, admin, 0, "instance.user_logout", "user", &targetID, "")
|
||||
return newOKOutput(), 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.SendToUser(targetID, "SESSION_INVALIDATED", map[string]any{"reason": "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,
|
||||
@@ -473,3 +669,23 @@ func (s *Server) createGuildAsAdmin(ctx context.Context, admin, owner *store.Use
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -442,9 +442,6 @@ func newInviteCode() (string, error) {
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
// maxInviteBackgroundSize — предел размера переопределения splash (AGENT.md 7.7).
|
||||
const maxInviteBackgroundSize = 5 << 20
|
||||
|
||||
// registerInviteBackgroundRoutes описывает картинку приглашения: создатель
|
||||
// приглашения или MANAGE_GUILD сервера может задать своё оформление страницы
|
||||
// (AGENT.md 7.9). Ручка multipart, поэтому живёт на роутере.
|
||||
@@ -464,18 +461,19 @@ func (s *Server) handleInviteBackgroundUpload(w http.ResponseWriter, r *http.Req
|
||||
writeHumaAPIError(w, err)
|
||||
return
|
||||
}
|
||||
limit := s.mediaLimit(ctx, mediaGuildImage)
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
if header.Size > maxInviteBackgroundSize {
|
||||
if header.Size > limit {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "invite background is too large")
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxInviteBackgroundSize+1))
|
||||
if err != nil || int64(len(data)) > maxInviteBackgroundSize {
|
||||
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
||||
if err != nil || int64(len(data)) > limit {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "invite background is too large")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
|
||||
// Лимиты звуков сервера (AGENT.md 7.13).
|
||||
const (
|
||||
maxSoundSize = 512 << 10
|
||||
maxSoundboardSounds = 30
|
||||
maxUISounds = 30
|
||||
)
|
||||
@@ -275,7 +274,8 @@ func (s *Server) handleSoundUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxSoundSize+maxMultipartOverhead)
|
||||
sizeLimit := s.mediaLimit(ctx, mediaSound)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, sizeLimit+maxMultipartOverhead)
|
||||
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader
|
||||
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body")
|
||||
return
|
||||
@@ -306,12 +306,12 @@ func (s *Server) handleSoundUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
if header.Size > maxSoundSize {
|
||||
if header.Size > sizeLimit {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "звук больше 512 КБ")
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxSoundSize+1))
|
||||
if err != nil || len(data) > maxSoundSize {
|
||||
data, err := io.ReadAll(io.LimitReader(file, sizeLimit+1))
|
||||
if err != nil || int64(len(data)) > sizeLimit {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "звук больше 512 КБ")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -804,3 +804,144 @@ func TestStaticServesPWAAssets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceAdminActions(t *testing.T) {
|
||||
srv, admin, member, guildID, memberID := cosmeticsFixture(t)
|
||||
// Первый зарегистрированный пользователь — администратор инстанса.
|
||||
promoteAdmin(t, srv, "ban-owner@example.com")
|
||||
|
||||
// Переименование чужого сервера администратором.
|
||||
renamed := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/guilds/"+guildID,
|
||||
`{"name":"Новое имя","description":"Описание"}`, admin)
|
||||
if renamed.Code != http.StatusOK {
|
||||
t.Fatalf("переименование сервера = %d (%s)", renamed.Code, renamed.Body.String())
|
||||
}
|
||||
detail := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID, "", admin)
|
||||
guild := decodeResponse[struct {
|
||||
Guild struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
} `json:"guild"`
|
||||
}](t, detail).Guild
|
||||
if guild.Name != "Новое имя" || guild.Description != "Описание" {
|
||||
t.Fatalf("сервер не обновлён: %+v", guild)
|
||||
}
|
||||
|
||||
// Сначала проверим, что обычный участник админ-ручки не трогает: дальше его
|
||||
// сессия будет отозвана сбросом пароля.
|
||||
if rec := doJSON(t, srv, http.MethodDelete, "/api/v1/instance/users/"+memberID, "", member); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("удаление без прав = %d, ожидался 403 (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Сброс пароля участнику: пароль выдаётся один раз, сессии отзываются.
|
||||
before := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
|
||||
`{"email":"ban-member@example.com","password":"correct-horse-battery"}`)
|
||||
if before.Code != http.StatusOK {
|
||||
t.Fatalf("вход до сброса = %d (%s)", before.Code, before.Body.String())
|
||||
}
|
||||
reset := doJSON(t, srv, http.MethodPost,
|
||||
"/api/v1/instance/users/"+memberID+"/reset-password", "", admin)
|
||||
if reset.Code != http.StatusOK {
|
||||
t.Fatalf("сброс пароля = %d (%s)", reset.Code, reset.Body.String())
|
||||
}
|
||||
password := decodeResponse[struct {
|
||||
Password string `json:"password"`
|
||||
SessionsRevoked int64 `json:"sessions_revoked"`
|
||||
}](t, reset)
|
||||
if len(password.Password) < 12 {
|
||||
t.Fatalf("временный пароль слишком короткий: %q", password.Password)
|
||||
}
|
||||
if password.SessionsRevoked < 1 {
|
||||
t.Fatalf("сессии не отозваны: %d", password.SessionsRevoked)
|
||||
}
|
||||
// Старый пароль больше не работает, новый — работает.
|
||||
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
|
||||
`{"email":"ban-member@example.com","password":"correct-horse-battery"}`); rec.Code == http.StatusOK {
|
||||
t.Fatal("старый пароль продолжает работать")
|
||||
}
|
||||
relogin := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
|
||||
`{"email":"ban-member@example.com","password":"`+password.Password+`"}`)
|
||||
if relogin.Code != http.StatusOK {
|
||||
t.Fatalf("вход с новым паролем = %d (%s)", relogin.Code, relogin.Body.String())
|
||||
}
|
||||
|
||||
// Выход со всех устройств.
|
||||
logout := doJSON(t, srv, http.MethodPost, "/api/v1/instance/users/"+memberID+"/logout", "", admin)
|
||||
if logout.Code != http.StatusOK {
|
||||
t.Fatalf("выход со всех устройств = %d (%s)", logout.Code, logout.Body.String())
|
||||
}
|
||||
if rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", member); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("сессия участника жива: %d", rec.Code)
|
||||
}
|
||||
|
||||
// Администратор удаляет пользователя: мягкое удаление (AGENT.md 6.4).
|
||||
deleted := doJSON(t, srv, http.MethodDelete, "/api/v1/instance/users/"+memberID, "", admin)
|
||||
if deleted.Code != http.StatusOK {
|
||||
t.Fatalf("удаление пользователя = %d (%s)", deleted.Code, deleted.Body.String())
|
||||
}
|
||||
users := doJSON(t, srv, http.MethodGet, "/api/v1/instance/users", "", admin)
|
||||
list := decodeResponse[struct {
|
||||
Users []struct {
|
||||
UserID string `json:"id"`
|
||||
} `json:"users"`
|
||||
}](t, users)
|
||||
for _, user := range list.Users {
|
||||
if user.UserID == memberID {
|
||||
t.Fatal("удалённый пользователь остался в списке")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceMediaLimitsEnforced(t *testing.T) {
|
||||
srv, admin, _, guildID, _ := cosmeticsFixture(t)
|
||||
promoteAdmin(t, srv, "ban-owner@example.com")
|
||||
|
||||
// Ограничиваем эмодзи 64 КБ и пробуем загрузить картинку больше лимита.
|
||||
patched := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings",
|
||||
`{"max_emoji_size":65536}`, admin)
|
||||
if patched.Code != http.StatusOK {
|
||||
t.Fatalf("настройки = %d (%s)", patched.Code, patched.Body.String())
|
||||
}
|
||||
settings := doJSON(t, srv, http.MethodGet, "/api/v1/instance/settings", "", admin)
|
||||
payload := decodeResponse[struct {
|
||||
Settings struct {
|
||||
MaxEmojiSize int64 `json:"max_emoji_size"`
|
||||
} `json:"settings"`
|
||||
}](t, settings)
|
||||
if payload.Settings.MaxEmojiSize != 65536 {
|
||||
t.Fatalf("лимит эмодзи = %d", payload.Settings.MaxEmojiSize)
|
||||
}
|
||||
|
||||
big := make([]byte, 70<<10)
|
||||
copy(big, pngBytes())
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
if err := writer.WriteField("name", "big_emoji"); err != nil {
|
||||
t.Fatalf("multipart name: %v", err)
|
||||
}
|
||||
part, err := writer.CreateFormFile("file", "big.png")
|
||||
if err != nil {
|
||||
t.Fatalf("multipart file: %v", err)
|
||||
}
|
||||
if _, err := part.Write(big); err != nil {
|
||||
t.Fatalf("multipart write: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("multipart close: %v", err)
|
||||
}
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||
"/api/v1/guilds/"+guildID+"/emojis", &buf)
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
request.AddCookie(admin)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, request)
|
||||
if rec.Code != http.StatusRequestEntityTooLarge && rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("загрузка сверх лимита = %d, ожидался отказ (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Возвращаем лимит по умолчанию.
|
||||
if rec := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings",
|
||||
`{"max_emoji_size":524288}`, admin); rec.Code != http.StatusOK {
|
||||
t.Fatalf("возврат лимита = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// Лимиты медиа (AGENT.md 7.7): значения живут в настройках инстанса и меняются
|
||||
// в админ-панели, а обработчики загрузок спрашивают их здесь.
|
||||
|
||||
// Пределы по умолчанию (AGENT.md 7.7): 5 МБ — аватары, баннеры и оформление,
|
||||
// 512 КБ — эмодзи и звуки. Администратор инстанса меняет их в админ-панели.
|
||||
const (
|
||||
maxAvatarSize int64 = 5 << 20
|
||||
maxGuildImageSize int64 = 5 << 20
|
||||
maxEmojiSize int64 = 512 << 10
|
||||
maxSoundSize int64 = 512 << 10
|
||||
maxCosmeticSize int64 = 5 << 20
|
||||
)
|
||||
|
||||
// mediaKind — вид загрузки, для которого действует свой лимит.
|
||||
type mediaKind string
|
||||
|
||||
const (
|
||||
mediaAvatar mediaKind = "avatar"
|
||||
mediaGuildImage mediaKind = "guild_image"
|
||||
mediaEmoji mediaKind = "emoji"
|
||||
mediaSound mediaKind = "sound"
|
||||
mediaCosmetic mediaKind = "cosmetic"
|
||||
)
|
||||
|
||||
// mediaLimit возвращает предел размера файла в байтах. Настройки читаются на
|
||||
// каждую загрузку: админ мог поменять лимит только что, а кэш настроек здесь не
|
||||
// нужен — запрос к базе дешёвый.
|
||||
func (s *Server) mediaLimit(ctx context.Context, kind mediaKind) int64 {
|
||||
fallback := defaultMediaLimit(kind)
|
||||
if s.store == nil {
|
||||
return fallback
|
||||
}
|
||||
settings, err := s.store.InstanceSettings(ctx)
|
||||
if err != nil {
|
||||
s.logger.WarnContext(ctx, "failed to read media limits", slog.Any("error", err))
|
||||
return fallback
|
||||
}
|
||||
switch kind {
|
||||
case mediaAvatar:
|
||||
return settings.MaxAvatarSize
|
||||
case mediaGuildImage:
|
||||
return settings.MaxGuildImageSize
|
||||
case mediaEmoji:
|
||||
return settings.MaxEmojiSize
|
||||
case mediaSound:
|
||||
return settings.MaxSoundSize
|
||||
case mediaCosmetic:
|
||||
return settings.MaxCosmeticSize
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
// defaultMediaLimit — значения по умолчанию, если настройка недоступна:
|
||||
// именованные константы рядом с обработчиками задают те же пределы.
|
||||
func defaultMediaLimit(kind mediaKind) int64 {
|
||||
switch kind {
|
||||
case mediaAvatar:
|
||||
return maxAvatarSize
|
||||
case mediaGuildImage:
|
||||
return maxGuildImageSize
|
||||
case mediaEmoji:
|
||||
return maxEmojiSize
|
||||
case mediaSound:
|
||||
return maxSoundSize
|
||||
case mediaCosmetic:
|
||||
return maxCosmeticSize
|
||||
default:
|
||||
return maxGuildImageSize
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -18,9 +19,24 @@ type InstanceSettings struct {
|
||||
MaxMembersPerGuild int
|
||||
MaxMessageLength int
|
||||
AuditRetentionDays int
|
||||
raw map[string]string
|
||||
// Лимиты медиа в байтах (AGENT.md 7.7): 0 — значение по умолчанию.
|
||||
MaxAvatarSize int64
|
||||
MaxGuildImageSize int64
|
||||
MaxEmojiSize int64
|
||||
MaxSoundSize int64
|
||||
MaxCosmeticSize int64
|
||||
raw map[string]string
|
||||
}
|
||||
|
||||
// MediaLimits по умолчанию: совпадают со спецификацией 7.7.
|
||||
const (
|
||||
DefaultAvatarSize int64 = 5 << 20
|
||||
DefaultGuildImageSize int64 = 5 << 20
|
||||
DefaultEmojiSize int64 = 512 << 10
|
||||
DefaultSoundSize int64 = 512 << 10
|
||||
DefaultCosmeticSize int64 = 5 << 20
|
||||
)
|
||||
|
||||
const instanceSettingsQuery = `SELECT key, value FROM instance_settings`
|
||||
|
||||
func (s *Store) InstanceSettings(ctx context.Context) (*InstanceSettings, error) {
|
||||
@@ -53,6 +69,11 @@ func settingsFromMap(values map[string]string) *InstanceSettings {
|
||||
MaxMembersPerGuild: parseSettingInt(values["max_members_per_guild"], 250),
|
||||
MaxMessageLength: parseSettingInt(values["max_message_length"], 4000),
|
||||
AuditRetentionDays: parseSettingInt(values["audit_retention_days"], 90),
|
||||
MaxAvatarSize: parseSettingInt64(values["max_avatar_size"], DefaultAvatarSize),
|
||||
MaxGuildImageSize: parseSettingInt64(values["max_guild_image_size"], DefaultGuildImageSize),
|
||||
MaxEmojiSize: parseSettingInt64(values["max_emoji_size"], DefaultEmojiSize),
|
||||
MaxSoundSize: parseSettingInt64(values["max_sound_size"], DefaultSoundSize),
|
||||
MaxCosmeticSize: parseSettingInt64(values["max_cosmetic_size"], DefaultCosmeticSize),
|
||||
raw: values,
|
||||
}
|
||||
if mainGuild := values["main_guild_id"]; mainGuild != "" {
|
||||
@@ -161,6 +182,18 @@ func optionalID(value sql.NullInt64) *uint64 {
|
||||
return &converted
|
||||
}
|
||||
|
||||
// parseSettingInt64 читает настройку-размер в байтах.
|
||||
func parseSettingInt64(value string, fallback int64) int64 {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func parseSettingInt(value string, fallback int) int {
|
||||
parsed, ok := parseUint(value)
|
||||
if !ok || parsed > math.MaxInt32 {
|
||||
|
||||
@@ -53,3 +53,46 @@ func (s *Store) RebuildSearchIndex(ctx context.Context) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeUserSessions удаляет все сессии пользователя: администратор инстанса
|
||||
// выкидывает его со всех устройств (AGENT.md 7.19).
|
||||
func (s *Store) RevokeUserSessions(ctx context.Context, userID uint64) (int64, error) {
|
||||
result, err := s.writer.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, int64(userID))
|
||||
if err != nil {
|
||||
return 0, mapError(err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// SoftDeleteUser помечает пользователя удалённым и убирает его из серверов:
|
||||
// сообщения остаются, чтобы история и ссылки не ломались (AGENT.md 6.4).
|
||||
func (s *Store) SoftDeleteUser(ctx context.Context, userID uint64) error {
|
||||
tx, err := s.writer.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE users SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
||||
s.Now(), s.Now(), int64(userID)); err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM guild_members WHERE user_id = ?`, int64(userID)); err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, int64(userID)); err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
// Личные связи и блокировки удалённого пользователя больше не нужны.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM relationships WHERE user_id = ? OR target_id = ?`,
|
||||
int64(userID), int64(userID)); err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user