Files
glchat/internal/server/api_guild_appearance.go
T
grendervill 02a5b99418 feat(guilds): оформление сервера — иконка, баннер, splash и акцентный цвет
Фаза 4 (AGENT.md 7.4): сервер можно оформить, поля в схеме были с Фазы 1, но
ручек и отдачи в API не было.

- `POST/DELETE /guilds/{id}/appearance/{icon|banner|splash}` (multipart,
  MANAGE_GUILD, только изображения до 8 МБ): файл сохраняется назначением
  `guild_icon|guild_banner|guild_splash`, предыдущий удаляется, пишется аудит
  и уходит `GUILD_UPDATE`; ответ — обновлённый сервер;
- `PATCH /guilds/{id}` принимает `accent_color` (0 — цвет темы);
- оформление отдаётся в READY, в списке серверов, в детальной карточке и в
  предпросмотре приглашения — клиенту не нужен отдельный запрос;
- анимированные GIF/APNG/WebP сохраняются как есть: изображения не
  перекодируются, поэтому анимация не теряется;
- тесты: загрузка и очистка всех трёх видов, замена файла, запрет без
  MANAGE_GUILD и без сессии, отказ для не-изображения, акцентный цвет.
2026-09-20 23:26:46 +03:00

221 lines
7.2 KiB
Go

package server
import (
"bytes"
"io"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"glchat/internal/permissions"
"glchat/internal/store"
)
// Оформление сервера (AGENT.md 7.4, Фаза 4): иконка, баннер, splash для
// страницы приглашения и акцентный цвет. Файлы хранятся обычными загрузками с
// отдельными purpose, поэтому анимированные GIF/APNG/WebP не теряют анимацию.
// maxGuildImageSize — предел размера для иконки, баннера и splash.
const maxGuildImageSize = 8 << 20
type appearanceKind struct {
// field — колонка гильдии, purpose — назначение файла.
field string
purpose string
label string
}
var guildAppearanceKinds = map[string]appearanceKind{
"icon": {field: "icon_file_id", purpose: "guild_icon", label: "icon"},
"banner": {field: "banner_file_id", purpose: "guild_banner", label: "banner"},
"splash": {field: "splash_file_id", purpose: "guild_splash", label: "splash"},
}
// registerGuildAppearanceRoutes описывает загрузку и удаление иконки, баннера
// и splash: huma не принимает multipart, поэтому ручки живут на роутере.
func (s *Server) registerGuildAppearanceRoutes(router chi.Router) {
router.Post("/guilds/{guild_id}/appearance/{kind}", s.handleGuildAppearanceUpload)
router.Delete("/guilds/{guild_id}/appearance/{kind}", s.handleGuildAppearanceDelete)
}
func (s *Server) handleGuildAppearanceUpload(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
ctx := r.Context()
kind, exists := guildAppearanceKinds[chi.URLParam(r, "kind")]
if !exists {
httpxWriteJSONError(w, http.StatusNotFound, "not_found", "unknown appearance kind")
return
}
guildID, err := parseID("guild_id", chi.URLParam(r, "guild_id"))
if err != nil {
writeHumaAPIError(w, err)
return
}
resolved, err := s.guildPermissions(ctx, guildID, user)
if err != nil {
writeHumaAPIError(w, err)
return
}
if !resolved.Has(permissions.ManageGuild) {
httpxWriteJSONError(w, http.StatusForbidden, "perm.denied", "MANAGE_GUILD is required")
return
}
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 {
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 {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", kind.label+" is too large")
return
}
contentType := header.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
contentType = http.DetectContentType(data)
}
if !strings.HasPrefix(contentType, "image/") {
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", kind.label+" must be an image")
return
}
guild, err := s.store.GetGuild(ctx, guildID)
if err != nil {
writeHumaAPIError(w, humaError(err))
return
}
// Старый файл удаляем: загрузки не должны копиться (AGENT.md 7.7).
if previous := appearanceFileID(guild, kind.field); previous != nil && *previous != 0 {
s.deleteStoredFile(ctx, *previous)
}
stored, err := s.saveUpload(ctx, store.File{
UploaderID: &user.ID,
Filename: sanitizeFilename(header.Filename),
ContentType: contentType,
Purpose: kind.purpose,
}, bytes.NewReader(data))
if err != nil {
writeHumaAPIError(w, err)
return
}
fileID := stored.ID
params := store.UpdateGuildParams{}
setAppearanceFileID(&params, kind.field, &fileID)
s.finishAppearanceChange(w, r, user, guildID, kind, params, "update")
}
func (s *Server) handleGuildAppearanceDelete(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
ctx := r.Context()
kind, exists := guildAppearanceKinds[chi.URLParam(r, "kind")]
if !exists {
httpxWriteJSONError(w, http.StatusNotFound, "not_found", "unknown appearance kind")
return
}
guildID, err := parseID("guild_id", chi.URLParam(r, "guild_id"))
if err != nil {
writeHumaAPIError(w, err)
return
}
resolved, err := s.guildPermissions(ctx, guildID, user)
if err != nil {
writeHumaAPIError(w, err)
return
}
if !resolved.Has(permissions.ManageGuild) {
httpxWriteJSONError(w, http.StatusForbidden, "perm.denied", "MANAGE_GUILD is required")
return
}
guild, err := s.store.GetGuild(ctx, guildID)
if err != nil {
writeHumaAPIError(w, humaError(err))
return
}
if previous := appearanceFileID(guild, kind.field); previous != nil && *previous != 0 {
s.deleteStoredFile(ctx, *previous)
}
params := store.UpdateGuildParams{}
clearAppearanceFileID(&params, kind.field)
s.finishAppearanceChange(w, r, user, guildID, kind, params, "delete")
}
// finishAppearanceChange применяет изменение, сбрасывает кэш, пишет аудит,
// рассылает GUILD_UPDATE и отдаёт обновлённый сервер одним ответом.
func (s *Server) finishAppearanceChange(
w http.ResponseWriter,
r *http.Request,
user *store.User,
guildID uint64,
kind appearanceKind,
params store.UpdateGuildParams,
action string,
) {
ctx := r.Context()
updated, err := s.store.UpdateGuild(ctx, guildID, params)
if err != nil {
writeHumaAPIError(w, humaError(err))
return
}
s.invalidateGuild(guildID)
s.recordAudit(ctx, user, guildID, "guild."+kind.label+"."+action, "guild", &guildID, "")
if s.gateway != nil {
s.gateway.Dispatch("GUILD_UPDATE", map[string]any{
"guild_id": formatSnowflake(guildID),
"name": updated.Name,
})
}
output, err := s.guildOutput(ctx, user, *updated)
if err != nil {
writeHumaAPIError(w, err)
return
}
httpxWriteJSON(w, http.StatusOK, map[string]any{"guild": output.Body.Guild})
}
// appearanceFileID читает поле оформления по имени колонки.
func appearanceFileID(guild *store.Guild, field string) *uint64 {
switch field {
case "icon_file_id":
return guild.IconFileID
case "banner_file_id":
return guild.BannerFileID
case "splash_file_id":
return guild.SplashFileID
default:
return nil
}
}
// setAppearanceFileID проставляет поле оформления в параметрах обновления.
func setAppearanceFileID(params *store.UpdateGuildParams, field string, value *uint64) {
switch field {
case "icon_file_id":
params.IconFileID = value
case "banner_file_id":
params.BannerFileID = value
case "splash_file_id":
params.SplashFileID = value
}
}
// clearAppearanceFileID очищает поле оформления (нулевой идентификатор).
func clearAppearanceFileID(params *store.UpdateGuildParams, field string) {
cleared := uint64(0)
setAppearanceFileID(params, field, &cleared)
}