2026-09-26 16:43:51 +03:00
|
|
|
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
|
|
|
|
|
}
|
2026-09-26 17:02:55 +03:00
|
|
|
httpxWriteJSON(w, http.StatusOK, map[string]any{"channel": s.dmChannelPayload(ctx, summary)})
|
2026-09-26 16:43:51 +03:00
|
|
|
}
|