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) }