221 lines
7.2 KiB
Go
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(¶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)
|
||
|
|
}
|