feat(instance): админ-панель с лимитами медиа и действиями над пользователями

- лимиты медиа живут в настройках инстанса (миграция 00016): аватары,
  оформление сервера, эмодзи, звуки и галерея; все загрузки берут предел
  оттуда, значения по умолчанию — из AGENT.md 7.7
- действия администратора: временный пароль (показывается один раз, сессии
  отзываются), выход со всех устройств, мягкое удаление пользователя
  (сообщения и аудит остаются), переименование и удаление любого сервера
- админ-панель разбита на вкладки: обзор и здоровье, лимиты, пользователи,
  серверы, аудит; смена администраторов подтверждается личностью (step-up)
- тесты: Go (действия администратора, лимит эмодзи из настроек) и Vitest
  (вкладки, сброс пароля, выход, удаление, переименование сервера)
This commit is contained in:
2026-09-21 22:37:47 +03:00
parent dd4432452c
commit ef871bcd96
20 changed files with 1617 additions and 204 deletions
+8 -11
View File
@@ -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
}