2026-09-21 01:28:48 +03:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
|
|
|
|
|
|
"glchat/internal/permissions"
|
|
|
|
|
"glchat/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Оформление профиля (AGENT.md 7.2, 7.4): галерея сервера с рамками и
|
|
|
|
|
// иконками, назначение их ролям и личные стили пользователя («для друзей» и
|
|
|
|
|
// пер-серверные) с поэлементным приоритетом.
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
// maxCosmeticsPerGuild — предел галереи сервера.
|
|
|
|
|
maxCosmeticsPerGuild = 50
|
|
|
|
|
// maxCosmeticNameLength — длина имени элемента галереи.
|
|
|
|
|
maxCosmeticNameLength = 40
|
|
|
|
|
// scopeGlobal — область личного стиля «для друзей».
|
|
|
|
|
scopeGlobal = "global"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type cosmeticPayload struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
GuildID string `json:"guild_id"`
|
|
|
|
|
Kind string `json:"kind"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
FileID string `json:"file_id"`
|
|
|
|
|
CreatedAt string `json:"created_at"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type cosmeticListOutput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
Cosmetics []cosmeticPayload `json:"cosmetics"`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type cosmeticOutput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
Cosmetic cosmeticPayload `json:"cosmetic"`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// cosmeticsPayload — вычисленное оформление участника (AGENT.md 7.2).
|
|
|
|
|
type cosmeticsPayload struct {
|
|
|
|
|
FrameID string `json:"frame_id,omitempty"`
|
|
|
|
|
BadgeID string `json:"badge_id,omitempty"`
|
|
|
|
|
NickColor *int64 `json:"nick_color,omitempty"`
|
|
|
|
|
NickEffect string `json:"nick_effect,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// stylePayload — личный стиль профиля вместе с элементами, которые доступны
|
|
|
|
|
// пользователю: клиент показывает выбор только из них (7.2).
|
|
|
|
|
type stylePayload struct {
|
|
|
|
|
Scope string `json:"scope"`
|
|
|
|
|
GuildID string `json:"guild_id,omitempty"`
|
|
|
|
|
GuildName string `json:"guild_name,omitempty"`
|
|
|
|
|
// Style — записанный стиль области (пустой, если его нет).
|
|
|
|
|
Style cosmeticsPayload `json:"style"`
|
|
|
|
|
FrameIDs []string `json:"frame_ids"`
|
|
|
|
|
BadgeIDs []string `json:"badge_ids"`
|
|
|
|
|
NickColors []int64 `json:"nick_colors"`
|
|
|
|
|
Effects []string `json:"effects"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type styleListOutput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
Styles []stylePayload `json:"styles"`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type styleOutput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
Style stylePayload `json:"style"`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// registerCosmeticRoutes описывает галерею оформления сервера и личные стили
|
|
|
|
|
// (AGENT.md 7.2, 7.4).
|
|
|
|
|
func (s *Server) registerCosmeticRoutes(api huma.API, router chi.Router) {
|
|
|
|
|
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "listGuildCosmetics",
|
|
|
|
|
Method: http.MethodGet,
|
|
|
|
|
Path: "/guilds/{guild_id}/cosmetics",
|
|
|
|
|
Summary: "Галерея оформления сервера",
|
|
|
|
|
Tags: []string{"Cosmetics"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
GuildID string `path:"guild_id"`
|
|
|
|
|
},
|
|
|
|
|
) (*cosmeticListOutput, error) {
|
|
|
|
|
user, _, err := requireUser(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
cosmetics, err := s.store.ListGuildCosmetics(ctx, guildID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
output := &cosmeticListOutput{}
|
|
|
|
|
output.Body.Cosmetics = make([]cosmeticPayload, 0, len(cosmetics))
|
|
|
|
|
for i := range cosmetics {
|
|
|
|
|
output.Body.Cosmetics = append(output.Body.Cosmetics, cosmeticFromStore(cosmetics[i]))
|
|
|
|
|
}
|
|
|
|
|
return output, nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "renameGuildCosmetic",
|
|
|
|
|
Method: http.MethodPatch,
|
|
|
|
|
Path: "/guilds/{guild_id}/cosmetics/{cosmetic_id}",
|
|
|
|
|
Summary: "Переименовать элемент галереи",
|
|
|
|
|
Tags: []string{"Cosmetics"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
GuildID string `path:"guild_id"`
|
|
|
|
|
CosmeticID string `path:"cosmetic_id"`
|
|
|
|
|
Body struct {
|
|
|
|
|
Name string `json:"name" minLength:"1" maxLength:"40"`
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
) (*cosmeticOutput, error) {
|
|
|
|
|
user, _, err := requireUser(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
cosmetic, err := s.requireGuildCosmetic(ctx, guildID, input.CosmeticID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
name := strings.TrimSpace(input.Body.Name)
|
|
|
|
|
if name == "" || len([]rune(name)) > maxCosmeticNameLength {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "invalid cosmetic name")
|
|
|
|
|
}
|
|
|
|
|
updated, err := s.store.RenameGuildCosmetic(ctx, cosmetic.ID, name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
s.recordAudit(ctx, user, guildID, "cosmetic.update", "cosmetic", &cosmetic.ID, "")
|
|
|
|
|
s.dispatchGuildCosmeticsUpdate(guildID)
|
|
|
|
|
output := &cosmeticOutput{}
|
|
|
|
|
output.Body.Cosmetic = cosmeticFromStore(*updated)
|
|
|
|
|
return output, nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "deleteGuildCosmetic",
|
|
|
|
|
Method: http.MethodDelete,
|
|
|
|
|
Path: "/guilds/{guild_id}/cosmetics/{cosmetic_id}",
|
|
|
|
|
Summary: "Удалить элемент галереи",
|
|
|
|
|
Tags: []string{"Cosmetics"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
GuildID string `path:"guild_id"`
|
|
|
|
|
CosmeticID string `path:"cosmetic_id"`
|
|
|
|
|
},
|
|
|
|
|
) (*okOutput, error) {
|
|
|
|
|
user, _, err := requireUser(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
cosmetic, err := s.requireGuildCosmetic(ctx, guildID, input.CosmeticID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if err := s.store.DeleteGuildCosmetic(ctx, cosmetic.ID); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
// Файл больше не нужен: галерея не должна копить мусор (AGENT.md 7.7).
|
|
|
|
|
s.deleteStoredFile(ctx, cosmetic.FileID)
|
|
|
|
|
s.recordAudit(ctx, user, guildID, "cosmetic.delete", "cosmetic", &cosmetic.ID, "")
|
|
|
|
|
s.dispatchGuildCosmeticsUpdate(guildID)
|
|
|
|
|
return newOKOutput(), nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Загрузка файла галереи — multipart, поэтому ручка живёт на роутере.
|
|
|
|
|
router.Post("/guilds/{guild_id}/cosmetics", s.handleCosmeticUpload)
|
|
|
|
|
s.registerStyleRoutes(api)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleCosmeticUpload принимает картинку рамки или иконки (AGENT.md 7.4).
|
|
|
|
|
func (s *Server) handleCosmeticUpload(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
user, _, ok := s.authenticate(w, r)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx := r.Context()
|
|
|
|
|
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.ManageRoles) {
|
|
|
|
|
httpxWriteJSONError(w, http.StatusForbidden, "perm.denied", "MANAGE_ROLES is required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if count, err := s.store.CountGuildCosmetics(ctx, guildID); err != nil {
|
|
|
|
|
writeHumaAPIError(w, humaError(err))
|
|
|
|
|
return
|
|
|
|
|
} else if count >= maxCosmeticsPerGuild {
|
|
|
|
|
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "limits.reached", "cosmetic gallery is full")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен ниже
|
|
|
|
|
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer func() {
|
|
|
|
|
if r.MultipartForm != nil {
|
|
|
|
|
_ = r.MultipartForm.RemoveAll()
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
kind := store.CosmeticKind(strings.TrimSpace(r.FormValue("kind")))
|
|
|
|
|
if kind != store.CosmeticFrame && kind != store.CosmeticBadge {
|
|
|
|
|
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "kind must be frame or badge")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
name := strings.TrimSpace(r.FormValue("name"))
|
|
|
|
|
if name == "" {
|
|
|
|
|
name = string(kind)
|
|
|
|
|
}
|
|
|
|
|
if len([]rune(name)) > maxCosmeticNameLength {
|
|
|
|
|
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "invalid cosmetic name")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 22:37:47 +03:00
|
|
|
limit := s.mediaLimit(ctx, mediaCosmetic)
|
2026-09-21 01:28:48 +03:00
|
|
|
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() }()
|
2026-09-21 22:37:47 +03:00
|
|
|
if header.Size > limit {
|
2026-09-21 01:28:48 +03:00
|
|
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "cosmetic is too large")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-21 22:37:47 +03:00
|
|
|
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
|
|
|
|
if err != nil || int64(len(data)) > limit {
|
2026-09-21 01:28:48 +03:00
|
|
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "cosmetic 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", "cosmetic must be an image")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stored, err := s.saveUpload(ctx, store.File{
|
|
|
|
|
UploaderID: &user.ID,
|
|
|
|
|
GuildID: &guildID,
|
|
|
|
|
Filename: sanitizeFilename(header.Filename),
|
|
|
|
|
ContentType: contentType,
|
|
|
|
|
Purpose: "guild_cosmetic",
|
|
|
|
|
}, bytes.NewReader(data))
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeHumaAPIError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
cosmetic, err := s.store.CreateGuildCosmetic(ctx, store.CreateGuildCosmeticParams{
|
|
|
|
|
GuildID: guildID,
|
|
|
|
|
Kind: kind,
|
|
|
|
|
Name: name,
|
|
|
|
|
FileID: stored.ID,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.deleteStoredFile(ctx, stored.ID)
|
|
|
|
|
writeHumaAPIError(w, humaError(err))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.recordAudit(ctx, user, guildID, "cosmetic.create", "cosmetic", &cosmetic.ID, "")
|
|
|
|
|
s.dispatchGuildCosmeticsUpdate(guildID)
|
|
|
|
|
httpxWriteJSON(w, http.StatusOK, map[string]any{"cosmetic": cosmeticFromStore(*cosmetic)})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// registerStyleRoutes описывает личные стили профиля (AGENT.md 7.2).
|
|
|
|
|
func (s *Server) registerStyleRoutes(api huma.API) {
|
|
|
|
|
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "listMyStyles",
|
|
|
|
|
Method: http.MethodGet,
|
|
|
|
|
Path: "/users/@me/styles",
|
|
|
|
|
Summary: "Личные стили профиля и доступные элементы",
|
|
|
|
|
Tags: []string{"Cosmetics"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, _ *struct{}) (*styleListOutput, error) {
|
|
|
|
|
user, _, err := requireUser(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
styles, err := s.store.ListUserStyles(ctx, user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
cosmetics, err := s.store.ListGrantedCosmetics(ctx, user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
colors := store.NickColorPalette()
|
|
|
|
|
|
|
|
|
|
// Доступность элементов считается для каждой области отдельно (7.2):
|
|
|
|
|
// пер-серверный стиль ограничен ролями этого сервера, «для друзей» —
|
|
|
|
|
// ролями на любом сервере.
|
|
|
|
|
byScope := make(map[string]*store.UserStyle, len(styles))
|
|
|
|
|
for i := range styles {
|
|
|
|
|
byScope[scopeOfStyle(styles[i])] = &styles[i]
|
|
|
|
|
}
|
|
|
|
|
grants, err := s.store.CosmeticGrantsForUsers(ctx, []uint64{user.ID}, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
output := &styleListOutput{}
|
|
|
|
|
output.Body.Styles = append(output.Body.Styles,
|
2026-09-21 01:32:48 +03:00
|
|
|
styleFromStore(byScope[scopeGlobal], scopeGlobal, "",
|
2026-09-21 01:28:48 +03:00
|
|
|
grantedIDs(cosmetics, grants[user.ID].Frames, store.CosmeticFrame),
|
|
|
|
|
grantedIDs(cosmetics, grants[user.ID].Badges, store.CosmeticBadge),
|
|
|
|
|
colors))
|
|
|
|
|
|
|
|
|
|
guilds, err := s.store.ListGuildsForUser(ctx, user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
for i := range guilds {
|
|
|
|
|
guildID := guilds[i].ID
|
|
|
|
|
scope := formatSnowflake(guildID)
|
|
|
|
|
scoped, err := s.store.CosmeticGrantsForUsers(ctx, []uint64{user.ID}, &guildID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
entry := scoped[user.ID]
|
2026-09-21 01:32:48 +03:00
|
|
|
output.Body.Styles = append(output.Body.Styles,
|
|
|
|
|
styleFromStore(byScope[scope], scope, guilds[i].Name,
|
|
|
|
|
grantedIDs(cosmetics, entry.GuildFrames, store.CosmeticFrame),
|
|
|
|
|
grantedIDs(cosmetics, entry.GuildBadges, store.CosmeticBadge),
|
|
|
|
|
colors))
|
2026-09-21 01:28:48 +03:00
|
|
|
}
|
|
|
|
|
return output, nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "putMyStyle",
|
|
|
|
|
Method: http.MethodPut,
|
|
|
|
|
Path: "/users/@me/styles/{scope}",
|
|
|
|
|
Summary: "Задать личный стиль профиля",
|
|
|
|
|
Tags: []string{"Cosmetics"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
Scope string `path:"scope"`
|
|
|
|
|
Body struct {
|
|
|
|
|
FrameID string `json:"frame_id,omitempty"`
|
|
|
|
|
BadgeID string `json:"badge_id,omitempty"`
|
|
|
|
|
NickColor *int64 `json:"nick_color,omitempty"`
|
|
|
|
|
NickEffect string `json:"nick_effect,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
) (*styleOutput, error) {
|
|
|
|
|
user, _, err := requireUser(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
guildID, err := parseStyleScope(input.Scope)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if guildID != nil {
|
|
|
|
|
// Пер-серверный стиль доступен только участнику сервера.
|
|
|
|
|
if _, _, err := s.requireGuildPermission(ctx, formatSnowflake(*guildID), user, permissions.ViewGuild); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
frameID, err := s.requiredCosmetic(ctx, user.ID, guildID, input.Body.FrameID, store.CosmeticFrame)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
badgeID, err := s.requiredCosmetic(ctx, user.ID, guildID, input.Body.BadgeID, store.CosmeticBadge)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
effect := input.Body.NickEffect
|
|
|
|
|
if effect == "" {
|
|
|
|
|
effect = string(store.NickEffectNone)
|
|
|
|
|
}
|
|
|
|
|
if !store.ValidNickEffect(effect) {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown nick effect")
|
|
|
|
|
}
|
|
|
|
|
nickColor := input.Body.NickColor
|
|
|
|
|
if nickColor != nil && !store.InNickColorPalette(*nickColor) {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "nick color is not in the palette")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
style, err := s.store.UpsertUserStyle(ctx, store.UpsertUserStyleParams{
|
|
|
|
|
UserID: user.ID,
|
|
|
|
|
GuildID: guildID,
|
|
|
|
|
FrameID: frameID,
|
|
|
|
|
BadgeID: badgeID,
|
|
|
|
|
NickColor: nickColor,
|
|
|
|
|
NickEffect: effect,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
grants, err := s.store.CosmeticGrantsForUsers(ctx, []uint64{user.ID}, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
cosmetics, err := s.store.ListGrantedCosmetics(ctx, user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
frames := grantedIDs(cosmetics, grants[user.ID].Frames, store.CosmeticFrame)
|
|
|
|
|
badges := grantedIDs(cosmetics, grants[user.ID].Badges, store.CosmeticBadge)
|
2026-09-21 01:32:48 +03:00
|
|
|
scope := scopeGlobal
|
|
|
|
|
guildName := ""
|
|
|
|
|
if guildID != nil {
|
|
|
|
|
scope = formatSnowflake(*guildID)
|
|
|
|
|
}
|
2026-09-21 01:28:48 +03:00
|
|
|
output := &styleOutput{}
|
2026-09-21 01:32:48 +03:00
|
|
|
output.Body.Style = styleFromStore(style, scope, guildName, frames, badges, store.NickColorPalette())
|
2026-09-21 01:28:48 +03:00
|
|
|
s.dispatchStyleUpdate(user.ID, guildID)
|
|
|
|
|
return output, nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "deleteMyStyle",
|
|
|
|
|
Method: http.MethodDelete,
|
|
|
|
|
Path: "/users/@me/styles/{scope}",
|
|
|
|
|
Summary: "Сбросить личный стиль профиля",
|
|
|
|
|
Tags: []string{"Cosmetics"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
Scope string `path:"scope"`
|
|
|
|
|
},
|
|
|
|
|
) (*okOutput, error) {
|
|
|
|
|
user, _, err := requireUser(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
guildID, err := parseStyleScope(input.Scope)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if err := s.store.DeleteUserStyle(ctx, user.ID, guildID); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
s.dispatchStyleUpdate(user.ID, guildID)
|
|
|
|
|
return newOKOutput(), nil
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// scopeOfStyle — область записанного стиля: `global` или id сервера.
|
|
|
|
|
func scopeOfStyle(style store.UserStyle) string {
|
|
|
|
|
if style.GuildID == nil {
|
|
|
|
|
return scopeGlobal
|
|
|
|
|
}
|
|
|
|
|
return formatSnowflake(*style.GuildID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parseStyleScope разбирает область стиля: `global` или идентификатор сервера.
|
|
|
|
|
func parseStyleScope(scope string) (*uint64, error) {
|
|
|
|
|
if scope == scopeGlobal {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
guildID, err := parseID("scope", scope)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return &guildID, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// requiredCosmetic проверяет, что элемент галереи существует и выдан ролями
|
|
|
|
|
// пользователя (AGENT.md 7.2: выбирать можно только выданное).
|
|
|
|
|
func (s *Server) requiredCosmetic(ctx context.Context, userID uint64, guildID *uint64, rawID string, kind store.CosmeticKind) (*uint64, error) {
|
|
|
|
|
if rawID == "" {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
id, err := parseID("cosmetic_id", rawID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
cosmetic, err := s.store.GetGuildCosmetic(ctx, id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
if cosmetic.Kind != kind {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cosmetic kind mismatch")
|
|
|
|
|
}
|
|
|
|
|
grants, err := s.store.CosmeticGrantsForUsers(ctx, []uint64{userID}, guildID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
entry := grants[userID]
|
|
|
|
|
granted := entry.Frames
|
|
|
|
|
if kind == store.CosmeticBadge {
|
|
|
|
|
granted = entry.Badges
|
|
|
|
|
}
|
|
|
|
|
// Пер-серверный стиль ограничен ролями этого сервера.
|
|
|
|
|
if guildID != nil {
|
|
|
|
|
granted = entry.GuildFrames
|
|
|
|
|
if kind == store.CosmeticBadge {
|
|
|
|
|
granted = entry.GuildBadges
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !granted[id] {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusForbidden, "cosmetic.not_granted", "cosmetic is not granted by your roles")
|
|
|
|
|
}
|
|
|
|
|
return &id, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// requireGuildCosmetic достаёт элемент галереи и проверяет его сервер.
|
|
|
|
|
func (s *Server) requireGuildCosmetic(ctx context.Context, guildID uint64, rawID string) (*store.GuildCosmetic, error) {
|
|
|
|
|
id, err := parseID("cosmetic_id", rawID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
cosmetic, err := s.store.GetGuildCosmetic(ctx, id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
if cosmetic.GuildID != guildID {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "cosmetic not found")
|
|
|
|
|
}
|
|
|
|
|
return cosmetic, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// grantedIDs оставляет только выданные роли элементы нужного вида.
|
|
|
|
|
func grantedIDs(cosmetics []store.GuildCosmetic, granted map[uint64]bool, kind store.CosmeticKind) []string {
|
|
|
|
|
ids := make([]string, 0, len(granted))
|
|
|
|
|
for _, cosmetic := range cosmetics {
|
|
|
|
|
if cosmetic.Kind == kind && granted[cosmetic.ID] {
|
|
|
|
|
ids = append(ids, formatSnowflake(cosmetic.ID))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ids
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// dispatchGuildCosmeticsUpdate сообщает участникам, что галерея изменилась.
|
|
|
|
|
func (s *Server) dispatchGuildCosmeticsUpdate(guildID uint64) {
|
|
|
|
|
if s.gateway == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.dispatchGuildEvent(guildID, "GUILD_COSMETICS_UPDATE", map[string]any{
|
|
|
|
|
"guild_id": formatSnowflake(guildID),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// dispatchStyleUpdate сообщает всем сессиям пользователя о смене стиля.
|
|
|
|
|
func (s *Server) dispatchStyleUpdate(userID uint64, guildID *uint64) {
|
|
|
|
|
if s.gateway == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
payload := map[string]any{"user_id": formatSnowflake(userID)}
|
|
|
|
|
if guildID != nil {
|
|
|
|
|
payload["guild_id"] = formatSnowflake(*guildID)
|
|
|
|
|
}
|
|
|
|
|
s.gateway.SendToUser(userID, "USER_STYLE_UPDATE", payload)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// cosmeticFromStore собирает ответ по элементу галереи.
|
|
|
|
|
func cosmeticFromStore(cosmetic store.GuildCosmetic) cosmeticPayload {
|
|
|
|
|
return cosmeticPayload{
|
|
|
|
|
ID: formatSnowflake(cosmetic.ID),
|
|
|
|
|
GuildID: formatSnowflake(cosmetic.GuildID),
|
|
|
|
|
Kind: string(cosmetic.Kind),
|
|
|
|
|
Name: cosmetic.Name,
|
|
|
|
|
FileID: formatSnowflake(cosmetic.FileID),
|
|
|
|
|
CreatedAt: cosmetic.CreatedAt.UTC().Format(time.RFC3339),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// cosmeticsFromEffective собирает вычисленное оформление участника.
|
|
|
|
|
func cosmeticsFromEffective(effective store.EffectiveCosmetics) *cosmeticsPayload {
|
|
|
|
|
if effective.IsEmpty() {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
payload := &cosmeticsPayload{NickEffect: effective.NickEffect}
|
|
|
|
|
if effective.FrameID != nil {
|
|
|
|
|
payload.FrameID = formatSnowflake(*effective.FrameID)
|
|
|
|
|
}
|
|
|
|
|
if effective.BadgeID != nil {
|
|
|
|
|
payload.BadgeID = formatSnowflake(*effective.BadgeID)
|
|
|
|
|
}
|
|
|
|
|
if effective.NickColor != nil {
|
|
|
|
|
payload.NickColor = effective.NickColor
|
|
|
|
|
}
|
|
|
|
|
if payload.NickEffect == string(store.NickEffectNone) {
|
|
|
|
|
payload.NickEffect = ""
|
|
|
|
|
}
|
|
|
|
|
return payload
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 01:32:48 +03:00
|
|
|
// styleFromStore собирает ответ по области стиля: даже без записанного стиля
|
|
|
|
|
// запись отдаётся клиенту — в ней список доступных элементов (AGENT.md 7.2).
|
|
|
|
|
func styleFromStore(style *store.UserStyle, scope, guildName string, frames, badges []string, colors []int64) stylePayload {
|
2026-09-21 01:28:48 +03:00
|
|
|
payload := stylePayload{
|
2026-09-21 01:32:48 +03:00
|
|
|
Scope: scope,
|
|
|
|
|
GuildName: guildName,
|
2026-09-21 01:28:48 +03:00
|
|
|
FrameIDs: frames,
|
|
|
|
|
BadgeIDs: badges,
|
|
|
|
|
NickColors: colors,
|
|
|
|
|
Effects: effectNames(),
|
|
|
|
|
}
|
2026-09-21 01:32:48 +03:00
|
|
|
if scope != scopeGlobal {
|
|
|
|
|
payload.GuildID = scope
|
|
|
|
|
}
|
2026-09-21 01:28:48 +03:00
|
|
|
if payload.FrameIDs == nil {
|
|
|
|
|
payload.FrameIDs = []string{}
|
|
|
|
|
}
|
|
|
|
|
if payload.BadgeIDs == nil {
|
|
|
|
|
payload.BadgeIDs = []string{}
|
|
|
|
|
}
|
|
|
|
|
if payload.NickColors == nil {
|
|
|
|
|
payload.NickColors = []int64{}
|
|
|
|
|
}
|
|
|
|
|
if style == nil {
|
|
|
|
|
return payload
|
|
|
|
|
}
|
|
|
|
|
if style.FrameID != nil {
|
|
|
|
|
payload.Style.FrameID = formatSnowflake(*style.FrameID)
|
|
|
|
|
}
|
|
|
|
|
if style.BadgeID != nil {
|
|
|
|
|
payload.Style.BadgeID = formatSnowflake(*style.BadgeID)
|
|
|
|
|
}
|
|
|
|
|
if style.NickColor != nil {
|
|
|
|
|
payload.Style.NickColor = style.NickColor
|
|
|
|
|
}
|
|
|
|
|
if style.NickEffect != "" && style.NickEffect != string(store.NickEffectNone) {
|
|
|
|
|
payload.Style.NickEffect = style.NickEffect
|
|
|
|
|
}
|
|
|
|
|
return payload
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// effectNames — встроенный набор эффектов ника.
|
|
|
|
|
func effectNames() []string {
|
|
|
|
|
effects := store.NickEffects()
|
|
|
|
|
names := make([]string, 0, len(effects))
|
|
|
|
|
for _, effect := range effects {
|
|
|
|
|
names = append(names, string(effect))
|
|
|
|
|
}
|
|
|
|
|
return names
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// roleCosmeticsInput — оформление роли из запроса: пустая строка снимает
|
|
|
|
|
// элемент, `nil` оставляет как было (AGENT.md 7.4).
|
|
|
|
|
type roleCosmeticsInput struct {
|
|
|
|
|
FrameID string
|
|
|
|
|
BadgeID string
|
|
|
|
|
NickColor *int64
|
|
|
|
|
NickEffect string
|
|
|
|
|
FrameSet bool
|
|
|
|
|
BadgeSet bool
|
|
|
|
|
ColorSet bool
|
|
|
|
|
EffectSet bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// applyRoleCosmetics проверяет и сохраняет оформление роли. Рамка и иконка
|
|
|
|
|
// обязаны принадлежать галерее этого сервера.
|
|
|
|
|
func (s *Server) applyRoleCosmetics(ctx context.Context, guildID uint64, role *store.Role, input roleCosmeticsInput) (*store.Role, error) {
|
|
|
|
|
params := store.UpdateRoleParams{}
|
|
|
|
|
if input.FrameSet {
|
|
|
|
|
if input.FrameID == "" {
|
|
|
|
|
params.ClearFrame = true
|
|
|
|
|
} else {
|
|
|
|
|
cosmetic, err := s.requireGuildCosmetic(ctx, guildID, input.FrameID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if cosmetic.Kind != store.CosmeticFrame {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cosmetic is not a frame")
|
|
|
|
|
}
|
|
|
|
|
params.CosmeticFrameID = &cosmetic.ID
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if input.BadgeSet {
|
|
|
|
|
if input.BadgeID == "" {
|
|
|
|
|
params.ClearBadge = true
|
|
|
|
|
} else {
|
|
|
|
|
cosmetic, err := s.requireGuildCosmetic(ctx, guildID, input.BadgeID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if cosmetic.Kind != store.CosmeticBadge {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cosmetic is not a badge")
|
|
|
|
|
}
|
|
|
|
|
params.CosmeticBadgeID = &cosmetic.ID
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if input.ColorSet {
|
|
|
|
|
if input.NickColor == nil {
|
|
|
|
|
params.ClearNickColor = true
|
|
|
|
|
} else {
|
|
|
|
|
if !store.InNickColorPalette(*input.NickColor) {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "nick color is not in the palette")
|
|
|
|
|
}
|
|
|
|
|
params.NickColor = input.NickColor
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if input.EffectSet {
|
|
|
|
|
effect := input.NickEffect
|
|
|
|
|
if effect == "" {
|
|
|
|
|
effect = string(store.NickEffectNone)
|
|
|
|
|
}
|
|
|
|
|
if !store.ValidNickEffect(effect) {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown nick effect")
|
|
|
|
|
}
|
|
|
|
|
params.NickEffect = &effect
|
|
|
|
|
}
|
|
|
|
|
if params.CosmeticFrameID == nil && params.CosmeticBadgeID == nil && params.NickColor == nil &&
|
|
|
|
|
params.NickEffect == nil && !params.ClearFrame && !params.ClearBadge && !params.ClearNickColor {
|
|
|
|
|
return role, nil
|
|
|
|
|
}
|
|
|
|
|
updated, err := s.store.UpdateRole(ctx, role.ID, params)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
return updated, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// optionalString разыменовывает необязательную строку из тела запроса.
|
|
|
|
|
func optionalString(value *string) string {
|
|
|
|
|
if value == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return *value
|
|
|
|
|
}
|
2026-09-21 18:08:21 +03:00
|
|
|
|
|
|
|
|
// registerChannelBackgroundRoutes описывает фон комнаты (AGENT.md 7.5, 7.7):
|
|
|
|
|
// картинка за лентой сообщений, право MANAGE_CHANNEL_BACKGROUND.
|
|
|
|
|
func (s *Server) registerChannelBackgroundRoutes(router chi.Router) {
|
|
|
|
|
router.Post("/channels/{channel_id}/background", s.handleChannelBackgroundUpload)
|
|
|
|
|
router.Delete("/channels/{channel_id}/background", s.handleChannelBackgroundDelete)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleChannelBackgroundUpload(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
user, _, ok := s.authenticate(w, r)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx := r.Context()
|
|
|
|
|
channelID, _, _, err := s.requireChannelPermission(ctx,
|
|
|
|
|
chi.URLParam(r, "channel_id"), user, permissions.ManageChannelBackground)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeHumaAPIError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-21 22:37:47 +03:00
|
|
|
limit := s.mediaLimit(ctx, mediaGuildImage)
|
2026-09-21 18:08:21 +03:00
|
|
|
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() }()
|
2026-09-21 22:37:47 +03:00
|
|
|
if header.Size > limit {
|
2026-09-21 18:08:21 +03:00
|
|
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "background is too large")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-21 22:37:47 +03:00
|
|
|
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
|
|
|
|
if err != nil || int64(len(data)) > limit {
|
2026-09-21 18:08:21 +03:00
|
|
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "background 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", "background must be an image")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
channel, err := s.store.GetChannel(ctx, channelID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeHumaAPIError(w, humaError(err))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
// Старый фон удаляем: загрузки не должны копиться (AGENT.md 7.7).
|
|
|
|
|
if previous := channel.BackgroundFileID; previous != nil && *previous != 0 {
|
|
|
|
|
s.deleteStoredFile(ctx, *previous)
|
|
|
|
|
}
|
|
|
|
|
stored, err := s.saveUpload(ctx, store.File{
|
|
|
|
|
UploaderID: &user.ID,
|
|
|
|
|
GuildID: channel.GuildID,
|
|
|
|
|
ChannelID: &channelID,
|
|
|
|
|
Filename: sanitizeFilename(header.Filename),
|
|
|
|
|
ContentType: contentType,
|
|
|
|
|
Purpose: "channel_background",
|
|
|
|
|
}, bytes.NewReader(data))
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeHumaAPIError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
fileID := stored.ID
|
|
|
|
|
updated, err := s.store.UpdateChannel(ctx, channelID, store.UpdateChannelParams{BackgroundFileID: &fileID})
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.deleteStoredFile(ctx, stored.ID)
|
|
|
|
|
writeHumaAPIError(w, humaError(err))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if channel.GuildID != nil {
|
|
|
|
|
s.invalidateGuild(*channel.GuildID)
|
|
|
|
|
s.recordAudit(ctx, user, *channel.GuildID, "channel.background", "channel", &channelID, "")
|
|
|
|
|
}
|
2026-09-22 19:14:05 +03:00
|
|
|
payload := channelFromStore(*updated, nil, nil)
|
2026-09-21 18:08:21 +03:00
|
|
|
s.dispatchChannelEvent(ctx, channelID, "CHANNEL_UPDATE", payload)
|
|
|
|
|
httpxWriteJSON(w, http.StatusOK, map[string]any{"channel": payload})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleChannelBackgroundDelete(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
user, _, ok := s.authenticate(w, r)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx := r.Context()
|
|
|
|
|
channelID, _, channel, err := s.requireChannelPermission(ctx,
|
|
|
|
|
chi.URLParam(r, "channel_id"), user, permissions.ManageChannelBackground)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeHumaAPIError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if previous := channel.BackgroundFileID; previous != nil && *previous != 0 {
|
|
|
|
|
s.deleteStoredFile(ctx, *previous)
|
|
|
|
|
}
|
|
|
|
|
updated, err := s.store.UpdateChannel(ctx, channelID, store.UpdateChannelParams{ClearBackground: true})
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeHumaAPIError(w, humaError(err))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if channel.GuildID != nil {
|
|
|
|
|
s.invalidateGuild(*channel.GuildID)
|
|
|
|
|
s.recordAudit(ctx, user, *channel.GuildID, "channel.background_remove", "channel", &channelID, "")
|
|
|
|
|
}
|
2026-09-22 19:14:05 +03:00
|
|
|
payload := channelFromStore(*updated, nil, nil)
|
2026-09-21 18:08:21 +03:00
|
|
|
s.dispatchChannelEvent(ctx, channelID, "CHANNEL_UPDATE", payload)
|
|
|
|
|
httpxWriteJSON(w, http.StatusOK, map[string]any{"channel": payload})
|
|
|
|
|
}
|