feat(social): друзья, личные беседы, присутствие, аватары и часовой пояс
По запросу пользователя (вне очереди AGENT.md §13): - главного сервера как точки входа больше нет: новичок начинает с пустым списком серверов, вход — только по приглашению или созданием своего; - друзья: поиск по логину с экранированием LIKE, заявки (POST /users/@me/relationships), принятие, удаление/отклонение, списки friends/ incoming/outgoing/blocked; - личные беседы: POST /users/@me/channels (идемпотентно), GET /users/@me/channels со собеседником, статусом и последним сообщением; сообщения в DM работают через общие ручки комнат, доступ — только участникам (посторонний получает 404, события в Gateway тоже фильтруются); - присутствие: last_seen_at обновляется при активности, «невидимка» и простой дольше двух минут выглядят как офлайн, смена статуса рассылает PRESENCE_UPDATE друзьям; - READY отдаёт dm_channels (собеседник, аватар, статус, последнее сообщение); - профиль: timezone (по умолчанию Europe/Moscow) в PATCH /users/@me, загрузка аватара POST /users/@me/avatar (проверка, что это изображение, в том числе по содержимому) и удаление DELETE /users/@me/avatar; старый файл удаляется с диска; - тесты: заявки в друзья, личные беседы и их изоляция, «невидимка», часовой пояс и аватар, PRESENCE_UPDATE другу, отсутствие событий DM у постороннего.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -27,6 +28,8 @@ const (
|
||||
maxMultipartOverhead = 1 << 20
|
||||
// maxMultipartMemory — сколько multipart держим в памяти, остальное — на диске.
|
||||
maxMultipartMemory = 8 << 20
|
||||
// maxAvatarSize — предел размера аватара (AGENT.md 7.2).
|
||||
maxAvatarSize = 8 << 20
|
||||
)
|
||||
|
||||
// uploadPayload — результат загрузки файла: метаданные для вложения.
|
||||
@@ -72,6 +75,115 @@ func (s *Server) registerFileRoutes(api huma.API, router chi.Router) {
|
||||
})
|
||||
|
||||
router.Post("/channels/{channel_id}/files", s.handleFileUpload)
|
||||
router.Post("/users/@me/avatar", s.handleAvatarUpload)
|
||||
router.Delete("/users/@me/avatar", s.handleAvatarDelete)
|
||||
}
|
||||
|
||||
// handleAvatarUpload принимает аватар пользователя (AGENT.md 7.2).
|
||||
func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser, _, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAvatarSize+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 > maxAvatarSize {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "avatar is too large")
|
||||
return
|
||||
}
|
||||
// Аватар читаем в память (не больше 8 МБ): нужно проверить, что это
|
||||
// действительно изображение, а не переименованный файл (AGENT.md 9.2).
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxAvatarSize+1))
|
||||
if err != nil || int64(len(data)) > maxAvatarSize {
|
||||
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "avatar 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", "avatar must be an image")
|
||||
return
|
||||
}
|
||||
|
||||
// Старый аватар удаляем: файлы не должны копиться (AGENT.md 7.7).
|
||||
if currentUser.AvatarFileID != nil {
|
||||
s.deleteStoredFile(ctx, *currentUser.AvatarFileID)
|
||||
}
|
||||
stored, err := s.saveUpload(ctx, store.File{
|
||||
UploaderID: ¤tUser.ID,
|
||||
Filename: sanitizeFilename(header.Filename),
|
||||
ContentType: contentType,
|
||||
}, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
writeHumaAPIError(w, err)
|
||||
return
|
||||
}
|
||||
fileID := stored.ID
|
||||
updated, err := s.store.UpdateUser(ctx, currentUser.ID, store.UpdateUserParams{AvatarFileID: &fileID})
|
||||
if err != nil {
|
||||
writeHumaAPIError(w, err)
|
||||
return
|
||||
}
|
||||
s.dispatchUserUpdate(updated)
|
||||
httpxWriteJSON(w, http.StatusOK, map[string]any{"user": s.profileFromUser(ctx, updated, true)})
|
||||
}
|
||||
|
||||
// handleAvatarDelete убирает аватар пользователя.
|
||||
func (s *Server) handleAvatarDelete(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser, _, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
if currentUser.AvatarFileID != nil {
|
||||
s.deleteStoredFile(ctx, *currentUser.AvatarFileID)
|
||||
if err := s.store.ClearAvatar(ctx, currentUser.ID); err != nil {
|
||||
writeHumaAPIError(w, humaError(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
updated, err := s.store.GetUser(ctx, currentUser.ID)
|
||||
if err != nil {
|
||||
writeHumaAPIError(w, humaError(err))
|
||||
return
|
||||
}
|
||||
s.dispatchUserUpdate(updated)
|
||||
httpxWriteJSON(w, http.StatusOK, map[string]any{"user": s.profileFromUser(ctx, updated, true)})
|
||||
}
|
||||
|
||||
// deleteStoredFile убирает запись и файл с диска, не ломая основную операцию.
|
||||
func (s *Server) deleteStoredFile(ctx context.Context, fileID uint64) {
|
||||
file, err := s.store.GetFile(ctx, fileID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if file.StoragePath != "" {
|
||||
if err := os.Remove(file.StoragePath); err != nil && !os.IsNotExist(err) {
|
||||
s.logger.WarnContext(ctx, "failed to remove file from disk",
|
||||
slog.String("file_id", formatSnowflake(fileID)), slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
if err := s.store.DeleteFile(ctx, fileID); err != nil {
|
||||
s.logger.WarnContext(ctx, "failed to delete file record",
|
||||
slog.String("file_id", formatSnowflake(fileID)), slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
// registerFileDownload вешает выдачу содержимого файла на корневой роутер:
|
||||
|
||||
Reference in New Issue
Block a user