package server import ( "bytes" "context" "io" "net/http" "strings" "github.com/go-chi/chi/v5" "glchat/internal/store" ) // Иконка групповой беседы (AGENT.md 7.8, D-070): обычная загрузка с // назначением dm_icon, поэтому анимированные GIF/APNG/WebP не перекодируются и // не теряют анимацию (D-042). Менять иконку может только владелец беседы; у // 1:1 своей иконки нет — в интерфейсе показывается аватар собеседника. // registerDMIconRoutes описывает загрузку и снятие иконки беседы: huma не // принимает multipart, поэтому ручки живут на роутере. func (s *Server) registerDMIconRoutes(router chi.Router) { router.Post("/channels/{channel_id}/icon", s.handleDMChannelIconUpload) router.Delete("/channels/{channel_id}/icon", s.handleDMChannelIconDelete) } // groupDMForIcon проверяет, что канал — групповая беседа, а пользователь — // её владелец. Посторонним отвечаем 404 (существование чужой беседы не // подтверждаем, D-070), участнику-не-владельцу — 403, у 1:1 своей иконки нет. func (s *Server) groupDMForIcon(ctx context.Context, rawChannelID string, userID uint64) (*store.Channel, error) { channelID, err := parseID("channel_id", rawChannelID) if err != nil { return nil, err } channel, err := s.store.GetChannel(ctx, channelID) if err != nil { return nil, humaError(err) } if channel.Type != store.ChannelDM || channel.GuildID != nil { return nil, humaErrorStatus(http.StatusNotFound, "not_found", "conversation not found") } participant, err := s.store.IsDMParticipant(ctx, channelID, userID) if err != nil { return nil, humaError(err) } if !participant { return nil, humaErrorStatus(http.StatusNotFound, "not_found", "conversation not found") } if channel.DMOwnerID == nil { // 1:1: иконка беседы — аватар собеседника, своей у неё нет. return nil, humaErrorStatus(http.StatusUnprocessableEntity, "dm.not_group", "a direct conversation shows the recipient's avatar and has no icon") } if *channel.DMOwnerID != userID { return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "only the group owner can change the conversation icon") } return channel, nil } // handleDMChannelIconUpload принимает иконку групповой беседы (владелец). func (s *Server) handleDMChannelIconUpload(w http.ResponseWriter, r *http.Request) { user, _, ok := s.authenticate(w, r) if !ok { return } ctx := r.Context() channel, err := s.groupDMForIcon(ctx, chi.URLParam(r, "channel_id"), user.ID) if err != nil { writeHumaAPIError(w, err) return } // Загрузки: 10 в минуту и 100 в сутки на пользователя (AGENT.md 8.6). if err := s.checkUploadLimit(user); err != nil { writeHumaAPIError(w, err) return } // Иконка — медиа аватарного размера (AGENT.md 7.7): лимит тот же, что у // аватаров и оформления. Тело ограничено и по объёму, и по памяти. limit := s.mediaLimit(ctx, mediaAvatar) r.Body = http.MaxBytesReader(w, r.Body, limit+maxMultipartOverhead) if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader выше httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body") return } defer func() { if r.MultipartForm != nil { _ = r.MultipartForm.RemoveAll() } }() 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 > limit { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "conversation icon is too large") return } // Картинку читаем в память: нужно убедиться, что это изображение, а не // переименованный файл (AGENT.md 9.2). data, err := io.ReadAll(io.LimitReader(file, limit+1)) if err != nil || int64(len(data)) > limit { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "conversation icon 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", "conversation icon must be an image") return } previous := channel.IconFileID channelID := channel.ID stored, err := s.saveUpload(ctx, store.File{ UploaderID: &user.ID, ChannelID: &channelID, Filename: sanitizeFilename(header.Filename), ContentType: contentType, Purpose: "dm_icon", }, bytes.NewReader(data)) if err != nil { writeHumaAPIError(w, err) return } fileID := stored.ID updated, err := s.store.UpdateChannel(ctx, channel.ID, store.UpdateChannelParams{IconFileID: &fileID}) if err != nil { // Новая иконка не прижилась: убираем загруженный файл, старая остаётся. s.deleteStoredFile(ctx, stored.ID) writeHumaAPIError(w, humaError(err)) return } // Предыдущий файл удаляем только после успешной замены: загрузки не должны // копиться (AGENT.md 7.7), но и терять старую иконку при сбое нельзя. if previous != nil && *previous != 0 { s.deleteStoredFile(ctx, *previous) } s.dispatchDMUpdate(updated, []uint64{}, 0) s.writeDMChannel(ctx, w, updated, user.ID) } // handleDMChannelIconDelete снимает иконку групповой беседы (владелец). func (s *Server) handleDMChannelIconDelete(w http.ResponseWriter, r *http.Request) { user, _, ok := s.authenticate(w, r) if !ok { return } ctx := r.Context() channel, err := s.groupDMForIcon(ctx, chi.URLParam(r, "channel_id"), user.ID) if err != nil { writeHumaAPIError(w, err) return } previous := channel.IconFileID updated, err := s.store.UpdateChannel(ctx, channel.ID, store.UpdateChannelParams{ClearIcon: true}) if err != nil { writeHumaAPIError(w, humaError(err)) return } if previous != nil && *previous != 0 { s.deleteStoredFile(ctx, *previous) } s.dispatchDMUpdate(updated, []uint64{}, 0) s.writeDMChannel(ctx, w, updated, user.ID) } // writeDMChannel отдаёт обновлённую беседу в формате `dmChannelPayload` — так // же, как переименование: клиенту не нужен отдельный запрос за иконкой. func (s *Server) writeDMChannel(ctx context.Context, w http.ResponseWriter, channel *store.Channel, viewerID uint64) { summary, err := s.groupDMSummary(ctx, channel, viewerID) if err != nil { writeHumaAPIError(w, err) return } httpxWriteJSON(w, http.StatusOK, map[string]any{"channel": s.dmChannelPayload(ctx, summary)}) }