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 и без сессии, отказ для не-изображения, акцентный цвет.
This commit is contained in:
@@ -71,6 +71,11 @@ type ReadyGuild struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
OwnerID string `json:"owner_id"`
|
OwnerID string `json:"owner_id"`
|
||||||
IsMain bool `json:"is_main"`
|
IsMain bool `json:"is_main"`
|
||||||
|
// Оформление сервера (AGENT.md 7.4): иконка, баннер, splash и акцент.
|
||||||
|
IconFileID string `json:"icon_file_id,omitempty"`
|
||||||
|
BannerFileID string `json:"banner_file_id,omitempty"`
|
||||||
|
SplashFileID string `json:"splash_file_id,omitempty"`
|
||||||
|
AccentColor int64 `json:"accent_color,omitempty"`
|
||||||
Channels []ReadyChannel `json:"channels"`
|
Channels []ReadyChannel `json:"channels"`
|
||||||
Roles []ReadyRole `json:"roles"`
|
Roles []ReadyRole `json:"roles"`
|
||||||
MemberIDs []string `json:"member_ids"`
|
MemberIDs []string `json:"member_ids"`
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *stor
|
|||||||
Name: guild.Name,
|
Name: guild.Name,
|
||||||
OwnerID: formatID(guild.OwnerID),
|
OwnerID: formatID(guild.OwnerID),
|
||||||
IsMain: guild.IsMain,
|
IsMain: guild.IsMain,
|
||||||
|
AccentColor: guild.AccentColor,
|
||||||
|
IconFileID: optionalFormatID(guild.IconFileID),
|
||||||
|
BannerFileID: optionalFormatID(guild.BannerFileID),
|
||||||
|
SplashFileID: optionalFormatID(guild.SplashFileID),
|
||||||
Channels: []ReadyChannel{},
|
Channels: []ReadyChannel{},
|
||||||
Roles: []ReadyRole{},
|
Roles: []ReadyRole{},
|
||||||
MemberIDs: []string{},
|
MemberIDs: []string{},
|
||||||
@@ -362,6 +366,14 @@ func visibleStatus(status string, lastSeen *time.Time) string {
|
|||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// optionalFormatID отдаёт идентификатор файла или пустую строку.
|
||||||
|
func optionalFormatID(id *uint64) string {
|
||||||
|
if id == nil || *id == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return formatID(*id)
|
||||||
|
}
|
||||||
|
|
||||||
func formatID(id uint64) string {
|
func formatID(id uint64) string {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -176,7 +176,9 @@ func (s *Server) deleteStoredFile(ctx context.Context, fileID uint64) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if file.StoragePath != "" {
|
if file.StoragePath != "" {
|
||||||
if err := os.Remove(file.StoragePath); err != nil && !os.IsNotExist(err) {
|
// Путь собран из идентификатора файла и каталога данных (saveUpload),
|
||||||
|
// имя от клиента в него не попадает (AGENT.md 9.2).
|
||||||
|
if err := os.Remove(file.StoragePath); err != nil && !os.IsNotExist(err) { //nolint:gosec // путь формирует сам инстанс
|
||||||
s.logger.WarnContext(ctx, "failed to remove file from disk",
|
s.logger.WarnContext(ctx, "failed to remove file from disk",
|
||||||
slog.String("file_id", formatSnowflake(fileID)), slog.Any("error", err))
|
slog.String("file_id", formatSnowflake(fileID)), slog.Any("error", err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
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(¶ms, 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(¶ms, 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)
|
||||||
|
}
|
||||||
@@ -63,6 +63,9 @@ type guildDetailPayload struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
OwnerID string `json:"owner_id"`
|
OwnerID string `json:"owner_id"`
|
||||||
IconFileID string `json:"icon_file_id,omitempty"`
|
IconFileID string `json:"icon_file_id,omitempty"`
|
||||||
|
BannerFileID string `json:"banner_file_id,omitempty"`
|
||||||
|
SplashFileID string `json:"splash_file_id,omitempty"`
|
||||||
|
AccentColor int64 `json:"accent_color,omitempty"`
|
||||||
IsMain bool `json:"is_main"`
|
IsMain bool `json:"is_main"`
|
||||||
MemberCount int `json:"member_count"`
|
MemberCount int `json:"member_count"`
|
||||||
Roles []rolePayload `json:"roles"`
|
Roles []rolePayload `json:"roles"`
|
||||||
@@ -224,6 +227,8 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
|
|||||||
// Публичность сервера: попадает в каталог и открывает вход без
|
// Публичность сервера: попадает в каталог и открывает вход без
|
||||||
// приглашения (AGENT.md 7.20).
|
// приглашения (AGENT.md 7.20).
|
||||||
Public *bool `json:"public,omitempty"`
|
Public *bool `json:"public,omitempty"`
|
||||||
|
// Акцентный цвет сервера (0 — цвет темы по умолчанию).
|
||||||
|
AccentColor *int64 `json:"accent_color,omitempty" minimum:"0" maximum:"16777215"`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) (*guildOutput, error) {
|
) (*guildOutput, error) {
|
||||||
@@ -235,7 +240,10 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
params := store.UpdateGuildParams{Description: input.Body.Description}
|
params := store.UpdateGuildParams{
|
||||||
|
Description: input.Body.Description,
|
||||||
|
AccentColor: input.Body.AccentColor,
|
||||||
|
}
|
||||||
if input.Body.Public != nil {
|
if input.Body.Public != nil {
|
||||||
// Главный сервер публичным не делаем: он и так точка входа.
|
// Главный сервер публичным не делаем: он и так точка входа.
|
||||||
if *input.Body.Public {
|
if *input.Body.Public {
|
||||||
@@ -661,6 +669,13 @@ func (s *Server) guildOutput(ctx context.Context, user *store.User, guild store.
|
|||||||
if guild.IconFileID != nil {
|
if guild.IconFileID != nil {
|
||||||
payload.IconFileID = formatSnowflake(*guild.IconFileID)
|
payload.IconFileID = formatSnowflake(*guild.IconFileID)
|
||||||
}
|
}
|
||||||
|
if guild.BannerFileID != nil {
|
||||||
|
payload.BannerFileID = formatSnowflake(*guild.BannerFileID)
|
||||||
|
}
|
||||||
|
if guild.SplashFileID != nil {
|
||||||
|
payload.SplashFileID = formatSnowflake(*guild.SplashFileID)
|
||||||
|
}
|
||||||
|
payload.AccentColor = guild.AccentColor
|
||||||
roleIDs, err := s.store.MemberRoleIDs(ctx, guild.ID, user.ID)
|
roleIDs, err := s.store.MemberRoleIDs(ctx, guild.ID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ type inviteOutput struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
IconFileID string `json:"icon_file_id,omitempty"`
|
IconFileID string `json:"icon_file_id,omitempty"`
|
||||||
|
BannerFileID string `json:"banner_file_id,omitempty"`
|
||||||
|
SplashFileID string `json:"splash_file_id,omitempty"`
|
||||||
|
AccentColor int64 `json:"accent_color,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
MemberCount int `json:"member_count"`
|
MemberCount int `json:"member_count"`
|
||||||
IsMember bool `json:"is_member"`
|
IsMember bool `json:"is_member"`
|
||||||
@@ -377,6 +380,13 @@ func (s *Server) inviteOutput(ctx context.Context, user *store.User, invite *sto
|
|||||||
if guild.IconFileID != nil {
|
if guild.IconFileID != nil {
|
||||||
output.Body.Guild.IconFileID = formatSnowflake(*guild.IconFileID)
|
output.Body.Guild.IconFileID = formatSnowflake(*guild.IconFileID)
|
||||||
}
|
}
|
||||||
|
if guild.BannerFileID != nil {
|
||||||
|
output.Body.Guild.BannerFileID = formatSnowflake(*guild.BannerFileID)
|
||||||
|
}
|
||||||
|
if guild.SplashFileID != nil {
|
||||||
|
output.Body.Guild.SplashFileID = formatSnowflake(*guild.SplashFileID)
|
||||||
|
}
|
||||||
|
output.Body.Guild.AccentColor = guild.AccentColor
|
||||||
if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil {
|
if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil {
|
||||||
output.Body.Guild.MemberCount = count
|
output.Body.Guild.MemberCount = count
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,9 @@ type guildSummary struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
IconFileID string `json:"icon_file_id,omitempty"`
|
IconFileID string `json:"icon_file_id,omitempty"`
|
||||||
|
BannerFileID string `json:"banner_file_id,omitempty"`
|
||||||
|
SplashFileID string `json:"splash_file_id,omitempty"`
|
||||||
|
AccentColor int64 `json:"accent_color,omitempty"`
|
||||||
OwnerID string `json:"owner_id"`
|
OwnerID string `json:"owner_id"`
|
||||||
IsMain bool `json:"is_main"`
|
IsMain bool `json:"is_main"`
|
||||||
MemberCount int `json:"member_count"`
|
MemberCount int `json:"member_count"`
|
||||||
@@ -409,6 +412,13 @@ func (s *Server) guildSummary(ctx context.Context, guild store.Guild, userID uin
|
|||||||
if guild.IconFileID != nil {
|
if guild.IconFileID != nil {
|
||||||
summary.IconFileID = formatSnowflake(*guild.IconFileID)
|
summary.IconFileID = formatSnowflake(*guild.IconFileID)
|
||||||
}
|
}
|
||||||
|
if guild.BannerFileID != nil {
|
||||||
|
summary.BannerFileID = formatSnowflake(*guild.BannerFileID)
|
||||||
|
}
|
||||||
|
if guild.SplashFileID != nil {
|
||||||
|
summary.SplashFileID = formatSnowflake(*guild.SplashFileID)
|
||||||
|
}
|
||||||
|
summary.AccentColor = guild.AccentColor
|
||||||
roleIDs, err := s.store.MemberRoleIDs(ctx, guild.ID, userID)
|
roleIDs, err := s.store.MemberRoleIDs(ctx, guild.ID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return guildSummary{}, humaError(err)
|
return guildSummary{}, humaError(err)
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Тесты оформления сервера (AGENT.md 7.4): иконка, баннер и splash доступны
|
||||||
|
// только с MANAGE_GUILD, принимают изображения и удаляют предыдущий файл.
|
||||||
|
|
||||||
|
// pngBytes — минимальный PNG: сигнатура и служебный блок (для DetectContentType).
|
||||||
|
func pngBytes() []byte {
|
||||||
|
return []byte{
|
||||||
|
0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a,
|
||||||
|
0x00, 0x00, 0x00, 0x0d, 'I', 'H', 'D', 'R',
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadAppearance отправляет multipart-запрос загрузки оформления.
|
||||||
|
func uploadAppearance(t *testing.T, srv *Server, guildID, kind, filename string, content []byte, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
part, err := writer.CreateFormFile("file", filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("multipart: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(content); err != nil {
|
||||||
|
t.Fatalf("multipart write: %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("multipart close: %v", err)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||||
|
"/api/v1/guilds/"+guildID+"/appearance/"+kind, &body)
|
||||||
|
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
if cookie != nil {
|
||||||
|
request.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
srv.Handler().ServeHTTP(rec, request)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func appearanceGuild(t *testing.T) (srv *Server, owner *http.Cookie, guildID string) {
|
||||||
|
t.Helper()
|
||||||
|
srv, _ = newTestServer(t)
|
||||||
|
owner = registerAndLogin(t, srv, "look_owner", "look-owner@example.com")
|
||||||
|
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Оформление"}`, owner)
|
||||||
|
guild := decodeResponse[struct {
|
||||||
|
Guild struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"guild"`
|
||||||
|
}](t, created)
|
||||||
|
return srv, owner, guild.Guild.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuildAppearanceUploadAndClear(t *testing.T) {
|
||||||
|
srv, owner, guildID := appearanceGuild(t)
|
||||||
|
|
||||||
|
for _, kind := range []string{"icon", "banner", "splash"} {
|
||||||
|
rec := uploadAppearance(t, srv, guildID, kind, kind+".png", pngBytes(), owner)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s: статус %d (%s)", kind, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
payload := decodeResponse[struct {
|
||||||
|
Guild struct {
|
||||||
|
IconFileID string `json:"icon_file_id"`
|
||||||
|
BannerFileID string `json:"banner_file_id"`
|
||||||
|
SplashFileID string `json:"splash_file_id"`
|
||||||
|
} `json:"guild"`
|
||||||
|
}](t, rec)
|
||||||
|
switch kind {
|
||||||
|
case "icon":
|
||||||
|
if payload.Guild.IconFileID == "" {
|
||||||
|
t.Fatalf("иконка не вернулась: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
case "banner":
|
||||||
|
if payload.Guild.BannerFileID == "" {
|
||||||
|
t.Fatal("баннер не вернулся")
|
||||||
|
}
|
||||||
|
case "splash":
|
||||||
|
if payload.Guild.SplashFileID == "" {
|
||||||
|
t.Fatal("splash не вернулся")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторная загрузка заменяет файл, а не копит их.
|
||||||
|
second := uploadAppearance(t, srv, guildID, kind, kind+"2.png", pngBytes(), owner)
|
||||||
|
if second.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s (повтор): статус %d", kind, second.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление очищает поле.
|
||||||
|
remove := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guildID+"/appearance/"+kind, "", owner)
|
||||||
|
if remove.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s (удаление): статус %d (%s)", kind, remove.Code, remove.Body.String())
|
||||||
|
}
|
||||||
|
cleared := decodeResponse[struct {
|
||||||
|
Guild struct {
|
||||||
|
IconFileID string `json:"icon_file_id"`
|
||||||
|
BannerFileID string `json:"banner_file_id"`
|
||||||
|
SplashFileID string `json:"splash_file_id"`
|
||||||
|
} `json:"guild"`
|
||||||
|
}](t, remove)
|
||||||
|
if cleared.Guild.IconFileID != "" || cleared.Guild.BannerFileID != "" || cleared.Guild.SplashFileID != "" {
|
||||||
|
t.Fatalf("%s: после удаления остались файлы: %+v", kind, cleared.Guild)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuildAppearanceRequiresPermissionAndImage(t *testing.T) {
|
||||||
|
srv, owner, guildID := appearanceGuild(t)
|
||||||
|
member := registerAndLogin(t, srv, "look_member", "look-member@example.com")
|
||||||
|
// Участник без прав: сервер не скрывает от него существование (404), потому
|
||||||
|
// что он уже состоит в сервере.
|
||||||
|
invite := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/invites",
|
||||||
|
`{"max_uses":1,"max_age_seconds":600}`, owner)
|
||||||
|
code := decodeResponse[struct {
|
||||||
|
Invite struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
} `json:"invite"`
|
||||||
|
}](t, invite).Invite.Code
|
||||||
|
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+code, "", member); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("вступление участника = %d (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Без MANAGE_GUILD загрузка запрещена.
|
||||||
|
denied := uploadAppearance(t, srv, guildID, "icon", "icon.png", pngBytes(), member)
|
||||||
|
if denied.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("без прав: статус %d, ожидался 403", denied.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Не изображение отклоняется.
|
||||||
|
text := uploadAppearance(t, srv, guildID, "icon", "note.txt", []byte("просто текст"), owner)
|
||||||
|
if text.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("текстовый файл: статус %d, ожидался 422", text.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Неизвестный вид оформления — 404.
|
||||||
|
unknown := uploadAppearance(t, srv, guildID, "cover", "cover.png", pngBytes(), owner)
|
||||||
|
if unknown.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("неизвестный вид: статус %d, ожидался 404", unknown.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Без сессии — 401.
|
||||||
|
anonymous := uploadAppearance(t, srv, guildID, "icon", "icon.png", pngBytes(), nil)
|
||||||
|
if anonymous.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("без сессии: статус %d, ожидался 401", anonymous.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuildAppearanceAccentColor(t *testing.T) {
|
||||||
|
srv, owner, guildID := appearanceGuild(t)
|
||||||
|
|
||||||
|
updated := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID,
|
||||||
|
`{"accent_color":13369344}`, owner)
|
||||||
|
if updated.Code != http.StatusOK {
|
||||||
|
t.Fatalf("акцент: статус %d (%s)", updated.Code, updated.Body.String())
|
||||||
|
}
|
||||||
|
payload := decodeResponse[struct {
|
||||||
|
Guild struct {
|
||||||
|
AccentColor int64 `json:"accent_color"`
|
||||||
|
} `json:"guild"`
|
||||||
|
}](t, updated)
|
||||||
|
if payload.Guild.AccentColor != 13369344 {
|
||||||
|
t.Fatalf("акцент = %d", payload.Guild.AccentColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сброс: цвет возвращается к значению темы.
|
||||||
|
cleared := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID, `{"accent_color":0}`, owner)
|
||||||
|
if cleared.Code != http.StatusOK {
|
||||||
|
t.Fatalf("сброс акцента: статус %d", cleared.Code)
|
||||||
|
}
|
||||||
|
if value := decodeResponse[struct {
|
||||||
|
Guild struct {
|
||||||
|
AccentColor int64 `json:"accent_color"`
|
||||||
|
} `json:"guild"`
|
||||||
|
}](t, cleared).Guild.AccentColor; value != 0 {
|
||||||
|
t.Fatalf("после сброса акцент = %d", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,6 +144,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
s.registerMessageRoutes(s.api)
|
s.registerMessageRoutes(s.api)
|
||||||
s.registerInviteRoutes(s.api)
|
s.registerInviteRoutes(s.api)
|
||||||
s.registerFileRoutes(s.api, apiRouter)
|
s.registerFileRoutes(s.api, apiRouter)
|
||||||
|
s.registerGuildAppearanceRoutes(apiRouter)
|
||||||
s.registerSocialRoutes(s.api)
|
s.registerSocialRoutes(s.api)
|
||||||
s.registerEmojiRoutes(s.api, apiRouter)
|
s.registerEmojiRoutes(s.api, apiRouter)
|
||||||
s.registerVoiceRoutes(s.api)
|
s.registerVoiceRoutes(s.api)
|
||||||
|
|||||||
Reference in New Issue
Block a user